You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
506 lines
17 KiB
506 lines
17 KiB
/**
|
|
* # 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<string, string[]>;
|
|
export type ValidationBehaviour = 'progressive' | 'onSubmit' | 'onBlur' | 'onChange';
|
|
export type FormIssue = {
|
|
message: string;
|
|
params?: Record<string, unknown>;
|
|
code?: string;
|
|
};
|
|
export type FormIssues = Record<string, FormIssue[]>;
|
|
|
|
/** 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<Schema>
|
|
: Record<string, unknown>
|
|
> {
|
|
/**
|
|
* 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<Schema> : Values,
|
|
e: SubmitEvent
|
|
) => void | Promise<void>;
|
|
/** 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<Values = Record<string, unknown>> {
|
|
/** 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<boolean>;
|
|
/** Reset values to `defaults`, clear errors/touched/submitCount. */
|
|
reset(): void;
|
|
/** Internal attach point for the `<form>`'s `onsubmit`. */
|
|
onsubmit(e: SubmitEvent): void;
|
|
}
|
|
|
|
// ── Registry entry (internal) ────────────────────────────────────────────────
|
|
|
|
interface FieldEntry {
|
|
name: string;
|
|
label?: string;
|
|
}
|
|
|
|
type RawStandardIssue = StandardSchemaV1.Issue & {
|
|
code?: string;
|
|
params?: Record<string, unknown>;
|
|
};
|
|
|
|
// ── 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:
|
|
* <Form.Provider {form}>
|
|
* <Field.Provider name="email">
|
|
* <input bind:value={form.values.email} />
|
|
* </Field.Provider>
|
|
* </Form.Provider>
|
|
*/
|
|
export function createForm<
|
|
Schema extends StandardSchemaV1 | undefined = undefined,
|
|
Values = Schema extends StandardSchemaV1
|
|
? StandardSchemaV1.InferInput<Schema>
|
|
: Record<string, unknown>
|
|
>(opts: CreateFormOpts<Schema, Values>): Form<Values> {
|
|
const schema = opts.schema;
|
|
const initial = structuredClone(opts.defaults) as Values;
|
|
|
|
// ── Reactive state ──────────────────────────────────────────────────────
|
|
const values = $state(structuredClone(opts.defaults) as Values);
|
|
let errors = $state<FormErrors>({});
|
|
let issues = $state<FormIssues>({});
|
|
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<string, boolean>();
|
|
const registry = new SvelteMap<string, FieldEntry>();
|
|
|
|
// ── 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<StandardSchemaV1.Issue>): {
|
|
errors: FormErrors;
|
|
issues: FormIssues;
|
|
} {
|
|
const outErrors: FormErrors = {};
|
|
const outIssues: FormIssues = {};
|
|
for (const issue of rawIssues as ReadonlyArray<RawStandardIssue>) {
|
|
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<string, unknown> | null | undefined)?.[key];
|
|
b = (b as Record<string, unknown> | 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<boolean> {
|
|
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<Schema>
|
|
: 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<string, unknown>)[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
|
|
};
|
|
}
|