@mui/utils 9.0.0-alpha.4 → 9.0.0-beta.1

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +119 -4
  2. package/fastDeepAssign/fastDeepAssign.d.mts +5 -0
  3. package/fastDeepAssign/fastDeepAssign.d.ts +5 -0
  4. package/fastDeepAssign/fastDeepAssign.js +79 -0
  5. package/fastDeepAssign/fastDeepAssign.mjs +73 -0
  6. package/fastDeepAssign/index.d.mts +1 -0
  7. package/fastDeepAssign/index.d.ts +1 -0
  8. package/fastDeepAssign/index.js +13 -0
  9. package/fastDeepAssign/index.mjs +1 -0
  10. package/fastObjectShallowCompare/fastObjectShallowCompare.d.mts +6 -0
  11. package/fastObjectShallowCompare/fastObjectShallowCompare.d.ts +6 -0
  12. package/fastObjectShallowCompare/fastObjectShallowCompare.js +41 -0
  13. package/fastObjectShallowCompare/fastObjectShallowCompare.mjs +35 -0
  14. package/fastObjectShallowCompare/index.d.mts +1 -0
  15. package/fastObjectShallowCompare/index.d.ts +1 -0
  16. package/fastObjectShallowCompare/index.js +13 -0
  17. package/fastObjectShallowCompare/index.mjs +1 -0
  18. package/index.d.mts +3 -1
  19. package/index.d.ts +3 -1
  20. package/index.js +26 -10
  21. package/index.mjs +4 -2
  22. package/isObjectEmpty/index.d.mts +1 -0
  23. package/isObjectEmpty/index.d.ts +1 -0
  24. package/isObjectEmpty/index.js +13 -0
  25. package/isObjectEmpty/index.mjs +1 -0
  26. package/isObjectEmpty/isObjectEmpty.d.mts +1 -0
  27. package/isObjectEmpty/isObjectEmpty.d.ts +1 -0
  28. package/isObjectEmpty/isObjectEmpty.js +16 -0
  29. package/isObjectEmpty/isObjectEmpty.mjs +10 -0
  30. package/package.json +45 -3
  31. package/useRovingTabIndex/RovingTabIndexContext.d.mts +6 -0
  32. package/useRovingTabIndex/RovingTabIndexContext.d.ts +6 -0
  33. package/useRovingTabIndex/RovingTabIndexContext.js +21 -0
  34. package/useRovingTabIndex/RovingTabIndexContext.mjs +14 -0
  35. package/useRovingTabIndex/index.d.mts +2 -1
  36. package/useRovingTabIndex/index.d.ts +2 -1
  37. package/useRovingTabIndex/index.js +21 -7
  38. package/useRovingTabIndex/index.mjs +2 -1
  39. package/useRovingTabIndex/useRovingTabIndex.d.mts +166 -18
  40. package/useRovingTabIndex/useRovingTabIndex.d.ts +166 -18
  41. package/useRovingTabIndex/useRovingTabIndex.js +352 -76
  42. 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 ownerDocument from "../ownerDocument/index.mjs";
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 default function useRovingTabIndex(options) {
20
+ export function useRovingTabIndexRoot(params) {
18
21
  const {
22
+ activeItemId: activeItemIdProp,
23
+ getDefaultActiveItemId,
19
24
  orientation,
20
- focusableIndex: focusableIndexProp,
21
25
  isRtl = false,
22
- shouldFocus = internalShouldFocus,
23
- shouldWrap = true
24
- } = options;
25
- const initialFocusableIndex = focusableIndexProp ?? 0;
26
- const [focusableIndex, setFocusableIndex] = React.useState(initialFocusableIndex);
27
- const elementsRef = React.useRef([]);
28
- const containerRef = React.useRef(null);
29
- const previousFocusableIndexPropRef = React.useRef(initialFocusableIndex);
30
- if (focusableIndexProp !== undefined && focusableIndexProp !== previousFocusableIndexPropRef.current) {
31
- previousFocusableIndexPropRef.current = focusableIndexProp;
32
- if (focusableIndexProp !== focusableIndex) {
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.useEffect(() => {
37
- if (elementsRef.current.length === 0 || focusableIndex === -1 || focusableIndex >= elementsRef.current.length) {
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
- if (!shouldFocus(elementsRef.current[focusableIndex])) {
41
- const nextIndex = focusNext(elementsRef, focusableIndex, 'next', false, shouldFocus);
42
- setFocusableIndex(nextIndex);
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
- }, [focusableIndex, shouldFocus]);
45
- const getItemProps = React.useCallback((index, ref) => ({
46
- ref: handleRefs(ref, elementNode => {
47
- elementsRef.current[index] = elementNode;
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 focusedElement = event.target;
54
- const focusedIndex = elementsRef.current.findIndex(element => element === focusedElement);
93
+ const snapshot = getNavigableItemsSnapshot(itemMapRef.current);
94
+ const focusedIndex = findItemIndexByElement(snapshot, event.target);
55
95
  if (focusedIndex !== -1) {
56
- setFocusableIndex(focusedIndex);
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 = elementsRef.current.length;
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
- // Set to length, so that the previous focused element will be the last one.
102
- currentIndex = elementsRef.current.length;
139
+ currentIndex = snapshot.length;
103
140
  break;
104
141
  default:
105
142
  return;
106
143
  }
107
- focusNext(elementsRef, currentIndex, direction, shouldWrap, shouldFocus);
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
- }, [focusableIndex, isRtl, orientation, shouldWrap, shouldFocus]);
117
- const focusNextExport = React.useCallback(shouldFocusOverride => {
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
- let currentIndex = focusableIndex;
121
- if (isFocusOnContainer) {
122
- currentIndex = -1;
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
- const nextIndex = focusNext(elementsRef, currentIndex, 'next', true, shouldFocusOverride ?? shouldFocus);
125
- if (nextIndex !== -1) {
126
- setFocusableIndex(nextIndex);
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
- return nextIndex;
129
- }, [focusableIndex, shouldFocus]);
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
- getItemProps,
132
- getContainerProps,
133
- focusNext: focusNextExport
238
+ ref: mergedRef,
239
+ tabIndex: activeItemId === params.id ? 0 : -1
134
240
  };
135
241
  }
136
- function focusNext(elementsRef, currentIndex, direction, wrap, shouldFocus) {
137
- const lastIndex = elementsRef.current.length - 1;
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 -1;
377
+ return null;
146
378
  }
147
379
  wrappedOnce = true;
148
380
  }
149
- const nextElement = elementsRef.current[nextIndex];
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
- nextElement?.focus();
157
- return nextIndex;
385
+ return nextItem;
158
386
  }
159
387
  }
160
- return -1;
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 internalShouldFocus(element) {
175
- if (!element) {
429
+ export function isItemFocusable(item) {
430
+ if (!item.element) {
176
431
  return false;
177
432
  }
178
- return !element.hasAttribute('disabled') && element.getAttribute('aria-disabled') !== 'true' && element.hasAttribute('tabindex');
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
- if (typeof ref === 'function') {
184
- ref(node);
185
- } else if (ref) {
186
- ref.current = node;
187
- }
461
+ setRef(ref ?? null, node);
188
462
  });
189
463
  };
190
464
  }