import type { JSX } from "@solidjs/web"; import { type Accessor, createContext, createMemo, runWithOwner, untrack, useContext, } from "solid-js"; import { type MaybeAccessor, readOption } from "../internals/maybe-accessor"; import { SNAPSHOT_KEYS, type Snapshottable, snapshotKeys, } from "../internals/observability/snapshot"; import { createCollatorItemFilter, createSingleSelectionCollatorFilter, type Filter, getFilter, } from "./internals/filter"; import { defaultItemEquality, type ItemEqualityComparer, selectedValueIncludes, } from "./internals/item-equality"; import { type Group, isGroupedItems, stringifyAsLabel, } from "./internals/resolve-value-label"; // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- export type ComboboxSelectionMode = "none" | "single" | "multiple"; export type ComboboxChangeReason = | "item-press" | "clear-press" | "input-change" | "escape-key" | "outside-press" | "imperative"; export interface CreateComboboxOptions { /** * Current selected value. Consumer-owned signal. Writes happen via * `onValueChange` — the primitive does NOT keep an internal copy. */ value: MaybeAccessor; onValueChange?: ( value: Value | Value[] | null, reason: ComboboxChangeReason, ) => void; /** * Current input text. Consumer-owned signal. When omitted, a computed * default derives the display text from the selected value's label in * single-select mode. */ inputValue?: MaybeAccessor; onInputValueChange?: (value: string, reason: ComboboxChangeReason) => void; /** Open state. Consumer-owned signal. */ open: MaybeAccessor; onOpenChange?: (open: boolean, reason: ComboboxChangeReason) => 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; /** Selection mode. @default "single" */ selectionMode?: MaybeAccessor; /** `true` is shorthand for `selectionMode: "multiple"`. */ multiple?: MaybeAccessor; disabled?: MaybeAccessor; readOnly?: MaybeAccessor; /** * Whether the user has typed anything since the popup last opened. When * `false` in single-select mode, opening with a committed value shows the * full list (filtering is bypassed) instead of just the matching item. * @default false */ queryChangedAfterOpen?: MaybeAccessor; /** Converts object items to a display string. */ itemToStringLabel?: MaybeAccessor<((item: Value) => string) | undefined>; /** Converts object items to a form-submission string. */ itemToStringValue?: MaybeAccessor<((item: Value) => string) | undefined>; /** Custom item equality. Defaults to `Object.is`. */ isItemEqualToValue?: MaybeAccessor< ((item: Value, value: Value) => boolean) | undefined >; } export interface ComboboxApi { /** Reactive accessor for the current selected value. */ value: Accessor; /** Reactive accessor for the current input text. */ inputValue: Accessor; /** Reactive accessor for the open state. */ open: Accessor; /** Current selection mode. */ selectionMode: 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; /** Check whether a given item value is currently selected. */ isItemSelected: (item: Value) => boolean; /** Pure event-emitter — fire `onValueChange`. Consumer owns the signal write. */ setValue: ( next: Value | Value[] | null, reason?: ComboboxChangeReason, ) => void; /** Pure event-emitter — fire `onInputValueChange`. */ setInputValue: (next: string, reason?: ComboboxChangeReason) => void; /** Pure event-emitter — fire `onOpenChange`. */ setOpen: (next: boolean, reason?: ComboboxChangeReason) => void; /** * Context provider. Wrap a subtree to make the api discoverable via * `useCombobox()` without prop-drilling. */ Provider: (props: { children: JSX.Element }) => JSX.Element; } // --------------------------------------------------------------------------- // Context // --------------------------------------------------------------------------- const ComboboxContext = createContext | null>(null); export function useCombobox(): ComboboxApi { const ctx = useContext(ComboboxContext); if (ctx == null) { throw new Error( "Base UI: useCombobox() must be called inside a .", ); } return ctx as ComboboxApi; } // --------------------------------------------------------------------------- // Primitive // --------------------------------------------------------------------------- /** * Solid 2-native headless primitive for the combobox selection + filtering * logic. Owns: value / inputValue / open state, items / filteredItems / * query derivation, and `isItemSelected`. DOM concerns (floating-ui * positioning, input element, keyboard nav, portal) live in the structural * `` wrapper that composes this primitive. */ export function createCombobox( options: CreateComboboxOptions, ): ComboboxApi { // Freeze multi-select at init — the scalar vs. array value shape cannot // flip after mount (same pattern as the structural wrapper). const isMultipleAtInit = untrack( () => readOption(options.multiple) === true || readOption(options.selectionMode) === "multiple", ); const selectionMode = createMemo( () => { if (readOption(options.multiple) === true) return "multiple"; return (readOption(options.selectionMode) ?? "single") as ComboboxSelectionMode; }, { name: "combobox.selectionMode" }, ); const isSingle = createMemo(() => selectionMode() === "single", { name: "combobox.isSingle", }); const disabled = createMemo( () => Boolean(readOption(options.disabled)), { name: "combobox.disabled" }, ); const readOnly = createMemo( () => Boolean(readOption(options.readOnly)), { name: "combobox.readOnly" }, ); const itemToStringLabel = createMemo<((item: Value) => string) | undefined>( () => readOption(options.itemToStringLabel), { name: "combobox.itemToStringLabel" }, ); const isItemEqualToValue = createMemo>( () => (readOption(options.isItemEqualToValue) as | ItemEqualityComparer | undefined) ?? (defaultItemEquality as ItemEqualityComparer), { name: "combobox.isItemEqualToValue" }, ); // Value — reads through the consumer's accessor. const emptyArray: Value[] = []; const value = createMemo( () => { const v = readOption(options.value); if (v === undefined) return isMultipleAtInit ? (emptyArray as Value[]) : null; return v ?? null; }, { name: "combobox.value" }, ); // inputValue — reads through the consumer's accessor if provided; otherwise // derives from the selected value label in single mode (computed default, // NOT internal state). const inputValue = createMemo( () => { const iv = readOption(options.inputValue); if (iv !== undefined) return iv; if (isSingle()) { const sv = readOption(options.value); if (sv === undefined || sv === null) return ""; if (Array.isArray(sv)) return ""; return stringifyAsLabel( sv as Value, itemToStringLabel() as ((item: unknown) => string) | undefined, ); } return ""; }, { name: "combobox.inputValue" }, ); // Open — reads through the consumer's accessor. const open = createMemo(() => Boolean(readOption(options.open)), { name: "combobox.open", }); // Items const items = createMemo< readonly Value[] | readonly Group[] | undefined >(() => readOption(options.items), { name: "combobox.items" }); const isGrouped = createMemo( () => isGroupedItems(items() as unknown[]), { name: "combobox.isGrouped" }, ); const query = createMemo(() => inputValue().trim(), { name: "combobox.query", }); // Filtering const collatorFilter = createMemo( () => getFilter({ locale: readOption(options.locale) }), { name: "combobox.collatorFilter" }, ); const resolvedFilter = createMemo< ( item: Value, query: string, itemToStringLabel?: (item: Value) => string, ) => boolean >( () => { const userFilter = readOption(options.filter) as | ((item: Value, q: string) => boolean) | null | undefined; if (userFilter === null) return () => true; if (userFilter !== undefined) return userFilter; const coll = collatorFilter(); if (isSingle() && !readOption(options.queryChangedAfterOpen)) { // Read `value()` reactively so the filter re-derives when the // single selection changes (mirrors React's `selectedValue` dep). const v = value(); return createSingleSelectionCollatorFilter( coll, itemToStringLabel() as ((item: unknown) => string) | undefined, Array.isArray(v) ? null : v, ) as (item: Value, q: string) => boolean; } return createCollatorItemFilter( coll, itemToStringLabel() as ((item: unknown) => string) | undefined, ) as (item: Value, q: string) => boolean; }, { name: "combobox.resolvedFilter" }, ); // Mirrors React's `shouldBypassFiltering` + `filterQuery`: in single-select // mode, when the user hasn't typed since opening and the query exactly equals // the selected label, blank the effective query so the full list is shown. const filterQuery = createMemo( () => { const q = query(); if (q === "") return q; if (!isSingle() || readOption(options.queryChangedAfterOpen)) return q; const v = value(); const selectedLabelString = v == null || Array.isArray(v) ? "" : stringifyAsLabel( v as Value, itemToStringLabel() as ((item: unknown) => string) | undefined, ); const shouldBypassFiltering = selectedLabelString !== "" && selectedLabelString.length === q.length && collatorFilter().contains(selectedLabelString, q); return shouldBypassFiltering ? "" : q; }, { name: "combobox.filterQuery" }, ); const filteredItems = createMemo[]>( () => { const list = items(); if (!list) return []; const filt = resolvedFilter(); const q = filterQuery(); 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: "combobox.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: "combobox.flatFilteredItems" }, ); // isItemSelected — pure derivation, no reactive subscription needed. const isItemSelected = (item: Value): boolean => { const sv = value(); if (sv == null) return false; if (Array.isArray(sv)) { return selectedValueIncludes( sv as Value[], item, isItemEqualToValue() as ItemEqualityComparer, ); } return isItemEqualToValue()(item, sv as Value); }; // Pure event-emitters — fire onXxxChange; the consumer is the only writer // of their signal. runWithOwner(null, ...) escapes the reactive owner so // signal writes inside the callback don't trigger SIGNAL_WRITE_IN_OWNED_SCOPE. const setValue = ( next: Value | Value[] | null, reason: ComboboxChangeReason = "imperative", ): void => { runWithOwner(null, () => options.onValueChange?.(next, reason)); }; const setInputValue = ( next: string, reason: ComboboxChangeReason = "imperative", ): void => { runWithOwner(null, () => options.onInputValueChange?.(next, reason)); }; const setOpen = ( next: boolean, reason: ComboboxChangeReason = "imperative", ): void => { runWithOwner(null, () => options.onOpenChange?.(next, reason)); }; const api: ComboboxApi = { value, inputValue, open, selectionMode, disabled, readOnly, items, filteredItems, flatFilteredItems, query, isGrouped, isItemSelected, setValue, setInputValue, setOpen, Provider: (props) => ( }> {props.children} ), }; (api as ComboboxApi & Snapshottable)[SNAPSHOT_KEYS] = COMBOBOX_SNAPSHOT_KEYS; return api; } const COMBOBOX_SNAPSHOT_KEYS = snapshotKeys( "value", "inputValue", "open", "selectionMode", "disabled", "readOnly", "query", );