[react-interactions] Add tab handling to FocusList (#16958)
Dominic Gannaway committed
Sep 30, 2019 at 17:13 UTC
ac8e8b3278c69ddb6267ca0658c49af34cc4e785
7 files changed
+153
-38
packages/react-interactions/accessibility/src/FocusList.js
+45
-9
@@ -12,6 +12,7 @@ import type {KeyboardEvent} from 'react-interactions/events/keyboard';
12
13
import React from 'react';
14
import {useKeyboard} from 'react-interactions/events/keyboard';
15
+import {setElementCanTab} from 'react-interactions/accessibility/focus-control';
16
17
type FocusItemProps = {
18
children?: React.Node,
@@ -22,6 +23,7 @@ type FocusListProps = {|
23
children: React.Node,
24
portrait: boolean,
25
wrap?: boolean,
26
+ tabScope?: ReactScope,
27
|};
28
29
const {useRef} = React;
@@ -41,7 +43,7 @@ function getPreviousListItem(
43
const items = list.getChildren();
44
if (items !== null) {
45
const currentItemIndex = items.indexOf(currentItem);
44
- const wrap = getListWrapProp(currentItem);
46
+ const wrap = getListProps(currentItem).wrap;
47
if (currentItemIndex === 0 && wrap) {
48
return items[items.length - 1] || null;
49
} else if (currentItemIndex > 0) {
@@ -58,7 +60,7 @@ function getNextListItem(
60
const items = list.getChildren();
61
if (items !== null) {
62
const currentItemIndex = items.indexOf(currentItem);
61
- const wrap = getListWrapProp(currentItem);
63
+ const wrap = getListProps(currentItem).wrap;
64
const end = currentItemIndex === items.length - 1;
65
if (end && wrap) {
66
return items[0] || null;
@@ -69,22 +71,38 @@ function getNextListItem(
71
return null;
72
}
73
72
-function getListWrapProp(currentItem: ReactScopeMethods): boolean {
73
- const list = currentItem.getParent();
74
+function getListProps(currentCell: ReactScopeMethods): Object {
75
+ const list = currentCell.getParent();
76
if (list !== null) {
77
const listProps = list.getProps();
76
- return (listProps.type === 'list' && listProps.wrap) || false;
78
+ if (listProps && listProps.type === 'list') {
79
+ return listProps;
80
+ }
81
}
78
- return false;
82
+ return {};
83
}
84
85
export function createFocusList(scope: ReactScope): Array<React.Component> {
86
const TableScope = React.unstable_createScope(scope.fn);
87
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);
95
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
+ )}
106
</TableScope>
107
);
108
}
@@ -100,6 +118,24 @@ export function createFocusList(scope: ReactScope): Array<React.Component> {
118
if (list !== null && listProps.type === 'list') {
119
const portrait = listProps.portrait;
120
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
+ }
139
case 'ArrowUp': {
140
if (portrait) {
141
const previousListItem = getPreviousListItem(
packages/react-interactions/accessibility/src/FocusManager.js
+1
@@ -64,6 +64,7 @@ const FocusManager = React.forwardRef(
64
onBlurWithin: function(event) {
65
if (!containFocus) {
66
event.continuePropagation();
67
+ return;
68
}
69
const lastNode = event.target;
70
if (lastNode) {
packages/react-interactions/accessibility/src/FocusTable.js
+1
@@ -31,6 +31,7 @@ type FocusTableProps = {|
31
focusTableByID: (id: string) => void,
32
) => void,
33
wrap?: boolean,
34
+ tabScope?: ReactScope,
35
|};
36
37
const {useRef} = React;
packages/react-interactions/accessibility/src/__tests__/FocusList-test.internal.js
+73
-1
@@ -7,7 +7,10 @@
7
* @flow
8
*/
9
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';
14
15
let React;
16
let ReactFeatureFlags;
@@ -156,5 +159,74 @@ describe('FocusList', () => {
159
});
160
expect(document.activeElement.textContent).toBe('Item 3');
161
});
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
+ });
231
});
232
});
packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js
+4
-28
@@ -7,7 +7,10 @@
7
* @flow
8
*/
9
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';
14
15
let React;
16
let ReactFeatureFlags;
@@ -29,33 +32,6 @@ describe('FocusTable', () => {
32
let ReactDOM;
33
let container;
34
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
-
35
beforeEach(() => {
36
ReactDOM = require('react-dom');
37
container = document.createElement('div');
packages/react-interactions/events/src/dom/testing-library/index.js
+28
@@ -158,6 +158,33 @@ function testWithPointerType(message, testFn) {
158
});
159
}
160
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
+
188
export {
189
buttonsType,
190
createEventTarget,
@@ -166,4 +193,5 @@ export {
193
hasPointerEvent,
194
setPointerEvent,
195
testWithPointerType,
196
+ emulateBrowserTab,
197
};
scripts/rollup/bundles.js
+1
@@ -718,6 +718,7 @@ const bundles = [
718
'react',
719
'react-interactions/events/keyboard',
720
'react-interactions/accessibility/tabbable-scope',
721
+ 'react-interactions/accessibility/focus-control',
722
],
723
},
724
];