[react-interactions] TabFocus -> FocusManager (#16874)
Dominic Gannaway committed
Sep 24, 2019 at 23:26 UTC
ebc299fc2f21a74fffa3f8aa2209259d98702156
6 files changed
+242
-111
packages/react-interactions/accessibility/focus-manager.js
renamed
+1
-1
@@ -9,4 +9,4 @@
9
10
'use strict';
11
12
-module.exports = require('./src/TabFocus');
12
+module.exports = require('./src/FocusManager');
packages/react-interactions/accessibility/src/FocusManager.js
new
+114
@@ -0,0 +1,114 @@
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 type {ReactScope} from 'shared/ReactTypes';
11
+import type {KeyboardEvent} from 'react-interactions/events/keyboard';
12
+
13
+import React from 'react';
14
+import {useKeyboard} from 'react-interactions/events/keyboard';
15
+import {useFocusWithin} from 'react-interactions/events/focus';
16
+import {
17
+ focusFirst,
18
+ focusPrevious,
19
+ focusNext,
20
+} from 'react-interactions/accessibility/focus-control';
21
+import TabbableScope from 'react-interactions/accessibility/tabbable-scope';
22
+
23
+type TabFocusProps = {|
24
+ autoFocus?: boolean,
25
+ children: React.Node,
26
+ containFocus?: boolean,
27
+ restoreFocus?: boolean,
28
+ scope: ReactScope,
29
+|};
30
+
31
+const {useLayoutEffect, useRef} = React;
32
+
33
+const FocusManager = React.forwardRef(
34
+ (
35
+ {
36
+ autoFocus,
37
+ children,
38
+ containFocus,
39
+ restoreFocus,
40
+ scope: CustomScope,
41
+ }: TabFocusProps,
42
+ ref,
43
+ ): React.Node => {
44
+ const ScopeToUse = CustomScope || TabbableScope;
45
+ const scopeRef = useRef(null);
46
+ // This ensures tabbing works through the React tree (including Portals and Suspense nodes)
47
+ const keyboard = useKeyboard({
48
+ onKeyDown(event: KeyboardEvent): void {
49
+ if (event.key !== 'Tab') {
50
+ event.continuePropagation();
51
+ return;
52
+ }
53
+ const scope = scopeRef.current;
54
+ if (scope !== null) {
55
+ if (event.shiftKey) {
56
+ focusPrevious(scope, event, containFocus);
57
+ } else {
58
+ focusNext(scope, event, containFocus);
59
+ }
60
+ }
61
+ },
62
+ });
63
+ const focusWithin = useFocusWithin({
64
+ onBlurWithin: function(event) {
65
+ if (!containFocus) {
66
+ event.continuePropagation();
67
+ }
68
+ const lastNode = event.target;
69
+ if (lastNode) {
70
+ requestAnimationFrame(() => {
71
+ (lastNode: any).focus();
72
+ });
73
+ }
74
+ },
75
+ });
76
+ useLayoutEffect(
77
+ () => {
78
+ const scope = scopeRef.current;
79
+ let restoreElem;
80
+ if (restoreFocus) {
81
+ restoreElem = document.activeElement;
82
+ }
83
+ if (autoFocus && scope !== null) {
84
+ focusFirst(scope);
85
+ }
86
+ if (restoreElem) {
87
+ return () => {
88
+ (restoreElem: any).focus();
89
+ };
90
+ }
91
+ },
92
+ [scopeRef],
93
+ );
94
+
95
+ return (
96
+ <ScopeToUse
97
+ ref={node => {
98
+ if (ref) {
99
+ if (typeof ref === 'function') {
100
+ ref(node);
101
+ } else {
102
+ ref.current = node;
103
+ }
104
+ }
105
+ scopeRef.current = node;
106
+ }}
107
+ listeners={[keyboard, focusWithin]}>
108
+ {children}
109
+ </ScopeToUse>
110
+ );
111
+ },
112
+);
113
+
114
+export default FocusManager;
packages/react-interactions/accessibility/src/TabFocus.js
deleted
-67
@@ -1,67 +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 type {ReactScope} from 'shared/ReactTypes';
11
-import type {KeyboardEvent} from 'react-interactions/events/keyboard';
12
-
13
-import React from 'react';
14
-import {useKeyboard} from 'react-interactions/events/keyboard';
15
-import {
16
- focusPrevious,
17
- focusNext,
18
-} from 'react-interactions/accessibility/focus-control';
19
-
20
-type TabFocusProps = {
21
- children: React.Node,
22
- contain?: boolean,
23
- scope: ReactScope,
24
-};
25
-
26
-const {useRef} = React;
27
-
28
-const TabFocus = React.forwardRef(
29
- ({children, contain, scope: Scope}: TabFocusProps, ref): React.Node => {
30
- const scopeRef = useRef(null);
31
- const keyboard = useKeyboard({
32
- onKeyDown(event: KeyboardEvent): void {
33
- if (event.key !== 'Tab') {
34
- event.continuePropagation();
35
- return;
36
- }
37
- const scope = scopeRef.current;
38
- if (scope !== null) {
39
- if (event.shiftKey) {
40
- focusPrevious(scope, event, contain);
41
- } else {
42
- focusNext(scope, event, contain);
43
- }
44
- }
45
- },
46
- });
47
-
48
- return (
49
- <Scope
50
- ref={node => {
51
- if (ref) {
52
- if (typeof ref === 'function') {
53
- ref(node);
54
- } else {
55
- ref.current = node;
56
- }
57
- }
58
- scopeRef.current = node;
59
- }}
60
- listeners={keyboard}>
61
- {children}
62
- </Scope>
63
- );
64
- },
65
-);
66
-
67
-export default TabFocus;
packages/react-interactions/accessibility/src/__tests__/FocusManager-test.internal.js
renamed
+83
-35
@@ -11,18 +11,16 @@ import {createEventTarget} from 'react-interactions/events/src/dom/testing-libra
11
12
let React;
13
let ReactFeatureFlags;
14
-let TabFocus;
15
-let TabbableScope;
14
+let FocusManager;
15
let FocusControl;
16
18
-describe('TabFocusController', () => {
17
+describe('FocusManager', () => {
18
beforeEach(() => {
19
jest.resetModules();
20
ReactFeatureFlags = require('shared/ReactFeatureFlags');
21
ReactFeatureFlags.enableScopeAPI = true;
22
ReactFeatureFlags.enableFlareAPI = true;
24
- TabFocus = require('../TabFocus').default;
25
- TabbableScope = require('../TabbableScope').default;
23
+ FocusManager = require('../FocusManager').default;
24
FocusControl = require('../FocusControl');
25
React = require('react');
26
});
@@ -42,21 +40,21 @@ describe('TabFocusController', () => {
40
container = null;
41
});
42
45
- it('handles tab operations', () => {
43
+ it('handles tab operations by default', () => {
44
const inputRef = React.createRef();
45
const input2Ref = React.createRef();
46
const buttonRef = React.createRef();
49
- const butto2nRef = React.createRef();
47
+ const button2Ref = React.createRef();
48
const divRef = React.createRef();
49
50
const Test = () => (
53
- <TabFocus scope={TabbableScope}>
51
+ <FocusManager>
52
<input ref={inputRef} />
53
<button ref={buttonRef} />
54
<div ref={divRef} tabIndex={0} />
55
<input ref={input2Ref} tabIndex={-1} />
58
- <button ref={butto2nRef} />
59
- </TabFocus>
56
+ <button ref={button2Ref} />
57
+ </FocusManager>
58
);
59
60
ReactDOM.render(<Test />, container);
@@ -66,24 +64,67 @@ describe('TabFocusController', () => {
64
createEventTarget(document.activeElement).tabNext();
65
expect(document.activeElement).toBe(divRef.current);
66
createEventTarget(document.activeElement).tabNext();
69
- expect(document.activeElement).toBe(butto2nRef.current);
67
+ expect(document.activeElement).toBe(button2Ref.current);
68
createEventTarget(document.activeElement).tabPrevious();
69
expect(document.activeElement).toBe(divRef.current);
70
});
71
74
- it('handles tab operations with containment', () => {
72
+ it('handles autoFocus', () => {
73
+ const buttonRef = React.createRef();
74
+
75
+ const Test = () => (
76
+ <FocusManager autoFocus={true}>
77
+ <input tabIndex={-1} />
78
+ <button ref={buttonRef} />
79
+ </FocusManager>
80
+ );
81
+
82
+ ReactDOM.render(<Test />, container);
83
+ expect(document.activeElement).toBe(buttonRef.current);
84
+ });
85
+
86
+ it('handles restoreFocus', () => {
87
+ const difRef = React.createRef();
88
+ const buttonRef = React.createRef();
89
+
90
+ const Test = ({flag}) => {
91
+ return (
92
+ <div ref={difRef} tabIndex={0}>
93
+ {flag ? (
94
+ <FocusManager autoFocus={true} restoreFocus={true}>
95
+ <button ref={buttonRef} />
96
+ </FocusManager>
97
+ ) : null}
98
+ </div>
99
+ );
100
+ };
101
+
102
+ ReactDOM.render(<Test flag={false} />, container);
103
+ difRef.current.focus();
104
+ expect(document.activeElement).toBe(difRef.current);
105
+ ReactDOM.render(<Test flag={true} />, container);
106
+ expect(document.activeElement).toBe(buttonRef.current);
107
+ ReactDOM.render(<Test flag={false} />, container);
108
+ expect(document.activeElement).toBe(difRef.current);
109
+ });
110
+
111
+ it('handles containFocus', () => {
112
const inputRef = React.createRef();
113
const input2Ref = React.createRef();
114
+ const input3Ref = React.createRef();
115
const buttonRef = React.createRef();
116
const button2Ref = React.createRef();
117
118
const Test = () => (
81
- <TabFocus scope={TabbableScope} contain={true}>
82
- <input ref={inputRef} tabIndex={-1} />
83
- <button ref={buttonRef} id={1} />
84
- <button ref={button2Ref} id={2} />
85
- <input ref={input2Ref} tabIndex={-1} />
86
- </TabFocus>
119
+ <div>
120
+ <FocusManager containFocus={true}>
121
+ <input ref={inputRef} tabIndex={-1} />
122
+ <button ref={buttonRef} id={1} />
123
+ <button ref={button2Ref} id={2} />
124
+ <input ref={input2Ref} tabIndex={-1} />
125
+ </FocusManager>
126
+ <input ref={input3Ref} />
127
+ </div>
128
);
129
130
ReactDOM.render(<Test />, container);
@@ -98,9 +139,16 @@ describe('TabFocusController', () => {
139
expect(document.activeElement).toBe(buttonRef.current);
140
createEventTarget(document.activeElement).tabPrevious();
141
expect(document.activeElement).toBe(button2Ref.current);
142
+ // Focus should be restored to the contained area
143
+ const rAF = window.requestAnimationFrame;
144
+ window.requestAnimationFrame = x => setTimeout(x);
145
+ input3Ref.current.focus();
146
+ jest.advanceTimersByTime(1);
147
+ window.requestAnimationFrame = rAF;
148
+ expect(document.activeElement).toBe(button2Ref.current);
149
});
150
103
- it('handles tab operations when controllers are nested', () => {
151
+ it('works with nested FocusManagers', () => {
152
const inputRef = React.createRef();
153
const input2Ref = React.createRef();
154
const buttonRef = React.createRef();
@@ -109,16 +157,16 @@ describe('TabFocusController', () => {
157
const button4Ref = React.createRef();
158
159
const Test = () => (
112
- <TabFocus scope={TabbableScope}>
160
+ <FocusManager>
161
<input ref={inputRef} tabIndex={-1} />
162
<button ref={buttonRef} id={1} />
115
- <TabFocus scope={TabbableScope}>
163
+ <FocusManager>
164
<button ref={button2Ref} id={2} />
165
<button ref={button3Ref} id={3} />
118
- </TabFocus>
166
+ </FocusManager>
167
<input ref={input2Ref} tabIndex={-1} />
168
<button ref={button4Ref} id={4} />
121
- </TabFocus>
169
+ </FocusManager>
170
);
171
172
ReactDOM.render(<Test />, container);
@@ -136,7 +184,7 @@ describe('TabFocusController', () => {
184
expect(document.activeElement).toBe(button2Ref.current);
185
});
186
139
- it('handles tab operations when controllers are nested with containment', () => {
187
+ it('handles containFocus (nested FocusManagers)', () => {
188
const inputRef = React.createRef();
189
const input2Ref = React.createRef();
190
const buttonRef = React.createRef();
@@ -145,16 +193,16 @@ describe('TabFocusController', () => {
193
const button4Ref = React.createRef();
194
195
const Test = () => (
148
- <TabFocus scope={TabbableScope}>
196
+ <FocusManager>
197
<input ref={inputRef} tabIndex={-1} />
198
<button ref={buttonRef} id={1} />
151
- <TabFocus contain={true} scope={TabbableScope}>
199
+ <FocusManager containFocus={true}>
200
<button ref={button2Ref} id={2} />
201
<button ref={button3Ref} id={3} />
154
- </TabFocus>
202
+ </FocusManager>
203
<input ref={input2Ref} tabIndex={-1} />
204
<button ref={button4Ref} id={4} />
157
- </TabFocus>
205
+ </FocusManager>
206
);
207
208
ReactDOM.render(<Test />, container);
@@ -195,14 +243,14 @@ describe('TabFocusController', () => {
243
}
244
245
const Test = () => (
198
- <TabFocus scope={TabbableScope}>
246
+ <FocusManager>
247
<button ref={buttonRef} id={1} />
248
<button ref={button2Ref} id={2} />
249
<React.Suspense fallback={<button ref={button3Ref} id={3} />}>
250
<Component />
251
</React.Suspense>
252
<button ref={button4Ref} id={4} />
205
- </TabFocus>
253
+ </FocusManager>
254
);
255
256
ReactDOM.render(<Test />, container);
@@ -220,7 +268,7 @@ describe('TabFocusController', () => {
268
expect(document.activeElement).toBe(button2Ref.current);
269
});
270
223
- it('allows for imperative tab focus control', () => {
271
+ it('allows for imperative tab focus control using FocusControl', () => {
272
const firstFocusControllerRef = React.createRef();
273
const secondFocusControllerRef = React.createRef();
274
const buttonRef = React.createRef();
@@ -229,16 +277,16 @@ describe('TabFocusController', () => {
277
278
const Test = () => (
279
<div>
232
- <TabFocus ref={firstFocusControllerRef} scope={TabbableScope}>
280
+ <FocusManager ref={firstFocusControllerRef}>
281
<input tabIndex={-1} />
282
<button ref={buttonRef} />
283
<button ref={button2Ref} />
284
<input tabIndex={-1} />
237
- </TabFocus>
238
- <TabFocus ref={secondFocusControllerRef} scope={TabbableScope}>
285
+ </FocusManager>
286
+ <FocusManager ref={secondFocusControllerRef}>
287
<input tabIndex={-1} />
288
<div ref={divRef} tabIndex={0} />
241
- </TabFocus>
289
+ </FocusManager>
290
</div>
291
);
292
packages/react-interactions/events/src/dom/Focus.js
+40
-6
@@ -26,6 +26,7 @@ type FocusEvent = {|
26
type: FocusEventType | FocusWithinEventType,
27
pointerType: PointerType,
28
timeStamp: number,
29
+ continuePropagation: () => void,
30
|};
31
32
type FocusState = {
@@ -47,12 +48,16 @@ type FocusProps = {
48
type FocusEventType = 'focus' | 'blur' | 'focuschange' | 'focusvisiblechange';
49
50
type FocusWithinProps = {
50
- disabled: boolean,
51
- onFocusWithinChange: boolean => void,
52
- onFocusWithinVisibleChange: boolean => void,
51
+ disabled?: boolean,
52
+ onBlurWithin?: (e: FocusEvent) => void,
53
+ onFocusWithinChange?: boolean => void,
54
+ onFocusWithinVisibleChange?: boolean => void,
55
};
56
55
-type FocusWithinEventType = 'focuswithinvisiblechange' | 'focuswithinchange';
57
+type FocusWithinEventType =
58
+ | 'focuswithinvisiblechange'
59
+ | 'focuswithinchange'
60
+ | 'blurwithin';
61
62
/**
63
* Shared between Focus and FocusWithin
@@ -89,6 +94,14 @@ function createFocusEvent(
94
type,
95
pointerType,
96
timeStamp: context.getTimeStamp(),
97
+ // We don't use stopPropagation, as the default behavior
98
+ // is to not propagate. Plus, there might be confusion
99
+ // using stopPropagation as we don't actually stop
100
+ // native propagation from working, but instead only
101
+ // allow propagation to the others keyboard responders.
102
+ continuePropagation() {
103
+ context.continuePropagation();
104
+ },
105
};
106
}
107
@@ -226,6 +239,26 @@ function dispatchBlurEvents(
239
}
240
}
241
242
+function dispatchBlurWithinEvents(
243
+ context: ReactDOMResponderContext,
244
+ event: ReactDOMResponderEvent,
245
+ props: FocusWithinProps,
246
+ state: FocusState,
247
+) {
248
+ const pointerType = state.pointerType;
249
+ const target = ((state.focusTarget: any): Element | Document) || event.target;
250
+ const onBlurWithin = (props.onBlurWithin: any);
251
+ if (isFunction(onBlurWithin)) {
252
+ const syntheticEvent = createFocusEvent(
253
+ context,
254
+ 'blurwithin',
255
+ target,
256
+ pointerType,
257
+ );
258
+ context.dispatchEvent(syntheticEvent, onBlurWithin, DiscreteEvent);
259
+ }
260
+}
261
+
262
function dispatchFocusChange(
263
context: ReactDOMResponderContext,
264
props: FocusProps,
@@ -364,7 +397,7 @@ function dispatchFocusWithinChangeEvent(
397
state: FocusState,
398
value: boolean,
399
) {
367
- const onFocusWithinChange = props.onFocusWithinChange;
400
+ const onFocusWithinChange = (props.onFocusWithinChange: any);
401
if (isFunction(onFocusWithinChange)) {
402
context.dispatchEvent(value, onFocusWithinChange, DiscreteEvent);
403
}
@@ -379,7 +412,7 @@ function dispatchFocusWithinVisibleChangeEvent(
412
state: FocusState,
413
value: boolean,
414
) {
382
- const onFocusWithinVisibleChange = props.onFocusWithinVisibleChange;
415
+ const onFocusWithinVisibleChange = (props.onFocusWithinVisibleChange: any);
416
if (isFunction(onFocusWithinVisibleChange)) {
417
context.dispatchEvent(value, onFocusWithinVisibleChange, DiscreteEvent);
418
}
@@ -447,6 +480,7 @@ const focusWithinResponderImpl = {
480
!context.isTargetWithinResponder(relatedTarget)
481
) {
482
dispatchFocusWithinChangeEvent(context, props, state, false);
483
+ dispatchBlurWithinEvents(context, event, props, state);
484
state.isFocused = false;
485
}
486
break;
scripts/rollup/bundles.js
+4
-2
@@ -681,11 +681,12 @@ const bundles = [
681
{
682
bundleTypes: [NODE_DEV, NODE_PROD, FB_WWW_DEV, FB_WWW_PROD],
683
moduleType: NON_FIBER_RENDERER,
684
- entry: 'react-interactions/accessibility/tab-focus',
685
- global: 'ReactTabFocus',
684
+ entry: 'react-interactions/accessibility/focus-manager',
685
+ global: 'ReactFocusManager',
686
externals: [
687
'react',
688
'react-interactions/events/keyboard',
689
+ 'react-interactions/events/focus',
690
'react-interactions/accessibility/tabbable-scope',
691
'react-interactions/accessibility/focus-control',
692
],
@@ -721,6 +722,7 @@ const bundles = [
722
];
723
724
const fbBundleExternalsMap = {
725
+ 'react-interactions/events/focus': 'ReactEventsFocus',
726
'react-interactions/events/keyboard': 'ReactEventsKeyboard',
727
'react-interactions/events/tap': 'ReactEventsTap',
728
'react-interactions/accessibility/tabbable-scope': 'ReactTabbableScope',