/** * Stage 4.5 port of Base UI React's `useListNavigation` hook. * * React source: * `base-ui/packages/react/src/floating-ui-react/hooks/useListNavigation.ts` * * Adds arrow-key navigation (1D + 2D grid), Home/End, typeable-combobox * integration, virtual focus (`aria-activedescendant`), and tree-aware * submenu open/close behavior. This is the single biggest interaction * hook in the fork — every listbox / menu / menubar / combobox-grid * consumer in tier-3a reaches for it. * * ### React → Solid deviations * * 1. `useRef` → `MutableRef` (matches sibling hooks). `useValueAsRef` * (React fork helper that mirrors a prop into a ref) is replaced by * reading the prop directly inside handlers/effects — Solid props * are stable references for the lifetime of the component body, and * callers pass reactive accessors when live updates matter (see the * `MaybeAccessor` wrappers on `activeIndex`, `selectedIndex`, * `disabledIndices`, etc.). * * 2. `useStableCallback` → `createStableCallback` from * `src/internals/stable-callback.ts`. Used only where the React * source stabilized identity for effect deps — Solid effects don't * re-fire on callback identity so most handlers are plain functions. * * 3. `useIsoLayoutEffect` (with React single-callback + deps) → * `createRenderEffect`-backed `useIsoLayoutEffect(compute, apply)` * in split form. Tracked reads go in the `compute` factory; the * side-effect body runs in `apply`. * * 4. `useMemo` for `item` / `floating` / `trigger` / `reference` bags * collapses to plain literals. Solid doesn't re-run component bodies, * so the bag identity is already stable for the lifetime of the call * site. Closed-over handlers read the latest prop values directly. * * 5. `isReactEvent` branches and `.nativeEvent` unwraps drop out: Solid * event handlers receive native events directly. * * 6. Shadow-DOM-safe `contains()` from `src/internals/dom/contains.ts`. * * 7. `store.useState('open')` subscribes a Solid tracking context when * read inside `createEffect` / `useIsoLayoutEffect`; the matching * sync escape hatch is `store.state.open` (via * `components/floating-root-store.ts`). * * 8. The `MaybeAccessor` pattern: callers may pass either a literal * or a `() => T` for the reactive option fields. `readOption` * normalises both at the call site (inside effects/handlers) so the * live value is read at event time. * * ### Reason strings * * Every `store.setOpen` emits one of: * - `REASONS.listNavigation` — opening on arrow-down on trigger * (nested / main-orientation paths), or closing via the * cross-orientation "close submenu" key in nested mode. * - `REASONS.focusOut` — Shift+Tab on the floating element. * * Matches React's source strings 1:1 — the React fork carries these * same two in this hook. */ import { isHTMLElement } from "@floating-ui/utils/dom"; import { onCleanup, untrack } from "solid-js"; import { type BaseUIChangeEventDetails, createChangeEventDetails, REASONS, } from "../../change-event-details"; import { contains } from "../../dom/contains"; import { ownerDocument } from "../../dom/owner-document"; import { createStableCallback } from "../../stable-callback"; import { useIsoLayoutEffect } from "../../use-iso-layout-effect"; import { useFloatingParentNodeId, useFloatingTree, } from "../components/floating-tree"; import type { FloatingTreeStore } from "../components/floating-tree-store"; import type { ElementProps, FloatingContext, FloatingRootContext, MutableRef, } from "../types"; import { createGridCellMap, findNonDisabledListIndex, getGridCellIndexOfCorner, getGridCellIndices, getGridNavigatedIndex, getMaxListIndex, getMinListIndex, isIndexOutOfListBounds, isListIndexDisabled, } from "../utils/composite"; import { ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, ARROW_UP, } from "../utils/constants"; import { activeElement, getFloatingFocusElement, getTarget, isTypeableCombobox, } from "../utils/element"; import { enqueueFocus } from "../utils/enqueue-focus"; import { isVirtualClick, isVirtualPointerEvent, stopEvent, } from "../utils/event"; const ESCAPE = "Escape"; import { type MaybeAccessor, readOption } from "../../maybe-accessor"; type Orientation = "vertical" | "horizontal" | "both"; type DisabledIndices = ReadonlyArray | ((index: number) => boolean); function doSwitch( orientation: Orientation, vertical: boolean, horizontal: boolean, ): boolean { switch (orientation) { case "vertical": return vertical; case "horizontal": return horizontal; default: return vertical || horizontal; } } function isMainOrientationKey(key: string, orientation: Orientation): boolean { const vertical = key === ARROW_UP || key === ARROW_DOWN; const horizontal = key === ARROW_LEFT || key === ARROW_RIGHT; return doSwitch(orientation, vertical, horizontal); } function isMainOrientationToEndKey( key: string, orientation: Orientation, rtl: boolean, ): boolean { const vertical = key === ARROW_DOWN; const horizontal = rtl ? key === ARROW_LEFT : key === ARROW_RIGHT; return ( doSwitch(orientation, vertical, horizontal) || key === "Enter" || key === " " || key === "" ); } function isCrossOrientationOpenKey( key: string, orientation: Orientation, rtl: boolean, ): boolean { const vertical = rtl ? key === ARROW_LEFT : key === ARROW_RIGHT; const horizontal = key === ARROW_DOWN; return doSwitch(orientation, vertical, horizontal); } function isCrossOrientationCloseKey( key: string, orientation: Orientation, rtl: boolean, cols?: number, ): boolean { const vertical = rtl ? key === ARROW_RIGHT : key === ARROW_LEFT; const horizontal = key === ARROW_UP; if ( orientation === "both" || (orientation === "horizontal" && cols && cols > 1) ) { return key === ESCAPE; } return doSwitch(orientation, vertical, horizontal); } export interface UseListNavigationProps { /** * A ref that holds an array of list items. * @default empty list */ listRef: MutableRef>; /** * The index of the currently active (focused or highlighted) item. May * be a literal or an accessor. * @default null */ activeIndex: MaybeAccessor; /** * Callback invoked when navigation changes the active index. */ onNavigate?: | ((activeIndex: number | null, event: Event | undefined) => void) | undefined; /** * Whether the hook is enabled. * @default true */ enabled?: MaybeAccessor | undefined; /** * Currently selected item index. * @default null */ selectedIndex?: MaybeAccessor | undefined; /** * Whether to focus the item upon opening. `'auto'` infers from input * type (keyboard vs pointer). * @default 'auto' */ focusItemOnOpen?: MaybeAccessor | undefined; /** * Whether hovering an item synchronizes focus. * @default true */ focusItemOnHover?: MaybeAccessor | undefined; /** * Whether pressing an arrow key on the main axis opens the floating * element. * @default true */ openOnArrowKeyDown?: MaybeAccessor | undefined; /** * Manually-specified indices to skip during navigation. Overrides the * default `disabled` / `aria-disabled` detection. */ disabledIndices?: MaybeAccessor | undefined; /** * Whether focus can escape the list (requires `loopFocus` + `virtual`). * @default false */ allowEscape?: MaybeAccessor | undefined; /** * Whether focus should loop around when navigating past the ends. * @default false */ loopFocus?: MaybeAccessor | undefined; /** * Whether the list is nested (e.g. submenu). * @default false */ nested?: MaybeAccessor | undefined; /** * Parent list orientation (for nested lists in a `Composite`). */ parentOrientation?: MaybeAccessor | undefined; /** * Whether navigation is RTL. * @default false */ rtl?: MaybeAccessor | undefined; /** * Virtual focus via `aria-activedescendant`. * @default false */ virtual?: MaybeAccessor | undefined; /** * Orientation in which navigation occurs. * @default 'vertical' */ orientation?: MaybeAccessor | undefined; /** * Number of columns. `>1` enables grid navigation. * @default 1 */ cols?: MaybeAccessor | undefined; /** * Root component id. Used for `aria-activedescendant` when `virtual`. */ id?: MaybeAccessor | undefined; /** * Whether to clear the active index when the pointer leaves an item. * @default true */ resetOnPointerLeave?: MaybeAccessor | undefined; /** * External `FloatingTreeStore` when the context-provided one is not * reachable. */ externalTree?: FloatingTreeStore | undefined; } /** * Adds arrow-key-based navigation of a list of items, using real DOM focus * or virtual focus via `aria-activedescendant`. * @see https://floating-ui.com/docs/useListNavigation */ export function useListNavigation( context: FloatingRootContext | FloatingContext, props: UseListNavigationProps, ): ElementProps { const store = "rootStore" in context ? context.rootStore : context; const dataRef = store.context.dataRef; // Untrack the body-time destructure (see use-dismiss for the full // reasoning). These fields are not `MaybeAccessor` shapes. const { listRef, onNavigate: onNavigateProp, externalTree, } = untrack(() => ({ listRef: props.listRef, onNavigate: props.onNavigate, externalTree: props.externalTree, })); // Option readers — normalise both literal and accessor shapes. const getActiveIndex = (): number | null => readOption(props.activeIndex); const getEnabled = (): boolean => props.enabled === undefined ? true : readOption(props.enabled); const getSelectedIndex = (): number | null => { const v = props.selectedIndex; if (v === undefined) return null; const read = readOption(v); return read ?? null; }; const getFocusItemOnOpen = (): boolean | "auto" => props.focusItemOnOpen === undefined ? "auto" : readOption(props.focusItemOnOpen); const getFocusItemOnHover = (): boolean => props.focusItemOnHover === undefined ? true : readOption(props.focusItemOnHover); const getOpenOnArrowKeyDown = (): boolean => props.openOnArrowKeyDown === undefined ? true : readOption(props.openOnArrowKeyDown); const getDisabledIndices = (): DisabledIndices | undefined => props.disabledIndices === undefined ? undefined : readOption(props.disabledIndices); const getAllowEscape = (): boolean => props.allowEscape === undefined ? false : readOption(props.allowEscape); const getLoopFocus = (): boolean => props.loopFocus === undefined ? false : readOption(props.loopFocus); const getNested = (): boolean => props.nested === undefined ? false : readOption(props.nested); const getParentOrientationProp = (): Orientation | undefined => props.parentOrientation === undefined ? undefined : readOption(props.parentOrientation); const getRtl = (): boolean => props.rtl === undefined ? false : readOption(props.rtl); const getVirtual = (): boolean => props.virtual === undefined ? false : readOption(props.virtual); const getOrientation = (): Orientation => props.orientation === undefined ? "vertical" : readOption(props.orientation); const getCols = (): number => props.cols === undefined ? 1 : readOption(props.cols); const getId = (): string | undefined => props.id === undefined ? undefined : readOption(props.id); const getResetOnPointerLeave = (): boolean => props.resetOnPointerLeave === undefined ? true : readOption(props.resetOnPointerLeave); const parentId = useFloatingParentNodeId(); const tree = useFloatingTree(externalTree); // Mirror React's `dataRef.current.orientation = orientation` layout // effect so child list-nav hooks (submenus) can read the parent // orientation via the tree. useIsoLayoutEffect( () => getOrientation(), (orientation) => { dataRef.current.orientation = orientation; }, ); // Refs — match React's `useRef` shape for inter-handler communication. // Body-time signal reads are wrapped in `untrack` so the strict-read // scope established by `devComponent(...)` does not warn. These are // one-shot snapshots; reactivity flows through the effects below. const focusItemOnOpenRef: MutableRef = { current: untrack(() => getFocusItemOnOpen()), }; const indexRef: MutableRef = { current: untrack(() => getSelectedIndex() ?? -1), }; const keyRef: MutableRef = { current: null }; const isPointerModalityRef: MutableRef = { current: true }; const previousMountedRef: MutableRef = { current: untrack(() => !!store.state.floatingElement), }; const previousOpenRef: MutableRef = { current: untrack(() => store.state.open), }; const forceSyncFocusRef: MutableRef = { current: false }; const forceScrollIntoViewRef: MutableRef = { current: false }; const cancelQueuedFocusRef: MutableRef<(() => void) | null> = { current: null, }; if (process.env.NODE_ENV !== "production") { const allowEscape = getAllowEscape(); const loopFocus = getLoopFocus(); const virtual = getVirtual(); const orientation = getOrientation(); const cols = getCols(); if (allowEscape) { if (!loopFocus) { console.warn( "`useListNavigation` looping must be enabled to allow escaping.", ); } if (!virtual) { console.warn("`useListNavigation` must be virtual to allow escaping."); } } if (orientation === "vertical" && cols > 1) { console.warn( "In grid list navigation mode (`cols` > 1), the `orientation` should", 'be either "horizontal" or "both".', ); } } const onNavigate = createStableCallback<(event?: Event | undefined) => void>( () => (event?: Event | undefined) => { onNavigateProp?.( indexRef.current === -1 ? null : indexRef.current, event, ); }, ); const previousOnNavigateRef: MutableRef<(event?: Event | undefined) => void> = { current: onNavigate, }; const focusItem = createStableCallback<() => void>(() => () => { const virtual = getVirtual(); const list = listRef.current; function runFocus(item: HTMLElement) { if (virtual) { tree?.events.emit("virtualfocus", item); } else { cancelQueuedFocusRef.current = enqueueFocus(item, { sync: forceSyncFocusRef.current, preventScroll: true, }); } } const initialItem = list[indexRef.current]; const forceScrollIntoView = forceScrollIntoViewRef.current; if (initialItem) { runFocus(initialItem); } const scheduler = forceSyncFocusRef.current ? (v: () => void) => v() : (v: () => void) => requestAnimationFrame(v); scheduler(() => { const waitedItem = listRef.current[indexRef.current] || initialItem; if (!waitedItem) { return; } if (!initialItem) { runFocus(waitedItem); } const shouldScrollIntoView = waitedItem && (forceScrollIntoView || !isPointerModalityRef.current); if (shouldScrollIntoView) { // Guard for test environments without `scrollIntoView`. waitedItem.scrollIntoView?.({ block: "nearest", inline: "nearest" }); } }); }); // Sync `selectedIndex` → `activeIndex` on open; reset on close. useIsoLayoutEffect( () => { if (!getEnabled()) return null; return { open: store.state.open, floatingElement: store.state.floatingElement, selectedIndex: getSelectedIndex(), }; }, (snapshot) => { if (snapshot == null) return; const { open, floatingElement, selectedIndex } = snapshot; if (open && floatingElement) { indexRef.current = selectedIndex ?? -1; if (focusItemOnOpenRef.current && selectedIndex != null) { forceScrollIntoViewRef.current = true; onNavigate(); } } else if (previousMountedRef.current) { // `onNavigate` may be swapped conditionally by callers // (onNavigate: open ? setActive : setSelected); fire the previous // function so the outgoing controller sees the reset. indexRef.current = -1; previousOnNavigateRef.current(); } }, ); // Sync `activeIndex` → focused item while the floating element is open. useIsoLayoutEffect( () => { if (!getEnabled()) return null; // Track active/selected index so an external swap re-runs this // effect (matches React's `[activeIndex, selectedIndex, ...]` // dep array). return { open: store.state.open, floatingElement: store.state.floatingElement, activeIndex: getActiveIndex(), selectedIndex: getSelectedIndex(), orientation: getOrientation(), rtl: getRtl(), nested: getNested(), }; }, (snapshot) => { if (snapshot == null) return; const { open, floatingElement, activeIndex, selectedIndex, orientation, rtl, nested, } = snapshot; if (!open) { forceSyncFocusRef.current = false; return; } if (!floatingElement) return; if (activeIndex == null) { forceSyncFocusRef.current = false; if (selectedIndex != null) { return; } // Reset while the floating element was open (e.g. list changed). if (previousMountedRef.current) { indexRef.current = -1; focusItem(); } if ( (!previousOpenRef.current || !previousMountedRef.current) && focusItemOnOpenRef.current && (keyRef.current != null || (focusItemOnOpenRef.current === true && keyRef.current == null)) ) { let runs = 0; const waitForListPopulated = () => { if (listRef.current[0] == null) { if (runs < 2) { const scheduler = runs ? (v: () => void) => requestAnimationFrame(v) : (v: () => void) => queueMicrotask(v); scheduler(waitForListPopulated); } runs += 1; } else { indexRef.current = keyRef.current == null || isMainOrientationToEndKey(keyRef.current, orientation, rtl) || nested ? getMinListIndex(listRef) : getMaxListIndex(listRef); keyRef.current = null; onNavigate(); } }; waitForListPopulated(); } } else if (!isIndexOutOfListBounds(listRef.current, activeIndex)) { indexRef.current = activeIndex; focusItem(); forceScrollIntoViewRef.current = false; } }, ); // Ensure parent floating has focus when a nested child closes, so arrow // navigation keeps working once the pointer leaves the child. useIsoLayoutEffect( () => ({ floatingElement: store.state.floatingElement, virtual: getVirtual(), enabled: getEnabled(), }), (snapshot) => { const { floatingElement, virtual, enabled } = snapshot; if ( !enabled || floatingElement || !tree || virtual || !previousMountedRef.current ) { return; } // Point-in-time reads of other tree nodes' (reactive) elements. const nodes = tree.nodesRef.current; const parent = untrack( () => nodes.find((node) => node.id === parentId)?.context?.elements .floating, ); const activeEl = activeElement(ownerDocument(floatingElement)); const treeContainsActiveEl = untrack(() => nodes.some( (node) => node.context && contains(node.context.elements.floating, activeEl), ), ); if (parent && !treeContainsActiveEl && isPointerModalityRef.current) { parent.focus({ preventScroll: true }); } }, ); // Mirror React's trailing `useIsoLayoutEffect` that captures the most // recent onNavigate/open/mount values for later reset comparisons. useIsoLayoutEffect( () => ({ open: store.state.open, floatingElement: store.state.floatingElement, }), (snapshot) => { previousOnNavigateRef.current = onNavigate; previousOpenRef.current = snapshot.open; previousMountedRef.current = !!snapshot.floatingElement; }, ); // Reset `keyRef` and refresh the `focusItemOnOpen` snapshot on close. useIsoLayoutEffect( () => ({ open: store.state.open, focusItemOnOpen: getFocusItemOnOpen(), }), (snapshot) => { if (!snapshot.open) { keyRef.current = null; focusItemOnOpenRef.current = snapshot.focusItemOnOpen; } }, ); // Component-unmount cleanup — mirrors nothing specific in React, but // matches other hooks' cancellation pattern. Cancels any pending focus // request so a detached list doesn't yank focus. onCleanup(() => { cancelQueuedFocusRef.current?.(); cancelQueuedFocusRef.current = null; }); const syncCurrentTarget = createStableCallback< (event: Event & { currentTarget: EventTarget | null }) => void >(() => (event) => { if (!store.state.open) return; const list = listRef.current; const index = list.indexOf(event.currentTarget as HTMLElement); // Snapshot for an imperative comparison — this focus handler can run // synchronously from an effect-apply scope (via `enqueueFocus`), where a // tracked `props.activeIndex` read would trip `[STRICT_READ_UNTRACKED]`. const activeIndex = untrack(getActiveIndex); if (index !== -1 && (indexRef.current !== index || activeIndex !== index)) { indexRef.current = index; onNavigate(event); } }); const item: ElementProps["item"] = { onFocus(event: FocusEvent) { forceSyncFocusRef.current = true; syncCurrentTarget(event as Event & { currentTarget: EventTarget | null }); }, // Safari focus workaround — re-focus on click. onClick(event: MouseEvent) { (event.currentTarget as HTMLElement).focus({ preventScroll: true }); }, onMouseMove(event: MouseEvent) { forceSyncFocusRef.current = true; forceScrollIntoViewRef.current = false; if (getFocusItemOnHover()) { syncCurrentTarget( event as Event & { currentTarget: EventTarget | null }, ); } }, onPointerLeave(event: PointerEvent) { if ( !store.state.open || !isPointerModalityRef.current || event.pointerType === "touch" ) { return; } forceSyncFocusRef.current = true; const relatedTarget = event.relatedTarget as HTMLElement | null; if (!getFocusItemOnHover() || listRef.current.includes(relatedTarget)) { return; } if (!getResetOnPointerLeave()) { return; } cancelQueuedFocusRef.current?.(); cancelQueuedFocusRef.current = null; indexRef.current = -1; onNavigate(event); if (!getVirtual()) { const floatingFocusEl = getFloatingFocusElement( store.state.floatingElement, ); const activeEl = activeElement(ownerDocument(floatingFocusEl)); if (floatingFocusEl && contains(floatingFocusEl, activeEl)) { floatingFocusEl.focus({ preventScroll: true }); } } }, }; const getParentOrientation = (): Orientation | undefined => { const fromProp = getParentOrientationProp(); if (fromProp !== undefined) return fromProp; return ( (tree?.nodesRef.current.find((node) => node.id === parentId)?.context ?.dataRef?.current.orientation as Orientation | undefined) ?? undefined ); }; const commonOnKeyDown = createStableCallback<(event: KeyboardEvent) => void>( () => (event: KeyboardEvent) => { isPointerModalityRef.current = false; forceSyncFocusRef.current = true; // Chrome fires ArrowDown twice while composing. Ignore the IME path. if ((event as KeyboardEvent & { which?: number }).which === 229) { return; } const floatingFocusEl = getFloatingFocusElement( store.state.floatingElement, ); // Floating element animating out: ignore so `activeIndex` doesn't // latch while closed. if (!store.state.open && event.currentTarget === floatingFocusEl) { return; } // beta.28 can deliver the first key event before the layout effect // that mirrors an externally-seeded active index into this imperative // cursor. Seed it on demand so initial auto-highlight advances from // item 0 instead of selecting item 0 again. if (indexRef.current === -1) { const activeIndex = getActiveIndex(); if ( activeIndex != null && !isIndexOutOfListBounds(listRef.current, activeIndex) ) { indexRef.current = activeIndex; } } const orientation = getOrientation(); const rtl = getRtl(); const nested = getNested(); const cols = getCols(); const virtual = getVirtual(); const disabledIndices = getDisabledIndices(); const loopFocus = getLoopFocus(); const allowEscape = getAllowEscape(); if ( nested && isCrossOrientationCloseKey(event.key, orientation, rtl, cols) ) { // If the nested close key matches the parent navigation key, let // the parent navigate; otherwise swallow the event. if ( !isMainOrientationKey(event.key, getParentOrientation() ?? "vertical") ) { stopEvent(event); } store.setOpen( false, createChangeEventDetails( REASONS.listNavigation, event, ) as BaseUIChangeEventDetails, ); const domReferenceElement = store.state.domReferenceElement; if (isHTMLElement(domReferenceElement)) { if (virtual) { tree?.events.emit("virtualfocus", domReferenceElement); } else { (domReferenceElement as HTMLElement).focus(); } } return; } const currentIndex = indexRef.current; const minIndex = getMinListIndex(listRef, disabledIndices); const maxIndex = getMaxListIndex(listRef, disabledIndices); const domReferenceElement = store.state.domReferenceElement; const typeableComboboxReference = isTypeableCombobox(domReferenceElement); if (!typeableComboboxReference) { if (event.key === "Home") { stopEvent(event); indexRef.current = minIndex; onNavigate(event); } if (event.key === "End") { stopEvent(event); indexRef.current = maxIndex; onNavigate(event); } } // Grid navigation. if (cols > 1) { const sizes = Array.from({ length: listRef.current.length }, () => ({ width: 1, height: 1, })); const cellMap = createGridCellMap(sizes, cols, false); const minGridIndex = cellMap.findIndex( (index) => index != null && !isListIndexDisabled(listRef.current, index, disabledIndices), ); const maxGridIndex = cellMap.reduce( (foundIndex: number, index, cellIndex) => index != null && !isListIndexDisabled(listRef.current, index, disabledIndices) ? cellIndex : foundIndex, -1, ); const index = cellMap[ getGridNavigatedIndex( cellMap.map((itemIndex) => itemIndex != null ? listRef.current[itemIndex] : null, ) as Array, { event, orientation, loopFocus, rtl, cols, disabledIndices: getGridCellIndices( [ ...((typeof disabledIndices !== "function" ? disabledIndices : null) || listRef.current.map((_, listIndex) => isListIndexDisabled( listRef.current, listIndex, disabledIndices, ) ? listIndex : undefined, )), undefined, ], cellMap, ), minIndex: minGridIndex, maxIndex: maxGridIndex, prevIndex: getGridCellIndexOfCorner( indexRef.current > maxIndex ? minIndex : indexRef.current, sizes, cellMap, cols, event.key === ARROW_DOWN ? "bl" : event.key === (rtl ? ARROW_LEFT : ARROW_RIGHT) ? "tr" : "tl", ), stopEvent: true, }, ) ]; if (index != null) { indexRef.current = index; onNavigate(event); } if (orientation === "both") { return; } } if (isMainOrientationKey(event.key, orientation)) { stopEvent(event); const ct = event.currentTarget as HTMLElement | null; // Reset if no item is focused. if ( store.state.open && !virtual && ct && activeElement(ct.ownerDocument) === ct ) { indexRef.current = isMainOrientationToEndKey( event.key, orientation, rtl, ) ? minIndex : maxIndex; onNavigate(event); return; } if (isMainOrientationToEndKey(event.key, orientation, rtl)) { if (loopFocus) { if (currentIndex >= maxIndex) { if (allowEscape && currentIndex !== listRef.current.length) { indexRef.current = -1; } else { // Give time for virtualizers to update the listRef. forceSyncFocusRef.current = false; indexRef.current = minIndex; } } else { indexRef.current = findNonDisabledListIndex(listRef.current, { startingIndex: currentIndex, disabledIndices, }); } } else { indexRef.current = Math.min( maxIndex, findNonDisabledListIndex(listRef.current, { startingIndex: currentIndex, disabledIndices, }), ); } } else if (loopFocus) { if (currentIndex <= minIndex) { if (allowEscape && currentIndex !== -1) { indexRef.current = listRef.current.length; } else { forceSyncFocusRef.current = false; indexRef.current = maxIndex; } } else { indexRef.current = findNonDisabledListIndex(listRef.current, { startingIndex: currentIndex, decrement: true, disabledIndices, }); } } else { indexRef.current = Math.max( minIndex, findNonDisabledListIndex(listRef.current, { startingIndex: currentIndex, decrement: true, disabledIndices, }), ); } if (isIndexOutOfListBounds(listRef.current, indexRef.current)) { indexRef.current = -1; } onNavigate(event); } }, ); const floating: ElementProps["floating"] = { get "aria-orientation"() { const orientation = getOrientation(); return orientation === "both" ? undefined : orientation; }, // `aria-activedescendant` is wired reactively so consumers see the // current index without re-running the component body. The `virtual` // gate is checked inside. get "aria-activedescendant"() { if (!getVirtual() || !store.state.open) return undefined; const activeIndex = getActiveIndex(); if (activeIndex == null) return undefined; const typeableComboboxReference = isTypeableCombobox( store.state.domReferenceElement, ); if (typeableComboboxReference) return undefined; const id = getId(); return `${id}-${activeIndex}`; }, onKeyDown(event: KeyboardEvent) { const virtual = getVirtual(); const open = store.state.open; // Close submenu on Shift+Tab if (event.key === "Tab" && event.shiftKey && open && !virtual) { const target = getTarget(event) as Element | null; const floatingFocusEl = getFloatingFocusElement( store.state.floatingElement, ); // If originated within a nested element, let the nested focus // manager handle Tab. if (target && !contains(floatingFocusEl, target)) { return; } stopEvent(event); store.setOpen( false, createChangeEventDetails( REASONS.focusOut, event, ) as BaseUIChangeEventDetails, ); const domReferenceElement = store.state.domReferenceElement; if (isHTMLElement(domReferenceElement)) { (domReferenceElement as HTMLElement).focus(); } return; } commonOnKeyDown(event); }, onPointerMove() { isPointerModalityRef.current = true; }, }; const triggerKeyDown = (event: KeyboardEvent) => { // Non-reactive open snapshot (matches React's `store.select('open')`). const currentOpen = store.state.open; isPointerModalityRef.current = false; const orientation = getOrientation(); const rtl = getRtl(); const nested = getNested(); const virtual = getVirtual(); const openOnArrowKeyDown = getOpenOnArrowKeyDown(); const isArrowKey = event.key.startsWith("Arrow"); const isParentCrossOpenKey = isCrossOrientationOpenKey( event.key, getParentOrientation() ?? "vertical", rtl, ); const isMainKey = isMainOrientationKey(event.key, orientation); const isNavigationKey = (nested ? isParentCrossOpenKey : isMainKey) || event.key === "Enter" || event.key.trim() === ""; if (virtual && currentOpen) { return commonOnKeyDown(event); } // Don't stash `activeIndex` while closed if arrow-key-down is opt-out. if (!currentOpen && !openOnArrowKeyDown && isArrowKey) { return undefined; } if (isNavigationKey) { const isParentMainKey = isMainOrientationKey( event.key, getParentOrientation() ?? "vertical", ); keyRef.current = nested && isParentMainKey ? null : event.key; } if (nested) { if (isParentCrossOpenKey) { stopEvent(event); if (currentOpen) { indexRef.current = getMinListIndex(listRef, getDisabledIndices()); onNavigate(event); } else { store.setOpen( true, createChangeEventDetails( REASONS.listNavigation, event, event.currentTarget as HTMLElement, ) as BaseUIChangeEventDetails, ); } } return undefined; } if (isMainKey) { const selectedIndex = getSelectedIndex(); if (selectedIndex != null) { indexRef.current = selectedIndex; } stopEvent(event); if (!currentOpen && openOnArrowKeyDown) { store.setOpen( true, createChangeEventDetails( REASONS.listNavigation, event, event.currentTarget as HTMLElement, ) as BaseUIChangeEventDetails, ); } else { commonOnKeyDown(event); } if (currentOpen) { onNavigate(event); } } return undefined; }; const checkVirtualMouse = (event: PointerEvent | MouseEvent) => { if ( getFocusItemOnOpen() === "auto" && isVirtualClick(event as MouseEvent) ) { focusItemOnOpenRef.current = !getVirtual(); } }; const checkVirtualPointer = (event: PointerEvent) => { // `pointerdown` fires first; reset then check. focusItemOnOpenRef.current = getFocusItemOnOpen(); if (getFocusItemOnOpen() === "auto" && isVirtualPointerEvent(event)) { focusItemOnOpenRef.current = true; } }; const trigger: ElementProps["trigger"] = { onKeyDown: triggerKeyDown, onFocus(event: FocusEvent) { if (store.state.open && !getVirtual()) { indexRef.current = -1; onNavigate(event); } }, onPointerDown: checkVirtualPointer, onPointerEnter: checkVirtualPointer, onMouseDown: checkVirtualMouse, onClick: checkVirtualMouse, }; const reference: ElementProps["reference"] = { // `aria-activedescendant` on the reference for virtual/combobox cases. get "aria-activedescendant"() { if (!getVirtual() || !store.state.open) return undefined; const activeIndex = getActiveIndex(); if (activeIndex == null) return undefined; const id = getId(); return `${id}-${activeIndex}`; }, onKeyDown: triggerKeyDown, onFocus(event: FocusEvent) { if (store.state.open && !getVirtual()) { indexRef.current = -1; onNavigate(event); } }, onPointerDown: checkVirtualPointer, onPointerEnter: checkVirtualPointer, onMouseDown: checkVirtualMouse, onClick: checkVirtualMouse, }; // Signalled enablement: when `enabled` is a literal `false`, React // returns `{}`. When it's an accessor, we always return the bag so // the attached handlers short-circuit internally when disabled. This // matches the hover/dismiss approach and preserves the "flip enabled // off" contract under tier-3a. Untrack the body-time `props.enabled` // access so the strict-read scope does not warn — accessor reads // flow through tracked effects/handlers below. const isStaticallyDisabled = untrack( () => typeof props.enabled !== "function" && !getEnabled(), ); if (isStaticallyDisabled) { return {}; } return { reference, floating, item, trigger }; }