@react-aria/focus 3.7.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.
- package/dist/main.js +170 -36
- package/dist/main.js.map +1 -1
- package/dist/module.js +170 -36
- package/dist/module.js.map +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/FocusScope.tsx +224 -53
package/src/FocusScope.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
270
|
+
if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey || !shouldContainFocus(scopeRef)) {
|
|
251
271
|
return;
|
|
252
272
|
}
|
|
253
273
|
|
|
@@ -277,7 +297,7 @@ 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
|
|
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) {
|
|
@@ -285,7 +305,7 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain: boolean) {
|
|
|
285
305
|
} else if (activeScope) {
|
|
286
306
|
focusFirstInScope(activeScope.current);
|
|
287
307
|
}
|
|
288
|
-
} else if (scopeRef
|
|
308
|
+
} else if (shouldContainFocus(scopeRef)) {
|
|
289
309
|
focusedNode.current = e.target;
|
|
290
310
|
}
|
|
291
311
|
};
|
|
@@ -294,10 +314,14 @@ 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
|
|
317
|
+
if (shouldContainFocus(scopeRef) && !isElementInChildScope(document.activeElement, scopeRef)) {
|
|
298
318
|
activeScope = scopeRef;
|
|
299
|
-
|
|
300
|
-
|
|
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
|
+
}
|
|
301
325
|
}
|
|
302
326
|
});
|
|
303
327
|
};
|
|
@@ -325,23 +349,18 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain: boolean) {
|
|
|
325
349
|
}
|
|
326
350
|
|
|
327
351
|
function isElementInAnyScope(element: Element) {
|
|
328
|
-
|
|
329
|
-
if (isElementInScope(element, scope.current)) {
|
|
330
|
-
return true;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
return false;
|
|
352
|
+
return isElementInChildScope(element);
|
|
334
353
|
}
|
|
335
354
|
|
|
336
355
|
function isElementInScope(element: Element, scope: Element[]) {
|
|
337
356
|
return scope.some(node => node.contains(element));
|
|
338
357
|
}
|
|
339
358
|
|
|
340
|
-
function isElementInChildScope(element: Element, scope: ScopeRef) {
|
|
359
|
+
function isElementInChildScope(element: Element, scope: ScopeRef = null) {
|
|
341
360
|
// node.contains in isElementInScope covers child scopes that are also DOM children,
|
|
342
361
|
// but does not cover child scopes in portals.
|
|
343
|
-
for (let s of
|
|
344
|
-
if (
|
|
362
|
+
for (let {scopeRef: s} of focusScopeTree.traverse(focusScopeTree.getTreeNode(scope))) {
|
|
363
|
+
if (isElementInScope(element, s.current)) {
|
|
345
364
|
return true;
|
|
346
365
|
}
|
|
347
366
|
}
|
|
@@ -350,16 +369,14 @@ function isElementInChildScope(element: Element, scope: ScopeRef) {
|
|
|
350
369
|
}
|
|
351
370
|
|
|
352
371
|
function isAncestorScope(ancestor: ScopeRef, scope: ScopeRef) {
|
|
353
|
-
let parent =
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
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;
|
|
360
378
|
}
|
|
361
|
-
|
|
362
|
-
return isAncestorScope(ancestor, parent);
|
|
379
|
+
return false;
|
|
363
380
|
}
|
|
364
381
|
|
|
365
382
|
function focusElement(element: FocusableElement | null, scroll = false) {
|
|
@@ -378,11 +395,20 @@ function focusElement(element: FocusableElement | null, scroll = false) {
|
|
|
378
395
|
}
|
|
379
396
|
}
|
|
380
397
|
|
|
381
|
-
function focusFirstInScope(scope: Element[]) {
|
|
398
|
+
function focusFirstInScope(scope: Element[], tabbable:boolean = true) {
|
|
382
399
|
let sentinel = scope[0].previousElementSibling;
|
|
383
|
-
let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable
|
|
400
|
+
let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable}, scope);
|
|
384
401
|
walker.currentNode = sentinel;
|
|
385
|
-
|
|
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);
|
|
386
412
|
}
|
|
387
413
|
|
|
388
414
|
function useAutoFocus(scopeRef: RefObject<Element[]>, autoFocus: boolean) {
|
|
@@ -395,16 +421,40 @@ function useAutoFocus(scopeRef: RefObject<Element[]>, autoFocus: boolean) {
|
|
|
395
421
|
}
|
|
396
422
|
}
|
|
397
423
|
autoFocusRef.current = false;
|
|
398
|
-
}, []);
|
|
424
|
+
}, [scopeRef]);
|
|
399
425
|
}
|
|
400
426
|
|
|
401
427
|
function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus: boolean, contain: boolean) {
|
|
402
428
|
// create a ref during render instead of useLayoutEffect so the active element is saved before a child with autoFocus=true mounts.
|
|
403
429
|
const nodeToRestoreRef = useRef(typeof document !== 'undefined' ? document.activeElement as FocusableElement : null);
|
|
404
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
|
+
|
|
405
455
|
// useLayoutEffect instead of useEffect so the active element is saved synchronously instead of asynchronously.
|
|
406
456
|
useLayoutEffect(() => {
|
|
407
|
-
|
|
457
|
+
focusScopeTree.getTreeNode(scopeRef).nodeToRestore = nodeToRestoreRef.current;
|
|
408
458
|
if (!restoreFocus) {
|
|
409
459
|
return;
|
|
410
460
|
}
|
|
@@ -422,6 +472,7 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus: boolean,
|
|
|
422
472
|
if (!isElementInScope(focusedElement, scopeRef.current)) {
|
|
423
473
|
return;
|
|
424
474
|
}
|
|
475
|
+
let nodeToRestore = focusScopeTree.getTreeNode(scopeRef).nodeToRestore;
|
|
425
476
|
|
|
426
477
|
// Create a DOM tree walker that matches all tabbable elements
|
|
427
478
|
let walker = getFocusableTreeWalker(document.body, {tabbable: true});
|
|
@@ -432,6 +483,7 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus: boolean,
|
|
|
432
483
|
|
|
433
484
|
if (!document.body.contains(nodeToRestore) || nodeToRestore === document.body) {
|
|
434
485
|
nodeToRestore = null;
|
|
486
|
+
focusScopeTree.getTreeNode(scopeRef).nodeToRestore = null;
|
|
435
487
|
}
|
|
436
488
|
|
|
437
489
|
// If there is no next element, or it is outside the current scope, move focus to the
|
|
@@ -469,12 +521,31 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus: boolean,
|
|
|
469
521
|
if (!contain) {
|
|
470
522
|
document.removeEventListener('keydown', onKeyDown, true);
|
|
471
523
|
}
|
|
524
|
+
let nodeToRestore = focusScopeTree.getTreeNode(scopeRef).nodeToRestore;
|
|
472
525
|
|
|
473
|
-
if
|
|
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();
|
|
474
537
|
requestAnimationFrame(() => {
|
|
475
538
|
// Only restore focus if we've lost focus to the body, the alternative is that focus has been purposefully moved elsewhere
|
|
476
|
-
if (document.
|
|
477
|
-
|
|
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
|
+
}
|
|
478
549
|
}
|
|
479
550
|
});
|
|
480
551
|
}
|
|
@@ -611,3 +682,103 @@ function last(walker: TreeWalker) {
|
|
|
611
682
|
} while (last);
|
|
612
683
|
return next;
|
|
613
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();
|