@@ -16,8 +16,6 @@ import {isElementVisible} from './isElementVisible';
1616import React , { ReactNode , RefObject , useContext , useEffect , useRef } from 'react' ;
1717import { useLayoutEffect } from '@react-aria/utils' ;
1818
19- // import {FocusScope, useFocusScope} from 'react-events/focus-scope';
20- // export {FocusScope};
2119
2220export interface FocusScopeProps {
2321 /** The contents of the focus scope. */
@@ -70,12 +68,9 @@ interface IFocusContext {
7068const FocusContext = React . createContext < IFocusContext > ( null ) ;
7169
7270let 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+
233253function 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
331351function 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
340355function 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
356371function 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
369382function 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