@@ -16,8 +16,6 @@ import {isElementVisible} from './isElementVisible';
16
16
import React , { ReactNode , RefObject , useContext , useEffect , useRef } from 'react' ;
17
17
import { useLayoutEffect } from '@react-aria/utils' ;
18
18
19
- // import {FocusScope, useFocusScope} from 'react-events/focus-scope';
20
- // export {FocusScope};
21
19
22
20
export interface FocusScopeProps {
23
21
/** The contents of the focus scope. */
@@ -70,12 +68,9 @@ interface IFocusContext {
70
68
const FocusContext = React . createContext < IFocusContext > ( null ) ;
71
69
72
70
let activeScope : ScopeRef = null ;
73
- let scopes : Map < ScopeRef , ScopeRef | null > = new Map ( ) ;
74
71
75
72
// This is a hacky DOM-based implementation of a FocusScope until this RFC lands in React:
76
73
// 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.
79
74
80
75
/**
81
76
* A FocusScope manages focus for its descendants. It supports containing focus inside
@@ -90,7 +85,8 @@ export function FocusScope(props: FocusScopeProps) {
90
85
let endRef = useRef < HTMLSpanElement > ( ) ;
91
86
let scopeRef = useRef < Element [ ] > ( [ ] ) ;
92
87
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 ;
94
90
95
91
useLayoutEffect ( ( ) => {
96
92
// Find all rendered nodes between the sentinels and add them to the scope.
@@ -104,26 +100,37 @@ export function FocusScope(props: FocusScopeProps) {
104
100
scopeRef . current = nodes ;
105
101
} , [ children , parentScope ] ) ;
106
102
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 ;
122
111
123
112
useFocusContainment ( scopeRef , contain ) ;
124
113
useRestoreFocus ( scopeRef , restoreFocus , contain ) ;
125
114
useAutoFocus ( scopeRef , autoFocus ) ;
126
115
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
+
127
134
let focusManager = createFocusManagerForScope ( scopeRef ) ;
128
135
129
136
return (
@@ -230,6 +237,19 @@ function getScopeRoot(scope: Element[]) {
230
237
return scope [ 0 ] . parentElement ;
231
238
}
232
239
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
+
233
253
function useFocusContainment ( scopeRef : RefObject < Element [ ] > , contain : boolean ) {
234
254
let focusedNode = useRef < FocusableElement > ( ) ;
235
255
@@ -247,7 +267,7 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain: boolean) {
247
267
248
268
// Handle the Tab key to contain focus within the scope
249
269
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 ) ) {
251
271
return ;
252
272
}
253
273
@@ -277,15 +297,15 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain: boolean) {
277
297
if ( ! activeScope || isAncestorScope ( activeScope , scopeRef ) ) {
278
298
activeScope = scopeRef ;
279
299
focusedNode . current = e . target ;
280
- } else if ( scopeRef === activeScope && ! isElementInChildScope ( e . target , scopeRef ) ) {
300
+ } else if ( shouldContainFocus ( scopeRef ) && ! isElementInChildScope ( e . target , scopeRef ) ) {
281
301
// If a focus event occurs outside the active scope (e.g. user tabs from browser location bar),
282
302
// restore focus to the previously focused node or the first tabbable element in the active scope.
283
303
if ( focusedNode . current ) {
284
304
focusedNode . current . focus ( ) ;
285
305
} else if ( activeScope ) {
286
306
focusFirstInScope ( activeScope . current ) ;
287
307
}
288
- } else if ( scopeRef === activeScope ) {
308
+ } else if ( shouldContainFocus ( scopeRef ) ) {
289
309
focusedNode . current = e . target ;
290
310
}
291
311
} ;
@@ -294,7 +314,7 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain: boolean) {
294
314
// Firefox doesn't shift focus back to the Dialog properly without this
295
315
raf . current = requestAnimationFrame ( ( ) => {
296
316
// 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 ) ) {
298
318
activeScope = scopeRef ;
299
319
if ( document . body . contains ( e . target ) ) {
300
320
focusedNode . current = e . target ;
@@ -329,23 +349,18 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain: boolean) {
329
349
}
330
350
331
351
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 ) ;
338
353
}
339
354
340
355
function isElementInScope ( element : Element , scope : Element [ ] ) {
341
356
return scope . some ( node => node . contains ( element ) ) ;
342
357
}
343
358
344
- function isElementInChildScope ( element : Element , scope : ScopeRef ) {
359
+ function isElementInChildScope ( element : Element , scope : ScopeRef = null ) {
345
360
// node.contains in isElementInScope covers child scopes that are also DOM children,
346
361
// 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 ) ) {
349
364
return true ;
350
365
}
351
366
}
@@ -354,16 +369,14 @@ function isElementInChildScope(element: Element, scope: ScopeRef) {
354
369
}
355
370
356
371
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 ;
364
378
}
365
-
366
- return isAncestorScope ( ancestor , parent ) ;
379
+ return false ;
367
380
}
368
381
369
382
function focusElement ( element : FocusableElement | null , scroll = false ) {
@@ -415,9 +428,33 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus: boolean,
415
428
// create a ref during render instead of useLayoutEffect so the active element is saved before a child with autoFocus=true mounts.
416
429
const nodeToRestoreRef = useRef ( typeof document !== 'undefined' ? document . activeElement as FocusableElement : null ) ;
417
430
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
+
418
455
// useLayoutEffect instead of useEffect so the active element is saved synchronously instead of asynchronously.
419
456
useLayoutEffect ( ( ) => {
420
- let nodeToRestore = nodeToRestoreRef . current ;
457
+ focusScopeTree . getTreeNode ( scopeRef ) . nodeToRestore = nodeToRestoreRef . current ;
421
458
if ( ! restoreFocus ) {
422
459
return ;
423
460
}
@@ -435,6 +472,7 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus: boolean,
435
472
if ( ! isElementInScope ( focusedElement , scopeRef . current ) ) {
436
473
return ;
437
474
}
475
+ let nodeToRestore = focusScopeTree . getTreeNode ( scopeRef ) . nodeToRestore ;
438
476
439
477
// Create a DOM tree walker that matches all tabbable elements
440
478
let walker = getFocusableTreeWalker ( document . body , { tabbable : true } ) ;
@@ -445,6 +483,7 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus: boolean,
445
483
446
484
if ( ! document . body . contains ( nodeToRestore ) || nodeToRestore === document . body ) {
447
485
nodeToRestore = null ;
486
+ focusScopeTree . getTreeNode ( scopeRef ) . nodeToRestore = null ;
448
487
}
449
488
450
489
// 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,
482
521
if ( ! contain ) {
483
522
document . removeEventListener ( 'keydown' , onKeyDown , true ) ;
484
523
}
524
+ let nodeToRestore = focusScopeTree . getTreeNode ( scopeRef ) . nodeToRestore ;
485
525
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 ( ) ;
487
537
requestAnimationFrame ( ( ) => {
488
538
// 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
+ }
491
549
}
492
550
} ) ;
493
551
}
@@ -624,3 +682,103 @@ function last(walker: TreeWalker) {
624
682
} while ( last ) ;
625
683
return next ;
626
684
}
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