/** * # createForm — runes-native form state * * A lightweight form state store built directly on Svelte 5 runes ($state, * $derived). No external stores, no register() calls — fields auto-register * via `Field.Provider`'s `name` prop once the Form context is present. * * Accepts any Standard Schema v1 validator (Zod, Valibot, ArkType, etc.) via * the `schema` option. Returns a reactive `Form` object whose properties * (`values`, `errors`, `isDirty`, `isTouched`, `isPending`, `isValid`) are * $state proxies — consumers read them directly, mutations ripple * automatically without subscribe/unsubscribe ceremony. */ import { untrack } from 'svelte'; import { SvelteMap } from 'svelte/reactivity'; import { type StandardSchemaV1, isPromiseLike } from '$libs/standard-schema'; // ── Public types ───────────────────────────────────────────────────────────── /** Aggregate error shape — keyed by dot-path, values are human messages. */ export type FormErrors = Record; export type ValidationBehaviour = 'progressive' | 'onSubmit' | 'onBlur' | 'onChange'; export type FormIssue = { message: string; params?: Record; code?: string; }; export type FormIssues = Record; /** Reason for the last invalid submit. */ export type SubmitFailureReason = 'validation' | 'cancelled'; /** Per-field UI state, derived by the form from values vs defaults + touched registry. */ export interface FieldState { /** Field has been blurred since last reset / mount. */ touched: boolean; /** Current value differs from the field's initial default. */ dirty: boolean; /** Errors scoped to this field. Empty array when valid. */ errors: string[]; /** Convenience — `errors.length > 0`. */ isInvalid: boolean; } /** Options for `createForm()`. */ export interface CreateFormOpts< Schema extends StandardSchemaV1 | undefined = undefined, Values = Schema extends StandardSchemaV1 ? StandardSchemaV1.InferInput : Record > { /** * Any Standard Schema validator (Zod/Valibot/ArkType/…). When present, * `form.validate()` runs on every `submit()` and fills `form.errors`. */ schema?: Schema; /** * Initial values. Used as the "pristine" snapshot for dirty tracking and * as the target of `reset()`. Mandatory when there is no schema. */ defaults: Values; /** * Called on submit **only when validation passes**. If it returns a * Promise, `isPending` stays `true` until it resolves. Thrown errors set * a generic form-level error (`form.errors['']`). */ onValidSubmit?: ( values: Schema extends StandardSchemaV1 ? StandardSchemaV1.InferOutput : Values, e: SubmitEvent ) => void | Promise; /** Called on submit **when validation fails**. */ onInvalidSubmit?: (errors: FormErrors, e: SubmitEvent) => void; /** * When `'progressive'` (default), validation stays quiet until the first * invalid submit, then upgrades to on-change feedback while the user fixes * the form. `'onSubmit'` only validates on submit. `'onBlur'` validates * after blur. `'onChange'` validates on every change (noisy — use sparingly). */ validationBehaviour?: ValidationBehaviour; } /** The reactive form handle returned by `createForm()`. */ export interface Form> { /** Mutable values. Bind inputs to `form.values.fieldName` directly. */ values: Values; /** Current errors keyed by dot-path. Empty object when valid. */ readonly errors: FormErrors; /** Raw structured issues keyed by dot-path, preserving params/code when present. */ readonly issues: FormIssues; /** `true` while an async `onValidSubmit` is in flight. */ readonly isPending: boolean; /** `true` when no errors. */ readonly isValid: boolean; /** `true` when any registered field's value differs from its default. */ readonly isDirty: boolean; /** `true` when any registered field has been blurred since last reset. */ readonly isTouched: boolean; /** Number of submit attempts (valid + invalid). */ readonly submitCount: number; /** Name of the first field that has an error (for focus management). */ readonly firstInvalidField: string | undefined; /** Read per-field state. Returns `undefined` when the field is not registered. */ getFieldState(name: string): FieldState | undefined; /** Mark a field as touched (internal — called by Field.Provider on blur). */ setFieldTouched(name: string, touched: boolean): void; /** Mark a field as untouched (internal — used by `reset()`). */ clearFieldTouched(name: string): void; /** Register a field (internal — called by Field.Provider on mount). */ registerField(name: string, meta: { label?: string }): void; /** Unregister a field (internal — on unmount). */ unregisterField(name: string): void; /** Run validation synchronously (async schemas run in the background). */ validate(): void; /** Imperative submit — returns `true` if valid and `onValidSubmit` completed. */ submit(e?: SubmitEvent): Promise; /** Reset values to `defaults`, clear errors/touched/submitCount. */ reset(): void; /** Internal attach point for the `
`'s `onsubmit`. */ onsubmit(e: SubmitEvent): void; } // ── Registry entry (internal) ──────────────────────────────────────────────── interface FieldEntry { name: string; label?: string; } type RawStandardIssue = StandardSchemaV1.Issue & { code?: string; params?: Record; }; // ── Implementation ─────────────────────────────────────────────────────────── /** * Create a runes-native form state store. Returns a reactive `Form` handle. * * @example * const form = createForm({ * schema: z.object({ email: z.string().email() }), * defaults: { email: '' }, * onValidSubmit: async (values) => { await api.save(values); } * }); * * // Later in markup: * * * * * */ export function createForm< Schema extends StandardSchemaV1 | undefined = undefined, Values = Schema extends StandardSchemaV1 ? StandardSchemaV1.InferInput : Record >(opts: CreateFormOpts): Form { const schema = opts.schema; const initial = structuredClone(opts.defaults) as Values; // ── Reactive state ────────────────────────────────────────────────────── const values = $state(structuredClone(opts.defaults) as Values); let errors = $state({}); let issues = $state({}); let isPending = $state(false); let submitCount = $state(0); let shouldValidateEagerly = $state(false); // SvelteMap (not plain Map) so `.set` / `.delete` / `.has` / `.keys` / // `.values` / `.size` reads in the derivations below react to per-entry // mutations. Plain `$state(new Map())` only tracks field reassignment — // `.set()` is silently non-reactive and `isTouched` / `isDirty` / // `firstInvalidField` would never re-run after a blur or field register // (A33). const touched = new SvelteMap(); const registry = new SvelteMap(); // ── Helpers ───────────────────────────────────────────────────────────── function dotPath(keys: readonly (PropertyKey | StandardSchemaV1.PathSegment)[]): string { return keys .map((k) => (typeof k === 'object' && k !== null && 'key' in k ? String(k.key) : String(k))) .join('.'); } function groupIssues(rawIssues: ReadonlyArray): { errors: FormErrors; issues: FormIssues; } { const outErrors: FormErrors = {}; const outIssues: FormIssues = {}; for (const issue of rawIssues as ReadonlyArray) { const key = issue.path ? dotPath(issue.path) : ''; (outErrors[key] ??= []).push(issue.message); (outIssues[key] ??= []).push({ message: issue.message, ...(issue.params === undefined ? {} : { params: issue.params }), ...(issue.code === undefined ? {} : { code: issue.code }) }); } return { errors: outErrors, issues: outIssues }; } function hasErrors(nextErrors: FormErrors): boolean { for (const key in nextErrors) { if ((nextErrors[key] ?? []).length > 0) return true; } return false; } function installValidationEffect(effect: () => void): void { try { $effect(effect); } catch (error) { const message = String((error as Error)?.message ?? error); if (!message.includes('effect_orphan')) throw error; $effect.root(() => { $effect(effect); }); } } function runValidate(): { errors: FormErrors; issues: FormIssues } { if (!schema) { return { errors: {}, issues: {} }; } const res = schema['~standard'].validate(values); if (isPromiseLike(res)) { // Async schema — kick off the promise, return the CURRENT sync // errors/issues as a placeholder. The read must be wrapped in // `untrack`: otherwise, when this function runs inside the // `validation` $effect, the effect would track `errors` + `issues` // as deps, then the `.then` microtask below writes them, which // retriggers the effect → another validate call → another `.then` // → infinite microtask-mediated loop (silent to Svelte's // synchronous effect-depth detector). See // `src/uix/soma/components/form/BUG-onchange-onblur-hang.md`. // // Note: sium's Standard-Schema adapter declares // `validate: async (...)` unconditionally, so this branch is taken // even for schemas with `~sium.async === false`. Without the // `untrack`, onChange/onBlur modes hang on mount. res .then((r) => { const grouped = r.issues ? groupIssues(r.issues) : { errors: {}, issues: {} }; errors = grouped.errors; issues = grouped.issues; }) .catch((e) => { errors = { '': [String((e as Error)?.message ?? e)] }; issues = { '': [{ message: String((e as Error)?.message ?? e) }] }; }); return untrack(() => ({ errors, issues })); } return res.issues ? groupIssues(res.issues) : { errors: {}, issues: {} }; } function runValidateForSubmit(): | { errors: FormErrors; issues: FormIssues } | Promise<{ errors: FormErrors; issues: FormIssues }> { if (!schema) { return { errors: {}, issues: {} }; } const res = schema['~standard'].validate(values); if (isPromiseLike(res)) { return res.then((resolved) => resolved.issues ? groupIssues(resolved.issues) : { errors: {}, issues: {} } ); } return res.issues ? groupIssues(res.issues) : { errors: {}, issues: {} }; } function isDirtyField(name: string): boolean { // Shallow compare using JSON serialization — good enough for the kinds // of values forms typically hold (primitives, small objects). Consumers // with custom equality can override `dirty` via `Field.Provider`'s prop. const path = name.split('.'); let a: unknown = values; let b: unknown = initial; for (const key of path) { a = (a as Record | null | undefined)?.[key]; b = (b as Record | null | undefined)?.[key]; } try { return JSON.stringify(a) !== JSON.stringify(b); } catch { return a !== b; } } // ── Derived ───────────────────────────────────────────────────────────── const isValid = $derived.by(() => { return !hasErrors(errors); }); const isDirty = $derived.by(() => { for (const name of registry.keys()) { if (isDirtyField(name)) return true; } return false; }); const isTouched = $derived.by(() => { for (const v of touched.values()) if (v) return true; return false; }); const firstInvalidField = $derived.by(() => { for (const name of registry.keys()) { if ((errors[name] ?? []).length > 0) return name; } // Fallback: first key in errors that matches a registered field. for (const key in errors) { if (registry.has(key) && errors[key].length > 0) return key; } return undefined; }); // ── On-blur validation (optional) ─────────────────────────────────────── const mode = opts.validationBehaviour ?? 'progressive'; if (mode === 'progressive') { installValidationEffect(() => { JSON.stringify(values); if (!shouldValidateEagerly) return; const validation = runValidate(); errors = validation.errors; issues = validation.issues; }); } else if (mode === 'onBlur' || mode === 'onChange') { installValidationEffect(() => { // Depend on values to re-run on change when onChange. if (mode === 'onChange') { // touch to track mutations JSON.stringify(values); } else { // onBlur — depend on touched map size. touched.size; } const validation = runValidate(); errors = validation.errors; issues = validation.issues; }); } // ── Public API ────────────────────────────────────────────────────────── function getFieldState(name: string): FieldState | undefined { if (!registry.has(name)) return undefined; const fieldErrors = errors[name] ?? []; return { touched: touched.get(name) ?? false, dirty: isDirtyField(name), errors: fieldErrors, isInvalid: fieldErrors.length > 0 }; } function setFieldTouched(name: string, v: boolean): void { touched.set(name, v); } function clearFieldTouched(name: string): void { touched.delete(name); } function registerField(name: string, meta: { label?: string }): void { registry.set(name, { name, ...meta }); } function unregisterField(name: string): void { registry.delete(name); // Don't clear errors — if the field was part of a schema, the errors // are still valid context until the next validate() call. } function validate(): void { const validation = runValidate(); errors = validation.errors; issues = validation.issues; } async function submit(e?: SubmitEvent): Promise { submitCount++; // Mark all registered fields as touched so errors display. for (const name of registry.keys()) touched.set(name, true); const validationResult = runValidateForSubmit(); const validation = isPromiseLike(validationResult) ? await validationResult : validationResult; errors = validation.errors; issues = validation.issues; const ok = !hasErrors(validation.errors); if (mode === 'progressive') { shouldValidateEagerly = !ok; } if (!ok) { opts.onInvalidSubmit?.(errors, e ?? new SubmitEvent('submit')); return false; } const validated = values as unknown as Schema extends StandardSchemaV1 ? StandardSchemaV1.InferOutput : Values; const handler = opts.onValidSubmit; if (!handler) return true; try { const out = handler(validated, e ?? new SubmitEvent('submit')); if (isPromiseLike(out)) { isPending = true; try { await out; } finally { isPending = false; } } return true; } catch (err) { errors = { ...errors, '': [String((err as Error)?.message ?? err)] }; issues = { ...issues, '': [{ message: String((err as Error)?.message ?? err) }] }; return false; } } function reset(): void { // Replace keys in-place so `bind:value={form.values.x}` keeps working. Object.keys(values as object).forEach((k) => { delete (values as Record)[k]; }); Object.assign(values as object, structuredClone(initial)); errors = {}; issues = {}; touched.clear(); submitCount = 0; shouldValidateEagerly = false; } function onsubmit(e: SubmitEvent): void { e.preventDefault(); void submit(e); } // ── Return reactive handle ────────────────────────────────────────────── return { values, get errors() { return errors; }, get issues() { return issues; }, get isPending() { return isPending; }, get isValid() { return isValid; }, get isDirty() { return isDirty; }, get isTouched() { return isTouched; }, get submitCount() { return submitCount; }, get firstInvalidField() { return firstInvalidField; }, getFieldState, setFieldTouched, clearFieldTouched, registerField, unregisterField, validate, submit, reset, onsubmit }; }