@react-aria/focus 3.6.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,15 +10,14 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
+ import {FocusableElement} from '@react-types/shared';
13
14
  import {focusSafely} from './focusSafely';
14
15
  import {isElementVisible} from './isElementVisible';
15
16
  import React, {ReactNode, RefObject, useContext, useEffect, useRef} from 'react';
16
17
  import {useLayoutEffect} from '@react-aria/utils';
17
18
 
18
- // import {FocusScope, useFocusScope} from 'react-events/focus-scope';
19
- // export {FocusScope};
20
19
 
21
- interface FocusScopeProps {
20
+ export interface FocusScopeProps {
22
21
  /** The contents of the focus scope. */
23
22
  children: ReactNode,
24
23
 
@@ -38,9 +37,9 @@ interface FocusScopeProps {
38
37
  autoFocus?: boolean
39
38
  }
40
39
 
41
- interface FocusManagerOptions {
40
+ export interface FocusManagerOptions {
42
41
  /** The element to start searching from. The currently focused element by default. */
43
- from?: HTMLElement,
42
+ from?: Element,
44
43
  /** Whether to only include tabbable elements, or all focusable elements. */
45
44
  tabbable?: boolean,
46
45
  /** Whether focus should wrap around when it reaches the end of the scope. */
@@ -51,16 +50,16 @@ interface FocusManagerOptions {
51
50
 
52
51
  export interface FocusManager {
53
52
  /** Moves focus to the next focusable or tabbable element in the focus scope. */
54
- focusNext(opts?: FocusManagerOptions): HTMLElement,
53
+ focusNext(opts?: FocusManagerOptions): FocusableElement,
55
54
  /** Moves focus to the previous focusable or tabbable element in the focus scope. */
56
- focusPrevious(opts?: FocusManagerOptions): HTMLElement,
55
+ focusPrevious(opts?: FocusManagerOptions): FocusableElement,
57
56
  /** Moves focus to the first focusable or tabbable element in the focus scope. */
58
- focusFirst(opts?: FocusManagerOptions): HTMLElement,
57
+ focusFirst(opts?: FocusManagerOptions): FocusableElement,
59
58
  /** Moves focus to the last focusable or tabbable element in the focus scope. */
60
- focusLast(opts?: FocusManagerOptions): HTMLElement
59
+ focusLast(opts?: FocusManagerOptions): FocusableElement
61
60
  }
62
61
 
63
- type ScopeRef = RefObject<HTMLElement[]>;
62
+ type ScopeRef = RefObject<Element[]>;
64
63
  interface IFocusContext {
65
64
  scopeRef: ScopeRef,
66
65
  focusManager: FocusManager
@@ -69,12 +68,9 @@ interface IFocusContext {
69
68
  const FocusContext = React.createContext<IFocusContext>(null);
70
69
 
71
70
  let activeScope: ScopeRef = null;
72
- let scopes: Map<ScopeRef, ScopeRef | null> = new Map();
73
71
 
74
72
  // This is a hacky DOM-based implementation of a FocusScope until this RFC lands in React:
75
73
  // https://github.com/reactjs/rfcs/pull/109
76
- // For now, it relies on the DOM tree order rather than the React tree order, and is probably
77
- // less optimized for performance.
78
74
 
79
75
  /**
80
76
  * A FocusScope manages focus for its descendants. It supports containing focus inside
@@ -87,9 +83,10 @@ export function FocusScope(props: FocusScopeProps) {
87
83
  let {children, contain, restoreFocus, autoFocus} = props;
88
84
  let startRef = useRef<HTMLSpanElement>();
89
85
  let endRef = useRef<HTMLSpanElement>();
90
- let scopeRef = useRef<HTMLElement[]>([]);
86
+ let scopeRef = useRef<Element[]>([]);
91
87
  let ctx = useContext(FocusContext);
92
- 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;
93
90
 
94
91
  useLayoutEffect(() => {
95
92
  // Find all rendered nodes between the sentinels and add them to the scope.
@@ -103,26 +100,37 @@ export function FocusScope(props: FocusScopeProps) {
103
100
  scopeRef.current = nodes;
104
101
  }, [children, parentScope]);
105
102
 
106
- useLayoutEffect(() => {
107
- scopes.set(scopeRef, parentScope);
108
- return () => {
109
- // Restore the active scope on unmount if this scope or a descendant scope is active.
110
- // Parent effect cleanups run before children, so we need to check if the
111
- // parent scope actually still exists before restoring the active scope to it.
112
- if (
113
- (scopeRef === activeScope || isAncestorScope(scopeRef, activeScope)) &&
114
- (!parentScope || scopes.has(parentScope))
115
- ) {
116
- activeScope = parentScope;
117
- }
118
- scopes.delete(scopeRef);
119
- };
120
- }, [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;
121
111
 
122
112
  useFocusContainment(scopeRef, contain);
123
113
  useRestoreFocus(scopeRef, restoreFocus, contain);
124
114
  useAutoFocus(scopeRef, autoFocus);
125
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
+
126
134
  let focusManager = createFocusManagerForScope(scopeRef);
127
135
 
128
136
  return (
@@ -143,19 +151,19 @@ export function useFocusManager(): FocusManager {
143
151
  return useContext(FocusContext)?.focusManager;
144
152
  }
145
153
 
146
- function createFocusManagerForScope(scopeRef: React.RefObject<HTMLElement[]>): FocusManager {
154
+ function createFocusManagerForScope(scopeRef: React.RefObject<Element[]>): FocusManager {
147
155
  return {
148
156
  focusNext(opts: FocusManagerOptions = {}) {
149
157
  let scope = scopeRef.current;
150
- let {from, tabbable, wrap} = opts;
158
+ let {from, tabbable, wrap, accept} = opts;
151
159
  let node = from || document.activeElement;
152
160
  let sentinel = scope[0].previousElementSibling;
153
- let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable}, scope);
161
+ let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable, accept}, scope);
154
162
  walker.currentNode = isElementInScope(node, scope) ? node : sentinel;
155
- let nextNode = walker.nextNode() as HTMLElement;
163
+ let nextNode = walker.nextNode() as FocusableElement;
156
164
  if (!nextNode && wrap) {
157
165
  walker.currentNode = sentinel;
158
- nextNode = walker.nextNode() as HTMLElement;
166
+ nextNode = walker.nextNode() as FocusableElement;
159
167
  }
160
168
  if (nextNode) {
161
169
  focusElement(nextNode, true);
@@ -164,15 +172,15 @@ function createFocusManagerForScope(scopeRef: React.RefObject<HTMLElement[]>): F
164
172
  },
165
173
  focusPrevious(opts: FocusManagerOptions = {}) {
166
174
  let scope = scopeRef.current;
167
- let {from, tabbable, wrap} = opts;
175
+ let {from, tabbable, wrap, accept} = opts;
168
176
  let node = from || document.activeElement;
169
177
  let sentinel = scope[scope.length - 1].nextElementSibling;
170
- let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable}, scope);
178
+ let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable, accept}, scope);
171
179
  walker.currentNode = isElementInScope(node, scope) ? node : sentinel;
172
- let previousNode = walker.previousNode() as HTMLElement;
180
+ let previousNode = walker.previousNode() as FocusableElement;
173
181
  if (!previousNode && wrap) {
174
182
  walker.currentNode = sentinel;
175
- previousNode = walker.previousNode() as HTMLElement;
183
+ previousNode = walker.previousNode() as FocusableElement;
176
184
  }
177
185
  if (previousNode) {
178
186
  focusElement(previousNode, true);
@@ -181,10 +189,10 @@ function createFocusManagerForScope(scopeRef: React.RefObject<HTMLElement[]>): F
181
189
  },
182
190
  focusFirst(opts = {}) {
183
191
  let scope = scopeRef.current;
184
- let {tabbable} = opts;
185
- let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable}, scope);
192
+ let {tabbable, accept} = opts;
193
+ let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable, accept}, scope);
186
194
  walker.currentNode = scope[0].previousElementSibling;
187
- let nextNode = walker.nextNode() as HTMLElement;
195
+ let nextNode = walker.nextNode() as FocusableElement;
188
196
  if (nextNode) {
189
197
  focusElement(nextNode, true);
190
198
  }
@@ -192,10 +200,10 @@ function createFocusManagerForScope(scopeRef: React.RefObject<HTMLElement[]>): F
192
200
  },
193
201
  focusLast(opts = {}) {
194
202
  let scope = scopeRef.current;
195
- let {tabbable} = opts;
196
- let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable}, scope);
203
+ let {tabbable, accept} = opts;
204
+ let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable, accept}, scope);
197
205
  walker.currentNode = scope[scope.length - 1].nextElementSibling;
198
- let previousNode = walker.previousNode() as HTMLElement;
206
+ let previousNode = walker.previousNode() as FocusableElement;
199
207
  if (previousNode) {
200
208
  focusElement(previousNode, true);
201
209
  }
@@ -225,27 +233,45 @@ const FOCUSABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]),') + '
225
233
  focusableElements.push('[tabindex]:not([tabindex="-1"]):not([disabled])');
226
234
  const TABBABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]):not([tabindex="-1"]),');
227
235
 
228
- function getScopeRoot(scope: HTMLElement[]) {
236
+ function getScopeRoot(scope: Element[]) {
229
237
  return scope[0].parentElement;
230
238
  }
231
239
 
232
- function useFocusContainment(scopeRef: RefObject<HTMLElement[]>, contain: boolean) {
233
- let focusedNode = useRef<HTMLElement>();
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
+
253
+ function useFocusContainment(scopeRef: RefObject<Element[]>, contain: boolean) {
254
+ let focusedNode = useRef<FocusableElement>();
234
255
 
235
256
  let raf = useRef(null);
236
257
  useLayoutEffect(() => {
237
258
  let scope = scopeRef.current;
238
259
  if (!contain) {
260
+ // if contain was changed, then we should cancel any ongoing waits to pull focus back into containment
261
+ if (raf.current) {
262
+ cancelAnimationFrame(raf.current);
263
+ raf.current = null;
264
+ }
239
265
  return;
240
266
  }
241
267
 
242
268
  // Handle the Tab key to contain focus within the scope
243
269
  let onKeyDown = (e) => {
244
- 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)) {
245
271
  return;
246
272
  }
247
273
 
248
- let focusedElement = document.activeElement as HTMLElement;
274
+ let focusedElement = document.activeElement;
249
275
  let scope = scopeRef.current;
250
276
  if (!isElementInScope(focusedElement, scope)) {
251
277
  return;
@@ -253,10 +279,10 @@ function useFocusContainment(scopeRef: RefObject<HTMLElement[]>, contain: boolea
253
279
 
254
280
  let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable: true}, scope);
255
281
  walker.currentNode = focusedElement;
256
- let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as HTMLElement;
282
+ let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement;
257
283
  if (!nextElement) {
258
284
  walker.currentNode = e.shiftKey ? scope[scope.length - 1].nextElementSibling : scope[0].previousElementSibling;
259
- nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as HTMLElement;
285
+ nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement;
260
286
  }
261
287
 
262
288
  e.preventDefault();
@@ -271,7 +297,7 @@ function useFocusContainment(scopeRef: RefObject<HTMLElement[]>, contain: boolea
271
297
  if (!activeScope || isAncestorScope(activeScope, scopeRef)) {
272
298
  activeScope = scopeRef;
273
299
  focusedNode.current = e.target;
274
- } else if (scopeRef === activeScope && !isElementInChildScope(e.target, scopeRef)) {
300
+ } else if (shouldContainFocus(scopeRef) && !isElementInChildScope(e.target, scopeRef)) {
275
301
  // If a focus event occurs outside the active scope (e.g. user tabs from browser location bar),
276
302
  // restore focus to the previously focused node or the first tabbable element in the active scope.
277
303
  if (focusedNode.current) {
@@ -279,7 +305,7 @@ function useFocusContainment(scopeRef: RefObject<HTMLElement[]>, contain: boolea
279
305
  } else if (activeScope) {
280
306
  focusFirstInScope(activeScope.current);
281
307
  }
282
- } else if (scopeRef === activeScope) {
308
+ } else if (shouldContainFocus(scopeRef)) {
283
309
  focusedNode.current = e.target;
284
310
  }
285
311
  };
@@ -288,10 +314,14 @@ function useFocusContainment(scopeRef: RefObject<HTMLElement[]>, contain: boolea
288
314
  // Firefox doesn't shift focus back to the Dialog properly without this
289
315
  raf.current = requestAnimationFrame(() => {
290
316
  // Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe
291
- if (scopeRef === activeScope && !isElementInChildScope(document.activeElement, scopeRef)) {
317
+ if (shouldContainFocus(scopeRef) && !isElementInChildScope(document.activeElement, scopeRef)) {
292
318
  activeScope = scopeRef;
293
- focusedNode.current = e.target;
294
- focusedNode.current.focus();
319
+ if (document.body.contains(e.target)) {
320
+ focusedNode.current = e.target;
321
+ focusedNode.current.focus();
322
+ } else if (activeScope) {
323
+ focusFirstInScope(activeScope.current);
324
+ }
295
325
  }
296
326
  });
297
327
  };
@@ -310,28 +340,27 @@ function useFocusContainment(scopeRef: RefObject<HTMLElement[]>, contain: boolea
310
340
 
311
341
  // eslint-disable-next-line arrow-body-style
312
342
  useEffect(() => {
313
- return () => cancelAnimationFrame(raf.current);
343
+ return () => {
344
+ if (raf.current) {
345
+ cancelAnimationFrame(raf.current);
346
+ }
347
+ };
314
348
  }, [raf]);
315
349
  }
316
350
 
317
351
  function isElementInAnyScope(element: Element) {
318
- for (let scope of scopes.keys()) {
319
- if (isElementInScope(element, scope.current)) {
320
- return true;
321
- }
322
- }
323
- return false;
352
+ return isElementInChildScope(element);
324
353
  }
325
354
 
326
- function isElementInScope(element: Element, scope: HTMLElement[]) {
355
+ function isElementInScope(element: Element, scope: Element[]) {
327
356
  return scope.some(node => node.contains(element));
328
357
  }
329
358
 
330
- function isElementInChildScope(element: Element, scope: ScopeRef) {
359
+ function isElementInChildScope(element: Element, scope: ScopeRef = null) {
331
360
  // node.contains in isElementInScope covers child scopes that are also DOM children,
332
361
  // but does not cover child scopes in portals.
333
- for (let s of scopes.keys()) {
334
- 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)) {
335
364
  return true;
336
365
  }
337
366
  }
@@ -340,19 +369,17 @@ function isElementInChildScope(element: Element, scope: ScopeRef) {
340
369
  }
341
370
 
342
371
  function isAncestorScope(ancestor: ScopeRef, scope: ScopeRef) {
343
- let parent = scopes.get(scope);
344
- if (!parent) {
345
- return false;
346
- }
347
-
348
- if (parent === ancestor) {
349
- 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;
350
378
  }
351
-
352
- return isAncestorScope(ancestor, parent);
379
+ return false;
353
380
  }
354
381
 
355
- function focusElement(element: HTMLElement | null, scroll = false) {
382
+ function focusElement(element: FocusableElement | null, scroll = false) {
356
383
  if (element != null && !scroll) {
357
384
  try {
358
385
  focusSafely(element);
@@ -368,14 +395,23 @@ function focusElement(element: HTMLElement | null, scroll = false) {
368
395
  }
369
396
  }
370
397
 
371
- function focusFirstInScope(scope: HTMLElement[]) {
398
+ function focusFirstInScope(scope: Element[], tabbable:boolean = true) {
372
399
  let sentinel = scope[0].previousElementSibling;
373
- let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable: true}, scope);
400
+ let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable}, scope);
374
401
  walker.currentNode = sentinel;
375
- focusElement(walker.nextNode() as HTMLElement);
402
+ let nextNode = walker.nextNode();
403
+
404
+ // If the scope does not contain a tabbable element, use the first focusable element.
405
+ if (tabbable && !nextNode) {
406
+ walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable: false}, scope);
407
+ walker.currentNode = sentinel;
408
+ nextNode = walker.nextNode();
409
+ }
410
+
411
+ focusElement(nextNode as FocusableElement);
376
412
  }
377
413
 
378
- function useAutoFocus(scopeRef: RefObject<HTMLElement[]>, autoFocus: boolean) {
414
+ function useAutoFocus(scopeRef: RefObject<Element[]>, autoFocus: boolean) {
379
415
  const autoFocusRef = React.useRef(autoFocus);
380
416
  useEffect(() => {
381
417
  if (autoFocusRef.current) {
@@ -385,16 +421,40 @@ function useAutoFocus(scopeRef: RefObject<HTMLElement[]>, autoFocus: boolean) {
385
421
  }
386
422
  }
387
423
  autoFocusRef.current = false;
388
- }, []);
424
+ }, [scopeRef]);
389
425
  }
390
426
 
391
- function useRestoreFocus(scopeRef: RefObject<HTMLElement[]>, restoreFocus: boolean, contain: boolean) {
427
+ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus: boolean, contain: boolean) {
392
428
  // create a ref during render instead of useLayoutEffect so the active element is saved before a child with autoFocus=true mounts.
393
- const nodeToRestoreRef = useRef(typeof document !== 'undefined' ? document.activeElement as HTMLElement : null);
429
+ const nodeToRestoreRef = useRef(typeof document !== 'undefined' ? document.activeElement as FocusableElement : null);
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]);
394
454
 
395
455
  // useLayoutEffect instead of useEffect so the active element is saved synchronously instead of asynchronously.
396
456
  useLayoutEffect(() => {
397
- let nodeToRestore = nodeToRestoreRef.current;
457
+ focusScopeTree.getTreeNode(scopeRef).nodeToRestore = nodeToRestoreRef.current;
398
458
  if (!restoreFocus) {
399
459
  return;
400
460
  }
@@ -408,20 +468,22 @@ function useRestoreFocus(scopeRef: RefObject<HTMLElement[]>, restoreFocus: boole
408
468
  return;
409
469
  }
410
470
 
411
- let focusedElement = document.activeElement as HTMLElement;
471
+ let focusedElement = document.activeElement as FocusableElement;
412
472
  if (!isElementInScope(focusedElement, scopeRef.current)) {
413
473
  return;
414
474
  }
475
+ let nodeToRestore = focusScopeTree.getTreeNode(scopeRef).nodeToRestore;
415
476
 
416
477
  // Create a DOM tree walker that matches all tabbable elements
417
478
  let walker = getFocusableTreeWalker(document.body, {tabbable: true});
418
479
 
419
480
  // Find the next tabbable element after the currently focused element
420
481
  walker.currentNode = focusedElement;
421
- let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as HTMLElement;
482
+ let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement;
422
483
 
423
484
  if (!document.body.contains(nodeToRestore) || nodeToRestore === document.body) {
424
485
  nodeToRestore = null;
486
+ focusScopeTree.getTreeNode(scopeRef).nodeToRestore = null;
425
487
  }
426
488
 
427
489
  // If there is no next element, or it is outside the current scope, move focus to the
@@ -431,7 +493,7 @@ function useRestoreFocus(scopeRef: RefObject<HTMLElement[]>, restoreFocus: boole
431
493
 
432
494
  // Skip over elements within the scope, in case the scope immediately follows the node to restore.
433
495
  do {
434
- nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as HTMLElement;
496
+ nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement;
435
497
  } while (isElementInScope(nextElement, scopeRef.current));
436
498
 
437
499
  e.preventDefault();
@@ -459,11 +521,31 @@ function useRestoreFocus(scopeRef: RefObject<HTMLElement[]>, restoreFocus: boole
459
521
  if (!contain) {
460
522
  document.removeEventListener('keydown', onKeyDown, true);
461
523
  }
524
+ let nodeToRestore = focusScopeTree.getTreeNode(scopeRef).nodeToRestore;
462
525
 
463
- 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();
464
537
  requestAnimationFrame(() => {
465
- if (document.body.contains(nodeToRestore)) {
466
- focusElement(nodeToRestore);
538
+ // Only restore focus if we've lost focus to the body, the alternative is that focus has been purposefully moved elsewhere
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
+ }
467
549
  }
468
550
  });
469
551
  }
@@ -475,7 +557,7 @@ function useRestoreFocus(scopeRef: RefObject<HTMLElement[]>, restoreFocus: boole
475
557
  * Create a [TreeWalker]{@link https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker}
476
558
  * that matches all focusable/tabbable elements.
477
559
  */
478
- export function getFocusableTreeWalker(root: HTMLElement, opts?: FocusManagerOptions, scope?: HTMLElement[]) {
560
+ export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions, scope?: Element[]) {
479
561
  let selector = opts?.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR;
480
562
  let walker = document.createTreeWalker(
481
563
  root,
@@ -487,9 +569,9 @@ export function getFocusableTreeWalker(root: HTMLElement, opts?: FocusManagerOpt
487
569
  return NodeFilter.FILTER_REJECT;
488
570
  }
489
571
 
490
- if ((node as HTMLElement).matches(selector)
491
- && isElementVisible(node as HTMLElement)
492
- && (!scope || isElementInScope(node as HTMLElement, scope))
572
+ if ((node as Element).matches(selector)
573
+ && isElementVisible(node as Element)
574
+ && (!scope || isElementInScope(node as Element, scope))
493
575
  && (!opts?.accept || opts.accept(node as Element))
494
576
  ) {
495
577
  return NodeFilter.FILTER_ACCEPT;
@@ -510,20 +592,23 @@ export function getFocusableTreeWalker(root: HTMLElement, opts?: FocusManagerOpt
510
592
  /**
511
593
  * Creates a FocusManager object that can be used to move focus within an element.
512
594
  */
513
- export function createFocusManager(ref: RefObject<HTMLElement>, defaultOptions: FocusManagerOptions = {}): FocusManager {
595
+ export function createFocusManager(ref: RefObject<Element>, defaultOptions: FocusManagerOptions = {}): FocusManager {
514
596
  return {
515
597
  focusNext(opts: FocusManagerOptions = {}) {
516
598
  let root = ref.current;
599
+ if (!root) {
600
+ return;
601
+ }
517
602
  let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts;
518
603
  let node = from || document.activeElement;
519
604
  let walker = getFocusableTreeWalker(root, {tabbable, accept});
520
605
  if (root.contains(node)) {
521
606
  walker.currentNode = node;
522
607
  }
523
- let nextNode = walker.nextNode() as HTMLElement;
608
+ let nextNode = walker.nextNode() as FocusableElement;
524
609
  if (!nextNode && wrap) {
525
610
  walker.currentNode = root;
526
- nextNode = walker.nextNode() as HTMLElement;
611
+ nextNode = walker.nextNode() as FocusableElement;
527
612
  }
528
613
  if (nextNode) {
529
614
  focusElement(nextNode, true);
@@ -532,6 +617,9 @@ export function createFocusManager(ref: RefObject<HTMLElement>, defaultOptions:
532
617
  },
533
618
  focusPrevious(opts: FocusManagerOptions = defaultOptions) {
534
619
  let root = ref.current;
620
+ if (!root) {
621
+ return;
622
+ }
535
623
  let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts;
536
624
  let node = from || document.activeElement;
537
625
  let walker = getFocusableTreeWalker(root, {tabbable, accept});
@@ -544,7 +632,7 @@ export function createFocusManager(ref: RefObject<HTMLElement>, defaultOptions:
544
632
  }
545
633
  return next;
546
634
  }
547
- let previousNode = walker.previousNode() as HTMLElement;
635
+ let previousNode = walker.previousNode() as FocusableElement;
548
636
  if (!previousNode && wrap) {
549
637
  walker.currentNode = root;
550
638
  previousNode = last(walker);
@@ -556,9 +644,12 @@ export function createFocusManager(ref: RefObject<HTMLElement>, defaultOptions:
556
644
  },
557
645
  focusFirst(opts = defaultOptions) {
558
646
  let root = ref.current;
647
+ if (!root) {
648
+ return;
649
+ }
559
650
  let {tabbable = defaultOptions.tabbable, accept = defaultOptions.accept} = opts;
560
651
  let walker = getFocusableTreeWalker(root, {tabbable, accept});
561
- let nextNode = walker.nextNode() as HTMLElement;
652
+ let nextNode = walker.nextNode() as FocusableElement;
562
653
  if (nextNode) {
563
654
  focusElement(nextNode, true);
564
655
  }
@@ -566,6 +657,9 @@ export function createFocusManager(ref: RefObject<HTMLElement>, defaultOptions:
566
657
  },
567
658
  focusLast(opts = defaultOptions) {
568
659
  let root = ref.current;
660
+ if (!root) {
661
+ return;
662
+ }
569
663
  let {tabbable = defaultOptions.tabbable, accept = defaultOptions.accept} = opts;
570
664
  let walker = getFocusableTreeWalker(root, {tabbable, accept});
571
665
  let next = last(walker);
@@ -578,13 +672,113 @@ export function createFocusManager(ref: RefObject<HTMLElement>, defaultOptions:
578
672
  }
579
673
 
580
674
  function last(walker: TreeWalker) {
581
- let next: HTMLElement;
582
- let last: HTMLElement;
675
+ let next: FocusableElement;
676
+ let last: FocusableElement;
583
677
  do {
584
- last = walker.lastChild() as HTMLElement;
678
+ last = walker.lastChild() as FocusableElement;
585
679
  if (last) {
586
680
  next = last;
587
681
  }
588
682
  } while (last);
589
683
  return next;
590
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();