/** * createForm - the HEADLESS core behind : values / errors / touched * state, required + rule + custom validation (validate-on-blur-then-live), and a * submit that validates every field first. No markup, no controls - you render * the fields however you like and read/write through the core. * * ```svelte * *
{ e.preventDefault(); form.submit() }}> * {#each fields as f} * form.setValue(f.name, e.currentTarget.value)} * onblur={() => form.handleBlur(f.name)} /> * {#if form.error(f.name)}{form.error(f.name)}{/if} * {/each} *
* ``` */ import { flattenFields, type FormField, type FormEntry } from './form-field' import { runRules } from './validators' /** Localizable strings the form generates itself (override via `messages`). */ export type FormMessages = { required: (label: string) => string minItems: (label: string, n: number) => string maxItems: (label: string, n: number) => string /** Shown by SvForm while an async validator runs. */ checking: string } const DEFAULT_FORM_MESSAGES: FormMessages = { required: (l) => `${l} is required`, minItems: (_l, n) => `Add at least ${n} item(s)`, maxItems: (_l, n) => `At most ${n} item(s)`, checking: 'Checking…', } export type FormConfig = { /** The schema - a mix of flat fields and titled sections. */ fields: () => ReadonlyArray /** Seed values on creation. */ initial?: Record /** Called with the (visible-field) values on a valid submit. May be async; * `submitting` stays true until the returned promise settles. */ onSubmit?: (values: Record) => void | Promise onChange?: (values: Record) => void /** Override the built-in generated strings (required / min-max items / checking). */ messages?: Partial } /** Deep-ish equality for dirty tracking: identity for primitives, structural * (JSON) for arrays/objects. Good enough for form values. */ function eq(a: unknown, b: unknown): boolean { if (Object.is(a, b)) return true if (a && b && typeof a === 'object' && typeof b === 'object') { try { return JSON.stringify(a) === JSON.stringify(b) } catch { return false } } return false } /** Resolve a `boolean | (values) => boolean` field flag. */ function resolveFlag( flag: boolean | ((values: Record) => boolean) | undefined, dflt: boolean, values: Record, ): boolean { if (flag == null) return dflt return typeof flag === 'function' ? !!flag(values) : !!flag } export function createForm(config: FormConfig) { const M: FormMessages = { ...DEFAULT_FORM_MESSAGES, ...(config.messages ?? {}) } let values = $state>({ ...(config.initial ?? {}) }) let errors = $state>({}) let touched = $state>({}) let submitting = $state(false) let validating = $state>({}) // Baseline for dirty tracking + reset. Rebased when `reset(next)` is passed. let baseline = { ...(config.initial ?? {}) } // Async-validation bookkeeping: debounce timers + a per-field run sequence so // only the latest response wins (stale responses are dropped). const asyncTimers = new Map>() const asyncSeq = new Map() // The flat field list (sections flattened) drives all values/validation. const flat = () => flattenFields(config.fields()) const fieldOf = (name: string) => flat().find((f) => f.name === name) const isVisible = (name: string) => resolveFlag(fieldOf(name)?.visible, true, values) const isDisabled = (name: string) => resolveFlag(fieldOf(name)?.disabled, false, values) function setError(name: string, err: string | null | undefined) { const next = { ...errors } if (err) next[name] = err else delete next[name] errors = next } /** The synchronous checks: required, then declarative rules, then a sync * `validate`. Returns the first error message, or null. `ctx` is the values * object rules/validate see - the form values, or a single item for array * rows (so within-row cross-field rules work). */ function syncError(field: FormField, v: any, ctx: Record = values): string | null { if (field.required && (v == null || v === '' || (Array.isArray(v) && !v.length))) return M.required(field.label) if (field.rules) { const e = runRules(v, field.rules, ctx); if (e) return e } if (field.validate) { const e = field.validate(v, ctx); if (e) return e } return null } function cancelAsync(name: string) { const t = asyncTimers.get(name) if (t) { clearTimeout(t); asyncTimers.delete(name) } // Bump the sequence so any in-flight response is treated as stale. asyncSeq.set(name, (asyncSeq.get(name) ?? 0) + 1) if (validating[name]) validating = { ...validating, [name]: false } } /** Run a field's async validator now (guarded against stale responses). */ async function runAsync(name: string): Promise { const field = fieldOf(name) if (!field?.asyncValidate || !isVisible(name)) return true if (syncError(field, values[name])) return false // sync error already shown const seq = (asyncSeq.get(name) ?? 0) + 1 asyncSeq.set(name, seq) validating = { ...validating, [name]: true } try { const err = await field.asyncValidate(values[name], values) if (asyncSeq.get(name) !== seq) return true // superseded - ignore setError(name, err ?? null) return !err } finally { if (asyncSeq.get(name) === seq) validating = { ...validating, [name]: false } } } function scheduleAsync(name: string) { const field = fieldOf(name) if (!field?.asyncValidate) return const prev = asyncTimers.get(name) if (prev) clearTimeout(prev) asyncTimers.set(name, setTimeout(() => { asyncTimers.delete(name); runAsync(name) }, field.asyncDebounce ?? 300)) } function validateField(name: string): boolean { const field = fieldOf(name) if (!field) return true // Computed fields are derived + read-only - never validated, never block submit. if (field.computed) { setError(name, null); return true } // Hidden fields never block submit; clear any stale error + pending check. if (!isVisible(name)) { setError(name, null); cancelAsync(name); return true } if (field.type === 'array') return validateArray(field) const err = syncError(field, values[name]) setError(name, err) if (err) { cancelAsync(name); return false } // Sync passed - kick off the debounced async check if there is one. if (field.asyncValidate) scheduleAsync(name) return true } // --- Field arrays (repeatable groups) ----------------------------------- const arrayItems = (name: string): Record[] => Array.isArray(values[name]) ? values[name] : [] const itemValue = (name: string, i: number, field: string) => arrayItems(name)[i]?.[field] const itemError = (name: string, i: number, field: string) => errors[`${name}.${i}.${field}`] function clearNamespacedErrors(name: string) { const prefix = `${name}.` const next = { ...errors } let changed = false for (const k of Object.keys(next)) if (k.startsWith(prefix)) { delete next[k]; changed = true } if (changed) errors = next } function setItems(name: string, next: Record[]) { values = { ...values, [name]: next } config.onChange?.(values) } function addItem(name: string, item?: Record) { const seed = item ?? Object.fromEntries((fieldOf(name)?.itemFields ?? []).map((f) => [f.name, undefined])) setItems(name, [...arrayItems(name), seed]) } function removeItem(name: string, i: number) { const items = arrayItems(name).slice() items.splice(i, 1) clearNamespacedErrors(name) // item indices shift - drop stale keys setItems(name, items) } function moveItem(name: string, from: number, to: number) { const items = arrayItems(name).slice() if (from < 0 || from >= items.length || to < 0 || to >= items.length) return const [it] = items.splice(from, 1) items.splice(to, 0, it as Record) clearNamespacedErrors(name) setItems(name, items) } function setItemValue(name: string, i: number, field: string, v: any) { setItems(name, arrayItems(name).map((it, ix) => (ix === i ? { ...it, [field]: v } : it))) if (touched[`${name}.${i}.${field}`]) validateItemField(name, i, field) } function handleItemBlur(name: string, i: number, field: string) { touched = { ...touched, [`${name}.${i}.${field}`]: true } validateItemField(name, i, field) } function validateItemField(name: string, i: number, fieldName: string): boolean { const itemField = fieldOf(name)?.itemFields?.find((f) => f.name === fieldName) if (!itemField) return true const item = arrayItems(name)[i] ?? {} const err = syncError(itemField, item[fieldName], item) setError(`${name}.${i}.${fieldName}`, err) return !err } function validateArray(field: FormField): boolean { const name = field.name const items = arrayItems(name) let arrErr: string | null = null if (field.required && items.length === 0) arrErr = M.required(field.label) else if (field.minItems != null && items.length < field.minItems) arrErr = M.minItems(field.label, field.minItems) else if (field.maxItems != null && items.length > field.maxItems) arrErr = M.maxItems(field.label, field.maxItems) setError(name, arrErr) let ok = !arrErr clearNamespacedErrors(name) // rebuild fresh per-item errors for (let i = 0; i < items.length; i++) for (const itf of field.itemFields ?? []) if (!validateItemField(name, i, itf.name)) ok = false return ok } /** True when `field` depends on `name` (cascading). */ function dependsOnName(field: FormField, name: string): boolean { const dep = field.dependsOn return dep == null ? false : Array.isArray(dep) ? dep.includes(name) : dep === name } function setValue(name: string, v: any) { let next = { ...values, [name]: v } // Cascading: clear any fields that depend on this one so a stale child // selection (e.g. a city under the old country) does not linger. for (const f of flat()) { if (dependsOnName(f, name) && next[f.name] != null) { next = { ...next, [f.name]: null } setError(f.name, null) } } values = next config.onChange?.(values) // Re-validate live once the field has been blurred at least once. if (touched[name]) validateField(name) } function handleBlur(name: string) { touched = { ...touched, [name]: true } validateField(name) } async function submit(): Promise { let ok = true const t: Record = { ...touched } for (const f of flat()) { if (!isVisible(f.name)) continue t[f.name] = true if (!validateField(f.name)) ok = false } touched = t if (!ok) return false // Await async validators (run now, skipping their debounce). Any failure blocks. const asyncFields = flat().filter((f) => f.asyncValidate && isVisible(f.name)) if (asyncFields.length) { for (const f of asyncFields) { const timer = asyncTimers.get(f.name); if (timer) { clearTimeout(timer); asyncTimers.delete(f.name) } } const results = await Promise.all(asyncFields.map((f) => runAsync(f.name))) if (results.some((r) => !r)) return false } // Exclude hidden fields from the submitted payload; keep any non-field keys. const payload = { ...values } for (const f of flat()) if (!isVisible(f.name)) delete payload[f.name] // Inject computed (derived) values so the payload carries them. for (const f of flat()) if (f.computed && isVisible(f.name)) payload[f.name] = f.computed(values) submitting = true try { await config.onSubmit?.(payload) } finally { submitting = false } return true } /** Validate a subset of fields by name (sync + their async validators). Used * to gate a wizard step before advancing. Returns true when all pass. */ async function validateFields(names: string[]): Promise { let ok = true const t = { ...touched } for (const name of names) { if (!isVisible(name)) continue t[name] = true if (!validateField(name)) ok = false } touched = t if (!ok) return false const asyncNames = names.filter((n) => fieldOf(n)?.asyncValidate && isVisible(n)) if (asyncNames.length) { for (const n of asyncNames) { const timer = asyncTimers.get(n); if (timer) { clearTimeout(timer); asyncTimers.delete(n) } } const results = await Promise.all(asyncNames.map((n) => runAsync(n))) if (results.some((r) => !r)) return false } return true } /** Restore values to the baseline (or a new one), clearing errors + touched. */ function reset(next?: Record) { if (next) baseline = { ...next } values = { ...baseline } errors = {} touched = {} config.onChange?.(values) } /** Inject errors (e.g. server-side validation after submit) and mark those * fields touched so the messages show. Empty message clears a field's error. */ function setErrors(map: Record) { const nextErr = { ...errors } const nextTouched = { ...touched } for (const [k, msg] of Object.entries(map)) { if (msg) { nextErr[k] = msg; nextTouched[k] = true } else delete nextErr[k] } errors = nextErr touched = nextTouched } const isFieldDirty = (name: string) => !eq(values[name], baseline[name]) /** Top-level field errors (visible fields only), for a form-level summary. */ function errorList(): { name: string; label: string; message: string }[] { const out: { name: string; label: string; message: string }[] = [] for (const f of flat()) { const m = errors[f.name] if (m && isVisible(f.name)) out.push({ name: f.name, label: f.label, message: m }) } return out } const firstErrorField = (): string | undefined => errorList()[0]?.name return { get values() { return values }, get submitting() { return submitting }, get isDirty() { const keys = new Set([...Object.keys(values), ...Object.keys(baseline)]) for (const k of keys) if (!eq(values[k], baseline[k])) return true return false }, value: (name: string) => { const f = fieldOf(name); return f?.computed ? f.computed(values) : values[name] }, error: (name: string): string | undefined => errors[name], isTouched: (name: string) => !!touched[name], hasError: (name: string) => !!errors[name], isValidating: (name: string) => !!validating[name], isFieldDirty, isVisible, isDisabled, entries: () => config.fields(), // Field arrays arrayItems, itemValue, itemError, addItem, removeItem, moveItem, setItemValue, handleItemBlur, setValue, handleBlur, validateField, validateFields, submit, reset, setErrors, errorList, firstErrorField, /** The resolved message strings (defaults merged with `config.messages`). */ messages: M, } } export type Form = ReturnType