@react-aria/focus 3.10.1 → 3.12.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/package.json CHANGED
@@ -1,10 +1,15 @@
1
1
  {
2
2
  "name": "@react-aria/focus",
3
- "version": "3.10.1",
3
+ "version": "3.12.0",
4
4
  "description": "Spectrum UI components in React",
5
5
  "license": "Apache-2.0",
6
6
  "main": "dist/main.js",
7
7
  "module": "dist/module.js",
8
+ "exports": {
9
+ "types": "./dist/types.d.ts",
10
+ "import": "./dist/import.mjs",
11
+ "require": "./dist/main.js"
12
+ },
8
13
  "types": "dist/types.d.ts",
9
14
  "source": "src/index.ts",
10
15
  "files": [
@@ -17,9 +22,9 @@
17
22
  "url": "https://github.com/adobe/react-spectrum"
18
23
  },
19
24
  "dependencies": {
20
- "@react-aria/interactions": "^3.13.1",
21
- "@react-aria/utils": "^3.14.2",
22
- "@react-types/shared": "^3.16.0",
25
+ "@react-aria/interactions": "^3.15.0",
26
+ "@react-aria/utils": "^3.16.0",
27
+ "@react-types/shared": "^3.18.0",
23
28
  "@swc/helpers": "^0.4.14",
24
29
  "clsx": "^1.1.1"
25
30
  },
@@ -29,5 +34,5 @@
29
34
  "publishConfig": {
30
35
  "access": "public"
31
36
  },
32
- "gitHead": "5480d76bd815e239366f92852c76b6831ad2a4fd"
37
+ "gitHead": "9d1ba9bd8ebcd63bf3495ade16d349bcb71795ce"
33
38
  }
@@ -61,8 +61,8 @@ export interface FocusManager {
61
61
 
62
62
  type ScopeRef = RefObject<Element[]>;
63
63
  interface IFocusContext {
64
- scopeRef: ScopeRef,
65
- focusManager: FocusManager
64
+ focusManager: FocusManager,
65
+ parentNode: TreeNode | null
66
66
  }
67
67
 
68
68
  const FocusContext = React.createContext<IFocusContext>(null);
@@ -84,13 +84,33 @@ export function FocusScope(props: FocusScopeProps) {
84
84
  let startRef = useRef<HTMLSpanElement>();
85
85
  let endRef = useRef<HTMLSpanElement>();
86
86
  let scopeRef = useRef<Element[]>([]);
87
- let ctx = useContext(FocusContext);
87
+ let {parentNode} = useContext(FocusContext) || {};
88
88
 
89
- // The parent scope is based on the JSX tree, using context.
90
- // However, if a new scope mounts outside the active scope (e.g. DialogContainer launched from a menu),
91
- // we want the parent scope to be the active scope instead.
92
- let ctxParent = ctx?.scopeRef ?? null;
93
- let parentScope = useMemo(() => activeScope && focusScopeTree.getTreeNode(activeScope) && !isAncestorScope(activeScope, ctxParent) ? activeScope : ctxParent, [ctxParent]);
89
+ // Create a tree node here so we can add children to it even before it is added to the tree.
90
+ let node = useMemo(() => new TreeNode({scopeRef}), [scopeRef]);
91
+
92
+ useLayoutEffect(() => {
93
+ // If a new scope mounts outside the active scope, (e.g. DialogContainer launched from a menu),
94
+ // use the active scope as the parent instead of the parent from context. Layout effects run bottom
95
+ // up, so if the parent is not yet added to the tree, don't do this. Only the outer-most FocusScope
96
+ // that is being added should get the activeScope as its parent.
97
+ let parent = parentNode || focusScopeTree.root;
98
+ if (focusScopeTree.getTreeNode(parent.scopeRef) && activeScope && !isAncestorScope(activeScope, parent.scopeRef)) {
99
+ let activeNode = focusScopeTree.getTreeNode(activeScope);
100
+ if (activeNode) {
101
+ parent = activeNode;
102
+ }
103
+ }
104
+
105
+ // Add the node to the parent, and to the tree.
106
+ parent.addChild(node);
107
+ focusScopeTree.addNode(node);
108
+ }, [node, parentNode]);
109
+
110
+ useLayoutEffect(() => {
111
+ let node = focusScopeTree.getTreeNode(scopeRef);
112
+ node.contain = contain;
113
+ }, [contain]);
94
114
 
95
115
  useLayoutEffect(() => {
96
116
  // Find all rendered nodes between the sentinels and add them to the scope.
@@ -102,16 +122,7 @@ export function FocusScope(props: FocusScopeProps) {
102
122
  }
103
123
 
104
124
  scopeRef.current = nodes;
105
- }, [children, parentScope]);
106
-
107
- // add to the focus scope tree in render order because useEffects/useLayoutEffects run children first whereas render runs parent first
108
- // which matters when constructing a tree
109
- if (focusScopeTree.getTreeNode(parentScope) && !focusScopeTree.getTreeNode(scopeRef)) {
110
- focusScopeTree.addTreeNode(scopeRef, parentScope);
111
- }
112
-
113
- let node = focusScopeTree.getTreeNode(scopeRef);
114
- node.contain = contain;
125
+ }, [children]);
115
126
 
