From 3717c25a7e392fa7a9fceb1890ed354c94e05e70 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Wed, 11 Sep 2019 22:35:33 +0200 Subject: [PATCH] [react-interactions] More Tab Focus control handling (#16751) --- .../src/client/focus/TabFocusContainer.js | 81 -------- .../src/client/focus/TabFocusController.js | 179 ++++++++++++++++++ ...js => TabFocusController-test.internal.js} | 92 ++++++--- packages/react-events/src/dom/Keyboard.js | 27 ++- .../react-reconciler/src/ReactFiberScope.js | 29 ++- packages/shared/ReactTypes.js | 2 + 6 files changed, 285 insertions(+), 125 deletions(-) delete mode 100644 packages/react-dom/src/client/focus/TabFocusContainer.js create mode 100644 packages/react-dom/src/client/focus/TabFocusController.js rename packages/react-dom/src/client/focus/__tests__/{TabFocusContainer-test.internal.js => TabFocusController-test.internal.js} (75%) diff --git a/packages/react-dom/src/client/focus/TabFocusContainer.js b/packages/react-dom/src/client/focus/TabFocusContainer.js deleted file mode 100644 index b6d930f84048..000000000000 --- a/packages/react-dom/src/client/focus/TabFocusContainer.js +++ /dev/null @@ -1,81 +0,0 @@ -/** - * 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/TabFocusController.js b/packages/react-dom/src/client/focus/TabFocusController.js new file mode 100644 index 000000000000..fbf941fcefbf --- /dev/null +++ b/packages/react-dom/src/client/focus/TabFocusController.js @@ -0,0 +1,179 @@ +/** + * 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 TabFocusControllerProps = { + children: React.Node, + contain?: boolean, +}; + +type KeyboardEventType = 'keydown' | 'keyup'; + +type KeyboardEvent = {| + altKey: boolean, + ctrlKey: boolean, + isComposing: boolean, + key: string, + metaKey: boolean, + shiftKey: boolean, + target: Element | Document, + type: KeyboardEventType, + timeStamp: number, + defaultPrevented: boolean, +|}; + +type ControllerHandle = {| + focusFirst: () => void, + focusNext: () => boolean, + focusPrevious: () => boolean, + getNextController: () => null | ControllerHandle, + getPreviousController: () => null | ControllerHandle, +|}; + +const {useImperativeHandle, useRef} = React; + +function getTabbableNodes(scopeRef) { + const tabbableScope = scopeRef.current; + const tabbableNodes = tabbableScope.getScopedNodes(); + const firstTabbableElem = tabbableNodes[0]; + const lastTabbableElem = tabbableNodes[tabbableNodes.length - 1]; + const currentIndex = tabbableNodes.indexOf(document.activeElement); + let focusedElement = null; + if (currentIndex !== -1) { + focusedElement = tabbableNodes[currentIndex]; + } + return [ + tabbableNodes, + firstTabbableElem, + lastTabbableElem, + currentIndex, + focusedElement, + ]; +} + +export const TabFocusController = React.forwardRef( + ({children, contain}: TabFocusControllerProps, ref): React.Node => { + const scopeRef = useRef(null); + const keyboard = useKeyboard({ + onKeyDown(event: KeyboardEvent): boolean { + if (event.key !== 'Tab') { + return true; + } + if (event.shiftKey) { + return focusPrevious(); + } else { + return focusNext(); + } + }, + preventKeys: ['Tab', ['Tab', {shiftKey: true}]], + }); + + function focusFirst(): void { + const [, firstTabbableElem] = getTabbableNodes(scopeRef); + firstTabbableElem.focus(); + } + + function focusNext(): boolean { + const [ + tabbableNodes, + firstTabbableElem, + lastTabbableElem, + currentIndex, + focusedElement, + ] = getTabbableNodes(scopeRef); + + if (focusedElement === null) { + firstTabbableElem.focus(); + } else if (focusedElement === lastTabbableElem) { + if (contain === true) { + firstTabbableElem.focus(); + } else { + return true; + } + } else { + tabbableNodes[currentIndex + 1].focus(); + } + return false; + } + + function focusPrevious(): boolean { + const [ + tabbableNodes, + firstTabbableElem, + lastTabbableElem, + currentIndex, + focusedElement, + ] = getTabbableNodes(scopeRef); + + if (focusedElement === null) { + firstTabbableElem.focus(); + } else if (focusedElement === firstTabbableElem) { + if (contain === true) { + lastTabbableElem.focus(); + } else { + return true; + } + } else { + tabbableNodes[currentIndex - 1].focus(); + } + return false; + } + + function getPreviousController(): null | ControllerHandle { + const tabbableScope = scopeRef.current; + const allScopes = tabbableScope.getChildrenFromRoot(); + if (allScopes === null) { + return null; + } + const currentScopeIndex = allScopes.indexOf(tabbableScope); + if (currentScopeIndex <= 0) { + return null; + } + return allScopes[currentScopeIndex - 1].getHandle(); + } + + function getNextController(): null | ControllerHandle { + const tabbableScope = scopeRef.current; + const allScopes = tabbableScope.getChildrenFromRoot(); + if (allScopes === null) { + return null; + } + const currentScopeIndex = allScopes.indexOf(tabbableScope); + if ( + currentScopeIndex === -1 || + currentScopeIndex === allScopes.length - 1 + ) { + return null; + } + return allScopes[currentScopeIndex + 1].getHandle(); + } + + const controllerHandle: ControllerHandle = { + focusFirst, + focusNext, + focusPrevious, + getNextController, + getPreviousController, + }; + + useImperativeHandle(ref, () => controllerHandle); + + return ( + + {children} + + ); + }, +); diff --git a/packages/react-dom/src/client/focus/__tests__/TabFocusContainer-test.internal.js b/packages/react-dom/src/client/focus/__tests__/TabFocusController-test.internal.js similarity index 75% rename from packages/react-dom/src/client/focus/__tests__/TabFocusContainer-test.internal.js rename to packages/react-dom/src/client/focus/__tests__/TabFocusController-test.internal.js index 4f1872d6e225..71cc56301f5e 100644 --- a/packages/react-dom/src/client/focus/__tests__/TabFocusContainer-test.internal.js +++ b/packages/react-dom/src/client/focus/__tests__/TabFocusController-test.internal.js @@ -11,15 +11,15 @@ import {createEventTarget} from 'react-events/src/dom/testing-library'; let React; let ReactFeatureFlags; -let TabFocusContainer; +let TabFocusController; -describe('TabFocusContainer', () => { +describe('TabFocusController', () => { beforeEach(() => { jest.resetModules(); ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactFeatureFlags.enableScopeAPI = true; ReactFeatureFlags.enableFlareAPI = true; - TabFocusContainer = require('../TabFocusContainer').TabFocusContainer; + TabFocusController = require('../TabFocusController').TabFocusController; React = require('react'); }); @@ -38,7 +38,7 @@ describe('TabFocusContainer', () => { container = null; }); - it('should work as expected with simple tab operations', () => { + it('handles tab operations', () => { const inputRef = React.createRef(); const input2Ref = React.createRef(); const buttonRef = React.createRef(); @@ -46,13 +46,13 @@ describe('TabFocusContainer', () => { const divRef = React.createRef(); const Test = () => ( - +