@lumx/react 4.11.0-next.1 → 4.11.0-next.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.
Files changed (3) hide show
  1. package/index.js +340 -96
  2. package/index.js.map +1 -1
  3. package/package.json +3 -3
package/index.js CHANGED
@@ -2554,51 +2554,6 @@ function notifySection(sectionElement, sectionRegistrations, optionRegistrations
2554
2554
  }
2555
2555
  }
2556
2556
 
2557
- /**
2558
- * Internal state for tracking the active (focused) item.
2559
- * Shared by both list and grid navigation implementations.
2560
- */
2561
-
2562
- /**
2563
- * Create shared active item state with cleanup on abort.
2564
- *
2565
- * Callback invocation:
2566
- * - `setActive(item)`: calls `onDeactivate(previous)` then `onActivate(item)`.
2567
- * - `clear()`: calls `onDeactivate(current)` then `onClear()`.
2568
- * - On `signal.abort()`: same as `clear()`.
2569
- *
2570
- * @param callbacks Focus state change callbacks.
2571
- * @param signal AbortSignal for cleanup.
2572
- * @param initialItem Optional item to silently pre-select on creation (no callbacks fired).
2573
- */
2574
- function createActiveItemState(callbacks, signal, initialItem) {
2575
- let activeItem = initialItem ?? null;
2576
- function clear() {
2577
- if (activeItem) {
2578
- callbacks.onDeactivate(activeItem);
2579
- activeItem = null;
2580
- }
2581
- callbacks.onClear?.();
2582
- }
2583
- signal.addEventListener('abort', clear);
2584
- return {
2585
- get active() {
2586
- return activeItem;
2587
- },
2588
- setActive(item) {
2589
- if (activeItem === item) return;
2590
- if (activeItem) {
2591
- callbacks.onDeactivate(activeItem);
2592
- }
2593
- activeItem = item;
2594
- if (item) {
2595
- callbacks.onActivate(item);
2596
- }
2597
- },
2598
- clear
2599
- };
2600
- }
2601
-
2602
2557
  /**
2603
2558
  * Create a TreeWalker that iterates over elements matching a CSS selector
2604
2559
  * within a container.
@@ -2619,13 +2574,29 @@ function createSelectorTreeWalker(container, selector) {
2619
2574
  });
2620
2575
  }
2621
2576
 
2577
+ /**
2578
+ * Transition the active item: deactivate the current one (if any) and activate the new one.
2579
+ * Reads the current active item via `getActiveItem` so there is no internal state to desync.
2580
+ */
2581
+ function transition(getActiveItem, callbacks, newItem) {
2582
+ const current = getActiveItem();
2583
+ if (current === newItem) return;
2584
+ if (current) callbacks.onDeactivate(current);
2585
+ callbacks.onActivate(newItem);
2586
+ }
2587
+
2622
2588
  /**
2623
2589
  * Create a focus navigation controller for a 1D list.
2624
2590
  *
2625
- * @param options List navigation options (container, itemSelector, direction, wrap).
2591
+ * This controller is **stateless** it does not maintain an internal reference to
2592
+ * the active item. Instead it reads the active item from the DOM each time via the
2593
+ * `getActiveItem` callback provided in the options. This avoids any desync between
2594
+ * the controller's internal state and the actual DOM.
2595
+ *
2596
+ * @param options List navigation options (container, itemSelector, direction, wrap, getActiveItem).
2626
2597
  * @param callbacks Callbacks for focus state changes.
2627
2598
  * @param signal AbortSignal for cleanup.
2628
- * @returns FocusNavigationController instance.
2599
+ * @returns ListFocusNavigationController instance.
2629
2600
  */
