import { type Accessor, createEffect, createMemo } from "solid-js"; import { useFieldMessagesContext } from "../field/field-messages-context.ts"; import { type FieldRootState, useFieldRootContext, } from "../internals/field-context.ts"; import { fieldStateAttrs } from "../internals/field-state-attrs"; import { useAriaLabelledBy } from "../internals/labelable-provider/use-aria-labelled-by.ts"; import { useLabelableId } from "../internals/labelable-provider/use-labelable-id.ts"; import { makeSlotRefDirective } from "../internals/make-slot-ref-directive"; import { type MaybeAccessor, readOption } from "../internals/maybe-accessor"; import { mergeRootProps } from "../internals/merge-root-props"; import { SNAPSHOT_KEYS, type Snapshottable, snapshotKeys, } from "../internals/observability/snapshot"; import { useFieldControlValidation } from "../internals/use-field-control-validation/use-field-control-validation.ts"; /** State surface exposed to slot consumers. Mirrors the surrounding field. */ export interface InputState extends FieldRootState {} /** * Options for {@link createInput}. Every reactive input is `MaybeAccessor` * — pass an accessor to make the value reactive (or a bare value; Solid's * JSX-proxy makes wrapper props reactive either way, and `readOption` is * identity on non-functions). * * **Solid-native shape.** Like the other field controls, `createInput` owns * no value/validation state of its own — that lives in the surrounding * ``. The primitive reads the field/labelable/messages contexts * internally and projects a control prop-bag. Outside a `` every * context resolves to its NOOP form and the control still renders a plain * ``. */ export interface CreateInputOptions { /** * The control's live value. Used for field-control registration and to * drive the surrounding field's `filled` state. Not written back onto the * element by the primitive — spread it (or pass it through `control()`'s * `userProps`) yourself. */ value?: MaybeAccessor; /** * Whether the control is disabled. Composed with the field's `disabled` * (either being true disables the control). * @default false */ disabled?: MaybeAccessor; /** * Explicit id for the control. Read once during setup (matches * `useBaseUiId`'s stable-id semantics); generated when omitted. */ id?: string | undefined; /** Form-submission name. Falls back to the field's `name`. */ name?: MaybeAccessor; } /** Shape of attributes emitted by `c.control()`. */ export interface InputControlSlotProps { id: string; disabled: boolean; name: string | undefined; "aria-labelledby": string; "aria-describedby": string; "data-disabled": "" | undefined; onFocus: (event: FocusEvent) => void; onBlur: (event: FocusEvent & { currentTarget: HTMLInputElement }) => void; onInput: (event: InputEvent & { currentTarget: HTMLInputElement }) => void; onKeyDown: ( event: KeyboardEvent & { currentTarget: HTMLInputElement }, ) => void; } /** * Public API returned by {@link createInput}. Flat accessors at the root; the * control prop-bag (`control`) and its directive form (`controlRef`) carry the * field wiring. No `Provider`/`useInput` — an `` is a leaf with no * descendant subtree that would read the api back (same call as Toggle's * skipped provider). */ export interface InputApi { /** Composed `disabled` (field's OR the option's). */ disabled: Accessor; /** The field-derived state (field state + composed `disabled`). */ state: Accessor; /** The resolved control id. Stable for the primitive's lifetime. */ id: string; /** * Control slot — getter-bag of attributes + handlers ready for spreading * onto a native ``. Does NOT include `ref`; bind the element via * {@link bindControl} (or use {@link controlRef} for the directive form). */ control: ( userProps?: U, ) => InputControlSlotProps & (U extends object ? U : object); /** * Plain setter that binds the live `` element into the field's * validation pipeline (`element.validity` reads, `setCustomValidity` * writes). Compose with the consumer's ref via `composeRefs`. Use this * when the element already receives `control()`'s prop bag via spread. */ bindControl: (el: HTMLInputElement | null) => void; /** * Directive form — a `ref` callback that spreads the control bag onto the * element AND binds it into the validation pipeline. For cva-style * consumers that own the element's prop surface and don't spread * `control()`. */ controlRef: () => (el: HTMLInputElement | null) => void; } /** * Solid 2.0-native field-control primitive for a native `` that * integrates with a surrounding ``. Wires (when inside a field): * * - id registration into the labelable provider so ``'s * `htmlFor` can target this input. * - value registration into the field (and any `
` above) via * `useFieldControlValidation`. * - `aria-labelledby` composed from registered labels. * - `aria-describedby` composed from `` / `` * ids via the field-messages registry. * - `aria-invalid` — flipped to literal `"true"` once validation latches * invalid (via `getInputValidationProps()`). * - `onInput` / `onBlur` / `onFocus` / `onKeyDown` into the field's * touched / dirty / filled / focused setters and the validation pipeline. * * Must be called in an owner scope (component body or `createRoot`) — it * reads contexts and installs effects / cleanups. */ export function createInput(options: CreateInputOptions = {}): InputApi { const field = useFieldRootContext(); const messages = useFieldMessagesContext(); const { id } = useLabelableId({ id: options.id }); const disabled = createMemo( (): boolean => field.disabled() || readOption(options.disabled) === true, { name: "input.disabled" }, ); const state = createMemo( (): InputState => ({ ...field.state(), disabled: disabled(), }), { name: "input.state" }, ); const { getInputValidationProps, inputRef, commit } = useFieldControlValidation({ value: () => readOption(options.value), id: () => id, controlRef: field.validation.inputRef, getValue: () => field.validation.inputRef.current?.value, }); // Mirror the live value into the field's `filled` state. A non-empty // external value (or a non-empty element value) marks filled; an explicit // empty string clears it. createEffect( () => readOption(options.value), (externalValue) => { const hasExternal = externalValue != null; const input = field.validation.inputRef.current; if (input?.value || (hasExternal && externalValue !== "")) { field.setFilled(true); } else if (hasExternal && externalValue === "") { field.setFilled(false); } }, ); const ariaLabelledBy = useAriaLabelledBy(); const ariaDescribedBy = createMemo( (): string | undefined => { const ids = messages.getMessageIds(); if (ids.length === 0) return undefined; return ids.join(" "); }, { name: "input.ariaDescribedBy" }, ); const controlInternal = { get id() { return id; }, get disabled() { return disabled(); }, get name() { return field.name() ?? readOption(options.name); }, get "aria-labelledby"() { return ariaLabelledBy() ?? ""; }, get "aria-describedby"() { return ariaDescribedBy() ?? ""; }, get "data-disabled"(): "" | undefined { return disabled() ? "" : undefined; }, onFocus: () => { field.setFocused(true); }, onBlur: (event: FocusEvent & { currentTarget: HTMLInputElement }) => { field.setTouched(true); field.setFocused(false); if (field.validationMode() === "onBlur") { void commit(event.currentTarget.value); } }, onInput: (event: InputEvent & { currentTarget: HTMLInputElement }) => { const value = event.currentTarget.value; field.setDirty(value !== field.validityData().initialValue); field.setFilled(value !== ""); }, onKeyDown: (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => { if (event.currentTarget.tagName === "INPUT" && event.key === "Enter") { field.setTouched(true); void commit(event.currentTarget.value); } }, }; // `getInputValidationProps()` is built from getters that re-read field // state, so materialize the bag once and merge it — the keys stay // reactive through `mergeRootProps`. const validationProps = getInputValidationProps(); const control = (userProps?: U) => { let merged: Record = mergeRootProps( controlInternal, fieldStateAttrs(state), ); merged = mergeRootProps(merged, validationProps); merged = mergeRootProps(merged, userProps); return merged as InputControlSlotProps & (U extends object ? U : object); }; const bindControl = (el: HTMLInputElement | null): void => { inputRef.current = el; }; const controlRef = (): ((el: HTMLInputElement | null) => void) => { const baseDirective = makeSlotRefDirective( () => control() as Record, ); return (el: HTMLInputElement | null): void => { bindControl(el); baseDirective(el); }; }; const api: InputApi = { disabled, state, id, control, bindControl, controlRef, }; (api as InputApi & Snapshottable)[SNAPSHOT_KEYS] = INPUT_SNAPSHOT_KEYS; return api; } const INPUT_SNAPSHOT_KEYS = snapshotKeys("disabled", "state");