Skip to content

Commit 2411012

Browse files
authored
Focusscope tree (#3323)
* FocusScope tree
1 parent c9c838b commit 2411012

File tree

11 files changed

+555
-91
lines changed

11 files changed

+555
-91
lines changed

packages/@react-aria/focus/src/FocusScope.tsx

Lines changed: 205 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ import {isElementVisible} from './isElementVisible';
1616
import React, {ReactNode, RefObject, useContext, useEffect, useRef} from 'react';
1717
import {useLayoutEffect} from '@react-aria/utils';
1818

19-
// import {FocusScope, useFocusScope} from 'react-events/focus-scope';
20-
// export {FocusScope};
2119

2220
export interface FocusScopeProps {
2321
/** The contents of the focus scope. */
@@ -70,12 +68,9 @@ interface IFocusContext {
7068
const FocusContext = React.createContext<IFocusContext>(null);
7169

7270
let activeScope: ScopeRef = null;
73-
let scopes: Map<ScopeRef, ScopeRef | null> = new Map();
7471

7572
// This is a hacky DOM-based implementation of a FocusScope until this RFC lands in React:
7673
// https://github.com/reactjs/rfcs/pull/109
77-
// For now, it relies on the DOM tree order rather than the React tree order, and is probably
78-
// less optimized for performance.
7974

8075
/**
8176
* A FocusScope manages focus for its descendants. It supports containing focus inside
@@ -90,7 +85,8 @@ export function FocusScope(props: FocusScopeProps) {
9085
let endRef = useRef<HTMLSpanElement>();
9186
let scopeRef = useRef<Element[]>([]);
9287
let ctx = useContext(FocusContext);
93-
let parentScope = ctx?.scopeRef;
88+
// if there is no scopeRef on the context, then the parent is the focusScopeTree's root, represented by null
89+
let parentScope = ctx?.scopeRef ?? null;
9490

9591
useLayoutEffect(() => {
9692
// Find all rendered nodes between the sentinels and add them to the scope.
@@ -104,26 +100,37 @@ export function FocusScope(props: FocusScopeProps) {
104100
scopeRef.current = nodes;
105101
}, [children, parentScope]);
106102

107-
useLayoutEffect(() => {
108-
scopes.set(scopeRef, parentScope);
109-
return () => {
110-
// Restore the active scope on unmount if this scope or a descendant scope is active.
111-
// Parent effect cleanups run before children, so we need to check if the
112-
// parent scope actually still exists before restoring the active scope to it.
113-
if (
114-
(scopeRef === activeScope || isAncestorScope(scopeRef, activeScope)) &&
115-
(!parentScope || scopes.has(parentScope))
116-
) {
117-
activeScope = parentScope;
118-
}
119-
scopes.delete(scopeRef);
120-
};
121-
}, [scopeRef, parentScope]);
103+
// add to the focus scope tree in render order because useEffects/useLayoutEffects run children first whereas render runs parent first
104+
// which matters when constructing a tree
105+
if (focusScopeTree.getTreeNode(parentScope) && !focusScopeTree.getTreeNode(scopeRef)) {
106+
focusScopeTree.addTreeNode(scopeRef, parentScope);
107+
}
108+
109+
let node = focusScopeTree.getTreeNode(scopeRef);
110+
node.contain = contain;
122111

123112
useFocusContainment(scopeRef, contain);
124113
useRestoreFocus(scopeRef, restoreFocus, contain);
125114
useAutoFocus(scopeRef, autoFocus);
126115

116+
// this layout effect needs to run last so that focusScopeTree cleanup happens at the last moment possible
117+
useLayoutEffect(() => {
118+
if (scopeRef && (parentScope || parentScope == null)) {
119+
return () => {
120+
// Restore the active scope on unmount if this scope or a descendant scope is active.
121+
// Parent effect cleanups run before children, so we need to check if the
122+
// parent scope actually still exists before restoring the active scope to it.
123+
if (
124+
(scopeRef === activeScope || isAncestorScope(scopeRef, activeScope)) &&
125+
(!parentScope || focusScopeTree.getTreeNode(parentScope))
126+
) {
127+
activeScope = parentScope;
128+
}
129+
focusScopeTree.removeTreeNode(scopeRef);
130+
};
131+
}
132+
}, [scopeRef, parentScope]);
133+
127134
let focusManager = createFocusManagerForScope(scopeRef);
128135

129136
return (
@@ -230,6 +237,19 @@ function getScopeRoot(scope: Element[]) {
230237
return scope[0].parentElement;
231238
}
232239

240+
function shouldContainFocus(scopeRef: ScopeRef) {
241+
let scope = focusScopeTree.getTreeNode(activeScope);
242+
while (scope && scope.scopeRef !== scopeRef) {
243+
if (scope.contain) {
244+
return false;
245+
}
246+
247+
scope = scope.parent;
248+
}
249+
250+
return true;
251+
}
252+
233253
function useFocusContainment(scopeRef: RefObject<Element[]>, contain: boolean) {
234254
let focusedNode = useRef<FocusableElement>();
235255

@@ -247,7 +267,7 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain: boolean) {
247267

248268
// Handle the Tab key to contain focus within the scope
249269
let onKeyDown = (e) => {
250-
if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey || scopeRef !== activeScope) {
270+
if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey || !shouldContainFocus(scopeRef)) {
251271
return;
252272
}
253273

@@ -277,15 +297,15 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain: boolean) {
277297
if (!activeScope || isAncestorScope(activeScope, scopeRef)) {
278298
activeScope = scopeRef;
279299
focusedNode.current = e.target;
280-
} else if (scopeRef === activeScope && !isElementInChildScope(e.target, scopeRef)) {
300+
} else if (shouldContainFocus(scopeRef) && !isElementInChildScope(e.target, scopeRef)) {
281301
// If a focus event occurs outside the active scope (e.g. user tabs from browser location bar),
282302
// restore focus to the previously focused node or the first tabbable element in the active scope.
283303
if (focusedNode.current) {
284304
focusedNode.current.focus();
285305
} else if (activeScope) {
286306
focusFirstInScope(activeScope.current);
287307
}
288-
} else if (scopeRef === activeScope) {
308+
} else if (shouldContainFocus(scopeRef)) {
289309
focusedNode.current = e.target;
290310
}
291311
};
@@ -294,7 +314,7 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain: boolean) {
294314
// Firefox doesn't shift focus back to the Dialog properly without this
295315
raf.current = requestAnimationFrame(() => {
296316
// Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe
297-
if (scopeRef === activeScope && !isElementInChildScope(document.activeElement, scopeRef)) {
317+
if (shouldContainFocus(scopeRef) && !isElementInChildScope(document.activeElement, scopeRef)) {
298318
activeScope = scopeRef;
299319
if (document.body.contains(e.target)) {
300320
focusedNode.current = e.target;
@@ -329,23 +349,18 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain: boolean) {
329349
}
330350

331351
function isElementInAnyScope(element: Element) {
332-
for (let scope of scopes.keys()) {
333-
if (isElementInScope(element, scope.current)) {
334-
return true;
335-
}
336-
}
337-
return false;
352+
return isElementInChildScope(element);
338353
}
339354

340355
function isElementInScope(element: Element, scope: Element[]) {
341356
return scope.some(node => node.contains(element));
342357
}
343358

344-
function isElementInChildScope(element: Element, scope: ScopeRef) {
359+
function isElementInChildScope(element: Element, scope: ScopeRef = null) {
345360
// node.contains in isElementInScope covers child scopes that are also DOM children,
346361
// but does not cover child scopes in portals.
347-
for (let s of scopes.keys()) {
348-
if ((s === scope || isAncestorScope(scope, s)) && isElementInScope(element, s.current)) {
362+
for (let {scopeRef: s} of focusScopeTree.traverse(focusScopeTree.getTreeNode(scope))) {
363+
if (isElementInScope(element, s.current)) {
349364
return true;
350365
}
351366
}
@@ -354,16 +369,14 @@ function isElementInChildScope(element: Element, scope: ScopeRef) {
354369
}
355370

356371
function isAncestorScope(ancestor: ScopeRef, scope: ScopeRef) {
357-
let parent = scopes.get(scope);
358-
if (!parent) {
359-
return false;
360-
}
361-
362-
if (parent === ancestor) {
363-
return true;
372+
let parent = focusScopeTree.getTreeNode(scope)?.parent;
373+
while (parent) {
374+
if (parent.scopeRef === ancestor) {
375+
return true;
376+
}
377+
parent = parent.parent;
364378
}
365-
366-
return isAncestorScope(ancestor, parent);
379+
return false;
367380
}
368381

369382
function focusElement(element: FocusableElement | null, scroll = false) {
@@ -415,9 +428,33 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus: boolean,
415428
// create a ref during render instead of useLayoutEffect so the active element is saved before a child with autoFocus=true mounts.
416429
const nodeToRestoreRef = useRef(typeof document !== 'undefined' ? document.activeElement as FocusableElement : null);
417430

431+
// restoring scopes should all track if they are active regardless of contain, but contain already tracks it plus logic to contain the focus
432+
// restoring-non-containing scopes should only care if they become active so they can perform the restore
433+
useLayoutEffect(() => {
434+
let scope = scopeRef.current;
435+
if (!restoreFocus || contain) {
436+
return;
437+
}
438+
439+
let onFocus = () => {
440+
// If focusing an element in a child scope of the currently active scope, the child becomes active.
441+
// Moving out of the active scope to an ancestor is not allowed.
442+
if (!activeScope || isAncestorScope(activeScope, scopeRef)) {
443+
activeScope = scopeRef;
444+
}
445+
};
446+
447+
document.addEventListener('focusin', onFocus, false);
448+
scope.forEach(element => element.addEventListener('focusin', onFocus, false));
449+
return () => {
450+
document.removeEventListener('focusin', onFocus, false);
451+
scope.forEach(element => element.removeEventListener('focusin', onFocus, false));
452+
};
453+
}, [scopeRef, contain]);
454+
418455
// useLayoutEffect instead of useEffect so the active element is saved synchronously instead of asynchronously.
419456
useLayoutEffect(() => {
420-
let nodeToRestore = nodeToRestoreRef.current;
457+
focusScopeTree.getTreeNode(scopeRef).nodeToRestore = nodeToRestoreRef.current;
421458
if (!restoreFocus) {
422459
return;
423460
}
@@ -435,6 +472,7 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus: boolean,
435472
if (!isElementInScope(focusedElement, scopeRef.current)) {
436473
return;
437474
}
475+
let nodeToRestore = focusScopeTree.getTreeNode(scopeRef).nodeToRestore;
438476

439477
// Create a DOM tree walker that matches all tabbable elements
440478
let walker = getFocusableTreeWalker(document.body, {tabbable: true});
@@ -445,6 +483,7 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus: boolean,
445483

446484
if (!document.body.contains(nodeToRestore) || nodeToRestore === document.body) {
447485
nodeToRestore = null;
486+
focusScopeTree.getTreeNode(scopeRef).nodeToRestore = null;
448487
}
449488

450489
// If there is no next element, or it is outside the current scope, move focus to the
@@ -482,12 +521,31 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus: boolean,
482521
if (!contain) {
483522
document.removeEventListener('keydown', onKeyDown, true);
484523
}
524+
let nodeToRestore = focusScopeTree.getTreeNode(scopeRef).nodeToRestore;
485525

486-
if (restoreFocus && nodeToRestore && isElementInScope(document.activeElement, scopeRef.current)) {
526+
// if we already lost focus to the body and this was the active scope, then we should attempt to restore
527+
if (
528+
restoreFocus
529+
&& nodeToRestore
530+
&& (
531+
isElementInScope(document.activeElement, scopeRef.current)
532+
|| (document.activeElement === document.body && activeScope === scopeRef)
533+
)
534+
) {
535+
// freeze the focusScopeTree so it persists after the raf, otherwise during unmount nodes are removed from it
536+
let clonedTree = focusScopeTree.clone();
487537
requestAnimationFrame(() => {
488538
// Only restore focus if we've lost focus to the body, the alternative is that focus has been purposefully moved elsewhere
489-
if (document.body.contains(nodeToRestore) && document.activeElement === document.body) {
490-
focusElement(nodeToRestore);
539+
if (document.activeElement === document.body) {
540+
// look up the tree starting with our scope to find a nodeToRestore still in the DOM
541+
let treeNode = clonedTree.getTreeNode(scopeRef);
542+
while (treeNode) {
543+
if (treeNode.nodeToRestore && document.body.contains(treeNode.nodeToRestore)) {
544+
focusElement(treeNode.nodeToRestore);
545+
return;
546+
}
547+
treeNode = treeNode.parent;
548+
}
491549
}
492550
});
493551
}
@@ -624,3 +682,103 @@ function last(walker: TreeWalker) {
624682
} while (last);
625683
return next;
626684
}
685+
686+
687+
class Tree {
688+
private root: TreeNode;
689+
private fastMap = new Map<ScopeRef, TreeNode>();
690+
691+
constructor() {
692+
this.root = new TreeNode({scopeRef: null});
693+
this.fastMap.set(null, this.root);
694+
}
695+
696+
get size() {
697+
return this.fastMap.size;
698+
}
699+
700+
getTreeNode(data: ScopeRef) {
701+
return this.fastMap.get(data);
702+
}
703+
704+
addTreeNode(scopeRef: ScopeRef, parent: ScopeRef, nodeToRestore?: FocusableElement) {
705+
let parentNode = this.fastMap.get(parent ?? null);
706+
let node = new TreeNode({scopeRef});
707+
parentNode.addChild(node);
708+
node.parent = parentNode;
709+
this.fastMap.set(scopeRef, node);
710+
if (nodeToRestore) {
711+
node.nodeToRestore = nodeToRestore;
712+
}
713+
}
714+
715+
removeTreeNode(scopeRef: ScopeRef) {
716+
// never remove the root
717+
if (scopeRef === null) {
718+
return;
719+
}
720+
let node = this.fastMap.get(scopeRef);
721+
let parentNode = node.parent;
722+
// when we remove a scope, check if any sibling scopes are trying to restore focus to something inside the scope we're removing
723+
// if we are, then replace the siblings restore with the restore from the scope we're removing
724+
for (let current of this.traverse()) {
725+
if (
726+
current !== node &&
727+
node.nodeToRestore &&
728+
current.nodeToRestore &&
729+
node.scopeRef.current &&
730+
isElementInScope(current.nodeToRestore, node.scopeRef.current)
731+
) {
732+
current.nodeToRestore = node.nodeToRestore;
733+
}
734+
}
735+
let children = node.children;
736+
parentNode.removeChild(node);
737+
if (children.length > 0) {
738+
children.forEach(child => parentNode.addChild(child));
739+
}
740+
this.fastMap.delete(node.scopeRef);
741+
}
742+
743+
// Pre Order Depth First
744+
*traverse(node: TreeNode = this.root): Generator<TreeNode> {
745+
if (node.scopeRef != null) {
746+
yield node;
747+
}
748+
if (node.children.length > 0) {
749+
for (let child of node.children) {
750+
yield* this.traverse(child);
751+
}
752+
}
753+
}
754+
755+
clone(): Tree {
756+
let newTree = new Tree();
757+
for (let node of this.traverse()) {
758+
newTree.addTreeNode(node.scopeRef, node.parent.scopeRef, node.nodeToRestore);
759+
}
760+
return newTree;
761+
}
762+
}
763+
764+
class TreeNode {
765+
public scopeRef: ScopeRef;
766+
public nodeToRestore: FocusableElement;
767+
public parent: TreeNode;
768+
public children: TreeNode[] = [];
769+
public contain = false;
770+
771+
constructor(props: {scopeRef: ScopeRef}) {
772+
this.scopeRef = props.scopeRef;
773+
}
774+
addChild(node: TreeNode) {
775+
this.children.push(node);
776+
node.parent = this;
777+
}
778+
removeChild(node: TreeNode) {
779+
this.children.splice(this.children.indexOf(node), 1);
780+
node.parent = undefined;
781+
}
782+
}
783+
784+
export let focusScopeTree = new Tree();

0 commit comments

Comments
 (0)