@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.
@@ -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
- const FocusContext = React.createContext<FocusManager>(null);
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: RefObject<HTMLElement[]> = null;
60
- let scopes: Set<RefObject<HTMLElement[]>> = new Set();
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
- scopes.add(scopeRef);
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
- }, [children]);
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
- useEffect(() => {
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 focus event occurs outside the active scope (e.g. user tabs from browser location bar),
221
- // restore focus to the previously focused node or the first tabbable element in the active scope.
222
- let isInAnyScope = isElementInAnyScope(e.target, scopes);
223
- if (!isInAnyScope) {
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
- let isInAnyScope = isElementInAnyScope(document.activeElement, scopes);
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, scopes: Set<RefObject<HTMLElement[]>>) {
268
- for (let scope of scopes.values()) {
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 (autoFocus) {
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
- }, [scopeRef, autoFocus]);
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 scope = scopeRef.current;
318
- let nodeToRestore = document.activeElement as HTMLElement;
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, scope)) {
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, scope)) && nodeToRestore) {
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, scope));
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
- // If there is no next element, blur the focused element to move focus to the body.
361
- focusedElement.blur();
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, scope)) {
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
  }
@@ -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: state.isFocused,
74
+ isFocused,
73
75
  isFocusVisible: state.isFocused && isFocusVisibleState,
74
76
  focusProps: within ? focusWithinProps : focusProps
75
77
  };
@@ -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
- return context;
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 (props.autoFocus && domRef.current) {
75
+ if (autoFocusRef.current && domRef.current) {
73
76
  domRef.current.focus();
74
77
  }
75
- }, [props.autoFocus, domRef]);
78
+ autoFocusRef.current = false;
79
+ }, []);
76
80
 
77
81
  return {
78
82
  focusableProps: mergeProps(