@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.
- package/dist/main.js +208 -87
- package/dist/main.js.map +1 -1
- package/dist/module.js +197 -68
- package/dist/module.js.map +1 -1
- package/dist/types.d.ts +24 -24
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/FocusRing.tsx +1 -1
- package/src/FocusScope.tsx +298 -104
- package/src/focusSafely.ts +2 -1
- package/src/index.ts +10 -5
- package/src/useFocusRing.ts +6 -5
- package/src/useFocusable.tsx +10 -10
package/src/FocusScope.tsx
CHANGED
|
@@ -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?:
|
|
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):
|
|
53
|
+
focusNext(opts?: FocusManagerOptions): FocusableElement,
|
|
55
54
|
/** Moves focus to the previous focusable or tabbable element in the focus scope. */
|
|
56
|
-
focusPrevious(opts?: FocusManagerOptions):
|
|
55
|
+
focusPrevious(opts?: FocusManagerOptions): FocusableElement,
|
|
57
56
|
/** Moves focus to the first focusable or tabbable element in the focus scope. */
|
|
58
|
-
focusFirst(opts?: FocusManagerOptions):
|
|
57
|
+
focusFirst(opts?: FocusManagerOptions): FocusableElement,
|
|
59
58
|
/** Moves focus to the last focusable or tabbable element in the focus scope. */
|
|
60
|
-
focusLast(opts?: FocusManagerOptions):
|
|
59
|
+
focusLast(opts?: FocusManagerOptions): FocusableElement
|
|
61
60
|
}
|
|
62
61
|
|
|
63
|
-
type ScopeRef = RefObject<
|
|
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<
|
|
86
|
+
let scopeRef = useRef<Element[]>([]);
|
|
91
87
|
let ctx = useContext(FocusContext);
|
|
92
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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<
|
|
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
|
|
163
|
+
let nextNode = walker.nextNode() as FocusableElement;
|
|
156
164
|
if (!nextNode && wrap) {
|
|
157
165
|
walker.currentNode = sentinel;
|
|
158
|
-
nextNode = walker.nextNode() as
|
|
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
|
|
180
|
+
let previousNode = walker.previousNode() as FocusableElement;
|
|
173
181
|
if (!previousNode && wrap) {
|
|
174
182
|
walker.currentNode = sentinel;
|
|
175
|
-
previousNode = walker.previousNode() as
|
|
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
|
|
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
|
|
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:
|
|
236
|
+
function getScopeRoot(scope: Element[]) {
|
|
229
237
|
return scope[0].parentElement;
|
|
230
238
|
}
|
|
231
239
|
|
|
232
|
-
function
|
|
233
|
-
let
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
317
|
+
if (shouldContainFocus(scopeRef) && !isElementInChildScope(document.activeElement, scopeRef)) {
|
|
292
318
|
activeScope = scopeRef;
|
|
293
|
-
|
|
294
|
-
|
|
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 () =>
|
|
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
|
-
|
|
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:
|
|
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
|
|
334
|
-
if (
|
|
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 =
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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:
|
|
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:
|
|
398
|
+
function focusFirstInScope(scope: Element[], tabbable:boolean = true) {
|
|
372
399
|
let sentinel = scope[0].previousElementSibling;
|
|
373
|
-
let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable
|
|
400
|
+
let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable}, scope);
|
|
374
401
|
walker.currentNode = sentinel;
|
|
375
|
-
|
|
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<
|
|
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<
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
466
|
-
|
|
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:
|
|
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
|
|
491
|
-
&& isElementVisible(node as
|
|
492
|
-
&& (!scope || isElementInScope(node as
|
|
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<
|
|
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
|
|
608
|
+
let nextNode = walker.nextNode() as FocusableElement;
|
|
524
609
|
if (!nextNode && wrap) {
|
|
525
610
|
walker.currentNode = root;
|
|
526
|
-
nextNode = walker.nextNode() as
|
|
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
|
|
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
|
|
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:
|
|
582
|
-
let last:
|
|
675
|
+
let next: FocusableElement;
|
|
676
|
+
let last: FocusableElement;
|
|
583
677
|
do {
|
|
584
|
-
last = walker.lastChild() as
|
|
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();
|