@mui/utils 9.0.0-beta.0 → 9.0.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/CHANGELOG.md +76 -0
- package/fastObjectShallowCompare/fastObjectShallowCompare.d.mts +6 -0
- package/fastObjectShallowCompare/fastObjectShallowCompare.d.ts +6 -0
- package/fastObjectShallowCompare/fastObjectShallowCompare.js +41 -0
- package/fastObjectShallowCompare/fastObjectShallowCompare.mjs +35 -0
- package/fastObjectShallowCompare/index.d.mts +1 -0
- package/fastObjectShallowCompare/index.d.ts +1 -0
- package/fastObjectShallowCompare/index.js +13 -0
- package/fastObjectShallowCompare/index.mjs +1 -0
- package/index.d.mts +1 -1
- package/index.d.ts +1 -1
- package/index.js +10 -10
- package/index.mjs +2 -2
- package/package.json +17 -3
- package/useRovingTabIndex/RovingTabIndexContext.d.mts +6 -0
- package/useRovingTabIndex/RovingTabIndexContext.d.ts +6 -0
- package/useRovingTabIndex/RovingTabIndexContext.js +21 -0
- package/useRovingTabIndex/RovingTabIndexContext.mjs +14 -0
- package/useRovingTabIndex/index.d.mts +2 -1
- package/useRovingTabIndex/index.d.ts +2 -1
- package/useRovingTabIndex/index.js +21 -7
- package/useRovingTabIndex/index.mjs +2 -1
- package/useRovingTabIndex/useRovingTabIndex.d.mts +166 -18
- package/useRovingTabIndex/useRovingTabIndex.d.ts +166 -18
- package/useRovingTabIndex/useRovingTabIndex.js +352 -76
- package/useRovingTabIndex/useRovingTabIndex.mjs +349 -75
|
@@ -1,59 +1,99 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import * as React from 'react';
|
|
4
|
-
import
|
|
4
|
+
import fastObjectShallowCompare from "../fastObjectShallowCompare/index.mjs";
|
|
5
5
|
import getActiveElement from "../getActiveElement/index.mjs";
|
|
6
|
+
import ownerDocument from "../ownerDocument/index.mjs";
|
|
7
|
+
import setRef from "../setRef/index.mjs";
|
|
8
|
+
import useEnhancedEffect from "../useEnhancedEffect/index.mjs";
|
|
9
|
+
import useEventCallback from "../useEventCallback/index.mjs";
|
|
10
|
+
import useForkRef from "../useForkRef/index.mjs";
|
|
11
|
+
import { useRovingTabIndexContext } from "./RovingTabIndexContext.mjs";
|
|
6
12
|
const SUPPORTED_KEYS = ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'Home', 'End'];
|
|
7
13
|
|
|
8
14
|
/**
|
|
9
|
-
* Provides roving tab index behavior for a container and its focusable children.
|
|
15
|
+
* Provides roving tab index behavior for a composite container and its focusable children.
|
|
10
16
|
* This is useful for implementing keyboard navigation in components like menus, tabs, and lists.
|
|
11
17
|
* The hook manages the focus state of child elements and provides props to be spread on both the container and the items.
|
|
12
18
|
* The container will handle keyboard events to move focus between items based on the specified orientation and wrapping behavior.
|
|
13
|
-
*
|
|
14
|
-
* @param options - Configuration options for the roving tab index behavior, including orientation, initial focusable index, RTL support, and custom focus logic.
|
|
15
|
-
* @returns An object containing `getItemProps` and `getContainerProps` functions to be spread on the respective elements, and a `focusNext` function to programmatically move focus to the next item.
|
|
16
19
|
*/
|
|
17
|
-
export
|
|
20
|
+
export function useRovingTabIndexRoot(params) {
|
|
18
21
|
const {
|
|
22
|
+
activeItemId: activeItemIdProp,
|
|
23
|
+
getDefaultActiveItemId,
|
|
19
24
|
orientation,
|
|
20
|
-
focusableIndex: focusableIndexProp,
|
|
21
25
|
isRtl = false,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
} =
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
setFocusableIndex(focusableIndexProp);
|
|
26
|
+
isItemFocusable: itemFilter = isItemFocusable,
|
|
27
|
+
wrap = true
|
|
28
|
+
} = params;
|
|
29
|
+
const [activeItemIdState, setActiveItemIdState] = React.useState(activeItemIdProp);
|
|
30
|
+
const previousActiveItemIdPropRef = React.useRef(activeItemIdProp);
|
|
31
|
+
let activeItemIdCandidate = activeItemIdState;
|
|
32
|
+
if (activeItemIdProp !== previousActiveItemIdPropRef.current) {
|
|
33
|
+
previousActiveItemIdPropRef.current = activeItemIdProp;
|
|
34
|
+
if (activeItemIdProp !== undefined && activeItemIdProp !== activeItemIdState) {
|
|
35
|
+
activeItemIdCandidate = activeItemIdProp;
|
|
36
|
+
setActiveItemIdState(activeItemIdProp);
|
|
34
37
|
}
|
|
35
38
|
}
|
|
36
|
-
React.
|
|
37
|
-
|
|
39
|
+
const containerRef = React.useRef(null);
|
|
40
|
+
|
|
41
|
+
// based on https://github.com/mui/base-ui/blob/7392a928fca91fcc68b9fad3439ac61e10f3f7ba/packages/react/src/composite/list/CompositeList.tsx#L25-L35
|
|
42
|
+
const itemMapRef = React.useRef(new Map());
|
|
43
|
+
const [mapTick, setMapTick] = React.useState(0);
|
|
44
|
+
const orderedItems = React.useMemo(() => {
|
|
45
|
+
void mapTick;
|
|
46
|
+
return getOrderedItems(itemMapRef.current);
|
|
47
|
+
}, [mapTick]);
|
|
48
|
+
const resolvedActiveItemId = resolveActiveItemId(activeItemIdCandidate, orderedItems, itemFilter, getDefaultActiveItemId);
|
|
49
|
+
const activeItemIdRef = React.useRef(resolvedActiveItemId);
|
|
50
|
+
activeItemIdRef.current = resolvedActiveItemId;
|
|
51
|
+
const getActiveItem = React.useCallback(() => {
|
|
52
|
+
const snapshot = getOrderedItems(itemMapRef.current);
|
|
53
|
+
const resolvedItemId = resolveActiveItemId(activeItemIdRef.current, snapshot, itemFilter, getDefaultActiveItemId);
|
|
54
|
+
return getItemById(snapshot, resolvedItemId);
|
|
55
|
+
}, [getDefaultActiveItemId, itemFilter]);
|
|
56
|
+
const getItemMap = React.useCallback(() => {
|
|
57
|
+
return itemMapRef.current;
|
|
58
|
+
}, []);
|
|
59
|
+
const registerItem = useEventCallback(item => {
|
|
60
|
+
const previousItem = itemMapRef.current.get(item.id);
|
|
61
|
+
if (fastObjectShallowCompare(previousItem ?? null, item)) {
|
|
38
62
|
return;
|
|
39
63
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
64
|
+
itemMapRef.current.set(item.id, item);
|
|
65
|
+
setMapTick(value => value + 1);
|
|
66
|
+
});
|
|
67
|
+
const unregisterItem = useEventCallback(itemId => {
|
|
68
|
+
if (itemMapRef.current.delete(itemId)) {
|
|
69
|
+
setMapTick(value => value + 1);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
const setActiveItemId = useEventCallback(itemId => {
|
|
73
|
+
setActiveItemIdState(itemId);
|
|
74
|
+
});
|
|
75
|
+
const isItemActive = React.useCallback(itemId => {
|
|
76
|
+
return activeItemIdRef.current === itemId;
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
// Moves focus relative to a starting index. This is the directional helper used by
|
|
80
|
+
// keyboard navigation and `focusNext()`.
|
|
81
|
+
const focusItem = React.useCallback((currentIndex, direction, wrap, isItemFocusableOverride) => {
|
|
82
|
+
const snapshot = getNavigableItemsSnapshot(itemMapRef.current);
|
|
83
|
+
const nextItem = getNextActiveItem(snapshot, currentIndex, direction, wrap, isItemFocusableOverride ?? itemFilter);
|
|
84
|
+
if (!nextItem) {
|
|
85
|
+
return null;
|
|
43
86
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}),
|
|
49
|
-
tabIndex: index === focusableIndex ? 0 : -1
|
|
50
|
-
}), [focusableIndex]);
|
|
87
|
+
nextItem.element?.focus();
|
|
88
|
+
setActiveItemIdState(nextItem.id);
|
|
89
|
+
return nextItem;
|
|
90
|
+
}, [itemFilter]);
|
|
51
91
|
const getContainerProps = React.useCallback(ref => {
|
|
52
92
|
const onFocus = event => {
|
|
53
|
-
const
|
|
54
|
-
const focusedIndex =
|
|
93
|
+
const snapshot = getNavigableItemsSnapshot(itemMapRef.current);
|
|
94
|
+
const focusedIndex = findItemIndexByElement(snapshot, event.target);
|
|
55
95
|
if (focusedIndex !== -1) {
|
|
56
|
-
|
|
96
|
+
setActiveItemIdState(snapshot[focusedIndex].id);
|
|
57
97
|
}
|
|
58
98
|
};
|
|
59
99
|
const onKeyDown = event => {
|
|
@@ -66,45 +106,42 @@ export default function useRovingTabIndex(options) {
|
|
|
66
106
|
let previousItemKey = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp';
|
|
67
107
|
let nextItemKey = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown';
|
|
68
108
|
if (orientation === 'horizontal' && isRtl) {
|
|
69
|
-
// swap previousItemKey with nextItemKey
|
|
70
109
|
previousItemKey = 'ArrowRight';
|
|
71
110
|
nextItemKey = 'ArrowLeft';
|
|
72
111
|
}
|
|
112
|
+
const snapshot = getNavigableItemsSnapshot(itemMapRef.current);
|
|
73
113
|
const currentFocus = getActiveElement(ownerDocument(containerRef.current));
|
|
74
114
|
const isFocusOnContainer = currentFocus === containerRef.current;
|
|
115
|
+
let currentIndex = getCurrentActiveItemIndex(snapshot, currentFocus, activeItemIdRef.current);
|
|
75
116
|
let direction = 'next';
|
|
76
|
-
let currentIndex = focusableIndex;
|
|
77
117
|
switch (event.key) {
|
|
78
118
|
case previousItemKey:
|
|
79
119
|
direction = 'previous';
|
|
80
120
|
event.preventDefault();
|
|
81
121
|
if (isFocusOnContainer) {
|
|
82
122
|
// Set to length, so that the previous focused element will be the last one.
|
|
83
|
-
currentIndex =
|
|
123
|
+
currentIndex = snapshot.length;
|
|
84
124
|
}
|
|
85
125
|
break;
|
|
86
126
|
case nextItemKey:
|
|
87
127
|
event.preventDefault();
|
|
88
128
|
if (isFocusOnContainer) {
|
|
89
|
-
// Set to -1, so that the next focused element will be the first one.
|
|
90
129
|
currentIndex = -1;
|
|
91
130
|
}
|
|
92
131
|
break;
|
|
93
132
|
case 'Home':
|
|
94
133
|
event.preventDefault();
|
|
95
|
-
// Set to -1, so that the next focused element will be the first one.
|
|
96
134
|
currentIndex = -1;
|
|
97
135
|
break;
|
|
98
136
|
case 'End':
|
|
99
137
|
event.preventDefault();
|
|
100
138
|
direction = 'previous';
|
|
101
|
-
|
|
102
|
-
currentIndex = elementsRef.current.length;
|
|
139
|
+
currentIndex = snapshot.length;
|
|
103
140
|
break;
|
|
104
141
|
default:
|
|
105
142
|
return;
|
|
106
143
|
}
|
|
107
|
-
|
|
144
|
+
focusItem(currentIndex, direction, wrap);
|
|
108
145
|
};
|
|
109
146
|
return {
|
|
110
147
|
onFocus,
|
|
@@ -113,51 +150,269 @@ export default function useRovingTabIndex(options) {
|
|
|
113
150
|
containerRef.current = elementNode;
|
|
114
151
|
})
|
|
115
152
|
};
|
|
116
|
-
}, [
|
|
117
|
-
const
|
|
153
|
+
}, [focusItem, isRtl, orientation, wrap]);
|
|
154
|
+
const focusNext = React.useCallback(isItemFocusableOverride => {
|
|
155
|
+
const snapshot = getNavigableItemsSnapshot(itemMapRef.current);
|
|
118
156
|
const currentFocus = getActiveElement(ownerDocument(containerRef.current));
|
|
119
157
|
const isFocusOnContainer = currentFocus === containerRef.current;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
158
|
+
const currentIndex = isFocusOnContainer ? -1 : getCurrentActiveItemIndex(snapshot, currentFocus, activeItemIdRef.current);
|
|
159
|
+
return focusItem(currentIndex, 'next', true, isItemFocusableOverride)?.id ?? null;
|
|
160
|
+
}, [focusItem]);
|
|
161
|
+
return React.useMemo(() => ({
|
|
162
|
+
activeItemId: resolvedActiveItemId,
|
|
163
|
+
focusNext,
|
|
164
|
+
getActiveItem,
|
|
165
|
+
getContainerProps,
|
|
166
|
+
getItemMap,
|
|
167
|
+
isItemActive,
|
|
168
|
+
registerItem,
|
|
169
|
+
setActiveItemId,
|
|
170
|
+
unregisterItem
|
|
171
|
+
}), [resolvedActiveItemId, focusNext, getActiveItem, getContainerProps, getItemMap, isItemActive, registerItem, setActiveItemId, unregisterItem]);
|
|
172
|
+
}
|
|
173
|
+
export function useRovingTabIndexItem(params) {
|
|
174
|
+
const rootContext = useRovingTabIndexContext();
|
|
175
|
+
const {
|
|
176
|
+
activeItemId,
|
|
177
|
+
registerItem,
|
|
178
|
+
unregisterItem
|
|
179
|
+
} = rootContext;
|
|
180
|
+
const elementRef = React.useRef(null);
|
|
181
|
+
const item = React.useMemo(() => ({
|
|
182
|
+
disabled: params.disabled ?? false,
|
|
183
|
+
element: null,
|
|
184
|
+
focusableWhenDisabled: params.focusableWhenDisabled ?? false,
|
|
185
|
+
id: params.id,
|
|
186
|
+
selected: params.selected ?? false,
|
|
187
|
+
textValue: params.textValue
|
|
188
|
+
}), [params.disabled, params.focusableWhenDisabled, params.id, params.selected, params.textValue]);
|
|
189
|
+
const latestItemRef = React.useRef(item);
|
|
190
|
+
// Keep the ref callback stable across item prop changes. The callback reads the latest
|
|
191
|
+
// item metadata from this ref so React does not have to detach and re-attach the ref
|
|
192
|
+
// every time `disabled`, `selected`, or similar item state changes.
|
|
193
|
+
latestItemRef.current = item;
|
|
194
|
+
const handleElementRef = React.useCallback(element => {
|
|
195
|
+
elementRef.current = element;
|
|
196
|
+
if (element == null) {
|
|
197
|
+
// Ref detachment runs during React's commit phase. Calling `unregisterItem()`
|
|
198
|
+
// synchronously here can trigger a nested state update while React is still
|
|
199
|
+
// finishing that commit. Unregister in a microtask so it runs after the
|
|
200
|
+
// commit completes.
|
|
201
|
+
queueMicrotask(() => {
|
|
202
|
+
// null check prevents stale unregisters for a remove-then-re-add edge case
|
|
203
|
+
if (elementRef.current == null) {
|
|
204
|
+
unregisterItem(params.id);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
return;
|
|
123
208
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
209
|
+
registerItem({
|
|
210
|
+
...latestItemRef.current,
|
|
211
|
+
element
|
|
212
|
+
});
|
|
213
|
+
}, [params.id, registerItem, unregisterItem]);
|
|
214
|
+
|
|
215
|
+
// `UseRovingTabIndexItemReturnValue.ref` must always be a callback ref. `useForkRef()`
|
|
216
|
+
// is typed to return `null` when every input ref is nullish, but this call always includes
|
|
217
|
+
// `handleElementRef`, so the merged ref cannot be `null` here.
|
|
218
|
+
const mergedRef = useForkRef(params.ref, handleElementRef);
|
|
219
|
+
useEnhancedEffect(() => {
|
|
220
|
+
if (!elementRef.current) {
|
|
221
|
+
return;
|
|
127
222
|
}
|
|
128
|
-
|
|
129
|
-
|
|
223
|
+
registerItem({
|
|
224
|
+
...item,
|
|
225
|
+
element: elementRef.current
|
|
226
|
+
});
|
|
227
|
+
}, [item, registerItem]);
|
|
228
|
+
useEnhancedEffect(() => {
|
|
229
|
+
const itemId = params.id;
|
|
230
|
+
|
|
231
|
+
// Keep unmount cleanup separate from the effect above. The effect above re-runs when
|
|
232
|
+
// item metadata changes, but we only want to unregister on unmount or when the item id changes.
|
|
233
|
+
return () => {
|
|
234
|
+
unregisterItem(itemId);
|
|
235
|
+
};
|
|
236
|
+
}, [params.id, unregisterItem]);
|
|
130
237
|
return {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
focusNext: focusNextExport
|
|
238
|
+
ref: mergedRef,
|
|
239
|
+
tabIndex: activeItemId === params.id ? 0 : -1
|
|
134
240
|
};
|
|
135
241
|
}
|
|
136
|
-
|
|
137
|
-
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Resolves which item id should own the roving tab stop for the current render.
|
|
245
|
+
*
|
|
246
|
+
* This is the top-level decision point for "who gets `tabIndex=0` right now?".
|
|
247
|
+
* For example:
|
|
248
|
+
* - `Tabs` sometimes passes `selectedValue` as `activeItemId` so the selected tab becomes
|
|
249
|
+
* the tab stop when focus enters the list from outside.
|
|
250
|
+
* - `MenuList` leaves `activeItemId` undefined and relies on the default-item logic below
|
|
251
|
+
* so that menu-specific rules decide which menu item should initially own the tab stop.
|
|
252
|
+
*
|
|
253
|
+
* @param activeItemId The item id supplied through the root hook's `activeItemId` option.
|
|
254
|
+
* `undefined` means "the caller did not ask for a specific item, use the default-item
|
|
255
|
+
* logic instead". `null` means "there is intentionally no preferred item, so also fall
|
|
256
|
+
* back to the default-item logic".
|
|
257
|
+
* @param items The ordered registered items currently in the roving set.
|
|
258
|
+
* @param isFocusable A predicate that decides whether an item may receive roving focus.
|
|
259
|
+
* @param getDefaultActiveItemId Optional caller-provided function that picks the preferred
|
|
260
|
+
* default item when `activeItemId` is not driving the tab stop directly.
|
|
261
|
+
* @returns The id of the item that should own `tabIndex=0`, or `null` if no item is focusable.
|
|
262
|
+
*/
|
|
263
|
+
function resolveActiveItemId(activeItemId, items, isFocusable, getDefaultActiveItemId) {
|
|
264
|
+
if (activeItemId != null) {
|
|
265
|
+
return resolveRequestedItemId(activeItemId, items, isFocusable);
|
|
266
|
+
}
|
|
267
|
+
return resolveDefaultItemId(items, isFocusable, getDefaultActiveItemId);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Resolves the item id supplied through the root hook's `activeItemId` option.
|
|
272
|
+
*
|
|
273
|
+
* This path is used when a component such as `Tabs` or `MenuList` wants roving focus to
|
|
274
|
+
* follow a specific logical item. For example, `Tabs` can pass the selected tab's value as
|
|
275
|
+
* `activeItemId` so that the selected tab owns `tabIndex=0` when focus enters the list.
|
|
276
|
+
*
|
|
277
|
+
* @param requestedItemId The item id passed to the root hook's `activeItemId` option.
|
|
278
|
+
* @param items The ordered registered items currently in the roving set.
|
|
279
|
+
* @param isFocusable A predicate that decides whether an item may receive roving focus.
|
|
280
|
+
* @returns The same id when it still points to a focusable item. If that id no longer exists,
|
|
281
|
+
* returns the first focusable item. If the id still exists but the item is not focusable,
|
|
282
|
+
* returns the next focusable item after it without wrapping.
|
|
283
|
+
*/
|
|
284
|
+
function resolveRequestedItemId(requestedItemId, items, isFocusable) {
|
|
285
|
+
const requestedItemIndex = findItemIndexById(items, requestedItemId);
|
|
286
|
+
if (requestedItemIndex === -1) {
|
|
287
|
+
return getFirstFocusableItemId(items, isFocusable);
|
|
288
|
+
}
|
|
289
|
+
if (isFocusable(items[requestedItemIndex])) {
|
|
290
|
+
return items[requestedItemIndex].id;
|
|
291
|
+
}
|
|
292
|
+
return getNextActiveItem(items, requestedItemIndex, 'next', false, isFocusable)?.id ?? null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Resolves the default active item when the caller is not driving roving focus with
|
|
297
|
+
* `activeItemId`.
|
|
298
|
+
*
|
|
299
|
+
* This path is used on the initial render and whenever the caller leaves the choice of tab
|
|
300
|
+
* stop to the hook. `getDefaultActiveItemId` lets a component prefer a specific logical item
|
|
301
|
+
* before falling back to the first focusable item.
|
|
302
|
+
*
|
|
303
|
+
* For example:
|
|
304
|
+
* - `MenuList` uses this path all the time. When `variant="selectedMenu"`, it prefers the
|
|
305
|
+
* selected menu item; otherwise it prefers the first focusable menu item.
|
|
306
|
+
* - `Tabs` uses this path while focus is already inside the tab list, because at that point
|
|
307
|
+
* the current roving position should be driven by actual focus movement rather than by the
|
|
308
|
+
* selected tab value.
|
|
309
|
+
*
|
|
310
|
+
* @param items The ordered registered items currently in the roving set.
|
|
311
|
+
* @param isFocusable A predicate that decides whether an item may receive roving focus.
|
|
312
|
+
* @param getDefaultActiveItemId Optional caller-provided function that chooses which item
|
|
313
|
+
* should own the tab stop before the generic "first focusable item" fallback runs.
|
|
314
|
+
* @returns The default item id when it points to a focusable item, otherwise the first
|
|
315
|
+
* focusable item in the snapshot, or `null` when none are focusable.
|
|
316
|
+
*/
|
|
317
|
+
function resolveDefaultItemId(items, isFocusable, getDefaultActiveItemId) {
|
|
318
|
+
const defaultItemId = getDefaultActiveItemId?.(items);
|
|
319
|
+
if (defaultItemId != null) {
|
|
320
|
+
const defaultItem = getItemById(items, defaultItemId);
|
|
321
|
+
if (defaultItem && isFocusable(defaultItem)) {
|
|
322
|
+
return defaultItem.id;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return getFirstFocusableItemId(items, isFocusable);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Finds the best starting index for keyboard navigation.
|
|
330
|
+
*
|
|
331
|
+
* This is used immediately before keyboard navigation and `focusNext()` navigation. It prefers
|
|
332
|
+
* the item that currently holds DOM focus, but if focus is on the container or outside the item
|
|
333
|
+
* set it falls back to the last known active item id.
|
|
334
|
+
*
|
|
335
|
+
* @param items The navigable item snapshot used for the current keyboard interaction.
|
|
336
|
+
* @param currentFocus The element that currently has DOM focus, if any.
|
|
337
|
+
* @param fallbackActiveItemId The last known active item id when focus is not on an item.
|
|
338
|
+
* @returns The focused item's index when focus is currently on an item. Otherwise, the index
|
|
339
|
+
* of the fallback active item id, or `-1` when no matching item exists.
|
|
340
|
+
*/
|
|
341
|
+
function getCurrentActiveItemIndex(items, currentFocus, fallbackActiveItemId) {
|
|
342
|
+
if (currentFocus) {
|
|
343
|
+
const focusedIndex = findItemIndexByElement(items, currentFocus);
|
|
344
|
+
if (focusedIndex !== -1) {
|
|
345
|
+
return focusedIndex;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return findItemIndexById(items, fallbackActiveItemId);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Walks the item snapshot to find the next focusable item in the requested direction.
|
|
353
|
+
*
|
|
354
|
+
* This is the shared navigation primitive used by keyboard handling and imperative helpers
|
|
355
|
+
* such as `focusNext()`. It starts from the supplied index, advances through the snapshot in
|
|
356
|
+
* the requested direction, and skips over items that fail the `isFocusable` predicate.
|
|
357
|
+
*
|
|
358
|
+
* @param items The ordered navigable item snapshot.
|
|
359
|
+
* @param currentIndex The index to start from. Use `-1` to start before the first item or
|
|
360
|
+
* `items.length` to start after the last item.
|
|
361
|
+
* @param direction The direction to move through the snapshot.
|
|
362
|
+
* @param wrap Whether navigation should wrap around at the ends of the list.
|
|
363
|
+
* @param isFocusable A predicate that decides whether an item may receive roving focus.
|
|
364
|
+
* @returns The next focusable item record, or `null` when no focusable item can be reached.
|
|
365
|
+
*/
|
|
366
|
+
function getNextActiveItem(items, currentIndex, direction, wrap, isFocusable) {
|
|
367
|
+
const lastIndex = items.length - 1;
|
|
368
|
+
if (lastIndex === -1) {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
138
371
|
let wrappedOnce = false;
|
|
139
372
|
let nextIndex = getNextIndex(currentIndex, lastIndex, direction, wrap);
|
|
140
373
|
const startIndex = nextIndex;
|
|
141
374
|
while (nextIndex !== -1) {
|
|
142
|
-
// Prevent infinite loop.
|
|
143
375
|
if (nextIndex === startIndex) {
|
|
144
376
|
if (wrappedOnce) {
|
|
145
|
-
return
|
|
377
|
+
return null;
|
|
146
378
|
}
|
|
147
379
|
wrappedOnce = true;
|
|
148
380
|
}
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
// Same logic as useAutocomplete.js
|
|
152
|
-
if (!shouldFocus(nextElement)) {
|
|
153
|
-
// Move to the next element.
|
|
381
|
+
const nextItem = items[nextIndex];
|
|
382
|
+
if (!nextItem || !isFocusable(nextItem)) {
|
|
154
383
|
nextIndex = getNextIndex(nextIndex, lastIndex, direction, wrap);
|
|
155
384
|
} else {
|
|
156
|
-
|
|
157
|
-
return nextIndex;
|
|
385
|
+
return nextItem;
|
|
158
386
|
}
|
|
159
387
|
}
|
|
160
|
-
return
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
function getFirstFocusableItemId(items, isFocusable) {
|
|
391
|
+
return items.find(item => isFocusable(item))?.id ?? null;
|
|
392
|
+
}
|
|
393
|
+
function getItemById(items, itemId) {
|
|
394
|
+
return itemId == null ? null : items.find(item => item.id === itemId) ?? null;
|
|
395
|
+
}
|
|
396
|
+
function findItemIndexById(items, itemId) {
|
|
397
|
+
return itemId == null ? -1 : items.findIndex(item => item.id === itemId);
|
|
398
|
+
}
|
|
399
|
+
function findItemIndexByElement(items, element) {
|
|
400
|
+
if (!element) {
|
|
401
|
+
return -1;
|
|
402
|
+
}
|
|
403
|
+
return items.findIndex(item => item.element === element || item.element?.contains(element));
|
|
404
|
+
}
|
|
405
|
+
function getOrderedItems(itemMap) {
|
|
406
|
+
const items = Array.from(itemMap.values());
|
|
407
|
+
if (items.every(item => item.element == null)) {
|
|
408
|
+
return items;
|
|
409
|
+
}
|
|
410
|
+
const connectedItems = items.filter(isConnectedItem).sort((itemA, itemB) => sortByDocumentPosition(itemA.element, itemB.element));
|
|
411
|
+
const disconnectedItems = items.filter(item => !isConnectedItem(item));
|
|
412
|
+
return [...connectedItems, ...disconnectedItems];
|
|
413
|
+
}
|
|
414
|
+
function getNavigableItemsSnapshot(itemMap) {
|
|
415
|
+
return getOrderedItems(itemMap).filter(isConnectedItem);
|
|
161
416
|
}
|
|
162
417
|
function getNextIndex(currentIndex, lastIndex, direction, wrap = true) {
|
|
163
418
|
if (direction === 'next') {
|
|
@@ -171,20 +426,39 @@ function getNextIndex(currentIndex, lastIndex, direction, wrap = true) {
|
|
|
171
426
|
}
|
|
172
427
|
return currentIndex - 1;
|
|
173
428
|
}
|
|
174
|
-
function
|
|
175
|
-
if (!element) {
|
|
429
|
+
export function isItemFocusable(item) {
|
|
430
|
+
if (!item.element) {
|
|
176
431
|
return false;
|
|
177
432
|
}
|
|
178
|
-
|
|
433
|
+
if (item.focusableWhenDisabled) {
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
return !item.disabled && !item.element.hasAttribute('disabled') && item.element.getAttribute('aria-disabled') !== 'true' && item.element.hasAttribute('tabindex');
|
|
437
|
+
}
|
|
438
|
+
function isConnectedItem(item) {
|
|
439
|
+
return item.element != null && item.element.isConnected;
|
|
179
440
|
}
|
|
441
|
+
|
|
442
|
+
/* eslint-disable no-bitwise */
|
|
443
|
+
function sortByDocumentPosition(a, b) {
|
|
444
|
+
if (a === b) {
|
|
445
|
+
return 0;
|
|
446
|
+
}
|
|
447
|
+
const position = a.compareDocumentPosition(b);
|
|
448
|
+
if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) {
|
|
449
|
+
return -1;
|
|
450
|
+
}
|
|
451
|
+
if (position & Node.DOCUMENT_POSITION_PRECEDING || position & Node.DOCUMENT_POSITION_CONTAINS) {
|
|
452
|
+
return 1;
|
|
453
|
+
}
|
|
454
|
+
return 0;
|
|
455
|
+
}
|
|
456
|
+
/* eslint-enable no-bitwise */
|
|
457
|
+
|
|
180
458
|
function handleRefs(...refs) {
|
|
181
459
|
return node => {
|
|
182
460
|
refs.forEach(ref => {
|
|
183
|
-
|
|
184
|
-
ref(node);
|
|
185
|
-
} else if (ref) {
|
|
186
|
-
ref.current = node;
|
|
187
|
-
}
|
|
461
|
+
setRef(ref ?? null, node);
|
|
188
462
|
});
|
|
189
463
|
};
|
|
190
464
|
}
|