Skip to content

Commit ae724be

Browse files
authored
[react-interactions] Add TabFocusContainer and TabbableScope UI components (#16732)
1 parent ab4951f commit ae724be

File tree

8 files changed

+455
-7
lines changed

8 files changed

+455
-7
lines changed
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
);
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

0 commit comments

Comments
 (0)