[react-interactions] Add TabFocusContainer and TabbableScope UI components (#16732)
Dominic Gannaway committed
Sep 11, 2019 at 12:46 UTC
ae724be7becd26f924b3888959b3e84b78a7e34d
8 files changed
+455
-7
packages/react-dom/src/client/focus/TabFocusContainer.js
new
+81
@@ -0,0 +1,81 @@
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/TabbableScope.js
new
+35
@@ -0,0 +1,35 @@
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
+
12
+export const TabbableScope = React.unstable_createScope(
13
+ (type: string, props: Object): boolean => {
14
+ if (props.tabIndex === -1 || props.disabled) {
15
+ return false;
16
+ }
17
+ if (props.tabIndex === 0 || props.contentEditable === true) {
18
+ return true;
19
+ }
20
+ if (type === 'a' || type === 'area') {
21
+ return !!props.href && props.rel !== 'ignore';
22
+ }
23
+ if (type === 'input') {
24
+ return props.type !== 'hidden' && props.type !== 'file';
25
+ }
26
+ return (
27
+ type === 'button' ||
28
+ type === 'textarea' ||
29
+ type === 'object' ||
30
+ type === 'select' ||
31
+ type === 'iframe' ||
32
+ type === 'embed'
33
+ );
34
+ },
35
+);
packages/react-dom/src/client/focus/__tests__/TabFocusContainer-test.internal.js
new
+225
@@ -0,0 +1,225 @@
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 {createEventTarget} from 'react-events/src/dom/testing-library';
11
+
12
+let React;
13
+let ReactFeatureFlags;
14
+let TabFocusContainer;
15
+
16
+describe('TabFocusContainer', () => {
17
+ beforeEach(() => {
18
+ jest.resetModules();
19
+ ReactFeatureFlags = require('shared/ReactFeatureFlags');
20
+ ReactFeatureFlags.enableScopeAPI = true;
21
+ ReactFeatureFlags.enableFlareAPI = true;
22
+ TabFocusContainer = require('../TabFocusContainer').TabFocusContainer;
23
+ React = require('react');
24
+ });
25
+
26
+ describe('ReactDOM', () => {
27
+ let ReactDOM;
28
+ let container;
29
+
30
+ beforeEach(() => {
31
+ ReactDOM = require('react-dom');
32
+ container = document.createElement('div');
33
+ document.body.appendChild(container);
34
+ });
35
+
36
+ afterEach(() => {
37
+ document.body.removeChild(container);
38
+ container = null;
39
+ });
40
+
41
+ it('should work as expected with simple tab operations', () => {
42
+ const inputRef = React.createRef();
43
+ const input2Ref = React.createRef();
44
+ const buttonRef = React.createRef();
45
+ const butto2nRef = React.createRef();
46
+ const divRef = React.createRef();
47
+
48
+ const Test = () => (
49
+ <TabFocusContainer>
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>
56
+ );
57
+
58
+ ReactDOM.render(<Test />, container);
59
+ inputRef.current.focus();
60
+ createEventTarget(document.activeElement).tabNext();
61
+ expect(document.activeElement).toBe(buttonRef.current);
62
+ createEventTarget(document.activeElement).tabNext();
63
+ expect(document.activeElement).toBe(divRef.current);
64
+ createEventTarget(document.activeElement).tabNext();
65
+ expect(document.activeElement).toBe(butto2nRef.current);
66
+ createEventTarget(document.activeElement).tabPrevious();
67
+ expect(document.activeElement).toBe(divRef.current);
68
+ });
69
+
70
+ it('should work as expected with wrapping tab operations', () => {
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>
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>
83
+ );
84
+
85
+ ReactDOM.render(<Test />, container);
86
+ buttonRef.current.focus();
87
+ createEventTarget(document.activeElement).tabNext();
88
+ expect(document.activeElement).toBe(button2Ref.current);
89
+ createEventTarget(document.activeElement).tabNext();
90
+ expect(document.activeElement).toBe(buttonRef.current);
91
+ createEventTarget(document.activeElement).tabNext();
92
+ expect(document.activeElement).toBe(button2Ref.current);
93
+ createEventTarget(document.activeElement).tabPrevious();
94
+ expect(document.activeElement).toBe(buttonRef.current);
95
+ createEventTarget(document.activeElement).tabPrevious();
96
+ expect(document.activeElement).toBe(button2Ref.current);
97
+ });
98
+
99
+ it('should work as expected when nested', () => {
100
+ const inputRef = React.createRef();
101
+ const input2Ref = React.createRef();
102
+ const buttonRef = React.createRef();
103
+ const button2Ref = React.createRef();
104
+ const button3Ref = React.createRef();
105
+ const button4Ref = React.createRef();
106
+
107
+ const Test = () => (
108
+ <TabFocusContainer>
109
+ <input ref={inputRef} tabIndex={-1} />
110
+ <button ref={buttonRef} id={1} />
111
+ <TabFocusContainer>
112
+ <button ref={button2Ref} id={2} />
113
+ <button ref={button3Ref} id={3} />
114
+ </TabFocusContainer>
115
+ <input ref={input2Ref} tabIndex={-1} />
116
+ <button ref={button4Ref} id={4} />
117
+ </TabFocusContainer>
118
+ );
119
+
120
+ ReactDOM.render(<Test />, container);
121
+ buttonRef.current.focus();
122
+ expect(document.activeElement).toBe(buttonRef.current);
123
+ createEventTarget(document.activeElement).tabNext();
124
+ expect(document.activeElement).toBe(button2Ref.current);
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();
134
+ expect(document.activeElement).toBe(button4Ref.current);
135
+ createEventTarget(document.activeElement).tabPrevious();
136
+ expect(document.activeElement).toBe(button3Ref.current);
137
+ createEventTarget(document.activeElement).tabPrevious();
138
+ expect(document.activeElement).toBe(button2Ref.current);
139
+ });
140
+
141
+ it('should work as expected when nested with scope that is contained', () => {
142
+ const inputRef = React.createRef();
143
+ const input2Ref = React.createRef();
144
+ const buttonRef = React.createRef();
145
+ const button2Ref = React.createRef();
146
+ const button3Ref = React.createRef();
147
+ const button4Ref = React.createRef();
148
+
149
+ const Test = () => (
150
+ <TabFocusContainer>
151
+ <input ref={inputRef} tabIndex={-1} />
152
+ <button ref={buttonRef} id={1} />
153
+ <TabFocusContainer>
154
+ <button ref={button2Ref} id={2} />
155
+ <button ref={button3Ref} id={3} />
156
+ </TabFocusContainer>
157
+ <input ref={input2Ref} tabIndex={-1} />
158
+ <button ref={button4Ref} id={4} />
159
+ </TabFocusContainer>
160
+ );
161
+
162
+ ReactDOM.render(<Test />, container);
163
+ buttonRef.current.focus();
164
+ expect(document.activeElement).toBe(buttonRef.current);
165
+ createEventTarget(document.activeElement).tabNext();
166
+ expect(document.activeElement).toBe(button2Ref.current);
167
+ createEventTarget(document.activeElement).tabNext();
168
+ expect(document.activeElement).toBe(button3Ref.current);
169
+ createEventTarget(document.activeElement).tabNext();
170
+ expect(document.activeElement).toBe(button2Ref.current);
171
+ createEventTarget(document.activeElement).tabPrevious();
172
+ expect(document.activeElement).toBe(button3Ref.current);
173
+ createEventTarget(document.activeElement).tabPrevious();
174
+ expect(document.activeElement).toBe(button2Ref.current);
175
+ });
176
+
177
+ it('should work as expected with suspense fallbacks', () => {
178
+ const buttonRef = React.createRef();
179
+ const button2Ref = React.createRef();
180
+ const button3Ref = React.createRef();
181
+ const button4Ref = React.createRef();
182
+ const button5Ref = React.createRef();
183
+
184
+ function SuspendedComponent() {
185
+ throw new Promise(() => {
186
+ // Never resolve
187
+ });
188
+ }
189
+
190
+ function Component() {
191
+ return (
192
+ <React.Fragment>
193
+ <button ref={button5Ref} id={5} />
194
+ <SuspendedComponent />
195
+ </React.Fragment>
196
+ );
197
+ }
198
+
199
+ const Test = () => (
200
+ <TabFocusContainer>
201
+ <button ref={buttonRef} id={1} />
202
+ <button ref={button2Ref} id={2} />
203
+ <React.Suspense fallback={<button ref={button3Ref} id={3} />}>
204
+ <Component />
205
+ </React.Suspense>
206
+ <button ref={button4Ref} id={4} />
207
+ </TabFocusContainer>
208
+ );
209
+
210
+ ReactDOM.render(<Test />, container);
211
+ buttonRef.current.focus();
212
+ expect(document.activeElement).toBe(buttonRef.current);
213
+ createEventTarget(document.activeElement).tabNext();
214
+ expect(document.activeElement).toBe(button2Ref.current);
215
+ createEventTarget(document.activeElement).tabNext();
216
+ expect(document.activeElement).toBe(button3Ref.current);
217
+ createEventTarget(document.activeElement).tabNext();
218
+ expect(document.activeElement).toBe(button4Ref.current);
219
+ createEventTarget(document.activeElement).tabPrevious();
220
+ expect(document.activeElement).toBe(button3Ref.current);
221
+ createEventTarget(document.activeElement).tabPrevious();
222
+ expect(document.activeElement).toBe(button2Ref.current);
223
+ });
224
+ });
225
+});
packages/react-dom/src/client/focus/__tests__/TabbableScope-test.internal.js
new
+73
@@ -0,0 +1,73 @@
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
+let React;
11
+let ReactFeatureFlags;
12
+let TabbableScope;
13
+
14
+describe('TabbableScope', () => {
15
+ beforeEach(() => {
16
+ jest.resetModules();
17
+ ReactFeatureFlags = require('shared/ReactFeatureFlags');
18
+ ReactFeatureFlags.enableScopeAPI = true;
19
+ TabbableScope = require('../TabbableScope').TabbableScope;
20
+ React = require('react');
21
+ });
22
+
23
+ describe('ReactDOM', () => {
24
+ let ReactDOM;
25
+ let container;
26
+
27
+ beforeEach(() => {
28
+ ReactDOM = require('react-dom');
29
+ container = document.createElement('div');
30
+ document.body.appendChild(container);
31
+ });
32
+
33
+ afterEach(() => {
34
+ document.body.removeChild(container);
35
+ container = null;
36
+ });
37
+
38
+ it('getScopedNodes() works as intended', () => {
39
+ const scopeRef = React.createRef();
40
+ const nodeRefA = React.createRef();
41
+ const nodeRefB = React.createRef();
42
+ const nodeRefC = React.createRef();
43
+ const nodeRefD = React.createRef();
44
+
45
+ function Test() {
46
+ return (
47
+ <TabbableScope ref={scopeRef}>
48
+ <input ref={nodeRefA} />
49
+ <textarea ref={nodeRefB} />
50
+ <div tabIndex={0} ref={nodeRefC}>
51
+ <input tabIndex={-1} />
52
+ <div tabIndex={0} ref={nodeRefD} />
53
+ </div>
54
+ <input disabled={true} />
55
+ <div tabIndex={-1} />
56
+ </TabbableScope>
57
+ );
58
+ }
59
+
60
+ ReactDOM.render(<Test />, container);
61
+ let nodes = scopeRef.current.getScopedNodes();
62
+ expect(nodes).toEqual([
63
+ nodeRefA.current,
64
+ nodeRefB.current,
65
+ nodeRefC.current,
66
+ nodeRefD.current,
67
+ ]);
68
+ expect(nodes.length).toBe(4);
69
+ ReactDOM.render(null, container);
70
+ expect(scopeRef.current).toBe(null);
71
+ });
72
+ });
73
+});
packages/react-events/src/dom/Keyboard.js
+7
-7
@@ -19,10 +19,10 @@ import type {ReactEventResponderListener} from 'shared/ReactTypes';
19
type KeyboardEventType = 'keydown' | 'keyup';
20
21
type KeyboardProps = {
22
- disabled: boolean,
23
- onKeyDown: (e: KeyboardEvent) => ?boolean,
24
- onKeyUp: (e: KeyboardEvent) => ?boolean,
25
- preventKeys: Array<string>,
22
+ disabled?: boolean,
23
+ onKeyDown?: (e: KeyboardEvent) => ?boolean,
24
+ onKeyUp?: (e: KeyboardEvent) => ?boolean,
25
+ preventKeys?: Array<string>,
26
};
27
28
type KeyboardEvent = {|
@@ -198,7 +198,7 @@ const keyboardResponderImpl = {
198
}
199
let defaultPrevented = nativeEvent.defaultPrevented === true;
200
if (type === 'keydown') {
201
- const preventKeys = props.preventKeys;
201
+ const preventKeys = ((props.preventKeys: any): Array<string>);
202
if (!defaultPrevented && isArray(preventKeys)) {
203
preventKeyLoop: for (let i = 0; i < preventKeys.length; i++) {
204
const preventKey = preventKeys[i];
@@ -230,7 +230,7 @@ const keyboardResponderImpl = {
230
if (isFunction(onKeyDown)) {
231
dispatchKeyboardEvent(
232
event,
233
- onKeyDown,
233
+ ((onKeyDown: any): (e: KeyboardEvent) => ?boolean),
234
context,
235
'keydown',
236
defaultPrevented,
@@ -241,7 +241,7 @@ const keyboardResponderImpl = {
241
if (isFunction(onKeyUp)) {
242
dispatchKeyboardEvent(
243
event,
244
- onKeyUp,
244
+ ((onKeyUp: any): (e: KeyboardEvent) => ?boolean),
245
context,
246
'keyup',
247
defaultPrevented,
packages/react-events/src/dom/testing-library/index.js
+26
@@ -50,6 +50,32 @@ const createEventTarget = node => ({
50
virtualclick(payload) {
51
node.dispatchEvent(domEvents.virtualclick(payload));
52
},
53
+ tabNext() {
54
+ node.dispatchEvent(
55
+ domEvents.keydown({
56
+ key: 'Tab',
57
+ }),
58
+ );
59
+ node.dispatchEvent(
60
+ domEvents.keyup({
61
+ key: 'Tab',
62
+ }),
63
+ );
64
+ },
65
+ tabPrevious() {
66
+ node.dispatchEvent(
67
+ domEvents.keydown({
68
+ key: 'Tab',
69
+ shiftKey: true,
70
+ }),
71
+ );
72
+ node.dispatchEvent(
73
+ domEvents.keyup({
74
+ key: 'Tab',
75
+ shiftKey: true,
76
+ }),
77
+ );
78
+ },
79
/**
80
* PointerEvent abstraction.
81
* Dispatches the expected sequence of PointerEvents, MouseEvents, and
packages/react-reconciler/src/ReactFiberCommitWork.js
+6
@@ -843,6 +843,12 @@ function commitUnmount(
843
}
844
}
845
}
846
+ return;
847
+ }
848
+ case ScopeComponent: {
849
+ if (enableScopeAPI) {
850
+ safelyDetachRef(current);
851
+ }
852
}
853
}
854
}
packages/react-reconciler/src/__tests__/ReactScope-test.internal.js
+2
@@ -67,6 +67,8 @@ describe('ReactScope', () => {
67
ReactDOM.render(<Test toggle={false} />, container);
68
nodes = scopeRef.current.getScopedNodes();
69
expect(nodes).toEqual([aRef.current, divRef.current, spanRef.current]);
70
+ ReactDOM.render(null, container);
71
+ expect(scopeRef.current).toBe(null);
72
});
73
74
it('mixed getParent() and getScopedNodes() works as intended', () => {