mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
[react-interactions] Add TabFocusContainer and TabbableScope UI components (#16732)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user