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
65 changes: 62 additions & 3 deletions packages/react/src/radio/root/RadioRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,11 +274,58 @@ describe('<Radio.Root />', () => {
expect(screen.getByTestId('field')).not.toHaveAttribute('data-focused');
});

it('keeps the tab stop on a checked radio that is disabled', async () => {
it.each([
{ name: 'first radio', checked: 'a', disabled: ['a'], tabStop: 'b' },
{ name: 'second radio', checked: 'b', disabled: ['b'], tabStop: 'a' },
{ name: 'leading disabled radios', checked: 'b', disabled: ['a', 'b'], tabStop: 'c' },
])(
'moves the tab stop to the first enabled radio when the checked radio is disabled ($name)',
async ({ checked, disabled, tabStop }) => {
await render(
<RadioGroup defaultValue={checked}>
<Radio.Root value="a" disabled={disabled.includes('a')} data-testid="a" />
<Radio.Root value="b" disabled={disabled.includes('b')} data-testid="b" />
<Radio.Root value="c" disabled={disabled.includes('c')} data-testid="c" />
</RadioGroup>,
);

expect(screen.getByTestId(tabStop)).toHaveAttribute('tabindex', '0');
expect(screen.getAllByRole('radio').filter((radio) => radio.tabIndex === 0)).toHaveLength(
1,
);
},
);

it('keeps the group in the tab order when the checked native button radio is disabled', async () => {
const { user } = await render(
<div>
<button type="button">Before</button>
<RadioGroup defaultValue="a">
<Radio.Root value="a" disabled nativeButton render={<button type="button" />}>
A
</Radio.Root>
<Radio.Root value="b" nativeButton render={<button type="button" />}>
B
</Radio.Root>
</RadioGroup>
</div>,
);

await act(async () => {
screen.getByRole('button', { name: 'Before' }).focus();
});
expect(screen.getByRole('button', { name: 'Before' })).toHaveFocus();

await user.keyboard('{Tab}');

expect(screen.getByRole('radio', { name: 'B' })).toHaveFocus();
});

it('keeps a single tab stop on the first radio when every radio is disabled', async () => {
await render(
<RadioGroup defaultValue="a">
<RadioGroup defaultValue="b">
<Radio.Root value="a" disabled data-testid="a" />
<Radio.Root value="b" data-testid="b" />
<Radio.Root value="b" disabled data-testid="b" />
</RadioGroup>,
);

Expand All @@ -288,6 +335,18 @@ describe('<Radio.Root />', () => {
});

describe('prop: readOnly', () => {
it('keeps the tab stop on a checked radio that is read-only', async () => {
await render(
<RadioGroup defaultValue="b">
<Radio.Root value="a" data-testid="a" />
<Radio.Root value="b" readOnly data-testid="b" />
</RadioGroup>,
);

expect(screen.getByTestId('a')).toHaveAttribute('tabindex', '-1');
expect(screen.getByTestId('b')).toHaveAttribute('tabindex', '0');
});

it('acquires the field focused state on focus', async () => {
await render(
<Field.Root data-testid="field">
Expand Down
4 changes: 3 additions & 1 deletion packages/react/src/radio/root/RadioRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,9 @@ export const RadioRoot = React.forwardRef(function RadioRoot<Value>(
role: 'radio',
'aria-checked': checked,
'aria-labelledby': ariaLabelledBy,
[ACTIVE_COMPOSITE_ITEM as string]: checked ? '' : undefined,
// A disabled checked radio can't hold the group's tab stop, so the composite falls
// back to the first enabled radio instead.
[ACTIVE_COMPOSITE_ITEM as string]: checked && !disabled ? '' : undefined,
id: nativeButton ? inputId : id,
// Only a fully disabled group leaves the tab order. A single disabled radio
// keeps the composite tab index.
Expand Down
Loading