import type { JSX } from "@solidjs/web"; /** * `` — Solid 2 port of Base UI React's `SelectRoot` * (`packages/react/src/select/root/SelectRoot.tsx`). * * Owns the select's open/value state (controlled + uncontrolled), wires * `useInteractions` with click + dismiss + list-navigation + typeahead, * manages highlighting (`activeIndex`) + selection (`selectedIndex`) * independently, and writes a hidden `` (or multiple hidden inputs * when `multiple`) for native form participation. * * ### React → Solid deviations * * - The React source's store + selectors pair is replaced by plain * signals — Solid's fine-grained reactivity + context gives the same * subscribe-where-you-care ergonomics. The signals live under the root * context. * - `useOpenInteractionType` is not yet wired; `openMethod` is omitted * from the port (no touch/keyboard differentiation inside the popup). * Deferred along with the align-item-with-trigger math. * - `alignItemWithTrigger` popup trigger-aligned placement is deferred — * the complex scroll-arrow + max-height geometry isn't exercisable in * happy-dom. * - `useOnFirstRender` stamping of `popupProps` / `triggerProps` maps to * the root context exposing interaction getters directly — parts read * them inside their own reactive scopes. */ import { type Accessor, createEffect, createMemo, createSignal, onCleanup, untrack, } from "solid-js"; import { useBaseUiId } from "../../internals/base-ui-id"; import { createChangeEventDetails, REASONS, } from "../../internals/change-event-details"; import { createControlledState } from "../../internals/create-controlled-state"; 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 { useInteractions } from "../../internals/floating-ui/hooks/use-interactions"; import { useListNavigation } from "../../internals/floating-ui/hooks/use-list-navigation"; import { useTypeahead } from "../../internals/floating-ui/hooks/use-typeahead"; 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 { useOpenInteractionType } from "../../internals/popups/use-open-interaction-type"; import { useTransitionStatus } from "../../internals/use-transition-status/use-transition-status"; import { VISUALLY_HIDDEN_STYLE } from "../../internals/visually-hidden-style"; import { createSelect } from "../create-select"; import { type SelectRootChangeEventDetails, type SelectRootChangeEventReason, SelectRootContext, type SelectRootContextValue, type SelectSelectionGuard, type SelectValue, useSelectRootContext, } from "../select-root-context"; import { defaultItemEquality, findItemIndex } from "../utils/item-equality"; import { type ItemsInput, stringifyAsLabel, stringifyAsValue, } from "../utils/resolve-value-label"; export type { SelectRootChangeEventDetails, SelectRootChangeEventReason }; /** * Imperative actions written into `actionsRef` on ``. Mirrors * React's `SelectRoot.Actions` — only `unmount()` is exposed (close is * reserved via `onOpenChange`). */ export interface SelectRootActions { unmount: () => void; } export interface SelectRootProps< Value = SelectValue, Multiple extends boolean | undefined = false, > { /** The id of the Select. */ id?: string | undefined; /** Field name for form submission. */ name?: string | undefined; /** Form id for the hidden input. */ form?: string | undefined; /** Autocomplete hint. */ autoComplete?: string | undefined; /** Whether the user must choose a value before submitting. */ required?: MaybeAccessor; /** Whether the user can change the selection. */ readOnly?: MaybeAccessor; /** Whether the component ignores interaction. */ disabled?: MaybeAccessor; /** Whether multiple items may be selected. */ multiple?: Multiple | undefined; /** Whether hovering an item highlights it. */ highlightItemOnHover?: boolean | undefined; /** * Controlled open state. When provided (resolved value not * `undefined`), the consumer owns the state — pair with * {@link onOpenChange}. When omitted, the select 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 select's own (uncontrolled) state unchanged, mirroring * React Base UI; controlled consumers simply don't update their signal. */ onOpenChange?: | ((open: boolean, eventDetails: SelectRootChangeEventDetails) => void) | undefined; /** Called after open/close transitions settle. */ onOpenChangeComplete?: ((open: boolean) => void) | undefined; /** Modal mode — locks outside interaction while open. @default true */ modal?: boolean | undefined; /** * Controlled value (scalar for single; array for multiple). When * provided (resolved value not `undefined` — `null` counts as * controlled), the consumer owns the state — pair with * {@link onValueChange}. When omitted, the select manages its own * value (seeded from {@link defaultValue}). */ value?: MaybeAccessor | null | undefined>; /** * Initial value when uncontrolled. Read once at mount. * @default null (normalized to `[]` when `multiple`) */ defaultValue?: MaybeAccessor< SelectValueType | null | undefined >; /** * Fires on every selection request. Calling `eventDetails.cancel()` * keeps the select's own (uncontrolled) value unchanged, mirroring * React Base UI; controlled consumers simply don't update their signal. */ onValueChange?: | (( value: | SelectValueType | (Multiple extends true ? never : null), eventDetails: SelectRootChangeEventDetails, ) => void) | undefined; /** * Imperative actions — callback-ref shape (React uses RefObject). */ actionsRef?: | ((actions: SelectRootActions | null) => void) | { current: SelectRootActions | null } | undefined; /** Ref to the hidden input. */ inputRef?: | ((el: HTMLInputElement | null) => void) | { current: HTMLInputElement | null } | undefined; /** Items registry — drives trigger label resolution for object values. */ items?: MaybeAccessor>; /** Formatter for object values (trigger display). */ itemToStringLabel?: ((value: Value) => string) | undefined; /** Formatter for object values (form submission). */ itemToStringValue?: ((value: Value) => string) | undefined; /** Custom equality comparer. */ isItemEqualToValue?: | ((itemValue: Value, value: Value) => boolean) | undefined; /** Children. */ children?: JSX.Element; } type SelectValueType< Value, Multiple extends boolean | undefined, > = Multiple extends true ? Value[] : Value; function SelectRootComponent< Value = SelectValue, Multiple extends boolean | undefined = false, >(props: SelectRootProps): JSX.Element { const multiple = (): boolean => props.multiple ?? false; const modal = (): boolean => props.modal ?? true; const disabled = (): boolean => readOption(props.disabled) ?? false; const readOnly = (): boolean => readOption(props.readOnly) ?? false; const required = (): boolean => readOption(props.required) ?? false; const highlightItemOnHover = (): boolean => props.highlightItemOnHover ?? true; const itemToStringLabel = (): ((value: Value) => string) | undefined => props.itemToStringLabel; const itemToStringValue = (): ((value: Value) => string) | undefined => props.itemToStringValue; const isItemEqualToValue = (): ((a: unknown, b: unknown) => boolean) => (props.isItemEqualToValue ?? defaultItemEquality) as ( a: unknown, b: unknown, ) => boolean; const items = (): ItemsInput => readOption(props.items) as ItemsInput; const name = (): string | undefined => props.name; const generatedId = useBaseUiId(props.id); // Controlled / uncontrolled state (Base UI `useControlled` contract, // applied to BOTH axes like React's `SelectRoot`): // - `value` is controlled while its resolved value is not `undefined` — // `value={null}` IS controlled (Base UI semantics). The uncontrolled // fallback is `null`; the createSelect primitive normalizes `null` to // `[]` in multiple mode (matching React's // `multiple ? defaultValue ?? EMPTY_ARRAY : defaultValue`). // - `open` is controlled while its resolved value is not `undefined`, // otherwise backed by an internal signal seeded from `defaultOpen`. const [resolvedValue, setUncontrolledValue] = createControlledState({ controlled: () => readOption(props.value) as unknown, default: () => readOption(props.defaultValue) as unknown, fallback: null, name: "", state: "value", }); const [resolvedOpen, setUncontrolledOpen] = createControlledState({ controlled: () => { const controlled = readOption(props.open); return controlled === undefined ? undefined : Boolean(controlled); }, default: () => readOption(props.defaultOpen), fallback: false, name: "", state: "open", }); // Logical state delegated to the createSelect primitive. // The primitive owns: value/open normalisation + isItemSelected + valueLabel. // The wrapper adds: floating-ui, keyboard nav, hidden form inputs, scroll arrows. const _primitive = createSelect({ value: resolvedValue, open: resolvedOpen, multiple: props.multiple as boolean | undefined, disabled: () => Boolean(readOption(props.disabled)), readOnly: () => Boolean(readOption(props.readOnly)), required: () => Boolean(readOption(props.required)), items: () => readOption(props.items) as ItemsInput | undefined, itemToStringLabel: () => props.itemToStringLabel as ((v: unknown) => string) | undefined, itemToStringValue: () => props.itemToStringValue as ((v: unknown) => string) | undefined, isItemEqualToValue: () => props.isItemEqualToValue as | ((a: unknown, b: unknown) => boolean) | undefined, }); // Value — reads from the primitive (handles multi-select empty-array default). const value = _primitive.value as Accessor; // Open — reads from the primitive. const open = _primitive.open; // Highlighted + selected index. They're independent — the highlight // drives keyboard navigation whereas the selection reflects the stored // value. const [activeIndex, setActiveIndex] = createSignal(null, { ownedWrite: true, }); const [selectedIndex, setSelectedIndex] = createSignal(null, { ownedWrite: true, }); // Label id — wired up via useRegisteredLabelId when Select.Label lands. const [labelId, setLabelId] = createSignal(undefined, { ownedWrite: true, }); // Element slots. const [triggerElement, setTriggerElement] = createSignal( null, { ownedWrite: true }, ); const [listElement, setListElement] = createSignal(null, { ownedWrite: true, }); const popupRef: MutableRef = { current: null }; const valueRef: MutableRef = { current: null }; const selectedItemTextRef: MutableRef = { current: null, }; const listRef: MutableRef> = { current: [] }; const labelsRef: MutableRef> = { current: [] }; // biome-ignore lint/suspicious/noExplicitAny: values registry indexes by position; typed as `any` to match React's surface. const valuesRef: MutableRef = { current: [] }; const typingRef: MutableRef = { current: false }; // biome-ignore lint/suspicious/noExplicitAny: initial value holder mirrors React's ref. const initialValueRef: MutableRef = { current: untrack(value) }; // Mount / transition machinery. const { mounted, transitionStatus, setMounted } = useTransitionStatus( open, () => true, () => false, ); // Scroll-arrow visibility signals — written by the popup's trigger-aligned // geometry effect. const [scrollUpArrowVisible, setScrollUpArrowVisible] = createSignal(false, { ownedWrite: true, }); const [scrollDownArrowVisible, setScrollDownArrowVisible] = createSignal( false, { ownedWrite: true }, ); const scrollArrowsMountedCountRef: MutableRef = { current: 0 }; // Filled by the popup's geometry effect on initial placement; called from // items / positioner to re-probe scroll edges after list mutations. React // stores this on `handleScrollArrowVisibility` via `useStableCallback`; we // use a plain ref so updates don't require re-running consumers. const scrollHandlerRef: MutableRef<(scroller: HTMLElement) => void> = { current: () => {}, }; const handleScrollArrowVisibilityRef: MutableRef<() => void> = { current: () => {}, }; const handleScrollArrowVisibility = (): void => { handleScrollArrowVisibilityRef.current(); }; // Most-recent-interaction flags (keyboard vs. mouse). Written from the // popup's onKeyDown / onMouseMove + trigger's onKeyDown / onPointerMove. const keyboardActiveRef: MutableRef = { current: false }; // Selection-window guard. Flips to `true` on a delay after open — mirrors // React's `SelectTrigger` behavior. const selectionRef: MutableRef = { current: { allowSelectedMouseUp: false, allowUnselectedMouseUp: false }, }; // Capture the interaction type that opened the popup. `openMethod` is // `null` while closed, `'mouse' | 'keyboard' | 'touch' | 'pen' | ''` after // the opening interaction. const { openMethod } = useOpenInteractionType(open); // Deferred unmount — waits for any CSS exit animation on the popup. useOpenChangeComplete({ open, ref: popupRef, onComplete: () => { if (!open()) { setMounted(false); setActiveIndex(null); props.onOpenChangeComplete?.(false); } else { props.onOpenChangeComplete?.(true); } }, }); // Reset trigger-aligned state when the popup unmounts. Mirrors React's // `` layout-effect which clears scroll-arrow flags // when `mounted` flips to false, and the trigger-side reset for // selectionRef when `open` flips to false. createEffect( () => mounted(), (isMounted) => { if (!isMounted) { setScrollUpArrowVisible(false); setScrollDownArrowVisible(false); } }, ); createEffect( () => open(), (isOpen) => { if (!isOpen) { selectionRef.current = { allowSelectedMouseUp: false, allowUnselectedMouseUp: false, }; } }, ); // Sync selectedIndex when open=false or the value changes — mirrors // React's `syncSelectedIndex` layout effect. createEffect( () => ({ isOpen: open(), currentValue: value(), equals: isItemEqualToValue(), }), ({ isOpen, currentValue, equals }) => { if (isOpen) return; const registry = valuesRef.current; if (multiple()) { const cv = Array.isArray(currentValue) ? currentValue : []; if (cv.length === 0) { setSelectedIndex(null); return; } const last = cv[cv.length - 1]; const idx = findItemIndex(registry, last, equals); setSelectedIndex(idx === -1 ? null : idx); return; } const idx = findItemIndex(registry, currentValue, equals); setSelectedIndex(idx === -1 ? null : idx); }, ); // Setters — mirror Base UI's `SelectRoot` `setOpen` / `setValue`: fire // the `onXChange` callback, 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). const setOpen = ( next: boolean, details: SelectRootChangeEventDetails, ): void => { props.onOpenChange?.(next, details); if (details.isCanceled) return; setUncontrolledOpen(next); }; const setValue = ( next: SelectValue, details: SelectRootChangeEventDetails, ): void => { props.onValueChange?.( next as SelectValueType, details as SelectRootChangeEventDetails, ); if (details.isCanceled) return; setUncontrolledValue(next); }; // Floating-UI integration. const floatingRootContext = useFloatingRootContext({ open: untrack(open), onOpenChange: (nextOpen, details) => { setOpen(nextOpen, details as SelectRootChangeEventDetails); }, }); createEffect( () => ({ openValue: open(), trigger: triggerElement(), status: transitionStatus(), }), ({ openValue, trigger, status }) => { floatingRootContext.update({ open: openValue, referenceElement: trigger, domReferenceElement: trigger, transitionStatus: status, }); }, ); // Interaction hooks. Each hook reads its accessor inputs once at body // time to seed local refs / signals — wrap in `untrack` so those // snapshot reads don't trip Solid 2's `[STRICT_READ_UNTRACKED]` shell // labelled with this component's name. Reactivity for subsequent // updates flows through the accessor closures the hooks retain. const click = untrack(() => useClick(floatingRootContext, { enabled: () => !readOnly() && !disabled(), event: "mousedown", }), ); const dismiss = untrack(() => useDismiss(floatingRootContext, { enabled: () => !readOnly() && !disabled(), bubbles: false, }), ); const listNavigation = untrack(() => useListNavigation(floatingRootContext, { enabled: () => !readOnly() && !disabled(), listRef, activeIndex, selectedIndex, focusItemOnHover: highlightItemOnHover, onNavigate(nextActiveIndex) { // Retain the highlight while transitioning out — React's note. if (nextActiveIndex === null && !untrack(open)) return; setActiveIndex(nextActiveIndex); }, }), ); const typeahead = untrack(() => useTypeahead(floatingRootContext, { enabled: () => !readOnly() && !disabled() && (open() || !multiple()), listRef: labelsRef, activeIndex, selectedIndex, onMatch(index) { if (untrack(open)) { setActiveIndex(index); } else { const nextValue = valuesRef.current[index]; setValue( nextValue, createChangeEventDetails( REASONS.none, ) as SelectRootChangeEventDetails, ); } }, onTypingChange(typing) { typingRef.current = typing; }, }), ); const interactions = untrack(() => useInteractions([click, dismiss, listNavigation, typeahead]), ); // actionsRef wiring. const handleUnmount = (): void => { setMounted(false); setActiveIndex(null); }; const actions: SelectRootActions = { unmount: handleUnmount }; 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; }); } // Hidden form input — single-select path. const serializedValue = createMemo(() => { const v = value(); if (multiple() && Array.isArray(v) && v.length === 0) return ""; return stringifyAsValue( v, itemToStringValue() as ((v: unknown) => string) | undefined, ); }); // For multi-select we render one hidden `` per value (matching // React). This accessor returns the list of serialized strings. const multipleSerialized = createMemo(() => { const v = value(); if (!multiple() || !Array.isArray(v)) return []; return v.map((x) => stringifyAsValue( x, itemToStringValue() as ((v: unknown) => string) | undefined, ), ); }); const hasMultipleSelection = createMemo(() => { const v = value(); return multiple() && Array.isArray(v) && v.length > 0; }); const hiddenInputName = (): string | undefined => multiple() ? undefined : name(); // Browser autofill handling — mirrors React. When autocomplete writes a // string into the hidden input, try to map it back into a registered // item value (by stringified value OR by label). const handleAutofill = (event: Event): void => { const target = event.currentTarget as HTMLInputElement | null; if (!target) return; const nextValue = target.value; if (multiple()) return; // Browser autofill only writes a single scalar. const registry = valuesRef.current; const matching = registry.find((candidate) => { const candidateValue = stringifyAsValue( candidate, itemToStringValue() as ((v: unknown) => string) | undefined, ); if (candidateValue.toLowerCase() === nextValue.toLowerCase()) return true; const candidateLabel = stringifyAsLabel( candidate, itemToStringLabel() as ((v: unknown) => string) | undefined, ); if (candidateLabel.toLowerCase() === nextValue.toLowerCase()) return true; return false; }); if (matching != null) { setValue( matching, createChangeEventDetails( REASONS.none, event, ) as SelectRootChangeEventDetails, ); } }; const setInputRef = (el: HTMLInputElement | null): void => { const r = props.inputRef; if (typeof r === "function") r(el); else if (r && typeof r === "object") r.current = el; }; const contextValue: SelectRootContextValue = { open, mounted, transitionStatus, modal, multiple, disabled, readOnly, required, name, id: () => generatedId, labelId, setLabelId, value, activeIndex, setActiveIndex, selectedIndex, setSelectedIndex, highlightItemOnHover, isItemEqualToValue: isItemEqualToValue as () => ( a: SelectValue, b: SelectValue, ) => boolean, itemToStringLabel: itemToStringLabel as () => | ((value: SelectValue) => string) | undefined, itemToStringValue: itemToStringValue as () => | ((value: SelectValue) => string) | undefined, items, setOpen, setValue, floatingRootContext, interactions, triggerElement, setTriggerElement, popupRef, listElement, setListElement, valueRef, selectedItemTextRef, listRef, labelsRef, valuesRef, typingRef, onOpenChangeComplete: props.onOpenChangeComplete, initialValueRef, openMethod, scrollUpArrowVisible, setScrollUpArrowVisible, scrollDownArrowVisible, setScrollDownArrowVisible, scrollArrowsMountedCountRef, handleScrollArrowVisibility, scrollHandlerRef, keyboardActiveRef, selectionRef, handleScrollArrowVisibilityRef, }; return ( {props.children} { // Move focus to the trigger when the hidden input is focused. const trigger = triggerElement(); trigger?.focus(); }} onChange={handleAutofill} /> {multiple() && name() ? ( ) : null} ); } function MultipleHiddenInputs(props: { name: string; form: string | undefined; values: Accessor; }): JSX.Element { return ( <> {props.values().map((v) => ( ))} ); } /** * Groups all parts of the select. Doesn't render its own HTML element — * the root is a pure context provider. */ export function SelectRoot< Value = SelectValue, Multiple extends boolean | undefined = false, >(props: SelectRootProps): JSX.Element { if (useSelectRootContext(true) != null || useFloatingParentNodeId() != null) { return {...props} />; } return ( {...props} /> ); }