Skip to content

Commit 8f0ba04

Browse files
fix: radiogroup navigation (#8488)
* fix: radio tab navigation in a focusscope * fix navigation inside RadioGroup * fix lint * build radio check into focusable tree walker so everyone benefits * make logic work outside a form as well * Update packages/@react-aria/focus/src/FocusScope.tsx Co-authored-by: Devon Govett <[email protected]> * add to dialog story for testing --------- Co-authored-by: Devon Govett <[email protected]>
1 parent 7548779 commit 8f0ba04

File tree

5 files changed

+357
-6
lines changed

5 files changed

+357
-6
lines changed

packages/@react-aria/focus/src/FocusScope.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,25 @@ function shouldContainFocus(scopeRef: ScopeRef) {
295295
return true;
296296
}
297297

298+
function isTabbableRadio(element: HTMLInputElement) {
299+
if (element.checked) {
300+
return true;
301+
}
302+
let radios: HTMLInputElement[] = [];
303+
if (!element.form) {
304+
radios = ([...getOwnerDocument(element).querySelectorAll(`input[type="radio"][name="${CSS.escape(element.name)}"]`)] as HTMLInputElement[]).filter(radio => !radio.form);
305+
} else {
306+
let radioList = element.form?.elements?.namedItem(element.name) as RadioNodeList;
307+
radios = [...(radioList ?? [])] as HTMLInputElement[];
308+
}
309+
if (!radios) {
310+
return false;
311+
}
312+
let anyChecked = radios.some(radio => radio.checked);
313+
314+
return !anyChecked;
315+
}
316+
298317
function useFocusContainment(scopeRef: RefObject<Element[] | null>, contain?: boolean) {
299318
let focusedNode = useRef<FocusableElement>(undefined);
300319

@@ -757,6 +776,21 @@ export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions
757776
return NodeFilter.FILTER_REJECT;
758777
}
759778

779+
if (opts?.tabbable
780+
&& (node as Element).tagName === 'INPUT'
781+
&& (node as HTMLInputElement).getAttribute('type') === 'radio') {
782+
// If the radio is in a form, we can get all the other radios by name
783+
if (!isTabbableRadio(node as HTMLInputElement)) {
784+
return NodeFilter.FILTER_REJECT;
785+
}
786+
// If the radio is in the same group as the current node and none are selected, we can skip it
787+
if ((walker.currentNode as Element).tagName === 'INPUT'
788+
&& (walker.currentNode as HTMLInputElement).type === 'radio'
789+
&& (walker.currentNode as HTMLInputElement).name === (node as HTMLInputElement).name) {
790+
return NodeFilter.FILTER_REJECT;
791+
}
792+
}
793+
760794
if (filter(node as Element)
761795
&& isElementVisible(node as Element)
762796
&& (!scope || isElementInScope(node as Element, scope))

packages/@react-aria/focus/test/FocusScope.test.js

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,6 +1280,264 @@ describe('FocusScope', function () {
12801280
});
12811281
});
12821282