116
127
  useActiveScopeTracker(scopeRef, restoreFocus, contain);
117
128
  useFocusContainment(scopeRef, contain);
@@ -119,8 +130,26 @@ export function FocusScope(props: FocusScopeProps) {
119
130
  useAutoFocus(scopeRef, autoFocus);
120
131
 
121
132
  // this layout effect needs to run last so that focusScopeTree cleanup happens at the last moment possible
122
- useLayoutEffect(() => {
133
+ useEffect(() => {
123
134
  if (scopeRef) {
135
+ let activeElement = document.activeElement;
136
+ let scope = null;
137
+ // In strict mode, active scope is incorrectly updated since cleanup will run even though scope hasn't unmounted.
138
+ // To fix this, we need to update the actual activeScope here
139
+ if (isElementInScope(activeElement, scopeRef.current)) {
140
+ // Since useLayoutEffect runs for children first, we need to traverse the focusScope tree and find the bottom most scope that
141
+ // contains the active element and set that as the activeScope
142
+ for (let node of focusScopeTree.traverse()) {
143
+ if (isElementInScope(activeElement, node.scopeRef.current)) {
144
+ scope = node;
145
+ }
146
+ }
147
+
148
+ if (scope === focusScopeTree.getTreeNode(scopeRef)) {
149
+ activeScope = scope.scopeRef;
150
+ }
151
+ }
152
+
124
153
  return () => {
125
154
  // Scope may have been re-parented.
126
155
  let parentScope = focusScopeTree.getTreeNode(scopeRef).parent.scopeRef;
@@ -137,12 +166,16 @@ export function FocusScope(props: FocusScopeProps) {
137
166
  focusScopeTree.removeTreeNode(scopeRef);
138
167
  };
139
168
  }
140
- }, [scopeRef, parentScope]);
169
+ }, [scopeRef]);
141
170
 
142
- let focusManager = createFocusManagerForScope(scopeRef);
171
+ let focusManager = useMemo(() => createFocusManagerForScope(scopeRef), []);
172
+ let value = useMemo(() => ({
173
+ focusManager,
174
+ parentNode: node
175
+ }), [node, focusManager]);
143
176
 
144
177
  return (
145
- <FocusContext.Provider value={{scopeRef, focusManager}}>
178
+ <FocusContext.Provider value={value}>
146
179
  <span data-focus-scope-start hidden ref={startRef} />
147
180
  {children}
148
181
  <span data-focus-scope-end hidden ref={endRef} />
@@ -320,6 +353,9 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain: boolean) {
320
353
 
321
354
  let onBlur = (e) => {
322
355
  // Firefox doesn't shift focus back to the Dialog properly without this
356
+ if (raf.current) {
357
+ cancelAnimationFrame(raf.current);
358
+ }
323
359
  raf.current = requestAnimationFrame(() => {
324
360
  // Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe
325
361
  if (shouldContainFocus(scopeRef) && !isElementInChildScope(document.activeElement, scopeRef)) {
@@ -365,6 +401,11 @@ function isElementInScope(element: Element, scope: Element[]) {
365
401
  }
366
402
 
367
403
  function isElementInChildScope(element: Element, scope: ScopeRef = null) {
404
+ // If the element is within a top layer element (e.g. toasts), always allow moving focus there.
405
+ if (element instanceof Element && element.closest('[data-react-aria-top-layer]')) {
406
+ return true;
407
+ }
408
+
368
409
  // node.contains in isElementInScope covers child scopes that are also DOM children,
369
410
  // but does not cover child scopes in portals.
370
411
  for (let {scopeRef: s} of focusScopeTree.traverse(focusScopeTree.getTreeNode(scope))) {
@@ -753,7 +794,7 @@ function last(walker: TreeWalker) {
753
794
 
754
795
 
755
796
  class Tree {
756
- private root: TreeNode;
797
+ root: TreeNode;
757
798
  private fastMap = new Map<ScopeRef, TreeNode>();
758
799
 
759
800
  constructor() {
@@ -780,6 +821,10 @@ class Tree {
780
821
  }
781
822
  }
782
823
 
824
+ addNode(node: TreeNode) {
825
+ this.fastMap.set(node.scopeRef, node);
826
+ }
827
+
783
828
  removeTreeNode(scopeRef: ScopeRef) {
784
829
  // never remove the root
785
830
  if (scopeRef === null) {
@@ -802,9 +847,10 @@ class Tree {
802
847
  }
803
848
  let children = node.children;
804
849
  parentNode.removeChild(node);
805
- if (children.length > 0) {
850
+ if (children.size > 0) {
806
851
  children.forEach(child => parentNode.addChild(child));
807
852
  }
853
+
808
854
  this.fastMap.delete(node.scopeRef);
809
855
  }
810
856
 
@@ -813,7 +859,7 @@ class Tree {
813
859
  if (node.scopeRef != null) {
814
860
  yield node;
815
861
  }
816
- if (node.children.length > 0) {
862
+ if (node.children.size > 0) {
817
863
  for (let child of node.children) {
818
864
  yield* this.traverse(child);
819
865
  }
@@ -833,18 +879,18 @@ class TreeNode {
833
879
  public scopeRef: ScopeRef;
834
880
  public nodeToRestore: FocusableElement;
835
881
  public parent: TreeNode;
836
- public children: TreeNode[] = [];
882
+ public children: Set<TreeNode> = new Set();
837
883
  public contain = false;
838
884
 
839
885
  constructor(props: {scopeRef: ScopeRef}) {
840
886
  this.scopeRef = props.scopeRef;
841
887
  }
842
888
  addChild(node: TreeNode) {
843
- this.children.push(node);
889
+ this.children.add(node);
844
890
  node.parent = this;
845
891
  }
846
892
  removeChild(node: TreeNode) {
847
- this.children.splice(this.children.indexOf(node), 1);
893
+ this.children.delete(node);
848
894
  node.parent = undefined;
849
895
  }
850
896
  }
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ export {FocusRing} from './FocusRing';
15
15
  export {FocusableProvider, useFocusable} from './useFocusable';
16
16
  export {useFocusRing} from './useFocusRing';
17
17
  export {focusSafely} from './focusSafely';
18
+ export {useHasTabbableChild} from './useHasTabbableChild';
18
19
 
19
20
  export type {FocusScopeProps, FocusManager, FocusManagerOptions} from './FocusScope';
20
21
  export type {FocusRingProps} from './FocusRing';
@@ -0,0 +1,65 @@
1
+ /*
2
+ * Copyright 2022 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import {getFocusableTreeWalker} from './FocusScope';
14
+ import {RefObject, useState} from 'react';
15
+ import {useLayoutEffect} from '@react-aria/utils';
16
+
17
+ interface AriaHasTabbableChildOptions {
18
+ isDisabled?: boolean
19
+ }
20
+
21
+ // This was created for a special empty case of a component that can have child or
22
+ // be empty, like Collection/Virtualizer/Table/ListView/etc. When these components
23
+ // are empty they can have a message with a tabbable element, which is like them
24
+ // being not empty, when it comes to focus and tab order.
25
+
26
+ /**
27
+ * Returns whether an element has a tabbable child, and updates as children change.
28
+ * @private
29
+ */
30
+ export function useHasTabbableChild(ref: RefObject<Element>, options?: AriaHasTabbableChildOptions): boolean {
31
+ let isDisabled = options?.isDisabled;
32
+ let [hasTabbableChild, setHasTabbableChild] = useState(false);
33
+
34
+ useLayoutEffect(() => {
35
+ if (ref?.current && !isDisabled) {
36
+ let update = () => {
37
+ if (ref.current) {
38
+ let walker = getFocusableTreeWalker(ref.current, {tabbable: true});
39
+ setHasTabbableChild(!!walker.nextNode());
40
+ }
41
+ };
42
+
43
+ update();
44
+
45
+ // Update when new elements are inserted, or the tabIndex/disabled attribute updates.
46
+ let observer = new MutationObserver(update);
47
+ observer.observe(ref.current, {
48
+ subtree: true,
49
+ childList: true,
50
+ attributes: true,
51
+ attributeFilter: ['tabIndex', 'disabled']
52
+ });
53
+
54
+ return () => {
55
+ // Disconnect mutation observer when a React update occurs on the top-level component
56
+ // so we update synchronously after re-rendering. Otherwise React will emit act warnings
57
+ // in tests since mutation observers fire asynchronously. The mutation observer is necessary
58
+ // so we also update if a child component re-renders and adds/removes something tabbable.
59
+ observer.disconnect();
60
+ };
61
+ }
62
+ });
63
+
64
+ return isDisabled ? false : hasTabbableChild;
65
+ }