[react-interactions] More Tab Focus control handling (#16751)
Dominic Gannaway committed
Sep 11, 2019 at 22:35 UTC
3717c25a7e392fa7a9fceb1890ed354c94e05e70
6 files changed
+285
-125
packages/react-dom/src/client/focus/TabFocusContainer.js
deleted
-81
@@ -1,81 +0,0 @@
1
-/**
2
- * Copyright (c) Facebook, Inc. and its affiliates.
3
- *
4
- * This source code is licensed under the MIT license found in the
5
- * LICENSE file in the root directory of this source tree.
6
- *
7
- * @flow
8
- */
9
-
10
-import React from 'react';
11
-import {TabbableScope} from './TabbableScope';
12
-import {useKeyboard} from 'react-events/keyboard';
13
-
14
-type TabFocusContainerProps = {
15
- children: React.Node,
16
-};
17
-
18
-type KeyboardEventType = 'keydown' | 'keyup';
19
-
20
-type KeyboardEvent = {|
21
- altKey: boolean,
22
- ctrlKey: boolean,
23
- isComposing: boolean,
24
- key: string,
25
- location: number,
26
- metaKey: boolean,
27
- repeat: boolean,
28
- shiftKey: boolean,
29
- target: Element | Document,
30
- type: KeyboardEventType,
31
- timeStamp: number,
32
- defaultPrevented: boolean,
33
-|};
34
-
35
-const {useRef} = React;
36
-
37
-export function TabFocusContainer({
38
- children,
39
-}: TabFocusContainerProps): React.Node {
40
- const scopeRef = useRef(null);
41
- const keyboard = useKeyboard({onKeyDown, preventKeys: ['tab']});
42
-
43
- function onKeyDown(event: KeyboardEvent): boolean {
44
- if (event.key !== 'Tab') {
45
- return true;
46
- }
47
- const tabbableScope = scopeRef.current;
48
- const tabbableNodes = tabbableScope.getScopedNodes();
49
- const currentIndex = tabbableNodes.indexOf(document.activeElement);
50
- const firstTabbableElem = tabbableNodes[0];
51
- const lastTabbableElem = tabbableNodes[tabbableNodes.length - 1];
52
-
53
- // We want to wrap focus back to start/end depending if
54
- // shift is pressed when tabbing.
55
- if (currentIndex === -1) {
56
- firstTabbableElem.focus();
57
- } else {
58
- const focusedElement = tabbableNodes[currentIndex];
59
- if (event.shiftKey) {
60
- if (focusedElement === firstTabbableElem) {
61
- lastTabbableElem.focus();
62
- } else {
63
- tabbableNodes[currentIndex - 1].focus();
64
- }
65
- } else {
66
- if (focusedElement === lastTabbableElem) {
67
- firstTabbableElem.focus();
68
- } else {
69
- tabbableNodes[currentIndex + 1].focus();
70
- }
71
- }
72
- }
73
- return false;
74
- }
75
-
76
- return (
77
- <TabbableScope ref={scopeRef} listeners={keyboard}>
78
- {children}
79
- </TabbableScope>
80
- );
81
-}
packages/react-dom/src/client/focus/TabFocusController.js
new
+179
@@ -0,0 +1,179 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+import React from 'react';
11
+import {TabbableScope} from './TabbableScope';
12
+import {useKeyboard} from 'react-events/keyboard';
13
+
14
+type TabFocusControllerProps = {
15
+ children: React.Node,
16
+ contain?: boolean,
17
+};
18
+
19
+type KeyboardEventType = 'keydown' | 'keyup';
20
+
21
+type KeyboardEvent = {|
22
+ altKey: boolean,
23
+ ctrlKey: boolean,
24
+ isComposing: boolean,
25
+ key: string,
26
+ metaKey: boolean,
27
+ shiftKey: boolean,
28
+ target: Element | Document,
29
+ type: KeyboardEventType,
30
+ timeStamp: number,
31
+ defaultPrevented: boolean,
32
+|};
33
+
34
+type ControllerHandle = {|
35
+ focusFirst: () => void,
36
+ focusNext: () => boolean,
37
+ focusPrevious: () => boolean,
38
+ getNextController: () => null | ControllerHandle,
39
+ getPreviousController: () => null | ControllerHandle,
40
+|};
41
+
42
+const {useImperativeHandle, useRef} = React;
43
+
44
+function getTabbableNodes(scopeRef) {
45
+ const tabbableScope = scopeRef.current;
46
+ const tabbableNodes = tabbableScope.getScopedNodes();
47
+ const firstTabbableElem = tabbableNodes[0];
48
+ const lastTabbableElem = tabbableNodes[tabbableNodes.length - 1];
49
+ const currentIndex = tabbableNodes.indexOf(document.activeElement);
50
+ let focusedElement = null;
51
+ if (currentIndex !== -1) {
52
+ focusedElement = tabbableNodes[currentIndex];
53
+ }
54
+ return [
55
+ tabbableNodes,
56
+ firstTabbableElem,
57
+ lastTabbableElem,
58
+ currentIndex,
59
+ focusedElement,
60
+ ];
61
+}
62
+
63
+export const TabFocusController = React.forwardRef(
64
+ ({children, contain}: TabFocusControllerProps, ref): React.Node => {
65
+ const scopeRef = useRef(null);
66
+ const keyboard = useKeyboard({
67
+ onKeyDown(event: KeyboardEvent): boolean {
68
+ if (event.key !== 'Tab') {
69
+ return true;
70
+ }
71
+ if (event.shiftKey) {
72
+ return focusPrevious();
73
+ } else {
74
+ return focusNext();
75
+ }
76
+ },
77
+ preventKeys: ['Tab', ['Tab', {shiftKey: true}]],
78
+ });
79
+
80
+ function focusFirst(): void {
81
+ const [, firstTabbableElem] = getTabbableNodes(scopeRef);
82
+ firstTabbableElem.focus();
83
+ }
84
+
85
+ function focusNext(): boolean {
86
+ const [
87
+ tabbableNodes,
88
+ firstTabbableElem,
89
+ lastTabbableElem,
90
+ currentIndex,
91
+ focusedElement,
92
+ ] = getTabbableNodes(scopeRef);
93
+
94
+ if (focusedElement === null) {
95
+ firstTabbableElem.focus();
96
+ } else if (focusedElement === lastTabbableElem) {
97
+ if (contain === true) {
98
+ firstTabbableElem.focus();
99
+ } else {
100
+ return true;
101
+ }
102
+ } else {
103
+ tabbableNodes[currentIndex + 1].focus();
104
+ }
105
+ return false;
106
+ }
107
+
108
+ function focusPrevious(): boolean {
109
+ const [
110
+ tabbableNodes,
111
+ firstTabbableElem,
112
+ lastTabbableElem,
113
+ currentIndex,
114
+ focusedElement,
115
+ ] = getTabbableNodes(scopeRef);
116
+
117
+ if (focusedElement === null) {
118
+ firstTabbableElem.focus();
119
+ } else if (focusedElement === firstTabbableElem) {
120
+ if (contain === true) {
121
+ lastTabbableElem.focus();
122
+ } else {
123
+ return true;
124
+ }
125
+ } else {
126
+ tabbableNodes[currentIndex - 1].focus();
127
+ }
128
+ return false;
129
+ }
130
+
131
+ function getPreviousController(): null | ControllerHandle {
132
+ const tabbableScope = scopeRef.current;
133
+ const allScopes = tabbableScope.getChildrenFromRoot();
134
+ if (allScopes === null) {
135
+ return null;
136
+ }
137
+ const currentScopeIndex = allScopes.indexOf(tabbableScope);
138
+ if (currentScopeIndex <= 0) {
139
+ return null;
140
+ }
141
+ return allScopes[currentScopeIndex - 1].getHandle();
142
+ }
143
+
144
+ function getNextController(): null | ControllerHandle {
145
+ const tabbableScope = scopeRef.current;
146
+ const allScopes = tabbableScope.getChildrenFromRoot();
147
+ if (allScopes === null) {
148
+ return null;
149
+ }
150
+ const currentScopeIndex = allScopes.indexOf(tabbableScope);
151
+ if (
152
+ currentScopeIndex === -1 ||
153
+ currentScopeIndex === allScopes.length - 1
154
+ ) {
155
+ return null;
156
+ }
157
+ return allScopes[currentScopeIndex + 1].getHandle();
158
+ }
159
+
160
+ const controllerHandle: ControllerHandle = {
161
+ focusFirst,
162
+ focusNext,
163
+ focusPrevious,
164
+ getNextController,
165
+ getPreviousController,
166
+ };
167
+
168
+ useImperativeHandle(ref, () => controllerHandle);
169
+
170
+ return (
171
+ <TabbableScope
172
+ ref={scopeRef}
173
+ handle={controllerHandle}
174
+ listeners={keyboard}>
175
+ {children}
176
+ </TabbableScope>
177
+ );
178
+ },
179
+);
packages/react-dom/src/client/focus/__tests__/TabFocusController-test.internal.js
renamed
+65
-27
@@ -11,15 +11,15 @@ import {createEventTarget} from 'react-events/src/dom/testing-library';
11
12
let React;
13
let ReactFeatureFlags;
14
-let TabFocusContainer;
14
+let TabFocusController;
15
16
-describe('TabFocusContainer', () => {
16
+describe('TabFocusController', () => {
17
beforeEach(() => {
18
jest.resetModules();
19
ReactFeatureFlags = require('shared/ReactFeatureFlags');
20
ReactFeatureFlags.enableScopeAPI = true;
21
ReactFeatureFlags.enableFlareAPI = true;
22
- TabFocusContainer = require('../TabFocusContainer').TabFocusContainer;
22
+ TabFocusController = require('../TabFocusController').TabFocusController;
23
React = require('react');
24
});
25
@@ -38,7 +38,7 @@ describe('TabFocusContainer', () => {
38
container = null;
39
});
40
41
- it('should work as expected with simple tab operations', () => {
41
+ it('handles tab operations', () => {
42
const inputRef = React.createRef();
43
const input2Ref = React.createRef();
44
const buttonRef = React.createRef();
@@ -46,13 +46,13 @@ describe('TabFocusContainer', () => {
46
const divRef = React.createRef();
47
48
const Test = () => (
49
- <TabFocusContainer>
49
+ <TabFocusController>
50
<input ref={inputRef} />
51
<button ref={buttonRef} />
52
<div ref={divRef} tabIndex={0} />
53
<input ref={input2Ref} tabIndex={-1} />
54
<button ref={butto2nRef} />
55
- </TabFocusContainer>
55
+ </TabFocusController>
56
);
57
58
ReactDOM.render(<Test />, container);
@@ -67,19 +67,19 @@ describe('TabFocusContainer', () => {
67
expect(document.activeElement).toBe(divRef.current);
68
});
69
70
- it('should work as expected with wrapping tab operations', () => {
70
+ it('handles tab operations with containment', () => {
71
const inputRef = React.createRef();
72
const input2Ref = React.createRef();
73
const buttonRef = React.createRef();
74
const button2Ref = React.createRef();
75
76
const Test = () => (
77
- <TabFocusContainer>
77
+ <TabFocusController contain={true}>
78
<input ref={inputRef} tabIndex={-1} />
79
<button ref={buttonRef} id={1} />
80
<button ref={button2Ref} id={2} />
81
<input ref={input2Ref} tabIndex={-1} />
82
- </TabFocusContainer>
82
+ </TabFocusController>
83
);
84
85
ReactDOM.render(<Test />, container);
@@ -96,7 +96,7 @@ describe('TabFocusContainer', () => {
96
expect(document.activeElement).toBe(button2Ref.current);
97
});
98
99
- it('should work as expected when nested', () => {
99
+ it('handles tab operations when controllers are nested', () => {
100
const inputRef = React.createRef();
101
const input2Ref = React.createRef();
102
const buttonRef = React.createRef();
@@ -105,16 +105,16 @@ describe('TabFocusContainer', () => {
105
const button4Ref = React.createRef();
106
107
const Test = () => (
108
- <TabFocusContainer>
108
+ <TabFocusController>
109
<input ref={inputRef} tabIndex={-1} />
110
<button ref={buttonRef} id={1} />
111
- <TabFocusContainer>
111
+ <TabFocusController>
112
<button ref={button2Ref} id={2} />
113
<button ref={button3Ref} id={3} />
114
- </TabFocusContainer>
114
+ </TabFocusController>
115
<input ref={input2Ref} tabIndex={-1} />
116
<button ref={button4Ref} id={4} />
117
- </TabFocusContainer>
117
+ </TabFocusController>
118
);
119
120
ReactDOM.render(<Test />, container);
@@ -125,12 +125,6 @@ describe('TabFocusContainer', () => {
125
createEventTarget(document.activeElement).tabNext();
126
expect(document.activeElement).toBe(button3Ref.current);
127
createEventTarget(document.activeElement).tabNext();
128
- expect(document.activeElement).toBe(button2Ref.current);
129
- // Focus is contained, so have to manually move it out
130
- button4Ref.current.focus();
131
- createEventTarget(document.activeElement).tabNext();
132
- expect(document.activeElement).toBe(buttonRef.current);
133
- createEventTarget(document.activeElement).tabPrevious();
128
expect(document.activeElement).toBe(button4Ref.current);
129
createEventTarget(document.activeElement).tabPrevious();
130
expect(document.activeElement).toBe(button3Ref.current);
@@ -138,7 +132,7 @@ describe('TabFocusContainer', () => {
132
expect(document.activeElement).toBe(button2Ref.current);
133
});
134
141
- it('should work as expected when nested with scope that is contained', () => {
135
+ it('handles tab operations when controllers are nested with containment', () => {
136
const inputRef = React.createRef();
137
const input2Ref = React.createRef();
138
const buttonRef = React.createRef();
@@ -147,16 +141,16 @@ describe('TabFocusContainer', () => {
141
const button4Ref = React.createRef();
142
143
const Test = () => (
150
- <TabFocusContainer>
144
+ <TabFocusController>
145
<input ref={inputRef} tabIndex={-1} />
146
<button ref={buttonRef} id={1} />
153
- <TabFocusContainer>
147
+ <TabFocusController contain={true}>
148
<button ref={button2Ref} id={2} />
149
<button ref={button3Ref} id={3} />
156
- </TabFocusContainer>
150
+ </TabFocusController>
151
<input ref={input2Ref} tabIndex={-1} />
152
<button ref={button4Ref} id={4} />
159
- </TabFocusContainer>
153
+ </TabFocusController>
154
);
155
156
ReactDOM.render(<Test />, container);
@@ -197,14 +191,14 @@ describe('TabFocusContainer', () => {
191
}
192
193
const Test = () => (
200
- <TabFocusContainer>
194
+ <TabFocusController>
195
<button ref={buttonRef} id={1} />
196
<button ref={button2Ref} id={2} />
197
<React.Suspense fallback={<button ref={button3Ref} id={3} />}>
198
<Component />
199
</React.Suspense>
200
<button ref={button4Ref} id={4} />
207
- </TabFocusContainer>
201
+ </TabFocusController>
202
);
203
204
ReactDOM.render(<Test />, container);
@@ -221,5 +215,49 @@ describe('TabFocusContainer', () => {
215
createEventTarget(document.activeElement).tabPrevious();
216
expect(document.activeElement).toBe(button2Ref.current);
217
});
218
+
219
+ it('allows for imperative tab focus control', () => {
220
+ const firstFocusControllerRef = React.createRef();
221
+ const secondFocusControllerRef = React.createRef();
222
+ const buttonRef = React.createRef();
223
+ const button2Ref = React.createRef();
224
+ const divRef = React.createRef();
225
+
226
+ const Test = () => (
227
+ <div>
228
+ <TabFocusController ref={firstFocusControllerRef}>
229
+ <input tabIndex={-1} />
230
+ <button ref={buttonRef} />
231
+ <button ref={button2Ref} />
232
+ <input tabIndex={-1} />
233
+ </TabFocusController>
234
+ <TabFocusController ref={secondFocusControllerRef}>
235
+ <input tabIndex={-1} />
236
+ <div ref={divRef} tabIndex={0} />
237
+ </TabFocusController>
238
+ </div>
239
+ );
240
+
241
+ ReactDOM.render(<Test />, container);
242
+ const firstFocusController = firstFocusControllerRef.current;
243
+ const secondFocusController = secondFocusControllerRef.current;
244
+
245
+ firstFocusController.focusFirst();
246
+ expect(document.activeElement).toBe(buttonRef.current);
247
+ firstFocusController.focusNext();
248
+ expect(document.activeElement).toBe(button2Ref.current);
249
+ firstFocusController.focusPrevious();
250
+ expect(document.activeElement).toBe(buttonRef.current);
251
+
252
+ const nextController = firstFocusController.getNextController();
253
+ expect(nextController).toBe(secondFocusController);
254
+ nextController.focusNext();
255
+ expect(document.activeElement).toBe(divRef.current);
256
+
257
+ const previousController = nextController.getPreviousController();
258
+ expect(previousController).toBe(firstFocusController);
259
+ previousController.focusNext();
260
+ expect(document.activeElement).toBe(buttonRef.current);
261
+ });
262
});
263
});
packages/react-events/src/dom/Keyboard.js
+12
-15
@@ -22,7 +22,7 @@ type KeyboardProps = {
22
disabled?: boolean,
23
onKeyDown?: (e: KeyboardEvent) => ?boolean,
24
onKeyUp?: (e: KeyboardEvent) => ?boolean,
25
- preventKeys?: Array<string>,
25
+ preventKeys?: PreventKeysArray,
26
};
27
28
type KeyboardEvent = {|
@@ -30,9 +30,7 @@ type KeyboardEvent = {|
30
ctrlKey: boolean,
31
isComposing: boolean,
32
key: string,
33
- location: number,
33
metaKey: boolean,
35
- repeat: boolean,
34
shiftKey: boolean,
35
target: Element | Document,
36
type: KeyboardEventType,
@@ -40,6 +38,15 @@ type KeyboardEvent = {|
38
defaultPrevented: boolean,
39
|};
40
41
+type ModifiersObject = {|
42
+ altKey?: boolean,
43
+ ctrlKey?: boolean,
44
+ metaKey?: boolean,
45
+ shiftKey?: boolean,
46
+|};
47
+
48
+type PreventKeysArray = Array<string | Array<string | ModifiersObject>>;
49
+
50
const isArray = Array.isArray;
51
const targetEventTypes = ['keydown_active', 'keyup'];
52
const modifiers = ['altKey', 'ctrlKey', 'metaKey', 'shiftKey'];
@@ -134,15 +141,7 @@ function createKeyboardEvent(
141
defaultPrevented: boolean,
142
): KeyboardEvent {
143
const nativeEvent = (event: any).nativeEvent;
137
- const {
138
- altKey,
139
- ctrlKey,
140
- isComposing,
141
- location,
142
- metaKey,
143
- repeat,
144
- shiftKey,
145
- } = nativeEvent;
144
+ const {altKey, ctrlKey, isComposing, metaKey, shiftKey} = nativeEvent;
145
146
return {
147
altKey,
@@ -150,9 +149,7 @@ function createKeyboardEvent(
149
defaultPrevented,
150
isComposing,
151
key: getEventKey(nativeEvent),
153
- location,
152
metaKey,
155
- repeat,
153
shiftKey,
154
target: event.target,
155
timeStamp: context.getTimeStamp(),
@@ -198,7 +195,7 @@ const keyboardResponderImpl = {
195
}
196
let defaultPrevented = nativeEvent.defaultPrevented === true;
197
if (type === 'keydown') {
201
- const preventKeys = ((props.preventKeys: any): Array<string>);
198
+ const preventKeys = ((props.preventKeys: any): PreventKeysArray);
199
if (!defaultPrevented && isArray(preventKeys)) {
200
preventKeyLoop: for (let i = 0; i < preventKeys.length; i++) {
201
const preventKey = preventKeys[i];
packages/react-reconciler/src/ReactFiberScope.js
+27
-2
@@ -71,7 +71,7 @@ function collectNearestScopeMethods(
71
scope: ReactScope,
72
childrenScopes: Array<ReactScopeMethods>,
73
): void {
74
- if (node.tag === ScopeComponent && node.type === scope) {
74
+ if (isValidScopeNode(node, scope)) {
75
childrenScopes.push(node.stateNode.methods);
76
} else {
77
let child = node.child;
@@ -86,7 +86,7 @@ function collectNearestScopeMethods(
86
}
87
88
function collectNearestChildScopeMethods(
89
- startingChild: Fiber,
89
+ startingChild: Fiber | null,
90
scope: ReactScope,
91
childrenScopes: Array<ReactScopeMethods>,
92
): void {
@@ -97,6 +97,10 @@ function collectNearestChildScopeMethods(
97
}
98
}
99
100
+function isValidScopeNode(node, scope) {
101
+ return node.tag === ScopeComponent && node.type === scope;
102
+}
103
+
104
export function createScopeMethods(
105
scope: ReactScope,
106
instance: ReactScopeInstance,
@@ -112,6 +116,27 @@ export function createScopeMethods(
116
}
117
return childrenScopes.length === 0 ? null : childrenScopes;
118
},
119
+ getChildrenFromRoot(): null | Array<ReactScopeMethods> {
120
+ const currentFiber = ((instance.fiber: any): Fiber);
121
+ let node = currentFiber;
122
+ while (node !== null) {
123
+ const parent = node.return;
124
+ if (parent === null) {
125
+ break;
126
+ }
127
+ node = parent;
128
+ if (node.tag === ScopeComponent && node.type === scope) {
129
+ break;
130
+ }
131
+ }
132
+ const childrenScopes = [];
133
+ collectNearestChildScopeMethods(node.child, scope, childrenScopes);
134
+ return childrenScopes.length === 0 ? null : childrenScopes;
135
+ },
136
+ getHandle(): null | mixed {
137
+ const currentFiber = ((instance.fiber: any): Fiber);
138
+ return currentFiber.memoizedProps.handle || null;
139
+ },
140
getParent(): null | ReactScopeMethods {
141
let node = ((instance.fiber: any): Fiber).return;
142
while (node !== null) {
packages/shared/ReactTypes.js
+2
@@ -166,6 +166,8 @@ export type ReactScope = {|
166
167
export type ReactScopeMethods = {|
168
getChildren(): null | Array<ReactScopeMethods>,
169
+ getChildrenFromRoot(): null | Array<ReactScopeMethods>,
170
+ getHandle(): null | mixed,
171
getParent(): null | ReactScopeMethods,
172
getScopedNodes(): null | Array<Object>,
173
|};