diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index a918ebc9c9f..b658475bd06 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -37,10 +37,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 { @@ -389,16 +385,9 @@ const eventResponderContext: ReactDOMResponderContext = { } } }, - getFocusableElementsInScope(): Array { + getCurrentInstance(): ReactDOMEventComponentInstance { validateResponderContext(); - const focusableElements = []; - const eventComponentInstance = ((currentInstance: any): ReactDOMEventComponentInstance); - const child = ((eventComponentInstance.currentFiber: any): Fiber).child; - - if (child !== null) { - collectFocusableElements(child, focusableElements); - } - return focusableElements; + return ((currentInstance: any): ReactDOMEventComponentInstance); }, getActiveDocument, objectAssign: Object.assign, @@ -464,33 +453,6 @@ const eventResponderContext: ReactDOMResponderContext = { }, }; -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 getActiveDocument(): Document { return ((currentDocument: any): Document); } @@ -506,33 +468,6 @@ function releaseOwnershipForEventComponentInstance( return false; } -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-events/focus-scope.js b/packages/react-events/focus-scope.js index 70586e2f9ea..0b783029644 100644 --- a/packages/react-events/focus-scope.js +++ b/packages/react-events/focus-scope.js @@ -11,4 +11,4 @@ const FocusScope = require('./src/dom/FocusScope'); -module.exports = FocusScope.default || FocusScope; +module.exports = FocusScope; diff --git a/packages/react-events/src/dom/FocusScope.js b/packages/react-events/src/dom/FocusScope.js index 43a2a05f864..0bdd445c9a0 100644 --- a/packages/react-events/src/dom/FocusScope.js +++ b/packages/react-events/src/dom/FocusScope.js @@ -10,14 +10,23 @@ import type { ReactDOMEventResponder, ReactDOMResponderEvent, ReactDOMResponderContext, + ReactDOMEventComponentInstance, } from 'shared/ReactDOMTypes'; +import type {Fiber} from 'react-reconciler/src/ReactFiber'; import React from 'react'; +import { + isFiberSuspenseAndTimedOut, + getSuspenseFallbackChild, +} from 'react-reconciler/src/ReactFiberEvents'; +import {HostComponent} from 'shared/ReactWorkTags'; +import invariant from 'shared/invariant'; type FocusScopeProps = { - autoFocus: Boolean, - contain: Boolean, - restoreFocus: Boolean, + autoFocus: boolean, + contain: boolean, + restoreFocus: boolean, + getFocusManager: (focusManager: FocusManager) => void, }; type FocusScopeState = { @@ -25,9 +34,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: ReactDOMEventComponentInstance, + 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 { @@ -36,11 +146,89 @@ function focusElement(element: ?HTMLElement) { } } +function moveFocusInScope( + scope: ReactDOMEventComponentInstance, + 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: ReactDOMEventComponentInstance, + 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 createFocusManager( + scope: ReactDOMEventComponentInstance, +): FocusManager { + return { + focusNext(options: ?FocusManagerOptions) { + return moveFocus(scope, options || {}, false); + }, + focusPrevious(options: ?FocusManagerOptions) { + return moveFocus(scope, options || {}, true); + }, + }; +} + function getFirstFocusableElement( context: ReactDOMResponderContext, state: FocusScopeState, ): ?HTMLElement { - const elements = context.getFocusableElementsInScope(); + const elements = getFocusableElementsInScope(context.getCurrentInstance()); if (elements.length > 0) { return elements[0]; } @@ -77,38 +265,20 @@ const FocusScopeResponder: ReactDOMEventResponder = { 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.continueLocalPropagation(); - return; - } - } else { - nextElement = elements[position - 1]; - } + + const nextElement = moveFocusInScope( + context.getCurrentInstance(), + focusedElement, + shiftKey, + { + tabbable: true, + wrap: props.contain, + }, + ); + + if (!nextElement) { + context.continueLocalPropagation(); } else { - if (position === lastPosition) { - if (props.contain) { - nextElement = elements[0]; - } else { - // Out of bounds - context.continueLocalPropagation(); - return; - } - } else { - nextElement = elements[position + 1]; - } - } - if (nextElement !== null) { - focusElement(nextElement); state.currentFocusedNode = nextElement; ((nativeEvent: any): KeyboardEvent).preventDefault(); } @@ -148,6 +318,9 @@ const FocusScopeResponder: ReactDOMEventResponder = { const firstElement = getFirstFocusableElement(context, state); focusElement(firstElement); } + if (props.getFocusManager) { + props.getFocusManager(createFocusManager(context.getCurrentInstance())); + } }, onUnmount( context: ReactDOMResponderContext, @@ -160,4 +333,50 @@ const FocusScopeResponder: ReactDOMEventResponder = { }, }; -export default React.unstable_createEvent(FocusScopeResponder); +export const FocusScopeEventComponent = React.unstable_createEvent( + FocusScopeResponder, + 'FocusScope', +); + +const FocusScopeContext: React.Context = React.createContext(); + +export function FocusScope(props: FocusScopeProps) { + let internalFocusManager: ?FocusManager; + let focusManager = React.useRef({ + 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); + }, + }); + + props = { + ...props, + getFocusManager(manager) { + internalFocusManager = manager; + }, + }; + + return React.createElement(FocusScopeContext.Provider, { + value: focusManager.current, + 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/dom/__tests__/FocusScope-test.internal.js b/packages/react-events/src/dom/__tests__/FocusScope-test.internal.js index 3930b25e1fa..ce1e9396edb 100644 --- a/packages/react-events/src/dom/__tests__/FocusScope-test.internal.js +++ b/packages/react-events/src/dom/__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); @@ -103,6 +105,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); @@ -241,4 +245,505 @@ describe('FocusScope event responder', () => { document.activeElement.dispatchEvent(createTabBackward()); expect(document.activeElement).toBe(button2Ref.current); }); + + 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.', + ); + }); + + 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(); + const divRef = React.createRef(); + const input2Ref = React.createRef(); + let focusManager; + + const ScopeParent = () => ( +
+ + + +
+ ); + + const Child = () => { + focusManager = useFocusManager(); + return ( +
+ + +
+ + +
+ ); + }; + + 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(); + let focusManager; + + const ScopeParent = () => ( +
+ + + +
+ ); + + const Child = () => { + focusManager = useFocusManager(); + return ( +
+ + +
+ + +
+ ); + }; + + 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(); + let focusManager; + + const ScopeParent = () => ( +
+ + + +
+ ); + + const Child = () => { + focusManager = useFocusManager(); + return ( +
+ + +
+ + +
+ ); + }; + + 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(); + let focusManager; + + const ScopeParent = () => ( +
+ + + +
+ ); + + const Child = () => { + focusManager = useFocusManager(); + return ( +
+ + +
+ + +
+ ); + }; + + 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(); + let focusManager; + + const ScopeParent = () => ( +
+ + + +
+ ); + + const Child = () => { + focusManager = useFocusManager(); + return ( +
+ + +
+ + +
+ ); + }; + + 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(); + let focusManager; + + const ScopeParent = () => ( +
+ + + +
+ ); + + const Child = () => { + focusManager = useFocusManager(); + return ( +
+ + +
+ + +
+ ); + }; + + 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(); + let focusManager; + + const ScopeParent = () => ( +
+ + + +
+ ); + + const Child = () => { + focusManager = useFocusManager(); + return ( +
+ + +
+ + +
+ ); + }; + + 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(); + let focusManager; + + const ScopeParent = () => ( +
+ + + +
+ ); + + const Child = () => { + focusManager = useFocusManager(); + return ( +
+ + +
+ + +
+ ); + }; + + 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(); + let focusManager; + + const ScopeParent = () => ( +
+ + + +
+ ); + + const Child = () => { + focusManager = useFocusManager(); + return ( +
+ + +
+ + +
+ ); + }; + + 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(); + let focusManager; + + const ScopeParent = () => ( +
+ + + +
+ ); + + const Child = () => { + focusManager = useFocusManager(); + return ( +
+ + +
+ + +
+ ); + }; + + ReactDOM.render(, container); + + focusManager.focusPrevious({from: divRef.current}); + expect(document.activeElement).toBe(inputRef.current); + }); + }); + }); }); diff --git a/packages/shared/ReactDOMTypes.js b/packages/shared/ReactDOMTypes.js index 920e6ec1714..357a3941183 100644 --- a/packages/shared/ReactDOMTypes.js +++ b/packages/shared/ReactDOMTypes.js @@ -73,7 +73,7 @@ export type ReactDOMResponderContext = { releaseOwnership: () => boolean, setTimeout: (func: () => void, timeout: number) => number, clearTimeout: (timerId: number) => void, - getFocusableElementsInScope(): Array, + getCurrentInstance(): ReactDOMEventComponentInstance, getActiveDocument(): Document, objectAssign: Function, getEventCurrentTarget(event: ReactDOMResponderEvent): Element, diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index caacc6085ef..2b5f9ad8921 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -334,5 +334,7 @@ "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": "The \"%s\" event responder cannot be used via the \"useEvent\" hook." + "336": "The \"%s\" event responder cannot be used via the \"useEvent\" hook.", + "337": "Tried to call useFocusManager outside of a FocusScope subtree.", + "338": "Attempt to use a focus manager method on an unmounted component." }