Skip to content

Commit 53906a0

Browse files
committed
More Form elements
1 parent b631b01 commit 53906a0

22 files changed

Lines changed: 1109 additions & 0 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.keepMounted {
2+
/* Wrapper used only when keepMounted=true */
3+
}
4+
5+
.hidden {
6+
display: none;
7+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
import { useState } from 'react';
3+
import { ConditionalField } from './ConditionalField';
4+
import { Checkbox } from '../Checkbox';
5+
import { Select } from '../Select';
6+
import { Input } from '../Input';
7+
import { FormGroup } from '../FormGroup';
8+
9+
const meta: Meta<typeof ConditionalField> = {
10+
title: 'Form/ConditionalField',
11+
component: ConditionalField,
12+
tags: ['autodocs'],
13+
parameters: { layout: 'padded' },
14+
};
15+
16+
export default meta;
17+
type Story = StoryObj<typeof ConditionalField>;
18+
19+
/** Show an extra field when a checkbox is checked. */
20+
export const CheckboxDriven: Story = {
21+
render: function Example() {
22+
const [hasAlias, setHasAlias] = useState(false);
23+
return (
24+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
25+
<Checkbox
26+
label="I go by a different name"
27+
checked={hasAlias}
28+
onChange={(e) => setHasAlias(e.target.checked)}
29+
/>
30+
<ConditionalField show={hasAlias}>
31+
<Input label="Preferred name" placeholder="e.g. Alex" />
32+
</ConditionalField>
33+
</div>
34+
);
35+
},
36+
};
37+
38+
/** Show a group of fields based on a select value. */
39+
export const SelectDriven: Story = {
40+
render: function Example() {
41+
const [deliveryType, setDeliveryType] = useState('');
42+
return (
43+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
44+
<Select
45+
label="Delivery method"
46+
placeholder="Choose…"
47+
options={[
48+
{ value: 'pickup', label: 'Store pickup' },
49+
{ value: 'delivery', label: 'Home delivery' },
50+
]}
51+
value={deliveryType}
52+
onChange={(e) => setDeliveryType(e.target.value)}
53+
/>
54+
<ConditionalField show={deliveryType === 'delivery'}>
55+
<FormGroup title="Delivery Address">
56+
<Input label="Street" />
57+
<Input label="City" />
58+
<Input label="Postal code" />
59+
</FormGroup>
60+
</ConditionalField>
61+
</div>
62+
);
63+
},
64+
};
65+
66+
/**
67+
* With `keepMounted`, the hidden content stays in the DOM (aria-hidden).
68+
* Useful when you need to preserve controlled state across visibility toggles.
69+
*/
70+
export const KeepMounted: Story = {
71+
render: function Example() {
72+
const [show, setShow] = useState(false);
73+
return (
74+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
75+
<Checkbox
76+
label="Show secondary contact (keepMounted)"
77+
checked={show}
78+
onChange={(e) => setShow(e.target.checked)}
79+
/>
80+
<ConditionalField show={show} keepMounted>
81+
<Input label="Secondary email" placeholder="backup@example.com" />
82+
</ConditionalField>
83+
</div>
84+
);
85+
},
86+
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { describe, it, expect } from 'vitest';
3+
import { ConditionalField } from './ConditionalField';
4+
5+
describe('ConditionalField', () => {
6+
it('renders children when show is true', () => {
7+
render(
8+
<ConditionalField show>
9+
<span data-testid="content">hello</span>
10+
</ConditionalField>,
11+
);
12+
expect(screen.getByTestId('content')).toBeInTheDocument();
13+
});
14+
15+
it('does not render children when show is false (default unmount)', () => {
16+
render(
17+
<ConditionalField show={false}>
18+
<span data-testid="content">hello</span>
19+
</ConditionalField>,
20+
);
21+
expect(screen.queryByTestId('content')).not.toBeInTheDocument();
22+
});
23+
24+
it('keeps children in DOM when keepMounted and show is false', () => {
25+
render(
26+
<ConditionalField show={false} keepMounted>
27+
<span data-testid="content">hello</span>
28+
</ConditionalField>,
29+
);
30+
expect(screen.getByTestId('content')).toBeInTheDocument();
31+
});
32+
33+
it('sets aria-hidden on wrapper when keepMounted and hidden', () => {
34+
const { container } = render(
35+
<ConditionalField show={false} keepMounted>
36+
<span>inner</span>
37+
</ConditionalField>,
38+
);
39+
expect(container.firstChild).toHaveAttribute('aria-hidden', 'true');
40+
});
41+
42+
it('does not set aria-hidden when keepMounted and visible', () => {
43+
const { container } = render(
44+
<ConditionalField show keepMounted>
45+
<span>inner</span>
46+
</ConditionalField>,
47+
);
48+
expect(container.firstChild).not.toHaveAttribute('aria-hidden');
49+
});
50+
51+
it('renders nothing (null) when show is false without keepMounted', () => {
52+
const { container } = render(
53+
<ConditionalField show={false}>
54+
<span>inner</span>
55+
</ConditionalField>,
56+
);
57+
expect(container.firstChild).toBeNull();
58+
});
59+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { ReactNode } from 'react';
2+
import styles from './ConditionalField.module.scss';
3+
4+
export interface ConditionalFieldProps {
5+
/** When true, children are shown. When false, they are hidden (or unmounted). */
6+
show: boolean;
7+
/**
8+
* When true, children remain in the DOM but are visually hidden when `show` is false.
9+
* Useful for preserving controlled input state across visibility toggles.
10+
* @default false
11+
*/
12+
keepMounted?: boolean;
13+
/** Additional class name for the wrapper element (only rendered when keepMounted). */
14+
className?: string;
15+
children: ReactNode;
16+
}
17+
18+
/**
19+
* Conditionally shows or hides content based on a boolean expression.
20+
*
21+
* By default, children are **unmounted** when `show` is false — this is the
22+
* accessible default (hidden content is not in the DOM or focus order).
23+
*
24+
* Set `keepMounted` to preserve DOM state (e.g. controlled inputs that must
25+
* retain their value while hidden).
26+
*
27+
* @example
28+
* ```tsx
29+
* // Unmounts when condition is false (default)
30+
* <ConditionalField show={country === 'us'}>
31+
* <Input label="State" />
32+
* </ConditionalField>
33+
*
34+
* // Keeps mounted but visually hidden
35+
* <ConditionalField show={hasBillingAddress} keepMounted>
36+
* <FormGroup title="Billing Address">...</FormGroup>
37+
* </ConditionalField>
38+
* ```
39+
*/
40+
export function ConditionalField({
41+
show,
42+
keepMounted = false,
43+
className,
44+
children,
45+
}: ConditionalFieldProps) {
46+
if (keepMounted) {
47+
const wrapperClasses = [styles.keepMounted, !show ? styles.hidden : '', className]
48+
.filter(Boolean)
49+
.join(' ');
50+
return (
51+
<div className={wrapperClasses} aria-hidden={!show || undefined} inert={!show || undefined}>
52+
{children}
53+
</div>
54+
);
55+
}
56+
57+
return show ? <>{children}</> : null;
58+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { ConditionalField } from './ConditionalField';
2+
export type { ConditionalFieldProps } from './ConditionalField';
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
@use '../../../styles/mixins' as *;
2+
3+
.group {
4+
border: 1px solid var(--color-border);
5+
border-radius: var(--radius-lg);
6+
padding: var(--space-5) var(--space-6);
7+
margin: 0;
8+
font-family: var(--font-family);
9+
}
10+
11+
.legend {
12+
padding: 0 var(--space-2);
13+
font-size: var(--font-size-sm);
14+
font-weight: var(--font-weight-semibold);
15+
color: var(--color-text-primary);
16+
line-height: var(--line-height-normal);
17+
}
18+
19+
.description {
20+
margin: 0 0 var(--space-4);
21+
font-size: var(--font-size-sm);
22+
color: var(--color-text-secondary);
23+
line-height: var(--line-height-normal);
24+
}
25+
26+
.fields {
27+
display: flex;
28+
flex-direction: column;
29+
gap: var(--space-4);
30+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
import { FormGroup } from './FormGroup';
3+
import { Input } from '../Input';
4+
import { Select } from '../Select';
5+
6+
const meta: Meta<typeof FormGroup> = {
7+
title: 'Form/FormGroup',
8+
component: FormGroup,
9+
tags: ['autodocs'],
10+
parameters: { layout: 'padded' },
11+
};
12+
13+
export default meta;
14+
type Story = StoryObj<typeof FormGroup>;
15+
16+
/** A basic group of related fields with a legend. */
17+
export const Default: Story = {
18+
render: () => (
19+
<FormGroup title="Personal Information">
20+
<Input label="First name" placeholder="Jane" />
21+
<Input label="Last name" placeholder="Doe" />
22+
</FormGroup>
23+
),
24+
};
25+
26+
/** With an optional description below the legend. */
27+
export const WithDescription: Story = {
28+
render: () => (
29+
<FormGroup title="Address" description="Enter your shipping address.">
30+
<Input label="Street" placeholder="123 Main St" />
31+
<Input label="City" placeholder="Amsterdam" />
32+
<Select
33+
label="Country"
34+
options={[
35+
{ value: 'nl', label: 'Netherlands' },
36+
{ value: 'de', label: 'Germany' },
37+
{ value: 'us', label: 'United States' },
38+
]}
39+
/>
40+
</FormGroup>
41+
),
42+
};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { describe, it, expect } from 'vitest';
3+
import { FormGroup } from './FormGroup';
4+
5+
describe('FormGroup', () => {
6+
it('renders a fieldset with legend', () => {
7+
render(<FormGroup title="Personal Info"><input /></FormGroup>);
8+
expect(screen.getByRole('group', { name: 'Personal Info' })).toBeInTheDocument();
9+
});
10+
11+
it('renders legend text', () => {
12+
render(<FormGroup title="Address"><input /></FormGroup>);
13+
expect(screen.getByText('Address')).toBeInTheDocument();
14+
});
15+
16+
it('renders description when provided', () => {
17+
render(
18+
<FormGroup title="Settings" description="Adjust your preferences">
19+
<input />
20+
</FormGroup>,
21+
);
22+
expect(screen.getByText('Adjust your preferences')).toBeInTheDocument();
23+
});
24+
25+
it('does not render description when omitted', () => {
26+
render(<FormGroup title="Settings"><input /></FormGroup>);
27+
expect(screen.queryByRole('paragraph')).not.toBeInTheDocument();
28+
});
29+
30+
it('renders children inside the fields wrapper', () => {
31+
render(
32+
<FormGroup title="Test">
33+
<input data-testid="child-input" />
34+
</FormGroup>,
35+
);
36+
expect(screen.getByTestId('child-input')).toBeInTheDocument();
37+
});
38+
39+
it('forwards className to fieldset', () => {
40+
const { container } = render(
41+
<FormGroup title="Test" className="custom">
42+
<input />
43+
</FormGroup>,
44+
);
45+
expect(container.querySelector('fieldset')).toHaveClass('custom');
46+
});
47+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { FieldsetHTMLAttributes, Ref } from 'react';
2+
import styles from './FormGroup.module.scss';
3+
4+
export interface FormGroupProps extends FieldsetHTMLAttributes<HTMLFieldSetElement> {
5+
/** Ref forwarded to the `<fieldset>`. */
6+
ref?: Ref<HTMLFieldSetElement>;
7+
/** Legend text — displayed as the group heading. */
8+
title: string;
9+
/** Optional description rendered below the legend. */
10+
description?: string;
11+
}
12+
13+
/**
14+
* Semantic grouping of related form controls using `<fieldset>` + `<legend>`.
15+
*
16+
* Use inside a `<FormSection>` to build structured multi-group forms.
17+
*
18+
* @example
19+
* ```tsx
20+
* <FormGroup title="Personal Information" description="Required fields are marked *">
21+
* <Input label="First name" required />
22+
* <Input label="Last name" required />
23+
* </FormGroup>
24+
* ```
25+
*/
26+
export function FormGroup({
27+
title,
28+
description,
29+
children,
30+
className,
31+
ref,
32+
...rest
33+
}: FormGroupProps) {
34+
const classNames = [styles.group, className].filter(Boolean).join(' ');
35+
36+
return (
37+
<fieldset ref={ref} className={classNames} {...rest}>
38+
<legend className={styles.legend}>{title}</legend>
39+
{description && <p className={styles.description}>{description}</p>}
40+
<div className={styles.fields}>{children}</div>
41+
</fieldset>
42+
);
43+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { FormGroup } from './FormGroup';
2+
export type { FormGroupProps } from './FormGroup';

0 commit comments

Comments
 (0)