@lumx/react 4.11.0-next.0 → 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.
- package/index.js +340 -96
- package/index.js.map +1 -1
- 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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2638
|
+
transition(getActiveItem, callbacks, first);
|
|
2670
2639
|
return true;
|
|
2671
2640
|
}
|
|
2672
2641
|
|
|
2673
|
-
/** Navigate to the last enabled item and
|
|
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
|
-
|
|
2646
|
+
transition(getActiveItem, callbacks, last);
|
|
2678
2647
|
return true;
|
|
2679
2648
|
}
|
|
2680
2649
|
function navigateByOffset(offset) {
|
|
2681
|
-
|
|
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 —
|
|
2686
|
-
if (!
|
|
2687
|
-
const
|
|
2688
|
-
if (
|
|
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 =
|
|
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 ===
|
|
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
|
-
|
|
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
|
|
2707
|
+
return getActiveItem();
|
|
2730
2708
|
},
|
|
2731
2709
|
get hasActiveItem() {
|
|
2732
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
3036
|
-
*
|
|
3037
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
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
|
-
//
|
|
3077
|
-
if (
|
|
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
|
/**
|