diff --git a/packages/form/README.md b/packages/form/README.md new file mode 100644 index 000000000..eec87b9f3 --- /dev/null +++ b/packages/form/README.md @@ -0,0 +1,560 @@ +

+ Solid Primitives Form +

+ +# @solid-primitives/form + +[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/form?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/form) +[![size](https://img.shields.io/npm/v/@solid-primitives/form?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/form) +[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) + +Reactive form primitive for Solid.js. Tracks field values, validation errors, touched state, and submission status — all as fine-grained signals that compose naturally with the rest of the Solid reactive graph. + +Bring your own validation: validators are plain functions `(value) => string | null | Promise`, so any schema library (Zod, Valibot, Arktype, …) wires in with a one-line adapter. Async validators (uniqueness checks, server-side rules) are first-class. + +## Installation + +``` +npm install @solid-primitives/form +# or +yarn add @solid-primitives/form +# or +pnpm add @solid-primitives/form +``` + +## `createForm` + +Creates a reactive form with per-field signals, derived validity state, and helpers for binding fields to DOM inputs. + +```tsx +import { createForm } from "@solid-primitives/form"; + +function LoginForm() { + const form = createForm({ + fields: { + email: { initial: "", validate: isEmail }, + password: { initial: "", validate: [minLength(8), hasUppercase] }, + }, + onSubmit: async values => { + await api.login(values); + }, + }); + + return ( +
+ + + {form.fields.email.error()} + + + + + {form.fields.password.error()} + + + +
+ ); +} +``` + +### Configuration + +`createForm` accepts a config object: + +| Property | Type | Description | +|----------|------|-------------| +| `fields` | `Record` | Field definitions (see below) | +| `onSubmit` | `(values) => void \| Promise` | Called with all field values when the form submits and all fields are valid | +| `validateOn` | `"change" \| "blur" \| "submit"` | Default display timing for field errors. Defaults to `"change"`. | + +**`FieldConfig`** + +| Property | Type | Description | +|----------|------|-------------| +| `initial` | `V` | Initial value for the field | +| `validate` | `ValidatorFn \| ValidatorFn[]` | One or more validator functions. Validators run in order; the first failure is the error. | +| `validateOn` | `"change" \| "blur" \| "submit"` | When to display this field's error in the UI. Overrides the form-level `validateOn`. | + +A **`ValidatorFn`** is any function with the signature `(value: V) => string | null | Promise` — returning an error message string on failure, `null` when valid, or a Promise that resolves to either. + +### Per-field API + +Each entry in `form.fields` exposes: + +| Member | Type | Description | +|--------|------|-------------| +| `value()` | `Accessor` | Current field value | +| `error()` | `Accessor` | First validation error, or `null` if valid. Respects `validateOn`. | +| `touched()` | `Accessor` | Whether the field has been blurred | +| `pending()` | `Accessor` | `true` while an async validator is in flight for this field | +| `setValue(v)` | `(v: V) => void` | Imperatively set the value | +| `setTouched(v)` | `(v: boolean) => void` | Imperatively set the touched flag | +| `setError(msg)` | `(error: string \| null) => void` | Inject an external error (e.g. from a server response). Cleared automatically when `setValue` is called. | +| `reset()` | `() => void` | Reset this field to its initial value and clear touched | + +### Form-level API + +| Member | Type | Description | +|--------|------|-------------| +| `dirty()` | `Accessor` | `true` when any field differs from its initial value | +| `valid()` | `Accessor` | `true` when all fields pass validation and no async validators are pending | +| `pending()` | `Accessor` | `true` while any field has an async validator in flight | +| `submitting()` | `Accessor` | `true` while `onSubmit` is in flight | +| `submitted()` | `Accessor` | `true` after the first submit attempt; reset by `reset()` | +| `values()` | `Accessor` | Plain object of all current field values | +| `errors()` | `Accessor>>` | Object containing only **field-level** errors (always reflects true validity, regardless of `validateOn`). Cross-field errors registered via `validate()` are not included — access those through the accessor each `validate()` call returns. | +| `bind(name)` | Ref directive factory | Wires an `` or ` + + +``` + +This sets up bidirectional sync: +- **Signal → DOM:** the input's value (or `checked` state for checkboxes/radios) stays in sync with the field signal +- **DOM → signal:** `input` events update the field value; `blur` events mark the field as touched + +For checkboxes and radios, `bind` reads and writes `el.checked` (a boolean) and listens on `change` rather than `input`. + +You can also apply the ref imperatively (e.g. in tests): + +```ts +const cleanup = form.bind("email")(inputElement); +// ... +cleanup(); // removes event listeners +``` + +> **Note:** `bind` must be called during component rendering (inside a reactive owner). Calling it at module level or outside a reactive context will warn. + +### Async validators + +A validator can return a `Promise`. While the promise is pending, `field.pending()` and `form.pending()` are `true`, and `form.valid()` returns `false`. + +```tsx +const isAvailable = async (username: string): Promise => { + const taken = await api.checkUsername(username); + return taken ? "Username is taken" : null; +}; + +const form = createForm({ + fields: { + username: { + initial: "", + validate: [required, isAvailable], + validateOn: "blur", // avoid checking on every keystroke + }, + }, +}); + +return ( +
+ + + Checking availability… + + + {form.fields.username.error()} + + +
+); +``` + +Sync and async validators can coexist in a single `validate` array. Sync validators run first and short-circuit immediately — the async validator is only awaited if all preceding sync validators pass. + +Stale async results are automatically discarded: if the field value changes while a validator is in flight, the in-flight result is ignored. + +> **Tip:** Debounce noisy async validators yourself before passing them to `validate`, so network requests don't fire on every keystroke. + +### `form.validate(fn)` + +Registers a cross-field validation rule and returns a reactive accessor for its error. Call it during component rendering — the same phase as `bind`. + +```tsx +function SignupForm() { + const form = createForm({ + fields: { + password: { initial: "" }, + confirm: { initial: "" }, + }, + onSubmit: async values => { /* ... */ }, + }); + + const confirmError = form.validate(values => + values.password !== values.confirm ? "Passwords must match" : null, + ); + + return ( +
+ + + +

{confirmError()}

+
+ +
+ ); +} +``` + +- `form.validate(fn)` returns an `Accessor` — reactive over `form.values()`, so it re-runs whenever any field changes. +- The result is included in `form.valid()` — submission is blocked when the rule fails. +- Call it multiple times to register independent rules; all are checked by `form.valid()`. + +Works directly with any schema library: + +```ts +import * as v from "valibot"; + +const SignupSchema = v.pipe( + v.object({ password: v.string(), confirm: v.string() }), + v.check(({ password, confirm }) => password === confirm, "Passwords must match"), +); + +const confirmError = form.validate(values => { + const result = v.safeParse(SignupSchema, values); + return result.success ? null : result.issues[0].message; +}); +``` + +> **Note:** `validate` must be called inside a reactive owner (i.e. during component rendering), because it calls `createMemo` internally. + +### `form.ref` + +Attach to a `
` element to intercept the native submit event: + +```tsx +...
+``` + +This calls `e.preventDefault()` and runs the submit flow: touches all fields, sets `submitted` to `true`, validates, then calls `onSubmit` if the form is valid. + +### `submitted` + +`form.submitted()` becomes `true` on the first submit attempt and stays `true` until `form.reset()` is called. Combined with `validateOn: "submit"`, this lets you reveal all errors only after the user first tries to submit: + +```tsx +const form = createForm({ + fields: { + email: { initial: "", validate: isEmail, validateOn: "submit" }, + password: { initial: "", validate: minLength(8), validateOn: "submit" }, + }, +}); + +// errors are hidden until form.submitted() becomes true +``` + +### `toFormData(values)` + +A standalone helper that converts a plain values object into a `FormData` instance. Useful when passing form data to Solid Router actions or server functions that expect `FormData`: + +```ts +import { createForm, toFormData } from "@solid-primitives/form"; +import { action, useAction } from "@solidjs/router"; + +const saveProfile = action(async (fd: FormData) => { + await api.save(Object.fromEntries(fd)); +}); + +function ProfileForm() { + const save = useAction(saveProfile); + const form = createForm({ + fields: { name: { initial: "" }, bio: { initial: "" } }, + onSubmit: values => save(toFormData(values)), + }); + + return
...
; +} +``` + +`null`, `undefined`, and `false` are omitted — matching HTML's native checkbox behaviour where unchecked inputs are absent from the form payload. All other values are coerced to strings via `String(value)`. + +```ts +function toFormData(values: Record): FormData; +``` + +### Server-side validation errors + +Use `form.setError(name, message)` (or `field.setError(message)`) to surface errors returned by the server after submission. The injected error is treated like any other field error: it blocks `form.valid()`, appears in `form.errors()`, and is visible via `field.error()`. + +The external error is cleared automatically the moment the user edits the field (`setValue` is called), so there is no manual cleanup needed. + +```tsx +const form = createForm({ + fields: { + email: { initial: "" }, + username: { initial: "" }, + }, + onSubmit: async values => { + const result = await api.signup(values); + if (!result.ok) { + // Surface each field-level error from the server response + for (const [field, message] of Object.entries(result.errors)) { + form.setError(field as "email" | "username", message); + } + } + }, +}); +``` + +Client-side validator errors take priority over external errors — if a field fails its own validators, the client error is shown first. The external error becomes visible once the client error is resolved. + +### Optimistic updates + +`createForm` works naturally with Solid 2.0's `action` primitive for optimistic mutations: + +```ts +import { action } from "@solidjs/router"; + +const form = createForm({ + fields: { name: { initial: "" } }, + onSubmit: action(function* (values) { + setOptimisticName(values.name); + yield api.updateName(values); + refresh(profile); + }), +}); +``` + +### TypeScript + +Field value types are inferred from `initial`: + +```ts +const form = createForm({ + fields: { + age: { initial: 0 }, // FormField + name: { initial: "" }, // FormField + tags: { initial: [] as string[] }, // FormField + }, +}); + +form.fields.age.value(); // number +form.fields.name.value(); // string +``` + +#### Definition + +```ts +function createForm(config: FormConfig): FormReturn; + +type ValidatorFn = (value: V) => string | null | Promise; + +type FormConfig = { + fields: C; + onSubmit?: (values: { [K in keyof C]: InferValue }) => void | Promise; + validateOn?: "change" | "blur" | "submit"; +}; + +type FieldConfig = { + initial: V; + validate?: ValidatorFn | ValidatorFn[]; + validateOn?: "change" | "blur" | "submit"; +}; + +type FormField = { + value: Accessor; + error: Accessor; + touched: Accessor; + pending: Accessor; + setValue: (v: V) => void; + setTouched: (v: boolean) => void; + setError: (error: string | null) => void; + reset: () => void; +}; + +type FormReturn = { + fields: { [K in keyof C]: FormField> }; + values: Accessor<{ [K in keyof C]: InferValue }>; + errors: Accessor>>; + dirty: Accessor; + valid: Accessor; + pending: Accessor; + submitting: Accessor; + submitted: Accessor; + bind: (name: keyof C & string) => (el: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement) => () => void; + ref: (el: HTMLFormElement) => () => void; + validate: (fn: (values: Values) => string | null) => Accessor; + setError: (name: keyof C & string, error: string | null) => void; + formData: () => FormData; + reset: (newValues?: Partial) => void; + submit: () => Promise; +}; +``` + +--- + +## Validation + +`createForm` has no built-in validators. A validator is any function `(value: V) => string | null | Promise`. This makes it trivial to use your preferred schema library. + +### Using Valibot + +```ts +import { createForm } from "@solid-primitives/form"; +import * as v from "valibot"; + +// Wrap any Valibot schema into a ValidatorFn +function valibot(schema: v.BaseSchema>) { + return (value: T): string | null => { + const result = v.safeParse(schema, value); + return result.success ? null : result.issues[0].message; + }; +} + +const form = createForm({ + fields: { + email: { initial: "", validate: valibot(v.pipe(v.string(), v.email())) }, + password: { initial: "", validate: valibot(v.pipe(v.string(), v.minLength(8))) }, + age: { initial: 0, validate: valibot(v.pipe(v.number(), v.minValue(18))) }, + }, + onSubmit: async values => { /* ... */ }, +}); +``` + +### Using Zod + +```ts +import { createForm } from "@solid-primitives/form"; +import { z } from "zod"; + +// Wrap any Zod schema into a ValidatorFn +function zod(schema: z.ZodType) { + return (value: T): string | null => { + const result = schema.safeParse(value); + return result.success ? null : result.error.issues[0].message; + }; +} + +const form = createForm({ + fields: { + email: { initial: "", validate: zod(z.string().email()) }, + password: { initial: "", validate: zod(z.string().min(8)) }, + age: { initial: 0, validate: zod(z.number().min(18)) }, + }, + onSubmit: async values => { /* ... */ }, +}); +``` + +### Whole-form validation with Valibot + +For schemas that validate the entire values object at once (cross-field rules, refinements), validate in `onSubmit` and surface errors manually: + +```ts +import * as v from "valibot"; + +const SignupSchema = v.pipe( + v.object({ + password: v.string(), + confirm: v.string(), + }), + v.check(({ password, confirm }) => password === confirm, "Passwords do not match"), +); + +const form = createForm({ + fields: { + password: { initial: "" }, + confirm: { initial: "" }, + }, + onSubmit: values => { + const result = v.safeParse(SignupSchema, values); + if (!result.success) { + throw new Error(result.issues[0].message); + } + return api.signup(result.output); + }, +}); +``` + +### Custom validators + +Plain functions work too — no library required: + +```ts +const required = (value: string) => + value.trim().length === 0 ? "Required" : null; + +const noSpaces = (value: string) => + value.includes(" ") ? "No spaces allowed" : null; + +const form = createForm({ + fields: { + username: { initial: "", validate: [required, noSpaces] }, + }, +}); +``` + +--- + +## Future work + +### Field sanitizers + +A `sanitize` option on `FieldConfig`, mirroring `validate`, that transforms a field's value rather than inspecting it: + +```ts +type SanitizerFn = (value: V) => V; + +// proposed config shape +email: { + initial: "", + sanitize: v => v.trim().toLowerCase(), // or an array, run as a chain + validate: isEmail, +} +``` + +Sanitizers would run on `blur` (better UX than on every keystroke — trimming mid-input prevents typing a space), producing the cleaned value before validation sees it. `form.fields.email.value()` would always return the sanitized value. + +--- + +## Changelog + +See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/form/package.json b/packages/form/package.json new file mode 100644 index 000000000..b657f57fd --- /dev/null +++ b/packages/form/package.json @@ -0,0 +1,65 @@ +{ + "name": "@solid-primitives/form", + "version": "0.0.100", + "description": "Reactive form primitive for Solid.js with per-field signals, sync/async validation, validateOn control, and DOM binding.", + "author": "David Di Biase ", + "contributors": [], + "license": "MIT", + "homepage": "https://primitives.solidjs.community/package/form", + "repository": { + "type": "git", + "url": "git+https://github.com/solidjs-community/solid-primitives.git" + }, + "primitive": { + "name": "form", + "stage": 0, + "list": [ + "createForm", + "toFormData" + ], + "category": "Reactivity" + }, + "keywords": [ + "solid", + "primitives", + "form", + "validation", + "reactive", + "async" + ], + "dependencies": { + "@solid-primitives/event-listener": "workspace:^" + }, + "private": false, + "sideEffects": false, + "files": [ + "dist" + ], + "type": "module", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "browser": {}, + "exports": { + "import": { + "@solid-primitives/source": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts", + "build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/build.ts", + "vitest": "vitest -c ../../configs/vitest.config.ts", + "test": "pnpm run vitest", + "test:ssr": "pnpm run vitest --mode ssr" + }, + "peerDependencies": { + "@solidjs/web": "^2.0.0-beta.14", + "solid-js": "^2.0.0-beta.14" + }, + "typesVersions": {}, + "devDependencies": { + "@solidjs/web": "2.0.0-beta.14", + "solid-js": "2.0.0-beta.14" + } +} diff --git a/packages/form/src/form.ts b/packages/form/src/form.ts new file mode 100644 index 000000000..c366c1f50 --- /dev/null +++ b/packages/form/src/form.ts @@ -0,0 +1,395 @@ +import { createSignal, createMemo, createRoot, onCleanup, untrack, createEffect, type Accessor } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { makeEventListener } from "@solid-primitives/event-listener"; +import type { ValidatorFn, FieldsConfig, InferValue, FormField, FormReturn, FormConfig } from "./types.js"; + +export type { ValidatorFn, FieldConfig, FieldsConfig, FormField, FormReturn, FormConfig } from "./types.js"; + + +/** + * Converts a plain values object into a `FormData` instance. + * + * `null`, `undefined`, and `false` are omitted — matching HTML's native behaviour where + * unchecked checkboxes are absent from the form payload. All other values are coerced to + * strings via `String(value)`. + * + * @example + * ```ts + * const form = createForm({ fields: { name: { initial: "" }, agreed: { initial: false } } }); + * const fd = toFormData(form.values()); // agreed is omitted + * await fetch("/api", { method: "POST", body: fd }); + * ``` + */ +export function toFormData(values: Record): FormData { + const fd = new FormData(); + for (const [k, v] of Object.entries(values)) { + if (v != null && v !== false) fd.append(k, String(v)); + } + return fd; +} + +/** + * Creates a reactive form with per-field signals, derived validity state, and helpers for + * binding fields to DOM inputs. + * + * Validators are plain functions `(value) => string | null | Promise`, so any + * schema library (Zod, Valibot, Arktype, …) wires in with a one-line adapter. Async validators + * (uniqueness checks, server-side rules) are first-class: while a check is in flight, + * `field.pending()` and `form.pending()` are `true` and `form.valid()` is `false`. + * + * @param config - Field definitions, optional `onSubmit` handler, and default `validateOn` mode. + * @returns Reactive form object with per-field accessors, form-level signals, and DOM helpers. + * @example + * ```tsx + * const form = createForm({ + * fields: { + * email: { initial: "", validate: isEmail }, + * password: { initial: "", validate: [minLength(8), hasUppercase] }, + * }, + * onSubmit: async values => { await api.login(values); }, + * }); + * + * return ( + *
+ * + * {err => {err()}} + * + *
+ * ); + * ``` + * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/form + */ +export function createForm(config: FormConfig): FormReturn { + type Values = { [K in keyof C]: InferValue }; + + if (isServer) { + const initialValues = Object.fromEntries( + Object.entries(config.fields).map(([k, f]) => [k, f.initial]), + ) as Values; + + const fields = Object.fromEntries( + Object.entries(config.fields).map(([k, f]) => [ + k, + { + value: () => f.initial, + error: () => null, + touched: () => false, + pending: () => false, + setValue: () => void 0, + setTouched: () => void 0, + setError: () => void 0, + reset: () => void 0, + }, + ]), + ) as unknown as FormReturn["fields"]; + + return { + fields, + values: () => initialValues, + errors: () => ({}), + dirty: () => false, + valid: () => true, + pending: () => false, + submitting: () => false, + submitted: () => false, + bind: () => () => () => {}, + ref: () => () => {}, + validate: () => () => null, + setValues: () => void 0, + setError: () => void 0, + formData: () => toFormData(initialValues), + reset: () => void 0, + submit: async () => void 0, + }; + } + + // submitted is read inside per-field error memos, so it must exist before the loop. + const [submitted, setSubmitted] = createSignal(false, { ownedWrite: true }); + const formValidateOn = config.validateOn ?? "change"; + + type InternalField = { + initial: any; + value: Accessor; + _setValue: (v: any) => void; + _rawError: Accessor; + _asyncPending: Accessor; + _setExternalError: (error: string | null) => void; + error: Accessor; + touched: Accessor; + _setTouched: (v: boolean) => void; + }; + + const internalFields: Record = {}; + + for (const [name, fc] of Object.entries(config.fields)) { + const validators = fc.validate + ? Array.isArray(fc.validate) ? fc.validate : [fc.validate] + : []; + const validateOn = fc.validateOn ?? formValidateOn; + + const [value, _setValueRaw] = createSignal(fc.initial, { ownedWrite: true }); + const [touched, setTouched] = createSignal(false, { ownedWrite: true }); + const [externalError, setExternalError] = createSignal(null, { ownedWrite: true }); + // Clearing the external error when the user edits the field is the expected UX: + // the server's previous verdict is stale the moment the user starts correcting. + const setValue = (v: any) => { _setValueRaw(v); setExternalError(null); }; + + let asyncError: Accessor = () => null; + let _asyncPending: Accessor = () => false; + let _syncError: Accessor = () => null; + + if (validators.length > 0) { + const [ae, setAe] = createSignal(null, { ownedWrite: true }); + const [ap, setAp] = createSignal(false, { ownedWrite: true }); + asyncError = ae; + _asyncPending = ap; + + // Classify validators by probing with the initial value — one call per validator. + // Sync: returns string | null → goes into syncFns (used by the reactive memo). + // Async: returns Promise → goes into asyncFns; initial promises are saved for reuse. + const syncFns: ValidatorFn[] = []; + const asyncFns: ValidatorFn[] = []; + const initProms: Promise[] = []; + + for (const fn of validators) { + const probe = fn(fc.initial); + if (probe instanceof Promise) { + asyncFns.push(fn); + initProms.push(probe); + } else { + syncFns.push(fn); + } + } + + // Sync-only memo: reactive to value(), never touches async validators. + // Lazy so the probe above serves as the only initial evaluation. + const syncMemo: Accessor = syncFns.length > 0 + ? createMemo(() => { + const val = value(); + for (const fn of syncFns) { + const r = fn(val); + if (r !== null) return r as string; + } + return null; + }, { lazy: true }) + : () => null; + _syncError = syncMemo; + + if (asyncFns.length > 0) { + let seq = 0; + // First run: reuse initProms (already started during classification). + // Subsequent runs: call asyncFns fresh — one invocation per value change. + let isFirstRun = true; + + createEffect( + // Compute reads value() and syncMemo() so the effect re-runs on value changes. + () => ({ val: value(), syncError: syncMemo() }), + ({ val, syncError }) => { + // Reuse initProms only if the value hasn't changed from the initial; otherwise + // the initial promises validated a stale value and must be discarded. + const asyncProms = (isFirstRun && val === fc.initial) + ? (isFirstRun = false, initProms) + : (isFirstRun = false, asyncFns.map(fn => fn(val) as Promise)); + + if (syncError !== null || asyncProms.length === 0) { setAe(null); setAp(false); return; } + const s = ++seq; + setAp(true); + void (async () => { + for (const p of asyncProms) { + const err = await p; + if (s !== seq) return; + if (err !== null) { setAe(err); setAp(false); return; } + } + setAe(null); + setAp(false); + })(); + }, + ); + } + } + + // _rawError reads the sync memo, async error, and external error signals. + // Never calls validators itself. External error is cleared whenever setValue is called. + const _rawError: Accessor = validators.length === 0 + ? externalError + : createMemo(() => _syncError() ?? asyncError() ?? externalError()); + + // Display error is gated by validateOn; raw error is always computed above. + const error = validateOn === "change" + ? _rawError + : createMemo(() => (validateOn === "blur" ? touched() : submitted()) ? _rawError() : null); + + internalFields[name] = { + initial: fc.initial, + value, + _setValue: setValue, + _rawError, + _asyncPending, + _setExternalError: setExternalError, + error, + touched, + _setTouched: setTouched, + }; + } + + const fieldEntries = Object.entries(internalFields); + + const fields = Object.fromEntries( + fieldEntries.map(([name, f]) => [ + name, + { + value: f.value, + error: f.error, + touched: f.touched, + pending: f._asyncPending, + setValue: f._setValue, + setTouched: f._setTouched, + setError: f._setExternalError, + reset: () => { f._setValue(f.initial); f._setTouched(false); }, + } satisfies FormField, + ]), + ) as unknown as FormReturn["fields"]; + + const values = createMemo( + () => Object.fromEntries(fieldEntries.map(([k, f]) => [k, f.value()])) as Values, + ); + + const errors = createMemo(() => { + const result: Partial> = {}; + for (const [k, f] of fieldEntries) { + const err = f._rawError(); + if (err !== null) result[k] = err; + } + return result; + }) as Accessor>>; + + // Bumped by reset(newValues) so dirty recomputes even when field values didn't change + // (the baseline changed, not the value). + const [dirtyVer, bumpDirtyVer] = createSignal(0, { ownedWrite: true }); + const dirty = createMemo(() => { + dirtyVer(); + return fieldEntries.some(([, f]) => f.value() !== f.initial); + }); + const pending = createMemo(() => fieldEntries.some(([, f]) => f._asyncPending())); + + // Counter signal: bumped by validate() so valid() re-computes immediately + // when a cross-field rule is registered after valid has already been read. + const [validVer, bumpValidVer] = createSignal(0, { ownedWrite: true }); + const formValidators: Accessor[] = []; + + const valid = createMemo( + () => { + validVer(); + return ( + fieldEntries.every(([, f]) => f._rawError() === null) && + !pending() && + formValidators.every(v => v() === null) + ); + }, + { lazy: true }, + ); + + const validate = (fn: (v: Values) => string | null): Accessor => { + const error = createMemo(() => fn(values())); + formValidators.push(error); + bumpValidVer(n => n + 1); + return error; + }; + + const setValues = (newValues: Partial) => { + for (const [k, v] of Object.entries(newValues)) { + if (k in internalFields) internalFields[k]!._setValue(v); + } + }; + + const [submitting, setSubmitting] = createSignal(false, { ownedWrite: true }); + let _isSubmitting = false; + + const setError = (name: keyof C & string, error: string | null) => { + internalFields[name]?._setExternalError(error); + }; + + const reset = (newValues?: Partial) => { + for (const [name, f] of fieldEntries) { + if (newValues && name in newValues) f.initial = (newValues as any)[name]; + f._setValue(f.initial); // also clears external error via the setValue wrapper + f._setTouched(false); + } + if (newValues) bumpDirtyVer(n => n + 1); // dirty must recompute when baseline changes + _isSubmitting = false; + setSubmitting(false); + setSubmitted(false); + }; + + const submit = async () => { + if (_isSubmitting) return; + for (const [, f] of fieldEntries) f._setTouched(true); + setSubmitted(true); + if (!untrack(valid)) return; + _isSubmitting = true; + setSubmitting(true); + try { + await config.onSubmit?.(untrack(values)); + } finally { + _isSubmitting = false; + setSubmitting(false); + } + }; + + // bind() uses a single Phase 2 ref callback. The sync-to-DOM effect is scoped + // to the element's lifetime via createRoot, disposed when the element unmounts. + const bind = (name: string) => { + const f = internalFields[name]; + if (!f) throw new Error(`createForm: unknown field "${name}"`); + + return (nextEl: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement) => { + const el = nextEl as HTMLInputElement; + const checkable = el.type === "checkbox" || el.type === "radio"; + + if (checkable) el.checked = Boolean(untrack(f.value)); + else el.value = untrack(() => String(f.value() ?? "")); + + const disposeEffect = createRoot(d => { + createEffect(f.value, v => { + if (checkable) el.checked = Boolean(v); + else if (el.value !== String(v ?? "")) el.value = String(v ?? ""); + }); + return d; + }); + + const off1 = makeEventListener(el, checkable ? "change" : "input", () => + f._setValue(checkable ? el.checked : el.value), + ); + const off2 = makeEventListener(el, "blur", () => f._setTouched(true)); + + return () => { off1(); off2(); disposeEffect(); }; + }; + }; + + const ref = (el: HTMLFormElement) => + makeEventListener(el, "submit", (e: SubmitEvent) => { + e.preventDefault(); + void submit(); + }); + + onCleanup(() => { _isSubmitting = false; }); + + return { + fields, + values, + errors, + dirty, + valid, + pending, + submitting, + submitted, + bind: bind as FormReturn["bind"], + ref, + validate: validate as FormReturn["validate"], + setValues: setValues as FormReturn["setValues"], + setError: setError as FormReturn["setError"], + formData: () => toFormData(values()), + reset, + submit, + }; +} diff --git a/packages/form/src/index.ts b/packages/form/src/index.ts new file mode 100644 index 000000000..bad3ff379 --- /dev/null +++ b/packages/form/src/index.ts @@ -0,0 +1,10 @@ +export { createForm, toFormData } from "./form.js"; + +export type { + ValidatorFn, + FieldConfig, + FieldsConfig, + FormField, + FormReturn, + FormConfig, +} from "./types.js"; diff --git a/packages/form/src/types.ts b/packages/form/src/types.ts new file mode 100644 index 000000000..777963ba1 --- /dev/null +++ b/packages/form/src/types.ts @@ -0,0 +1,86 @@ +import type { Accessor } from "solid-js"; + +/** A field validator. Return an error string on failure, `null` when valid, or a Promise resolving to either. */ +export type ValidatorFn = (value: V) => string | null | Promise; + +/** Configuration for a single form field. */ +export type FieldConfig = { + /** Initial (and reset-target) value. The field's value type `V` is inferred from this. */ + initial: V; + /** One or more validators. Sync validators run first and short-circuit; async validators run after all sync ones pass. */ + validate?: ValidatorFn | ValidatorFn[]; + /** When to expose this field's error in `field.error()`. Overrides the form-level `validateOn`. */ + validateOn?: "change" | "blur" | "submit"; +}; + +/** A map of field names to their configs. The shape of this object determines the form's value type. */ +export type FieldsConfig = Record>; + +/** Extracts the value type `V` from a `FieldConfig`. */ +export type InferValue = C extends FieldConfig ? V : never; + +/** Reactive accessors and imperative setters for a single form field. */ +export type FormField = { + /** Current field value. */ + value: Accessor; + /** First validation error, or `null` when valid. Respects `validateOn`. */ + error: Accessor; + /** `true` after the field has been blurred at least once. */ + touched: Accessor; + /** `true` while an async validator is in flight for this field. */ + pending: Accessor; + /** Imperatively update the field value. Clears any external error set via `setError`. */ + setValue: (v: V) => void; + /** Imperatively set the touched flag. */ + setTouched: (v: boolean) => void; + /** Inject an external error (e.g. from a server response). Cleared automatically when `setValue` is called. */ + setError: (error: string | null) => void; + /** Reset this field to its initial value, clear touched, and clear any external error. */ + reset: () => void; +}; + +/** The object returned by `createForm`. */ +export type FormReturn = { + /** Per-field reactive accessors and setters, keyed by field name. */ + fields: { [K in keyof C]: FormField> }; + /** Reactive snapshot of all current field values. */ + values: Accessor<{ [K in keyof C]: InferValue }>; + /** Fields that currently have errors, keyed by name. Always reflects true validity regardless of `validateOn`. Cross-field errors from `validate()` are not included. */ + errors: Accessor>>; + /** `true` when any field's value differs from its initial value. */ + dirty: Accessor; + /** `true` when all fields pass validation and no async validators are pending. Always reflects true validity regardless of `validateOn`. */ + valid: Accessor; + /** `true` while any field has an async validator in flight. */ + pending: Accessor; + /** `true` while `onSubmit` is executing. */ + submitting: Accessor; + /** `true` after the first submit attempt; reset to `false` by `reset()`. */ + submitted: Accessor; + /** Two-phase ref directive factory. Wire a field to an ``, `