Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/form-control-reset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@radix-ui/react-radio-group": patch
"@radix-ui/react-slider": patch
"@radix-ui/react-select": patch
"@radix-ui/react-switch": patch
"radix-ui": patch
---

All form control components now listen to their associated form's `reset` event and restore their initial value. This affects `RadioGroup`, `Slider`, `Select`, and `Switch`.
49 changes: 49 additions & 0 deletions apps/storybook/stories/radio-group.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,55 @@ export const PartsWithinForm = () => {
);
};

export const WithinFormReset = () => {
const [controlled, setControlled] = React.useState('1');

return (
<form onSubmit={(event) => event.preventDefault()}>
<p>
Change the selection, then press <strong>Reset</strong>. Each group returns to its initial
value (the uncontrolled group via its <code>defaultValue</code>, the controlled group via
its initial <code>value</code> state).
</p>

<fieldset>
<legend>Uncontrolled (defaultValue)</legend>
<RadioGroup.Root className={styles.root} name="uncontrolled" defaultValue="1">
{['1', '2', '3'].map((value) => (
<RadioGroup.Item key={value} className={styles.item} value={value}>
<RadioGroup.Indicator className={styles.indicator} />
</RadioGroup.Item>
))}
</RadioGroup.Root>
</fieldset>

<br />
<br />

<fieldset>
<legend>Controlled value: {controlled}</legend>
<RadioGroup.Root
className={styles.root}
name="controlled"
value={controlled}
onValueChange={setControlled}
>
{['1', '2', '3'].map((value) => (
<RadioGroup.Item key={value} className={styles.item} value={value}>
<RadioGroup.Indicator className={styles.indicator} />
</RadioGroup.Item>
))}
</RadioGroup.Root>
</fieldset>

<br />
<br />

<button type="reset">Reset</button>
</form>
);
};

export const LegacyStyled = () => (
<Label>
Favourite pet
Expand Down
88 changes: 88 additions & 0 deletions apps/storybook/stories/select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,94 @@ export const WithinForm = () => {
);
};

export const WithinFormReset = () => {
const [controlled, setControlled] = React.useState('fr');

return (
<form style={{ padding: 50 }} onSubmit={(event) => event.preventDefault()}>
<p>
Change the selections, then press <strong>Reset</strong>. Each select returns to its initial
value (the uncontrolled select via its <code>defaultValue</code>, the controlled select via
its initial <code>value</code> state).
</p>

<Label style={{ display: 'block' }}>
Uncontrolled (defaultValue)
<Select.Root name="uncontrolled" defaultValue="fr">
<Select.Trigger className={styles.trigger}>
<Select.Value />
<Select.Icon />
</Select.Trigger>
<Select.Portal>
<Select.Content className={styles.content}>
<Select.Viewport className={styles.viewport}>
<Select.Item className={styles.item} value="fr">
<Select.ItemText>France</Select.ItemText>
<Select.ItemIndicator className={styles.indicator}>
<TickIcon />
</Select.ItemIndicator>
</Select.Item>
<Select.Item className={styles.item} value="uk">
<Select.ItemText>United Kingdom</Select.ItemText>
<Select.ItemIndicator className={styles.indicator}>
<TickIcon />
</Select.ItemIndicator>
</Select.Item>
<Select.Item className={styles.item} value="es">
<Select.ItemText>Spain</Select.ItemText>
<Select.ItemIndicator className={styles.indicator}>
<TickIcon />
</Select.ItemIndicator>
</Select.Item>
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
</Label>

<br />

<Label style={{ display: 'block' }}>
Controlled value: {controlled}
<Select.Root name="controlled" value={controlled} onValueChange={setControlled}>
<Select.Trigger className={styles.trigger}>
<Select.Value />
<Select.Icon />
</Select.Trigger>
<Select.Portal>
<Select.Content className={styles.content}>
<Select.Viewport className={styles.viewport}>
<Select.Item className={styles.item} value="fr">
<Select.ItemText>France</Select.ItemText>
<Select.ItemIndicator className={styles.indicator}>
<TickIcon />
</Select.ItemIndicator>
</Select.Item>
<Select.Item className={styles.item} value="uk">
<Select.ItemText>United Kingdom</Select.ItemText>
<Select.ItemIndicator className={styles.indicator}>
<TickIcon />
</Select.ItemIndicator>
</Select.Item>
<Select.Item className={styles.item} value="es">
<Select.ItemText>Spain</Select.ItemText>
<Select.ItemIndicator className={styles.indicator}>
<TickIcon />
</Select.ItemIndicator>
</Select.Item>
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
</Label>

<br />

<button type="reset">Reset</button>
</form>
);
};

export const DisabledWithinForm = () => {
const [data, setData] = React.useState({});

Expand Down
47 changes: 47 additions & 0 deletions apps/storybook/stories/slider.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,53 @@ export const WithinForm = () => {
);
};

export const WithinFormReset = () => {
const [controlled, setControlled] = React.useState([40]);

return (
<form onSubmit={(event) => event.preventDefault()}>
<p>
Move the thumbs, then press <strong>Reset</strong>. Each slider returns to its initial value
(the uncontrolled slider via its <code>defaultValue</code>, the controlled slider via its
initial <code>value</code> state).
</p>

<fieldset>
<legend>Uncontrolled (defaultValue)</legend>
<Slider.Root name="uncontrolled" defaultValue={[20]} className={styles.root}>
<Slider.Track className={styles.track}>
<Slider.Range className={styles.range} />
</Slider.Track>
<Slider.Thumb className={styles.thumb} />
</Slider.Root>
</fieldset>

<br />
<br />

<fieldset>
<legend>Controlled value: {String(controlled)}</legend>
<Slider.Root
name="controlled"
value={controlled}
onValueChange={setControlled}
className={styles.root}
>
<Slider.Track className={styles.track}>
<Slider.Range className={styles.range} />
</Slider.Track>
<Slider.Thumb className={styles.thumb} />
</Slider.Root>
</fieldset>

<br />
<br />

<button type="reset">Reset</button>
</form>
);
};

