[react-interactions] Add TabFocusContainer and TabbableScope UI components (#16732)

This commit is contained in:
Dominic Gannaway
2019-09-11 12:46:41 +02:00
committed by GitHub
parent ab4951fc03
commit ae724be7be
8 changed files with 455 additions and 7 deletions
@@ -0,0 +1,81 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import React from 'react';
import {TabbableScope} from './TabbableScope';
import {useKeyboard} from 'react-events/keyboard';
type TabFocusContainerProps = {
children: React.Node,
};
type KeyboardEventType = 'keydown' | 'keyup';
type KeyboardEvent = {|
altKey: boolean,
ctrlKey: boolean,
isComposing: boolean,
key: string,
location: number,
metaKey: boolean,
repeat: boolean,
shiftKey: boolean,
target: Element | Document,
type: KeyboardEventType,
timeStamp: number,
defaultPrevented: boolean,
|};
const {useRef} = React;
export function TabFocusContainer({
children,
}: TabFocusContainerProps): React.Node {
const scopeRef = useRef(null);
const keyboard = useKeyboard({onKeyDown, preventKeys: ['tab']});
function onKeyDown(event: KeyboardEvent): boolean {
if (event.key !== 'Tab') {
return true;
}
const tabbableScope = scopeRef.current;
const tabbableNodes = tabbableScope.getScopedNodes();
const currentIndex = tabbableNodes.indexOf(document.activeElement);
const firstTabbableElem = tabbableNodes[0];
const lastTabbableElem = tabbableNodes[tabbableNodes.length - 1];
// We want to wrap focus back to start/end depending if
// shift is pressed when tabbing.
if (currentIndex === -1) {
firstTabbableElem.focus();
} else {
const focusedElement = tabbableNodes[currentIndex];
if (event.shiftKey) {
if (focusedElement === firstTabbableElem) {
lastTabbableElem.focus();
} else {
tabbableNodes[currentIndex - 1].focus();
}
} else {
if (focusedElement === lastTabbableElem) {
firstTabbableElem.focus();
} else {
tabbableNodes[currentIndex + 1].focus();
}
}
}
return false;
}
return (
<TabbableScope ref={scopeRef} listeners={keyboard}>
{children}
</TabbableScope>
);
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import React from 'react';
export const TabbableScope = React.unstable_createScope(
(type: string, props: Object): boolean => {
if (props.tabIndex === -1 || props.disabled) {
return false;
}
if (props.tabIndex === 0 || props.contentEditable === true) {
return true;
}
if (type === 'a' || type === 'area') {
return !!props.href && props.rel !== 'ignore';
}
if (type === 'input') {
return props.type !== 'hidden' && props.type !== 'file';
}
return (
type === 'button' ||
type === 'textarea' ||
type === 'object' ||
type === 'select' ||
type === 'iframe' ||
type === 'embed'
);
},
);
@@ -0,0 +1,225 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import {createEventTarget} from 'react-events/src/dom/testing-library';
let React;
let ReactFeatureFlags;
let TabFocusContainer;
describe('TabFocusContainer', () => {
beforeEach(() => {
jest.resetModules();
ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.enableScopeAPI = true;
ReactFeatureFlags.enableFlareAPI = true;
TabFocusContainer = require('../TabFocusContainer').TabFocusContainer;
React = require('react');
});
describe('ReactDOM', () => {
let ReactDOM;
let container;
beforeEach(() => {
ReactDOM = require('react-dom');
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
container = null;
});
it('should work as expected with simple tab operations', () => {
const inputRef = React.createRef();
const input2Ref = React.createRef();
const buttonRef = React.createRef();
const butto2nRef = React.createRef();
const divRef = React.createRef();
const Test = () => (
<TabFocusContainer>
<input ref={inputRef} />
<button ref={buttonRef} />
<div ref={divRef} tabIndex={0} />
<input ref={input2Ref} tabIndex={-1} />
<button ref={butto2nRef} />
</TabFocusContainer>
);
ReactDOM.render(<Test />, container);
inputRef.current.focus();
createEventTarget(document.activeElement).tabNext();
expect(document.activeElement).toBe(buttonRef.current);
createEventTarget(document.activeElement).tabNext();
expect(document.activeElement).toBe(divRef.current);
createEventTarget(document.activeElement).tabNext();
expect(document.activeElement).toBe(butto2nRef.current);
createEventTarget(document.activeElement).tabPrevious();
expect(document.activeElement).toBe(divRef.current);
});
it('should work as expected with wrapping tab operations', () => {
const inputRef = React.createRef();
const input2Ref = React.createRef();
const buttonRef = React.createRef();
const button2Ref = React.createRef();
const Test = () => (
<TabFocusContainer>
<input ref={inputRef} tabIndex={-1} />
<button ref={buttonRef} id={1} />
<button ref={button2Ref} id={2} />
<input ref={input2Ref} tabIndex={-1} />
</TabFocusContainer>
);
ReactDOM.render(<Test />, container);
buttonRef.current.focus();
createEventTarget(document.activeElement).tabNext();
expect(document.activeElement).toBe(button2Ref.current);
createEventTarget(document.activeElement).tabNext();
expect(document.activeElement).toBe(buttonRef.current);
createEventTarget(document.activeElement).tabNext();
expect(document.activeElement).toBe(button2Ref.current);
createEventTarget(document.activeElement).tabPrevious();
expect(document.activeElement).toBe(buttonRef.current);
createEventTarget(document.activeElement).tabPrevious();
expect(document.activeElement).toBe(button2Ref.current);
});
it('should work as expected when nested', () => {
const inputRef = React.createRef();
const input2Ref = React.createRef();
const buttonRef = React.createRef();
const button2Ref = React.createRef();
const button3Ref = React.createRef();
const button4Ref = React.createRef();
const Test = () => (
<TabFocusContainer>
<input ref={inputRef} tabIndex={-1} />
<button ref={buttonRef} id={1} />
<TabFocusContainer>
<button ref={button2Ref} id={2} />
<button ref={button3Ref} id={3} />
</TabFocusContainer>
<input ref={input2Ref} tabIndex={-1} />
<button ref={button4Ref} id={4} />
</TabFocusContainer>
);
ReactDOM.render(<Test />, container);
buttonRef.current.focus();
expect(document.activeElement).toBe(buttonRef.current);
createEventTarget(document.activeElement).tabNext();
expect(document.activeElement).toBe(button2Ref.current);
createEventTarget(document.activeElement).tabNext();
expect(document.activeElement).toBe(button3Ref.current);
createEventTarget(document.activeElement).tabNext();
expect(document.activeElement).toBe(button2Ref.current);
// Focus is contained, so have to manually move it out
button4Ref.current.focus();
createEventTarget(document.activeElement).tabNext();
expect(document.activeElement).toBe(buttonRef.current);
createEventTarget(document.activeElement).tabPrevious();
expect(document.activeElement).toBe(button4Ref.current);
createEventTarget(document.activeElement).tabPrevious();
expect(document.activeElement).toBe(button3Ref.current);
createEventTarget(document.activeElement).tabPrevious();
expect(document.activeElement).toBe(button2Ref.current);
});
it('should work as expected when nested with scope that is contained', () => {
const inputRef = React.createRef();
const input2Ref = React.createRef();
const buttonRef = React.createRef();
const button2Ref = React.createRef();
const button3Ref = React.createRef();
const button4Ref = React.createRef();
const Test = () => (
<TabFocusContainer>
<input ref={inputRef} tabIndex={-1} />
<button ref={buttonRef} id={1} />
<TabFocusContainer>
<button ref={button2Ref} id={2} />
<button ref={button3Ref} id={3} />
</TabFocusContainer>
<input ref={input2Ref} tabIndex={-1} />
<button ref={button4Ref} id={4} />
</TabFocusContainer>
);
ReactDOM.render(<Test />, container);
buttonRef.current.focus();
expect(document.activeElement).toBe(buttonRef.current);
createEventTarget(document.activeElement).tabNext();
expect(document.activeElement).toBe(button2Ref.current);
createEventTarget(document.activeElement).tabNext();
expect(document.activeElement).toBe(button3Ref.current);
createEventTarget(document.activeElement).tabNext();
expect(document.activeElement).toBe(button2Ref.current);
createEventTarget(document.activeElement).tabPrevious();
expect(document.activeElement).toBe(button3Ref.current);
createEventTarget(document.activeElement).tabPrevious();
expect(document.activeElement).toBe(button2Ref.current);
});
it('should work as expected with suspense fallbacks', () => {
const buttonRef = React.createRef();
const button2Ref = React.createRef();
const button3Ref = React.createRef();
const button4Ref = React.createRef();
const button5Ref = React.createRef();
function SuspendedComponent() {
throw new Promise(() => {
// Never resolve
});
}
function Component() {
return (
<React.Fragment>
<button ref={button5Ref} id={5} />
<SuspendedComponent />
</React.Fragment>
);
}
const Test = () => (
<TabFocusContainer>
<button ref={buttonRef} id={1} />
<button ref={button2Ref} id={2} />
<React.Suspense fallback={<button ref={button3Ref} id={3} />}>
<Component />
</React.Suspense>
<button ref={button4Ref} id={4} />
</TabFocusContainer>
);
ReactDOM.render(<Test />, container);
buttonRef.current.focus();
expect(document.activeElement).toBe(buttonRef.current);
createEventTarget(document.activeElement).tabNext();
expect(document.activeElement).toBe(button2Ref.current);
createEventTarget(document.activeElement).tabNext();
expect(document.activeElement).toBe(button3Ref.current);
createEventTarget(document.activeElement).tabNext();
expect(document.activeElement).toBe(button4Ref.current);
createEventTarget(document.activeElement).tabPrevious();
expect(document.activeElement).toBe(button3Ref.current);
createEventTarget(document.activeElement).tabPrevious();
expect(document.activeElement).toBe(button2Ref.current);
});
});
});
@@ -0,0 +1,73 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
let React;
let ReactFeatureFlags;
let TabbableScope;
describe('TabbableScope', () => {
beforeEach(() => {
jest.resetModules();
ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.enableScopeAPI = true;
TabbableScope = require('../TabbableScope').TabbableScope;
React = require('react');
});
describe('ReactDOM', () => {
let ReactDOM;
let container;
beforeEach(() => {
ReactDOM = require('react-dom');
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
container = null;
});
it('getScopedNodes() works as intended', () => {
const scopeRef = React.createRef();
const nodeRefA = React.createRef();
const nodeRefB = React.createRef();
const nodeRefC = React.createRef();
const nodeRefD = React.createRef();
function Test() {
return (
<TabbableScope ref={scopeRef}>
<input ref={nodeRefA} />
<textarea ref={nodeRefB} />
<div tabIndex={0} ref={nodeRefC}>
<input tabIndex={-1} />
<div tabIndex={0} ref={nodeRefD} />
</div>
<input disabled={true} />
<div tabIndex={-1} />
</TabbableScope>
);
}
ReactDOM.render(<Test />, container);
let nodes = scopeRef.current.getScopedNodes();
expect(nodes).toEqual([
nodeRefA.current,
nodeRefB.current,
nodeRefC.current,
nodeRefD.current,
]);
expect(nodes.length).toBe(4);
ReactDOM.render(null, container);
expect(scopeRef.current).toBe(null);
});
});
});
+7 -7
View File
@@ -19,10 +19,10 @@ import type {ReactEventResponderListener} from 'shared/ReactTypes';
type KeyboardEventType = 'keydown' | 'keyup';
type KeyboardProps = {
disabled: boolean,
onKeyDown: (e: KeyboardEvent) => ?boolean,
onKeyUp: (e: KeyboardEvent) => ?boolean,
preventKeys: Array<string>,
disabled?: boolean,
onKeyDown?: (e: KeyboardEvent) => ?boolean,
onKeyUp?: (e: KeyboardEvent) => ?boolean,
preventKeys?: Array<string>,
};
type KeyboardEvent = {|
@@ -198,7 +198,7 @@ const keyboardResponderImpl = {
}
let defaultPrevented = nativeEvent.defaultPrevented === true;
if (type === 'keydown') {
const preventKeys = props.preventKeys;
const preventKeys = ((props.preventKeys: any): Array<string>);
if (!defaultPrevented && isArray(preventKeys)) {
preventKeyLoop: for (let i = 0; i < preventKeys.length; i++) {
const preventKey = preventKeys[i];
@@ -230,7 +230,7 @@ const keyboardResponderImpl = {
if (isFunction(onKeyDown)) {
dispatchKeyboardEvent(
event,
onKeyDown,
((onKeyDown: any): (e: KeyboardEvent) => ?boolean),
context,
'keydown',
defaultPrevented,
@@ -241,7 +241,7 @@ const keyboardResponderImpl = {
if (isFunction(onKeyUp)) {
dispatchKeyboardEvent(
event,
onKeyUp,
((onKeyUp: any): (e: KeyboardEvent) => ?boolean),
context,
'keyup',
defaultPrevented,
+26
View File
@@ -50,6 +50,32 @@ const createEventTarget = node => ({
virtualclick(payload) {
node.dispatchEvent(domEvents.virtualclick(payload));
},
tabNext() {
node.dispatchEvent(
domEvents.keydown({
key: 'Tab',
}),
);
node.dispatchEvent(
domEvents.keyup({
key: 'Tab',
}),
);
},
tabPrevious() {
node.dispatchEvent(
domEvents.keydown({
key: 'Tab',
shiftKey: true,
}),
);
node.dispatchEvent(
domEvents.keyup({
key: 'Tab',
shiftKey: true,
}),
);
},
/**
* PointerEvent abstraction.
* Dispatches the expected sequence of PointerEvents, MouseEvents, and
+6
View File
@@ -843,6 +843,12 @@ function commitUnmount(
}
}
}
return;
}
case ScopeComponent: {
if (enableScopeAPI) {
safelyDetachRef(current);
}
}
}
}
@@ -67,6 +67,8 @@ describe('ReactScope', () => {
ReactDOM.render(<Test toggle={false} />, container);
nodes = scopeRef.current.getScopedNodes();
expect(nodes).toEqual([aRef.current, divRef.current, spanRef.current]);
ReactDOM.render(null, container);
expect(scopeRef.current).toBe(null);
});
it('mixed getParent() and getScopedNodes() works as intended', () => {