import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createSignal, omit, untrack } from "solid-js"; import type { FieldValidationMode } from "../internals/field-constants"; import { FormContext, type FormContextValue, type FormErrors, type FormFieldEntry, } from "../internals/form-context"; import { mergeRootProps } from "../internals/merge-root-props"; import { resolveClassStyle } from "../internals/resolve-class-style"; import { createStableCallback } from "../internals/stable-callback.ts"; import type { BaseUIComponentProps } from "../internals/types"; import { useValueChanged } from "../internals/use-value-changed.ts"; /** * Focus helper mirroring React's `focusControl` in `Form.tsx`: focuses the * element and, when it's an ``, selects its contents so the user can * immediately re-type. */ function focusInvalidControl(control: HTMLElement | null): void { if (!control) return; control.focus(); if (control.tagName === "INPUT") { (control as HTMLInputElement).select(); } } /** * Pick the "first" invalid field. Prefers DOM order via * `compareDocumentPosition` when the controls are connected to a document; * falls back to registration order (the Map's insertion order — which matches * mount order and therefore DOM order for most apps) when either control is * detached or the positions are identical. */ function firstInvalidInDomOrder( invalidFields: readonly FormFieldEntry[], ): FormFieldEntry | undefined { if (invalidFields.length === 0) return undefined; if (invalidFields.length === 1) return invalidFields[0]; let best = invalidFields[0]; for (let i = 1; i < invalidFields.length; i++) { const candidate = invalidFields[i]; const a = best?.controlRef.current; const b = candidate?.controlRef.current; if (!a || !b) continue; // Node.DOCUMENT_POSITION_FOLLOWING (0x04) — `b` follows `a`, keep `a`. // Node.DOCUMENT_POSITION_PRECEDING (0x02) — `b` precedes `a`, swap. const position = a.compareDocumentPosition(b); if (position & 0x02) { best = candidate; } } return best; } export type { FormContextValue, FormErrors, FormFieldEntry }; export type FormValidationMode = FieldValidationMode; // The form has no state exposed to children — state-driven `class`/`style` // callbacks will receive an empty object. Using `Record` // communicates "intentionally empty shape" (biome's `noEmptyInterface` / // `noEmptyBlockStatements` don't flag it). export type FormState = Record; export interface FormProps extends BaseUIComponentProps<"form", FormState> { /** * Tag/component to render. Defaults to `"form"`. */ as?: ValidComponent; /** * Determines when the form should be validated. The `validationMode` * prop on `` takes precedence over this. * * @default "onSubmit" */ validationMode?: FormValidationMode | undefined; /** * Validation errors returned externally (e.g. from server submission). * Keys correspond to ``'s `name` attribute; values are the * error(s) for that field. */ errors?: FormErrors | undefined; /** * Called when the consumer submits the form AND every registered field * passed validation. The native submit event is passed through — the * consumer is free to call `preventDefault()` as needed. */ onSubmit?: JSX.EventHandler | undefined; /** * Called when submission is blocked because one or more registered * fields failed validation. Base UI already calls `preventDefault()` on * the submit event before invoking this handler. */ onSubmitValidationFailed?: | ((event: SubmitEvent, invalidFields: FormFieldEntry[]) => void) | undefined; } /** * A native `
` element that coordinates cross-field validation with * any `` descendants. * * On native submit: * 1. Flip `submitAttemptedRef.current` to `true`. * 2. Iterate `formRef.current.fields` and call each field's `validate`. * 3. If any field reports `validityData.state.valid === false`, call * `preventDefault()` on the submit event and invoke * `onSubmitValidationFailed`. * 4. Otherwise pass the event through to the consumer's `onSubmit`. * * On native reset: clear the internal error map and reset * `submitAttemptedRef`. * * Documentation: [Base UI Form](https://base-ui.com/react/components/form) */ export function Form(props: FormProps): JSX.Element { // `props.errors` can change across renders. We surface an internal // accessor so consumers of `FormContext.errors` re-run when the external // prop changes OR when we clear entries locally (e.g. via // `clearErrors(name)` on reset). // // Starting value comes from the current prop. `useValueChanged` below // mirrors later changes of the external prop into this signal — the // clear-errors handler writes into it too, so we own it outright. // // `ownedWrite: true` because we write the initial mirrored value from // `useValueChanged`'s apply callback (unowned — fine) AND from the // native reset handler (event scope — fine); the opt-in only exists to // silence the dev guard uniformly. // // The option is `ownedWrite` (Solid's runtime name — the migration doc // calls it `ownedWrite` but the actual API is `ownedWrite`). Used here // because the clear-errors handler and the reset handler both want to // write into this from inside owned-like scopes (event scope is // unowned, but Solid's guard is conservative). Matches the pattern // from `render-element.tsx` for the host-element signal. // Initial value is a body-time snapshot — `useValueChanged` below // mirrors `props.errors` changes into this signal, so the seed read // is intentionally non-tracking. const [errors, setErrorsSignal] = createSignal( untrack(() => props.errors ?? {}), { ownedWrite: true }, ); const errorsAccessor: () => FormErrors = () => errors(); // Mirror external `errors` prop changes into the internal signal. The // apply callback runs in unowned scope per `useValueChanged`'s contract // so the write is safe. useValueChanged( () => props.errors, () => { setErrorsSignal(props.errors ?? {}); }, ); // Plain mutable refs — identity is stable for the ``'s lifetime // and never flows through the reactive graph. Each `` instance // gets its own fresh `fields` map; this is what prevents cross-instance // leaks from the shared `NOOP_FORM_CONTEXT` sentinel. const formRef: FormContextValue["formRef"] = { current: { fields: new Map() }, }; const submitAttemptedRef: FormContextValue["submitAttemptedRef"] = { current: false, }; const setErrors = (next: FormErrors): void => { setErrorsSignal(next); }; // `clearErrors` is a stable trampoline so long-lived consumers (fields // stashing the handler in a registration record) don't get stale // closures. The inner function sees the current `errors()` snapshot on // each invocation. const clearErrors = createStableCallback<(name: string | undefined) => void>( () => (name: string | undefined) => { if (name === undefined) { setErrorsSignal({}); return; } const current = errors(); if (!Object.hasOwn(current, name)) return; const next = { ...current }; delete next[name]; setErrorsSignal(next); }, ); const validationMode = (): FormValidationMode => props.validationMode ?? "onSubmit"; const contextValue: FormContextValue = { formRef, errors: errorsAccessor, setErrors, clearErrors, validationMode, submitAttemptedRef, present: true, }; // Wrap consumer-supplied handlers so fields that capture the trampoline // at mount time always dispatch to the latest prop closure. const onSubmitCallback = createStableCallback< NonNullable >(() => props.onSubmit); const onSubmitValidationFailedCallback = createStableCallback< NonNullable >(() => props.onSubmitValidationFailed); // The native submit handler. Runs in event scope (unowned) so all // writes are safe — this is the single orchestration point between // native form submit and the field registry. // // Sync vs async: each field's `validate()` returns `void` for sync // validators (writes already landed) or `Promise` for async // validators (`zod.parseAsync`, async refinements, server-side // checks). When ALL fields are sync we keep React-parity sync // behaviour; when ANY field is async we `preventDefault` upfront, // `Promise.all` the pending validators, then read validity and // dispatch. The async branch is the path that lets server-side // validation gate submission cleanly. const finishSubmit = ( event: SubmitEvent & { currentTarget: HTMLFormElement; target: Element; }, ): void => { const invalidFields = Array.from(formRef.current.fields.values()).filter( (field) => field.validityData.state.valid === false, ); if (invalidFields.length > 0) { if (!event.defaultPrevented) { event.preventDefault(); } // Match React's `SubmitOnInvalid` behavior: focus the first invalid // control (in DOM order) so keyboard users land on the field that // blocked the submit. Happens BEFORE dispatching to the consumer's // `onSubmitValidationFailed` so the handler sees the focused state // if it inspects `document.activeElement`. focusInvalidControl( firstInvalidInDomOrder(invalidFields)?.controlRef.current ?? null, ); onSubmitValidationFailedCallback(event, invalidFields); return; } onSubmitCallback(event); }; const handleSubmit: JSX.EventHandler = ( event, ) => { submitAttemptedRef.current = true; const fields = Array.from(formRef.current.fields.values()); const pending: Promise[] = []; for (const field of fields) { const r = field.validate(true); if (r) pending.push(r); } if (pending.length === 0) { // All-sync path: identical to the React-parity behavior. Keeps // native form auto-submission alive when validation passes and // the consumer doesn't `preventDefault` themselves. finishSubmit(event); return; } // At least one async validator is in flight. Native auto-submission // must be cancelled NOW (before crossing the microtask boundary) // because the default action runs synchronously after this handler // returns. The Promise.all chain then runs in a microtask, by which // point native submission is no longer reachable for `event`. event.preventDefault(); void Promise.all(pending).then(() => finishSubmit(event)); }; const handleReset: JSX.EventHandler = () => { setErrorsSignal({}); submitAttemptedRef.current = false; }; // Strip our own API props (and `as`) before spreading user HTML attrs. // `children` is stripped because our provider wrapper owns the children // subtree below. `onSubmit` is stripped because our chained handler is // the sole submit handler — `createStableCallback` already dispatches to // the latest consumer closure. const userStyling = resolveClassStyle(props, () => ({})); const userHtmlProps = omit( props, "class", "style", "attrs", "as", "children", "errors", "validationMode", "onSubmit", "onSubmitValidationFailed", ) as Record; const internalProps = { noValidate: true, onSubmit: handleSubmit, onReset: handleReset, }; const styledUserProps = mergeRootProps(userStyling, userHtmlProps); const merged = mergeRootProps(internalProps, styledUserProps); const Tag = dynamic(() => props.as ?? "form"); return ( {props.children} ); }