import type { JSX } from "@solidjs/web"; import { type Accessor, createContext, createMemo, runWithOwner, useContext, } from "solid-js"; import { createCollatorItemFilter, type Filter, getFilter, } from "../combobox/internals/filter"; import { type Group, isGroupedItems, stringifyAsLabel, } from "../combobox/internals/resolve-value-label"; import { type MaybeAccessor, readOption } from "../internals/maybe-accessor"; import { SNAPSHOT_KEYS, type Snapshottable, snapshotKeys, } from "../internals/observability/snapshot"; // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- export type AutocompleteMode = "list" | "both" | "inline" | "none"; export type AutocompleteChangeReason = | "item-press" | "input-change" | "escape-key" | "outside-press" | "imperative"; export interface CreateAutocompleteOptions { /** * Input value (the text in the autocomplete input). Consumer-owned signal. * Writes happen via `onValueChange` — the primitive does NOT keep an internal * copy. */ value: MaybeAccessor; onValueChange?: (value: string, reason: AutocompleteChangeReason) => void; /** Open state. Consumer-owned signal. */ open: MaybeAccessor; onOpenChange?: (open: boolean, reason: AutocompleteChangeReason) => void; /** Items to filter. Flat array or grouped (`{ items: [...] }`). */ items?: MaybeAccessor[] | undefined>; /** * Filter function. Pass `null` to disable filtering. Defaults to an * `Intl.Collator`-backed "contains" filter. */ filter?: | MaybeAccessor< | (( item: Value, query: string, itemToStringLabel?: (item: Value) => string, ) => boolean) | null | undefined > | undefined; /** Locale for the default `Intl.Collator`. */ locale?: MaybeAccessor; /** * Filtering and inline-autocomplete mode. * @default "list" */ mode?: MaybeAccessor; disabled?: MaybeAccessor; readOnly?: MaybeAccessor; /** Converts object items to a display string. */ itemToStringLabel?: MaybeAccessor<((item: Value) => string) | undefined>; /** Custom item equality. Defaults to `Object.is`. */ isItemEqualToValue?: MaybeAccessor< ((item: Value, value: Value) => boolean) | undefined >; } export interface AutocompleteApi { /** Reactive accessor for the current input value. */ value: Accessor; /** Reactive accessor for the open state. */ open: Accessor; /** Current mode. */ mode: Accessor; disabled: Accessor; readOnly: Accessor; /** All items (before filtering). */ items: Accessor[] | undefined>; /** Items after applying the filter against the current query. */ filteredItems: Accessor[]>; /** Flat version of `filteredItems` (groups are unwrapped). */ flatFilteredItems: Accessor; /** Trimmed query string used for filtering. */ query: Accessor; /** Whether the items list is grouped. */ isGrouped: Accessor; /** Label accessor for rendering item text. */ itemLabel: (item: Value) => string; /** Pure event-emitter — fire `onValueChange`. Consumer owns the signal write. */ setValue: (next: string, reason?: AutocompleteChangeReason) => void; /** Pure event-emitter — fire `onOpenChange`. */ setOpen: (next: boolean, reason?: AutocompleteChangeReason) => void; /** * Context provider. Wrap a subtree to make the api discoverable via * `useAutocomplete()` without prop-drilling. */ Provider: (props: { children: JSX.Element }) => JSX.Element; } // --------------------------------------------------------------------------- // Context // --------------------------------------------------------------------------- const AutocompleteCtx = createContext | null>(null); export function useAutocomplete(): AutocompleteApi { const ctx = useContext(AutocompleteCtx); if (ctx == null) { throw new Error( "Base UI: useAutocomplete() must be called inside a .", ); } return ctx as AutocompleteApi; } // --------------------------------------------------------------------------- // Primitive // --------------------------------------------------------------------------- /** * Solid 2-native headless primitive for autocomplete selection + filtering * logic. Autocomplete is the "input value is the source of truth" variant: * there is no separate selected-value state — the input text IS the value. * Owns: value / open state, items / filteredItems / query derivation, and * item label helpers. DOM concerns (floating-ui, input element, keyboard nav, * portal) live in the structural `` wrapper. */ export function createAutocomplete( options: CreateAutocompleteOptions, ): AutocompleteApi { const mode = createMemo( () => (readOption(options.mode) ?? "list") as AutocompleteMode, { name: "autocomplete.mode" }, ); const disabled = createMemo( () => Boolean(readOption(options.disabled)), { name: "autocomplete.disabled" }, ); const readOnly = createMemo( () => Boolean(readOption(options.readOnly)), { name: "autocomplete.readOnly" }, ); const itemToStringLabel = createMemo<((item: Value) => string) | undefined>( () => readOption(options.itemToStringLabel), { name: "autocomplete.itemToStringLabel" }, ); // Value — reads through the consumer's accessor (defaults to ""). const value = createMemo(() => readOption(options.value) ?? "", { name: "autocomplete.value", }); // Open — reads through the consumer's accessor. const open = createMemo(() => Boolean(readOption(options.open)), { name: "autocomplete.open", }); // Items const items = createMemo< readonly Value[] | readonly Group[] | undefined >(() => readOption(options.items), { name: "autocomplete.items" }); const isGrouped = createMemo( () => isGroupedItems(items() as unknown[]), { name: "autocomplete.isGrouped" }, ); // In "both" mode we filter against the typed value (not any inline-autocomplete // overlay). The query is the trimmed text. const query = createMemo(() => value().trim(), { name: "autocomplete.query", }); // Filtering — "inline" and "none" modes show all items unfiltered. const isStaticItems = createMemo(() => { const m = mode(); return m === "inline" || m === "none"; }); const collatorFilter = createMemo( () => getFilter({ locale: readOption(options.locale) }), { name: "autocomplete.collatorFilter" }, ); const resolvedFilter = createMemo< ( item: Value, query: string, itemToStringLabel?: (item: Value) => string, ) => boolean >( () => { if (isStaticItems()) return () => true; const userFilter = readOption(options.filter) as | ((item: Value, q: string) => boolean) | null | undefined; if (userFilter === null) return () => true; if (userFilter !== undefined) return userFilter; return createCollatorItemFilter( collatorFilter(), itemToStringLabel() as ((item: unknown) => string) | undefined, ) as (item: Value, q: string) => boolean; }, { name: "autocomplete.resolvedFilter" }, ); const filteredItems = createMemo[]>( () => { const list = items(); if (!list) return []; const filt = resolvedFilter(); const q = query(); const label = itemToStringLabel(); if (isGrouped()) { const groups = list as ReadonlyArray>; const out: Group[] = []; for (const g of groups) { const candidates = q === "" ? g.items : g.items.filter((item) => filt(item, q, label)); if (candidates.length > 0) { out.push({ ...g, items: candidates }); } } return out; } if (q === "") return list as readonly Value[]; return (list as readonly Value[]).filter((item) => filt(item, q, label)); }, { name: "autocomplete.filteredItems" }, ); const flatFilteredItems = createMemo( () => { const fi = filteredItems(); if (isGrouped()) { return (fi as ReadonlyArray>).flatMap( (g) => g.items as Value[], ); } return fi as readonly Value[]; }, { name: "autocomplete.flatFilteredItems" }, ); const itemLabel = (item: Value): string => stringifyAsLabel( item, itemToStringLabel() as ((item: unknown) => string) | undefined, ); // Pure event-emitters — runWithOwner(null, ...) escapes the reactive owner // so signal writes inside the callback don't trigger SIGNAL_WRITE_IN_OWNED_SCOPE. const setValue = ( next: string, reason: AutocompleteChangeReason = "imperative", ): void => { runWithOwner(null, () => options.onValueChange?.(next, reason)); }; const setOpen = ( next: boolean, reason: AutocompleteChangeReason = "imperative", ): void => { runWithOwner(null, () => options.onOpenChange?.(next, reason)); }; const api: AutocompleteApi = { value, open, mode, disabled, readOnly, items, filteredItems, flatFilteredItems, query, isGrouped, itemLabel, setValue, setOpen, Provider: (props) => ( }> {props.children} ), }; (api as AutocompleteApi & Snapshottable)[SNAPSHOT_KEYS] = AUTOCOMPLETE_SNAPSHOT_KEYS; return api; } const AUTOCOMPLETE_SNAPSHOT_KEYS = snapshotKeys( "value", "open", "mode", "disabled", "readOnly", "query", );