Skip to content

Commit ac8e8b3

Browse files
authored
[react-interactions] Add tab handling to FocusList (#16958)
1 parent 10c7dfe commit ac8e8b3

File tree

7 files changed

+153
-38
lines changed

7 files changed

+153
-38
lines changed

packages/react-interactions/accessibility/src/FocusList.js

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {KeyboardEvent} from 'react-interactions/events/keyboard';
1212

1313
import React from 'react';
1414
import {useKeyboard} from 'react-interactions/events/keyboard';
15+
import {setElementCanTab} from 'react-interactions/accessibility/focus-control';
1516

1617
type FocusItemProps = {
1718
children?: React.Node,
@@ -22,6 +23,7 @@ type FocusListProps = {|
2223
children: React.Node,
2324
portrait: boolean,
2425
wrap?: boolean,
26+
tabScope?: ReactScope,
2527
|};
2628

2729
const {useRef} = React;
@@ -41,7 +43,7 @@ function getPreviousListItem(
4143
const items = list.getChildren();
4244
if (items !== null) {
4345
const currentItemIndex = items.indexOf(currentItem);
44-
const wrap = getListWrapProp(currentItem);
46+
const wrap = getListProps(currentItem).wrap;
4547
if (currentItemIndex === 0 && wrap) {
4648
return items[items.length - 1] || null;
4749
} else if (currentItemIndex > 0) {
@@ -58,7 +60,7 @@ function getNextListItem(
5860
const items = list.getChildren();
5961
if (items !== null) {
6062
const currentItemIndex = items.indexOf(currentItem);
61-
const wrap = getListWrapProp(currentItem);
63+
const wrap = getListProps(currentItem).wrap;
6264
const end = currentItemIndex === items.length - 1;
6365
if (end && wrap) {
6466
return items[0] || null;
@@ -69,22 +71,38 @@ function getNextListItem(
6971
return null;
7072
}
7173

72-
function getListWrapProp(currentItem: ReactScopeMethods): boolean {
73-
const list = currentItem.getParent();
74+
function getListProps(currentCell: ReactScopeMethods): Object {
75+
const list = currentCell.getParent();
7476
if (list !== null) {
7577
const listProps = list.getProps();
76-
return (listProps.type === 'list' && listProps.wrap) || false;
78+
if (listProps && listProps.type === 'list') {
79+
return listProps;
80+
}
7781
}
78-
return false;
82+
return {};
7983
}
8084

8185
export function createFocusList(scope: ReactScope): Array<React.Component> {
8286
const TableScope = React.unstable_createScope(scope.fn);
8387

84-
function List({children, portrait, wrap}): FocusListProps {
88+
function List({
89+
children,
90+
portrait,
91+
wrap,
92+
tabScope: TabScope,
93+
}): FocusListProps {
94+
const tabScopeRef = useRef(null);
8595
return (
86-
<TableScope type="list" portrait={portrait} wrap={wrap}>
87-
{children}
96+
<TableScope
97+
type="list"
98+
portrait={portrait}
99+
wrap={wrap}
100+
tabScopeRef={tabScopeRef}>
101+
{TabScope ? (
102+
<TabScope ref={tabScopeRef}>{children}</TabScope>
103+
) : (
104+
children
105+
)}
88106
</TableScope>
89107
);
90108
}
@@ -100,6 +118,24 @@ export function createFocusList(scope: ReactScope): Array<React.Component> {
100118
if (list !== null && listProps.type === 'list') {
101119
const portrait = listProps.portrait;
102120
switch (event.key) {
121+
case 'Tab': {
122+
const tabScope = getListProps(currentItem).tabScopeRef.current;
123+
if (tabScope) {
124+
const activeNode = document.activeElement;
125+
const nodes = tabScope.getScopedNodes();
126+
for (let i = 0; i < nodes.length; i++) {
127+
const node = nodes[i];
128+
if (node !== activeNode) {
129+
setElementCanTab(node, false);
130+
} else {
131+
setElementCanTab(node, true);
132+
}
133+
}
134+
return;
135+
}
136+
event.continuePropagation();
137+
return;
138+
}
103139
case 'ArrowUp': {
104140
if (portrait) {
105141
const previousListItem = getPreviousListItem(

packages/react-interactions/accessibility/src/FocusManager.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ const FocusManager = React.forwardRef(
6464
onBlurWithin: function(event) {
6565
if (!containFocus) {
6666
event.continuePropagation();
67+
return;
6768
}
6869
const lastNode = event.target;
6970
if (lastNode) {

packages/react-interactions/accessibility/src/FocusTable.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type FocusTableProps = {|
3131
focusTableByID: (id: string) => void,
3232
) => void,
3333
wrap?: boolean,
34+
tabScope?: ReactScope,
3435
|};
3536

3637
const {useRef} = React;

packages/react-interactions/accessibility/src/__tests__/FocusList-test.internal.js

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
* @flow
88
*/
99

10-
import {createEventTarget} from 'react-interactions/events/src/dom/testing-library';
10+
import {
11+
createEventTarget,
12+
emulateBrowserTab,
13+
} from 'react-interactions/events/src/dom/testing-library';
1114

1215
let React;
1316
let ReactFeatureFlags;
@@ -156,5 +159,74 @@ describe('FocusList', () => {
156159
});
157160
expect(document.activeElement.textContent).toBe('Item 3');
158161
});
162+
163+
it('handles keyboard arrow operations mixed with tabbing', () => {
164+
const [FocusList, FocusItem] = createFocusList(TabbableScope);
165+
const beforeRef = React.createRef();
166+
const afterRef = React.createRef();
167+
168+
function Test() {
169+
return (
170+
<>
171+
<input placeholder="Before" ref={beforeRef} />
172+
<FocusList tabScope={TabbableScope} portrait={true}>
173+
<ul>
174+
<FocusItem>
175+
<li>
176+
<input placeholder="A" />
177+
</li>
178+
</FocusItem>
179+
<FocusItem>
180+
<li>
181+
<input placeholder="B" />
182+
</li>
183+
</FocusItem>
184+
<FocusItem>
185+
<li>
186+
<input placeholder="C" />
187+
</li>
188+
</FocusItem>
189+
<FocusItem>
190+
<li>
191+
<input placeholder="D" />
192+
</li>
193+
</FocusItem>
194+
<FocusItem>
195+
<li>
196+
<input placeholder="E" />
197+
</li>
198+
</FocusItem>
199+
<FocusItem>
200+
<li>
201+
<input placeholder="F" />
202+
</li>
203+
</FocusItem>
204+
</ul>
205+
</FocusList>
206+
<input placeholder="After" ref={afterRef} />
207+
</>
208+
);
209+
}
210+
211+
ReactDOM.render(<Test />, container);
212+
beforeRef.current.focus();
213+
214+
expect(document.activeElement.placeholder).toBe('Before');
215+
emulateBrowserTab();
216+
expect(document.activeElement.placeholder).toBe('A');
217+
emulateBrowserTab();
218+
expect(document.activeElement.placeholder).toBe('After');
219+
emulateBrowserTab(true);
220+
expect(document.activeElement.placeholder).toBe('A');
221+
const a = createEventTarget(document.activeElement);
222+
a.keydown({
223+
key: 'ArrowDown',
224+
});
225+
expect(document.activeElement.placeholder).toBe('B');
226+
emulateBrowserTab();
227+
expect(document.activeElement.placeholder).toBe('After');
228+
emulateBrowserTab(true);
229+
expect(document.activeElement.placeholder).toBe('B');
230+
});
159231
});
160232
});

packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
* @flow
88
*/
99

10-
import {createEventTarget} from 'react-interactions/events/src/dom/testing-library';
10+
import {
11+
createEventTarget,
12+
emulateBrowserTab,
13+
} from 'react-interactions/events/src/dom/testing-library';
1114

1215
let React;
1316
let ReactFeatureFlags;
@@ -29,33 +32,6 @@ describe('FocusTable', () => {
2932
let ReactDOM;
3033
let container;
3134

32-
function emulateBrowserTab(backwards) {
33-
const activeElement = document.activeElement;
34-
const focusedElem = createEventTarget(activeElement);
35-
let defaultPrevented = false;
36-
focusedElem.keydown({
37-
key: 'Tab',
38-
shiftKey: backwards,
39-
preventDefault() {
40-
defaultPrevented = true;
41-
},
42-
});
43-
if (!defaultPrevented) {
44-
// This is not a full spec compliant version, but should be suffice for this test
45-
const focusableElems = Array.from(
46-
document.querySelectorAll(
47-
'input, button, select, textarea, a[href], [tabindex], [contenteditable], iframe, object, embed',
48-
),
49-
).filter(
50-
elem => elem.tabIndex > -1 && !elem.disabled && !elem.contentEditable,
51-
);
52-
const idx = focusableElems.indexOf(activeElement);
53-
if (idx !== -1) {
54-
focusableElems[backwards ? idx - 1 : idx + 1].focus();
55-
}
56-
}
57-
}
58-
5935
beforeEach(() => {
6036
ReactDOM = require('react-dom');
6137
container = document.createElement('div');

packages/react-interactions/events/src/dom/testing-library/index.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,33 @@ function testWithPointerType(message, testFn) {
158158
});
159159
}
160160

161+
function emulateBrowserTab(backwards) {
162+
const activeElement = document.activeElement;
163+
const focusedElem = createEventTarget(activeElement);
164+
let defaultPrevented = false;
165+
focusedElem.keydown({
166+
key: 'Tab',
167+
shiftKey: backwards,
168+
preventDefault() {
169+
defaultPrevented = true;
170+
},
171+
});
172+
if (!defaultPrevented) {
173+
// This is not a full spec compliant version, but should be suffice for this test
174+
const focusableElems = Array.from(
175+
document.querySelectorAll(
176+
'input, button, select, textarea, a[href], [tabindex], [contenteditable], iframe, object, embed',
177+
),
178+
).filter(
179+
elem => elem.tabIndex > -1 && !elem.disabled && !elem.contentEditable,
180+
);
181+
const idx = focusableElems.indexOf(activeElement);
182+
if (idx !== -1) {
183+
focusableElems[backwards ? idx - 1 : idx + 1].focus();
184+
}
185+
}
186+
}
187+
161188
export {
162189
buttonsType,
163190
createEventTarget,
@@ -166,4 +193,5 @@ export {
166193
hasPointerEvent,
167194
setPointerEvent,
168195
testWithPointerType,
196+
emulateBrowserTab,
169197
};

scripts/rollup/bundles.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,7 @@ const bundles = [
718718
'react',
719719
'react-interactions/events/keyboard',
720720
'react-interactions/accessibility/tabbable-scope',
721+
'react-interactions/accessibility/focus-control',
721722
],
722723
},
723724
];

0 commit comments

Comments
 (0)