@samitouri / QOS-React-2 / commits / 3717c25a7e

[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 |};