/* * Copyright 2020 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ import {FocusableElement, RefObject} from '@react-types/shared'; import {focusSafely} from './focusSafely'; import {getOwnerDocument, useLayoutEffect} from '@react-aria/utils'; import {isElementVisible} from './isElementVisible'; import React, {ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; export interface FocusScopeProps { /** The contents of the focus scope. */ children: ReactNode, /** * Whether to contain focus inside the scope, so users cannot * move focus outside, for example in a modal dialog. */ contain?: boolean, /** * Whether to restore focus back to the element that was focused * when the focus scope mounted, after the focus scope unmounts. */ restoreFocus?: boolean, /** Whether to auto focus the first focusable element in the focus scope on mount. */ autoFocus?: boolean } export interface FocusManagerOptions { /** The element to start searching from. The currently focused element by default. */ from?: Element, /** Whether to only include tabbable elements, or all focusable elements. */ tabbable?: boolean, /** Whether focus should wrap around when it reaches the end of the scope. */ wrap?: boolean, /** A callback that determines whether the given element is focused. */ accept?: (node: Element) => boolean } export interface FocusManager { /** Moves focus to the next focusable or tabbable element in the focus scope. */ focusNext(opts?: FocusManagerOptions): FocusableElement | null, /** Moves focus to the previous focusable or tabbable element in the focus scope. */ focusPrevious(opts?: FocusManagerOptions): FocusableElement | null, /** Moves focus to the first focusable or tabbable element in the focus scope. */ focusFirst(opts?: FocusManagerOptions): FocusableElement | null, /** Moves focus to the last focusable or tabbable element in the focus scope. */ focusLast(opts?: FocusManagerOptions): FocusableElement | null } type ScopeRef = RefObject | null; interface IFocusContext { focusManager: FocusManager, parentNode: TreeNode | null } const FocusContext = React.createContext(null); const RESTORE_FOCUS_EVENT = 'react-aria-focus-scope-restore'; let activeScope: ScopeRef = null; // This is a hacky DOM-based implementation of a FocusScope until this RFC lands in React: // https://github.com/reactjs/rfcs/pull/109 /** * A FocusScope manages focus for its descendants. It supports containing focus inside * the scope, restoring focus to the previously focused element on unmount, and auto * focusing children on mount. It also acts as a container for a programmatic focus * management interface that can be used to move focus forward and back in response * to user events. */ export function FocusScope(props: FocusScopeProps) { let {children, contain, restoreFocus, autoFocus} = props; let startRef = useRef(null); let endRef = useRef(null); let scopeRef = useRef([]); let {parentNode} = useContext(FocusContext) || {}; // Create a tree node here so we can add children to it even before it is added to the tree. let node = useMemo(() => new TreeNode({scopeRef}), [scopeRef]); useLayoutEffect(() => { // If a new scope mounts outside the active scope, (e.g. DialogContainer launched from a menu), // use the active scope as the parent instead of the parent from context. Layout effects run bottom // up, so if the parent is not yet added to the tree, don't do this. Only the outer-most FocusScope // that is being added should get the activeScope as its parent. let parent = parentNode || focusScopeTree.root; if (focusScopeTree.getTreeNode(parent.scopeRef) && activeScope && !isAncestorScope(activeScope, parent.scopeRef)) { let activeNode = focusScopeTree.getTreeNode(activeScope); if (activeNode) { parent = activeNode; } } // Add the node to the parent, and to the tree. parent.addChild(node); focusScopeTree.addNode(node); }, [node, parentNode]); useLayoutEffect(() => { let node = focusScopeTree.getTreeNode(scopeRef); if (node) { node.contain = !!contain; } }, [contain]); useLayoutEffect(() => { // Find all rendered nodes between the sentinels and add them to the scope. let node = startRef.current?.nextSibling!; let nodes: Element[] = []; let stopPropagation = e => e.stopPropagation(); while (node && node !== endRef.current) { nodes.push(node as Element); // Stop custom restore focus event from propagating to parent focus scopes. node.addEventListener(RESTORE_FOCUS_EVENT, stopPropagation); node = node.nextSibling as Element; } scopeRef.current = nodes; return () => { for (let node of nodes) { node.removeEventListener(RESTORE_FOCUS_EVENT, stopPropagation); } }; }, [children]); useActiveScopeTracker(scopeRef, restoreFocus, contain); useFocusContainment(scopeRef, contain); useRestoreFocus(scopeRef, restoreFocus, contain); useAutoFocus(scopeRef, autoFocus); // This needs to be an effect so that activeScope is updated after the FocusScope tree is complete. // It cannot be a useLayoutEffect because the parent of this node hasn't been attached in the tree yet. useEffect(() => { const activeElement = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined).activeElement; let scope: TreeNode | null = null; if (isElementInScope(activeElement, scopeRef.current)) { // We need to traverse the focusScope tree and find the bottom most scope that // contains the active element and set that as the activeScope. for (let node of focusScopeTree.traverse()) { if (node.scopeRef && isElementInScope(activeElement, node.scopeRef.current)) { scope = node; } } if (scope === focusScopeTree.getTreeNode(scopeRef)) { activeScope = scope.scopeRef; } } }, [scopeRef]); // This layout effect cleanup is so that the tree node is removed synchronously with react before the RAF // in useRestoreFocus cleanup runs. useLayoutEffect(() => { return () => { // Scope may have been re-parented. let parentScope = focusScopeTree.getTreeNode(scopeRef)?.parent?.scopeRef ?? null; if ( (scopeRef === activeScope || isAncestorScope(scopeRef, activeScope)) && (!parentScope || focusScopeTree.getTreeNode(parentScope)) ) { activeScope = parentScope; } focusScopeTree.removeTreeNode(scopeRef); }; }, [scopeRef]); let focusManager = useMemo(() => createFocusManagerForScope(scopeRef), []); let value = useMemo(() => ({ focusManager, parentNode: node }), [node, focusManager]); return ( ); } /** * Returns a FocusManager interface for the parent FocusScope. * A FocusManager can be used to programmatically move focus within * a FocusScope, e.g. in response to user events like keyboard navigation. */ export function useFocusManager(): FocusManager | undefined { return useContext(FocusContext)?.focusManager; } function createFocusManagerForScope(scopeRef: React.RefObject): FocusManager { return { focusNext(opts: FocusManagerOptions = {}) { let scope = scopeRef.current!; let {from, tabbable, wrap, accept} = opts; let node = from || getOwnerDocument(scope[0]).activeElement!; let sentinel = scope[0].previousElementSibling!; let scopeRoot = getScopeRoot(scope); let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope); walker.currentNode = isElementInScope(node, scope) ? node : sentinel; let nextNode = walker.nextNode() as FocusableElement; if (!nextNode && wrap) { walker.currentNode = sentinel; nextNode = walker.nextNode() as FocusableElement; } if (nextNode) { focusElement(nextNode, true); } return nextNode; }, focusPrevious(opts: FocusManagerOptions = {}) { let scope = scopeRef.current!; let {from, tabbable, wrap, accept} = opts; let node = from || getOwnerDocument(scope[0]).activeElement!; let sentinel = scope[scope.length - 1].nextElementSibling!; let scopeRoot = getScopeRoot(scope); let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope); walker.currentNode = isElementInScope(node, scope) ? node : sentinel; let previousNode = walker.previousNode() as FocusableElement; if (!previousNode && wrap) { walker.currentNode = sentinel; previousNode = walker.previousNode() as FocusableElement; } if (previousNode) { focusElement(previousNode, true); } return previousNode; }, focusFirst(opts = {}) { let scope = scopeRef.current!; let {tabbable, accept} = opts; let scopeRoot = getScopeRoot(scope); let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope); walker.currentNode = scope[0].previousElementSibling!; let nextNode = walker.nextNode() as FocusableElement; if (nextNode) { focusElement(nextNode, true); } return nextNode; }, focusLast(opts = {}) { let scope = scopeRef.current!; let {tabbable, accept} = opts; let scopeRoot = getScopeRoot(scope); let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope); walker.currentNode = scope[scope.length - 1].nextElementSibling!; let previousNode = walker.previousNode() as FocusableElement; if (previousNode) { focusElement(previousNode, true); } return previousNode; } }; } const focusableElements = [ 'input:not([disabled]):not([type=hidden])', 'select:not([disabled])', 'textarea:not([disabled])', 'button:not([disabled])', 'a[href]', 'area[href]', 'summary', 'iframe', 'object', 'embed', 'audio[controls]', 'video[controls]', '[contenteditable]' ]; const FOCUSABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]),') + ',[tabindex]:not([disabled]):not([hidden])'; focusableElements.push('[tabindex]:not([tabindex="-1"]):not([disabled])'); const TABBABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]):not([tabindex="-1"]),'); export function isFocusable(element: HTMLElement) { return element.matches(FOCUSABLE_ELEMENT_SELECTOR); } function getScopeRoot(scope: Element[]) { return scope[0].parentElement!; } function shouldContainFocus(scopeRef: ScopeRef) { let scope = focusScopeTree.getTreeNode(activeScope); while (scope && scope.scopeRef !== scopeRef) { if (scope.contain) { return false; } scope = scope.parent; } return true; } function useFocusContainment(scopeRef: RefObject, contain?: boolean) { let focusedNode = useRef(undefined); let raf = useRef>(undefined); useLayoutEffect(() => { let scope = scopeRef.current; if (!contain) { // if contain was changed, then we should cancel any ongoing waits to pull focus back into containment if (raf.current) { cancelAnimationFrame(raf.current); raf.current = undefined; } return; } const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined); // Handle the Tab key to contain focus within the scope let onKeyDown = (e) => { if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey || !shouldContainFocus(scopeRef) || e.isComposing) { return; } let focusedElement = ownerDocument.activeElement; let scope = scopeRef.current; if (!scope || !isElementInScope(focusedElement, scope)) { return; } let scopeRoot = getScopeRoot(scope); let walker = getFocusableTreeWalker(scopeRoot, {tabbable: true}, scope); if (!focusedElement) { return; } walker.currentNode = focusedElement; let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement; if (!nextElement) { walker.currentNode = e.shiftKey ? scope[scope.length - 1].nextElementSibling! : scope[0].previousElementSibling!; nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement; } e.preventDefault(); if (nextElement) { focusElement(nextElement, true); } }; let onFocus = (e) => { // If focusing an element in a child scope of the currently active scope, the child becomes active. // Moving out of the active scope to an ancestor is not allowed. if ((!activeScope || isAncestorScope(activeScope, scopeRef)) && isElementInScope(e.target, scopeRef.current)) { activeScope = scopeRef; focusedNode.current = e.target; } else if (shouldContainFocus(scopeRef) && !isElementInChildScope(e.target, scopeRef)) { // If a focus event occurs outside the active scope (e.g. user tabs from browser location bar), // restore focus to the previously focused node or the first tabbable element in the active scope. if (focusedNode.current) { focusedNode.current.focus(); } else if (activeScope && activeScope.current) { focusFirstInScope(activeScope.current); } } else if (shouldContainFocus(scopeRef)) { focusedNode.current = e.target; } }; let onBlur = (e) => { // Firefox doesn't shift focus back to the Dialog properly without this if (raf.current) { cancelAnimationFrame(raf.current); } raf.current = requestAnimationFrame(() => { // Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe if (ownerDocument.activeElement && shouldContainFocus(scopeRef) && !isElementInChildScope(ownerDocument.activeElement, scopeRef)) { activeScope = scopeRef; if (ownerDocument.body.contains(e.target)) { focusedNode.current = e.target; focusedNode.current?.focus(); } else if (activeScope.current) { focusFirstInScope(activeScope.current); } } }); }; ownerDocument.addEventListener('keydown', onKeyDown, false); ownerDocument.addEventListener('focusin', onFocus, false); scope?.forEach(element => element.addEventListener('focusin', onFocus, false)); scope?.forEach(element => element.addEventListener('focusout', onBlur, false)); return () => { ownerDocument.removeEventListener('keydown', onKeyDown, false); ownerDocument.removeEventListener('focusin', onFocus, false); scope?.forEach(element => element.removeEventListener('focusin', onFocus, false)); scope?.forEach(element => element.removeEventListener('focusout', onBlur, false)); }; }, [scopeRef, contain]); // This is a useLayoutEffect so it is guaranteed to run before our async synthetic blur // eslint-disable-next-line arrow-body-style useLayoutEffect(() => { return () => { if (raf.current) { cancelAnimationFrame(raf.current); } }; }, [raf]); } function isElementInAnyScope(element: Element) { return isElementInChildScope(element); } function isElementInScope(element?: Element | null, scope?: Element[] | null) { if (!element) { return false; } if (!scope) { return false; } return scope.some(node => node.contains(element)); } function isElementInChildScope(element: Element, scope: ScopeRef = null) { // If the element is within a top layer element (e.g. toasts), always allow moving focus there. if (element instanceof Element && element.closest('[data-react-aria-top-layer]')) { return true; } // node.contains in isElementInScope covers child scopes that are also DOM children, // but does not cover child scopes in portals. for (let {scopeRef: s} of focusScopeTree.traverse(focusScopeTree.getTreeNode(scope))) { if (s && isElementInScope(element, s.current)) { return true; } } return false; } /** @private */ export function isElementInChildOfActiveScope(element: Element) { return isElementInChildScope(element, activeScope); } function isAncestorScope(ancestor: ScopeRef, scope: ScopeRef) { let parent = focusScopeTree.getTreeNode(scope)?.parent; while (parent) { if (parent.scopeRef === ancestor) { return true; } parent = parent.parent; } return false; } function focusElement(element: FocusableElement | null, scroll = false) { if (element != null && !scroll) { try { focusSafely(element); } catch (err) { // ignore } } else if (element != null) { try { element.focus(); } catch (err) { // ignore } } } function getFirstInScope(scope: Element[], tabbable = true) { let sentinel = scope[0].previousElementSibling!; let scopeRoot = getScopeRoot(scope); let walker = getFocusableTreeWalker(scopeRoot, {tabbable}, scope); walker.currentNode = sentinel; let nextNode = walker.nextNode(); // If the scope does not contain a tabbable element, use the first focusable element. if (tabbable && !nextNode) { scopeRoot = getScopeRoot(scope); walker = getFocusableTreeWalker(scopeRoot, {tabbable: false}, scope); walker.currentNode = sentinel; nextNode = walker.nextNode(); } return nextNode as FocusableElement; } function focusFirstInScope(scope: Element[], tabbable:boolean = true) { focusElement(getFirstInScope(scope, tabbable)); } function useAutoFocus(scopeRef: RefObject, autoFocus?: boolean) { const autoFocusRef = React.useRef(autoFocus); useEffect(() => { if (autoFocusRef.current) { activeScope = scopeRef; const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); if (!isElementInScope(ownerDocument.activeElement, activeScope.current) && scopeRef.current) { focusFirstInScope(scopeRef.current); } } autoFocusRef.current = false; }, [scopeRef]); } function useActiveScopeTracker(scopeRef: RefObject, restore?: boolean, contain?: boolean) { // tracks the active scope, in case restore and contain are both false. // if either are true, this is tracked in useRestoreFocus or useFocusContainment. useLayoutEffect(() => { if (restore || contain) { return; } let scope = scopeRef.current; const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined); let onFocus = (e) => { let target = e.target as Element; if (isElementInScope(target, scopeRef.current)) { activeScope = scopeRef; } else if (!isElementInAnyScope(target)) { activeScope = null; } }; ownerDocument.addEventListener('focusin', onFocus, false); scope?.forEach(element => element.addEventListener('focusin', onFocus, false)); return () => { ownerDocument.removeEventListener('focusin', onFocus, false); scope?.forEach(element => element.removeEventListener('focusin', onFocus, false)); }; }, [scopeRef, restore, contain]); } function shouldRestoreFocus(scopeRef: ScopeRef) { let scope = focusScopeTree.getTreeNode(activeScope); while (scope && scope.scopeRef !== scopeRef) { if (scope.nodeToRestore) { return false; } scope = scope.parent; } return scope?.scopeRef === scopeRef; } function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, contain?: boolean) { // create a ref during render instead of useLayoutEffect so the active element is saved before a child with autoFocus=true mounts. // eslint-disable-next-line no-restricted-globals const nodeToRestoreRef = useRef(typeof document !== 'undefined' ? getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined).activeElement as FocusableElement : null); // restoring scopes should all track if they are active regardless of contain, but contain already tracks it plus logic to contain the focus // restoring-non-containing scopes should only care if they become active so they can perform the restore useLayoutEffect(() => { let scope = scopeRef.current; const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined); if (!restoreFocus || contain) { return; } let onFocus = () => { // If focusing an element in a child scope of the currently active scope, the child becomes active. // Moving out of the active scope to an ancestor is not allowed. if ((!activeScope || isAncestorScope(activeScope, scopeRef)) && isElementInScope(ownerDocument.activeElement, scopeRef.current) ) { activeScope = scopeRef; } }; ownerDocument.addEventListener('focusin', onFocus, false); scope?.forEach(element => element.addEventListener('focusin', onFocus, false)); return () => { ownerDocument.removeEventListener('focusin', onFocus, false); scope?.forEach(element => element.removeEventListener('focusin', onFocus, false)); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [scopeRef, contain]); useLayoutEffect(() => { const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); if (!restoreFocus) { return; } // Handle the Tab key so that tabbing out of the scope goes to the next element // after the node that had focus when the scope mounted. This is important when // using portals for overlays, so that focus goes to the expected element when // tabbing out of the overlay. let onKeyDown = (e: KeyboardEvent) => { if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey || !shouldContainFocus(scopeRef) || e.isComposing) { return; } let focusedElement = ownerDocument.activeElement as FocusableElement; if (!isElementInScope(focusedElement, scopeRef.current)) { return; } let treeNode = focusScopeTree.getTreeNode(scopeRef); if (!treeNode) { return; } let nodeToRestore = treeNode.nodeToRestore; // Create a DOM tree walker that matches all tabbable elements let walker = getFocusableTreeWalker(ownerDocument.body, {tabbable: true}); // Find the next tabbable element after the currently focused element walker.currentNode = focusedElement; let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement; if (!nodeToRestore || !ownerDocument.body.contains(nodeToRestore) || nodeToRestore === ownerDocument.body) { nodeToRestore = undefined; treeNode.nodeToRestore = undefined; } // If there is no next element, or it is outside the current scope, move focus to the // next element after the node to restore to instead. if ((!nextElement || !isElementInScope(nextElement, scopeRef.current)) && nodeToRestore) { walker.currentNode = nodeToRestore; // Skip over elements within the scope, in case the scope immediately follows the node to restore. do { nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement; } while (isElementInScope(nextElement, scopeRef.current)); e.preventDefault(); e.stopPropagation(); if (nextElement) { focusElement(nextElement, true); } else { // If there is no next element and the nodeToRestore isn't within a FocusScope (i.e. we are leaving the top level focus scope) // then move focus to the body. // Otherwise restore focus to the nodeToRestore (e.g menu within a popover -> tabbing to close the menu should move focus to menu trigger) if (!isElementInAnyScope(nodeToRestore)) { focusedElement.blur(); } else { focusElement(nodeToRestore, true); } } } }; if (!contain) { ownerDocument.addEventListener('keydown', onKeyDown, true); } return () => { if (!contain) { ownerDocument.removeEventListener('keydown', onKeyDown, true); } }; }, [scopeRef, restoreFocus, contain]); // useLayoutEffect instead of useEffect so the active element is saved synchronously instead of asynchronously. useLayoutEffect(() => { const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); if (!restoreFocus) { return; } let treeNode = focusScopeTree.getTreeNode(scopeRef); if (!treeNode) { return; } treeNode.nodeToRestore = nodeToRestoreRef.current ?? undefined; return () => { let treeNode = focusScopeTree.getTreeNode(scopeRef); if (!treeNode) { return; } let nodeToRestore = treeNode.nodeToRestore; // if we already lost focus to the body and this was the active scope, then we should attempt to restore if ( restoreFocus && nodeToRestore && ( // eslint-disable-next-line react-hooks/exhaustive-deps (isElementInScope(ownerDocument.activeElement, scopeRef.current) || (ownerDocument.activeElement === ownerDocument.body && shouldRestoreFocus(scopeRef))) ) ) { // freeze the focusScopeTree so it persists after the raf, otherwise during unmount nodes are removed from it let clonedTree = focusScopeTree.clone(); requestAnimationFrame(() => { // Only restore focus if we've lost focus to the body, the alternative is that focus has been purposefully moved elsewhere if (ownerDocument.activeElement === ownerDocument.body) { // look up the tree starting with our scope to find a nodeToRestore still in the DOM let treeNode = clonedTree.getTreeNode(scopeRef); while (treeNode) { if (treeNode.nodeToRestore && treeNode.nodeToRestore.isConnected) { restoreFocusToElement(treeNode.nodeToRestore); return; } treeNode = treeNode.parent; } // If no nodeToRestore was found, focus the first element in the nearest // ancestor scope that is still in the tree. treeNode = clonedTree.getTreeNode(scopeRef); while (treeNode) { if (treeNode.scopeRef && treeNode.scopeRef.current && focusScopeTree.getTreeNode(treeNode.scopeRef)) { let node = getFirstInScope(treeNode.scopeRef.current, true); restoreFocusToElement(node); return; } treeNode = treeNode.parent; } } }); } }; }, [scopeRef, restoreFocus]); } function restoreFocusToElement(node: FocusableElement) { // Dispatch a custom event that parent elements can intercept to customize focus restoration. // For example, virtualized collection components reuse DOM elements, so the original element // might still exist in the DOM but representing a different item. if (node.dispatchEvent(new CustomEvent(RESTORE_FOCUS_EVENT, {bubbles: true, cancelable: true}))) { focusElement(node); } } /** * Create a [TreeWalker]{@link https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker} * that matches all focusable/tabbable elements. */ export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions, scope?: Element[]) { let selector = opts?.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR; let walker = getOwnerDocument(root).createTreeWalker( root, NodeFilter.SHOW_ELEMENT, { acceptNode(node) { // Skip nodes inside the starting node. if (opts?.from?.contains(node)) { return NodeFilter.FILTER_REJECT; } if ((node as Element).matches(selector) && isElementVisible(node as Element) && (!scope || isElementInScope(node as Element, scope)) && (!opts?.accept || opts.accept(node as Element)) ) { return NodeFilter.FILTER_ACCEPT; } return NodeFilter.FILTER_SKIP; } } ); if (opts?.from) { walker.currentNode = opts.from; } return walker; } /** * Creates a FocusManager object that can be used to move focus within an element. */ export function createFocusManager(ref: RefObject, defaultOptions: FocusManagerOptions = {}): FocusManager { return { focusNext(opts: FocusManagerOptions = {}) { let root = ref.current; if (!root) { return null; } let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts; let node = from || getOwnerDocument(root).activeElement; let walker = getFocusableTreeWalker(root, {tabbable, accept}); if (root.contains(node)) { walker.currentNode = node!; } let nextNode = walker.nextNode() as FocusableElement; if (!nextNode && wrap) { walker.currentNode = root; nextNode = walker.nextNode() as FocusableElement; } if (nextNode) { focusElement(nextNode, true); } return nextNode; }, focusPrevious(opts: FocusManagerOptions = defaultOptions) { let root = ref.current; if (!root) { return null; } let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts; let node = from || getOwnerDocument(root).activeElement; let walker = getFocusableTreeWalker(root, {tabbable, accept}); if (root.contains(node)) { walker.currentNode = node!; } else { let next = last(walker); if (next) { focusElement(next, true); } return next ?? null; } let previousNode = walker.previousNode() as FocusableElement; if (!previousNode && wrap) { walker.currentNode = root; let lastNode = last(walker); if (!lastNode) { // couldn't wrap return null; } previousNode = lastNode; } if (previousNode) { focusElement(previousNode, true); } return previousNode ?? null; }, focusFirst(opts = defaultOptions) { let root = ref.current; if (!root) { return null; } let {tabbable = defaultOptions.tabbable, accept = defaultOptions.accept} = opts; let walker = getFocusableTreeWalker(root, {tabbable, accept}); let nextNode = walker.nextNode() as FocusableElement; if (nextNode) { focusElement(nextNode, true); } return nextNode; }, focusLast(opts = defaultOptions) { let root = ref.current; if (!root) { return null; } let {tabbable = defaultOptions.tabbable, accept = defaultOptions.accept} = opts; let walker = getFocusableTreeWalker(root, {tabbable, accept}); let next = last(walker); if (next) { focusElement(next, true); } return next ?? null; } }; } function last(walker: TreeWalker) { let next: FocusableElement | undefined = undefined; let last: FocusableElement; do { last = walker.lastChild() as FocusableElement; if (last) { next = last; } } while (last); return next; } class Tree { root: TreeNode; private fastMap = new Map(); constructor() { this.root = new TreeNode({scopeRef: null}); this.fastMap.set(null, this.root); } get size() { return this.fastMap.size; } getTreeNode(data: ScopeRef) { return this.fastMap.get(data); } addTreeNode(scopeRef: ScopeRef, parent: ScopeRef, nodeToRestore?: FocusableElement) { let parentNode = this.fastMap.get(parent ?? null); if (!parentNode) { return; } let node = new TreeNode({scopeRef}); parentNode.addChild(node); node.parent = parentNode; this.fastMap.set(scopeRef, node); if (nodeToRestore) { node.nodeToRestore = nodeToRestore; } } addNode(node: TreeNode) { this.fastMap.set(node.scopeRef, node); } removeTreeNode(scopeRef: ScopeRef) { // never remove the root if (scopeRef === null) { return; } let node = this.fastMap.get(scopeRef); if (!node) { return; } let parentNode = node.parent; // when we remove a scope, check if any sibling scopes are trying to restore focus to something inside the scope we're removing // if we are, then replace the siblings restore with the restore from the scope we're removing for (let current of this.traverse()) { if ( current !== node && node.nodeToRestore && current.nodeToRestore && node.scopeRef && node.scopeRef.current && isElementInScope(current.nodeToRestore, node.scopeRef.current) ) { current.nodeToRestore = node.nodeToRestore; } } let children = node.children; if (parentNode) { parentNode.removeChild(node); if (children.size > 0) { children.forEach(child => parentNode && parentNode.addChild(child)); } } this.fastMap.delete(node.scopeRef); } // Pre Order Depth First *traverse(node: TreeNode = this.root): Generator { if (node.scopeRef != null) { yield node; } if (node.children.size > 0) { for (let child of node.children) { yield* this.traverse(child); } } } clone(): Tree { let newTree = new Tree(); for (let node of this.traverse()) { newTree.addTreeNode(node.scopeRef, node.parent?.scopeRef ?? null, node.nodeToRestore); } return newTree; } } class TreeNode { public scopeRef: ScopeRef; public nodeToRestore?: FocusableElement; public parent?: TreeNode; public children: Set = new Set(); public contain = false; constructor(props: {scopeRef: ScopeRef}) { this.scopeRef = props.scopeRef; } addChild(node: TreeNode) { this.children.add(node); node.parent = this; } removeChild(node: TreeNode) { this.children.delete(node); node.parent = undefined; } } export let focusScopeTree = new Tree();