From ae724be7becd26f924b3888959b3e84b78a7e34d Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Wed, 11 Sep 2019 12:46:41 +0200 Subject: [PATCH] [react-interactions] Add TabFocusContainer and TabbableScope UI components (#16732) --- .../src/client/focus/TabFocusContainer.js | 81 +++++++ .../src/client/focus/TabbableScope.js | 35 +++ .../TabFocusContainer-test.internal.js | 225 ++++++++++++++++++ .../__tests__/TabbableScope-test.internal.js | 73 ++++++ packages/react-events/src/dom/Keyboard.js | 14 +- .../src/dom/testing-library/index.js | 26 ++ .../src/ReactFiberCommitWork.js | 6 + .../src/__tests__/ReactScope-test.internal.js | 2 + 8 files changed, 455 insertions(+), 7 deletions(-) create mode 100644 packages/react-dom/src/client/focus/TabFocusContainer.js create mode 100644 packages/react-dom/src/client/focus/TabbableScope.js create mode 100644 packages/react-dom/src/client/focus/__tests__/TabFocusContainer-test.internal.js create mode 100644 packages/react-dom/src/client/focus/__tests__/TabbableScope-test.internal.js diff --git a/packages/react-dom/src/client/focus/TabFocusContainer.js b/packages/react-dom/src/client/focus/TabFocusContainer.js new file mode 100644 index 000000000000..b6d930f84048 --- /dev/null +++ b/packages/react-dom/src/client/focus/TabFocusContainer.js @@ -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 ( + + {children} + + ); +} diff --git a/packages/react-dom/src/client/focus/TabbableScope.js b/packages/react-dom/src/client/focus/TabbableScope.js new file mode 100644 index 000000000000..57b86d528681 --- /dev/null +++ b/packages/react-dom/src/client/focus/TabbableScope.js @@ -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' + ); + }, +); diff --git a/packages/react-dom/src/client/focus/__tests__/TabFocusContainer-test.internal.js b/packages/react-dom/src/client/focus/__tests__/TabFocusContainer-test.internal.js new file mode 100644 index 000000000000..4f1872d6e225 --- /dev/null +++ b/packages/react-dom/src/client/focus/__tests__/TabFocusContainer-test.internal.js @@ -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 = () => ( + + +