import { type Accessor, createEffect, createSignal, untrack } from "solid-js"; export interface CreateControlledStateOptions { /** * Reactive read of the controlled prop, already resolved through * `readOption` where the prop is a `MaybeAccessor`. A resolved value of * `undefined` means "uncontrolled"; any other value (including `null` * and `false`) means the consumer owns the state. */ controlled: Accessor; /** * Reactive read of the matching `default*` prop. Read ONCE at creation * to seed the internal signal — later changes are ignored (and warned * about, mirroring React Base UI's `useControlled` dev warning). */ default?: Accessor; /** * Initial value when neither the controlled prop nor the `default*` * prop is provided (Base UI's destructuring default, e.g. * `defaultOpen = false`). */ fallback: T; /** Component label for warnings, e.g. `""`. */ name: string; /** State label for warnings, e.g. `"open"`, `"checked"`. @default "value" */ state?: string; } /** * Solid port of Base UI's controlled/uncontrolled state model. * * Mirrors two equivalent React Base UI mechanisms at once: * - `useControlled({ controlled, default })` (checkbox, select, tabs, …) * - the popup-store selector `state.openProp ?? state.open` plus * `useControlledProp` (dialog, popover, menu, …) * * Contract: * - The read accessor returns the controlled prop whenever its resolved * value is not `undefined`, otherwise the internal signal. * - The internal signal is seeded once from `default` (else `fallback`). * - The setter always writes the internal signal — harmless while * controlled because the read prefers the prop (exactly like * `DialogStore.setOpen` writing `state.open` behind `openProp`). The * `onXChange` callback and any `eventDetails.isCanceled` gate stay at * the component's `setX`, NOT here. * - Switching controlled <-> uncontrolled after creation warns, as does * changing the `default*` prop while uncontrolled. */ export function createControlledState( options: CreateControlledStateOptions, ): [Accessor, (next: T) => void] { const initialControlled = untrack(options.controlled); const initiallyControlled = initialControlled !== undefined; const initialDefault = untrack(() => options.default?.()); const stateLabel = options.state ?? "value"; // `T` is unconstrained and may itself be a function, so seed through the // functional-setter form to avoid the value/updater ambiguity in // `createSignal`'s overloads (same idiom as navigation-menu-root). const [internal, setInternal] = createSignal(undefined, { name: `${options.name}.${stateLabel}`, ownedWrite: true, }); setInternal(() => initialDefault !== undefined ? initialDefault : options.fallback, ); // Warn on controlled <-> uncontrolled switches (Base UI dev warning; // quoin warns unconditionally, matching slider/floating-ui internals). createEffect( () => options.controlled() !== undefined, (isControlled) => { if (isControlled !== initiallyControlled) { console.error( `${options.name} is changing the ${ initiallyControlled ? "" : "un" }controlled ${stateLabel} state to be ${ initiallyControlled ? "un" : "" }controlled. Decide between controlled and uncontrolled for the lifetime of the component.`, ); } }, ); if (options.default) { const readDefault = options.default; createEffect( () => readDefault(), (next) => { if (!initiallyControlled && !Object.is(next, initialDefault)) { console.error( `${options.name} is changing the default ${stateLabel} state of an uncontrolled component after initialization. The default is only read once — use the controlled ${stateLabel} prop instead.`, ); } }, ); } const value: Accessor = () => { const controlled = options.controlled(); // The internal signal is seeded before first read, so the // `undefined` in its type never escapes. return controlled !== undefined ? controlled : (internal() as T); }; const setValue = (next: T): void => { setInternal(() => next); }; return [value, setValue]; }