UNPKG

35.1 kBTypeScriptView Raw
1/*
2 * Copyright 2020 Adobe. All rights reserved.
3 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4 * you may not use this file except in compliance with the License. You may obtain a copy
5 * of the License at http://www.apache.org/licenses/LICENSE-2.0
6 *
7 * Unless required by applicable law or agreed to in writing, software distributed under
8 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9 * OF ANY KIND, either express or implied. See the License for the specific language
10 * governing permissions and limitations under the License.
11 */
12
13import {FocusableElement, RefObject} from '@react-types/shared';
14import {focusSafely} from './focusSafely';
15import {getOwnerDocument, useLayoutEffect} from '@react-aria/utils';
16import {isElementVisible} from './isElementVisible';
17import React, {ReactNode, useContext, useEffect, useMemo, useRef} from 'react';
18
19export interface FocusScopeProps {
20 /** The contents of the focus scope. */
21 children: ReactNode,
22
23 /**
24 * Whether to contain focus inside the scope, so users cannot
25 * move focus outside, for example in a modal dialog.
26 */
27 contain?: boolean,
28
29 /**
30 * Whether to restore focus back to the element that was focused
31 * when the focus scope mounted, after the focus scope unmounts.
32 */
33 restoreFocus?: boolean,
34
35 /** Whether to auto focus the first focusable element in the focus scope on mount. */
36 autoFocus?: boolean
37}
38
39export interface FocusManagerOptions {
40 /** The element to start searching from. The currently focused element by default. */
41 from?: Element,
42 /** Whether to only include tabbable elements, or all focusable elements. */
43 tabbable?: boolean,
44 /** Whether focus should wrap around when it reaches the end of the scope. */
45 wrap?: boolean,
46 /** A callback that determines whether the given element is focused. */
47 accept?: (node: Element) => boolean
48}
49
50export interface FocusManager {
51 /** Moves focus to the next focusable or tabbable element in the focus scope. */
52 focusNext(opts?: FocusManagerOptions): FocusableElement | null,
53 /** Moves focus to the previous focusable or tabbable element in the focus scope. */
54 focusPrevious(opts?: FocusManagerOptions): FocusableElement | null,
55 /** Moves focus to the first focusable or tabbable element in the focus scope. */
56 focusFirst(opts?: FocusManagerOptions): FocusableElement | null,
57 /** Moves focus to the last focusable or tabbable element in the focus scope. */
58 focusLast(opts?: FocusManagerOptions): FocusableElement | null
59}
60
61type ScopeRef = RefObject<Element[] | null> | null;
62interface IFocusContext {
63 focusManager: FocusManager,
64 parentNode: TreeNode | null
65}
66
67const FocusContext = React.createContext<IFocusContext | null>(null);
68const RESTORE_FOCUS_EVENT = 'react-aria-focus-scope-restore';
69
70let activeScope: ScopeRef = null;
71
72// This is a hacky DOM-based implementation of a FocusScope until this RFC lands in React:
73// https://github.com/reactjs/rfcs/pull/109
74
75/**
76 * A FocusScope manages focus for its descendants. It supports containing focus inside
77 * the scope, restoring focus to the previously focused element on unmount, and auto
78 * focusing children on mount. It also acts as a container for a programmatic focus
79 * management interface that can be used to move focus forward and back in response
80 * to user events.
81 */
82export function FocusScope(props: FocusScopeProps) {
83 let {children, contain, restoreFocus, autoFocus} = props;
84 let startRef = useRef<HTMLSpanElement>(null);
85 let endRef = useRef<HTMLSpanElement>(null);
86 let scopeRef = useRef<Element[]>([]);
87 let {parentNode} = useContext(FocusContext) || {};
88
89 // Create a tree node here so we can add children to it even before it is added to the tree.
90 let node = useMemo(() => new TreeNode({scopeRef}), [scopeRef]);
91
92 useLayoutEffect(() => {
93 // If a new scope mounts outside the active scope, (e.g. DialogContainer launched from a menu),
94 // use the active scope as the parent instead of the parent from context. Layout effects run bottom
95 // up, so if the parent is not yet added to the tree, don't do this. Only the outer-most FocusScope
96 // that is being added should get the activeScope as its parent.
97 let parent = parentNode || focusScopeTree.root;
98 if (focusScopeTree.getTreeNode(parent.scopeRef) && activeScope && !isAncestorScope(activeScope, parent.scopeRef)) {
99 let activeNode = focusScopeTree.getTreeNode(activeScope);
100 if (activeNode) {
101 parent = activeNode;
102 }
103 }
104
105 // Add the node to the parent, and to the tree.
106 parent.addChild(node);
107 focusScopeTree.addNode(node);
108 }, [node, parentNode]);
109
110 useLayoutEffect(() => {
111 let node = focusScopeTree.getTreeNode(scopeRef);
112 if (node) {
113 node.contain = !!contain;
114 }
115 }, [contain]);
116
117 useLayoutEffect(() => {
118 // Find all rendered nodes between the sentinels and add them to the scope.
119 let node = startRef.current?.nextSibling!;
120 let nodes: Element[] = [];
121 let stopPropagation = e => e.stopPropagation();
122 while (node && node !== endRef.current) {
123 nodes.push(node as Element);
124 // Stop custom restore focus event from propagating to parent focus scopes.
125 node.addEventListener(RESTORE_FOCUS_EVENT, stopPropagation);
126 node = node.nextSibling as Element;
127 }
128
129 scopeRef.current = nodes;
130
131 return () => {
132 for (let node of nodes) {
133 node.removeEventListener(RESTORE_FOCUS_EVENT, stopPropagation);
134 }
135 };
136 }, [children]);
137
138 useActiveScopeTracker(scopeRef, restoreFocus, contain);
139 useFocusContainment(scopeRef, contain);
140 useRestoreFocus(scopeRef, restoreFocus, contain);
141 useAutoFocus(scopeRef, autoFocus);
142
143 // This needs to be an effect so that activeScope is updated after the FocusScope tree is complete.
144 // It cannot be a useLayoutEffect because the parent of this node hasn't been attached in the tree yet.
145 useEffect(() => {
146 const activeElement = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined).activeElement;
147 let scope: TreeNode | null = null;
148
149 if (isElementInScope(activeElement, scopeRef.current)) {
150 // We need to traverse the focusScope tree and find the bottom most scope that
151 // contains the active element and set that as the activeScope.
152 for (let node of focusScopeTree.traverse()) {
153 if (node.scopeRef && isElementInScope(activeElement, node.scopeRef.current)) {
154 scope = node;
155 }
156 }
157
158 if (scope === focusScopeTree.getTreeNode(scopeRef)) {
159 activeScope = scope.scopeRef;
160 }
161 }
162 }, [scopeRef]);
163
164 // This layout effect cleanup is so that the tree node is removed synchronously with react before the RAF
165 // in useRestoreFocus cleanup runs.
166 useLayoutEffect(() => {
167 return () => {
168 // Scope may have been re-parented.
169 let parentScope = focusScopeTree.getTreeNode(scopeRef)?.parent?.scopeRef ?? null;
170
171 if (
172 (scopeRef === activeScope || isAncestorScope(scopeRef, activeScope)) &&
173 (!parentScope || focusScopeTree.getTreeNode(parentScope))
174 ) {
175 activeScope = parentScope;
176 }
177 focusScopeTree.removeTreeNode(scopeRef);
178 };
179 }, [scopeRef]);
180
181 let focusManager = useMemo(() => createFocusManagerForScope(scopeRef), []);
182 let value = useMemo(() => ({
183 focusManager,
184 parentNode: node
185 }), [node, focusManager]);
186
187 return (
188 <FocusContext.Provider value={value}>
189 <span data-focus-scope-start hidden ref={startRef} />
190 {children}
191 <span data-focus-scope-end hidden ref={endRef} />
192 </FocusContext.Provider>
193 );
194}
195
196/**
197 * Returns a FocusManager interface for the parent FocusScope.
198 * A FocusManager can be used to programmatically move focus within
199 * a FocusScope, e.g. in response to user events like keyboard navigation.
200 */
201export function useFocusManager(): FocusManager | undefined {
202 return useContext(FocusContext)?.focusManager;
203}
204
205function createFocusManagerForScope(scopeRef: React.RefObject<Element[] | null>): FocusManager {
206 return {
207 focusNext(opts: FocusManagerOptions = {}) {
208 let scope = scopeRef.current!;
209 let {from, tabbable, wrap, accept} = opts;
210 let node = from || getOwnerDocument(scope[0]).activeElement!;
211 let sentinel = scope[0].previousElementSibling!;
212 let scopeRoot = getScopeRoot(scope);
213 let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope);
214 walker.currentNode = isElementInScope(node, scope) ? node : sentinel;
215 let nextNode = walker.nextNode() as FocusableElement;
216 if (!nextNode && wrap) {
217 walker.currentNode = sentinel;
218 nextNode = walker.nextNode() as FocusableElement;
219 }
220 if (nextNode) {
221 focusElement(nextNode, true);
222 }
223 return nextNode;
224 },
225 focusPrevious(opts: FocusManagerOptions = {}) {
226 let scope = scopeRef.current!;
227 let {from, tabbable, wrap, accept} = opts;
228 let node = from || getOwnerDocument(scope[0]).activeElement!;
229 let sentinel = scope[scope.length - 1].nextElementSibling!;
230 let scopeRoot = getScopeRoot(scope);
231 let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope);
232 walker.currentNode = isElementInScope(node, scope) ? node : sentinel;
233 let previousNode = walker.previousNode() as FocusableElement;
234 if (!previousNode && wrap) {
235 walker.currentNode = sentinel;
236 previousNode = walker.previousNode() as FocusableElement;
237 }
238 if (previousNode) {
239 focusElement(previousNode, true);
240 }
241 return previousNode;
242 },
243 focusFirst(opts = {}) {
244 let scope = scopeRef.current!;
245 let {tabbable, accept} = opts;
246 let scopeRoot = getScopeRoot(scope);
247 let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope);
248 walker.currentNode = scope[0].previousElementSibling!;
249 let nextNode = walker.nextNode() as FocusableElement;
250 if (nextNode) {
251 focusElement(nextNode, true);
252 }
253 return nextNode;
254 },
255 focusLast(opts = {}) {
256 let scope = scopeRef.current!;
257 let {tabbable, accept} = opts;
258 let scopeRoot = getScopeRoot(scope);
259 let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope);
260 walker.currentNode = scope[scope.length - 1].nextElementSibling!;
261 let previousNode = walker.previousNode() as FocusableElement;
262 if (previousNode) {
263 focusElement(previousNode, true);
264 }
265 return previousNode;
266 }
267 };
268}
269
270const focusableElements = [
271 'input:not([disabled]):not([type=hidden])',
272 'select:not([disabled])',
273 'textarea:not([disabled])',
274 'button:not([disabled])',
275 'a[href]',
276 'area[href]',
277 'summary',
278 'iframe',
279 'object',
280 'embed',
281 'audio[controls]',
282 'video[controls]',
283 '[contenteditable]'
284];
285
286const FOCUSABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]),') + ',[tabindex]:not([disabled]):not([hidden])';
287
288focusableElements.push('[tabindex]:not([tabindex="-1"]):not([disabled])');
289const TABBABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]):not([tabindex="-1"]),');
290
291export function isFocusable(element: HTMLElement) {
292 return element.matches(FOCUSABLE_ELEMENT_SELECTOR);
293}
294
295function getScopeRoot(scope: Element[]) {
296 return scope[0].parentElement!;
297}
298
299function shouldContainFocus(scopeRef: ScopeRef) {
300 let scope = focusScopeTree.getTreeNode(activeScope);
301 while (scope && scope.scopeRef !== scopeRef) {
302 if (scope.contain) {
303 return false;
304 }
305
306 scope = scope.parent;
307 }
308
309 return true;
310}
311
312function useFocusContainment(scopeRef: RefObject<Element[] | null>, contain?: boolean) {
313 let focusedNode = useRef<FocusableElement>(undefined);
314
315 let raf = useRef<ReturnType<typeof requestAnimationFrame>>(undefined);
316 useLayoutEffect(() => {
317 let scope = scopeRef.current;
318 if (!contain) {
319 // if contain was changed, then we should cancel any ongoing waits to pull focus back into containment
320 if (raf.current) {
321 cancelAnimationFrame(raf.current);
322 raf.current = undefined;
323 }
324 return;
325 }
326
327 const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined);
328
329 // Handle the Tab key to contain focus within the scope
330 let onKeyDown = (e) => {
331 if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey || !shouldContainFocus(scopeRef) || e.isComposing) {
332 return;
333 }
334
335 let focusedElement = ownerDocument.activeElement;
336 let scope = scopeRef.current;
337 if (!scope || !isElementInScope(focusedElement, scope)) {
338 return;
339 }
340
341 let scopeRoot = getScopeRoot(scope);
342 let walker = getFocusableTreeWalker(scopeRoot, {tabbable: true}, scope);
343 if (!focusedElement) {
344 return;
345 }
346 walker.currentNode = focusedElement;
347 let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement;
348 if (!nextElement) {
349 walker.currentNode = e.shiftKey ? scope[scope.length - 1].nextElementSibling! : scope[0].previousElementSibling!;
350 nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement;
351 }
352
353 e.preventDefault();
354 if (nextElement) {
355 focusElement(nextElement, true);
356 }
357 };
358
359 let onFocus = (e) => {
360 // If focusing an element in a child scope of the currently active scope, the child becomes active.
361 // Moving out of the active scope to an ancestor is not allowed.
362 if ((!activeScope || isAncestorScope(activeScope, scopeRef)) && isElementInScope(e.target, scopeRef.current)) {
363 activeScope = scopeRef;
364 focusedNode.current = e.target;
365 } else if (shouldContainFocus(scopeRef) && !isElementInChildScope(e.target, scopeRef)) {
366 // If a focus event occurs outside the active scope (e.g. user tabs from browser location bar),
367 // restore focus to the previously focused node or the first tabbable element in the active scope.
368 if (focusedNode.current) {
369 focusedNode.current.focus();
370 } else if (activeScope && activeScope.current) {
371 focusFirstInScope(activeScope.current);
372 }
373 } else if (shouldContainFocus(scopeRef)) {
374 focusedNode.current = e.target;
375 }
376 };
377
378 let onBlur = (e) => {
379 // Firefox doesn't shift focus back to the Dialog properly without this
380 if (raf.current) {
381 cancelAnimationFrame(raf.current);
382 }
383 raf.current = requestAnimationFrame(() => {
384 // Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe
385 if (ownerDocument.activeElement && shouldContainFocus(scopeRef) && !isElementInChildScope(ownerDocument.activeElement, scopeRef)) {
386 activeScope = scopeRef;
387 if (ownerDocument.body.contains(e.target)) {
388 focusedNode.current = e.target;
389 focusedNode.current?.focus();
390 } else if (activeScope.current) {
391 focusFirstInScope(activeScope.current);
392 }
393 }
394 });
395 };
396
397 ownerDocument.addEventListener('keydown', onKeyDown, false);
398 ownerDocument.addEventListener('focusin', onFocus, false);
399 scope?.forEach(element => element.addEventListener('focusin', onFocus, false));
400 scope?.forEach(element => element.addEventListener('focusout', onBlur, false));
401 return () => {
402 ownerDocument.removeEventListener('keydown', onKeyDown, false);
403 ownerDocument.removeEventListener('focusin', onFocus, false);
404 scope?.forEach(element => element.removeEventListener('focusin', onFocus, false));
405 scope?.forEach(element => element.removeEventListener('focusout', onBlur, false));
406 };
407 }, [scopeRef, contain]);
408
409 // This is a useLayoutEffect so it is guaranteed to run before our async synthetic blur
410 // eslint-disable-next-line arrow-body-style
411 useLayoutEffect(() => {
412 return () => {
413 if (raf.current) {
414 cancelAnimationFrame(raf.current);
415 }
416 };
417 }, [raf]);
418}
419
420function isElementInAnyScope(element: Element) {
421 return isElementInChildScope(element);
422}
423
424function isElementInScope(element?: Element | null, scope?: Element[] | null) {
425 if (!element) {
426 return false;
427 }
428 if (!scope) {
429 return false;
430 }
431 return scope.some(node => node.contains(element));
432}
433
434function isElementInChildScope(element: Element, scope: ScopeRef = null) {
435 // If the element is within a top layer element (e.g. toasts), always allow moving focus there.
436 if (element instanceof Element && element.closest('[data-react-aria-top-layer]')) {
437 return true;
438 }
439
440 // node.contains in isElementInScope covers child scopes that are also DOM children,
441 // but does not cover child scopes in portals.
442 for (let {scopeRef: s} of focusScopeTree.traverse(focusScopeTree.getTreeNode(scope))) {
443 if (s && isElementInScope(element, s.current)) {
444 return true;
445 }
446 }
447
448 return false;
449}
450
451/** @private */
452export function isElementInChildOfActiveScope(element: Element) {
453 return isElementInChildScope(element, activeScope);
454}
455
456function isAncestorScope(ancestor: ScopeRef, scope: ScopeRef) {
457 let parent = focusScopeTree.getTreeNode(scope)?.parent;
458 while (parent) {
459 if (parent.scopeRef === ancestor) {
460 return true;
461 }
462 parent = parent.parent;
463 }
464 return false;
465}
466
467function focusElement(element: FocusableElement | null, scroll = false) {
468 if (element != null && !scroll) {
469 try {
470 focusSafely(element);
471 } catch (err) {
472 // ignore
473 }
474 } else if (element != null) {
475 try {
476 element.focus();
477 } catch (err) {
478 // ignore
479 }
480 }
481}
482
483function getFirstInScope(scope: Element[], tabbable = true) {
484 let sentinel = scope[0].previousElementSibling!;
485 let scopeRoot = getScopeRoot(scope);
486 let walker = getFocusableTreeWalker(scopeRoot, {tabbable}, scope);
487 walker.currentNode = sentinel;
488 let nextNode = walker.nextNode();
489
490 // If the scope does not contain a tabbable element, use the first focusable element.
491 if (tabbable && !nextNode) {
492 scopeRoot = getScopeRoot(scope);
493 walker = getFocusableTreeWalker(scopeRoot, {tabbable: false}, scope);
494 walker.currentNode = sentinel;
495 nextNode = walker.nextNode();
496 }
497
498 return nextNode as FocusableElement;
499}
500
501function focusFirstInScope(scope: Element[], tabbable:boolean = true) {
502 focusElement(getFirstInScope(scope, tabbable));
503}
504
505function useAutoFocus(scopeRef: RefObject<Element[] | null>, autoFocus?: boolean) {
506 const autoFocusRef = React.useRef(autoFocus);
507 useEffect(() => {
508 if (autoFocusRef.current) {
509 activeScope = scopeRef;
510 const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined);
511 if (!isElementInScope(ownerDocument.activeElement, activeScope.current) && scopeRef.current) {
512 focusFirstInScope(scopeRef.current);
513 }
514 }
515 autoFocusRef.current = false;
516 }, [scopeRef]);
517}
518
519function useActiveScopeTracker(scopeRef: RefObject<Element[] | null>, restore?: boolean, contain?: boolean) {
520 // tracks the active scope, in case restore and contain are both false.
521 // if either are true, this is tracked in useRestoreFocus or useFocusContainment.
522 useLayoutEffect(() => {
523 if (restore || contain) {
524 return;
525 }
526
527 let scope = scopeRef.current;
528 const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined);
529
530 let onFocus = (e) => {
531 let target = e.target as Element;
532 if (isElementInScope(target, scopeRef.current)) {
533 activeScope = scopeRef;
534 } else if (!isElementInAnyScope(target)) {
535 activeScope = null;
536 }
537 };
538
539 ownerDocument.addEventListener('focusin', onFocus, false);
540 scope?.forEach(element => element.addEventListener('focusin', onFocus, false));
541 return () => {
542 ownerDocument.removeEventListener('focusin', onFocus, false);
543 scope?.forEach(element => element.removeEventListener('focusin', onFocus, false));
544 };
545 }, [scopeRef, restore, contain]);
546}
547
548function shouldRestoreFocus(scopeRef: ScopeRef) {
549 let scope = focusScopeTree.getTreeNode(activeScope);
550 while (scope && scope.scopeRef !== scopeRef) {
551 if (scope.nodeToRestore) {
552 return false;
553 }
554
555 scope = scope.parent;
556 }
557
558 return scope?.scopeRef === scopeRef;
559}
560
561function useRestoreFocus(scopeRef: RefObject<Element[] | null>, restoreFocus?: boolean, contain?: boolean) {
562 // create a ref during render instead of useLayoutEffect so the active element is saved before a child with autoFocus=true mounts.
563 // eslint-disable-next-line no-restricted-globals
564 const nodeToRestoreRef = useRef(typeof document !== 'undefined' ? getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined).activeElement as FocusableElement : null);
565
566 // restoring scopes should all track if they are active regardless of contain, but contain already tracks it plus logic to contain the focus
567 // restoring-non-containing scopes should only care if they become active so they can perform the restore
568 useLayoutEffect(() => {
569 let scope = scopeRef.current;
570 const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined);
571 if (!restoreFocus || contain) {
572 return;
573 }
574
575 let onFocus = () => {
576 // If focusing an element in a child scope of the currently active scope, the child becomes active.
577 // Moving out of the active scope to an ancestor is not allowed.
578 if ((!activeScope || isAncestorScope(activeScope, scopeRef)) &&
579 isElementInScope(ownerDocument.activeElement, scopeRef.current)
580 ) {
581 activeScope = scopeRef;
582 }
583 };
584
585 ownerDocument.addEventListener('focusin', onFocus, false);
586 scope?.forEach(element => element.addEventListener('focusin', onFocus, false));
587 return () => {
588 ownerDocument.removeEventListener('focusin', onFocus, false);
589 scope?.forEach(element => element.removeEventListener('focusin', onFocus, false));
590 };
591 // eslint-disable-next-line react-hooks/exhaustive-deps
592 }, [scopeRef, contain]);
593
594 useLayoutEffect(() => {
595 const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined);
596
597 if (!restoreFocus) {
598 return;
599 }
600
601 // Handle the Tab key so that tabbing out of the scope goes to the next element
602 // after the node that had focus when the scope mounted. This is important when
603 // using portals for overlays, so that focus goes to the expected element when
604 // tabbing out of the overlay.
605 let onKeyDown = (e: KeyboardEvent) => {
606 if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey || !shouldContainFocus(scopeRef) || e.isComposing) {
607 return;
608 }
609
610 let focusedElement = ownerDocument.activeElement as FocusableElement;
611 if (!isElementInScope(focusedElement, scopeRef.current)) {
612 return;
613 }
614 let treeNode = focusScopeTree.getTreeNode(scopeRef);
615 if (!treeNode) {
616 return;
617 }
618 let nodeToRestore = treeNode.nodeToRestore;
619
620 // Create a DOM tree walker that matches all tabbable elements
621 let walker = getFocusableTreeWalker(ownerDocument.body, {tabbable: true});
622
623 // Find the next tabbable element after the currently focused element
624 walker.currentNode = focusedElement;
625 let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement;
626
627 if (!nodeToRestore || !ownerDocument.body.contains(nodeToRestore) || nodeToRestore === ownerDocument.body) {
628 nodeToRestore = undefined;
629 treeNode.nodeToRestore = undefined;
630 }
631
632 // If there is no next element, or it is outside the current scope, move focus to the
633 // next element after the node to restore to instead.
634 if ((!nextElement || !isElementInScope(nextElement, scopeRef.current)) && nodeToRestore) {
635 walker.currentNode = nodeToRestore;
636
637 // Skip over elements within the scope, in case the scope immediately follows the node to restore.
638 do {
639 nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement;
640 } while (isElementInScope(nextElement, scopeRef.current));
641
642 e.preventDefault();
643 e.stopPropagation();
644 if (nextElement) {
645 focusElement(nextElement, true);
646 } else {
647 // If there is no next element and the nodeToRestore isn't within a FocusScope (i.e. we are leaving the top level focus scope)
648 // then move focus to the body.
649 // Otherwise restore focus to the nodeToRestore (e.g menu within a popover -> tabbing to close the menu should move focus to menu trigger)
650 if (!isElementInAnyScope(nodeToRestore)) {
651 focusedElement.blur();
652 } else {
653 focusElement(nodeToRestore, true);
654 }
655 }
656 }
657 };
658
659 if (!contain) {
660 ownerDocument.addEventListener('keydown', onKeyDown, true);
661 }
662
663 return () => {
664 if (!contain) {
665 ownerDocument.removeEventListener('keydown', onKeyDown, true);
666 }
667 };
668 }, [scopeRef, restoreFocus, contain]);
669
670 // useLayoutEffect instead of useEffect so the active element is saved synchronously instead of asynchronously.
671 useLayoutEffect(() => {
672 const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined);
673
674 if (!restoreFocus) {
675 return;
676 }
677
678 let treeNode = focusScopeTree.getTreeNode(scopeRef);
679 if (!treeNode) {
680 return;
681 }
682 treeNode.nodeToRestore = nodeToRestoreRef.current ?? undefined;
683 return () => {
684 let treeNode = focusScopeTree.getTreeNode(scopeRef);
685 if (!treeNode) {
686 return;
687 }
688 let nodeToRestore = treeNode.nodeToRestore;
689
690 // if we already lost focus to the body and this was the active scope, then we should attempt to restore
691 if (
692 restoreFocus
693 && nodeToRestore
694 && (
695 // eslint-disable-next-line react-hooks/exhaustive-deps
696 (isElementInScope(ownerDocument.activeElement, scopeRef.current) || (ownerDocument.activeElement === ownerDocument.body && shouldRestoreFocus(scopeRef)))
697 )
698 ) {
699 // freeze the focusScopeTree so it persists after the raf, otherwise during unmount nodes are removed from it
700 let clonedTree = focusScopeTree.clone();
701 requestAnimationFrame(() => {
702 // Only restore focus if we've lost focus to the body, the alternative is that focus has been purposefully moved elsewhere
703 if (ownerDocument.activeElement === ownerDocument.body) {
704 // look up the tree starting with our scope to find a nodeToRestore still in the DOM
705 let treeNode = clonedTree.getTreeNode(scopeRef);
706 while (treeNode) {
707 if (treeNode.nodeToRestore && treeNode.nodeToRestore.isConnected) {
708 restoreFocusToElement(treeNode.nodeToRestore);
709 return;
710 }
711 treeNode = treeNode.parent;
712 }
713
714 // If no nodeToRestore was found, focus the first element in the nearest
715 // ancestor scope that is still in the tree.
716 treeNode = clonedTree.getTreeNode(scopeRef);
717 while (treeNode) {
718 if (treeNode.scopeRef && treeNode.scopeRef.current && focusScopeTree.getTreeNode(treeNode.scopeRef)) {
719 let node = getFirstInScope(treeNode.scopeRef.current, true);
720 restoreFocusToElement(node);
721 return;
722 }
723 treeNode = treeNode.parent;
724 }
725 }
726 });
727 }
728 };
729 }, [scopeRef, restoreFocus]);
730}
731
732function restoreFocusToElement(node: FocusableElement) {
733 // Dispatch a custom event that parent elements can intercept to customize focus restoration.
734 // For example, virtualized collection components reuse DOM elements, so the original element
735 // might still exist in the DOM but representing a different item.
736 if (node.dispatchEvent(new CustomEvent(RESTORE_FOCUS_EVENT, {bubbles: true, cancelable: true}))) {
737 focusElement(node);
738 }
739}
740
741/**
742 * Create a [TreeWalker]{@link https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker}
743 * that matches all focusable/tabbable elements.
744 */
745export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions, scope?: Element[]) {
746 let selector = opts?.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR;
747 let walker = getOwnerDocument(root).createTreeWalker(
748 root,
749 NodeFilter.SHOW_ELEMENT,
750 {
751 acceptNode(node) {
752 // Skip nodes inside the starting node.
753 if (opts?.from?.contains(node)) {
754 return NodeFilter.FILTER_REJECT;
755 }
756
757 if ((node as Element).matches(selector)
758 && isElementVisible(node as Element)
759 && (!scope || isElementInScope(node as Element, scope))
760 && (!opts?.accept || opts.accept(node as Element))
761 ) {
762 return NodeFilter.FILTER_ACCEPT;
763 }
764
765 return NodeFilter.FILTER_SKIP;
766 }
767 }
768 );
769
770 if (opts?.from) {
771 walker.currentNode = opts.from;
772 }
773
774 return walker;
775}
776
777/**
778 * Creates a FocusManager object that can be used to move focus within an element.
779 */
780export function createFocusManager(ref: RefObject<Element | null>, defaultOptions: FocusManagerOptions = {}): FocusManager {
781 return {
782 focusNext(opts: FocusManagerOptions = {}) {
783 let root = ref.current;
784 if (!root) {
785 return null;
786 }
787 let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts;
788 let node = from || getOwnerDocument(root).activeElement;
789 let walker = getFocusableTreeWalker(root, {tabbable, accept});
790 if (root.contains(node)) {
791 walker.currentNode = node!;
792 }
793 let nextNode = walker.nextNode() as FocusableElement;
794 if (!nextNode && wrap) {
795 walker.currentNode = root;
796 nextNode = walker.nextNode() as FocusableElement;
797 }
798 if (nextNode) {
799 focusElement(nextNode, true);
800 }
801 return nextNode;
802 },
803 focusPrevious(opts: FocusManagerOptions = defaultOptions) {
804 let root = ref.current;
805 if (!root) {
806 return null;
807 }
808 let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts;
809 let node = from || getOwnerDocument(root).activeElement;
810 let walker = getFocusableTreeWalker(root, {tabbable, accept});
811 if (root.contains(node)) {
812 walker.currentNode = node!;
813 } else {
814 let next = last(walker);
815 if (next) {
816 focusElement(next, true);
817 }
818 return next ?? null;
819 }
820 let previousNode = walker.previousNode() as FocusableElement;
821 if (!previousNode && wrap) {
822 walker.currentNode = root;
823 let lastNode = last(walker);
824 if (!lastNode) {
825 // couldn't wrap
826 return null;
827 }
828 previousNode = lastNode;
829 }
830 if (previousNode) {
831 focusElement(previousNode, true);
832 }
833 return previousNode ?? null;
834 },
835 focusFirst(opts = defaultOptions) {
836 let root = ref.current;
837 if (!root) {
838 return null;
839 }
840 let {tabbable = defaultOptions.tabbable, accept = defaultOptions.accept} = opts;
841 let walker = getFocusableTreeWalker(root, {tabbable, accept});
842 let nextNode = walker.nextNode() as FocusableElement;
843 if (nextNode) {
844 focusElement(nextNode, true);
845 }
846 return nextNode;
847 },
848 focusLast(opts = defaultOptions) {
849 let root = ref.current;
850 if (!root) {
851 return null;
852 }
853 let {tabbable = defaultOptions.tabbable, accept = defaultOptions.accept} = opts;
854 let walker = getFocusableTreeWalker(root, {tabbable, accept});
855 let next = last(walker);
856 if (next) {
857 focusElement(next, true);
858 }
859 return next ?? null;
860 }
861 };
862}
863
864function last(walker: TreeWalker) {
865 let next: FocusableElement | undefined = undefined;
866 let last: FocusableElement;
867 do {
868 last = walker.lastChild() as FocusableElement;
869 if (last) {
870 next = last;
871 }
872 } while (last);
873 return next;
874}
875
876
877class Tree {
878 root: TreeNode;
879 private fastMap = new Map<ScopeRef, TreeNode>();
880
881 constructor() {
882 this.root = new TreeNode({scopeRef: null});
883 this.fastMap.set(null, this.root);
884 }
885
886 get size() {
887 return this.fastMap.size;
888 }
889
890 getTreeNode(data: ScopeRef) {
891 return this.fastMap.get(data);
892 }
893
894 addTreeNode(scopeRef: ScopeRef, parent: ScopeRef, nodeToRestore?: FocusableElement) {
895 let parentNode = this.fastMap.get(parent ?? null);
896 if (!parentNode) {
897 return;
898 }
899 let node = new TreeNode({scopeRef});
900 parentNode.addChild(node);
901 node.parent = parentNode;
902 this.fastMap.set(scopeRef, node);
903 if (nodeToRestore) {
904 node.nodeToRestore = nodeToRestore;
905 }
906 }
907
908 addNode(node: TreeNode) {
909 this.fastMap.set(node.scopeRef, node);
910 }
911
912 removeTreeNode(scopeRef: ScopeRef) {
913 // never remove the root
914 if (scopeRef === null) {
915 return;
916 }
917 let node = this.fastMap.get(scopeRef);
918 if (!node) {
919 return;
920 }
921 let parentNode = node.parent;
922 // when we remove a scope, check if any sibling scopes are trying to restore focus to something inside the scope we're removing
923 // if we are, then replace the siblings restore with the restore from the scope we're removing
924 for (let current of this.traverse()) {
925 if (
926 current !== node &&
927 node.nodeToRestore &&
928 current.nodeToRestore &&
929 node.scopeRef &&
930 node.scopeRef.current &&
931 isElementInScope(current.nodeToRestore, node.scopeRef.current)
932 ) {
933 current.nodeToRestore = node.nodeToRestore;
934 }
935 }
936 let children = node.children;
937 if (parentNode) {
938 parentNode.removeChild(node);
939 if (children.size > 0) {
940 children.forEach(child => parentNode && parentNode.addChild(child));
941 }
942 }
943
944 this.fastMap.delete(node.scopeRef);
945 }
946
947 // Pre Order Depth First
948 *traverse(node: TreeNode = this.root): Generator<TreeNode> {
949 if (node.scopeRef != null) {
950 yield node;
951 }
952 if (node.children.size > 0) {
953 for (let child of node.children) {
954 yield* this.traverse(child);
955 }
956 }
957 }
958
959 clone(): Tree {
960 let newTree = new Tree();
961 for (let node of this.traverse()) {
962 newTree.addTreeNode(node.scopeRef, node.parent?.scopeRef ?? null, node.nodeToRestore);
963 }
964 return newTree;
965 }
966}
967
968class TreeNode {
969 public scopeRef: ScopeRef;
970 public nodeToRestore?: FocusableElement;
971 public parent?: TreeNode;
972 public children: Set<TreeNode> = new Set();
973 public contain = false;
974
975 constructor(props: {scopeRef: ScopeRef}) {
976 this.scopeRef = props.scopeRef;
977 }
978 addChild(node: TreeNode) {
979 this.children.add(node);
980 node.parent = this;
981 }
982 removeChild(node: TreeNode) {
983 this.children.delete(node);
984 node.parent = undefined;
985 }
986}
987
988export let focusScopeTree = new Tree();