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 { defaultItemEquality } from "./utils/item-equality"; type ItemEqualityComparer = (item: A, value: B) => boolean; import { type ItemsInput, stringifyAsLabel } from "./utils/resolve-value-label"; /** Detect grouped items (has at least one entry with an `items` key). */ function isGroupedItems(items: unknown): boolean { if (!Array.isArray(items) || items.length === 0) return false; const first = items[0]; return ( typeof first === "object" && first !== null && "items" in first && !("value" in first) ); } // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- export type SelectChangeReason = | "item-press" | "escape-key" | "outside-press" | "imperative"; import type { SelectValue } from "./select-root-context"; export type { SelectValue }; export interface CreateSelectOptions< Value = SelectValue, Multiple extends boolean | undefined = false, > { /** * Current selected value. Consumer-owned signal. Writes happen via * `onValueChange` — the primitive does NOT keep an internal copy. * Pass an array when `multiple: true`. */ value: MaybeAccessor | null | undefined>; onValueChange?: ( value: SelectValueType | null, reason: SelectChangeReason, ) => void; /** Open state. Consumer-owned signal. */ open: MaybeAccessor; onOpenChange?: (open: boolean, reason: SelectChangeReason) => void; /** Whether multiple items may be selected. */ multiple?: Multiple; disabled?: MaybeAccessor; readOnly?: MaybeAccessor; required?: MaybeAccessor; /** Items registry — drives trigger label resolution for object values. */ items?: MaybeAccessor | undefined>; /** Formatter for object values (trigger display). */ itemToStringLabel?: MaybeAccessor<((value: Value) => string) | undefined>; /** Formatter for object values (form submission). */ itemToStringValue?: MaybeAccessor<((value: Value) => string) | undefined>; /** Custom equality comparer. Defaults to `Object.is`. */ isItemEqualToValue?: MaybeAccessor< ((itemValue: Value, value: Value) => boolean) | undefined >; } type SelectValueType< Value, Multiple extends boolean | undefined, > = Multiple extends true ? Value[] : Value; export interface SelectApi< Value = SelectValue, Multiple extends boolean | undefined = false, > { /** Reactive accessor for the current selected value. */ value: Accessor | null>; /** Reactive accessor for the open state. */ open: Accessor; multiple: boolean; disabled: Accessor; readOnly: Accessor; required: Accessor; /** Items registry (for label resolution / group support). */ items: Accessor | undefined>; /** Whether the items list is grouped. */ isGrouped: Accessor; /** Check whether a given item value is currently selected. */ isItemSelected: (item: Value) => boolean; /** Display label for the current value. Empty string when nothing is selected. */ valueLabel: Accessor; /** Pure event-emitter — fire `onValueChange`. Consumer owns the signal write. */ setValue: ( next: SelectValueType | null, reason?: SelectChangeReason, ) => void; /** Pure event-emitter — fire `onOpenChange`. */ setOpen: (next: boolean, reason?: SelectChangeReason) => void; /** * Context provider. Wrap a subtree to make the api discoverable via * `useSelect()` without prop-drilling. */ Provider: (props: { children: JSX.Element }) => JSX.Element; } // --------------------------------------------------------------------------- // Context // --------------------------------------------------------------------------- const SelectCtx = createContext | null>( null, ); export function useSelect< Value = SelectValue, Multiple extends boolean | undefined = false, >(): SelectApi { const ctx = useContext(SelectCtx); if (ctx == null) { throw new Error( "Base UI: useSelect() must be called inside a .", ); } return ctx as SelectApi; } // --------------------------------------------------------------------------- // Primitive // --------------------------------------------------------------------------- /** * Solid 2-native headless primitive for the select selection logic. Owns: * value / open state, isItemSelected derivation, and valueLabel. No filter * input (Select has no text input). DOM concerns (floating-ui positioning, * keyboard nav, portal, hidden form input) live in the structural * `` wrapper that composes this primitive. */ export function createSelect< Value = SelectValue, Multiple extends boolean | undefined = false, >(options: CreateSelectOptions): SelectApi { // Freeze multi-select at init — the scalar vs. array value shape cannot // flip after mount. const isMultiple = untrack(() => options.multiple === true); const disabled = createMemo( () => Boolean(readOption(options.disabled)), { name: "select.disabled" }, ); const readOnly = createMemo( () => Boolean(readOption(options.readOnly)), { name: "select.readOnly" }, ); const required = createMemo( () => Boolean(readOption(options.required)), { name: "select.required" }, ); const itemToStringLabel = createMemo<((value: Value) => string) | undefined>( () => readOption(options.itemToStringLabel), { name: "select.itemToStringLabel" }, ); const isItemEqualToValue = createMemo>( () => (readOption(options.isItemEqualToValue) as | ItemEqualityComparer | undefined) ?? (defaultItemEquality as ItemEqualityComparer), { name: "select.isItemEqualToValue" }, ); // Value — reads through the consumer's accessor. const value = createMemo | null>( () => { const raw = readOption(options.value) as | SelectValueType | null | undefined; if (isMultiple) { return ( Array.isArray(raw) ? raw : raw == null ? [] : raw ) as SelectValueType; } return (raw ?? null) as SelectValueType | null; }, { name: "select.value" }, ); // Open — reads through the consumer's accessor. const open = createMemo(() => Boolean(readOption(options.open)), { name: "select.open", }); // Items registry const items = createMemo | undefined>( () => readOption(options.items) as ItemsInput | undefined, { name: "select.items" }, ); const isGrouped = createMemo( () => { const its = items(); if (!its) return false; return isGroupedItems(its as unknown[]); }, { name: "select.isGrouped" }, ); // isItemSelected — pure derivation. const isItemSelected = (item: Value): boolean => { const sv = value(); if (sv == null) return false; const comparer = isItemEqualToValue(); if (isMultiple && Array.isArray(sv)) { return (sv as Value[]).some((v) => comparer(item, v)); } return comparer(item, sv as Value); }; // Display label for the trigger — derived from value + itemToStringLabel. const valueLabel = createMemo( () => { const sv = value(); if (sv == null) return ""; if (isMultiple && Array.isArray(sv)) { return (sv as Value[]) .map((v) => stringifyAsLabel( v, itemToStringLabel() as ((item: unknown) => string) | undefined, ), ) .join(", "); } return stringifyAsLabel( sv, itemToStringLabel() as ((item: unknown) => string) | undefined, ); }, { name: "select.valueLabel" }, ); // 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: SelectValueType | null, reason: SelectChangeReason = "imperative", ): void => { runWithOwner(null, () => options.onValueChange?.(next, reason)); }; const setOpen = ( next: boolean, reason: SelectChangeReason = "imperative", ): void => { runWithOwner(null, () => options.onOpenChange?.(next, reason)); }; const api: SelectApi = { value, open, multiple: isMultiple, disabled, readOnly, required, items, isGrouped, isItemSelected, valueLabel, setValue, setOpen, Provider: (props) => ( }> {props.children} ), }; (api as SelectApi & Snapshottable)[SNAPSHOT_KEYS] = SELECT_SNAPSHOT_KEYS; return api; } const SELECT_SNAPSHOT_KEYS = snapshotKeys( "value", "open", "disabled", "readOnly", "required", "valueLabel", );