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
105 changes: 102 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,98 @@ 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('moves the tab stop to the checked native button radio when it becomes enabled', async () => {
function App(props: { disabledB: boolean }) {
return (
<div>
<button type="button">Before</button>
<RadioGroup defaultValue="b">
<Radio.Root value="a" disabled nativeButton render={<button type="button" />}>
A
</Radio.Root>
<Radio.Root
value="b"
disabled={props.disabledB}
nativeButton
render={<button type="button" />}
>
B
</Radio.Root>
</RadioGroup>
<button type="button">After</button>
</div>
);
}

const { rerender, user } = await render(<App disabledB />);
expect(screen.getByRole('radio', { name: 'B' })).toBeDisabled();

await rerender(<App disabledB={false} />);
expect(screen.getByRole('radio', { name: 'B' })).not.toBeDisabled();
expect(screen.getByRole('radio', { name: 'B' })).toHaveAttribute('tabindex', '0');

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 +375,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
73 changes: 69 additions & 4 deletions packages/react/src/radio/root/RadioRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { useMergedRefs } from '@base-ui/utils/useMergedRefs';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { visuallyHiddenInput, visuallyHiddenInputPinned } from '@base-ui/utils/visuallyHidden';
import { EMPTY_OBJECT } from '@base-ui/utils/empty';
import { ownerDocument } from '@base-ui/utils/owner';
import { activeElement, closest, contains } from '@base-ui/utils/shadowDom';
import type { BaseUIComponentProps, HTMLProps, NonNativeButtonProps } from '../../internals/types';
import type { StateAttributesMapping } from '../../internals/getStateAttributesProps';
import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails';
import { REASONS } from '../../internals/reasons';
import { NOOP } from '../../internals/noop';
Expand All @@ -14,7 +17,8 @@ import { useBaseUiId } from '../../internals/useBaseUiId';
import { useRenderElement } from '../../internals/useRenderElement';
import { useButton } from '../../internals/use-button';
import { ACTIVE_COMPOSITE_ITEM } from '../../internals/composite/constants';
import { CompositeItem } from '../../internals/composite/item/CompositeItem';
import { useCompositeItem } from '../../internals/composite/item/useCompositeItem';
import { useCompositeRootContext } from '../../internals/composite/root/CompositeRootContext';
import type { FieldRootState } from '../../field/root/FieldRoot';
import { useFieldRootContext } from '../../internals/field-root-context/FieldRootContext';
import { useSetFieldFocused } from '../../internals/field-root-context/useSetFieldFocused';
Expand Down Expand Up @@ -132,7 +136,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 Expand Up @@ -266,8 +272,7 @@ export const RadioRoot = React.forwardRef(function RadioRoot<Value>(
return (
<RadioRootContext.Provider value={contextValue}>
{isRadioGroup ? (
<CompositeItem
tag="span"
<RadioGroupItem
render={render}
className={className}
style={style}
Expand All @@ -286,6 +291,66 @@ export const RadioRoot = React.forwardRef(function RadioRoot<Value>(
<Value>(props: RadioRoot.Props<Value>): React.JSX.Element;
};

/**
* A composite item that takes the group's tab stop when its radio becomes checked and enabled
* after mount. The initial tab stop comes from `ACTIVE_COMPOSITE_ITEM`, which the composite only
* reads when its items first register.
*/
function RadioGroupItem(props: RadioGroupItemProps) {
const { state, refs, props: itemProps, stateAttributesMapping } = props;
const { highlightedIndex, onHighlightedIndexChange } = useCompositeRootContext();
const { compositeProps, compositeRef, index } = useCompositeItem();

const itemRef = React.useRef<HTMLElement | null>(null);
const isTabStopCandidate = state.checked && !state.disabled;
const wasTabStopCandidateRef = React.useRef(isTabStopCandidate);

useIsoLayoutEffect(() => {
if (index === -1) {
return;
}

const wasTabStopCandidate = wasTabStopCandidateRef.current;
wasTabStopCandidateRef.current = isTabStopCandidate;

// Only react to the radio becoming checked and enabled, so roving focus that moves the
// highlight away from the checked radio (e.g. in a read-only group) isn't pulled back.
if (!isTabStopCandidate || wasTabStopCandidate || highlightedIndex === index) {
return;
}

// While focus is inside the group, keep the highlight on the focused radio so keyboard
// navigation stays relative to it after a late or rejected value change.
const element = itemRef.current;
const group = element ? closest(element, '[role="radiogroup"]') : null;
if (group) {
const focused = activeElement(ownerDocument(group));
if (focused && contains(group, focused)) {
return;
}
}

onHighlightedIndexChange(index);
}, [isTabStopCandidate, index, highlightedIndex, onHighlightedIndexChange]);

return useRenderElement('span', props, {
state,
ref: [compositeRef, itemRef, ...refs],
props: [compositeProps, ...itemProps],
stateAttributesMapping,
});
}

interface RadioGroupItemProps extends Pick<
BaseUIComponentProps<'span', RadioRootState>,
'render' | 'className' | 'style'
> {
state: RadioRootState;
refs: React.Ref<HTMLElement | null>[];
props: Array<Record<string, any> | (() => Record<string, any>)>;
stateAttributesMapping: StateAttributesMapping<RadioRootState>;
}

export interface RadioRootState extends FieldRootState {
/**
* Whether the radio button is currently selected.
Expand Down
Loading