@samitouri / QOS-React-2 / commits / ae724be7be

[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', () => {