2630
2601
  function createListFocusNavigation(options, callbacks, signal) {
2631
2602
  const {
@@ -2634,10 +2605,8 @@ function createListFocusNavigation(options, callbacks, signal) {
2634
2605
  direction = 'vertical',
2635
2606
  wrap = false,
2636
2607
  itemDisabledSelector,
2637
- itemActiveSelector
2608
+ getActiveItem = () => null
2638
2609
  } = options;
2639
- const initialItem = itemActiveSelector ? container.querySelector(itemActiveSelector) ?? null : null;
2640
- const state = createActiveItemState(callbacks, signal, initialItem);
2641
2610
 
2642
2611
  /** Combined CSS selector matching enabled (non-disabled) items. */
2643
2612
  const enabledItemSelector = itemDisabledSelector ? `${itemSelector}:not(${itemDisabledSelector})` : itemSelector;
@@ -2662,42 +2631,38 @@ function createListFocusNavigation(options, callbacks, signal) {
2662
2631
  return items.length > 0 ? items[items.length - 1] : null;
2663
2632
  }
2664
2633
 
2665
- /** Navigate to the first enabled item and set it as active. */
2634
+ /** Navigate to the first enabled item and activate it. */
2666
2635
  function goToFirst() {
2667
2636
  const first = findFirstEnabled();
2668
2637
  if (!first) return false;
2669
- state.setActive(first);
2638
+ transition(getActiveItem, callbacks, first);
2670
2639
  return true;
2671
2640
  }
2672
2641
 
2673
- /** Navigate to the last enabled item and set it as active. */
2642
+ /** Navigate to the last enabled item and activate it. */
2674
2643
  function goToLast() {
2675
2644
  const last = findLastEnabled();
2676
2645
  if (!last) return false;
2677
- state.setActive(last);
2646
+ transition(getActiveItem, callbacks, last);
2678
2647
  return true;
2679
2648
  }
2680
2649
  function navigateByOffset(offset) {
2681
- if (offset === 0) return state.active !== null;
2650
+ const active = getActiveItem();
2651
+ if (offset === 0) return active !== null;
2682
2652
  const forward = offset > 0;
2683
2653
  const stepsNeeded = Math.abs(offset);
2684
2654
 
2685
- // No active item — try to recover from DOM state, then fall back to first/last.
2686
- if (!state.active) {
2687
- const activeFromDom = itemActiveSelector && container.querySelector(itemActiveSelector);
2688
- if (activeFromDom && activeFromDom.matches(enabledItemSelector)) {
2689
- state.setActive(activeFromDom);
2690
- } else {
2691
- const started = forward ? goToFirst() : goToLast();
2692
- if (!started) return false;
2693
- }
2655
+ // No active item — fall back to first/last.
2656
+ if (!active) {
2657
+ const started = forward ? goToFirst() : goToLast();
2658
+ if (!started) return false;
2694
2659
  if (stepsNeeded === 1) return true;
2695
2660
  return navigateByOffset(forward ? offset - 1 : offset + 1);
2696
2661
  }
2697
2662
 
2698
2663
  // Walk from the active item using a TreeWalker.
2699
2664
  const walker = createItemWalker();
2700
- walker.currentNode = state.active;
2665
+ walker.currentNode = active;
2701
2666
  const step = forward ? () => walker.nextNode() : () => walker.previousNode();
2702
2667
  let stepsCompleted = 0;
2703
2668
  let lastFound = null;
@@ -2709,7 +2674,7 @@ function createListFocusNavigation(options, callbacks, signal) {
2709
2674
  } else if (wrap) {
2710
2675
  // Hit boundary — wrap around to the opposite end.
2711
2676
  const wrapped = forward ? findFirstEnabled() : findLastEnabled();
2712
- if (!wrapped || wrapped === state.active) break;
2677
+ if (!wrapped || wrapped === active) break;
2713
2678
  lastFound = wrapped;
2714
2679
  stepsCompleted += 1;
2715
2680
  walker.currentNode = wrapped;
@@ -2718,18 +2683,31 @@ function createListFocusNavigation(options, callbacks, signal) {
2718
2683
  }
2719
2684
  }
2720
2685
  if (stepsCompleted === 0) return false;
2721
- state.setActive(lastFound);
2686
+ transition(getActiveItem, callbacks, lastFound);
2722
2687
  return true;
2723
2688
  }
2724
2689
  const navigateForward = () => navigateByOffset(1);
2725
2690
  const navigateBackward = () => navigateByOffset(-1);
2691
+
2692
+ /** Clear the active item. */
2693
+ function clear() {
2694
+ const current = getActiveItem();
2695
+ if (current) {
2696
+ callbacks.onDeactivate(current);
2697
+ }
2698
+ callbacks.onClear?.();
2699
+ }
2700
+
2701
+ // Cleanup on abort.
2702
+ signal.addEventListener('abort', clear);
2726
2703
  return {
2727
2704
  type: 'list',
2705
+ enabledItemSelector,
2728
2706
  get activeItem() {
2729
- return state.active;
2707
+ return getActiveItem();
2730
2708
  },
2731
2709
  get hasActiveItem() {
2732
- return state.active !== null;
2710
+ return getActiveItem() !== null;
2733
2711
  },
2734
2712
  get hasNavigableItems() {
2735
2713
  return container.querySelector(enabledItemSelector) !== null;
@@ -2739,7 +2717,7 @@ function createListFocusNavigation(options, callbacks, signal) {
2739
2717
  goToItem(item) {
2740
2718
  if (!item.matches(itemSelector)) return false;
2741
2719
  if (!container.contains(item)) return false;
2742
- state.setActive(item);
2720
+ transition(getActiveItem, callbacks, item);
2743
2721
  return true;
2744
2722
  },
2745
2723
  goToOffset(offset) {
@@ -2750,14 +2728,32 @@ function createListFocusNavigation(options, callbacks, signal) {
2750
2728
  let node = walker.nextNode();
2751
2729
  while (node) {
2752
2730
  if (predicate(node)) {
2753
- state.setActive(node);
2731
+ transition(getActiveItem, callbacks, node);
2754
2732
  return true;
2755
2733
  }
2756
2734
  node = walker.nextNode();
2757
2735
  }
2758
2736
  return false;
2759
2737
  },
2760
- clear: state.clear,
2738
+ findNearestEnabled(anchor) {
2739
+ if (!container.contains(anchor)) return findFirstEnabled();
2740
+
2741
+ // If the anchor itself is an enabled item, return it directly.
2742
+ if (anchor instanceof HTMLElement && anchor.matches(enabledItemSelector)) {
2743
+ return anchor;
2744
+ }
2745
+
2746
+ // Walk forward from the anchor for the nearest enabled item.
2747
+ const walker = createItemWalker();
2748
+ walker.currentNode = anchor;
2749
+ const next = walker.nextNode();
2750
+ if (next instanceof HTMLElement) return next;
2751
+
2752
+ // No enabled item after anchor — walk backward (reuse same walker).
2753
+ walker.currentNode = anchor;
2754
+ return walker.previousNode();
2755
+ },
2756
+ clear,
2761
2757
  goUp() {
2762
2758
  return direction === 'vertical' ? navigateBackward() : false;
2763
2759
  },
@@ -2773,6 +2769,51 @@ function createListFocusNavigation(options, callbacks, signal) {
2773
2769
  };
2774
2770
  }
2775
2771
 
2772
+ /**
2773
+ * Internal state for tracking the active (focused) item.
2774
+ * Shared by both list and grid navigation implementations.
2775
+ */
2776
+
2777
+ /**
2778
+ * Create shared active item state with cleanup on abort.
2779
+ *
2780
+ * Callback invocation:
2781
+ * - `setActive(item)`: calls `onDeactivate(previous)` then `onActivate(item)`.
2782
+ * - `clear()`: calls `onDeactivate(current)` then `onClear()`.
2783
+ * - On `signal.abort()`: same as `clear()`.
2784
+ *
2785
+ * @param callbacks Focus state change callbacks.
2786
+ * @param signal AbortSignal for cleanup.
2787
+ * @param initialItem Optional item to silently pre-select on creation (no callbacks fired).
2788
+ */
2789
+ function createActiveItemState(callbacks, signal, initialItem) {
2790
+ let activeItem = initialItem ?? null;
2791
+ function clear() {
2792
+ if (activeItem) {
2793
+ callbacks.onDeactivate(activeItem);
2794
+ activeItem = null;
2795
+ }
2796
+ callbacks.onClear?.();
2797
+ }
2798
+ signal.addEventListener('abort', clear);
2799
+ return {
2800
+ get active() {
2801
+ return activeItem;
2802
+ },
2803
+ setActive(item) {
2804
+ if (activeItem === item) return;
2805
+ if (activeItem) {
2806
+ callbacks.onDeactivate(activeItem);
2807
+ }
2808
+ activeItem = item;
2809
+ if (item) {
2810
+ callbacks.onActivate(item);
2811
+ }
2812
+ },
2813
+ clear
2814
+ };
2815
+ }
2816
+
2776
2817
  /**
2777
2818
  * Create a focus navigation controller for a 2D grid.
2778
2819
  *
@@ -3021,26 +3062,65 @@ function createGridFocusNavigation(options, callbacks, signal) {
3021
3062
  }
3022
3063
 
3023
3064
  /**
3024
- * Options for the roving tabindex setup.
3065
+ * Like `querySelectorAll`, but also tests the root node itself.
3066
+ *
3067
+ * Yields the root element first (if it matches), then all matching descendants
3068
+ * in document order. Being a generator, callers that only need the first match
3069
+ * can break early without collecting the full list.
3070
+ *
3071
+ * @param node The starting DOM node.
3072
+ * @param selector CSS selector to match against.
3025
3073
  */
3074
+ function* querySelectorInclusive(node, selector) {
3075
+ if (!(node instanceof HTMLElement)) return;
3076
+ if (node.matches(selector)) yield node;
3077
+ yield* node.querySelectorAll(selector);
3078
+ }
3026
3079
 
3027
3080
  /**
3028
- * Set up the roving tabindex pattern on a container element.
3029
- *
3030
- * Handles:
3031
- * - Keyboard navigation (Arrow keys, Home, End) via a keydown listener on the container
3032
- * - tabindex management on focus changes (`0` on active, `-1` on inactive)
3033
- * - Calling `.focus()` on the newly active item
3081
+ * Track whether the container currently has focus.
3034
3082
  *
3035
- * The consumer is responsible for setting the initial tabindex values on items
3036
- * (`tabindex="0"` on the active item, `tabindex="-1"` on the rest). On setup, the item
3037
- * with `tabindex="0"` is silently adopted as the initial active item.
3083
+ * We can't rely on `document.activeElement` inside MutationObserver callbacks because
3084
+ * the browser moves focus to `<body>` before they fire. focusout with `relatedTarget === null`
3085
+ * (element removed from DOM) keeps the flag true so the observer can move focus to a fallback.
3086
+ */
3087
+ function trackContainerFocus(container, signal) {
3088
+ let hasFocus = container.contains(document.activeElement);
3089
+ container.addEventListener('focusin', () => {
3090
+ hasFocus = true;
3091
+ }, {
3092
+ signal
3093
+ });
3094
+ container.addEventListener('focusout', evt => {
3095
+ const newTarget = evt.relatedTarget;
3096
+ if (newTarget && !container.contains(newTarget)) {
3097
+ hasFocus = false;
3098
+ }
3099
+ }, {
3100
+ signal
3101
+ });
3102
+ return {
3103
+ get hasFocus() {
3104
+ return hasFocus;
3105
+ },
3106
+ reset() {
3107
+ hasFocus = false;
3108
+ }
3109
+ };
3110
+ }
3111
+
3112
+ /** Options for {@link setupRovingTabIndex}. */
3113
+
3114
+ /**
3115
+ * Set up the roving tabindex pattern on a container element.
3038
3116
  *
3039
- * The setup is torn down when the provided `signal` is aborted.
3117
+ * - Keyboard navigation (Arrow keys, Home, End)
3118
+ * - tabindex management (`0` on active, `-1` on others)
3119
+ * - Mount normalization (single-tabstop invariant)
3120
+ * - DOM mutation handling via MutationObserver:
3121
+ * removal recovery, insertion normalization, disabled-state changes
3040
3122
  *
3041
- * @param options Roving tabindex configuration.
3042
- * @param signal AbortSignal for teardown.
3043
- * @returns The underlying {@link FocusNavigationController} for programmatic access.
3123
+ * Torn down when `signal` is aborted.
3044
3124
  */
3045
3125
  function setupRovingTabIndex(options, signal) {
3046
3126
  const {
@@ -3048,15 +3128,24 @@ function setupRovingTabIndex(options, signal) {
3048
3128
  itemSelector,
3049
3129
  direction = 'horizontal',
3050
3130
  itemDisabledSelector,
3131
+ itemSelectedAttr = 'aria-selected',
3051
3132
  onItemFocused
3052
3133
  } = options;
3134
+ const itemActiveSelector = `${itemSelector}[tabindex="0"]`;
3135
+
3136
+ // Item selected selector
3137
+ let itemSelectedSelector = `${itemSelector}[${itemSelectedAttr}=true]`;
3138
+ if (itemDisabledSelector) itemSelectedSelector += `:not(${itemDisabledSelector})`;
3139
+
3140
+ // Track focus inside container
3141
+ const containerFocus = trackContainerFocus(container, signal);
3053
3142
  const nav = createListFocusNavigation({
3054
3143
  container,
3055
3144
  itemSelector,
3056
3145
  direction,
3057
3146
  wrap: true,
3058
3147
  itemDisabledSelector,
3059
- itemActiveSelector: `${itemSelector}[tabindex="0"]`
3148
+ getActiveItem: () => container.querySelector(itemActiveSelector)
3060
3149
  }, {
3061
3150
  onActivate(item) {
3062
3151
  item.setAttribute('tabindex', '0');
@@ -3067,14 +3156,163 @@ function setupRovingTabIndex(options, signal) {
3067
3156
  item.setAttribute('tabindex', '-1');
3068
3157
  }
3069
3158
  }, signal);
3070
- container.addEventListener('keydown', evt => {
3071
- // Clear stale reference if the active item was removed from the DOM.
3072
- if (nav.hasActiveItem && !nav.activeItem?.isConnected) {
3073
- nav.clear();
3159
+
3160
+ // ─── tabindex normalization ────────────────────────────────────
3161
+
3162
+ /** Set tabindex only when the value actually changes to avoid unnecessary DOM mutations. */
3163
+ function setTabIndex(item, value) {
3164
+ if (item.getAttribute('tabindex') !== value) {
3165
+ item.setAttribute('tabindex', value);
3166
+ }
3167
+ }
3168
+
3169
+ /** Assign tabindex across `items`, ensuring at most one `tabindex="0"` */
3170
+ function normalizeItems(items, hasExistingTabStop) {
3171
+ let hasTabStop = hasExistingTabStop;
3172
+ for (const item of items) {
3173
+ if (!container.contains(item)) continue;
3174
+ const isDisabled = itemDisabledSelector ? item.matches(itemDisabledSelector) : false;
3175
+ if (!isDisabled && !hasTabStop) {
3176
+ setTabIndex(item, '0');
3177
+ hasTabStop = true;
3178
+ } else {
3179
+ setTabIndex(item, '-1');
3180
+ }
3181
+ }
3182
+ if (!hasTabStop) {
3183
+ const fallback = container.querySelector(nav.enabledItemSelector);
3184
+ if (fallback) setTabIndex(fallback, '0');
3185
+ }
3186
+ }
3187
+
3188
+ /** Enforce the single-tabstop invariant across all items. */
3189
+ function normalizeAllItems() {
3190
+ const items = Array.from(container.querySelectorAll(itemSelector));
3191
+ const {
3192
+ activeItem
3193
+ } = nav;
3194
+
3195
+ // Prefer either the current active item (from DOM) or the current selected item
3196
+ let preferredTabStopIndex = activeItem && items.indexOf(activeItem);
3197
+ if (!preferredTabStopIndex || preferredTabStopIndex === -1) {
3198
+ preferredTabStopIndex = items.findIndex(item => item.matches(itemSelectedSelector));
3199
+ }
3200
+ if (preferredTabStopIndex && preferredTabStopIndex > 0) {
3201
+ const [preferredItem] = items.splice(preferredTabStopIndex, 1);
3202
+ items.unshift(preferredItem);
3203
+ }
3204
+ normalizeItems(items, false);
3205
+ }
3206
+
3207
+ /** Ensure a tab stop exists; find a fallback near `anchor` and optionally move focus to it. */
3208
+ function ensureTabStop(shouldFocus, anchor) {
3209
+ if (container.querySelector(itemActiveSelector)) return;
3210
+ const fallback = (anchor && nav.findNearestEnabled(anchor)) ?? container.querySelector(nav.enabledItemSelector);
3211
+ if (!fallback) return;
3212
+ if (shouldFocus) {
3213
+ nav.goToItem(fallback);
3214
+ } else {
3215
+ setTabIndex(fallback, '0');
3216
+ }
3217
+ }
3218
+ normalizeAllItems();
3219
+
3220
+ // ─── MutationObserver ──────────────────────────────────────────
3221
+
3222
+ const observer = new MutationObserver(mutations => {
3223
+ // Track removed
3224
+ let removedActiveItem = null;
3225
+ let removalAnchor = null;
3226
+ // Track selected
3227
+ let newSelectedItem = null;
3228
+ // Track disabled
3229
+ const disabledTargets = [];
3230
+ // Track added
3231
+ const insertedItems = [];
3232
+ for (const mutation of mutations) {
3233
+ if (mutation.type === 'childList') {
3234
+ // Track removed — scan removed nodes for the active item (tabindex="0").
3235
+ // We cannot gate this on a live DOM query because the item is already removed.
3236
+ if (!removedActiveItem) {
3237
+ for (const removedNode of mutation.removedNodes) {
3238
+ const {
3239
+ value: firstMatch
3240
+ } = querySelectorInclusive(removedNode, itemActiveSelector).next();
3241
+ if (firstMatch) {
3242
+ removedActiveItem = firstMatch;
3243
+ removalAnchor = mutation.nextSibling ?? mutation.previousSibling ?? null;
3244
+ break;
3245
+ }
3246
+ }
3247
+ }
3248
+
3249
+ // Track added
3250
+ for (const addedNode of mutation.addedNodes) {
3251
+ for (const item of querySelectorInclusive(addedNode, itemSelector)) {
3252
+ insertedItems.push(item);
3253
+ }
3254
+ }
3255
+ }
3256
+ const target = mutation.target;
3257
+ if (mutation.type === 'attributes' && target.matches(itemSelector)) {
3258
+ if (mutation.attributeName === itemSelectedAttr && target.getAttribute(itemSelectedAttr) === 'true') {
3259
+ // Track selected item
3260
+ newSelectedItem = target;
3261
+ } else if (itemDisabledSelector && target.matches(itemDisabledSelector)) {
3262
+ // Track disabled items
3263
+ disabledTargets.push(target);
3264
+ }
3265
+ }
3266
+ }
3267
+
3268
+ // Handle removed item
3269
+ if (removedActiveItem) {
3270
+ const shouldMoveFocus = containerFocus.hasFocus;
3271
+ containerFocus.reset();
3272
+ ensureTabStop(shouldMoveFocus, removalAnchor);
3273
+ }
3274
+
3275
+ // Handle inserted items
3276
+ if (insertedItems.length > 0) {
3277
+ const hasTabStop = container.querySelector(itemActiveSelector) !== null;
3278
+ normalizeItems(insertedItems, hasTabStop);
3074
3279
  }
3075
3280
 
3076
- // Sync: if nothing is active yet, pick up focus from the event target.
3077
- if (!nav.hasActiveItem) {
3281
+ // Handle disabled
3282
+ if (disabledTargets.length > 0) {
3283
+ const currentActive = nav.activeItem;
3284
+ for (const target of disabledTargets) {
3285
+ setTabIndex(target, '-1');
3286
+ }
3287
+ const activeItemDisabled = currentActive && disabledTargets.includes(currentActive);
3288
+ const shouldFocus = !!(activeItemDisabled && containerFocus.hasFocus);
3289
+ ensureTabStop(shouldFocus);
3290
+ }
3291
+
3292
+ // Handle selected
3293
+ if (newSelectedItem) {
3294
+ // Demote previous item
3295
+ const currentTabStop = container.querySelector(itemActiveSelector);
3296
+ if (currentTabStop && currentTabStop !== newSelectedItem) {
3297
+ setTabIndex(currentTabStop, '-1');
3298
+ }
3299
+ // Selected item is the new default focuable item
3300
+ setTabIndex(newSelectedItem, '0');
3301
+ }
3302
+ });
3303
+ observer.observe(container, {
3304
+ childList: true,
3305
+ subtree: true,
3306
+ attributes: true,
3307
+ attributeFilter: ['aria-disabled', 'disabled', itemSelectedAttr]
3308
+ });
3309
+ signal.addEventListener('abort', () => observer.disconnect());
3310
+
3311
+ // ─── Keyboard navigation ───────────────────────────────────────
3312
+
3313
+ container.addEventListener('keydown', evt => {
3314
+ // Adopt focus target if nothing is active yet.
3315
+ if (!nav.activeItem) {
3078
3316
  const target = evt.target;
3079
3317
  if (target.matches(itemSelector) && container.contains(target)) {
3080
3318
  nav.goToItem(target);
@@ -3170,7 +3408,11 @@ function setupListbox(handle, signal, notify) {
3170
3408
  container: listbox,
3171
3409
  // Filtered options don't render [role="option"] at all (they render only a
3172
3410
  // hidden placeholder), so no :not([data-filtered]) filter is needed here.
3173
- itemSelector: '[role="option"]'
3411
+ itemSelector: '[role="option"]',
3412
+ getActiveItem: () => {
3413
+ const id = trigger.getAttribute('aria-activedescendant');
3414
+ return id ? document.getElementById(id) : null;
3415
+ }
3174
3416
  }, focusCallbacks, signal);
3175
3417
  }
3176
3418
 
@@ -15142,7 +15384,8 @@ const useRovingTabIndexContainer = ({
15142
15384
  itemSelector,
15143
15385
  onItemFocused: unstableOnItemFocused,
15144
15386
  direction,
15145
- itemDisabledSelector
15387
+ itemDisabledSelector,
15388
+ itemSelectedAttr
15146
15389
  }) => {
15147
15390
  const onItemFocused = useEventCallback(unstableOnItemFocused);
15148
15391
  useIsomorphicLayoutEffect(() => {
@@ -15156,10 +15399,11 @@ const useRovingTabIndexContainer = ({
15156
15399
  itemSelector,
15157
15400
  direction,
15158
15401
  itemDisabledSelector,
15402
+ itemSelectedAttr,
15159
15403
  onItemFocused
15160
15404
  }, abortController.signal);
15161
15405
  return () => abortController.abort();
15162
- }, [containerRef, itemSelector, direction, itemDisabledSelector, onItemFocused]);
15406
+ }, [containerRef, itemSelector, direction, itemDisabledSelector, itemSelectedAttr, onItemFocused]);
15163
15407
  };
15164
15408
 
15165
15409
  /**