@react-aria/focus 3.4.0 → 3.5.2
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 +552 -680
- package/dist/main.js.map +1 -1
- package/dist/module.js +535 -635
- package/dist/module.js.map +1 -1
- package/dist/types.d.ts +173 -169
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/FocusScope.tsx +139 -33
- package/src/useFocusRing.ts +3 -1
- package/src/useFocusable.tsx +8 -4
package/src/FocusScope.tsx
CHANGED
|
@@ -51,13 +51,23 @@ interface FocusManager {
|
|
|
51
51
|
/** Moves focus to the next focusable or tabbable element in the focus scope. */
|
|
52
52
|
focusNext(opts?: FocusManagerOptions): HTMLElement,
|
|
53
53
|
/** Moves focus to the previous focusable or tabbable element in the focus scope. */
|
|
54
|
-
focusPrevious(opts?: FocusManagerOptions): HTMLElement
|
|
54
|
+
focusPrevious(opts?: FocusManagerOptions): HTMLElement,
|
|
55
|
+
/** Moves focus to the first focusable or tabbable element in the focus scope. */
|
|
56
|
+
focusFirst(opts?: FocusManagerOptions): HTMLElement,
|
|
57
|
+
/** Moves focus to the last focusable or tabbable element in the focus scope. */
|
|
58
|
+
focusLast(opts?: FocusManagerOptions): HTMLElement
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
|
|
61
|
+
type ScopeRef = RefObject<HTMLElement[]>;
|
|
62
|
+
interface IFocusContext {
|
|
63
|
+
scopeRef: ScopeRef,
|
|
64
|
+
focusManager: FocusManager
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const FocusContext = React.createContext<IFocusContext>(null);
|
|
58
68
|
|
|
59
|
-
let activeScope:
|
|
60
|
-
let scopes:
|
|
69
|
+
let activeScope: ScopeRef = null;
|
|
70
|
+
let scopes: Map<ScopeRef, ScopeRef | null> = new Map();
|
|
61
71
|
|
|
62
72
|
// This is a hacky DOM-based implementation of a FocusScope until this RFC lands in React:
|
|
63
73
|
// https://github.com/reactjs/rfcs/pull/109
|
|
@@ -76,6 +86,8 @@ export function FocusScope(props: FocusScopeProps) {
|
|
|
76
86
|
let startRef = useRef<HTMLSpanElement>();
|
|
77
87
|
let endRef = useRef<HTMLSpanElement>();
|
|
78
88
|
let scopeRef = useRef<HTMLElement[]>([]);
|
|
89
|
+
let ctx = useContext(FocusContext);
|
|
90
|
+
let parentScope = ctx?.scopeRef;
|
|
79
91
|
|
|
80
92
|
useLayoutEffect(() => {
|
|
81
93
|
// Find all rendered nodes between the sentinels and add them to the scope.
|
|
@@ -87,11 +99,23 @@ export function FocusScope(props: FocusScopeProps) {
|
|
|
87
99
|
}
|
|
88
100
|
|
|
89
101
|
scopeRef.current = nodes;
|
|
90
|
-
|
|
102
|
+
}, [children, parentScope]);
|
|
103
|
+
|
|
104
|
+
useLayoutEffect(() => {
|
|
105
|
+
scopes.set(scopeRef, parentScope);
|
|
91
106
|
return () => {
|
|
107
|
+
// Restore the active scope on unmount if this scope or a descendant scope is active.
|
|
108
|
+
// Parent effect cleanups run before children, so we need to check if the
|
|
109
|
+
// parent scope actually still exists before restoring the active scope to it.
|
|
110
|
+
if (
|
|
111
|
+
(scopeRef === activeScope || isAncestorScope(scopeRef, activeScope)) &&
|
|
112
|
+
(!parentScope || scopes.has(parentScope))
|
|
113
|
+
) {
|
|
114
|
+
activeScope = parentScope;
|
|
115
|
+
}
|
|
92
116
|
scopes.delete(scopeRef);
|
|
93
117
|
};
|
|
94
|
-
}, [
|
|
118
|
+
}, [scopeRef, parentScope]);
|
|
95
119
|
|
|
96
120
|
useFocusContainment(scopeRef, contain);
|
|
97
121
|
useRestoreFocus(scopeRef, restoreFocus, contain);
|
|
@@ -100,10 +124,10 @@ export function FocusScope(props: FocusScopeProps) {
|
|
|
100
124
|
let focusManager = createFocusManagerForScope(scopeRef);
|
|
101
125
|
|
|
102
126
|
return (
|
|
103
|
-
<FocusContext.Provider value={focusManager}>
|
|
104
|
-
<span hidden ref={startRef} />
|
|
127
|
+
<FocusContext.Provider value={{scopeRef, focusManager}}>
|
|
128
|
+
<span data-focus-scope-start hidden ref={startRef} />
|
|
105
129
|
{children}
|
|
106
|
-
<span hidden ref={endRef} />
|
|
130
|
+
<span data-focus-scope-end hidden ref={endRef} />
|
|
107
131
|
</FocusContext.Provider>
|
|
108
132
|
);
|
|
109
133
|
}
|
|
@@ -114,7 +138,7 @@ export function FocusScope(props: FocusScopeProps) {
|
|
|
114
138
|
* a FocusScope, e.g. in response to user events like keyboard navigation.
|
|
115
139
|
*/
|
|
116
140
|
export function useFocusManager(): FocusManager {
|
|
117
|
-
return useContext(FocusContext);
|
|
141
|
+
return useContext(FocusContext)?.focusManager;
|
|
118
142
|
}
|
|
119
143
|
|
|
120
144
|
function createFocusManagerForScope(scopeRef: React.RefObject<HTMLElement[]>): FocusManager {
|
|
@@ -152,6 +176,28 @@ function createFocusManagerForScope(scopeRef: React.RefObject<HTMLElement[]>): F
|
|
|
152
176
|
focusElement(previousNode, true);
|
|
153
177
|
}
|
|
154
178
|
return previousNode;
|
|
179
|
+
},
|
|
180
|
+
focusFirst(opts = {}) {
|
|
181
|
+
let scope = scopeRef.current;
|
|
182
|
+
let {tabbable} = opts;
|
|
183
|
+
let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable}, scope);
|
|
184
|
+
walker.currentNode = scope[0].previousElementSibling;
|
|
185
|
+
let nextNode = walker.nextNode() as HTMLElement;
|
|
186
|
+
if (nextNode) {
|
|
187
|
+
focusElement(nextNode, true);
|
|
188
|
+
}
|
|
189
|
+
return nextNode;
|
|
190
|
+
},
|
|
191
|
+
focusLast(opts = {}) {
|
|
192
|
+
let scope = scopeRef.current;
|
|
193
|
+
let {tabbable} = opts;
|
|
194
|
+
let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable}, scope);
|
|
195
|
+
walker.currentNode = scope[scope.length - 1].nextElementSibling;
|
|
196
|
+
let previousNode = walker.previousNode() as HTMLElement;
|
|
197
|
+
if (previousNode) {
|
|
198
|
+
focusElement(previousNode, true);
|
|
199
|
+
}
|
|
200
|
+
return previousNode;
|
|
155
201
|
}
|
|
156
202
|
};
|
|
157
203
|
}
|
|
@@ -185,7 +231,7 @@ function useFocusContainment(scopeRef: RefObject<HTMLElement[]>, contain: boolea
|
|
|
185
231
|
let focusedNode = useRef<HTMLElement>();
|
|
186
232
|
|
|
187
233
|
let raf = useRef(null);
|
|
188
|
-
|
|
234
|
+
useLayoutEffect(() => {
|
|
189
235
|
let scope = scopeRef.current;
|
|
190
236
|
if (!contain) {
|
|
191
237
|
return;
|
|
@@ -193,11 +239,12 @@ function useFocusContainment(scopeRef: RefObject<HTMLElement[]>, contain: boolea
|
|
|
193
239
|
|
|
194
240
|
// Handle the Tab key to contain focus within the scope
|
|
195
241
|
let onKeyDown = (e) => {
|
|
196
|
-
if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey) {
|
|
242
|
+
if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey || scopeRef !== activeScope) {
|
|
197
243
|
return;
|
|
198
244
|
}
|
|
199
245
|
|
|
200
246
|
let focusedElement = document.activeElement as HTMLElement;
|
|
247
|
+
let scope = scopeRef.current;
|
|
201
248
|
if (!isElementInScope(focusedElement, scope)) {
|
|
202
249
|
return;
|
|
203
250
|
}
|
|
@@ -217,17 +264,20 @@ function useFocusContainment(scopeRef: RefObject<HTMLElement[]>, contain: boolea
|
|
|
217
264
|
};
|
|
218
265
|
|
|
219
266
|
let onFocus = (e) => {
|
|
220
|
-
// If a
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
267
|
+
// If focusing an element in a child scope of the currently active scope, the child becomes active.
|
|
268
|
+
// Moving out of the active scope to an ancestor is not allowed.
|
|
269
|
+
if (!activeScope || isAncestorScope(activeScope, scopeRef)) {
|
|
270
|
+
activeScope = scopeRef;
|
|
271
|
+
focusedNode.current = e.target;
|
|
272
|
+
} else if (scopeRef === activeScope && !isElementInChildScope(e.target, scopeRef)) {
|
|
273
|
+
// If a focus event occurs outside the active scope (e.g. user tabs from browser location bar),
|
|
274
|
+
// restore focus to the previously focused node or the first tabbable element in the active scope.
|
|
224
275
|
if (focusedNode.current) {
|
|
225
276
|
focusedNode.current.focus();
|
|
226
277
|
} else if (activeScope) {
|
|
227
278
|
focusFirstInScope(activeScope.current);
|
|
228
279
|
}
|
|
229
|
-
} else {
|
|
230
|
-
activeScope = scopeRef;
|
|
280
|
+
} else if (scopeRef === activeScope) {
|
|
231
281
|
focusedNode.current = e.target;
|
|
232
282
|
}
|
|
233
283
|
};
|
|
@@ -236,9 +286,7 @@ function useFocusContainment(scopeRef: RefObject<HTMLElement[]>, contain: boolea
|
|
|
236
286
|
// Firefox doesn't shift focus back to the Dialog properly without this
|
|
237
287
|
raf.current = requestAnimationFrame(() => {
|
|
238
288
|
// Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (!isInAnyScope) {
|
|
289
|
+
if (scopeRef === activeScope && !isElementInChildScope(document.activeElement, scopeRef)) {
|
|
242
290
|
activeScope = scopeRef;
|
|
243
291
|
focusedNode.current = e.target;
|
|
244
292
|
focusedNode.current.focus();
|
|
@@ -264,8 +312,8 @@ function useFocusContainment(scopeRef: RefObject<HTMLElement[]>, contain: boolea
|
|
|
264
312
|
}, [raf]);
|
|
265
313
|
}
|
|
266
314
|
|
|
267
|
-
function isElementInAnyScope(element: Element
|
|
268
|
-
for (let scope of scopes.
|
|
315
|
+
function isElementInAnyScope(element: Element) {
|
|
316
|
+
for (let scope of scopes.keys()) {
|
|
269
317
|
if (isElementInScope(element, scope.current)) {
|
|
270
318
|
return true;
|
|
271
319
|
}
|
|
@@ -277,6 +325,31 @@ function isElementInScope(element: Element, scope: HTMLElement[]) {
|
|
|
277
325
|
return scope.some(node => node.contains(element));
|
|
278
326
|
}
|
|
279
327
|
|
|
328
|
+
function isElementInChildScope(element: Element, scope: ScopeRef) {
|
|
329
|
+
// node.contains in isElementInScope covers child scopes that are also DOM children,
|
|
330
|
+
// but does not cover child scopes in portals.
|
|
331
|
+
for (let s of scopes.keys()) {
|
|
332
|
+
if ((s === scope || isAncestorScope(scope, s)) && isElementInScope(element, s.current)) {
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function isAncestorScope(ancestor: ScopeRef, scope: ScopeRef) {
|
|
341
|
+
let parent = scopes.get(scope);
|
|
342
|
+
if (!parent) {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (parent === ancestor) {
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return isAncestorScope(ancestor, parent);
|
|
351
|
+
}
|
|
352
|
+
|
|
280
353
|
function focusElement(element: HTMLElement | null, scroll = false) {
|
|
281
354
|
if (element != null && !scroll) {
|
|
282
355
|
try {
|
|
@@ -301,21 +374,28 @@ function focusFirstInScope(scope: HTMLElement[]) {
|
|
|
301
374
|
}
|
|
302
375
|
|
|
303
376
|
function useAutoFocus(scopeRef: RefObject<HTMLElement[]>, autoFocus: boolean) {
|
|
377
|
+
const autoFocusRef = React.useRef(autoFocus);
|
|
304
378
|
useEffect(() => {
|
|
305
|
-
if (
|
|
379
|
+
if (autoFocusRef.current) {
|
|
306
380
|
activeScope = scopeRef;
|
|
307
381
|
if (!isElementInScope(document.activeElement, activeScope.current)) {
|
|
308
382
|
focusFirstInScope(scopeRef.current);
|
|
309
383
|
}
|
|
310
384
|
}
|
|
311
|
-
|
|
385
|
+
autoFocusRef.current = false;
|
|
386
|
+
}, []);
|
|
312
387
|
}
|
|
313
388
|
|
|
314
389
|
function useRestoreFocus(scopeRef: RefObject<HTMLElement[]>, restoreFocus: boolean, contain: boolean) {
|
|
390
|
+
// create a ref during render instead of useLayoutEffect so the active element is saved before a child with autoFocus=true mounts.
|
|
391
|
+
const nodeToRestoreRef = useRef(typeof document !== 'undefined' ? document.activeElement as HTMLElement : null);
|
|
392
|
+
|
|
315
393
|
// useLayoutEffect instead of useEffect so the active element is saved synchronously instead of asynchronously.
|
|
316
394
|
useLayoutEffect(() => {
|
|
317
|
-
let
|
|
318
|
-
|
|
395
|
+
let nodeToRestore = nodeToRestoreRef.current;
|
|
396
|
+
if (!restoreFocus) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
319
399
|
|
|
320
400
|
// Handle the Tab key so that tabbing out of the scope goes to the next element
|
|
321
401
|
// after the node that had focus when the scope mounted. This is important when
|
|
@@ -327,7 +407,7 @@ function useRestoreFocus(scopeRef: RefObject<HTMLElement[]>, restoreFocus: boole
|
|
|
327
407
|
}
|
|
328
408
|
|
|
329
409
|
let focusedElement = document.activeElement as HTMLElement;
|
|
330
|
-
if (!isElementInScope(focusedElement,
|
|
410
|
+
if (!isElementInScope(focusedElement, scopeRef.current)) {
|
|
331
411
|
return;
|
|
332
412
|
}
|
|
333
413
|
|
|
@@ -344,21 +424,27 @@ function useRestoreFocus(scopeRef: RefObject<HTMLElement[]>, restoreFocus: boole
|
|
|
344
424
|
|
|
345
425
|
// If there is no next element, or it is outside the current scope, move focus to the
|
|
346
426
|
// next element after the node to restore to instead.
|
|
347
|
-
if ((!nextElement || !isElementInScope(nextElement,
|
|
427
|
+
if ((!nextElement || !isElementInScope(nextElement, scopeRef.current)) && nodeToRestore) {
|
|
348
428
|
walker.currentNode = nodeToRestore;
|
|
349
429
|
|
|
350
430
|
// Skip over elements within the scope, in case the scope immediately follows the node to restore.
|
|
351
431
|
do {
|
|
352
432
|
nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as HTMLElement;
|
|
353
|
-
} while (isElementInScope(nextElement,
|
|
433
|
+
} while (isElementInScope(nextElement, scopeRef.current));
|
|
354
434
|
|
|
355
435
|
e.preventDefault();
|
|
356
436
|
e.stopPropagation();
|
|
357
437
|
if (nextElement) {
|
|
358
438
|
focusElement(nextElement, true);
|
|
359
439
|
} else {
|
|
360
|
-
|
|
361
|
-
|
|
440
|
+
// If there is no next element and the nodeToRestore isn't within a FocusScope (i.e. we are leaving the top level focus scope)
|
|
441
|
+
// then move focus to the body.
|
|
442
|
+
// Otherwise restore focus to the nodeToRestore (e.g menu within a popover -> tabbing to close the menu should move focus to menu trigger)
|
|
443
|
+
if (!isElementInAnyScope(nodeToRestore)) {
|
|
444
|
+
focusedElement.blur();
|
|
445
|
+
} else {
|
|
446
|
+
focusElement(nodeToRestore, true);
|
|
447
|
+
}
|
|
362
448
|
}
|
|
363
449
|
}
|
|
364
450
|
};
|
|
@@ -372,7 +458,7 @@ function useRestoreFocus(scopeRef: RefObject<HTMLElement[]>, restoreFocus: boole
|
|
|
372
458
|
document.removeEventListener('keydown', onKeyDown, true);
|
|
373
459
|
}
|
|
374
460
|
|
|
375
|
-
if (restoreFocus && nodeToRestore && isElementInScope(document.activeElement,
|
|
461
|
+
if (restoreFocus && nodeToRestore && isElementInScope(document.activeElement, scopeRef.current)) {
|
|
376
462
|
requestAnimationFrame(() => {
|
|
377
463
|
if (document.body.contains(nodeToRestore)) {
|
|
378
464
|
focusElement(nodeToRestore);
|
|
@@ -463,6 +549,26 @@ export function createFocusManager(ref: RefObject<HTMLElement>): FocusManager {
|
|
|
463
549
|
focusElement(previousNode, true);
|
|
464
550
|
}
|
|
465
551
|
return previousNode;
|
|
552
|
+
},
|
|
553
|
+
focusFirst(opts = {}) {
|
|
554
|
+
let root = ref.current;
|
|
555
|
+
let {tabbable} = opts;
|
|
556
|
+
let walker = getFocusableTreeWalker(root, {tabbable});
|
|
557
|
+
let nextNode = walker.nextNode() as HTMLElement;
|
|
558
|
+
if (nextNode) {
|
|
559
|
+
focusElement(nextNode, true);
|
|
560
|
+
}
|
|
561
|
+
return nextNode;
|
|
562
|
+
},
|
|
563
|
+
focusLast(opts = {}) {
|
|
564
|
+
let root = ref.current;
|
|
565
|
+
let {tabbable} = opts;
|
|
566
|
+
let walker = getFocusableTreeWalker(root, {tabbable});
|
|
567
|
+
let next = last(walker);
|
|
568
|
+
if (next) {
|
|
569
|
+
focusElement(next, true);
|
|
570
|
+
}
|
|
571
|
+
return next;
|
|
466
572
|
}
|
|
467
573
|
};
|
|
468
574
|
}
|
package/src/useFocusRing.ts
CHANGED
|
@@ -44,12 +44,14 @@ export function useFocusRing(props: FocusRingProps = {}): FocusRingAria {
|
|
|
44
44
|
isFocused: false,
|
|
45
45
|
isFocusVisible: autoFocus || isFocusVisible()
|
|
46
46
|
}).current;
|
|
47
|
+
let [isFocused, setFocused] = useState(false);
|
|
47
48
|
let [isFocusVisibleState, setFocusVisible] = useState(() => state.isFocused && state.isFocusVisible);
|
|
48
49
|
|
|
49
50
|
let updateState = () => setFocusVisible(state.isFocused && state.isFocusVisible);
|
|
50
51
|
|
|
51
52
|
let onFocusChange = isFocused => {
|
|
52
53
|
state.isFocused = isFocused;
|
|
54
|
+
setFocused(isFocused);
|
|
53
55
|
updateState();
|
|
54
56
|
};
|
|
55
57
|
|
|
@@ -69,7 +71,7 @@ export function useFocusRing(props: FocusRingProps = {}): FocusRingAria {
|
|
|
69
71
|
});
|
|
70
72
|
|
|
71
73
|
return {
|
|
72
|
-
isFocused
|
|
74
|
+
isFocused,
|
|
73
75
|
isFocusVisible: state.isFocused && isFocusVisibleState,
|
|
74
76
|
focusProps: within ? focusWithinProps : focusProps
|
|
75
77
|
};
|
package/src/useFocusable.tsx
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import {FocusableDOMProps, FocusableProps} from '@react-types/shared';
|
|
14
14
|
import {mergeProps, useSyncRef} from '@react-aria/utils';
|
|
15
|
-
import React, {HTMLAttributes, MutableRefObject, ReactNode, RefObject, useContext, useEffect} from 'react';
|
|
15
|
+
import React, {HTMLAttributes, MutableRefObject, ReactNode, RefObject, useContext, useEffect, useRef} from 'react';
|
|
16
16
|
import {useFocus, useKeyboard} from '@react-aria/interactions';
|
|
17
17
|
|
|
18
18
|
interface FocusableOptions extends FocusableProps, FocusableDOMProps {
|
|
@@ -35,7 +35,9 @@ function useFocusableContext(ref: RefObject<HTMLElement>): FocusableContextValue
|
|
|
35
35
|
let context = useContext(FocusableContext) || {};
|
|
36
36
|
useSyncRef(context, ref);
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
// eslint-disable-next-line
|
|
39
|
+
let {ref: _, ...otherProps} = context;
|
|
40
|
+
return otherProps;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
/**
|
|
@@ -67,12 +69,14 @@ export function useFocusable(props: FocusableOptions, domRef: RefObject<HTMLElem
|
|
|
67
69
|
let interactions = mergeProps(focusProps, keyboardProps);
|
|
68
70
|
let domProps = useFocusableContext(domRef);
|
|
69
71
|
let interactionProps = props.isDisabled ? {} : domProps;
|
|
72
|
+
let autoFocusRef = useRef(props.autoFocus);
|
|
70
73
|
|
|
71
74
|
useEffect(() => {
|
|
72
|
-
if (
|
|
75
|
+
if (autoFocusRef.current && domRef.current) {
|
|
73
76
|
domRef.current.focus();
|
|
74
77
|
}
|
|
75
|
-
|
|
78
|
+
autoFocusRef.current = false;
|
|
79
|
+
}, []);
|
|
76
80
|
|
|
77
81
|
return {
|
|
78
82
|
focusableProps: mergeProps(
|