/** * Stage 4.6 port of Base UI React's `useTypeahead` hook. * * React source: * `base-ui/packages/react/src/floating-ui-react/hooks/useTypeahead.ts` * * Provides a matching callback that focuses / highlights a list item as * the user types. Typically paired with `useListNavigation`; the React * fork delivers this hook unchanged from upstream `@floating-ui/react`. * * ### React → Solid deviations * * 1. `useRef` → `MutableRef` (matches sibling hooks). Closed-over * `{ current }` containers hold the typeahead string, the last matched * index, and the previous-index snapshot. * * 2. `useStableCallback` → `createStableCallback` from * `src/internals/stable-callback.ts`. Used only where React stabilises * identity for effect deps / memo keys — here it stabilises the * event handlers so they can be captured in `reference` / `floating` * bags without identity churn. * * 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`. We use `store.state.open` * inside `compute` to subscribe and `store.state.open` inside `apply` * for the sync read. * * 4. `useMemo` for `reference` / `floating` bags collapses to plain * object literals — Solid doesn't re-run component bodies, so the bag * identity is already stable for the lifetime of the call site. * * 5. Event handlers receive native events directly (no `.nativeEvent` * unwrap). * * 6. `MaybeAccessor` applies to the reactive option fields * (`activeIndex`, `selectedIndex`, `enabled`, `resetMs`) so consumers * that mount the hook once can still flip values later — same * convention as `useListNavigation`. Literal values also work. * * 7. Shadow-DOM-safe `contains()` from `src/internals/dom/contains.ts` * replaces raw `node.contains(target)`. * * ### Reason strings * * `useTypeahead` does not call `store.setOpen` — it only invokes * `onMatch(index)` and the `onTypingChange(isTyping)` callback. No * `REASONS.*` strings are emitted from this hook. Matches React 1:1. */ import { untrack } from "solid-js"; import { contains } from "../../dom/contains"; import { type MaybeAccessor, readOption } from "../../maybe-accessor"; import { createStableCallback } from "../../stable-callback"; import { useIsoLayoutEffect } from "../../use-iso-layout-effect"; import { useTimeout } from "../../use-timeout"; import type { ElementProps, FloatingContext, FloatingRootContext, MutableRef, } from "../types"; import { isElementVisible } from "../utils/composite"; import { stopEvent } from "../utils/event"; export interface UseTypeaheadProps { /** * A ref which contains an array of strings whose indices match the HTML * elements of the list. * @default empty list */ listRef: MutableRef>; /** * The index of the active (focused or highlighted) item in the list. * May be a literal or an accessor. * @default null */ activeIndex: MaybeAccessor; /** * Callback invoked with the matching index if found as the user types. */ onMatch?: ((index: number) => void) | undefined; /** * Optional list of item elements that correspond to `listRef` indices. * When an element exists for an index, typeahead skips it if it is * hidden via CSS (`display: none`). */ elementsRef?: MutableRef> | undefined; /** * Callback invoked with the typing state as the user types. */ onTypingChange?: ((isTyping: boolean) => void) | undefined; /** * Whether the Hook is enabled, including all internal Effects and * event handlers. * @default true */ enabled?: MaybeAccessor | undefined; /** * The number of milliseconds to wait before resetting the typed * string. * @default 750 */ resetMs?: MaybeAccessor | undefined; /** * The index of the selected item in the list, if available. * @default null */ selectedIndex?: MaybeAccessor | undefined; } /** * Provides a matching callback that can be used to focus an item as the * user types, often used in tandem with `useListNavigation()`. * @see https://floating-ui.com/docs/useTypeahead */ export function useTypeahead( context: FloatingRootContext | FloatingContext, props: UseTypeaheadProps, ): 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, elementsRef, onMatch: onMatchProp, onTypingChange, } = untrack(() => ({ listRef: props.listRef, elementsRef: props.elementsRef, onMatch: props.onMatch, onTypingChange: props.onTypingChange, })); // Option readers — normalise literal + accessor shapes. const getActiveIndex = (): number | null => readOption(props.activeIndex); const getEnabled = (): boolean => props.enabled === undefined ? true : readOption(props.enabled); const getResetMs = (): number => props.resetMs === undefined ? 750 : readOption(props.resetMs); const getSelectedIndex = (): number | null => { const v = props.selectedIndex; if (v === undefined) return null; const read = readOption(v); return read ?? null; }; const timeout = useTimeout(); const stringRef: MutableRef = { current: "" }; // One-shot seed: untrack so the body-time strict-read scope // established by `devComponent(...)` does not warn. Reactivity for // later updates flows through the `useIsoLayoutEffect` below. const prevIndexRef: MutableRef = { current: untrack(() => getSelectedIndex() ?? getActiveIndex() ?? -1), }; const matchIndexRef: MutableRef = { current: null }; // Reset the typeahead accumulator when the floating element closes. // React's effect dep array is `[open, selectedIndex, timeout]` — the // Solid split form tracks `open` + `selectedIndex` in `compute`; the // `timeout` handle identity is stable for the owned scope's lifetime // (see `use-timeout.ts`) so we omit it from tracking. useIsoLayoutEffect( () => ({ open: store.state.open, selectedIndex: getSelectedIndex(), }), (snapshot) => { const { open, selectedIndex } = snapshot; if (!open && selectedIndex !== null) { return; } timeout.clear(); matchIndexRef.current = null; if (stringRef.current !== "") { stringRef.current = ""; } }, ); // Sync arrow-key navigation but not typeahead navigation — update the // `prevIndexRef` snapshot only when there's no active typing session. useIsoLayoutEffect( () => ({ open: store.state.open, selectedIndex: getSelectedIndex(), activeIndex: getActiveIndex(), }), (snapshot) => { const { open, selectedIndex, activeIndex } = snapshot; if (open && stringRef.current === "") { prevIndexRef.current = selectedIndex ?? activeIndex ?? -1; } }, ); const setTypingChange = createStableCallback<(value: boolean) => void>( () => (value: boolean) => { if (value) { if (!dataRef.current.typing) { dataRef.current.typing = value; onTypingChange?.(value); } } else if (dataRef.current.typing) { dataRef.current.typing = value; onTypingChange?.(value); } }, ); const onKeyDown = createStableCallback<(event: KeyboardEvent) => void>( () => (event: KeyboardEvent) => { if (!getEnabled()) return; function isVisible(index: number) { const element = elementsRef?.current[index]; return !element || isElementVisible(element); } function getMatchingIndex( list: Array, string: string, startIndex = 0, ) { if (list.length === 0) { return -1; } const normalizedStartIndex = ((startIndex % list.length) + list.length) % list.length; const lowerString = string.toLocaleLowerCase(); for (let offset = 0; offset < list.length; offset += 1) { const index = (normalizedStartIndex + offset) % list.length; const text = list[index]; if ( !text?.toLocaleLowerCase().startsWith(lowerString) || !isVisible(index) ) { continue; } return index; } return -1; } const listContent = listRef.current; const activeIndex = getActiveIndex(); const selectedIndex = getSelectedIndex(); const resetMs = getResetMs(); if (stringRef.current.length > 0 && event.key === " ") { // Space should continue the in-progress typeahead session. stopEvent(event); setTypingChange(true); } if (stringRef.current.length > 0 && stringRef.current[0] !== " ") { if ( getMatchingIndex(listContent, stringRef.current) === -1 && event.key !== " " ) { setTypingChange(false); } } if ( listContent == null || // Character key. event.key.length !== 1 || // Modifier key. event.ctrlKey || event.metaKey || event.altKey ) { return; } const open = store.state.open; if (open && event.key !== " ") { stopEvent(event); setTypingChange(true); } // Capture whether this is a new typing session before mutating // the string. const isNewSession = stringRef.current === ""; if (isNewSession) { prevIndexRef.current = selectedIndex ?? activeIndex ?? -1; } // Bail out if the list contains a word like "llama" or "aaron". // TODO: allow it in this case, too. const allowRapidSuccessionOfFirstLetter = listContent.every((text) => text ? text[0]?.toLocaleLowerCase() !== text[1]?.toLocaleLowerCase() : true, ); // Allows the user to cycle through items that start with the // same letter in rapid succession. if ( allowRapidSuccessionOfFirstLetter && stringRef.current === event.key ) { stringRef.current = ""; prevIndexRef.current = matchIndexRef.current; } stringRef.current += event.key; timeout.start(resetMs, () => { stringRef.current = ""; prevIndexRef.current = matchIndexRef.current; setTypingChange(false); }); // Compute the starting index for this search. For a new session, // base it on the current selection/active item; otherwise // continue from the last matched index. const prevIndex = isNewSession ? (selectedIndex ?? activeIndex ?? -1) : prevIndexRef.current; const startIndex = (prevIndex ?? 0) + 1; const index = getMatchingIndex( listContent, stringRef.current, startIndex, ); if (index !== -1) { onMatchProp?.(index); matchIndexRef.current = index; } else if (event.key !== " ") { stringRef.current = ""; setTypingChange(false); } }, ); const onBlur = createStableCallback<(event: FocusEvent) => void>( () => (event: FocusEvent) => { if (!getEnabled()) return; const next = event.relatedTarget as Element | null; const currentDomReferenceElement = store.state.domReferenceElement; const currentFloatingElement = store.state.floatingElement; const withinReference = contains(currentDomReferenceElement, next); const withinFloating = contains(currentFloatingElement, next); // Keep the session if focus moves within the composite // (reference <-> floating). if (withinReference || withinFloating) { return; } // End the current typing session when focus leaves the composite. timeout.clear(); stringRef.current = ""; prevIndexRef.current = matchIndexRef.current; setTypingChange(false); }, ); const reference: ElementProps["reference"] = { onKeyDown, onBlur }; const floating: ElementProps["floating"] = { onKeyDown, onBlur }; // Signalled enablement: when `enabled` is a literal `false`, return // `{}` to match React. When it's an accessor, always return the bag // so handlers can short-circuit internally — matches the hover / // list-navigation enablement contract. Untrack the body-time check // so the strict-read scope established by `devComponent(...)` does // not warn — handlers re-read the accessor at event time inside // already-tracked scopes. const isStaticallyDisabled = untrack( () => typeof props.enabled !== "function" && !getEnabled(), ); if (isStaticallyDisabled) { return {}; } return { reference, floating }; }