export const BubbleInputWithinForm = () => {
const [data, setData] = React.useState<any>({ single: [25], price: { min: 30, max: 70 } });
return (
Expand Down
45 changes: 45 additions & 0 deletions apps/storybook/stories/switch.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,51 @@ export const WithinForm = () => {
);
};

export const WithinFormReset = () => {
const [controlled, setControlled] = React.useState(true);

return (
<form onSubmit={(event) => event.preventDefault()}>
<p>
Toggle the switches, then press <strong>Reset</strong>. Each switch returns to its initial
value (the uncontrolled switch via its <code>defaultChecked</code>, the controlled switch
via its initial <code>checked</code> state).
</p>

<fieldset>
<legend>Uncontrolled (defaultChecked)</legend>
<label>
<Switch.Root className={styles.root} name="uncontrolled" defaultChecked>
<Switch.Thumb className={styles.thumb} />
</Switch.Root>{' '}
with label
</label>
</fieldset>

<br />

<fieldset>
<legend>Controlled checked: {String(controlled)}</legend>
<label>
<Switch.Root
className={styles.root}
name="controlled"
checked={controlled}
onCheckedChange={setControlled}
>
<Switch.Thumb className={styles.thumb} />
</Switch.Root>{' '}
with label
</label>
</fieldset>

<br />

<button type="reset">Reset</button>
</form>
);
};

export const Parts = () => {
const [checked, setChecked] = React.useState(true);

Expand Down
72 changes: 72 additions & 0 deletions packages/react/radio-group/src/radio-group.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,78 @@ describe('RadioGroup', () => {
expect(form.checkValidity()).toBe(true);
});
});

describe('given a RadioGroup in a form that is reset', () => {
describe('uncontrolled', () => {
it('should restore its `defaultValue` selection when the form is reset', () => {
render(
<form>
<ClassicRadioGroup name="pet" defaultValue="1" />
<button type="reset">Reset</button>
</form>,
);

const radios = screen.getAllByRole(RADIO_ROLE);
expect(radios[0]).toHaveAttribute('aria-checked', 'true');

act(() => fireEvent.click(radios[1]!));
expect(radios[0]).toHaveAttribute('aria-checked', 'false');
expect(radios[1]).toHaveAttribute('aria-checked', 'true');

act(() => fireEvent.click(screen.getByText('Reset')));
expect(radios[0]).toHaveAttribute('aria-checked', 'true');
expect(radios[1]).toHaveAttribute('aria-checked', 'false');
});

it('should restore an empty selection when there is no `defaultValue`', () => {
render(
<form>
<ClassicRadioGroup name="pet" />
<button type="reset">Reset</button>
</form>,
);

const radios = screen.getAllByRole(RADIO_ROLE);
act(() => fireEvent.click(radios[1]!));
expect(radios[1]).toHaveAttribute('aria-checked', 'true');

act(() => fireEvent.click(screen.getByText('Reset')));
radios.forEach((radio) => expect(radio).toHaveAttribute('aria-checked', 'false'));
});
});

describe('controlled', () => {
it('should restore its initial `value` selection when the form is reset', () => {
function ControlledRadioGroup() {
const [value, setValue] = React.useState<string | null>('1');
return (
<form>
<RadioGroup.Root aria-label="pets" name="pet" value={value} onValueChange={setValue}>
{VALUES.map((v) => (
<RadioGroup.Item key={v} value={v} aria-label={LABELS[v]}>
<RadioGroup.Indicator data-testid={`${INDICATOR_TEST_ID}-${v}`} />
</RadioGroup.Item>
))}
</RadioGroup.Root>
<button type="reset">Reset</button>
</form>
);
}

render(<ControlledRadioGroup />);

const radios = screen.getAllByRole(RADIO_ROLE);
expect(radios[0]).toHaveAttribute('aria-checked', 'true');

act(() => fireEvent.click(radios[2]!));
expect(radios[2]).toHaveAttribute('aria-checked', 'true');

act(() => fireEvent.click(screen.getByText('Reset')));
expect(radios[0]).toHaveAttribute('aria-checked', 'true');
expect(radios[2]).toHaveAttribute('aria-checked', 'false');
});
});
});
});

describe('Composable RadioGroup', () => {
Expand Down
14 changes: 13 additions & 1 deletion packages/react/radio-group/src/radio-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ const RadioGroup = React.forwardRef<RadioGroupElement, RadioGroupProps>(
onChange: onValueChange as (value: string | null) => void,
caller: RADIO_GROUP_NAME,
});
const [control, setControl] = React.useState<RadioGroupElement | null>(null);
const composedRefs = useComposedRefs(forwardedRef, setControl);

const initialValueRef = React.useRef(value);
React.useEffect(() => {
const form = control?.closest('form');
if (form) {
const reset = () => setValue(initialValueRef.current);
form.addEventListener('reset', reset);
return () => form.removeEventListener('reset', reset);
}
}, [control, setValue]);

return (
<RadioGroupProvider
Expand All @@ -107,7 +119,7 @@ const RadioGroup = React.forwardRef<RadioGroupElement, RadioGroupProps>(
data-disabled={disabled ? '' : undefined}
dir={direction}
{...groupProps}
ref={forwardedRef}
ref={composedRefs}
/>
</RovingFocusGroup.Root>
</RadioGroupProvider>
Expand Down
Loading
Loading