Skip to content

Commit 8f70ce2

Browse files
pladariaPedro Ladaria
andauthored
1 parent fb0223a commit 8f70ce2

2 files changed

Lines changed: 75 additions & 4 deletions

File tree

src/__tests__/radio-button-test.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,3 +297,45 @@ test('Radio onClick event is not propagated', async () => {
297297

298298
expect(onPressHandler).not.toHaveBeenCalled();
299299
});
300+
301+
test('Radiogroup with no radio selected gets focus on first radio using TAB navigation', async () => {
302+
render(
303+
<ThemeContextProvider theme={makeTheme()}>
304+
<ButtonPrimary onPress={() => {}}>Focus me first</ButtonPrimary>
305+
<RadioGroup name="radio-group" aria-labelledby="label">
306+
<RadioButton value="banana" />
307+
<RadioButton value="apple" />
308+
</RadioGroup>
309+
</ThemeContextProvider>
310+
);
311+
312+
const button = screen.getByRole('button', {name: 'Focus me first'});
313+
await userEvent.click(button);
314+
expect(button).toHaveFocus();
315+
316+
await userEvent.tab();
317+
318+
const radios = screen.getAllByRole('radio');
319+
expect(radios[0]).toHaveFocus();
320+
});
321+
322+
test('Radiogroup with selected radio gets focus on selected radio using TAB navigation', async () => {
323+
render(
324+
<ThemeContextProvider theme={makeTheme()}>
325+
<ButtonPrimary onPress={() => {}}>Focus me first</ButtonPrimary>
326+
<RadioGroup name="radio-group" aria-labelledby="label" defaultValue="apple">
327+
<RadioButton value="banana" />
328+
<RadioButton value="apple" />
329+
</RadioGroup>
330+
</ThemeContextProvider>
331+
);
332+
333+
const button = screen.getByRole('button', {name: 'Focus me first'});
334+
await userEvent.click(button);
335+
expect(button).toHaveFocus();
336+
337+
await userEvent.tab();
338+
339+
const radios = screen.getAllByRole('radio');
340+
expect(radios[1]).toHaveFocus();
341+
});

src/radio-button.tsx

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {useIsInverseVariant} from './theme-variant-context';
1414
import type {DataAttributes} from './utils/types';
1515

1616
type RadioContextType = {
17+
id: string;
1718
disabled?: boolean;
1819
selectedValue?: string | null;
1920
focusableValue?: string | null;
@@ -22,6 +23,7 @@ type RadioContextType = {
2223
selectPrev: () => void;
2324
};
2425
const RadioContext = React.createContext<RadioContextType>({
26+
id: '',
2527
disabled: false,
2628
selectedValue: null,
2729
focusableValue: null,
@@ -64,15 +66,39 @@ const RadioButton = ({
6466
'aria-label': ariaLabel,
6567
...rest
6668
}: PropsRender | PropsChildren): JSX.Element => {
67-
const {disabled, selectedValue, focusableValue, select, selectNext, selectPrev} = useRadioContext();
69+
const {
70+
id: groupId,
71+
disabled,
72+
selectedValue,
73+
focusableValue,
74+
select,
75+
selectNext,
76+
selectPrev,
77+
} = useRadioContext();
78+
const [isFirstRadio, setIsFirstRadio] = React.useState(false);
6879
const reactId = React.useId();
6980
const labelId = ariaLabelledby || reactId;
7081
const ref = React.useRef<HTMLDivElement>(null);
7182
const checked = value === selectedValue;
72-
const tabIndex = focusableValue === value ? 0 : -1;
7383
const {isIos} = useTheme();
7484
const isInverse = useIsInverseVariant();
7585

86+
/**
87+
* The radio will gain focus with tab navigation if:
88+
* - it is not disabled
89+
* - it is the currently selected radio -OR- it is the first radio in the group and no radio is selected
90+
*/
91+
const tabIndex = disabled
92+
? undefined
93+
: focusableValue === value || (isFirstRadio && !selectedValue)
94+
? 0
95+
: -1;
96+
97+
React.useEffect(() => {
98+
const firstRadio = document.getElementById(groupId)?.querySelector('[role=radio]');
99+
setIsFirstRadio(firstRadio === ref.current);
100+
}, [groupId]);
101+
76102
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
77103
switch (event.key) {
78104
case SPACE:
@@ -124,11 +150,10 @@ const RadioButton = ({
124150
);
125151

126152
return (
127-
// eslint-disable-next-line jsx-a11y/interactive-supports-focus
128153
<span
129154
ref={ref}
130155
id={id}
131-
tabIndex={disabled ? undefined : tabIndex}
156+
tabIndex={tabIndex}
132157
role="radio"
133158
data-value={value}
134159
aria-checked={checked}
@@ -189,6 +214,8 @@ export const RadioGroup = (props: RadioGroupProps): JSX.Element => {
189214
disabled: props.disabled,
190215
});
191216

217+
const id = React.useId();
218+
192219
const isControlledByParent = typeof valueContext !== 'undefined';
193220

194221
// This state is needed because the component should be able to work outside a Form context
@@ -265,13 +292,15 @@ export const RadioGroup = (props: RadioGroupProps): JSX.Element => {
265292
return (
266293
<div
267294
ref={combineRefs(ref, focusableRef)}
295+
id={id}
268296
role="radiogroup"
269297
aria-label={props['aria-label']}
270298
aria-labelledby={props['aria-label'] ? undefined : props['aria-labelledby']}
271299
{...getPrefixedDataAttributes(props.dataAttributes, 'RadioGroup')}
272300
>
273301
<RadioContext.Provider
274302
value={{
303+
id,
275304
disabled,
276305
selectedValue: value ?? defaultValue,
277306
focusableValue,

0 commit comments

Comments
 (0)