1283+
it('skips radio buttons that are in the same group and are not the selectable one forwards', async function () {
1284+
function Test() {
1285+
return (
1286+
<FocusScope contain>
1287+
<button data-testid="button1">button</button>
1288+
<form>
1289+
<fieldset>
1290+
<legend>Select a maintenance drone:</legend>
1291+
1292+
<div>
1293+
<input type="radio" id="huey" name="drone" value="huey" defaultChecked />
1294+
<label htmlFor="huey">Huey</label>
1295+
</div>
1296+
1297+
<div>
1298+
<input type="radio" id="dewey" name="drone" value="dewey" />
1299+
<label htmlFor="dewey">Dewey</label>
1300+
</div>
1301+
<button data-testid="button2">button</button>
1302+
<div>
1303+
<input type="radio" id="louie" name="drone" value="louie" />
1304+
<label htmlFor="louie">Louie</label>
1305+
</div>
1306+
</fieldset>
1307+
<fieldset>
1308+
<legend>Select a ship:</legend>
1309+
1310+
<div>
1311+
<input type="radio" id="larry" name="ship" value="larry" />
1312+
<label htmlFor="larry">Larry</label>
1313+
</div>
1314+
1315+
<div>
1316+
<input type="radio" id="moe" name="ship" value="moe" />
1317+
<label htmlFor="moe">Moe</label>
1318+
</div>
1319+
<button data-testid="button3">button</button>
1320+
<div>
1321+
<input type="radio" id="curly" name="ship" value="curly" />
1322+
<label htmlFor="curly">Curly</label>
1323+
</div>
1324+
</fieldset>
1325+
</form>
1326+
<button data-testid="button4">button</button>
1327+
</FocusScope>
1328+
);
1329+
}
1330+
1331+
let {getByTestId, getAllByRole} = render(<Test />);
1332+
let radios = getAllByRole('radio');
1333+
await user.tab();
1334+
expect(document.activeElement).toBe(getByTestId('button1'));
1335+
await user.tab();
1336+
expect(document.activeElement).toBe(radios[0]);
1337+
await user.tab();
1338+
expect(document.activeElement).toBe(getByTestId('button2'));
1339+
await user.tab();
1340+
expect(document.activeElement).toBe(radios[3]);
1341+
await user.tab();
1342+
expect(document.activeElement).toBe(getByTestId('button3'));
1343+
await user.tab();
1344+
expect(document.activeElement).toBe(radios[5]);
1345+
await user.tab();
1346+
expect(document.activeElement).toBe(getByTestId('button4'));
1347+
});
1348+
1349+
it('skips radio buttons that are in the same group and are not the selectable one forwards outside of a form', async function () {
1350+
function Test() {
1351+
return (
1352+
<FocusScope contain>
1353+
<button data-testid="button1">button</button>
1354+
<fieldset>
1355+
<legend>Select a maintenance drone:</legend>
1356+
1357+
<div>
1358+
<input type="radio" id="huey" name="drone" value="huey" defaultChecked />
1359+
<label htmlFor="huey">Huey</label>
1360+
</div>
1361+
1362+
<div>
1363+
<input type="radio" id="dewey" name="drone" value="dewey" />
1364+
<label htmlFor="dewey">Dewey</label>
1365+
</div>
1366+
<button data-testid="button2">button</button>
1367+
<div>
1368+
<input type="radio" id="louie" name="drone" value="louie" />
1369+
<label htmlFor="louie">Louie</label>
1370+
</div>
1371+
</fieldset>
1372+
<fieldset>
1373+
<legend>Select a ship:</legend>
1374+
1375+
<div>
1376+
<input type="radio" id="larry" name="ship" value="larry" />
1377+
<label htmlFor="larry">Larry</label>
1378+
</div>
1379+
1380+
<div>
1381+
<input type="radio" id="moe" name="ship" value="moe" />
1382+
<label htmlFor="moe">Moe</label>
1383+
</div>
1384+
<button data-testid="button3">button</button>
1385+
<div>
1386+
<input type="radio" id="curly" name="ship" value="curly" />
1387+
<label htmlFor="curly">Curly</label>
1388+
</div>
1389+
</fieldset>
1390+
<button data-testid="button4">button</button>
1391+
</FocusScope>
1392+
);
1393+
}
1394+
1395+
let {getByTestId, getAllByRole} = render(<Test />);
1396+
let radios = getAllByRole('radio');
1397+
await user.tab();
1398+
expect(document.activeElement).toBe(getByTestId('button1'));
1399+
await user.tab();
1400+
expect(document.activeElement).toBe(radios[0]);
1401+
await user.tab();
1402+
expect(document.activeElement).toBe(getByTestId('button2'));
1403+
await user.tab();
1404+
expect(document.activeElement).toBe(radios[3]);
1405+
await user.tab();
1406+
expect(document.activeElement).toBe(getByTestId('button3'));
1407+
await user.tab();
1408+
expect(document.activeElement).toBe(radios[5]);
1409+
await user.tab();
1410+
expect(document.activeElement).toBe(getByTestId('button4'));
1411+
});
1412+
1413+
it('skips radio buttons that are in the same group and are not the selectable one backwards', async function () {
1414+
function Test() {
1415+
return (
1416+
<FocusScope contain>
1417+
<button data-testid="button1">button</button>
1418+
<form>
1419+
<fieldset>
1420+
<legend>Select a maintenance drone:</legend>
1421+
1422+
<div>
1423+
<input type="radio" id="huey" name="drone" value="huey" defaultChecked />
1424+
<label htmlFor="huey">Huey</label>
1425+
</div>
1426+
1427+
<div>
1428+
<input type="radio" id="dewey" name="drone" value="dewey" />
1429+
<label htmlFor="dewey">Dewey</label>
1430+
</div>
1431+
<button data-testid="button2">button</button>
1432+
<div>
1433+
<input type="radio" id="louie" name="drone" value="louie" />
1434+
<label htmlFor="louie">Louie</label>
1435+
</div>
1436+
</fieldset>
1437+
<fieldset>
1438+
<legend>Select a ship:</legend>
1439+
1440+
<div>
1441+
<input type="radio" id="larry" name="ship" value="larry" />
1442+
<label htmlFor="larry">Larry</label>
1443+
</div>
1444+
1445+
<div>
1446+
<input type="radio" id="moe" name="ship" value="moe" />
1447+
<label htmlFor="moe">Moe</label>
1448+
</div>
1449+
<button data-testid="button3">button</button>
1450+
<div>
1451+
<input type="radio" id="curly" name="ship" value="curly" />
1452+
<label htmlFor="curly">Curly</label>
1453+
</div>
1454+
</fieldset>
1455+
</form>
1456+
<button data-testid="button4">button</button>
1457+
</FocusScope>
1458+
);
1459+
}
1460+
1461+
let {getByTestId, getAllByRole} = render(<Test />);
1462+
let radios = getAllByRole('radio');
1463+
await user.click(getByTestId('button4'));
1464+
await user.tab({shift: true});
1465+
expect(document.activeElement).toBe(radios[5]);
1466+
await user.tab({shift: true});
1467+
expect(document.activeElement).toBe(getByTestId('button3'));
1468+
await user.tab({shift: true});
1469+
expect(document.activeElement).toBe(radios[4]);
1470+
await user.tab({shift: true});
1471+
expect(document.activeElement).toBe(getByTestId('button2'));
1472+
await user.tab({shift: true});
1473+
expect(document.activeElement).toBe(radios[0]);
1474+
await user.tab({shift: true});
1475+
expect(document.activeElement).toBe(getByTestId('button1'));
1476+
});
1477+
1478+
it('skips radio buttons that are in the same group and are not the selectable one backwards outside of a form', async function () {
1479+
function Test() {
1480+
return (
1481+
<FocusScope contain>
1482+
<button data-testid="button1">button</button>
1483+
<fieldset>
1484+
<legend>Select a maintenance drone:</legend>
1485+
1486+
<div>
1487+
<input type="radio" id="huey" name="drone" value="huey" defaultChecked />
1488+
<label htmlFor="huey">Huey</label>
1489+
</div>
1490+
1491+
<div>
1492+
<input type="radio" id="dewey" name="drone" value="dewey" />
1493+
<label htmlFor="dewey">Dewey</label>
1494+
</div>
1495+
<button data-testid="button2">button</button>
1496+
<div>
1497+
<input type="radio" id="louie" name="drone" value="louie" />
1498+
<label htmlFor="louie">Louie</label>
1499+
</div>
1500+
</fieldset>
1501+
<fieldset>
1502+
<legend>Select a ship:</legend>
1503+
1504+
<div>
1505+
<input type="radio" id="larry" name="ship" value="larry" />
1506+
<label htmlFor="larry">Larry</label>
1507+
</div>
1508+
1509+
<div>
1510+
<input type="radio" id="moe" name="ship" value="moe" />
1511+
<label htmlFor="moe">Moe</label>
1512+
</div>
1513+
<button data-testid="button3">button</button>
1514+
<div>
1515+
<input type="radio" id="curly" name="ship" value="curly" />
1516+
<label htmlFor="curly">Curly</label>
1517+
</div>
1518+
</fieldset>
1519+
<button data-testid="button4">button</button>
1520+
</FocusScope>
1521+
);
1522+
}
1523+
1524+
let {getByTestId, getAllByRole} = render(<Test />);
1525+
let radios = getAllByRole('radio');
1526+
await user.click(getByTestId('button4'));
1527+
await user.tab({shift: true});
1528+
expect(document.activeElement).toBe(radios[5]);
1529+
await user.tab({shift: true});
1530+
expect(document.activeElement).toBe(getByTestId('button3'));
1531+
await user.tab({shift: true});
1532+
expect(document.activeElement).toBe(radios[4]);
1533+
await user.tab({shift: true});
1534+
expect(document.activeElement).toBe(getByTestId('button2'));
1535+
await user.tab({shift: true});
1536+
expect(document.activeElement).toBe(radios[0]);
1537+
await user.tab({shift: true});
1538+
expect(document.activeElement).toBe(getByTestId('button1'));
1539+
});
1540+
12831541
describe('nested focus scopes', function () {
12841542
it('should make child FocusScopes the active scope regardless of DOM structure', function () {
12851543
function ChildComponent(props) {

packages/@react-aria/radio/src/useRadioGroup.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import {AriaRadioGroupProps} from '@react-types/radio';
1414
import {DOMAttributes, ValidationResult} from '@react-types/shared';
15-
import {filterDOMProps, mergeProps, useId} from '@react-aria/utils';
15+
import {filterDOMProps, getOwnerWindow, mergeProps, useId} from '@react-aria/utils';
1616
import {getFocusableTreeWalker} from '@react-aria/focus';
1717
import {radioGroupData} from './utils';
1818
import {RadioGroupState} from '@react-stately/radio';
@@ -102,7 +102,10 @@ export function useRadioGroup(props: AriaRadioGroupProps, state: RadioGroupState
102102
return;
103103
}
104104
e.preventDefault();
105-
let walker = getFocusableTreeWalker(e.currentTarget, {from: e.target});
105+
let walker = getFocusableTreeWalker(e.currentTarget, {
106+
from: e.target,
107+
accept: (node) => node instanceof getOwnerWindow(node).HTMLInputElement && node.type === 'radio'
108+
});
106109
let nextElem;
107110
if (nextDir === 'next') {
108111
nextElem = walker.nextNode();

packages/react-aria-components/stories/RadioGroup.stories.tsx

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,16 +79,49 @@ export const RadioGroupInDialogExample = () => {
7979
position: 'relative'
8080
}}>
8181
{({close}) => (
82-
<>
83-
<div>
84-
<RadioGroupExample />
82+
<div style={{display: 'flex', flexDirection: 'column', gap: 10}}>
83+
<div style={{display: 'flex', flexDirection: 'row', gap: 20}}>
84+
<div>
85+
<RadioGroupExample />
86+
</div>
87+
<Form>
88+
<RadioGroup
89+
className={styles.radiogroup}
90+
data-testid="radio-group-example-2"
91+
isRequired>
92+
<Label>Second Favorite pet</Label>
93+
<Radio className={styles.radio} value="dogs" data-testid="radio-dog">Dog</Radio>
94+
<Button>About dogs</Button>
95+
<Radio className={styles.radio} value="cats">Cat</Radio>
96+
<Button>About cats</Button>
97+
<Radio className={styles.radio} value="dragon">Dragon</Radio>
98+
<Button>About dragons</Button>
99+
<FieldError className={styles.errorMessage} />
100+
</RadioGroup>
101+
</Form>
102+
<Form>
103+
<RadioGroup
104+
className={styles.radiogroup}
105+
data-testid="radio-group-example-3"
106+
defaultValue="dragon"
107+
isRequired>
108+
<Label>Third Favorite pet</Label>
109+
<Radio className={styles.radio} value="dogs" data-testid="radio-dog">Dog</Radio>
110+
<Button>About dogs</Button>
111+
<Radio className={styles.radio} value="cats">Cat</Radio>
112+
<Button>About cats</Button>
113+
<Radio className={styles.radio} value="dragon">Dragon</Radio>
114+
<Button>About dragons</Button>
115+
<FieldError className={styles.errorMessage} />
116+
</RadioGroup>
117+
</Form>
85118
</div>
86119
<div>
87120
<Button onPress={close} style={{marginTop: 10}}>
88121
Close
89122
</Button>
90123
</div>
91-
</>
124+
</div>
92125
)}
93126
</Dialog>
94127
</Modal>

0 commit comments

Comments
 (0)