/** * `useCompositeRoot` — keyboard navigation primitive for 1D lists. * * Translates the keyboard half of Base UI React's `useCompositeRoot` * (`packages/react/src/internals/composite/root/useCompositeRoot.ts`) to Solid * 2. This is intentionally a 1D-only engine — grid / dense / itemSizes / * onLoop / cols are deliberately not implemented here. Every non-floating * consumer (toolbar, tabs, radio-group, toggle-group) is 1D. Grid navigation * lives in the floating path (`floating-ui/hooks/use-list-navigation.ts` + * `floating-ui/utils/composite.ts`, `getGridNavigatedIndex`), where any grid * consumer (menu / select / combobox) already gets it. Build a non-floating * grid path only if a non-floating grid consumer surfaces — none is planned. * * Shape: * * - Returns `{ props, highlightedIndex, onHighlightedIndexChange, * elementsRef, onMapChange, relayKeyboardEvent }`. * - `props` is a plain object containing the keyboard handler + focus * handler, ready to be dropped into `renderElement`'s `params.props` * array. Handlers read reactive values at call time inside the body; no * top-level reactive reads. * - `highlightedIndex` is an accessor; `onHighlightedIndexChange(index, * shouldScrollIntoView)` mirrors React Base UI's signature exactly. * - `elementsRef` is a plain array (NOT a signal) — downstream `` * owns its contents and the keyboard handler reads it at call time. */ import { createSignal, untrack } from "solid-js"; import { ACTIVE_COMPOSITE_ITEM, ALL_KEYS, ARROW_DOWN, ARROW_KEYS, ARROW_LEFT, ARROW_RIGHT, ARROW_UP, type CompositeOrientation, END, HOME, HORIZONTAL_KEYS, HORIZONTAL_KEYS_WITH_EXTRA_KEYS, MODIFIER_KEYS, type ModifierKey, type TextDirection, VERTICAL_KEYS, VERTICAL_KEYS_WITH_EXTRA_KEYS, } from "./composite-constants"; import type { CompositeMetadata } from "./composite-context"; import { type DisabledIndices, type ElementList, findNonDisabledListIndex, getMaxListIndex, getMinListIndex, isElementDisabled, isIndexOutOfListBounds, isNativeInput, scrollIntoViewIfNeeded, } from "./composite-utils"; export interface UseCompositeRootParameters { orientation?: CompositeOrientation | undefined; loopFocus?: boolean | undefined; /** Controlled highlighted index. */ highlightedIndex?: (() => number | undefined) | undefined; /** Fired when the highlighted index changes. */ onHighlightedIndexChange?: ((index: number) => void) | undefined; /** Text direction (for rtl-aware horizontal navigation). */ direction: () => TextDirection; /** Opts into Home/End key handling. */ enableHomeAndEndKeys?: boolean | undefined; /** When `true`, `event.stopPropagation()` fires on handled keys. */ stopEventPropagation?: boolean | undefined; /** Indices that should be treated as disabled for navigation. */ disabledIndices?: (() => number[] | undefined) | undefined; /** Modifier keys that allow native behavior (default: none). */ modifierKeys?: ModifierKey[] | undefined; } /** * A `MouseEvent`/`KeyboardEvent` with Base UI's handler-chain bailout. */ interface BaseUIEventLike { baseUIHandlerPrevented?: boolean; preventBaseUIHandler?: () => void; } const EMPTY_MODIFIERS: readonly ModifierKey[] = []; export interface UseCompositeRootReturn { /** Props to merge onto the composite's root element. */ props: { "aria-orientation"?: "horizontal" | "vertical" | undefined; onFocus: (event: FocusEvent) => void; onKeyDown: (event: KeyboardEvent & BaseUIEventLike) => void; }; highlightedIndex: () => number; onHighlightedIndexChange: ( index: number, shouldScrollIntoView?: boolean, ) => void; /** Plain array of registered elements (owned by ``). */ elementsRef: Array; /** Root DOM element ref callback (captured internally for scroll/focus). */ rootRef: (el: HTMLElement | null) => void; /** Handler for ``. Picks up the initial * active item from the `ACTIVE_COMPOSITE_ITEM` attribute once. */ onMapChange: (map: Map | null>) => void; /** Alias for `props.onKeyDown`, suitable for relaying events from * detached trigger elements. */ relayKeyboardEvent: (event: KeyboardEvent & BaseUIEventLike) => void; } export function useCompositeRoot( params: UseCompositeRootParameters, ): UseCompositeRootReturn { // Internal highlighted-index state. Composite is internal infrastructure // (no public consumer ever passes `highlightedIndex` directly — only // outer compounds like Tabs.List / ToggleGroup forward an accessor for // composite-to-composite coordination). The optional consumer-controlled // override stays via the `params.highlightedIndex` accessor; if absent, // the internal signal is the source of truth. // // Why this internal signal stays as part of §4.1: composite is a 100% // internal helper, not a public-API surface. Forcing every wrapper to // bring its own signal would push controlled/uncontrolled friction onto // internals where the consumer is never exposed. The pattern we removed // from public wrappers (drop defaultX, force consumer to bring signal) // doesn't apply here. const [internalHighlightedIndex, setInternalHighlightedIndex] = createSignal( 0, { ownedWrite: true, name: "composite.internalHighlightedIndex" }, ); const highlightedIndex = (): number => params.highlightedIndex?.() ?? internalHighlightedIndex(); const setHighlightedIndex = (next: number | undefined): void => { if (next === undefined) return; // Always notify the external onChange — consumers may use this to // observe even when they don't pass a controlled `highlightedIndex`. params.onHighlightedIndexChange?.(next); // Only write the internal signal when the consumer isn't driving // `highlightedIndex` themselves; mirrors consumer-owned signal mechanics // ("once-controlled-always-controlled" pattern, see solidify.md §6). if (params.highlightedIndex === undefined) { setInternalHighlightedIndex(next); } }; // Synchronous mirror for keyboard-handler reads. In Solid 2 signal writes // settle on the next microtask (MIGRATION.md), so two arrow presses inside // the same tick would both see the pre-write value — making the second // press read a stale `highlightedIndex()`. We mirror the latest written // value into a plain variable so the handler can advance monotonically. // The accessor remains the source of truth for external readers. // // Strategy (see B3 in the wave-0 review): // // - On every handler entry we read the external controlled value (when // the consumer passes one) and compare it against `lastSeenControlled`. // - If the consumer drove it elsewhere (external change across ticks), // the value differs from our last observation; we resync // `committedIndex` to the external value. // - Otherwise, we assume the caller is still mid-tick and prefer // `committedIndex` — which reflects all writes we've issued so far in // this tick, even if the controlled accessor hasn't flushed yet. // // The controlled accessor remains the single source of truth across // ticks; `committedIndex` is an intra-tick overlay only. let committedIndex = 0; let lastSeenControlled: number | undefined; // Root DOM element, captured via ref callback. `ownedWrite` because the // callback fires from unowned scope. const [rootEl, setRootEl] = createSignal(null, { ownedWrite: true, }); const rootRef = (el: HTMLElement | null): void => { setRootEl(el); }; // The elements array lives here; `` mutates it on our // behalf. Keeping it as a plain array (not a signal) matches the // `ref.current`-shaped contract floating-ui uses and keeps keyboard // handler reads synchronous. const elementsRef: Array = []; // Once-only latch for the "initial active item" scan. let hasSetDefaultIndex = false; const onHighlightedIndexChange = ( index: number, shouldScrollIntoView = false, ): void => { committedIndex = index; setHighlightedIndex(index); if (shouldScrollIntoView) { const el = elementsRef[index] ?? null; scrollIntoViewIfNeeded( rootEl(), el, params.direction(), params.orientation ?? "both", ); } }; const onMapChange = ( map: Map | null>, ): void => { // Invoked from `` inside an effect apply when items // register/unregister; reads of `rootEl()` / `params.direction()` // are snapshots driving a one-shot scroll, not subscriptions. // `untrack` keeps the strict-read context from flagging them. untrack(() => { if (map.size === 0 || hasSetDefaultIndex) return; hasSetDefaultIndex = true; const sortedElements = Array.from(map.keys()); const active = sortedElements.find((el) => el.hasAttribute(ACTIVE_COMPOSITE_ITEM), ) as HTMLElement | undefined; const activeIndex = active ? sortedElements.indexOf(active) : -1; if (activeIndex !== -1) { onHighlightedIndexChange(activeIndex); } scrollIntoViewIfNeeded( rootEl(), active ?? null, params.direction(), params.orientation ?? "both", ); }); }; const disabledIndicesAccessor = (): number[] | undefined => params.disabledIndices?.(); const getDisabledIndicesInput = (): DisabledIndices | undefined => { const list = disabledIndicesAccessor(); return list; }; const handleKeyDown = (event: KeyboardEvent & BaseUIEventLike): void => { const enableHomeEnd = params.enableHomeAndEndKeys ?? false; const orientation = params.orientation ?? "both"; const loopFocus = params.loopFocus ?? true; const stopEventPropagation = params.stopEventPropagation ?? false; const modifierKeys = params.modifierKeys ?? EMPTY_MODIFIERS; const RELEVANT_KEYS = enableHomeEnd ? ALL_KEYS : ARROW_KEYS; if (!RELEVANT_KEYS.has(event.key)) return; if (isModifierKeySet(event, modifierKeys)) return; const root = rootEl(); if (!root) return; const isRtl = params.direction() === "rtl"; const keys = resolveDirectionalKeys( orientation, isRtl, enableHomeEnd, RELEVANT_KEYS, ); // Textbox bailout — defer arrow keys to the input when the cursor // isn't at the extent in the travel direction. const target = event.target as EventTarget | null; if ( shouldDeferToNativeInput(event, target, keys.forwardKey, keys.backwardKey) ) { return; } const list: ElementList = elementsRef; const disabledInput = getDisabledIndicesInput(); // See `committedIndex` declaration: prefer the mirror for // intra-tick reads, only resync to the controlled accessor when the // external value changed between handler calls (across ticks). const controlledValue = params.highlightedIndex?.(); if ( controlledValue !== undefined && controlledValue !== lastSeenControlled ) { committedIndex = controlledValue; } lastSeenControlled = controlledValue; const currentIndex = committedIndex; const minIndex = getMinListIndex(list, disabledInput); const maxIndex = getMaxListIndex(list, disabledInput); const nextIndex = computeNextIndex( event, currentIndex, list, disabledInput, minIndex, maxIndex, enableHomeEnd, loopFocus, keys, ); if ( nextIndex !== currentIndex && !isIndexOutOfListBounds(list, nextIndex) ) { if (stopEventPropagation) event.stopPropagation(); if (keys.preventedKeys.has(event.key)) event.preventDefault(); onHighlightedIndexChange(nextIndex, true); // Defer focus until after the flush completes — matches React's // `queueMicrotask` placement so any concurrent focus-manager work // (e.g. `returnFocus`) has already settled. queueMicrotask(() => { elementsRef[nextIndex]?.focus(); }); } }; const handleFocus = (event: FocusEvent): void => { const root = rootEl(); const target = event.target; if (!root || target == null || !isNativeInput(target)) return; const input = target as HTMLInputElement | HTMLTextAreaElement; input.setSelectionRange(0, input.value?.length ?? 0); }; const orientationAttr = (): "horizontal" | "vertical" | undefined => { const o = params.orientation ?? "both"; return o === "both" ? undefined : o; }; const props = { get "aria-orientation"(): "horizontal" | "vertical" | undefined { return orientationAttr(); }, onFocus: handleFocus, onKeyDown: handleKeyDown, }; return { props, highlightedIndex, onHighlightedIndexChange, elementsRef, rootRef, onMapChange, relayKeyboardEvent: handleKeyDown, }; } function isModifierKeySet( event: KeyboardEvent, ignoredModifierKeys: readonly ModifierKey[], ): boolean { for (const key of MODIFIER_KEYS.values()) { if (ignoredModifierKeys.includes(key)) continue; if (event.getModifierState(key)) return true; } return false; } interface DirectionalKeys { /** Single key (per orientation) that moves the highlight forward. */ forwardKey: string; /** Single key (per orientation) that moves the highlight backward. */ backwardKey: string; /** All keys that move forward (`both` orientation has two). */ forwardKeys: readonly string[]; /** All keys that move backward (`both` orientation has two). */ backwardKeys: readonly string[]; /** Keys whose default action should be prevented when handled. */ preventedKeys: ReadonlySet; } // Resolve the orientation/rtl-aware key sets used to drive navigation. Pulled // out of the handler so the (otherwise dense) lookup tables read as data. function resolveDirectionalKeys( orientation: CompositeOrientation, isRtl: boolean, enableHomeEnd: boolean, relevantKeys: ReadonlySet, ): DirectionalKeys { const horizontalForwardKey = isRtl ? ARROW_LEFT : ARROW_RIGHT; const horizontalBackwardKey = isRtl ? ARROW_RIGHT : ARROW_LEFT; return { forwardKey: { horizontal: horizontalForwardKey, vertical: ARROW_DOWN, both: horizontalForwardKey, }[orientation], backwardKey: { horizontal: horizontalBackwardKey, vertical: ARROW_UP, both: horizontalBackwardKey, }[orientation], forwardKeys: { horizontal: [horizontalForwardKey], vertical: [ARROW_DOWN], both: [horizontalForwardKey, ARROW_DOWN], }[orientation], backwardKeys: { horizontal: [horizontalBackwardKey], vertical: [ARROW_UP], both: [horizontalBackwardKey, ARROW_UP], }[orientation], preventedKeys: { horizontal: enableHomeEnd ? HORIZONTAL_KEYS_WITH_EXTRA_KEYS : HORIZONTAL_KEYS, vertical: enableHomeEnd ? VERTICAL_KEYS_WITH_EXTRA_KEYS : VERTICAL_KEYS, both: relevantKeys, }[orientation], }; } // Textbox bailout: when the event originated in a native input, arrow keys // should defer to the input unless the caret sits at the extent in the travel // direction. Returns `true` when the composite handler should stand down. function shouldDeferToNativeInput( event: KeyboardEvent, target: EventTarget | null, forwardKey: string, backwardKey: string, ): boolean { if (!isNativeInput(target) || isElementDisabled(target)) return false; const input = target as HTMLInputElement | HTMLTextAreaElement; const selectionStart = input.selectionStart; const selectionEnd = input.selectionEnd; const textContent = input.value ?? ""; if ( selectionStart == null || event.shiftKey || selectionStart !== selectionEnd ) { return true; } if (event.key !== backwardKey && selectionStart < textContent.length) { return true; } if (event.key !== forwardKey && selectionStart > 0) { return true; } return false; } // The next-index math: Home/End jumps, edge looping, and the find-next-enabled // scan. Returns the index the highlight should move to (possibly unchanged). function computeNextIndex( event: KeyboardEvent, currentIndex: number, list: ElementList, disabledInput: DisabledIndices | undefined, minIndex: number, maxIndex: number, enableHomeEnd: boolean, loopFocus: boolean, keys: DirectionalKeys, ): number { let nextIndex = currentIndex; if (enableHomeEnd) { if (event.key === HOME) nextIndex = minIndex; else if (event.key === END) nextIndex = maxIndex; } const isForward = keys.forwardKeys.includes(event.key); const isBackward = keys.backwardKeys.includes(event.key); if (nextIndex === currentIndex && (isForward || isBackward)) { if (loopFocus && nextIndex === maxIndex && isForward) { nextIndex = minIndex; } else if (loopFocus && nextIndex === minIndex && isBackward) { nextIndex = maxIndex; } else { nextIndex = findNonDisabledListIndex(list, { startingIndex: nextIndex, decrement: isBackward, disabledIndices: disabledInput, }); } } return nextIndex; }