Skip to content

Commit

Permalink
[react-interactions] Add TabFocusContainer and TabbableScope UI compo…
Browse files Browse the repository at this point in the history
…nents (#16732)
  • Loading branch information
trueadm committed Sep 11, 2019
1 parent ab4951f commit ae724be
Show file tree
Hide file tree
Showing 8 changed files with 455 additions and 7 deletions.
81 changes: 81 additions & 0 deletions packages/react-dom/src/client/focus/TabFocusContainer.js
Original file line number Diff line number Diff line change
@@ -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 changes: 35 additions & 0 deletions packages/react-dom/src/client/focus/TabbableScope.js
Original file line number Diff line number Diff line change
@@ -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'
);
},
);
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading

0 comments on commit ae724be

Please sign in to comment.