Skip to content

Commit 049c672

Browse files
authored
Ensure that arrow keys in grid cells always win over children (#6116)
1 parent 56515c9 commit 049c672

File tree

4 files changed

+73
-12
lines changed

4 files changed

+73
-12
lines changed

packages/@react-aria/focus/src/isElementVisible.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ function isStyleVisible(element: Element) {
4343
function isAttributeVisible(element: Element, childElement?: Element) {
4444
return (
4545
!element.hasAttribute('hidden') &&
46+
// Ignore HiddenSelect when tree walking.
47+
!(element.hasAttribute('data-a11y-ignore') && element.getAttribute('aria-hidden') === 'true') &&
4648
(element.nodeName === 'DETAILS' &&
4749
childElement &&
4850
childElement.nodeName !== 'SUMMARY'

packages/@react-aria/grid/src/useGridCell.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,9 @@ export function useGridCell<T, C extends GridCollection<T>>(props: GridCellProps
124124
focusable = null;
125125
}
126126

127+
e.preventDefault();
128+
e.stopPropagation();
127129
if (focusable) {
128-
e.preventDefault();
129-
e.stopPropagation();
130130
focusSafely(focusable);
131131
scrollIntoViewport(focusable, {containingElement: getScrollParent(ref.current)});
132132
} else {
@@ -137,11 +137,15 @@ export function useGridCell<T, C extends GridCollection<T>>(props: GridCellProps
137137
// child, depending on the focus mode.
138138
let prev = keyboardDelegate.getKeyLeftOf(node.key);
139139
if (prev !== node.key) {
140+
// We prevent the capturing event from reaching children of the cell, e.g. pickers.
141+
// We want arrow keys to navigate to the next cell instead. We need to re-dispatch
142+
// the event from a higher parent so it still bubbles and gets handled by useSelectableCollection.
143+
ref.current.parentElement.dispatchEvent(
144+
new KeyboardEvent(e.nativeEvent.type, e.nativeEvent)
145+
);
140146
break;
141147
}
142148

143-
e.preventDefault();
144-
e.stopPropagation();
145149
if (focusMode === 'cell' && direction === 'rtl') {
146150
focusSafely(ref.current);
147151
scrollIntoViewport(ref.current, {containingElement: getScrollParent(ref.current)});
@@ -167,19 +171,23 @@ export function useGridCell<T, C extends GridCollection<T>>(props: GridCellProps
167171
focusable = null;
168172
}
169173

174+
e.preventDefault();
175+
e.stopPropagation();
170176
if (focusable) {
171-
e.preventDefault();
172-
e.stopPropagation();
173177
focusSafely(focusable);
174178
scrollIntoViewport(focusable, {containingElement: getScrollParent(ref.current)});
175179
} else {
176180
let next = keyboardDelegate.getKeyRightOf(node.key);
177181
if (next !== node.key) {
182+
// We prevent the capturing event from reaching children of the cell, e.g. pickers.
183+
// We want arrow keys to navigate to the next cell instead. We need to re-dispatch
184+
// the event from a higher parent so it still bubbles and gets handled by useSelectableCollection.
185+
ref.current.parentElement.dispatchEvent(
186+
new KeyboardEvent(e.nativeEvent.type, e.nativeEvent)
187+
);
178188
break;
179189
}
180190

181-
e.preventDefault();
182-
e.stopPropagation();
183191
if (focusMode === 'cell' && direction === 'ltr') {
184192
focusSafely(ref.current);
185193
scrollIntoViewport(ref.current, {containingElement: getScrollParent(ref.current)});

packages/@react-spectrum/table/stories/Table.stories.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {IllustratedMessage} from '@react-spectrum/illustratedmessage';
3131
import {Key, LoadingState} from '@react-types/shared';
3232
import {Link} from '@react-spectrum/link';
3333
import NoSearchResults from '@spectrum-icons/illustrations/NoSearchResults';
34+
import {Picker} from '@react-spectrum/picker';
3435
import {Radio, RadioGroup} from '@react-spectrum/radio';
3536
import React, {useCallback, useState} from 'react';
3637
import {SearchField} from '@react-spectrum/searchfield';
@@ -450,6 +451,17 @@ export const FocusableCells: TableStory = {
450451
<Cell><Link><a href="https://yahoo.com" target="_blank">Yahoo</a></Link></Cell>
451452
<Cell>Three</Cell>
452453
</Row>
454+
<Row>
455+
<Cell><Switch aria-label="Foo" /></Cell>
456+
<Cell>
457+
<Picker aria-label="Search engine" placeholder="Search with:" width={'100%'} isQuiet>
458+
<Item key="Yahoo">Yahoo</Item>
459+
<Item key="Google">Google</Item>
460+
<Item key="DuckDuckGo">DuckDuckGo</Item>
461+
</Picker>
462+
</Cell>
463+
<Cell>Three</Cell>
464+
</Row>
453465
</TableBody>
454466
</TableView>
455467
<label htmlFor="focus-after">Focus after</label>
@@ -927,7 +939,7 @@ function AsyncLoadingExample(props) {
927939
let url = new URL('https://www.reddit.com/r/upliftingnews.json');
928940
if (cursor) {
929941
url.searchParams.append('after', cursor);
930-
}
942+
}
931943
let res = await fetch(url.toString(), {signal});
932944
let json = await res.json();
933945
return {items: json.data.children, cursor: json.data.after};
@@ -1076,14 +1088,14 @@ function AsyncLoadingExampleQuarryTest(props) {
10761088
return {
10771089
items: items.slice().sort((a, b) => {
10781090
let cmp = a[sortDescriptor.column] < b[sortDescriptor.column] ? -1 : 1;
1079-
1091+
10801092
if (sortDescriptor.direction === 'descending') {
10811093
cmp *= -1;
10821094
}
1083-
1095+
10841096
return cmp;
10851097
})
1086-
1098+
10871099
};
10881100
}
10891101
});

packages/@react-spectrum/table/test/Table.test.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {Divider} from '@react-spectrum/divider';
2525
import {enableTableNestedRows} from '@react-stately/flags';
2626
import {getFocusableTreeWalker} from '@react-aria/focus';
2727
import {Heading} from '@react-spectrum/text';
28+
import {Item, Picker} from '@react-spectrum/picker';
2829
import {Link} from '@react-spectrum/link';
2930
import {Provider} from '@react-spectrum/provider';
3031
import React from 'react';
@@ -1499,6 +1500,31 @@ export let tableTests = () => {
14991500
</>
15001501
);
15011502

1503+
let renderWithPicker = () => render(
1504+
<>
1505+
<TableView aria-label="Table">
1506+
<TableHeader>
1507+
<Column>Foo</Column>
1508+
<Column>Bar</Column>
1509+
<Column>baz</Column>
1510+
</TableHeader>
1511+
<TableBody>
1512+
<Row>
1513+
<Cell textValue="Foo 1"><Switch aria-label="Foo 1" /></Cell>
1514+
<Cell textValue="Search engine">
1515+
<Picker aria-label="Search engine" placeholder="Search with:" width={'100%'} isQuiet>
1516+
<Item key="Yahoo">Yahoo</Item>
1517+
<Item key="Google">Google</Item>
1518+
<Item key="DuckDuckGo">DuckDuckGo</Item>
1519+
</Picker>
1520+
</Cell>
1521+
<Cell>Baz 1</Cell>
1522+
</Row>
1523+
</TableBody>
1524+
</TableView>
1525+
</>
1526+
);
1527+
15021528
it('should retain focus on the pressed child', async function () {
15031529
let tree = renderFocusable();
15041530
let switchToPress = tree.getAllByRole('switch')[2];
@@ -1629,6 +1655,19 @@ export let tableTests = () => {
16291655
expect(document.activeElement).toBe(baz1);
16301656
});
16311657

1658+
it('should not trap focus when navigating through a cell with a picker using the arrow keys', function () {
1659+
let tree = renderWithPicker();
1660+
focusCell(tree, 'Baz 1');
1661+
moveFocus('ArrowLeft');
1662+
expect(document.activeElement).toBe(tree.getByRole('button'));
1663+
moveFocus('ArrowLeft');
1664+
expect(document.activeElement).toBe(tree.getByRole('switch'));
1665+
moveFocus('ArrowRight');
1666+
expect(document.activeElement).toBe(tree.getByRole('button'));
1667+
moveFocus('ArrowRight');
1668+
expect(document.activeElement).toBe(tree.getAllByRole('gridcell')[1]);
1669+
});
1670+
16321671
it('should move focus after the table when tabbing', async function () {
16331672
let tree = renderFocusable();
16341673

0 commit comments

Comments
 (0)