From e70de2c205a6e8b01b565b8a23304f61f6c0f6d6 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Sun, 9 Jun 2019 19:21:45 -0700 Subject: [PATCH 01/11] [Flare] First pass at an implementation of FocusManager --- packages/react-dom/src/client/ReactDOM.js | 6 + .../src/events/DOMEventResponderSystem.js | 77 +---- packages/react-dom/src/events/FocusManager.js | 209 ++++++++++++ .../src/events/__tests__/FocusManager-test.js | 305 ++++++++++++++++++ packages/react-events/src/FocusScope.js | 45 +-- .../src/__tests__/FocusScope-test.internal.js | 2 + packages/shared/ReactTypes.js | 12 + 7 files changed, 559 insertions(+), 97 deletions(-) create mode 100644 packages/react-dom/src/events/FocusManager.js create mode 100644 packages/react-dom/src/events/__tests__/FocusManager-test.js diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index 1db9e527efe..632dfb96f6a 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -80,6 +80,8 @@ import { DOCUMENT_FRAGMENT_NODE, } from '../shared/HTMLNodeType'; import {ROOT_ATTRIBUTE_NAME} from '../shared/DOMProperty'; +import {enableEventAPI} from 'shared/ReactFeatureFlags'; +import FocusManager from '../events/FocusManager'; const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner; @@ -910,4 +912,8 @@ if (__DEV__) { } } +if (enableEventAPI) { + ReactDOM.unstable_FocusManager = FocusManager; +} + export default ReactDOM; diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index 18aca524d56..3943e25c311 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -36,10 +36,6 @@ import warning from 'shared/warning'; import {enableEventAPI} from 'shared/ReactFeatureFlags'; import {invokeGuardedCallbackAndCatchFirstError} from 'shared/ReactErrorUtils'; import invariant from 'shared/invariant'; -import { - isFiberSuspenseAndTimedOut, - getSuspenseFallbackChild, -} from 'react-reconciler/src/ReactFiberEvents'; import {getClosestInstanceFromNode} from '../client/ReactDOMComponentTree'; import { @@ -48,6 +44,7 @@ import { DiscreteEvent, } from 'shared/ReactTypes'; import {enableUserBlockingEvents} from 'shared/ReactFeatureFlags'; +import {getFocusableElementsInScope, moveFocusInScope} from './FocusManager'; // Intentionally not named imports because Rollup would use dynamic dispatch for // CommonJS interop named imports. @@ -383,14 +380,18 @@ const eventResponderContext: ReactResponderContext = { }, getFocusableElementsInScope(): Array { validateResponderContext(); - const focusableElements = []; const eventComponentInstance = ((currentInstance: any): ReactEventComponentInstance); - const child = ((eventComponentInstance.currentFiber: any): Fiber).child; - - if (child !== null) { - collectFocusableElements(child, focusableElements); - } - return focusableElements; + return getFocusableElementsInScope(eventComponentInstance); + }, + moveFocusInScope(element: HTMLElement, backwards, options) { + validateResponderContext(); + const eventComponentInstance = ((currentInstance: any): ReactEventComponentInstance); + return moveFocusInScope( + eventComponentInstance, + element, + backwards, + options, + ); }, getActiveDocument, objectAssign: Object.assign, @@ -455,33 +456,6 @@ const eventResponderContext: ReactResponderContext = { }, }; -function collectFocusableElements( - node: Fiber, - focusableElements: Array, -): void { - if (isFiberSuspenseAndTimedOut(node)) { - const fallbackChild = getSuspenseFallbackChild(node); - if (fallbackChild !== null) { - collectFocusableElements(fallbackChild, focusableElements); - } - } else { - if (isFiberHostComponentFocusable(node)) { - focusableElements.push(node.stateNode); - } else { - const child = node.child; - - if (child !== null) { - collectFocusableElements(child, focusableElements); - } - } - } - const sibling = node.sibling; - - if (sibling !== null) { - collectFocusableElements(sibling, focusableElements); - } -} - function isTargetWithinEventComponent(target: Element | Document): boolean { validateResponderContext(); if (target != null) { @@ -523,33 +497,6 @@ function releaseOwnershipForEventComponentInstance( } } -function isFiberHostComponentFocusable(fiber: Fiber): boolean { - if (fiber.tag !== HostComponent) { - return false; - } - const {type, memoizedProps} = fiber; - if (memoizedProps.tabIndex === -1 || memoizedProps.disabled) { - return false; - } - if (memoizedProps.tabIndex === 0 || memoizedProps.contentEditable === true) { - return true; - } - if (type === 'a' || type === 'area') { - return !!memoizedProps.href && memoizedProps.rel !== 'ignore'; - } - if (type === 'input') { - return memoizedProps.type !== 'hidden' && memoizedProps.type !== 'file'; - } - return ( - type === 'button' || - type === 'textarea' || - type === 'object' || - type === 'select' || - type === 'iframe' || - type === 'embed' - ); -} - function processTimers( timers: Map, delay: number, diff --git a/packages/react-dom/src/events/FocusManager.js b/packages/react-dom/src/events/FocusManager.js new file mode 100644 index 00000000000..c92c0cd7f65 --- /dev/null +++ b/packages/react-dom/src/events/FocusManager.js @@ -0,0 +1,209 @@ +/** + * 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 getActiveElement from '../client/getActiveElement'; +import {getClosestInstanceFromNode} from '../client/ReactDOMComponentTree'; +import { + isFiberSuspenseAndTimedOut, + getSuspenseFallbackChild, +} from 'react-reconciler/src/ReactFiberEvents'; +import {EventComponent, HostComponent} from 'shared/ReactWorkTags'; +import type { + FocusManagerOptions, + ReactEventComponentInstance, +} from 'shared/ReactTypes'; +import type {Fiber} from 'react-reconciler/src/ReactFiber'; + +function findScope(node: Element) { + let fiber = getClosestInstanceFromNode(node); + while (fiber !== null) { + if ( + fiber.tag === EventComponent && + fiber.stateNode && + fiber.stateNode.responder.isFocusScope + ) { + return fiber.stateNode; + } + fiber = fiber.return; + } +} + +export function getFocusableElementsInScope( + eventComponentInstance: ReactEventComponentInstance, + tabbable?: boolean, + searchNode?: Element, +): Array { + const focusableElements = []; + const child = ((eventComponentInstance.currentFiber: any): Fiber).child; + + if (child !== null) { + collectFocusableElements(child, focusableElements, tabbable, searchNode); + } + return focusableElements; +} + +function collectFocusableElements( + node: Fiber, + focusableElements: Array, + tabbable?: boolean, + searchNode?: Element, +): void { + if (isFiberSuspenseAndTimedOut(node)) { + const fallbackChild = getSuspenseFallbackChild(node); + if (fallbackChild !== null) { + collectFocusableElements( + fallbackChild, + focusableElements, + tabbable, + searchNode, + ); + } + } else { + if ( + isFiberHostComponentFocusable(node, tabbable) || + node.stateNode === searchNode + ) { + focusableElements.push(node.stateNode); + } else { + const child = node.child; + + if (child !== null) { + collectFocusableElements( + child, + focusableElements, + tabbable, + searchNode, + ); + } + } + } + const sibling = node.sibling; + + if (sibling !== null) { + collectFocusableElements(sibling, focusableElements, tabbable, searchNode); + } +} + +function isFiberHostComponentFocusable( + fiber: Fiber, + tabbable?: boolean, +): boolean { + if (fiber.tag !== HostComponent) { + return false; + } + const {type, memoizedProps} = fiber; + if ((tabbable && memoizedProps.tabIndex === -1) || memoizedProps.disabled) { + return false; + } + const minTabIndex = tabbable ? 0 : -1; + if ( + (memoizedProps.tabIndex != null && memoizedProps.tabIndex >= minTabIndex) || + memoizedProps.contentEditable === true + ) { + return true; + } + if (type === 'a' || type === 'area') { + return !!memoizedProps.href && memoizedProps.rel !== 'ignore'; + } + if (type === 'input') { + return memoizedProps.type !== 'hidden' && memoizedProps.type !== 'file'; + } + return ( + type === 'button' || + type === 'textarea' || + type === 'object' || + type === 'select' || + type === 'iframe' || + type === 'embed' + ); +} + +function focusElement(element: ?HTMLElement) { + if (element != null) { + try { + element.focus(); + } catch (err) {} + } +} + +export function moveFocusInScope( + scope: ReactEventComponentInstance, + node: Element, + backwards: boolean, + options: FocusManagerOptions = {}, +) { + let elements = getFocusableElementsInScope(scope, !!options.tabbable, node); + if (elements.length === 0) { + return null; + } + + const position = elements.indexOf(node); + const lastPosition = elements.length - 1; + let nextElement = null; + + if (backwards) { + if (position === 0) { + if (options.wrap) { + nextElement = elements[lastPosition]; + } else { + // Out of bounds + return null; + } + } else { + nextElement = elements[position - 1]; + } + } else { + if (position === lastPosition) { + if (options.wrap) { + nextElement = elements[0]; + } else { + // Out of bounds + return null; + } + } else { + nextElement = elements[position + 1]; + } + } + + if (nextElement) { + focusElement(nextElement); + return nextElement; + } + + return null; +} + +function moveFocus(options: FocusManagerOptions = {}, backwards: boolean) { + let node = options.from; + if (!node) { + node = getActiveElement(); + } + + if (!node) { + return; + } + + let scope = findScope(node); + if (!scope) { + return null; + } + + return moveFocusInScope(scope, node, backwards, options); +} + +const FocusManager = { + focusNext(options: FocusManagerOptions = {}) { + return moveFocus(options, false); + }, + focusPrevious(options: FocusManagerOptions = {}) { + return moveFocus(options, true); + }, +}; + +export default FocusManager; diff --git a/packages/react-dom/src/events/__tests__/FocusManager-test.js b/packages/react-dom/src/events/__tests__/FocusManager-test.js new file mode 100644 index 00000000000..ed976f58f77 --- /dev/null +++ b/packages/react-dom/src/events/__tests__/FocusManager-test.js @@ -0,0 +1,305 @@ +/** + * 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. + * + * @emails react-core + */ + +'use strict'; + +let React; +let ReactDOM; +let FocusManager; +let FocusScope; + +describe('FocusManager', () => { + let container; + + beforeEach(() => { + jest.resetModules(); + let ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableEventAPI = true; + React = require('react'); + ReactDOM = require('react-dom'); + FocusManager = ReactDOM.unstable_FocusManager; + FocusScope = require('react-events/focus-scope'); // TODO: is this dependency bad?? + + // The container has to be attached for events to fire. + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + ReactDOM.render(null, container); + document.body.removeChild(container); + container = null; + }); + + describe('focusNext', () => { + it('focuses the next focusable element in the current scope', () => { + const inputRef = React.createRef(); + const divRef = React.createRef(); + const input2Ref = React.createRef(); + + const SimpleFocusScope = () => ( +
+ + + +
+ + + +
+ ); + + ReactDOM.render(, container); + + expect(document.activeElement).toBe(inputRef.current); + FocusManager.focusNext(); + expect(document.activeElement).toBe(divRef.current); + FocusManager.focusNext(); + expect(document.activeElement).toBe(input2Ref.current); + FocusManager.focusNext(); + expect(document.activeElement).toBe(input2Ref.current); + }); + + it('focuses the next focusable element in the current scope and wraps around', () => { + const inputRef = React.createRef(); + const divRef = React.createRef(); + const input2Ref = React.createRef(); + + const SimpleFocusScope = () => ( +
+ + + +
+ + + +
+ ); + + ReactDOM.render(, container); + + expect(document.activeElement).toBe(inputRef.current); + FocusManager.focusNext({wrap: true}); + expect(document.activeElement).toBe(divRef.current); + FocusManager.focusNext({wrap: true}); + expect(document.activeElement).toBe(input2Ref.current); + FocusManager.focusNext({wrap: true}); + expect(document.activeElement).toBe(inputRef.current); + }); + + it('focuses the next tabbable element in the current scope', () => { + const inputRef = React.createRef(); + const divRef = React.createRef(); + const input2Ref = React.createRef(); + + const SimpleFocusScope = () => ( +
+ + + +
+ + + +
+ ); + + ReactDOM.render(, container); + + expect(document.activeElement).toBe(inputRef.current); + FocusManager.focusNext({tabbable: true}); + expect(document.activeElement).toBe(input2Ref.current); + FocusManager.focusNext({tabbable: true}); + expect(document.activeElement).toBe(input2Ref.current); + }); + + it('focuses the next tabbable element in the current scope and wraps around', () => { + const inputRef = React.createRef(); + const divRef = React.createRef(); + const input2Ref = React.createRef(); + + const SimpleFocusScope = () => ( +
+ + + +
+ + + +
+ ); + + ReactDOM.render(, container); + + expect(document.activeElement).toBe(inputRef.current); + FocusManager.focusNext({tabbable: true, wrap: true}); + expect(document.activeElement).toBe(input2Ref.current); + FocusManager.focusNext({tabbable: true, wrap: true}); + expect(document.activeElement).toBe(inputRef.current); + }); + + it('focuses the next focusable element after the given element', () => { + const inputRef = React.createRef(); + const divRef = React.createRef(); + const input2Ref = React.createRef(); + + const SimpleFocusScope = () => ( +
+ + + +
+ + + +
+ ); + + ReactDOM.render(, container); + + FocusManager.focusNext({from: divRef.current}); + expect(document.activeElement).toBe(input2Ref.current); + }); + }); + + describe('focusPrevious', () => { + it('focuses the previous focusable element in the current scope', () => { + const inputRef = React.createRef(); + const divRef = React.createRef(); + const input2Ref = React.createRef(); + + const SimpleFocusScope = () => ( +
+ + + +
+ + + +
+ ); + + ReactDOM.render(, container); + + input2Ref.current.focus(); + FocusManager.focusPrevious(); + expect(document.activeElement).toBe(divRef.current); + FocusManager.focusPrevious(); + expect(document.activeElement).toBe(inputRef.current); + FocusManager.focusPrevious(); + expect(document.activeElement).toBe(inputRef.current); + }); + + it('focuses the previous focusable element in the current scope and wraps around', () => { + const inputRef = React.createRef(); + const divRef = React.createRef(); + const input2Ref = React.createRef(); + + const SimpleFocusScope = () => ( +
+ + + +
+ + + +
+ ); + + ReactDOM.render(, container); + + input2Ref.current.focus(); + FocusManager.focusPrevious({wrap: true}); + expect(document.activeElement).toBe(divRef.current); + FocusManager.focusPrevious({wrap: true}); + expect(document.activeElement).toBe(inputRef.current); + FocusManager.focusPrevious({wrap: true}); + expect(document.activeElement).toBe(input2Ref.current); + }); + + it('focuses the previous tabbable element in the current scope', () => { + const inputRef = React.createRef(); + const divRef = React.createRef(); + const input2Ref = React.createRef(); + + const SimpleFocusScope = () => ( +
+ + + +
+ + + +
+ ); + + ReactDOM.render(, container); + + input2Ref.current.focus(); + FocusManager.focusPrevious({tabbable: true}); + expect(document.activeElement).toBe(inputRef.current); + FocusManager.focusPrevious({tabbable: true}); + expect(document.activeElement).toBe(inputRef.current); + }); + + it('focuses the previous tabbable element in the current scope and wraps around', () => { + const inputRef = React.createRef(); + const divRef = React.createRef(); + const input2Ref = React.createRef(); + + const SimpleFocusScope = () => ( +
+ + + +
+ + + +
+ ); + + ReactDOM.render(, container); + + input2Ref.current.focus(); + FocusManager.focusPrevious({tabbable: true, wrap: true}); + expect(document.activeElement).toBe(inputRef.current); + FocusManager.focusPrevious({tabbable: true, wrap: true}); + expect(document.activeElement).toBe(input2Ref.current); + }); + + it('focuses the previous focusable element before the given element', () => { + const inputRef = React.createRef(); + const divRef = React.createRef(); + const input2Ref = React.createRef(); + + const SimpleFocusScope = () => ( +
+ + + +
+ + + +
+ ); + + ReactDOM.render(, container); + + FocusManager.focusPrevious({from: divRef.current}); + expect(document.activeElement).toBe(inputRef.current); + }); + }); +}); diff --git a/packages/react-events/src/FocusScope.js b/packages/react-events/src/FocusScope.js index 7108c6e612c..24b4dc8a62f 100644 --- a/packages/react-events/src/FocusScope.js +++ b/packages/react-events/src/FocusScope.js @@ -15,9 +15,9 @@ import type { import React from 'react'; type FocusScopeProps = { - autoFocus: Boolean, - contain: Boolean, - restoreFocus: Boolean, + autoFocus: boolean, + contain: boolean, + restoreFocus: boolean, }; type FocusScopeState = { @@ -47,6 +47,7 @@ function getFirstFocusableElement( } const FocusScopeResponder = { + isFocusScope: true, targetEventTypes, rootEventTypes, createInitialState(): FocusScopeState { @@ -81,43 +82,23 @@ const FocusScopeResponder = { if (altkey || ctrlKey || metaKey) { return; } - const elements = context.getFocusableElementsInScope(); - const position = elements.indexOf(focusedElement); - const lastPosition = elements.length - 1; - let nextElement = null; - if (shiftKey) { - if (position === 0) { - if (props.contain) { - nextElement = elements[lastPosition]; - } else { - // Out of bounds - context.releaseOwnership(); - return; - } - } else { - nextElement = elements[position - 1]; - } - } else { - if (position === lastPosition) { - if (props.contain) { - nextElement = elements[0]; - } else { - // Out of bounds - context.releaseOwnership(); - return; - } - } else { - nextElement = elements[position + 1]; - } + const nextElement = context.moveFocusInScope(focusedElement, shiftKey, { + tabbable: true, + wrap: props.contain, + }); + + if (!nextElement) { + context.releaseOwnership(); + return; } + // If this element is possibly inside the scope of another // FocusScope responder or is out of bounds, then we release ownership. if (nextElement !== null) { if (!context.isTargetWithinEventResponderScope(nextElement)) { context.releaseOwnership(); } - focusElement(nextElement); state.currentFocusedNode = nextElement; ((nativeEvent: any): KeyboardEvent).preventDefault(); } diff --git a/packages/react-events/src/__tests__/FocusScope-test.internal.js b/packages/react-events/src/__tests__/FocusScope-test.internal.js index 3930b25e1fa..5281c79f322 100644 --- a/packages/react-events/src/__tests__/FocusScope-test.internal.js +++ b/packages/react-events/src/__tests__/FocusScope-test.internal.js @@ -103,6 +103,8 @@ describe('FocusScope event responder', () => { ); ReactDOM.render(, container); + expect(document.activeElement).toBe(inputRef.current); + document.activeElement.dispatchEvent(createTabForward()); expect(document.activeElement).toBe(buttonRef.current); document.activeElement.dispatchEvent(createTabForward()); expect(document.activeElement).toBe(button2Ref.current); diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 96ebdcb58b5..c0ecbc513ea 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -91,6 +91,7 @@ export type ReactEventResponder = { createInitialState?: (props: null | Object) => Object, allowMultipleHostChildren: boolean, stopLocalPropagation: boolean, + isFocusScope?: boolean, onEvent?: ( event: ReactResponderEvent, context: ReactResponderContext, @@ -164,6 +165,12 @@ export const DiscreteEvent: EventPriority = 0; export const UserBlockingEvent: EventPriority = 1; export const ContinuousEvent: EventPriority = 2; +export type FocusManagerOptions = { + from?: HTMLElement, + tabbable?: boolean, + wrap?: boolean, +}; + export type ReactResponderContext = { dispatchEvent: ( eventObject: Object, @@ -190,6 +197,11 @@ export type ReactResponderContext = { setTimeout: (func: () => void, timeout: number) => number, clearTimeout: (timerId: number) => void, getFocusableElementsInScope(): Array, + moveFocusInScope( + element: HTMLElement, + backwards: boolean, + options: FocusManagerOptions, + ): ?HTMLElement, getActiveDocument(): Document, objectAssign: Function, getEventPointerType( From 32ea1b34c71fb7d6193a9b363ad3cfbe85ddd2c3 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 10 Jun 2019 11:06:34 -0700 Subject: [PATCH 02/11] Rename test file --- .../{FocusManager-test.js => FocusManager-test.internal.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/react-dom/src/events/__tests__/{FocusManager-test.js => FocusManager-test.internal.js} (100%) diff --git a/packages/react-dom/src/events/__tests__/FocusManager-test.js b/packages/react-dom/src/events/__tests__/FocusManager-test.internal.js similarity index 100% rename from packages/react-dom/src/events/__tests__/FocusManager-test.js rename to packages/react-dom/src/events/__tests__/FocusManager-test.internal.js From dd33b6bfefaea3589afb4752f8b813842946c6d4 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 10 Jun 2019 11:36:36 -0700 Subject: [PATCH 03/11] Update ReactFire --- packages/react-dom/src/fire/ReactFire.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/react-dom/src/fire/ReactFire.js b/packages/react-dom/src/fire/ReactFire.js index bb74931fdc3..207395cbb8c 100644 --- a/packages/react-dom/src/fire/ReactFire.js +++ b/packages/react-dom/src/fire/ReactFire.js @@ -86,6 +86,8 @@ import { DOCUMENT_FRAGMENT_NODE, } from '../shared/HTMLNodeType'; import {ROOT_ATTRIBUTE_NAME} from '../shared/DOMProperty'; +import {enableEventAPI} from 'shared/ReactFeatureFlags'; +import FocusManager from '../events/FocusManager'; const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner; @@ -916,4 +918,8 @@ if (__DEV__) { } } +if (enableEventAPI) { + ReactDOM.unstable_FocusManager = FocusManager; +} + export default ReactDOM; From baca7748435ccf0f5b6cb918f95e63f4e1c7ec99 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Sat, 15 Jun 2019 17:02:51 -0700 Subject: [PATCH 04/11] Move to ref and hooks based implementation --- packages/react-dom/src/client/ReactDOM.js | 6 - .../src/events/DOMEventResponderSystem.js | 17 +- packages/react-dom/src/events/FocusManager.js | 209 ----------- .../__tests__/FocusManager-test.internal.js | 305 --------------- packages/react-events/src/FocusScope.js | 235 +++++++++++- .../src/__tests__/FocusScope-test.internal.js | 350 +++++++++++++++++- packages/shared/ReactTypes.js | 13 +- scripts/error-codes/codes.json | 3 +- 8 files changed, 574 insertions(+), 564 deletions(-) delete mode 100644 packages/react-dom/src/events/FocusManager.js delete mode 100644 packages/react-dom/src/events/__tests__/FocusManager-test.internal.js diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index 632dfb96f6a..1db9e527efe 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -80,8 +80,6 @@ import { DOCUMENT_FRAGMENT_NODE, } from '../shared/HTMLNodeType'; import {ROOT_ATTRIBUTE_NAME} from '../shared/DOMProperty'; -import {enableEventAPI} from 'shared/ReactFeatureFlags'; -import FocusManager from '../events/FocusManager'; const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner; @@ -912,8 +910,4 @@ if (__DEV__) { } } -if (enableEventAPI) { - ReactDOM.unstable_FocusManager = FocusManager; -} - export default ReactDOM; diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index 2460120e69e..1b43141c15d 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -44,7 +44,6 @@ import { DiscreteEvent, } from 'shared/ReactTypes'; import {enableUserBlockingEvents} from 'shared/ReactFeatureFlags'; -import {getFocusableElementsInScope, moveFocusInScope} from './FocusManager'; // Intentionally not named imports because Rollup would use dynamic dispatch for // CommonJS interop named imports. @@ -359,20 +358,8 @@ const eventResponderContext: ReactResponderContext = { } } }, - getFocusableElementsInScope(): Array { - validateResponderContext(); - const eventComponentInstance = ((currentInstance: any): ReactEventComponentInstance); - return getFocusableElementsInScope(eventComponentInstance); - }, - moveFocusInScope(element: HTMLElement, backwards, options) { - validateResponderContext(); - const eventComponentInstance = ((currentInstance: any): ReactEventComponentInstance); - return moveFocusInScope( - eventComponentInstance, - element, - backwards, - options, - ); + getCurrentInstance(): ReactEventComponentInstance { + return currentInstance; }, getActiveDocument, objectAssign: Object.assign, diff --git a/packages/react-dom/src/events/FocusManager.js b/packages/react-dom/src/events/FocusManager.js deleted file mode 100644 index c92c0cd7f65..00000000000 --- a/packages/react-dom/src/events/FocusManager.js +++ /dev/null @@ -1,209 +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 getActiveElement from '../client/getActiveElement'; -import {getClosestInstanceFromNode} from '../client/ReactDOMComponentTree'; -import { - isFiberSuspenseAndTimedOut, - getSuspenseFallbackChild, -} from 'react-reconciler/src/ReactFiberEvents'; -import {EventComponent, HostComponent} from 'shared/ReactWorkTags'; -import type { - FocusManagerOptions, - ReactEventComponentInstance, -} from 'shared/ReactTypes'; -import type {Fiber} from 'react-reconciler/src/ReactFiber'; - -function findScope(node: Element) { - let fiber = getClosestInstanceFromNode(node); - while (fiber !== null) { - if ( - fiber.tag === EventComponent && - fiber.stateNode && - fiber.stateNode.responder.isFocusScope - ) { - return fiber.stateNode; - } - fiber = fiber.return; - } -} - -export function getFocusableElementsInScope( - eventComponentInstance: ReactEventComponentInstance, - tabbable?: boolean, - searchNode?: Element, -): Array { - const focusableElements = []; - const child = ((eventComponentInstance.currentFiber: any): Fiber).child; - - if (child !== null) { - collectFocusableElements(child, focusableElements, tabbable, searchNode); - } - return focusableElements; -} - -function collectFocusableElements( - node: Fiber, - focusableElements: Array, - tabbable?: boolean, - searchNode?: Element, -): void { - if (isFiberSuspenseAndTimedOut(node)) { - const fallbackChild = getSuspenseFallbackChild(node); - if (fallbackChild !== null) { - collectFocusableElements( - fallbackChild, - focusableElements, - tabbable, - searchNode, - ); - } - } else { - if ( - isFiberHostComponentFocusable(node, tabbable) || - node.stateNode === searchNode - ) { - focusableElements.push(node.stateNode); - } else { - const child = node.child; - - if (child !== null) { - collectFocusableElements( - child, - focusableElements, - tabbable, - searchNode, - ); - } - } - } - const sibling = node.sibling; - - if (sibling !== null) { - collectFocusableElements(sibling, focusableElements, tabbable, searchNode); - } -} - -function isFiberHostComponentFocusable( - fiber: Fiber, - tabbable?: boolean, -): boolean { - if (fiber.tag !== HostComponent) { - return false; - } - const {type, memoizedProps} = fiber; - if ((tabbable && memoizedProps.tabIndex === -1) || memoizedProps.disabled) { - return false; - } - const minTabIndex = tabbable ? 0 : -1; - if ( - (memoizedProps.tabIndex != null && memoizedProps.tabIndex >= minTabIndex) || - memoizedProps.contentEditable === true - ) { - return true; - } - if (type === 'a' || type === 'area') { - return !!memoizedProps.href && memoizedProps.rel !== 'ignore'; - } - if (type === 'input') { - return memoizedProps.type !== 'hidden' && memoizedProps.type !== 'file'; - } - return ( - type === 'button' || - type === 'textarea' || - type === 'object' || - type === 'select' || - type === 'iframe' || - type === 'embed' - ); -} - -function focusElement(element: ?HTMLElement) { - if (element != null) { - try { - element.focus(); - } catch (err) {} - } -} - -export function moveFocusInScope( - scope: ReactEventComponentInstance, - node: Element, - backwards: boolean, - options: FocusManagerOptions = {}, -) { - let elements = getFocusableElementsInScope(scope, !!options.tabbable, node); - if (elements.length === 0) { - return null; - } - - const position = elements.indexOf(node); - const lastPosition = elements.length - 1; - let nextElement = null; - - if (backwards) { - if (position === 0) { - if (options.wrap) { - nextElement = elements[lastPosition]; - } else { - // Out of bounds - return null; - } - } else { - nextElement = elements[position - 1]; - } - } else { - if (position === lastPosition) { - if (options.wrap) { - nextElement = elements[0]; - } else { - // Out of bounds - return null; - } - } else { - nextElement = elements[position + 1]; - } - } - - if (nextElement) { - focusElement(nextElement); - return nextElement; - } - - return null; -} - -function moveFocus(options: FocusManagerOptions = {}, backwards: boolean) { - let node = options.from; - if (!node) { - node = getActiveElement(); - } - - if (!node) { - return; - } - - let scope = findScope(node); - if (!scope) { - return null; - } - - return moveFocusInScope(scope, node, backwards, options); -} - -const FocusManager = { - focusNext(options: FocusManagerOptions = {}) { - return moveFocus(options, false); - }, - focusPrevious(options: FocusManagerOptions = {}) { - return moveFocus(options, true); - }, -}; - -export default FocusManager; diff --git a/packages/react-dom/src/events/__tests__/FocusManager-test.internal.js b/packages/react-dom/src/events/__tests__/FocusManager-test.internal.js deleted file mode 100644 index ed976f58f77..00000000000 --- a/packages/react-dom/src/events/__tests__/FocusManager-test.internal.js +++ /dev/null @@ -1,305 +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. - * - * @emails react-core - */ - -'use strict'; - -let React; -let ReactDOM; -let FocusManager; -let FocusScope; - -describe('FocusManager', () => { - let container; - - beforeEach(() => { - jest.resetModules(); - let ReactFeatureFlags = require('shared/ReactFeatureFlags'); - ReactFeatureFlags.enableEventAPI = true; - React = require('react'); - ReactDOM = require('react-dom'); - FocusManager = ReactDOM.unstable_FocusManager; - FocusScope = require('react-events/focus-scope'); // TODO: is this dependency bad?? - - // The container has to be attached for events to fire. - container = document.createElement('div'); - document.body.appendChild(container); - }); - - afterEach(() => { - ReactDOM.render(null, container); - document.body.removeChild(container); - container = null; - }); - - describe('focusNext', () => { - it('focuses the next focusable element in the current scope', () => { - const inputRef = React.createRef(); - const divRef = React.createRef(); - const input2Ref = React.createRef(); - - const SimpleFocusScope = () => ( -
- - - -
- - - -
- ); - - ReactDOM.render(, container); - - expect(document.activeElement).toBe(inputRef.current); - FocusManager.focusNext(); - expect(document.activeElement).toBe(divRef.current); - FocusManager.focusNext(); - expect(document.activeElement).toBe(input2Ref.current); - FocusManager.focusNext(); - expect(document.activeElement).toBe(input2Ref.current); - }); - - it('focuses the next focusable element in the current scope and wraps around', () => { - const inputRef = React.createRef(); - const divRef = React.createRef(); - const input2Ref = React.createRef(); - - const SimpleFocusScope = () => ( -
- - - -
- - - -
- ); - - ReactDOM.render(, container); - - expect(document.activeElement).toBe(inputRef.current); - FocusManager.focusNext({wrap: true}); - expect(document.activeElement).toBe(divRef.current); - FocusManager.focusNext({wrap: true}); - expect(document.activeElement).toBe(input2Ref.current); - FocusManager.focusNext({wrap: true}); - expect(document.activeElement).toBe(inputRef.current); - }); - - it('focuses the next tabbable element in the current scope', () => { - const inputRef = React.createRef(); - const divRef = React.createRef(); - const input2Ref = React.createRef(); - - const SimpleFocusScope = () => ( -
- - - -
- - - -
- ); - - ReactDOM.render(, container); - - expect(document.activeElement).toBe(inputRef.current); - FocusManager.focusNext({tabbable: true}); - expect(document.activeElement).toBe(input2Ref.current); - FocusManager.focusNext({tabbable: true}); - expect(document.activeElement).toBe(input2Ref.current); - }); - - it('focuses the next tabbable element in the current scope and wraps around', () => { - const inputRef = React.createRef(); - const divRef = React.createRef(); - const input2Ref = React.createRef(); - - const SimpleFocusScope = () => ( -
- - - -
- - - -
- ); - - ReactDOM.render(, container); - - expect(document.activeElement).toBe(inputRef.current); - FocusManager.focusNext({tabbable: true, wrap: true}); - expect(document.activeElement).toBe(input2Ref.current); - FocusManager.focusNext({tabbable: true, wrap: true}); - expect(document.activeElement).toBe(inputRef.current); - }); - - it('focuses the next focusable element after the given element', () => { - const inputRef = React.createRef(); - const divRef = React.createRef(); - const input2Ref = React.createRef(); - - const SimpleFocusScope = () => ( -
- - - -
- - - -
- ); - - ReactDOM.render(, container); - - FocusManager.focusNext({from: divRef.current}); - expect(document.activeElement).toBe(input2Ref.current); - }); - }); - - describe('focusPrevious', () => { - it('focuses the previous focusable element in the current scope', () => { - const inputRef = React.createRef(); - const divRef = React.createRef(); - const input2Ref = React.createRef(); - - const SimpleFocusScope = () => ( -
- - - -
- - - -
- ); - - ReactDOM.render(, container); - - input2Ref.current.focus(); - FocusManager.focusPrevious(); - expect(document.activeElement).toBe(divRef.current); - FocusManager.focusPrevious(); - expect(document.activeElement).toBe(inputRef.current); - FocusManager.focusPrevious(); - expect(document.activeElement).toBe(inputRef.current); - }); - - it('focuses the previous focusable element in the current scope and wraps around', () => { - const inputRef = React.createRef(); - const divRef = React.createRef(); - const input2Ref = React.createRef(); - - const SimpleFocusScope = () => ( -
- - - -
- - - -
- ); - - ReactDOM.render(, container); - - input2Ref.current.focus(); - FocusManager.focusPrevious({wrap: true}); - expect(document.activeElement).toBe(divRef.current); - FocusManager.focusPrevious({wrap: true}); - expect(document.activeElement).toBe(inputRef.current); - FocusManager.focusPrevious({wrap: true}); - expect(document.activeElement).toBe(input2Ref.current); - }); - - it('focuses the previous tabbable element in the current scope', () => { - const inputRef = React.createRef(); - const divRef = React.createRef(); - const input2Ref = React.createRef(); - - const SimpleFocusScope = () => ( -
- - - -
- - - -
- ); - - ReactDOM.render(, container); - - input2Ref.current.focus(); - FocusManager.focusPrevious({tabbable: true}); - expect(document.activeElement).toBe(inputRef.current); - FocusManager.focusPrevious({tabbable: true}); - expect(document.activeElement).toBe(inputRef.current); - }); - - it('focuses the previous tabbable element in the current scope and wraps around', () => { - const inputRef = React.createRef(); - const divRef = React.createRef(); - const input2Ref = React.createRef(); - - const SimpleFocusScope = () => ( -
- - - -
- - - -
- ); - - ReactDOM.render(, container); - - input2Ref.current.focus(); - FocusManager.focusPrevious({tabbable: true, wrap: true}); - expect(document.activeElement).toBe(inputRef.current); - FocusManager.focusPrevious({tabbable: true, wrap: true}); - expect(document.activeElement).toBe(input2Ref.current); - }); - - it('focuses the previous focusable element before the given element', () => { - const inputRef = React.createRef(); - const divRef = React.createRef(); - const input2Ref = React.createRef(); - - const SimpleFocusScope = () => ( -
- - - -
- - - -
- ); - - ReactDOM.render(, container); - - FocusManager.focusPrevious({from: divRef.current}); - expect(document.activeElement).toBe(inputRef.current); - }); - }); -}); diff --git a/packages/react-events/src/FocusScope.js b/packages/react-events/src/FocusScope.js index 2cf1c2de921..948f1b6eb30 100644 --- a/packages/react-events/src/FocusScope.js +++ b/packages/react-events/src/FocusScope.js @@ -9,8 +9,17 @@ import type { ReactResponderEvent, ReactResponderContext, + ReactEventComponentInstance, } from 'shared/ReactTypes'; import React from 'react'; +import { + isFiberSuspenseAndTimedOut, + getSuspenseFallbackChild, +} from 'react-reconciler/src/ReactFiberEvents'; +import {HostComponent} from 'shared/ReactWorkTags'; +import type {Fiber} from 'react-reconciler/src/ReactFiber'; +import ReactSharedInternals from 'shared/ReactSharedInternals'; +import invariant from 'shared/invariant'; type FocusScopeProps = { autoFocus: boolean, @@ -23,9 +32,110 @@ type FocusScopeState = { currentFocusedNode: null | HTMLElement, }; +type FocusManagerOptions = { + from?: HTMLElement, + tabbable?: boolean, + wrap?: boolean, +}; + +type FocusManager = { + focusNext(opts: FocusManagerOptions): HTMLElement, + focusPrevious(opts: FocusManagerOptions): HTMLElement, +}; + const targetEventTypes = [{name: 'keydown', passive: false}]; const rootEventTypes = [{name: 'focus', passive: true}]; +function getFocusableElementsInScope( + eventComponentInstance: ReactEventComponentInstance, + tabbable?: boolean, + searchNode?: Element, +): Array { + const focusableElements = []; + const child = ((eventComponentInstance.currentFiber: any): Fiber).child; + + if (child !== null) { + collectFocusableElements(child, focusableElements, tabbable, searchNode); + } + return focusableElements; +} + +function collectFocusableElements( + node: Fiber, + focusableElements: Array, + tabbable?: boolean, + searchNode?: Element, +): void { + if (isFiberSuspenseAndTimedOut(node)) { + const fallbackChild = getSuspenseFallbackChild(node); + if (fallbackChild !== null) { + collectFocusableElements( + fallbackChild, + focusableElements, + tabbable, + searchNode, + ); + } + } else { + if ( + isFiberHostComponentFocusable(node, tabbable) || + node.stateNode === searchNode + ) { + focusableElements.push(node.stateNode); + } else { + const child = node.child; + + if (child !== null) { + collectFocusableElements( + child, + focusableElements, + tabbable, + searchNode, + ); + } + } + } + const sibling = node.sibling; + + if (sibling !== null) { + collectFocusableElements(sibling, focusableElements, tabbable, searchNode); + } +} + +function isFiberHostComponentFocusable( + fiber: Fiber, + tabbable?: boolean, +): boolean { + if (fiber.tag !== HostComponent) { + return false; + } + const {type, memoizedProps} = fiber; + if ((tabbable && memoizedProps.tabIndex === -1) || memoizedProps.disabled) { + return false; + } + const minTabIndex = tabbable ? 0 : -1; + if ( + (memoizedProps.tabIndex != null && memoizedProps.tabIndex >= minTabIndex) || + memoizedProps.contentEditable === true + ) { + return true; + } + if (type === 'a' || type === 'area') { + return !!memoizedProps.href && memoizedProps.rel !== 'ignore'; + } + if (type === 'input') { + return memoizedProps.type !== 'hidden' && memoizedProps.type !== 'file'; + } + return ( + type === 'button' || + type === 'textarea' || + type === 'object' || + type === 'select' || + type === 'iframe' || + type === 'embed' + ); +} + function focusElement(element: ?HTMLElement) { if (element != null) { try { @@ -34,11 +144,76 @@ function focusElement(element: ?HTMLElement) { } } +function moveFocusInScope( + scope: ReactEventComponentInstance, + node: Element, + backwards: boolean, + options: FocusManagerOptions = {}, +) { + let elements = getFocusableElementsInScope(scope, !!options.tabbable, node); + if (elements.length === 0) { + return null; + } + + const position = elements.indexOf(node); + const lastPosition = elements.length - 1; + let nextElement = null; + + if (backwards) { + if (position === 0) { + if (options.wrap) { + nextElement = elements[lastPosition]; + } else { + // Out of bounds + return null; + } + } else { + nextElement = elements[position - 1]; + } + } else { + if (position === lastPosition) { + if (options.wrap) { + nextElement = elements[0]; + } else { + // Out of bounds + return null; + } + } else { + nextElement = elements[position + 1]; + } + } + + if (nextElement) { + focusElement(nextElement); + return nextElement; + } + + return null; +} + +function moveFocus( + scope: ReactEventComponentInstance, + options: FocusManagerOptions = {}, + backwards: boolean, +) { + let node = options.from; + if (!node) { + let doc = typeof document !== 'undefined' ? document : null; + node = doc && doc.activeElement; + } + + if (!node) { + return; + } + + return moveFocusInScope(scope, node, backwards, options); +} + function getFirstFocusableElement( context: ReactResponderContext, state: FocusScopeState, ): ?HTMLElement { - const elements = context.getFocusableElementsInScope(); + const elements = getFocusableElementsInScope(context.getCurrentInstance()); if (elements.length > 0) { return elements[0]; } @@ -75,22 +250,19 @@ const FocusScopeResponder = { return; } - const nextElement = context.moveFocusInScope(focusedElement, shiftKey, { - tabbable: true, - wrap: props.contain, - }); + const nextElement = moveFocusInScope( + context.getCurrentInstance(), + focusedElement, + shiftKey, + { + tabbable: true, + wrap: props.contain, + }, + ); if (!nextElement) { context.continueLocalPropagation(); - return; - } - - // If this element is possibly inside the scope of another - // FocusScope responder or is out of bounds, then we release ownership. - if (nextElement !== null) { - if (!context.isTargetWithinEventResponderScope(nextElement)) { - context.continueLocalPropagation(); - } + } else { state.currentFocusedNode = nextElement; ((nativeEvent: any): KeyboardEvent).preventDefault(); } @@ -142,7 +314,40 @@ const FocusScopeResponder = { }, }; -export default React.unstable_createEventComponent( +export const FocusScopeEventComponent = React.unstable_createEventComponent( FocusScopeResponder, 'FocusScope', ); + +const FocusScopeContext: React.Context = React.createContext(); + +export const FocusScope = React.forwardRef( + (props: FocusScopeProps, ref: React.Ref) => { + let fiber = ReactSharedInternals.ReactCurrentOwner.current; + let focusManager = { + focusNext(options: FocusManagerOptions = {}) { + // forwardRef -> context provider -> event component -> responder + return moveFocus(fiber.child.child.stateNode, options, false); + }, + focusPrevious(options: FocusManagerOptions = {}) { + return moveFocus(fiber.child.child.stateNode, options, true); + }, + }; + + React.useImperativeHandle(ref, () => focusManager); + + return React.createElement(FocusScopeContext.Provider, { + value: focusManager, + children: React.createElement(FocusScopeEventComponent, props), + }); + }, +); + +export function useFocusManager(): FocusManager { + let focusManager = React.useContext(FocusScopeContext); + invariant( + focusManager != null, + 'Tried to call useFocusManager outside of a FocusScope subtree.', + ); + return focusManager; +} diff --git a/packages/react-events/src/__tests__/FocusScope-test.internal.js b/packages/react-events/src/__tests__/FocusScope-test.internal.js index 5281c79f322..43a380f0174 100644 --- a/packages/react-events/src/__tests__/FocusScope-test.internal.js +++ b/packages/react-events/src/__tests__/FocusScope-test.internal.js @@ -13,6 +13,7 @@ let React; let ReactFeatureFlags; let ReactDOM; let FocusScope; +let useFocusManager; const createTabForward = type => { const event = new KeyboardEvent('keydown', { @@ -42,7 +43,8 @@ describe('FocusScope event responder', () => { ReactFeatureFlags.enableEventAPI = true; React = require('react'); ReactDOM = require('react-dom'); - FocusScope = require('react-events/focus-scope'); + FocusScope = require('react-events/focus-scope').FocusScope; + useFocusManager = require('react-events/focus-scope').useFocusManager; container = document.createElement('div'); document.body.appendChild(container); @@ -243,4 +245,350 @@ describe('FocusScope event responder', () => { document.activeElement.dispatchEvent(createTabBackward()); expect(document.activeElement).toBe(button2Ref.current); }); + + describe('FocusManager ref', () => { + describe('focusNext', () => { + it('focuses the next focusable element in the current scope', () => { + const focusManagerRef = React.createRef(); + const inputRef = React.createRef(); + const divRef = React.createRef(); + const input2Ref = React.createRef(); + + const SimpleFocusScope = () => ( +
+ + + +
+ + + +
+ ); + + ReactDOM.render(, container); + + expect(document.activeElement).toBe(inputRef.current); + focusManagerRef.current.focusNext(); + expect(document.activeElement).toBe(divRef.current); + focusManagerRef.current.focusNext(); + expect(document.activeElement).toBe(input2Ref.current); + focusManagerRef.current.focusNext(); + expect(document.activeElement).toBe(input2Ref.current); + }); + + it('focuses the next focusable element in the current scope and wraps around', () => { + const focusManagerRef = React.createRef(); + const inputRef = React.createRef(); + const divRef = React.createRef(); + const input2Ref = React.createRef(); + + const SimpleFocusScope = () => ( +
+ + + +
+ + + +
+ ); + + ReactDOM.render(, container); + + expect(document.activeElement).toBe(inputRef.current); + focusManagerRef.current.focusNext({wrap: true}); + expect(document.activeElement).toBe(divRef.current); + focusManagerRef.current.focusNext({wrap: true}); + expect(document.activeElement).toBe(input2Ref.current); + focusManagerRef.current.focusNext({wrap: true}); + expect(document.activeElement).toBe(inputRef.current); + }); + + it('focuses the next tabbable element in the current scope', () => { + const focusManagerRef = React.createRef(); + const inputRef = React.createRef(); + const divRef = React.createRef(); + const input2Ref = React.createRef(); + + const SimpleFocusScope = () => ( +
+ + + +
+ + + +
+ ); + + ReactDOM.render(, container); + + expect(document.activeElement).toBe(inputRef.current); + focusManagerRef.current.focusNext({tabbable: true}); + expect(document.activeElement).toBe(input2Ref.current); + focusManagerRef.current.focusNext({tabbable: true}); + expect(document.activeElement).toBe(input2Ref.current); + }); + + it('focuses the next tabbable element in the current scope and wraps around', () => { + const focusManagerRef = React.createRef(); + const inputRef = React.createRef(); + const divRef = React.createRef(); + const input2Ref = React.createRef(); + + const SimpleFocusScope = () => ( +
+ + + +
+ + + +
+ ); + + ReactDOM.render(, container); + + expect(document.activeElement).toBe(inputRef.current); + focusManagerRef.current.focusNext({tabbable: true, wrap: true}); + expect(document.activeElement).toBe(input2Ref.current); + focusManagerRef.current.focusNext({tabbable: true, wrap: true}); + expect(document.activeElement).toBe(inputRef.current); + }); + + it('focuses the next focusable element after the given element', () => { + const focusManagerRef = React.createRef(); + const inputRef = React.createRef(); + const divRef = React.createRef(); + const input2Ref = React.createRef(); + + const SimpleFocusScope = () => ( +
+ + + +
+ + + +
+ ); + + ReactDOM.render(, container); + + focusManagerRef.current.focusNext({from: divRef.current}); + expect(document.activeElement).toBe(input2Ref.current); + }); + }); + + describe('focusPrevious', () => { + it('focuses the previous focusable element in the current scope', () => { + const focusManagerRef = React.createRef(); + const inputRef = React.createRef(); + const divRef = React.createRef(); + const input2Ref = React.createRef(); + + const SimpleFocusScope = () => ( +
+ + + +
+ + + +
+ ); + + ReactDOM.render(, container); + + input2Ref.current.focus(); + focusManagerRef.current.focusPrevious(); + expect(document.activeElement).toBe(divRef.current); + focusManagerRef.current.focusPrevious(); + expect(document.activeElement).toBe(inputRef.current); + focusManagerRef.current.focusPrevious(); + expect(document.activeElement).toBe(inputRef.current); + }); + + it('focuses the previous focusable element in the current scope and wraps around', () => { + const focusManagerRef = React.createRef(); + const inputRef = React.createRef(); + const divRef = React.createRef(); + const input2Ref = React.createRef(); + + const SimpleFocusScope = () => ( +
+ + + +
+ + + +
+ ); + + ReactDOM.render(, container); + + input2Ref.current.focus(); + focusManagerRef.current.focusPrevious({wrap: true}); + expect(document.activeElement).toBe(divRef.current); + focusManagerRef.current.focusPrevious({wrap: true}); + expect(document.activeElement).toBe(inputRef.current); + focusManagerRef.current.focusPrevious({wrap: true}); + expect(document.activeElement).toBe(input2Ref.current); + }); + + it('focuses the previous tabbable element in the current scope', () => { + const focusManagerRef = React.createRef(); + const inputRef = React.createRef(); + const divRef = React.createRef(); + const input2Ref = React.createRef(); + + const SimpleFocusScope = () => ( +
+ + + +
+ + + +
+ ); + + ReactDOM.render(, container); + + input2Ref.current.focus(); + focusManagerRef.current.focusPrevious({tabbable: true}); + expect(document.activeElement).toBe(inputRef.current); + focusManagerRef.current.focusPrevious({tabbable: true}); + expect(document.activeElement).toBe(inputRef.current); + }); + + it('focuses the previous tabbable element in the current scope and wraps around', () => { + const focusManagerRef = React.createRef(); + const inputRef = React.createRef(); + const divRef = React.createRef(); + const input2Ref = React.createRef(); + + const SimpleFocusScope = () => ( +
+ + + +
+ + + +
+ ); + + ReactDOM.render(, container); + + input2Ref.current.focus(); + focusManagerRef.current.focusPrevious({tabbable: true, wrap: true}); + expect(document.activeElement).toBe(inputRef.current); + focusManagerRef.current.focusPrevious({tabbable: true, wrap: true}); + expect(document.activeElement).toBe(input2Ref.current); + }); + + it('focuses the previous focusable element before the given element', () => { + const focusManagerRef = React.createRef(); + const inputRef = React.createRef(); + const divRef = React.createRef(); + const input2Ref = React.createRef(); + + const SimpleFocusScope = () => ( +
+ + + +
+ + + +
+ ); + + ReactDOM.render(, container); + + focusManagerRef.current.focusPrevious({from: divRef.current}); + expect(document.activeElement).toBe(inputRef.current); + }); + }); + }); + + describe('useFocusManager hook', () => { + it('returns a focus manager', () => { + let focusManager; + + const ScopeParent = () => ( + + + + ); + + const Child = () => { + focusManager = useFocusManager(); + return null; + }; + + ReactDOM.render(, container); + + expect(focusManager).toHaveProperty('focusPrevious'); + expect(focusManager).toHaveProperty('focusNext'); + }); + + it('throws if not inside a focus scope', () => { + const Child = () => { + useFocusManager(); + return null; + }; + + expect(() => { + ReactDOM.render(, container); + }).toThrow( + 'Tried to call useFocusManager outside of a FocusScope subtree.', + ); + }); + + it('can be used to move focus', () => { + const inputRef = React.createRef(); + const input2Ref = React.createRef(); + const divRef = React.createRef(); + + const ScopeParent = () => ( +
+ + + + + + + +
+ ); + + const Child = () => { + let focusManager = useFocusManager(); + return ( +
focusManager.focusNext()}> + Focus Next +
+ ); + }; + + ReactDOM.render(, container); + + expect(document.activeElement).toBe(inputRef.current); + divRef.current.click(); + expect(document.activeElement).toBe(input2Ref.current); + }); + }); }); diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 428ef0a4126..42f238c175c 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -173,12 +173,6 @@ export const DiscreteEvent: EventPriority = 0; export const UserBlockingEvent: EventPriority = 1; export const ContinuousEvent: EventPriority = 2; -export type FocusManagerOptions = { - from?: HTMLElement, - tabbable?: boolean, - wrap?: boolean, -}; - export type ReactResponderContext = { dispatchEvent: ( eventObject: Object, @@ -203,12 +197,7 @@ export type ReactResponderContext = { releaseOwnership: () => boolean, setTimeout: (func: () => void, timeout: number) => number, clearTimeout: (timerId: number) => void, - getFocusableElementsInScope(): Array, - moveFocusInScope( - element: HTMLElement, - backwards: boolean, - options: FocusManagerOptions, - ): ?HTMLElement, + getCurrentInstance(): ReactEventComponentInstance, getActiveDocument(): Document, objectAssign: Function, getEventCurrentTarget(event: ReactResponderEvent): Element, diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index c2b8d3a976f..8e56ac4e5ba 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -333,5 +333,6 @@ "332": "Unknown priority level.", "333": "This should have a parent host component initialized. This error is likely caused by a bug in React. Please file an issue.", "334": "accumulate(...): Accumulated items must not be null or undefined.", - "335": "ReactDOMServer does not yet support the event API." + "335": "ReactDOMServer does not yet support the event API.", + "336": "Tried to call useFocusManager outside of a FocusScope subtree." } From af48b9f54483b29e283fa8e7f1ac3b3bc67e1a5f Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Sat, 15 Jun 2019 17:10:25 -0700 Subject: [PATCH 05/11] Clean up --- packages/react-dom/src/events/DOMEventResponderSystem.js | 3 ++- packages/react-dom/src/fire/ReactFire.js | 6 ------ packages/react-events/focus-scope.js | 2 +- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index 1b43141c15d..47395cc73cb 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -359,7 +359,8 @@ const eventResponderContext: ReactResponderContext = { } }, getCurrentInstance(): ReactEventComponentInstance { - return currentInstance; + validateResponderContext(); + return ((currentInstance: any): ReactEventComponentInstance); }, getActiveDocument, objectAssign: Object.assign, diff --git a/packages/react-dom/src/fire/ReactFire.js b/packages/react-dom/src/fire/ReactFire.js index 207395cbb8c..bb74931fdc3 100644 --- a/packages/react-dom/src/fire/ReactFire.js +++ b/packages/react-dom/src/fire/ReactFire.js @@ -86,8 +86,6 @@ import { DOCUMENT_FRAGMENT_NODE, } from '../shared/HTMLNodeType'; import {ROOT_ATTRIBUTE_NAME} from '../shared/DOMProperty'; -import {enableEventAPI} from 'shared/ReactFeatureFlags'; -import FocusManager from '../events/FocusManager'; const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner; @@ -918,8 +916,4 @@ if (__DEV__) { } } -if (enableEventAPI) { - ReactDOM.unstable_FocusManager = FocusManager; -} - export default ReactDOM; diff --git a/packages/react-events/focus-scope.js b/packages/react-events/focus-scope.js index 2ff785ef792..3971d4b71e0 100644 --- a/packages/react-events/focus-scope.js +++ b/packages/react-events/focus-scope.js @@ -11,4 +11,4 @@ const FocusScope = require('./src/FocusScope'); -module.exports = FocusScope.default || FocusScope; +module.exports = FocusScope; From 3c3775ca106ed9d053098607315706955ea53a99 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Sat, 15 Jun 2019 17:43:27 -0700 Subject: [PATCH 06/11] Don't rely on ReactCurrentOwner in prod --- packages/react-events/src/FocusScope.js | 38 ++++++++++++++++--- .../src/__tests__/FocusScope-test.internal.js | 28 ++++++++++++++ scripts/error-codes/codes.json | 3 +- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/packages/react-events/src/FocusScope.js b/packages/react-events/src/FocusScope.js index 948f1b6eb30..d59bf15cb8a 100644 --- a/packages/react-events/src/FocusScope.js +++ b/packages/react-events/src/FocusScope.js @@ -18,13 +18,13 @@ import { } from 'react-reconciler/src/ReactFiberEvents'; import {HostComponent} from 'shared/ReactWorkTags'; import type {Fiber} from 'react-reconciler/src/ReactFiber'; -import ReactSharedInternals from 'shared/ReactSharedInternals'; import invariant from 'shared/invariant'; type FocusScopeProps = { autoFocus: boolean, contain: boolean, restoreFocus: boolean, + getFocusManager: (focusManager: FocusManager) => void, }; type FocusScopeState = { @@ -209,6 +209,17 @@ function moveFocus( return moveFocusInScope(scope, node, backwards, options); } +function createFocusManager(scope: ReactEventComponentInstance) { + return { + focusNext(options: FocusManagerOptions = {}) { + return moveFocus(scope, options, false); + }, + focusPrevious(options: FocusManagerOptions = {}) { + return moveFocus(scope, options, true); + }, + }; +} + function getFirstFocusableElement( context: ReactResponderContext, state: FocusScopeState, @@ -220,7 +231,6 @@ function getFirstFocusableElement( } const FocusScopeResponder = { - isFocusScope: true, targetEventTypes, rootEventTypes, createInitialState(): FocusScopeState { @@ -302,6 +312,9 @@ const FocusScopeResponder = { const firstElement = getFirstFocusableElement(context, state); focusElement(firstElement); } + if (props.getFocusManager) { + props.getFocusManager(createFocusManager(context.getCurrentInstance())); + } }, onUnmount( context: ReactResponderContext, @@ -323,18 +336,31 @@ const FocusScopeContext: React.Context = React.createContext(); export const FocusScope = React.forwardRef( (props: FocusScopeProps, ref: React.Ref) => { - let fiber = ReactSharedInternals.ReactCurrentOwner.current; + let internalFocusManager: ?FocusManager; let focusManager = { focusNext(options: FocusManagerOptions = {}) { - // forwardRef -> context provider -> event component -> responder - return moveFocus(fiber.child.child.stateNode, options, false); + invariant( + internalFocusManager != null, + 'Attempt to use a focus manager method on an unmounted component.', + ); + return internalFocusManager.focusNext(options); }, focusPrevious(options: FocusManagerOptions = {}) { - return moveFocus(fiber.child.child.stateNode, options, true); + invariant( + internalFocusManager != null, + 'Attempt to use a focus manager method on an unmounted component.', + ); + return internalFocusManager.focusPrevious(options); }, }; React.useImperativeHandle(ref, () => focusManager); + props = { + ...props, + getFocusManager(manager) { + internalFocusManager = manager; + }, + }; return React.createElement(FocusScopeContext.Provider, { value: focusManager, diff --git a/packages/react-events/src/__tests__/FocusScope-test.internal.js b/packages/react-events/src/__tests__/FocusScope-test.internal.js index 43a380f0174..f7ba634e262 100644 --- a/packages/react-events/src/__tests__/FocusScope-test.internal.js +++ b/packages/react-events/src/__tests__/FocusScope-test.internal.js @@ -590,5 +590,33 @@ describe('FocusScope event responder', () => { divRef.current.click(); expect(document.activeElement).toBe(input2Ref.current); }); + + it('throws when trying to use a focus manager method on an unmounted component', () => { + const inputRef = React.createRef(); + const input2Ref = React.createRef(); + const ScopeParent = () => ( +
+ + + + + + + +
+ ); + + const Child = () => { + let focusManager = useFocusManager(); + focusManager.focusNext(); + return null; + }; + + expect(() => { + ReactDOM.render(, container); + }).toThrow( + 'Attempt to use a focus manager method on an unmounted component.', + ); + }); }); }); diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 8e56ac4e5ba..501ed915f5d 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -334,5 +334,6 @@ "333": "This should have a parent host component initialized. This error is likely caused by a bug in React. Please file an issue.", "334": "accumulate(...): Accumulated items must not be null or undefined.", "335": "ReactDOMServer does not yet support the event API.", - "336": "Tried to call useFocusManager outside of a FocusScope subtree." + "336": "Tried to call useFocusManager outside of a FocusScope subtree.", + "337": "Attempt to use a focus manager method on an unmounted component." } From e44a678b3a40effda4eafc901418236c22462b10 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Sat, 15 Jun 2019 17:49:57 -0700 Subject: [PATCH 07/11] Flow --- packages/react-events/src/FocusScope.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/react-events/src/FocusScope.js b/packages/react-events/src/FocusScope.js index d59bf15cb8a..0879a5a33a3 100644 --- a/packages/react-events/src/FocusScope.js +++ b/packages/react-events/src/FocusScope.js @@ -39,8 +39,8 @@ type FocusManagerOptions = { }; type FocusManager = { - focusNext(opts: FocusManagerOptions): HTMLElement, - focusPrevious(opts: FocusManagerOptions): HTMLElement, + focusNext(opts: ?FocusManagerOptions): ?HTMLElement, + focusPrevious(opts: ?FocusManagerOptions): ?HTMLElement, }; const targetEventTypes = [{name: 'keydown', passive: false}]; @@ -209,13 +209,13 @@ function moveFocus( return moveFocusInScope(scope, node, backwards, options); } -function createFocusManager(scope: ReactEventComponentInstance) { +function createFocusManager(scope: ReactEventComponentInstance): FocusManager { return { - focusNext(options: FocusManagerOptions = {}) { - return moveFocus(scope, options, false); + focusNext(options: ?FocusManagerOptions) { + return moveFocus(scope, options || {}, false); }, - focusPrevious(options: FocusManagerOptions = {}) { - return moveFocus(scope, options, true); + focusPrevious(options: ?FocusManagerOptions) { + return moveFocus(scope, options || {}, true); }, }; } From 5729e5bd0a4b2e2eba93cd024053e79b20d96a86 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 17 Jun 2019 15:12:00 -0700 Subject: [PATCH 08/11] Remove ref support --- packages/react-events/src/FocusScope.js | 61 ++- .../src/__tests__/FocusScope-test.internal.js | 466 +++++++++++------- 2 files changed, 306 insertions(+), 221 deletions(-) diff --git a/packages/react-events/src/FocusScope.js b/packages/react-events/src/FocusScope.js index 0879a5a33a3..1f336565d0a 100644 --- a/packages/react-events/src/FocusScope.js +++ b/packages/react-events/src/FocusScope.js @@ -334,40 +334,37 @@ export const FocusScopeEventComponent = React.unstable_createEventComponent( const FocusScopeContext: React.Context = React.createContext(); -export const FocusScope = React.forwardRef( - (props: FocusScopeProps, ref: React.Ref) => { - let internalFocusManager: ?FocusManager; - let focusManager = { - focusNext(options: FocusManagerOptions = {}) { - invariant( - internalFocusManager != null, - 'Attempt to use a focus manager method on an unmounted component.', - ); - return internalFocusManager.focusNext(options); - }, - focusPrevious(options: FocusManagerOptions = {}) { - invariant( - internalFocusManager != null, - 'Attempt to use a focus manager method on an unmounted component.', - ); - return internalFocusManager.focusPrevious(options); - }, - }; +export function FocusScope(props: FocusScopeProps) { + let internalFocusManager: ?FocusManager; + let focusManager = { + focusNext(options: FocusManagerOptions = {}) { + invariant( + internalFocusManager != null, + 'Attempt to use a focus manager method on an unmounted component.', + ); + return internalFocusManager.focusNext(options); + }, + focusPrevious(options: FocusManagerOptions = {}) { + invariant( + internalFocusManager != null, + 'Attempt to use a focus manager method on an unmounted component.', + ); + return internalFocusManager.focusPrevious(options); + }, + }; - React.useImperativeHandle(ref, () => focusManager); - props = { - ...props, - getFocusManager(manager) { - internalFocusManager = manager; - }, - }; + props = { + ...props, + getFocusManager(manager) { + internalFocusManager = manager; + }, + }; - return React.createElement(FocusScopeContext.Provider, { - value: focusManager, - children: React.createElement(FocusScopeEventComponent, props), - }); - }, -); + return React.createElement(FocusScopeContext.Provider, { + value: focusManager, + children: React.createElement(FocusScopeEventComponent, props), + }); +} export function useFocusManager(): FocusManager { let focusManager = React.useContext(FocusScopeContext); diff --git a/packages/react-events/src/__tests__/FocusScope-test.internal.js b/packages/react-events/src/__tests__/FocusScope-test.internal.js index f7ba634e262..12d8fac8aa2 100644 --- a/packages/react-events/src/__tests__/FocusScope-test.internal.js +++ b/packages/react-events/src/__tests__/FocusScope-test.internal.js @@ -246,377 +246,465 @@ describe('FocusScope event responder', () => { expect(document.activeElement).toBe(button2Ref.current); }); - describe('FocusManager ref', () => { + describe('useFocusManager', () => { + it('returns a focus manager', () => { + let focusManager; + + const ScopeParent = () => ( + + + + ); + + const Child = () => { + focusManager = useFocusManager(); + return null; + }; + + ReactDOM.render(, container); + + expect(focusManager).toHaveProperty('focusPrevious'); + expect(focusManager).toHaveProperty('focusNext'); + }); + + it('throws if not inside a focus scope', () => { + const Child = () => { + useFocusManager(); + return null; + }; + + expect(() => { + ReactDOM.render(, container); + }).toThrow( + 'Tried to call useFocusManager outside of a FocusScope subtree.', + ); + }); + + it('can be used to move focus', () => { + const inputRef = React.createRef(); + const input2Ref = React.createRef(); + const divRef = React.createRef(); + + const ScopeParent = () => ( +
+ + + + + + + +
+ ); + + const Child = () => { + let focusManager = useFocusManager(); + return ( +
focusManager.focusNext()}> + Focus Next +
+ ); + }; + + ReactDOM.render(, container); + + expect(document.activeElement).toBe(inputRef.current); + divRef.current.click(); + expect(document.activeElement).toBe(input2Ref.current); + }); + + it('throws when trying to use a focus manager method on an unmounted component', () => { + const inputRef = React.createRef(); + const input2Ref = React.createRef(); + const ScopeParent = () => ( +
+ + + + + + + +
+ ); + + const Child = () => { + let focusManager = useFocusManager(); + focusManager.focusNext(); + return null; + }; + + expect(() => { + ReactDOM.render(, container); + }).toThrow( + 'Attempt to use a focus manager method on an unmounted component.', + ); + }); + describe('focusNext', () => { it('focuses the next focusable element in the current scope', () => { - const focusManagerRef = React.createRef(); const inputRef = React.createRef(); const divRef = React.createRef(); const input2Ref = React.createRef(); + let focusManager; - const SimpleFocusScope = () => ( + const ScopeParent = () => (
- + + + +
+ ); + + const Child = () => { + focusManager = useFocusManager(); + return ( +
- -
- ); +
+ ); + }; - ReactDOM.render(, container); + ReactDOM.render(, container); expect(document.activeElement).toBe(inputRef.current); - focusManagerRef.current.focusNext(); + focusManager.focusNext(); expect(document.activeElement).toBe(divRef.current); - focusManagerRef.current.focusNext(); + focusManager.focusNext(); expect(document.activeElement).toBe(input2Ref.current); - focusManagerRef.current.focusNext(); + focusManager.focusNext(); expect(document.activeElement).toBe(input2Ref.current); }); it('focuses the next focusable element in the current scope and wraps around', () => { - const focusManagerRef = React.createRef(); const inputRef = React.createRef(); const divRef = React.createRef(); const input2Ref = React.createRef(); + let focusManager; - const SimpleFocusScope = () => ( + const ScopeParent = () => (
- + + + +
+ ); + + const Child = () => { + focusManager = useFocusManager(); + return ( +
- -
- ); +
+ ); + }; - ReactDOM.render(, container); + ReactDOM.render(, container); expect(document.activeElement).toBe(inputRef.current); - focusManagerRef.current.focusNext({wrap: true}); + focusManager.focusNext({wrap: true}); expect(document.activeElement).toBe(divRef.current); - focusManagerRef.current.focusNext({wrap: true}); + focusManager.focusNext({wrap: true}); expect(document.activeElement).toBe(input2Ref.current); - focusManagerRef.current.focusNext({wrap: true}); + focusManager.focusNext({wrap: true}); expect(document.activeElement).toBe(inputRef.current); }); it('focuses the next tabbable element in the current scope', () => { - const focusManagerRef = React.createRef(); const inputRef = React.createRef(); const divRef = React.createRef(); const input2Ref = React.createRef(); + let focusManager; - const SimpleFocusScope = () => ( + const ScopeParent = () => (
- + + + +
+ ); + + const Child = () => { + focusManager = useFocusManager(); + return ( +
- -
- ); +
+ ); + }; - ReactDOM.render(, container); + ReactDOM.render(, container); expect(document.activeElement).toBe(inputRef.current); - focusManagerRef.current.focusNext({tabbable: true}); + focusManager.focusNext({tabbable: true}); expect(document.activeElement).toBe(input2Ref.current); - focusManagerRef.current.focusNext({tabbable: true}); + focusManager.focusNext({tabbable: true}); expect(document.activeElement).toBe(input2Ref.current); }); it('focuses the next tabbable element in the current scope and wraps around', () => { - const focusManagerRef = React.createRef(); const inputRef = React.createRef(); const divRef = React.createRef(); const input2Ref = React.createRef(); + let focusManager; - const SimpleFocusScope = () => ( + const ScopeParent = () => (
- + + + +
+ ); + + const Child = () => { + focusManager = useFocusManager(); + return ( +
- -
- ); +
+ ); + }; - ReactDOM.render(, container); + ReactDOM.render(, container); expect(document.activeElement).toBe(inputRef.current); - focusManagerRef.current.focusNext({tabbable: true, wrap: true}); + focusManager.focusNext({tabbable: true, wrap: true}); expect(document.activeElement).toBe(input2Ref.current); - focusManagerRef.current.focusNext({tabbable: true, wrap: true}); + focusManager.focusNext({tabbable: true, wrap: true}); expect(document.activeElement).toBe(inputRef.current); }); it('focuses the next focusable element after the given element', () => { - const focusManagerRef = React.createRef(); const inputRef = React.createRef(); const divRef = React.createRef(); const input2Ref = React.createRef(); + let focusManager; - const SimpleFocusScope = () => ( + const ScopeParent = () => (
- + + + +
+ ); + + const Child = () => { + focusManager = useFocusManager(); + return ( +
- -
- ); +
+ ); + }; - ReactDOM.render(, container); + ReactDOM.render(, container); - focusManagerRef.current.focusNext({from: divRef.current}); + focusManager.focusNext({from: divRef.current}); expect(document.activeElement).toBe(input2Ref.current); }); }); describe('focusPrevious', () => { it('focuses the previous focusable element in the current scope', () => { - const focusManagerRef = React.createRef(); const inputRef = React.createRef(); const divRef = React.createRef(); const input2Ref = React.createRef(); + let focusManager; - const SimpleFocusScope = () => ( + const ScopeParent = () => (
- + + + +
+ ); + + const Child = () => { + focusManager = useFocusManager(); + return ( +
- -
- ); +
+ ); + }; - ReactDOM.render(, container); + ReactDOM.render(, container); input2Ref.current.focus(); - focusManagerRef.current.focusPrevious(); + focusManager.focusPrevious(); expect(document.activeElement).toBe(divRef.current); - focusManagerRef.current.focusPrevious(); + focusManager.focusPrevious(); expect(document.activeElement).toBe(inputRef.current); - focusManagerRef.current.focusPrevious(); + focusManager.focusPrevious(); expect(document.activeElement).toBe(inputRef.current); }); it('focuses the previous focusable element in the current scope and wraps around', () => { - const focusManagerRef = React.createRef(); const inputRef = React.createRef(); const divRef = React.createRef(); const input2Ref = React.createRef(); + let focusManager; - const SimpleFocusScope = () => ( + const ScopeParent = () => (
- + + + +
+ ); + + const Child = () => { + focusManager = useFocusManager(); + return ( +
- -
- ); +
+ ); + }; - ReactDOM.render(, container); + ReactDOM.render(, container); input2Ref.current.focus(); - focusManagerRef.current.focusPrevious({wrap: true}); + focusManager.focusPrevious({wrap: true}); expect(document.activeElement).toBe(divRef.current); - focusManagerRef.current.focusPrevious({wrap: true}); + focusManager.focusPrevious({wrap: true}); expect(document.activeElement).toBe(inputRef.current); - focusManagerRef.current.focusPrevious({wrap: true}); + focusManager.focusPrevious({wrap: true}); expect(document.activeElement).toBe(input2Ref.current); }); it('focuses the previous tabbable element in the current scope', () => { - const focusManagerRef = React.createRef(); const inputRef = React.createRef(); const divRef = React.createRef(); const input2Ref = React.createRef(); + let focusManager; - const SimpleFocusScope = () => ( + const ScopeParent = () => (
- + + + +
+ ); + + const Child = () => { + focusManager = useFocusManager(); + return ( +
- -
- ); +
+ ); + }; - ReactDOM.render(, container); + ReactDOM.render(, container); input2Ref.current.focus(); - focusManagerRef.current.focusPrevious({tabbable: true}); + focusManager.focusPrevious({tabbable: true}); expect(document.activeElement).toBe(inputRef.current); - focusManagerRef.current.focusPrevious({tabbable: true}); + focusManager.focusPrevious({tabbable: true}); expect(document.activeElement).toBe(inputRef.current); }); it('focuses the previous tabbable element in the current scope and wraps around', () => { - const focusManagerRef = React.createRef(); const inputRef = React.createRef(); const divRef = React.createRef(); const input2Ref = React.createRef(); + let focusManager; - const SimpleFocusScope = () => ( + const ScopeParent = () => (
- + + + +
+ ); + + const Child = () => { + focusManager = useFocusManager(); + return ( +
- -
- ); +
+ ); + }; - ReactDOM.render(, container); + ReactDOM.render(, container); input2Ref.current.focus(); - focusManagerRef.current.focusPrevious({tabbable: true, wrap: true}); + focusManager.focusPrevious({tabbable: true, wrap: true}); expect(document.activeElement).toBe(inputRef.current); - focusManagerRef.current.focusPrevious({tabbable: true, wrap: true}); + focusManager.focusPrevious({tabbable: true, wrap: true}); expect(document.activeElement).toBe(input2Ref.current); }); it('focuses the previous focusable element before the given element', () => { - const focusManagerRef = React.createRef(); const inputRef = React.createRef(); const divRef = React.createRef(); const input2Ref = React.createRef(); + let focusManager; - const SimpleFocusScope = () => ( + const ScopeParent = () => (
- + + + +
+ ); + + const Child = () => { + focusManager = useFocusManager(); + return ( +
- -
- ); +
+ ); + }; - ReactDOM.render(, container); + ReactDOM.render(, container); - focusManagerRef.current.focusPrevious({from: divRef.current}); + focusManager.focusPrevious({from: divRef.current}); expect(document.activeElement).toBe(inputRef.current); }); }); }); - - describe('useFocusManager hook', () => { - it('returns a focus manager', () => { - let focusManager; - - const ScopeParent = () => ( - - - - ); - - const Child = () => { - focusManager = useFocusManager(); - return null; - }; - - ReactDOM.render(, container); - - expect(focusManager).toHaveProperty('focusPrevious'); - expect(focusManager).toHaveProperty('focusNext'); - }); - - it('throws if not inside a focus scope', () => { - const Child = () => { - useFocusManager(); - return null; - }; - - expect(() => { - ReactDOM.render(, container); - }).toThrow( - 'Tried to call useFocusManager outside of a FocusScope subtree.', - ); - }); - - it('can be used to move focus', () => { - const inputRef = React.createRef(); - const input2Ref = React.createRef(); - const divRef = React.createRef(); - - const ScopeParent = () => ( -
- - - - - - - -
- ); - - const Child = () => { - let focusManager = useFocusManager(); - return ( -
focusManager.focusNext()}> - Focus Next -
- ); - }; - - ReactDOM.render(, container); - - expect(document.activeElement).toBe(inputRef.current); - divRef.current.click(); - expect(document.activeElement).toBe(input2Ref.current); - }); - - it('throws when trying to use a focus manager method on an unmounted component', () => { - const inputRef = React.createRef(); - const input2Ref = React.createRef(); - const ScopeParent = () => ( -
- - - - - - - -
- ); - - const Child = () => { - let focusManager = useFocusManager(); - focusManager.focusNext(); - return null; - }; - - expect(() => { - ReactDOM.render(, container); - }).toThrow( - 'Attempt to use a focus manager method on an unmounted component.', - ); - }); - }); }); From b9e0da406d24430141cc318748baed95da474f9d Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 19 Jun 2019 20:03:34 -0700 Subject: [PATCH 09/11] Prettier --- packages/shared/ReactDOMTypes.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/shared/ReactDOMTypes.js b/packages/shared/ReactDOMTypes.js index 378116d2b40..bee8bdde7b1 100644 --- a/packages/shared/ReactDOMTypes.js +++ b/packages/shared/ReactDOMTypes.js @@ -7,7 +7,10 @@ * @flow */ -import type {EventPriority, ReactEventComponentInstance} from 'shared/ReactTypes'; +import type { + EventPriority, + ReactEventComponentInstance, +} from 'shared/ReactTypes'; type AnyNativeEvent = Event | KeyboardEvent | MouseEvent | Touch; From 216ae81ec7a127f0299839dcc7cf0432e7014a47 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 26 Jun 2019 20:34:54 -0700 Subject: [PATCH 10/11] Use a ref to memoize the focus manager context --- packages/react-events/src/dom/FocusScope.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-events/src/dom/FocusScope.js b/packages/react-events/src/dom/FocusScope.js index 3633292bd3f..0bdd445c9a0 100644 --- a/packages/react-events/src/dom/FocusScope.js +++ b/packages/react-events/src/dom/FocusScope.js @@ -342,7 +342,7 @@ const FocusScopeContext: React.Context = React.createContext(); export function FocusScope(props: FocusScopeProps) { let internalFocusManager: ?FocusManager; - let focusManager = { + let focusManager = React.useRef({ focusNext(options: FocusManagerOptions = {}) { invariant( internalFocusManager != null, @@ -357,7 +357,7 @@ export function FocusScope(props: FocusScopeProps) { ); return internalFocusManager.focusPrevious(options); }, - }; + }); props = { ...props, @@ -367,7 +367,7 @@ export function FocusScope(props: FocusScopeProps) { }; return React.createElement(FocusScopeContext.Provider, { - value: focusManager, + value: focusManager.current, children: React.createElement(FocusScopeEventComponent, props), }); } From ef25df70f77c79ef727cefac5beff29c95aa27b1 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 26 Jun 2019 20:35:09 -0700 Subject: [PATCH 11/11] Add a test for focus manager with nested FocusScope --- .../dom/__tests__/FocusScope-test.internal.js | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/react-events/src/dom/__tests__/FocusScope-test.internal.js b/packages/react-events/src/dom/__tests__/FocusScope-test.internal.js index 12d8fac8aa2..ce1e9396edb 100644 --- a/packages/react-events/src/dom/__tests__/FocusScope-test.internal.js +++ b/packages/react-events/src/dom/__tests__/FocusScope-test.internal.js @@ -341,6 +341,45 @@ describe('FocusScope event responder', () => { ); }); + it('moves focus only within the current scope', () => { + const inputRef = React.createRef(); + const input2Ref = React.createRef(); + const divRef = React.createRef(); + + const ScopeParent = () => ( +
+ + + + + + + + + +
+ ); + + const Child = () => { + let focusManager = useFocusManager(); + return ( +
focusManager.focusNext({wrap: true})}> + Focus Next +
+ ); + }; + + ReactDOM.render(, container); + + expect(document.activeElement).toBe(inputRef.current); + divRef.current.click(); + expect(document.activeElement).toBe(input2Ref.current); + divRef.current.click(); + expect(document.activeElement).toBe(inputRef.current); + }); + describe('focusNext', () => { it('focuses the next focusable element in the current scope', () => { const inputRef = React.createRef();