import type { JSX } from "@solidjs/web"; /** * `` — Solid 2 port of Base UI React's * `AutocompleteRoot` (~280 LOC) layered on top of the combobox * primitives. Autocomplete is the "input value is the source of truth" * variant of combobox: the `value` prop controls the input text * directly, there's no separate selected value, and pressing an item * fills the input rather than committing a separate selection. * * ### Shipped scope * * - Single free-text autocomplete (`selectionMode: "none"`) under the * hood — shares the combobox root-context shape so all combobox * sub-parts (Portal, Positioner, Popup, List, Empty, Group, * GroupLabel, ItemIndicator, Arrow, Clear) work via pass-through * re-exports. * - Controlled + uncontrolled `value` (input text) and `open`. * - `mode` prop: `"list"` (default) / `"both"` / `"inline"` / `"none"`. * `"list"` filters; `"none"` / `"inline"` disable filtering; `"both"` * filters against the typed query even while inline-autocomplete * updates the display value. * - `autoHighlight`, `openOnInputClick` (default `false`, unlike * combobox where it defaults to `true`), `loopFocus`, * `highlightItemOnHover`, `modal`, `keepHighlight` (prop accepted, * hover-leave clearing mirrors combobox default). * - `onValueChange`, `onOpenChange`, `onOpenChangeComplete`, * `onItemHighlighted` callbacks with `BaseUIChangeEventDetails`. * - Inline-autocomplete display-value composition: when the user * keyboard-navigates over an item in `"inline"` / `"both"` modes, the * input's visible value shows that item's label without updating the * committed `value`. * * ### Deferred (inherits combobox's deferred sub-parts in shape) * * - Form-validation flow-through. * - `useClick` / `useDismiss` `enabled` latch. * - Positioning math in happy-dom. * - Input-inside-popup layout. * - Chips / multi-select / grid / virtualized (deferred sub-parts apply — * autocomplete UX is single-input-only so these are less relevant). */ import { createEffect, createMemo, createSignal, createUniqueId, onCleanup, untrack, } from "solid-js"; import { INITIAL_LAST_HIGHLIGHT } from "../../combobox/internals/constants"; import { type Filter, getFilter } from "../../combobox/internals/filter"; import { defaultItemEquality, findItemIndex, type ItemEqualityComparer, } from "../../combobox/internals/item-equality"; import { type Group, stringifyAsLabel, } from "../../combobox/internals/resolve-value-label"; import type { ComboboxSide } from "../../combobox/positioner/positioner-context"; import { type ComboboxRootChangeEventDetails, type ComboboxRootChangeEventReason, ComboboxRootContext, type ComboboxRootContextValue, type ComboboxRootHighlightEventDetails, type ComboboxRootHighlightEventReason, useComboboxRootContext, } from "../../combobox/root/combobox-root-context"; import { createChangeEventDetails, createGenericEventDetails, REASONS, } from "../../internals/change-event-details"; import { createControlledState } from "../../internals/create-controlled-state"; import { useFieldRootContext } from "../../internals/field-context"; import type { FloatingRootStore } from "../../internals/floating-ui/components/floating-root-store"; import { FloatingTree, useFloatingParentNodeId, } from "../../internals/floating-ui/components/floating-tree"; import { useClick } from "../../internals/floating-ui/hooks/use-click"; import { useDismiss } from "../../internals/floating-ui/hooks/use-dismiss"; import { useFloatingRootContext } from "../../internals/floating-ui/hooks/use-floating-root-context"; import { type UseInteractionsReturn, useInteractions, } from "../../internals/floating-ui/hooks/use-interactions"; import { useListNavigation } from "../../internals/floating-ui/hooks/use-list-navigation"; import { useRole } from "../../internals/floating-ui/hooks/use-role"; import type { MutableRef } from "../../internals/floating-ui/types"; import { type MaybeAccessor, readOption } from "../../internals/maybe-accessor"; import { useOpenChangeComplete } from "../../internals/popups/use-open-change-complete"; import { useFieldControlValidation } from "../../internals/use-field-control-validation/use-field-control-validation"; import { useTransitionStatus } from "../../internals/use-transition-status/use-transition-status"; import { VISUALLY_HIDDEN_STYLE } from "../../internals/visually-hidden-style"; import { createAutocomplete } from "../create-autocomplete"; import { AutocompleteRootContext, type AutocompleteRootContextValue, } from "./autocomplete-root-context"; export type AutocompleteRootChangeEventReason = ComboboxRootChangeEventReason; export type AutocompleteRootChangeEventDetails = ComboboxRootChangeEventDetails; export type AutocompleteRootHighlightEventReason = ComboboxRootHighlightEventReason; export type AutocompleteRootHighlightEventDetails = ComboboxRootHighlightEventDetails; import type { AutocompleteMode } from "../create-autocomplete"; export type { AutocompleteMode }; export interface AutocompleteRootActions { /** Force-unmount the autocomplete popup immediately. */ unmount: () => void; } export interface AutocompleteRootProps { /** * Items to filter. Either a flat array or an array of groups with * `items`. When omitted, consumers render ``s * manually and the composite-list path takes over. */ items?: readonly Value[] | readonly Group[] | undefined; /** * Custom filter function. Pass `null` to disable filtering (show * everything). When omitted, uses `Intl.Collator`-backed * case/diacritics-insensitive "contains". */ filter?: | (( item: Value, query: string, itemToStringLabel?: (item: Value) => string, ) => boolean) | null | undefined; /** Locale for the default `Intl.Collator`. */ locale?: Intl.LocalesArgument | undefined; /** * Controlled value — the input text (autocomplete has no separate * selection; the value IS the input text). When provided (resolved * value not `undefined` — the empty string IS controlled), the consumer * owns the state — pair with {@link onValueChange}. When omitted, the * autocomplete manages its own value (seeded from {@link defaultValue}). */ value?: MaybeAccessor; /** * Initial value when uncontrolled. Read once at mount. * @default "" */ defaultValue?: MaybeAccessor; /** * Fires when the input value changes. Mirroring React Base UI's * `AutocompleteRoot`, the uncontrolled value is written BEFORE this * callback fires — `eventDetails.cancel()` does not roll it back * (unlike the `open` axis). */ onValueChange?: | ((value: string, details: AutocompleteRootChangeEventDetails) => void) | undefined; /** * Controlled open state. When provided (resolved value not * `undefined`), the consumer owns the state — pair with * {@link onOpenChange}. When omitted, the autocomplete manages its own * open state (seeded from {@link defaultOpen}). */ open?: MaybeAccessor; /** * Initial open state when uncontrolled. Read once at mount. * @default false */ defaultOpen?: MaybeAccessor; /** * Fires on every open/close request. Calling `eventDetails.cancel()` * keeps the autocomplete's own (uncontrolled) state unchanged, * mirroring React Base UI; controlled consumers simply don't update * their signal. */ onOpenChange?: | ((open: boolean, details: AutocompleteRootChangeEventDetails) => void) | undefined; /** Fires once the open/close transition has settled. */ onOpenChangeComplete?: ((open: boolean) => void) | undefined; onItemHighlighted?: | (( value: Value | undefined, details: AutocompleteRootHighlightEventDetails, ) => void) | undefined; /** * Behaviour with respect to filtering and inline autocompletion. * @default "list" */ mode?: AutocompleteMode | undefined; autoHighlight?: boolean | "always" | undefined; /** @default true */ highlightItemOnHover?: boolean | undefined; /** @default false — autocomplete defaults to opening on typing only. */ openOnInputClick?: boolean | undefined; /** @default true */ loopFocus?: boolean | undefined; /** @default false */ keepHighlight?: boolean | undefined; /** @default false */ modal?: boolean | undefined; disabled?: boolean | undefined; readOnly?: boolean | undefined; required?: boolean | undefined; itemToStringValue?: ((item: Value) => string) | undefined; isItemEqualToValue?: ((item: Value, value: Value) => boolean) | undefined; id?: string | undefined; actionsRef?: | ((actions: AutocompleteRootActions | null) => void) | { current: AutocompleteRootActions | null } | undefined; children?: JSX.Element; } // --------------------------------------------------------------------------- // Inner — assumes is in scope. // --------------------------------------------------------------------------- function AutocompleteRootComponent( props: AutocompleteRootProps, ): JSX.Element { // ------------------------------------------------------------------- // Normalised props // ------------------------------------------------------------------- const mode = (): AutocompleteMode => props.mode ?? "list"; const enableInline = (): boolean => { const m = mode(); return m === "inline" || m === "both"; }; const disabled = (): boolean => props.disabled ?? false; const readOnly = (): boolean => props.readOnly ?? false; const required = (): boolean => props.required ?? false; const modal = (): boolean => props.modal ?? false; const loopFocus = (): boolean => props.loopFocus ?? true; const openOnInputClick = (): boolean => props.openOnInputClick ?? false; const highlightItemOnHover = (): boolean => props.highlightItemOnHover ?? true; // Treat the itemToStringValue prop as the label source (matches React). const itemToStringLabel = (): ((item: Value) => string) | undefined => props.itemToStringValue; const itemToStringValue = (): ((item: Value) => string) | undefined => props.itemToStringValue; const isItemEqualToValue = (): ItemEqualityComparer => (props.isItemEqualToValue as ItemEqualityComparer) ?? (defaultItemEquality as ItemEqualityComparer); const autoHighlightMode = (): false | "input-change" | "always" => { const v = props.autoHighlight; if (v === "always") return "always"; return v ? "input-change" : false; }; // ------------------------------------------------------------------- // Controlled / uncontrolled signals // ------------------------------------------------------------------- // Autocomplete has NO separate selection — the input value is it. We // mirror React's shape by keeping a `selectedValue` signal that's // always `null` (unused), so downstream combobox parts that read it // (Clear, etc.) stay correct. const [selectedValueSignal, setSelectedValueSignal] = createSignal(null, { ownedWrite: true }); const selectedValue = (): Value | null => selectedValueSignal(); const setSelectedValue = (next: Value | null): void => { setSelectedValueSignal(() => next); }; // Inline display value — the temporary input value derived from the // currently-highlighted item when `enableInline`. When non-empty, this // takes precedence over the user's typed value for display purposes. const [inlineInputValue, setInlineInputValue] = createSignal("", { ownedWrite: true, }); // Controlled / uncontrolled typed value (Base UI `value` / `defaultValue` // contract — autocomplete's `value` IS the input text): the `value` prop // wins while its resolved value is defined ("" IS controlled); otherwise // an internal signal (seeded once from `defaultValue`, mirroring React's // `useState(defaultValue ?? '')`) backs the state. const [typedValue, setUncontrolledValue] = createControlledState({ controlled: () => readOption(props.value), default: () => readOption(props.defaultValue), fallback: "", name: "", state: "value", }); // Composed input value — inline display takes precedence when present. const inputValue = createMemo(() => { const inline = inlineInputValue(); if (enableInline() && inline !== "") return inline; return typedValue(); }); // When the controlled value updates externally, drop any transient // inline value to avoid stale display. React gates this on // `isControlled` — uncontrolled writes clear the inline value inside // `setInputValue` instead. createEffect( () => ({ v: readOption(props.value), controlled: readOption(props.value) !== undefined, }), ({ controlled }) => { if (controlled) setInlineInputValue(""); }, ); // Controlled / uncontrolled open state (Base UI `open` / `defaultOpen` // contract). const [open, setUncontrolledOpen] = createControlledState({ controlled: () => { const controlled = readOption(props.open); return controlled === undefined ? undefined : Boolean(controlled); }, default: () => readOption(props.defaultOpen), fallback: false, name: "", state: "open", }); const { mounted, transitionStatus, setMounted } = useTransitionStatus( open, () => true, () => false, ); // ------------------------------------------------------------------- // Identity / element slots // ------------------------------------------------------------------- const [labelId, setLabelId] = createSignal(undefined, { ownedWrite: true, }); // Base UI's React root auto-generates a stable `useId()` when the consumer // passes no `id`. Without that, item ids (`${root.id()}-${index}`) collapse // to the literal `"undefined-0"` and the input's `aria-activedescendant` // points at a nonexistent node — virtual focus / arrow-nav into the list // silently breaks (e.g. the gitcafe Command palette, which supplies no id). // Mirror that here with a `createUniqueId()` fallback, stable for the // lifetime of the root, only used when the consumer omits `id`. const fallbackId = createUniqueId(); const id = (): string | undefined => typeof props.id === "string" ? props.id : fallbackId; const [triggerElement, setTriggerElementInternal] = createSignal(null, { ownedWrite: true }); const [inputElement, setInputElementInternal] = createSignal(null, { ownedWrite: true }); const [listElement, setListElementInternal] = createSignal(null, { ownedWrite: true }); const [positionerElement, setPositionerElementInternal] = createSignal(null, { ownedWrite: true }); const [popupSide, setPopupSide] = createSignal(null, { ownedWrite: true, }); const listRef: MutableRef> = { current: [] }; const labelsRef: MutableRef> = { current: [] }; const popupRef: MutableRef = { current: null }; const inputRef: MutableRef = { current: null }; const emptyRef: MutableRef = { current: null }; const clearRef: MutableRef = { current: null }; const valuesRef: MutableRef = { current: [] }; const allValuesRef: MutableRef = { current: [] }; const chipsContainerRef: MutableRef = { current: null, }; const fieldControlRef: MutableRef = { current: null }; const keyboardActiveRef: MutableRef = { current: true }; const field = useFieldRootContext(); // ------------------------------------------------------------------- // Active / selected indices // ------------------------------------------------------------------- const [activeIndex, setActiveIndex] = createSignal(null, { ownedWrite: true, }); const [selectedIndex, setSelectedIndex] = createSignal(null, { ownedWrite: true, }); const lastHighlightRef: MutableRef<{ value: unknown; index: number }> = { current: INITIAL_LAST_HIGHLIGHT, }; // Imperative highlight write. Called from event handlers AND from // effect-apply scopes (`useListNavigation` onNavigate, post-close // handleUnmount). Wrap in `untrack` so the inline-mode reads // (`enableInline()`, `itemToStringLabel()`) don't trip // `[STRICT_READ_UNTRACKED]`. const setIndices = (options: { activeIndex?: number | null | undefined; selectedIndex?: number | null | undefined; type?: AutocompleteRootHighlightEventReason | undefined; }): void => untrack(() => { if (options.activeIndex !== undefined) { setActiveIndex(options.activeIndex); } if (options.selectedIndex !== undefined) { setSelectedIndex(options.selectedIndex); } const type: AutocompleteRootHighlightEventReason = options.type ?? "none"; if (options.activeIndex === undefined) return; if (options.activeIndex === null) { // Clear inline display when highlight is cleared. if (enableInline()) { setInlineInputValue(""); } if (lastHighlightRef.current !== INITIAL_LAST_HIGHLIGHT) { lastHighlightRef.current = INITIAL_LAST_HIGHLIGHT; props.onItemHighlighted?.( undefined, createGenericEventDetails(type, undefined, { index: -1, }) as AutocompleteRootHighlightEventDetails, ); } } else { const activeValue = valuesRef.current[options.activeIndex] as | Value | undefined; lastHighlightRef.current = { value: activeValue, index: options.activeIndex, }; // Inline-autocomplete: reflect the highlighted item's label in // the visible input — but only when highlight came from the // keyboard (pointer hover shouldn't overwrite typed text). if (enableInline() && type !== "pointer") { if (activeValue == null) { setInlineInputValue(""); } else { setInlineInputValue( stringifyAsLabel( activeValue, itemToStringLabel() as ((item: unknown) => string) | undefined, ), ); } } else if (!enableInline()) { setInlineInputValue(""); } props.onItemHighlighted?.( activeValue, createGenericEventDetails(type, undefined, { index: options.activeIndex, }) as AutocompleteRootHighlightEventDetails, ); } }); // ------------------------------------------------------------------- // Filtering — delegated to the createAutocomplete primitive. // The primitive owns items, isGrouped, filteredItems, flatFilteredItems, // and the query derivation. In "both" mode the primitive receives the // typed value (not the inline-overlay value) so the filter runs against // the actual user input, matching React's behaviour. // ------------------------------------------------------------------- const _primitive = createAutocomplete({ // In "both" mode filter against the typed value (not the inline overlay). value: () => (mode() === "both" ? typedValue() : inputValue()), open, items: () => props.items, filter: () => props.filter, locale: () => props.locale, mode, itemToStringLabel, isItemEqualToValue: () => (props.isItemEqualToValue as | ItemEqualityComparer | undefined) ?? (defaultItemEquality as ItemEqualityComparer), }); const items = _primitive.items; const isGrouped = _primitive.isGrouped; const query = _primitive.query; const filteredItems = _primitive.filteredItems; const flatFilteredItems = _primitive.flatFilteredItems; const flatItems = createMemo(() => { const list = items(); if (!list) return []; if (isGrouped()) { return (list as ReadonlyArray>).flatMap( (g) => g.items as Value[], ); } return list as readonly Value[]; }); // ------------------------------------------------------------------- // Static-children filtering (no `items` prop — the cmdk pattern) // ------------------------------------------------------------------- // When `items` is omitted the consumer renders `` // children directly, so there is no `flatFilteredItems` to drive // empty-state / per-item visibility. We filter the registered children // against the live query here. `mode: "inline" | "none"` are the static // modes (Base UI Autocomplete contract: items are never filtered), so // `matchesQuery` short-circuits to `true` there — every child stays // visible. Otherwise it uses the same default `Intl.Collator` "contains" // filter as items-mode (honoring a custom `filter` / `filter={null}`). const staticCollator = createMemo(() => getFilter({ locale: props.locale as Intl.LocalesArgument | undefined }), ); const matchesQuery = (value: Value): boolean => { const m = mode(); if (m === "inline" || m === "none") return true; const q = query().trim(); if (q === "") return true; const userFilter = props.filter; if (userFilter === null) return true; if (userFilter !== undefined) { return userFilter( value, q, itemToStringLabel() as ((item: Value) => string) | undefined, ); } return staticCollator().contains( value, q, itemToStringLabel() as ((item: unknown) => string) | undefined, ); }; const visibleItemTokens = new Set(); const [visibleItemTick, setVisibleItemTick] = createSignal(0, { ownedWrite: true, }); const setItemVisible = (token: symbol, visible: boolean): void => { const had = visibleItemTokens.has(token); if (visible === had) return; if (visible) visibleItemTokens.add(token); else visibleItemTokens.delete(token); setVisibleItemTick((t) => t + 1); }; const visibleItemCount = createMemo(() => { visibleItemTick(); return visibleItemTokens.size; }); // Keep `valuesRef` / `listRef` synced with the filtered flat list when // `items` is supplied. The apply-side `itemToStringLabel()` read is a // snapshot — `untrack` so it's silent when the effect re-runs as part // of a parent flush. createEffect( () => ({ hasItems: items() !== undefined, flat: flatFilteredItems() }), ({ hasItems, flat }) => untrack(() => { if (!hasItems) return; valuesRef.current = flat as Value[]; listRef.current.length = flat.length; labelsRef.current = flat.map((item) => stringifyAsLabel( item, itemToStringLabel() as ((item: unknown) => string) | undefined, ), ); }), ); // ------------------------------------------------------------------- // setOpen / setInputValue / handleSelection // ------------------------------------------------------------------- // May be invoked from inside an effect-apply scope (e.g. floating-ui's // onOpenChange / onNavigate). Reads here are snapshots — wrap in // `untrack` to silence `[STRICT_READ_UNTRACKED]`. // // `setOpen` mirrors React's `AriaCombobox.setOpen` change flow: fire // `onOpenChange` FIRST, honor `details.cancel()`, then write the // uncontrolled state (a no-op for reads while the `open` prop is // controlled). const setOpen = ( nextOpen: boolean, details: AutocompleteRootChangeEventDetails, ): void => untrack(() => { if (open() === nextOpen) return; props.onOpenChange?.(nextOpen, details); if (details.isCanceled) return; setUncontrolledOpen(nextOpen); }); // Public input-value write. Mirrors React's `AutocompleteRoot` // `handleValueChange` EXACTLY: clear any transient inline value, write // the uncontrolled value, THEN fire `onValueChange`. Note Base UI writes // the internal value before the callback on this axis — `cancel()` does // not roll the typed value back (unlike `setOpen`). const setInputValue = ( next: string, details: AutocompleteRootChangeEventDetails, ): void => untrack(() => { setInlineInputValue(""); setUncontrolledValue(next); props.onValueChange?.(next, details); }); // Sub-parts call this via root.setSelectedValue. For autocomplete, // "selecting" an item means filling the input with its label — routed // through `setInputValue` so the uncontrolled value updates too (React // routes this through `AriaCombobox.setInputValue` → `handleValueChange`). const setSelectedValueImpl = ( next: Value | Value[] | null, details: AutocompleteRootChangeEventDetails, ): void => untrack(() => { const scalar = Array.isArray(next) ? null : (next as Value | null); setSelectedValue(scalar); if ( details.reason === REASONS.itemPress || details.reason === REASONS.none || details.reason === REASONS.clearPress ) { const labelValue = stringifyAsLabel( scalar, itemToStringLabel() as ((item: unknown) => string) | undefined, ); setInputValue(labelValue, details); } }); const handleSelection = ( event: MouseEvent | PointerEvent | KeyboardEvent, passedValue?: Value, ): void => untrack(() => { let itemValue = passedValue; if (itemValue === undefined) { const ai = activeIndex(); if (ai === null) return; itemValue = valuesRef.current[ai] as Value; } const details = createChangeEventDetails( REASONS.itemPress, event, ) as AutocompleteRootChangeEventDetails; setSelectedValueImpl(itemValue as Value | null, details); setOpen(false, details); }); // ------------------------------------------------------------------- // Post-close // ------------------------------------------------------------------- // `handleUnmount` and the `onComplete` callback fire from // `useOpenChangeComplete`'s effect-apply scope. The `props.*` reads are // snapshots — wrap in `untrack` to silence // `[STRICT_READ_UNTRACKED]` on the reactive props proxy. const handleUnmount = (): void => untrack(() => { setMounted(false); setInlineInputValue(""); props.onOpenChangeComplete?.(false); setIndices({ activeIndex: null, selectedIndex: null }); }); useOpenChangeComplete({ open, ref: popupRef, onComplete: () => untrack(() => { if (!open()) handleUnmount(); else props.onOpenChangeComplete?.(true); }), }); // ------------------------------------------------------------------- // Floating-UI // ------------------------------------------------------------------- const floatingRootContext: FloatingRootStore = useFloatingRootContext({ open: untrack(open), onOpenChange: (nextOpen, details) => { setOpen(nextOpen, details as AutocompleteRootChangeEventDetails); }, }); createEffect( () => ({ openValue: open(), reference: inputElement() ?? triggerElement(), floating: positionerElement(), status: transitionStatus(), }), ({ openValue, reference, floating, status }) => untrack(() => { floatingRootContext.update({ open: openValue, referenceElement: reference, domReferenceElement: reference, floatingElement: floating, transitionStatus: status, }); }), ); const role = untrack(() => useRole(floatingRootContext, { role: "listbox" })); const click = untrack(() => useClick(floatingRootContext, { enabled: () => !readOnly() && !disabled() && openOnInputClick(), event: "mousedown-only", toggle: false, }), ); const dismiss = untrack(() => useDismiss(floatingRootContext, { enabled: () => !readOnly() && !disabled(), outsidePressEvent: { mouse: "sloppy", touch: "intentional", }, }), ); const listNavigation = untrack(() => useListNavigation(floatingRootContext, { enabled: () => !readOnly() && !disabled(), id, listRef, activeIndex, selectedIndex, virtual: true, loopFocus, allowEscape: () => loopFocus() && !autoHighlightMode(), focusItemOnOpen: () => (autoHighlightMode() ? ("auto" as const) : false), focusItemOnHover: highlightItemOnHover, resetOnPointerLeave: true, // onNavigate runs in an effect-apply scope inside `useListNavigation`. // Wrap in `untrack` to silence `[STRICT_READ_UNTRACKED]` on the // `open()` / `transitionStatus()` snapshot reads. onNavigate: (nextActiveIndex, event) => untrack(() => { if (!event && !open()) return; if (transitionStatus() === "ending") return; setIndices({ activeIndex: nextActiveIndex, type: event ? keyboardActiveRef.current ? "keyboard" : "pointer" : "none", }); }), }), ); const interactions: UseInteractionsReturn = untrack(() => useInteractions([role, click, dismiss, listNavigation]), ); // Auto-highlight on input change. Capture the `query()` and // `activeIndex()` reads in compute (snapshot) so the apply scope — // which is unowned/strict-read — doesn't trip // `[STRICT_READ_UNTRACKED]`. // // The navigable item count is mode-dependent: in items-mode it's the // filtered flat list (`flatFilteredItems().length`); in static-children // mode (the cmdk pattern, no `items` prop) `flatFilteredItems` is ALWAYS // empty, so we count the live registered+visible ``s // via `visibleItemCount()`. Using `flatFilteredItems` unconditionally here // was the static-children nav bug: with an empty flat list the effect // (a) short-circuited so `autoHighlight="always"` never highlighted item 0 // and (b) — because `activeIndex()` is a dependency — re-ran on every // keyboard navigation and reset the freshly-set `activeIndex` back to // `null`, killing arrow-key movement entirely. createEffect( () => { const hasItems = items() !== undefined; return { typed: typedValue(), ahm: autoHighlightMode(), count: hasItems ? flatFilteredItems().length : visibleItemCount(), hasQuery: query() !== "", activeIdx: activeIndex(), }; }, ({ ahm, count, hasQuery, activeIdx }) => untrack(() => { if (!ahm) return; if (count === 0) { setActiveIndex(null); return; } if (ahm === "always" || (ahm === "input-change" && hasQuery)) { if (activeIdx == null) setIndices({ activeIndex: 0, type: "none" }); } }), ); // ------------------------------------------------------------------- // Field-context integration // ------------------------------------------------------------------- const validation = useFieldControlValidation({ value: inputValue, id, controlRef: fieldControlRef, getValue: () => inputValue(), }); createEffect( () => inputElement(), (el) => { fieldControlRef.current = el as HTMLElement | null; }, ); let initialFieldValue = untrack(inputValue); createEffect( () => inputValue(), (v) => untrack(() => { if (!field.present) return; if (v === initialFieldValue) return; initialFieldValue = v; const initial = field.validityData().initialValue; field.setDirty(v !== initial); if (field.shouldValidateOnChange()) { void validation.commit(v); } else { void validation.commit(v, true); } }), ); createEffect( () => inputValue(), (v) => untrack(() => { if (!field.present) return; field.setFilled(v !== ""); }), ); // Imperative actions. const actions: AutocompleteRootActions = { unmount: () => setMounted(false), }; // `actionsRef` is a setup-time wiring contract (like React refs) — read // once, untracked, so it doesn't trip `[STRICT_READ_UNTRACKED]`. const ref = untrack(() => props.actionsRef); if (typeof ref === "function") { ref(actions); onCleanup(() => ref(null)); } else if (ref && typeof ref === "object") { ref.current = actions; onCleanup(() => { ref.current = null; }); } // ------------------------------------------------------------------- // Context (shape-compatible with Combobox so all combobox sub-parts // work via re-export). // ------------------------------------------------------------------- const contextValue: ComboboxRootContextValue = { id, labelId, setLabelId, open, mounted, transitionStatus, inputValue, setInputValue, selectedValue, setSelectedValue: setSelectedValueImpl, selectionMode: () => "none" as const, activeIndex, selectedIndex, setIndices, disabled, readOnly, required, autoHighlightMode, highlightItemOnHover, modal, loopFocus, openOnInputClick, // Autocomplete doesn't support grid/virtualized; provide inert accessors // for context-shape parity with combobox. grid: () => false, virtualized: () => false, itemToStringLabel, itemToStringValue, isItemEqualToValue, items, filteredItems, flatFilteredItems, query, matchesQuery, setItemVisible, visibleItemCount, listRef, labelsRef, popupRef, inputRef, emptyRef, clearRef, valuesRef, allValuesRef, chipsContainerRef, triggerElement, setTriggerElement: setTriggerElementInternal, inputElement, setInputElement: (el) => { inputRef.current = el; setInputElementInternal(el); }, listElement, setListElement: setListElementInternal, positionerElement, setPositionerElement: setPositionerElementInternal, popupSide, setPopupSide, setOpen, handleSelection, forceMount: () => {}, floatingRootContext, interactions, keyboardActiveRef, }; // Sync selectedIndex when `selectedValue` changes (unused in // autocomplete but kept for context-shape parity with combobox). createEffect( () => ({ sv: selectedValue(), list: flatItems() }), ({ sv, list }) => untrack(() => { if (sv == null) { setSelectedIndex(null); return; } const registry = items() !== undefined ? list : allValuesRef.current; const idx = findItemIndex( registry, sv, isItemEqualToValue() as ItemEqualityComparer, ); setSelectedIndex(idx === -1 ? null : idx); }), ); const autocompleteContextValue: AutocompleteRootContextValue = { mode, }; // ------------------------------------------------------------------- // Browser-autofill hidden control input // ------------------------------------------------------------------- // Mirrors React's `AriaCombobox` hidden text input: a visually-hidden, // tab-skipped, `aria-hidden` `` that receives native // browser-autofill events. When the browser writes a value into it via // autofill, we forward the value through `setInputValue` so the // autocomplete's root state (and the visible input) update in lockstep. // Focus delegation hands focus back to the visible input if an assistive // technology routes into the hidden control. const setHiddenInputRef = (el: HTMLInputElement | null): void => { validation.inputRef.current = el; }; function handleHiddenAutofill(event: Event): void { // Autocomplete's selection model is `'none'` — the input value IS the // autocomplete value. Mirror React's autofill branch: treat the // autofilled value as a user-typed write and commit through the same // pipeline. const target = event.currentTarget as HTMLInputElement | null; if (!target) return; const nextValue = target.value; const details = createChangeEventDetails( REASONS.none, event, ) as AutocompleteRootChangeEventDetails; setInputValue(nextValue, details); } function handleHiddenFocus(): void { const visible = inputElement() ?? triggerElement(); (visible as HTMLElement | null)?.focus(); } // Fresh mutable copy — the base Solid style binding mutates its `prev` // object when reconciling, which would throw on a frozen / shared source. const hiddenStyle = (): JSX.CSSProperties => ({ ...VISUALLY_HIDDEN_STYLE }); return ( } > {props.children} {/* biome-ignore lint/a11y/noAriaHiddenOnFocusable: tabindex={-1} is intentional — browsers focus this input during autofill, which we detect via onFocus/onInput. aria-hidden hides it from screen readers without blocking that native browser behavior. */} ); } /** * Groups all parts of the autocomplete. Doesn't render its own HTML element. */ export function AutocompleteRoot( props: AutocompleteRootProps, ): JSX.Element { if ( useComboboxRootContext(true) != null || useFloatingParentNodeId() != null ) { return {...props} />; } return ( {...props} /> ); }