import type { JSX } from "@solidjs/web"; import { type Accessor, createContext, createEffect, createMemo, createSignal, createUniqueId, onCleanup, runWithOwner, untrack, useContext, } from "solid-js"; import { createDefaultValidityData, type FieldRegistration, type FieldValidateFn, type FieldValidationMode, type FieldValidationProps, type FieldValidityData, type MutableRef, } from "../internals/field-context"; import { type FormFieldEntry, useFormContext } from "../internals/form-context"; 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 { useFieldValidation } from "../internals/use-field-validation/use-field-validation"; /** * Why a value/touched/lifecycle change happened. Emitted as the third * argument of `onValueChange(next, event, reason)` and accepted by * imperative setters. * * - `"input"` — change originated from the control's `onInput` handler. * - `"reset"` — change originated from {@link FieldApi.reset}. * - `"imperative"` — programmatic change with no originating DOM event, * or caller-supplied reason not covered above. */ export type FieldChangeReason = "input" | "reset" | "imperative"; /** * Options for {@link createField}. * * Everything reactive accepts `MaybeAccessor` so a consumer can flip * `disabled`/`invalid`/`name` etc. at runtime without re-creating the * primitive. */ export interface CreateFieldOptions { /** * Value accessor. Consumer-owned signal — `createField` reads through * this and never owns its own value state. The setup-time read is * captured and used as the reset target (`reset()` fires * `onValueChange(initial, event, "reset")` so the consumer can * restore their signal). See `solidify.md` §6. */ value?: MaybeAccessor; /** * Pre-change callback. Called synchronously before the internal value * write. The consumer may call `event.preventDefault()` to veto the * change. * * **Pre/post asymmetry — important.** Reading `c.value()` inside this * handler returns the OLD value. For post-change observation, use * `createEffect(c.value, (next) => ...)`. */ onValueChange?: (value: V, event: Event, reason: FieldChangeReason) => void; /** Whether interaction is suppressed. */ disabled?: MaybeAccessor; /** * Force `valid: false` regardless of validator output. Useful when * field validity is owned by an external system (server response, * separate model layer). */ invalid?: MaybeAccessor; /** * External `dirty` override. When defined, `c.dirty()` reads this * instead of the internal flip-flop. Setter calls become no-ops * (consumer is the source of truth). */ dirty?: MaybeAccessor; /** External `touched` override. Same shape as {@link dirty}. */ touched?: MaybeAccessor; /** * Identifier this field uses when registering into a surrounding * `
`. Optional — when omitted, the field still validates but * isn't keyed into the form's `fields` map by name (server-error * lookup uses this name; cross-field `formValues` keys by it). */ name?: MaybeAccessor; /** * Custom validator. Receives the current value and a snapshot of all * sibling field values keyed by name. Return a string / list of * strings (errors) or `null` (valid). Both sync and `async` shapes * are supported — `async` validators (`zod.parseAsync`, async * refinements, server-side checks) are awaited by the surrounding * `` on submit before validity is read. */ validate?: FieldValidateFn; /** * When validation should run. Inherited from `` * if undefined. * * - `"onSubmit"` — only on form submit (default after a failed submit * triggers re-validation on every change). * - `"onBlur"` — when the control loses focus. * - `"onChange"` — every input event (with optional debounce). */ validationMode?: MaybeAccessor; /** Debounce for `validationMode === "onChange"` (ms). */ validationDebounceTime?: MaybeAccessor; } /** * Public API returned by {@link createField}. Flat accessors at the root — * no `state` blob. * * The `[SNAPSHOT_KEYS]` symbol is attached post-construction (NOT declared * on this interface; see `primitive-template.md` §2.5) so `snapshot(c)` * returns a debug-friendly plain-object view of the field's logical state. */ export interface FieldApi { /** Current value (controlled accessor wins; falls back to internal). */ value: Accessor; /** * Imperative value setter. Fires `onValueChange(next, event, reason)` * before the internal write; consumer can `event.preventDefault()` to * veto. */ setValue: (next: V, event?: Event, reason?: FieldChangeReason) => void; /** Whether interaction is suppressed. */ disabled: Accessor; /** External-invalid override or form-level error presence. */ invalid: Accessor; /** Whether the user has typed/cleared since mount. */ dirty: Accessor; /** Whether the control has lost focus at least once. */ touched: Accessor; /** Whether the input element currently has a non-empty value. */ filled: Accessor; /** Whether the input element currently has focus. */ focused: Accessor; setDirty: (value: boolean) => void; setTouched: (value: boolean) => void; setFilled: (value: boolean) => void; setFocused: (value: boolean) => void; /** Raw validity record (state + errors + value snapshot). */ validity: Accessor; /** * Tri-state: `true` (passed all checks), `false` (custom or native * error), `null` (not yet validated). */ valid: Accessor; /** True while an async `validate` is in-flight. */ validating: Accessor; /** Active error messages from the last validation. */ errors: Accessor; /** * First / primary error, if any. Convenience for the common * single-error rendering case. */ error: Accessor; /** * Imperatively validate the current value. Returns the in-flight * Promise when `options.validate` is `async` (so callers can `await`); * returns `void` when the validator is sync (writes have already * landed). The surrounding ``'s submit path uses the same * shape to gate submission on async validators. */ validate: (event?: Event) => Promise | void; /** * Restore the value to the setup-time captured initial, clear * touched/dirty/validity. Fires `onValueChange(initial, event, * "reset")` with the synthesized event so consumers can observe (and * optionally veto). */ reset: (event?: Event) => void; /** * Control slot — getter-bag of HTML attributes ready for spreading * onto an `` (or any compatible element). Includes the * validation pipeline's `onInput` handler, `aria-invalid`, focus/blur * handlers, and the registered control id. */ control: ( userProps?: U, ) => FieldControlSlotProps & (U extends object ? U : object); /** Directive form of {@link control}. */ controlRef: () => (el: HTMLInputElement | null) => void; /** * Context provider. Wrap a subtree to make this api discoverable via * {@link useField} without prop-drilling. */ Provider: (props: { children: JSX.Element }) => JSX.Element; /** * Internal bridge surface for `` composition. Not part of * the public API — see {@link FieldApiInternal}. */ _internal: FieldApiInternal; } /** * Bridge surface exposed on `api._internal`. Consumed by `` to * compose the `FieldContextValue` without duplicating state. **Not part of * the public API** — the leading underscore signals intent; downstream code * should use `FieldApi` members directly. */ export interface FieldApiInternal<_V = unknown> { /** The raw `validate` option as a reactive accessor (for useFieldValidation). */ validateFn: Accessor; /** Props-factories + inputRef + commit from the composed validation pipeline. */ validation: FieldValidationProps; /** The `markedDirtyRef` used to gate `valueMissing`-only errors. */ markedDirtyRef: MutableRef; /** Resolved validation mode (inherits from Form). */ validationMode: Accessor; /** Debounce time in ms for `onChange` mode. */ validationDebounceTime: Accessor; /** Whether validation should run on every input change. */ shouldValidateOnChange: Accessor; /** Direct setter for `validityData` (bypasses the tracked commit path). */ setValidityData: (next: FieldValidityData) => void; /** Combined field state for `data-*` projections. */ state: Accessor<{ disabled: boolean; touched: boolean; dirty: boolean; valid: boolean | null; filled: boolean; focused: boolean; }>; /** * Register (or unregister) a control via the same symbol+registration * protocol used by ``. FieldRoot builds its own thin * `registerFieldControl` on top of this to sync `initialValue` while * leaving form-entry management to createField. * * `controlRef` on the registration is intentionally not used here — * `Field.Control` already writes `field.validation.inputRef.current` * directly via its `ref` prop, which is the same ref `useFieldValidation` * reads for `element.validity` checks. */ pushInitialValue: (registration: FieldRegistration | undefined) => void; /** * Override createField's auto-generated controlId. FieldRoot's bridge * calls this from `registerFieldControl` so the form-fields-map key * matches the actual ``'s DOM id (which comes from the * labelable-provider id pool, not createField's `createUniqueId()`). */ setControlId: (id: string) => void; } /** * Shape of attributes emitted by `c.control()`. Intersected with any * caller-supplied extras at the call site. */ export interface FieldControlSlotProps { id: string; name: string | undefined; disabled: boolean; "aria-invalid": "true" | "false"; onInput: (event: InputEvent & { currentTarget: HTMLInputElement }) => void; onFocus: (event: FocusEvent) => void; onBlur: (event: FocusEvent & { currentTarget: HTMLInputElement }) => void; } const FieldApiContext = createContext | null>(null); /** * Read the nearest {@link FieldApi} provided by `c.Provider`. Throws if * called outside one — match the * `useCollapsible()` / `useToggle()` pattern. */ export function useField(): FieldApi { const ctx = useContext(FieldApiContext); if (ctx == null) { throw new Error( "Base UI: useField() must be called inside a .", ); } return ctx as FieldApi; } /** * Solid 2.0-native primitive for a single form field. Owns value * (controlled/uncontrolled), interaction lifecycle (touched/dirty/ * filled/focused), and the validation pipeline. Composes * `useFieldValidation` under the hood — async validators (`async fn`, * `zod.parseAsync`, server-side checks) are first-class and gate the * surrounding ``'s submit when used inside one. * * Consumers can drive markup either via `c.control()` (slot getter-bag * spread onto an ``) or via `c.controlRef()` (directive form, for * cva-style variants and any consumer-owned JSX). Both surfaces wire the * same internal handlers. * * When called inside a ``, the field auto-registers into the form's * fields map under the generated control id so submit-time validation * can find it. Outside a Form, the field works standalone. */ export function createField( options: CreateFieldOptions = {}, ): FieldApi { const form = useFormContext(); // ---------- value: consumer-owned signal ---------- // Solid-native shape (2026-04-30, §4.1): no internal signal, no // defaultValue. Consumer brings the accessor; `setValue` is a pure // event-emitter that fires `onValueChange`. The consumer's signal // write is the only writer. // // We capture the initial value once at setup so `reset()` has a // target to fire `onValueChange(initial, …, "reset")` against; the // consumer is then expected to update their signal in response. const initialValueAtSetup = untrack(() => readOption(options.value)); const value = createMemo(() => readOption(options.value), { name: "field.value", }); const setValue = ( next: V, event?: Event, reason: FieldChangeReason = "imperative", ): void => { const ev = event ?? new Event("valuechange", { cancelable: true }); // Use runWithOwner(null, ...) to escape the current reactive owner so // that consumer signal writes inside onValueChange don't trigger // SIGNAL_WRITE_IN_OWNED_SCOPE. Event-emitter pattern — exits the // reactive graph intentionally. runWithOwner(null, () => options.onValueChange?.(next, ev, reason)); }; // ---------- lifecycle flip-flops ---------- const [internalDirty, setInternalDirty] = createSignal(false, { ownedWrite: true, name: "field.dirty", }); const [internalTouched, setInternalTouched] = createSignal(false, { ownedWrite: true, name: "field.touched", }); const [filled, setFilledInner] = createSignal(false, { ownedWrite: true, name: "field.filled", }); const [focused, setFocusedInner] = createSignal(false, { ownedWrite: true, name: "field.focused", }); const dirty = createMemo( () => readOption(options.dirty) ?? internalDirty(), { name: "field.dirtyResolved" }, ); const touched = createMemo( () => readOption(options.touched) ?? internalTouched(), { name: "field.touchedResolved" }, ); // `markedDirtyRef` gates valueMissing: native `required` errors only // surface once the user has touched the field, so a fresh form // doesn't render every empty required field as invalid on mount. // Plain mutable ref — NOT a signal. Mirrors React's pattern. const markedDirtyRef: MutableRef = { current: false }; const setDirty = (next: boolean): void => { if (readOption(options.dirty) !== undefined) return; if (next) markedDirtyRef.current = true; setInternalDirty(next); }; const setTouched = (next: boolean): void => { if (readOption(options.touched) !== undefined) return; setInternalTouched(next); }; const setFilled = (next: boolean): void => { setFilledInner(next); }; const setFocused = (next: boolean): void => { setFocusedInner(next); }; // ---------- disabled / invalid ---------- const disabled = createMemo( () => Boolean(readOption(options.disabled)), { name: "field.disabled" }, ); const name = (): string | undefined => readOption(options.name); // `invalid` combines the consumer override with any Form-level error // for this field's name. Mirrors ``'s logic. const invalid = createMemo( () => { if (readOption(options.invalid) === true) return true; const n = name(); if (!n) return false; const errs = form.errors(); return Object.hasOwn(errs, n) && errs[n] !== undefined; }, { name: "field.invalid" }, ); // ---------- validation mode ---------- const validationMode = createMemo( () => readOption(options.validationMode) ?? form.validationMode(), { name: "field.validationMode" }, ); const validationDebounceTime = (): number => readOption(options.validationDebounceTime) ?? 0; const shouldValidateOnChange = createMemo( () => { const mode = validationMode(); return ( mode === "onChange" || (mode === "onSubmit" && form.submitAttemptedRef.current) ); }, { name: "field.shouldValidateOnChange" }, ); // ---------- validity ---------- const [validityData, setValidityDataInner] = createSignal( createDefaultValidityData(), { ownedWrite: true, name: "field.validityData" }, ); const setValidityData = (next: FieldValidityData): void => { setValidityDataInner(next); }; const valid = createMemo( () => { if (invalid()) return false; return validityData().state.valid; }, { name: "field.valid" }, ); const errors = createMemo(() => validityData().errors, { name: "field.errors", }); const error = createMemo(() => validityData().error, { name: "field.error", }); // `validating`: in-flight counter for async commits. Increments at the // start of every commit, decrements after the await chain settles. // Counter (not boolean) so concurrent calls don't false-resolve to // `false` when an inner one settles before the outer. const [inFlightCount, setInFlightCount] = createSignal(0, { ownedWrite: true, name: "field.inFlightCount", }); const validating = createMemo(() => inFlightCount() > 0, { name: "field.validating", }); // ---------- composed validation pipeline ---------- // `useFieldValidation` is the engine. Wrap its `commit` so we can // track in-flight count for the `validating` accessor. const validateAccessor = (): FieldValidateFn | undefined => options.validate; // Stable controlId. `useFieldValidation` keys back into Form's fields // map under this; it's also stamped on the `` we render via // `c.control()` so server-error lookup-by-name and DOM-id-by-control // stay aligned. // // Signal (not constant) so the structural FieldRoot bridge can swap // in the actual DOM input id when `` registers via // `registerFieldControl` (the labelable-provider id pool gives the // input its own id; the form-fields-map key must match it so // `formRef.fields.has(input.id)` finds the entry). const fallbackControlId = createUniqueId(); const [controlIdSignal, setControlIdSignal] = createSignal( fallbackControlId, { ownedWrite: true, name: "field.controlId" }, ); const controlIdAccessor = (): string => controlIdSignal(); // Named state memo — shared with useFieldValidation and exposed via // `_internal.state` so FieldRoot can build FieldContextValue.state. const state = createMemo( () => ({ disabled: disabled(), touched: touched(), dirty: dirty(), valid: valid(), filled: filled(), focused: focused(), }), { name: "field.state" }, ); const validation = useFieldValidation({ setValidityData, validate: validateAccessor, validityData, validationDebounceTime, invalid, markedDirtyRef, state, name, controlId: controlIdAccessor, shouldValidateOnChange, }); const trackedCommit = (v: unknown, revalidate?: boolean): Promise => { setInFlightCount((c) => c + 1); const p = validation.commit(v, revalidate); // `validation.commit` always returns a Promise (the underlying // commit body is async); decrement once it settles, regardless of // outcome. We do NOT await here — caller decides whether to. p.finally(() => setInFlightCount((c) => c - 1)); return p; }; // ---------- imperative validate ---------- const validate = (event?: Event): Promise | void => { // Synthesize an event purely for parity with `setValue` / `reset`'s // shape — we don't currently expose a pre-validate hook, but we // honor `defaultPrevented` if a consumer wires one. const ev = event ?? new Event("validate", { cancelable: true }); if (ev.defaultPrevented) return; markedDirtyRef.current = true; const userValidate = options.validate; const isAsync = userValidate?.constructor.name === "AsyncFunction"; const result = trackedCommit(untrack(value)); if (isAsync) return result; return undefined; }; // ---------- reset ---------- // Fire `onValueChange(initialValue, event, "reset")` against the value // captured at setup; the consumer is expected to update their signal // in response. createField does NOT write the value itself — same // rule as setValue. Lifecycle flags + validity ARE cleared internally // since they're owned here. const reset = (event?: Event): void => { const ev = event ?? new Event("reset", { cancelable: true }); // runWithOwner(null) escapes the reactive owner so consumer signal // writes inside onValueChange don't trigger SIGNAL_WRITE_IN_OWNED_SCOPE. runWithOwner(null, () => options.onValueChange?.(initialValueAtSetup as V, ev, "reset"), ); setInternalDirty(false); setInternalTouched(false); setFilledInner(false); setFocusedInner(false); markedDirtyRef.current = false; setValidityDataInner( createDefaultValidityData(initialValueAtSetup ?? null), ); }; // ---------- form integration ---------- // Whenever this primitive lives inside a ``, register a // `FormFieldEntry` so submit-time validation can find us. The entry // is keyed by `controlId`. We re-write on every value change so the // snapshot the form captures stays current. // Always register — even when `form.present === false` (i.e. no // `` ancestor), the consumer-supplied noop context has its own // `formRef.current.fields` Map that test fixtures and external consumers // inspect via `useFieldContext` lookups. Skipping the write would break // the "test registers the control into Form's fields map under its id" // contract that field-root previously enforced unconditionally. { const fallbackControlRef: MutableRef = { current: null, }; const entry = (): FormFieldEntry => ({ name: name(), getValue: () => untrack(value), controlRef: (validation.inputRef as MutableRef) ?? fallbackControlRef, validityData: validityData(), validate: () => { // Match `validateControl()` in field-root: detect async via // the user's validator shape and only return the Promise to // Form when one is in-flight, so all-sync forms keep their // React-parity sync submit. markedDirtyRef.current = true; const userValidate = options.validate; const isAsync = userValidate?.constructor.name === "AsyncFunction"; const r = trackedCommit(untrack(value)); if (isAsync) return r; return undefined; }, }); // Track the previous key so a controlId swap (when FieldRoot's // bridge calls `_internal.setControlId(input.id)`) can delete the // old entry before writing under the new key. let lastKey: string | undefined; const writeEntry = (): void => { const key = controlIdSignal(); if (lastKey !== undefined && lastKey !== key) { form.formRef.current.fields.delete(lastKey); } form.formRef.current.fields.set(key, entry()); lastKey = key; }; // Initial registration. untrack(writeEntry); // Refresh on controlId / value / name / validity change so the // snapshot is current and the key tracks the registered control id. createEffect( () => { controlIdSignal(); value(); name(); validityData(); }, () => { untrack(writeEntry); }, ); onCleanup(() => { if (lastKey !== undefined) { form.formRef.current.fields.delete(lastKey); } }); } // ---------- bridge: initial-value sync for FieldRoot ---------- // When `` wraps this primitive, `` registers // itself via `registerFieldControl`. FieldRoot calls `pushInitialValue` // so the first-known value is seeded into `validityData.initialValue` // (mirrors the old `syncInitialValue` in FieldRoot). const pushInitialValue = ( registration: FieldRegistration | undefined, ): void => { if (!registration) return; untrack(() => { let initialValue = registration.value; if (initialValue === undefined && registration.getValue) { initialValue = registration.getValue(); } const cur = validityData(); if (cur.initialValue === null && initialValue !== null) { setValidityData({ ...cur, initialValue }); } }); }; // ---------- control slot ---------- const controlInternal = { get id() { return controlIdSignal(); }, get name() { return name(); }, get disabled() { return disabled(); }, get "aria-invalid"() { return valid() === false ? ("true" as const) : ("false" as const); }, onFocus(_event: FocusEvent): void { setFocused(true); }, onBlur(event: FocusEvent & { currentTarget: HTMLInputElement }): void { setTouched(true); setFocused(false); if (validationMode() === "onBlur") { void trackedCommit(event.currentTarget.value); } }, onInput(event: InputEvent & { currentTarget: HTMLInputElement }): void { const next = event.currentTarget.value; setDirty(next !== validityData().initialValue); setFilled(next !== ""); // Mirror the value through the controlled-or-internal channel. // `setValue` honors the `event.preventDefault()` veto and // dispatches `onValueChange` with the live `InputEvent`. setValue(next as V, event, "input"); // Validation-on-change lives in `useFieldValidation`'s // `getInputValidationProps().onInput` — but since we're not // splatting that handler (we own the control bag here), do // the equivalent dispatch ourselves: clear form-level errors // for our name, then commit either with revalidate=true (when // not validating on change) or directly (when on-change). form.clearErrors(name()); if (!shouldValidateOnChange()) { void trackedCommit(next, true); return; } if (next === "") { // Empty bypasses debounce so `required` clears immediately. void trackedCommit(next); return; } const debounce = validationDebounceTime(); if (debounce > 0) { // We don't ship a timer instance here — keep the simple // path: trigger the commit. Consumers wanting debounced // validation should use the structural // which composes `useFieldValidation`'s debounced // handler. createField's slot is the no-debounce path. void trackedCommit(next); } else { void trackedCommit(next); } }, }; const control = (userProps?: U) => mergeRootProps( controlInternal as Record, userProps, ) as FieldControlSlotProps & (U extends object ? U : object); const controlRef = (): ((el: HTMLInputElement | null) => void) => { const baseDirective = makeSlotRefDirective( () => control() as Record, ); // The validation pipeline needs access to the live element for // `element.validity` reads + `setCustomValidity()` writes. Layer // the inputRef.current assignment on top of the slot-ref // directive's own ref-application step. return (el: HTMLInputElement | null): void => { validation.inputRef.current = el; baseDirective(el); }; }; // ---------- assemble api ---------- const api: FieldApi = { value, setValue, disabled, invalid, dirty, touched, filled, focused, setDirty, setTouched, setFilled, setFocused, validity: validityData, valid, validating, errors, error, validate, reset, control, controlRef, Provider: (props) => ( }> {props.children} ), _internal: { validateFn: validateAccessor, validation, markedDirtyRef, validationMode, validationDebounceTime, shouldValidateOnChange, setValidityData, state, pushInitialValue, setControlId: (id: string) => { setControlIdSignal(id); }, }, }; (api as FieldApi & Snapshottable)[SNAPSHOT_KEYS] = FIELD_SNAPSHOT_KEYS; return api; } const FIELD_SNAPSHOT_KEYS = snapshotKeys>( "value", "disabled", "invalid", "dirty", "touched", "filled", "focused", "valid", "validating", "errors", "error", );