import type { JSX } from "@solidjs/web"; /** * `` — Solid 2 port of Base UI React's `ComboboxRoot` + * `AriaCombobox` (~1600 LOC combined). The port scope is narrowed to the * single-select, list-autocomplete path that matches ~95% of combobox * usage; the deferred features are flagged in `remaining-work.md`. * * ### Shipped scope * * - Single-select + `selectionMode: "none"` (free-text autocomplete) * - Controlled + uncontrolled `value` / `inputValue` / `open` * - Synchronous client-side filtering via `items` + `Intl.Collator` * - Consumer-supplied `filter` function override * - `autoHighlight` ("input-change" / "always" modes) * - `loopFocus`, `openOnInputClick`, `highlightItemOnHover`, `modal` * - Keyboard navigation via `useListNavigation` (virtual focus, * `aria-activedescendant`) * - `useTypeahead` integration on the list * - `useDismiss` (Escape + outside-press) and `useClick` * - `onOpenChange`, `onInputValueChange`, `onValueChange`, * `onItemHighlighted` callbacks with `BaseUIChangeEventDetails` * - `onOpenChangeComplete` + `useTransitionStatus` exit-animation deferral * - `` + `` + `` + * `` + `` + `` + * `` + `` + `` + * `` + `` + `` * * ### Deferred * * - **Multi-select mode** + `chips` / `chip` / `chip-remove` / `row` / * `input-group` / `collection` / `backdrop` / `value` / `label` / * `status` / `icon` sub-parts * - **Grid navigation** (`grid={true}` + `` + `cols`/`orientation` on list-nav) — landed. * - **Virtualized lists** (`virtualized={true}`) — stable-index override landed. * - **Input-inside-popup layout** (the "select-shaped" combobox where the * input is mounted inside the popup + `` is the * anchor) * - **Form integration** — full field-context write-through * (`setDirty` / `setFilled` / `validation.commit`) is a no-op here; * field registration is wired but validation does not flow. * - **`fillInputOnItemPress` / `submitOnItemClick`** — landed. * - **`inline` mode** (no popup — inline autocomplete within the input) * - **IME / composition handling** — the basic `change` handler is wired * but the full Android/IME-composition path is not ported. * - **Live-region announcement** (`` renders the * `role="status"` container but without the "initial text mutation" hack) * - **`closeQuery` / `queryChangedAfterOpen` machinery** — landed. * - **`allValuesRef` stable index tracking** — simplified: items * registered via `` only. * * Positioning math (happy-dom returns 0 rects) and full pointer flows * (focus restoration ordering, iOS touch-open delay) need real browser * coverage. */ import { createEffect, createMemo, createSignal, createUniqueId, onCleanup, untrack, } from "solid-js"; 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 { createCombobox } from "../create-combobox"; import { INITIAL_LAST_HIGHLIGHT } from "../internals/constants"; import { type Filter, getFilter } from "../internals/filter"; import { defaultItemEquality, findItemIndex, type ItemEqualityComparer, removeItem, selectedValueIncludes, } from "../internals/item-equality"; import { type Group, stringifyAsLabel, stringifyAsValue, } from "../internals/resolve-value-label"; import type { ComboboxSide } from "../positioner/positioner-context"; import { type ComboboxRootChangeEventDetails, type ComboboxRootChangeEventReason, ComboboxRootContext, type ComboboxRootContextValue, type ComboboxRootHighlightEventDetails, type ComboboxRootHighlightEventReason, useComboboxRootContext, } from "./combobox-root-context"; export type { ComboboxRootChangeEventDetails, ComboboxRootChangeEventReason, ComboboxRootHighlightEventDetails, ComboboxRootHighlightEventReason, }; import type { ComboboxSelectionMode } from "../create-combobox"; export type { ComboboxSelectionMode }; export interface ComboboxRootActions { /** Force-unmount the combobox popup immediately. */ unmount: () => void; } export interface ComboboxRootProps { /** * Items to filter. When provided, `` filters them * against the current input value using the active filter. When * omitted, consumers must render `` children manually * — typeahead still works via labels registered through the * composite list. */ items?: readonly Value[] | readonly Group[] | undefined; /** * Custom filter function. Receives `(item, query, itemToStringLabel)` * and returns a boolean. Pass `null` to render every item * unfiltered. When omitted, uses an `Intl.Collator`-backed * case/diacritics-insensitive "contains" filter. */ filter?: | (( item: Value, query: string, itemToStringLabel?: (item: Value) => string, ) => boolean) | null | undefined; /** Locale for the default `Intl.Collator`. */ locale?: Intl.LocalesArgument | undefined; /** * Controlled selected value. When provided (resolved value not * `undefined` — `null` IS controlled, meaning "cleared"), the consumer * owns the state — pair with {@link onValueChange}. When omitted, the * combobox manages its own selected value (seeded from * {@link defaultValue}). In multi-select mode pass an array. */ value?: MaybeAccessor; /** * Initial selected value when uncontrolled. Read once at mount. * @default null (normalized to `[]` in multi-select mode) */ defaultValue?: MaybeAccessor; /** * Fires on every selection request. Calling `eventDetails.cancel()` * keeps the combobox's own (uncontrolled) value unchanged, mirroring * React Base UI; controlled consumers simply don't update their signal. */ onValueChange?: | (( value: Value | Value[] | null, details: ComboboxRootChangeEventDetails, ) => void) | undefined; /** * When `true`, the combobox accepts multiple selections (chips). Pairs * with an array-valued `value` / `defaultValue`. When omitted or `false` * the combobox is single-select. * @default false */ multiple?: boolean | undefined; /** * Identifies the field when submitted in a form. Threaded into the * hidden input(s) generated for multi-select mode. */ name?: string | undefined; /** Identifies the owning form for the hidden submission inputs. */ form?: string | undefined; /** * Controlled input value. When provided (resolved value not * `undefined` — the empty string IS controlled), the consumer owns the * state — pair with {@link onInputValueChange}. When omitted, the * combobox manages its own input value (seeded from * {@link defaultInputValue}, else — in single-select mode — from the * initial selected value's label, mirroring React Base UI). */ inputValue?: MaybeAccessor; /** * Initial input value when uncontrolled. Read once at mount. * @default "" (single-select: the initial selected value's label) */ defaultInputValue?: MaybeAccessor; /** * Fires on every input-value change request. Calling * `eventDetails.cancel()` keeps the combobox's own (uncontrolled) input * value unchanged, mirroring React Base UI; controlled consumers simply * don't update their signal. */ onInputValueChange?: | ((value: string, details: ComboboxRootChangeEventDetails) => void) | undefined; /** * Controlled open state. When provided (resolved value not * `undefined`), the consumer owns the state — pair with * {@link onOpenChange}. When omitted, the combobox 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 combobox's own (uncontrolled) state unchanged, mirroring * React Base UI; controlled consumers simply don't update their signal. */ onOpenChange?: | ((open: boolean, details: ComboboxRootChangeEventDetails) => void) | undefined; /** Fires once the open/close transition has settled. */ onOpenChangeComplete?: ((open: boolean) => void) | undefined; /** Fires when an item gets / loses highlight. */ onItemHighlighted?: | (( value: Value | undefined, details: ComboboxRootHighlightEventDetails, ) => void) | undefined; /** * Selection mode. `"single"` (default) = single selection; `"none"` = * free-text autocomplete (the input value is the source of truth, * items just drive the dropdown). */ selectionMode?: ComboboxSelectionMode | undefined; /** When `true`, the first matching item is highlighted on filter. */ autoHighlight?: boolean | "always" | undefined; /** Whether pointer-over items highlights them. @default true */ highlightItemOnHover?: boolean | undefined; /** Whether arrow-key navigation loops at list ends. @default true */ loopFocus?: boolean | undefined; /** Whether clicking the input opens the popup. @default true */ openOnInputClick?: boolean | undefined; /** Whether the combobox is disabled. */ disabled?: boolean | undefined; /** Whether the combobox is read-only. */ readOnly?: boolean | undefined; /** Whether the combobox is required. */ required?: boolean | undefined; /** Whether the popup is modal. @default false */ modal?: boolean | undefined; /** * Whether list items are presented in a grid layout. * * **Solid port scope:** accepted at the prop boundary and * threaded through the root context + input's `aria-haspopup="grid"`. * Grid-specific keyboard navigation (multi-column traversal via * `` + `cols > 1` in `useListNavigation`) is not yet * wired — the list still navigates as a flat 1-D listbox. `` * is not ported. * @default false */ grid?: boolean | undefined; /** * Whether the items are externally virtualized (e.g. TanStack Virtual). * When `true`, the list indices are consumer-managed and item * registrations are kept flat even as DOM elements come and go. * * **Solid port scope:** accepted at the prop boundary and * threaded through the root context. The React `` has * virtualization-aware index tracking that avoids stale refs; the * Solid port's `` observes DOM mutations for reordering * but does not implement the virtualized "stable index override" path. * Consumers should pass an explicit `index` prop on each * `` until that lands. * @default false */ virtualized?: boolean | undefined; /** * When `selectionMode: "none"` and `submitOnItemClick` is `true`, clicking * an item submits the closest enclosing form via `form.requestSubmit()`. * @default false */ submitOnItemClick?: boolean | undefined; /** * When `true` (default) and `selectionMode: "single"` or `"none"`, pressing * an item writes its label into the input. Set to `false` to leave the * input value untouched on selection. * @default true */ fillInputOnItemPress?: boolean | undefined; /** Converts object item values to a string label for display / typeahead. */ itemToStringLabel?: ((item: Value) => string) | undefined; /** Converts object item values to a string for form submission. */ itemToStringValue?: ((item: Value) => string) | undefined; /** Custom item equality. */ isItemEqualToValue?: ((item: Value, value: Value) => boolean) | undefined; /** Optional id (flows to the input's aria-labelledby hook). */ id?: string | undefined; /** Imperative handle slot. */ actionsRef?: | ((actions: ComboboxRootActions | null) => void) | { current: ComboboxRootActions | null } | undefined; children?: JSX.Element; } // ----------------------------------------------------------------------- // Inner implementation — assumes `` is in scope. // ----------------------------------------------------------------------- function ComboboxRootComponent( props: ComboboxRootProps, ): JSX.Element { // ------------------------------------------------------------------- // Normalised props // ------------------------------------------------------------------- // `multiple` wins when explicitly set; otherwise fall back to // `selectionMode`. This mirrors React's `ComboboxRoot` wrapper which // converts `multiple={true}` → `selectionMode="multiple"` and leaves // `selectionMode` in place otherwise. const selectionMode = (): ComboboxSelectionMode => { if (props.multiple === true) return "multiple"; return props.selectionMode ?? "single"; }; const single = (): boolean => selectionMode() === "single"; const multiple = (): boolean => selectionMode() === "multiple"; const field = useFieldRootContext(); const fieldName = (): string | undefined => field.name() ?? props.name; const disabled = (): boolean => field.disabled() || (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 ?? true; const highlightItemOnHover = (): boolean => props.highlightItemOnHover ?? true; const grid = (): boolean => props.grid ?? false; const virtualized = (): boolean => props.virtualized ?? false; const submitOnItemClick = (): boolean => props.submitOnItemClick ?? false; const fillInputOnItemPress = (): boolean => props.fillInputOnItemPress ?? true; const itemToStringLabel = (): ((item: Value) => string) | undefined => props.itemToStringLabel; 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 state (Base UI `useControlled` contract, // applied to all THREE axes like React's `AriaCombobox`): // - `value` is controlled while its resolved value is not `undefined` — // `value={null}` IS controlled. Uncontrolled fallback is `null` // (`[]` in multi-select, matching React's // `multiple ? defaultSelectedValue ?? EMPTY_ARRAY : defaultSelectedValue`). // - `inputValue` is controlled while not `undefined` ("" IS controlled). // The uncontrolled seed mirrors React's `initialDefaultInputValue`: // `defaultInputValue`, else the initial selected value's label in // single mode, else "". // - `open` is controlled while not `undefined`, seeded from `defaultOpen`. // ------------------------------------------------------------------- // Freeze the multi-select latch at setup — the selection-mode decision // cannot flip after mount (the scalar vs. array value shape would // diverge from the consumer). This mirrors React's controlled-vs-uncontrolled // branch freezing (see solidify.md §6). const isMultipleAtInit = untrack( () => props.multiple === true || props.selectionMode === "multiple", ); const emptyArray: Value[] = []; const [selectedValue, setUncontrolledSelectedValue] = createControlledState< Value | Value[] | null >({ controlled: () => readOption(props.value) as Value | Value[] | null | undefined, default: () => readOption(props.defaultValue) as Value | Value[] | null | undefined, fallback: isMultipleAtInit ? (emptyArray as Value[]) : null, name: "", state: "value", }); // Whether the consumer engaged the inputValue axis at all (controlled // prop OR default). When neither is supplied, the input display follows // the selection in single mode (seed below + the selectedValue→input // sync effect), mirroring React's `hasInputValue`. const hasInputValueProp = (): boolean => readOption(props.inputValue) !== undefined || readOption(props.defaultInputValue) !== undefined; const [inputValue, setUncontrolledInputValue] = createControlledState( { controlled: () => readOption(props.inputValue), default: () => readOption(props.defaultInputValue), // React's `initialDefaultInputValue`: when neither `inputValue` nor // `defaultInputValue` is provided, derive the seed from the initial // selected value's label in single mode so the input reflects the // selection on mount. fallback: untrack(() => { if (hasInputValueProp()) return ""; if (single()) { const sv = selectedValue(); if (sv === null || Array.isArray(sv)) return ""; return stringifyAsLabel( sv as Value, itemToStringLabel() as ((item: unknown) => string) | undefined, ); } return ""; }), name: "", state: "inputValue", }, ); 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", }); // React's "mid-close filter freeze": when the popup is closing we snapshot // the last query into `closeQuery`, and the `query` memo below prefers the // snapshot while `closeQuery !== null`. Cleared on unmount (post-close) or // when a fresh open transitions false→true. `queryChangedAfterOpen` is set // once the user has typed anything after opening so the freeze only // activates when there's an actual filtered view to keep stable. const [closeQuery, setCloseQuery] = createSignal(null, { ownedWrite: true, }); const [queryChangedAfterOpen, setQueryChangedAfterOpen] = createSignal( false, { ownedWrite: true }, ); // Mount / transition machinery. const { mounted, transitionStatus, setMounted } = useTransitionStatus( open, () => true, () => false, ); // Identity 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; // ------------------------------------------------------------------- // Element slots // ------------------------------------------------------------------- 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, }); // Refs 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 keyboardActiveRef: MutableRef = { current: true }; const chipsContainerRef: MutableRef = { current: null, }; // ------------------------------------------------------------------- // 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, }; const setIndices = (options: { activeIndex?: number | null | undefined; selectedIndex?: number | null | undefined; type?: ComboboxRootHighlightEventReason | undefined; }): void => { if (options.activeIndex !== undefined) { setActiveIndex(options.activeIndex); } if (options.selectedIndex !== undefined) { setSelectedIndex(options.selectedIndex); } const type: ComboboxRootHighlightEventReason = options.type ?? "none"; if (options.activeIndex === undefined) return; if (options.activeIndex === null) { if (lastHighlightRef.current !== INITIAL_LAST_HIGHLIGHT) { lastHighlightRef.current = INITIAL_LAST_HIGHLIGHT; props.onItemHighlighted?.( undefined, createGenericEventDetails(type, undefined, { index: -1, }) as ComboboxRootHighlightEventDetails, ); } } else { const activeValue = valuesRef.current[options.activeIndex] as | Value | undefined; lastHighlightRef.current = { value: activeValue, index: options.activeIndex, }; props.onItemHighlighted?.( activeValue, createGenericEventDetails(type, undefined, { index: options.activeIndex, }) as ComboboxRootHighlightEventDetails, ); } }; // ------------------------------------------------------------------- // Filtering — delegated to the createCombobox primitive. // The primitive owns items, isGrouped, filteredItems, flatFilteredItems, // and the base query derivation. The wrapper layers the mid-close // "closeQuery" freeze on top of the primitive's query so the popup's // filtered view stays stable while the exit animation plays. // ------------------------------------------------------------------- const _primitive = createCombobox({ value: selectedValue, inputValue: () => { // Pass closeQuery as a frozen inputValue when in the closing freeze, // so the primitive's query (and filteredItems) use the frozen string. const frozen = closeQuery(); if (frozen !== null) return frozen; return inputValue(); }, open, items: () => props.items, filter: () => props.filter, locale: () => props.locale, queryChangedAfterOpen: () => queryChangedAfterOpen(), selectionMode, itemToStringLabel, itemToStringValue, isItemEqualToValue: () => (props.isItemEqualToValue as | ItemEqualityComparer | undefined) ?? (defaultItemEquality as ItemEqualityComparer), }); const items = _primitive.items; const isGrouped = _primitive.isGrouped; // query: prefer the close-query freeze; otherwise use the primitive's query. const query = createMemo(() => { const frozen = closeQuery(); if (frozen !== null) return frozen; return _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 using the same default `Intl.Collator` "contains" filter as // items-mode (honoring a custom `filter` / `filter={null}`). Each // `` reports its visibility via `setItemVisible`; // `visibleItemCount` is the reactive total `` / // `` read. Inert in items-mode. const staticCollator = createMemo(() => getFilter({ locale: props.locale as Intl.LocalesArgument | undefined }), ); const matchesQuery = (value: Value): boolean => { 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` in sync with the filtered flat list when // `items` is supplied. When `items` is omitted, the -driven // item registration path owns these. 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 / setSelectedValue — the core write path. // ------------------------------------------------------------------- // Imperative write-paths. These may be invoked from inside an effect's // apply scope (e.g. floating-ui's onOpenChange callback fired from // `useDismiss` / `useClick` apply, or onNavigate fired from // `useListNavigation`). The reads below are snapshots — there's no // subscription contract here — so wrap the body in `untrack` to keep // `[STRICT_READ_UNTRACKED]` quiet while preserving semantics. // Each setter mirrors React's `AriaCombobox` change flow exactly: fire // the `onXChange` callback FIRST, honor `details.cancel()`, THEN write // the uncontrolled state (a no-op for reads while the matching prop is // controlled — the consumer's signal write is the source of truth) and // run the downstream bookkeeping. const setOpen = ( nextOpen: boolean, details: ComboboxRootChangeEventDetails, ): void => untrack(() => { if (open() === nextOpen) return; props.onOpenChange?.(nextOpen, details); if (details.isCanceled) return; if (!nextOpen && queryChangedAfterOpen()) { // Snapshot the current typed query so the popup's filter view stays // stable while the exit transition plays. Cleared in handleUnmount. const q = inputValue().trim(); setCloseQuery(q); if (q === "") setQueryChangedAfterOpen(false); } if (nextOpen) { setCloseQuery(null); setQueryChangedAfterOpen(false); } setUncontrolledOpen(nextOpen); }); const setInputValue = ( next: string, details: ComboboxRootChangeEventDetails, ): void => untrack(() => { props.onInputValueChange?.(next, details); if (details.isCanceled) return; // Typed input after open marks the filter as dirty so the mid-close // freeze can latch on close. Non-typed sources (programmatic resets, // item-press fill) are exempt. if ( details.reason === REASONS.inputChange && open() && next.trim() !== "" ) { setQueryChangedAfterOpen(true); } setUncontrolledInputValue(next); }); const setSelectedValue = ( next: Value | Value[] | null, details: ComboboxRootChangeEventDetails, ): void => untrack(() => { const normalized = (next ?? (isMultipleAtInit ? emptyArray : null)) as | Value | Value[] | null; props.onValueChange?.(normalized, details); if (details.isCanceled) return; setUncontrolledSelectedValue(normalized); // In single mode, sync the input to the selected label (unless the // caller is currently mid-type). React gates this behind the same // cancel check — a canceled selection leaves the input untouched. if ( single() && !Array.isArray(normalized) && (details.reason === REASONS.itemPress || details.reason === REASONS.none || details.reason === REASONS.clearPress) ) { setInputValue( stringifyAsLabel( normalized as Value | null, itemToStringLabel() as ((item: unknown) => string) | undefined, ), createChangeEventDetails( details.reason as ComboboxRootChangeEventReason, details.event, ) as ComboboxRootChangeEventDetails, ); } }); 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 ComboboxRootChangeEventDetails; if (multiple()) { const current = selectedValue(); const currentArr = Array.isArray(current) ? (current as Value[]) : []; const isSelected = selectedValueIncludes( currentArr, itemValue as Value, isItemEqualToValue() as ItemEqualityComparer, ); const nextArr = isSelected ? removeItem( currentArr, itemValue as Value, isItemEqualToValue() as ItemEqualityComparer, ) : [...currentArr, itemValue as Value]; setSelectedValue(nextArr, details); // Clear the typed filter if the user was filtering — next open // starts fresh. Keep the popup open so multiple items can be // toggled. const wasFiltering = inputRef.current ? inputRef.current.value.trim() !== "" : false; if (wasFiltering) { setOpen(false, details); } return; } if (selectionMode() === "none") { // Free-text mode: items just project their label into the input; // the selected-value state is untouched unless fillInputOnItemPress. if (fillInputOnItemPress()) { setInputValue( stringifyAsLabel( itemValue as Value, itemToStringLabel() as ((item: unknown) => string) | undefined, ), details, ); } setOpen(false, details); if (submitOnItemClick()) { const formEl = inputRef.current?.form ?? (inputRef.current?.closest("form") as HTMLFormElement | null); if (formEl && typeof formEl.requestSubmit === "function") { formEl.requestSubmit(); } } return; } setSelectedValue(itemValue as Value | null, details); setOpen(false, details); }); // ------------------------------------------------------------------- // Post-close side effects // ------------------------------------------------------------------- const handleUnmount = (): void => { setMounted(false); props.onOpenChangeComplete?.(false); setCloseQuery(null); setQueryChangedAfterOpen(false); setIndices({ activeIndex: null, selectedIndex: null }); // Multi-select: clear the typed filter after close so the next open // starts fresh. Single: sync the input to the current selected label. if (multiple()) { if (inputRef.current && inputRef.current.value !== "") { setInputValue( "", createChangeEventDetails( REASONS.inputClear, undefined, ) as ComboboxRootChangeEventDetails, ); } return; } if (single()) { const sv = selectedValue(); const stringVal = stringifyAsLabel( Array.isArray(sv) ? null : (sv as Value | null), itemToStringLabel() as ((item: unknown) => string) | undefined, ); if (inputRef.current && inputRef.current.value !== stringVal) { // React uses `inputClear` when no selection was made (treats the // close as clearing the typed filter), `none` otherwise. setInputValue( stringVal, createChangeEventDetails( stringVal === "" ? REASONS.inputClear : REASONS.none, undefined, ) as ComboboxRootChangeEventDetails, ); } } }; useOpenChangeComplete({ open, ref: popupRef, onComplete: () => { if (!open()) handleUnmount(); else props.onOpenChangeComplete?.(true); }, }); // Sync selectedIndex when `selectedValue` changes. Capture all reactive // inputs in compute (snapshot pattern). The apply runs in an // unowned/untracked scope — wrap in `untrack` so any nested signal reads // stay quiet. createEffect( () => ({ sv: selectedValue(), list: flatItems(), hasItems: items() !== undefined, comparer: isItemEqualToValue() as ItemEqualityComparer, }), (snap) => untrack(() => { const { sv, list, hasItems, comparer } = snap; if (sv == null) { setSelectedIndex(null); return; } const registry = hasItems ? list : allValuesRef.current; if (Array.isArray(sv)) { const arr = sv as Value[]; if (arr.length === 0) { setSelectedIndex(null); return; } const last = arr[arr.length - 1] as Value; const idx = findItemIndex(registry, last, comparer); setSelectedIndex(idx === -1 ? null : idx); return; } const idx = findItemIndex(registry, sv as Value, comparer); setSelectedIndex(idx === -1 ? null : idx); }), ); // Mirror React's `useValueChanged(selectedValue, …)` input sync: when the // consumer engages neither `inputValue` nor `defaultInputValue`, the // visible input follows the selection in single mode — external `value` // writes update the display text (this replaces the old reactive // "computed default" memo; selection-by-item-press is already handled // inside `setSelectedValue`). let prevSelectedForInputSync = untrack(selectedValue); createEffect( () => selectedValue(), (sv) => untrack(() => { if (Object.is(sv, prevSelectedForInputSync)) return; prevSelectedForInputSync = sv; if (!single()) return; if (hasInputValueProp()) return; const nextInputValue = stringifyAsLabel( Array.isArray(sv) ? null : (sv as Value | null), itemToStringLabel() as ((item: unknown) => string) | undefined, ); if (inputValue() !== nextInputValue) { setInputValue( nextInputValue, createChangeEventDetails( REASONS.none, undefined, ) as ComboboxRootChangeEventDetails, ); } }), ); // ------------------------------------------------------------------- // Field-context integration // ------------------------------------------------------------------- // The value that a `` should validate against. // - selectionMode "none": the input string // - selectionMode "single": the selected scalar value // - selectionMode "multiple": the selected array const fieldRawValue = createMemo(() => { if (selectionMode() === "none") return inputValue(); return selectedValue(); }); // Projected to a string / string[] for form submission. const fieldStringValue = createMemo(() => { const mode = selectionMode(); const raw = fieldRawValue(); if (mode === "none") return raw; if (Array.isArray(raw)) { const itv = itemToStringValue(); return (raw as Value[]).map((v) => stringifyAsValue(v, itv as ((item: unknown) => string) | undefined), ); } const itv = itemToStringValue(); return stringifyAsValue( raw as Value | null, itv as ((item: unknown) => string) | undefined, ); }); const fieldControlRef: MutableRef = { current: null }; const validation = useFieldControlValidation({ value: fieldRawValue, id, controlRef: fieldControlRef, getValue: () => fieldStringValue(), }); // Keep `fieldControlRef` synced to the live input element so the // registration carries a real control reference. createEffect( () => inputElement(), (el) => { fieldControlRef.current = el as HTMLElement | null; }, ); // Mirror React's `useValueChanged(selectedValue, …)` / `useValueChanged(inputValue, …)` // — push `setDirty` + run validation on each change. let initialDirtyValue = untrack(fieldRawValue); createEffect( () => fieldRawValue(), (v) => untrack(() => { if (!field.present) return; // Avoid firing on the initial mount cycle (no value change yet). if (Object.is(v, initialDirtyValue)) return; initialDirtyValue = v; const initial = field.validityData().initialValue; field.setDirty(v !== initial); if (field.shouldValidateOnChange()) { void validation.commit(v); } else { void validation.commit(v, true); } }), ); // `setFilled` — reflects whether the control has a user-visible value. createEffect( () => ({ mode: selectionMode(), iv: inputValue(), sv: selectedValue() }), ({ mode, iv, sv }) => untrack(() => { if (!field.present) return; if (mode === "none") { field.setFilled(iv !== ""); return; } if (Array.isArray(sv)) { field.setFilled(sv.length > 0); return; } field.setFilled(sv != null); }), ); // ------------------------------------------------------------------- // Floating-UI integration // ------------------------------------------------------------------- const floatingRootContext: FloatingRootStore = useFloatingRootContext({ open: untrack(open), onOpenChange: (nextOpen, details) => { setOpen(nextOpen, details as ComboboxRootChangeEventDetails); }, }); 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, }); }), ); // Floating-UI interaction hooks read accessor inputs once at body // time to seed local state — wrap in `untrack` so the component-body // strict-read shell stays quiet. Reactivity flows through the // accessor closures the hooks retain for subsequent updates. 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", }, outsidePress: (event) => { // Don't dismiss when clicking inside the chips container (e.g. on // a `` button). const target = event.target as Node | null; const chipsEl = chipsContainerRef.current; if (chipsEl && target && chipsEl.contains(target)) return false; return true; }, }), ); 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, cols: () => (grid() ? 2 : 1), orientation: () => (grid() ? "horizontal" : "vertical"), // onNavigate is invoked from `useListNavigation`'s effect-apply scope. // `open()` / `transitionStatus()` reads are snapshots, not subscriptions, // so wrap in `untrack` to silence `[STRICT_READ_UNTRACKED]`. 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. createEffect( () => ({ iv: inputValue(), mode: autoHighlightMode(), ff: flatFilteredItems(), q: query(), ai: activeIndex(), }), ({ mode, ff, q, ai }) => untrack(() => { if (!mode) return; if (ff.length === 0) { setActiveIndex(null); return; } if (mode === "always" || (mode === "input-change" && q !== "")) { if (ai == null) setActiveIndex(0); } }), ); // Virtualized stable-index override: when the consumer renders a windowed // subset of `listRef`, the sliced array mutates as the viewport scrolls // and the active index would otherwise be clamped to the visible range. // React's active-index effect skips the `>= candidateItems.length` reset // when `virtualized` is on so the logical index is preserved across // slice shifts. Mirror that here by not clamping when virtualized. createEffect( () => ({ virt: virtualized(), ai: activeIndex(), len: flatFilteredItems().length, openV: open(), }), ({ virt, ai, len, openV }) => { if (!openV) return; if (ai == null) return; if (virt) return; if (ai >= len) { setActiveIndex(null); } }, ); // Imperative actions. const actions: ComboboxRootActions = { unmount: () => setMounted(false), }; const ref = 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 value // ------------------------------------------------------------------- const contextValue: ComboboxRootContextValue = { id, labelId, setLabelId, open, mounted, transitionStatus, inputValue, setInputValue, selectedValue, setSelectedValue, selectionMode, activeIndex, selectedIndex, setIndices, disabled, readOnly, required, autoHighlightMode, highlightItemOnHover, modal, loopFocus, openOnInputClick, grid, virtualized, 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: () => { // No-op for now. In React this is used to pre-populate the labels // ref from `items`; our port does that eagerly in the // `items` / `flatFilteredItems` effect above. }, floatingRootContext, interactions, keyboardActiveRef, }; return ( } > {props.children} {multiple() && fieldName() ? (() => { const sv = selectedValue(); if (!Array.isArray(sv)) return null; const itv = itemToStringValue(); const n = fieldName(); return sv.map((v) => ( string) | undefined, )} /> )); })() : null} ); } /** * Groups all parts of the combobox. Doesn't render its own HTML element. */ export function ComboboxRoot( props: ComboboxRootProps, ): JSX.Element { if ( useComboboxRootContext(true) != null || useFloatingParentNodeId() != null ) { return {...props} />; } return ( {...props} /> ); }