a form engine over any schema: typed fields, errors when due, async checks
npx shadcn add @ag/formAlready installed? Preview an update
npx shadcn add @ag/form --dry-runYour installed source stays unchanged until you update it. How updates work
useFormField(form, path, label?)binds one path with noFieldround it: value, change, leaving, the error when it is due, checking, read-only while sending, andanchor, a ref callback that places the control for the summary.inputspreads on a text control, asc.inputdoes. For a control that draws its own label and error (a name written on a card, a cell in a table).form.Fieldis built on it, unchanged for its callers.- A failed submit focuses the first field to fix in the order the fields stand, read from the form's own places rather than from
Field's markup, so a headless control is focused too.
also called validation
shelf controls
uses button, dialog, field, kbd, material, spinner
used by components
the demo above, verbatim
"use client"
import { ArrowDown, ArrowUp, Plus, X } from "lucide-react"
import * as React from "react"
import { z } from "zod"
import { Sample } from "@/app/samples"
import { cn } from "@/lib/utils"
import { Button } from "@/registry/base-nova/ui/button"
import { Checkbox } from "@/registry/base-nova/ui/checkbox"
import { FieldSet } from "@/registry/base-nova/ui/field"
import {
Form,
FormLeaveGuard,
FormReset,
FormStatus,
FormSubmit,
FormSummary,
useForm,
useFormField,
} from "@/registry/base-nova/ui/form"
import { Input } from "@/registry/base-nova/ui/input"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { NumberField, SECONDS } from "@/registry/base-nova/ui/number"
import { Radio, RadioGroup } from "@/registry/base-nova/ui/radio-group"
import { Select } from "@/registry/base-nova/ui/select"
import { Slider } from "@/registry/base-nova/ui/slider"
import { Switch } from "@/registry/base-nova/ui/switch"
import { Textarea } from "@/registry/base-nova/ui/textarea"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/registry/base-nova/ui/toggle-group"
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms))
// #region settings
// A real settings form, every control the registry's: text, a long text, a number, a
// slider, a select, a switch, a checkbox, radios and a segmented choice, each bound by
// the one field contract. The rules are a zod schema (any Standard Schema); the
// server is a promise.
const Settings = z.object({
name: z.string().trim().min(2, "two letters at least"),
bio: z.string().max(160, "160 characters at most"),
delay: z.number().min(0).max(10_000),
volume: z.number().min(0).max(1),
quality: z.enum(["auto", "1080p", "720p", "480p"]),
autoplay: z.boolean(),
captions: z.boolean(),
start: z.enum(["beginning", "resume"]),
theme: z.enum(["system", "light", "dark"]),
})
type Settings = z.infer<typeof Settings>
const QUALITIES = [
{ value: "auto", label: "auto", description: "as the connection allows" },
{ value: "1080p", label: "1080p" },
{ value: "720p", label: "720p" },
{ value: "480p", label: "480p", description: "saves data" },
] as const
function SettingsForm() {
const form = useForm<Settings>({
initial: {
name: "adrian",
bio: "",
delay: 1500,
volume: 0.6,
quality: "auto",
autoplay: true,
captions: false,
start: "resume",
theme: "system",
},
schema: Settings,
onSubmit: async () => {
await wait(900)
},
})
return (
<Form form={form} className="w-full max-w-md">
<FieldSet legend="playback" description="how the player starts">
<form.Field name="name" label="display name" required>
{(c) => <Input {...c.input} autoComplete="nickname" />}
</form.Field>
<form.Field
name="bio"
label="bio"
description="a line or two, shown on your page"
>
{(c) => <Textarea {...c.input} rows={2} maxRows={5} />}
</form.Field>
<form.Field name="delay" label="wait before playing">
{(c) => (
<NumberField
label={c.label}
unit={SECONDS}
min={0}
max={10_000}
step={100}
value={c.value}
onValueChange={c.onValueChange}
/>
)}
</form.Field>
<form.Field name="volume" label="volume">
{(c) => (
<Slider
label={c.label}
min={0}
max={1}
step={0.01}
value={c.value}
onValueChange={c.onValueChange}
/>
)}
</form.Field>
<form.Field name="quality" label="quality">
{(c) => (
<Select
options={QUALITIES}
value={c.value}
onValueChange={(v) => v && c.onValueChange(v)}
/>
)}
</form.Field>
<form.Field
name="autoplay"
label="play on arrival"
orientation="horizontal"
>
{(c) => (
<Switch checked={c.value} onCheckedChange={c.onValueChange} />
)}
</form.Field>
<form.Field name="captions" label="captions" orientation="horizontal">
{(c) => (
<Checkbox checked={c.value} onCheckedChange={c.onValueChange} />
)}
</form.Field>
<form.Field name="start" label="start from">
{(c) => (
<RadioGroup
value={c.value}
onValueChange={(v) => c.onValueChange(v as Settings["start"])}
orientation="horizontal"
>
<Radio value="beginning">the beginning</Radio>
<Radio value="resume">where I left it</Radio>
</RadioGroup>
)}
</form.Field>
<form.Field name="theme" label="theme">
{(c) => (
<ToggleGroup
value={c.value}
onValueChange={(v) => c.onValueChange(v as Settings["theme"])}
>
<ToggleGroupItem value="system">system</ToggleGroupItem>
<ToggleGroupItem value="light">light</ToggleGroupItem>
<ToggleGroupItem value="dark">dark</ToggleGroupItem>
</ToggleGroup>
)}
</form.Field>
</FieldSet>
<FormSummary />
<div className="flex items-center gap-2">
<FormSubmit />
<FormReset />
<FormStatus className="ml-auto" />
</div>
<FormLeaveGuard />
</Form>
)
}
// #endregion
// #region signup
// Sign up: an async check (is the name taken?) that can also fail to answer, a rule
// across two fields (the passwords), and a server that refuses an address and,
// sometimes, everything.
const SignUp = z
.object({
username: z
.string()
.min(3, "three characters at least")
.regex(/^[a-z0-9-]+$/, "lowercase letters, digits and dashes"),
email: z.email("an address, like you@example.com"),
password: z.string().min(8, "eight characters or more"),
confirm: z.string(),
})
.refine((v) => v.password === v.confirm, {
path: ["confirm"],
message: "not the same password",
})
type SignUp = z.infer<typeof SignUp>
const TAKEN = ["adrian", "admin", "root"]
function SignUpForm() {
const [done, setDone] = React.useState<string | null>(null)
const form = useForm<SignUp>({
initial: { username: "", email: "", password: "", confirm: "" },
schema: SignUp,
checks: {
username: {
debounce: 400,
run: async (value, { signal }) => {
await wait(600)
if (signal.aborted) return undefined
// A check that cannot answer: said as such, asked again on submit.
if (String(value).startsWith("offline"))
throw new Error("the name service is not answering")
return TAKEN.includes(String(value))
? "taken: try another"
: undefined
},
},
},
onSubmit: async (values) => {
await wait(800)
if (values.email.endsWith("@example.com"))
return { errors: { email: "already has an account" } }
if (values.email.endsWith(".test"))
return { error: "the server is down for a moment; try again" }
setDone(values.username)
},
})
if (done)
return (
<p className="text-sm">
welcome, <span className="font-medium">{done}</span>.{" "}
<Button
variant="link"
className="text-muted-foreground underline"
onClick={() => {
setDone(null)
form.reset()
}}
>
again
</Button>
</p>
)
return (
<Form form={form} guard={false} className="w-full max-w-sm">
<form.Field
name="username"
label="username"
required
description="try adrian: it is taken; one starting with offline cannot be checked"
>
{(c) => <Input {...c.input} autoComplete="username" />}
</form.Field>
<form.Field
name="email"
label="email"
required
description="an address at example.com is already registered; one ending in .test makes the server fail"
>
{(c) => <Input {...c.input} type="email" autoComplete="email" />}
</form.Field>
<form.Field name="password" label="password" required>
{(c) => (
<Input {...c.input} type="password" autoComplete="new-password" />
)}
</form.Field>
<form.Field name="confirm" label="the same password" required>
{(c) => (
<Input {...c.input} type="password" autoComplete="new-password" />
)}
</form.Field>
<FormSummary />
<FormSubmit>create account</FormSubmit>
</Form>
)
}
// #endregion
// #region playlist
// Rows: a playlist's tracks, added, removed and reordered, each keyed so a row keeps
// its state wherever it moves; the rows' messages and touched marks move with them.
const Playlist = z.object({
title: z.string().min(1, "a name for it"),
tracks: z
.array(
z.object({
title: z.string().min(1, "a title"),
length: z.number().min(1000, "a second at least"),
}),
)
.min(1, "one track at least"),
})
type Playlist = z.infer<typeof Playlist>
function PlaylistForm() {
const form = useForm<Playlist>({
initial: {
title: "night drive",
tracks: [
{ title: "harbour lights", length: 214_000 },
{ title: "", length: 187_000 },
],
},
schema: Playlist,
// A rule over the rows the schema does not say: no title twice.
validate: (v) => {
const seen = new Map<string, number>()
const out: Record<string, string> = {}
v.tracks.forEach((t, i) => {
const key = t.title.trim().toLowerCase()
if (!key) return
if (seen.has(key)) out[`tracks.${i}.title`] = "already in the list"
else seen.set(key, i)
})
return out
},
onSubmit: async () => {
await wait(700)
},
})
return (
<Form form={form} guard={false} className="w-full max-w-lg">
<form.Field name="title" label="playlist">
{(c) => <Input {...c.input} />}
</form.Field>
<form.FieldArray name="tracks">
{(rows, { append, remove, move }) => (
<FieldSet legend="tracks">
{rows.map((row) => (
<div key={row.key} className="flex items-start gap-2">
<span className="mt-[30px] w-5 font-mono text-muted-foreground text-xs tabular-nums">
{row.index + 1}
</span>
<form.Field
name={`${row.path}.title`}
label="title"
className="flex-1"
>
{(c) => <Input {...c.input} />}
</form.Field>
<form.Field name={`${row.path}.length`} label="length">
{(c) => (
<NumberField
label={c.label}
unit={SECONDS}
min={0}
step={1000}
value={c.value}
onValueChange={c.onValueChange}
/>
)}
</form.Field>
<div className="mt-6 flex">
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label="up"
disabled={row.index === 0}
onClick={() => move(row.index, row.index - 1)}
>
<ArrowUp />
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label="down"
disabled={row.index === rows.length - 1}
onClick={() => move(row.index, row.index + 1)}
>
<ArrowDown />
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label="remove"
onClick={() => remove(row.index)}
>
<X />
</Button>
</div>
</div>
))}
<Button
type="button"
size="sm"
variant="outline"
className="self-start"
onClick={() => append({ title: "", length: 180_000 })}
>
<Plus /> add a track
</Button>
</FieldSet>
)}
</form.FieldArray>
<FormSummary />
<div className="flex items-center gap-2">
<FormSubmit />
<FormReset />
</div>
</Form>
)
}
// #endregion
// #region notes
// Saves by itself: a beat after the last change, when the rules pass; the status line
// is everything it says. A note with "offline" in it fails to save (with a retry).
const Notes = z.object({
title: z.string().min(1, "a title"),
body: z.string(),
})
type Notes = z.infer<typeof Notes>
function NotesForm() {
const form = useForm<Notes>({
initial: { title: "groceries", body: "" },
schema: Notes,
autosave: 800,
onSubmit: async (values) => {
await wait(500)
if (values.body.includes("offline")) throw new Error("offline")
},
})
return (
<Form form={form} className="w-full max-w-md">
<form.Field name="title" label="title">
{(c) => <Input {...c.input} />}
</form.Field>
<form.Field name="body" label="note" aside={<FormStatus />}>
{(c) => <Textarea {...c.input} rows={4} maxRows={10} />}
</form.Field>
</Form>
)
}
// #endregion
// #region inline
// Fields that are no field: written into a sentence, each bound headless
// (`useFormField`) and drawing its own state. Their errors are the summary's; its
// links, and a failed save, still find them (`anchor`).
const Ticket = z.object({
guest: z.string().trim().min(2, "who is coming"),
seats: z.string().regex(/^[1-8]$/, "one to eight seats"),
})
type Ticket = z.infer<typeof Ticket>
const WRITTEN_IN =
"mx-1 inline-block w-[7ch] border-b border-current/30 bg-transparent text-center outline-none focus:border-current aria-invalid:border-tone-bad aria-invalid:text-tone-bad"
function InlineForm() {
const form = useForm<Ticket>({
initial: { guest: "", seats: "2" },
schema: Ticket,
onSubmit: async () => {
await wait(400)
},
})
const guest = useFormField(form, "guest", "guest")
const seats = useFormField(form, "seats", "seats")
return (
<Form form={form} className="w-full max-w-md">
<p className="text-lg leading-loose">
A table for
<input
ref={guest.anchor}
aria-label="guest"
className={cn(WRITTEN_IN, "w-[12ch]")}
{...guest.input}
/>
, with
<input
ref={seats.anchor}
aria-label="seats"
inputMode="numeric"
className={WRITTEN_IN}
{...seats.input}
/>
seats.
</p>
<FormSummary />
<FormSubmit className="self-start">book</FormSubmit>
</Form>
)
}
// #endregion
export default function FormDemo() {
return (
<div className="space-y-8">
<Sample
name="settings"
title="settings"
description="errors only once a field is left or a save was tried, then they follow every key. Change something and follow a link: a dialog asks first; ⌘↵ saves from anywhere in the form."
with="settings"
>
<SettingsForm />
</Sample>
<Sample
name="signup"
title="sign up: checks and a server"
description="the username is checked as you pause (and again on submit, for exactly what is sent), the passwords against each other, and the server's refusals land on their fields. A check that cannot answer says so, with a way to ask again."
with="signup"
>
<SignUpForm />
</Sample>
<Sample
name="playlist"
title="rows: a playlist"
description="add, remove and reorder; each row keeps its key, its messages and what was touched."
with="playlist"
>
<PlaylistForm />
</Sample>
<Sample
name="inline"
title="headless: fields in a sentence"
description="useFormField binds a path to a control of your own, which draws its own label and error; the summary and a failed save still reach it."
with="inline"
>
<InlineForm />
</Sample>
<Sample name="notes" glance title="autosave" with="notes">
<NotesForm />
</Sample>
<Sample
name="glass"
title="in glass"
description="MaterialProvider round the whole form: the fieldset a pane, the labels and the summary in glass."
with="settings"
>
<MaterialProvider material="glass">
<div className="rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6">
<SettingsForm />
</div>
</MaterialProvider>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
import { CircleAlert, RotateCcw } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
type ArrayPath,
createForm,
deepEqual,
errorAt,
type Form as FormCore,
type FormOptions,
type FormState,
getAt,
isDirty,
type Path,
type PathValue,
problemsOf,
type RowOf,
statusOf,
} from "@/registry/base-nova/lib/form-core"
import { Button, type ButtonProps } from "@/registry/base-nova/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/registry/base-nova/ui/dialog"
import {
Field,
type FieldProps,
type FieldWiring,
} from "@/registry/base-nova/ui/field"
import { Kbd } from "@/registry/base-nova/ui/kbd"
import {
type Material,
type Scheme,
useMaterial,
useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { Glass, schemeOf } from "@/registry/base-nova/ui/material-glass"
import { Spinner } from "@/registry/base-nova/ui/spinner"
// THE FORM: lib/form-core's values, rules, timing and sending, bound to React and
// drawn. A form is made once per mount (`useForm`), its fields bound to it by name
// (`form.Field`, typed: the name autocompletes, the value is the type at that path),
// and the page around it says how it stands:
//
// const form = useForm({ initial, schema, onSubmit })
// <Form form={form}>
// <form.Field name="email" label="email">
// {(c) => <Input type="email" {...c.input} />}
// </form.Field>
// <FormSummary />
// <FormSubmit>save</FormSubmit>
// <FormLeaveGuard />
// </Form>
//
// ONE CONTRACT FOR EVERY CONTROL. A field's render prop is handed `c`, its control:
// a text field (a string path, and only a string path: `c.input` does not exist on a
// number's) spreads `c.input` (id, name, value, onChange, onBlur, the aria wiring,
// readOnly while sending); any other control takes `c.value` and `c.onValueChange`
// and reads the rest from the field itself (`field-context`'s `useField()`: its wiring,
// named by the field's label, read-only while the form sends, invalid). The registry's
// value controls (number, slider, select, switch, checkbox, radio group, toggle group)
// all do; a control of your own reads it the same way, or spreads `c.wiring` on its
// focusable element and names a group by `aria-labelledby={c.labelId}`. Leaving a
// field is heard on the field itself (focus going out of it), so a control with no
// blur of its own still marks itself left.
//
// WHAT THE FORM DOES FOR A PERSON: errors said when they are due (lib/form-core);
// a failed submit focuses the first field with something to fix, and a summary lists
// every problem politely (heard after the focused field's own error), each a way to
// its field, a check that could not answer with a way to ask again; ⌘↵ (Ctrl↵)
// submits from anywhere in it, Enter from a single-line field; while it sends the
// button turns (and keeps its focus) and the fields hold still without losing theirs
// (read-only, never disabled, which would drop the focus); leaving with changes not
// saved asks first: the browser's own prompt for a reload or a closed tab (`guard`),
// a dialog for a link in the page (`FormLeaveGuard`); `autosave` sends by itself and
// a status line says saved, unsaved, saving, or why it failed, with a retry.
//
// MATERIALS: the field chrome is `field`'s (plain or glass); the summary is a panel
// of the page's red or a pane of glass stained red; `<MaterialProvider
// material="glass">` round a form puts all of it in glass.
/** A form made for this mount, with its fields bound by name. */
export interface FormHandle<T> extends FormCore<T> {
/** A field bound by name: label, description, the error when it is due. */
Field: <P extends Path<T>>(props: BoundFieldProps<T, P>) => React.ReactNode
/** Rows bound by name: a list that grows, shrinks and reorders, each row keyed. */
FieldArray: <P extends ArrayPath<T>>(
props: BoundFieldArrayProps<T, P>,
) => React.ReactNode
}
/** A text field's props: wiring, name, value, onChange, onBlur, readOnly. */
export interface TextInput extends FieldWiring {
name: string
value: string
onChange: (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => void
onBlur: () => void
readOnly: boolean
}
interface ControlBase<V> {
name: string
value: V
onValueChange: (value: V) => void
/** Leaving it; a control that never focuses can call it when it is done. */
onBlur: () => void
/** What to spread on its focusable element: id and the aria wiring. */
wiring: FieldWiring
/** The label's id: a group of controls (radios, a segmented choice) is named by
* `aria-labelledby={labelId}`. */
labelId: string
/** The field's label as words, for a control that takes its name as text. */
label: string
invalid: boolean
/** An async check is running for it. */
checking: boolean
/** Sending: hold still (read-only), keep the focus. */
readOnly: boolean
}
/** A field's control: everything a control needs to be this field. `input`, a text
* field's props, exists only where the value is text: spreading it on a number's
* field does not compile. */
export type FieldControl<V> = ControlBase<V> &
([V] extends [string | null | undefined] ? { input: TextInput } : unknown)
export interface BoundFieldProps<T, P extends Path<T>>
extends Omit<FieldProps, "children" | "error" | "readOnly"> {
name: P
children: (control: FieldControl<PathValue<T, P>>) => React.ReactNode
}
export interface FieldRow<P extends string> {
/** Stable for the row while it lives, wherever it moves (and given back to a loaded
* row on a reset): the React key. */
key: string
index: number
/** The row's path, to name its fields: `${path}.title`. */
path: `${P}.${number}`
}
export interface FieldArrayOps<R> {
append: (row: R) => void
insert: (index: number, row: R) => void
remove: (index: number) => void
move: (from: number, to: number) => void
}
export interface BoundFieldArrayProps<T, P extends ArrayPath<T>> {
name: P
children: (
rows: FieldRow<P>[],
ops: FieldArrayOps<RowOf<T, P>>,
) => React.ReactNode
}
// ── reading the form ─────────────────────────────────────────────────────────────
/** Shallow equality: a selector returning a small object or tuple re-renders only
* when one of its members changed. */
function shallow(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true
if (
a === null ||
b === null ||
typeof a !== "object" ||
typeof b !== "object"
)
return false
const ka = Object.keys(a)
if (ka.length !== Object.keys(b).length) return false
return ka.every((k) =>
Object.is(
(a as Record<string, unknown>)[k],
(b as Record<string, unknown>)[k],
),
)
}
/** A slice of the form's state; re-renders only when the slice changes, by `equal`
* (shallow by default; `deepEqual` for a slice that builds lists). */
export function useFormState<T, S>(
form: FormCore<T>,
select: (state: FormState<T>) => S,
equal: (a: S, b: S) => boolean = shallow,
): S {
const cache = React.useRef<{ state: FormState<T>; value: S } | null>(null)
const read = () => {
const state = form.get()
const was = cache.current
if (was && was.state === state) return was.value
const value = select(state)
if (was && equal(was.value, value)) {
cache.current = { state, value: was.value }
return was.value
}
cache.current = { state, value }
return value
}
return React.useSyncExternalStore(form.subscribe, read, read)
}
// ── where the fields stand ───────────────────────────────────────────────────────
interface Placed {
path: string
label: string
element: HTMLElement
}
/** The bound fields in the order they stand on the page, and what each is called:
* what the summary lists them in and by. Kept as fields mount, move and go, in their
* effects, so reading it never reads the page during a render. */
function placesOf() {
let placed: Placed[] = []
return {
place(path: string, label: string, element: HTMLElement) {
placed = placed.filter((p) => p.path !== path && p.element !== element)
const entry = { path, label, element }
// Before the first field that comes after it in the document.
const at = placed.findIndex(
(p) =>
element.compareDocumentPosition(p.element) &
Node.DOCUMENT_POSITION_FOLLOWING,
)
if (at < 0) placed.push(entry)
else placed.splice(at, 0, entry)
return () => {
placed = placed.filter((p) => p !== entry)
}
},
/** Where a path stands (the form's own `""` first, a path with no field last). */
rank(path: string) {
if (path === "") return -1
const at = placed.findIndex((p) => p.path === path)
return at < 0 ? placed.length : at
},
get: (path: string) => placed.find((p) => p.path === path),
}
}
type Places = ReturnType<typeof placesOf>
// ── the form ─────────────────────────────────────────────────────────────────────
interface FormContextValue {
// biome-ignore lint/suspicious/noExplicitAny: one context for forms of every shape
form: FormCore<any>
element: React.RefObject<HTMLFormElement | null>
places: Places
}
const FormContext = React.createContext<FormContextValue | null>(null)
function useFormContext(part: string): FormContextValue {
const context = React.useContext(FormContext)
if (!context) throw new Error(`${part} outside a <Form>`)
return context
}
/** Makes the form for this mount. Its callbacks are read live (a handler closing
* over this render's props is the one called); its schema, its checks' paths and its
* initial values are the first render's (new loaded values: `form.setInitial`). */
export function useForm<T>(options: FormOptions<T>): FormHandle<T> {
const live = React.useRef(options)
live.current = options
const [handle] = React.useState(() => {
const core = createForm<T>({
...options,
validate: options.validate && ((v) => live.current.validate?.(v) ?? {}),
checks:
options.checks &&
Object.fromEntries(
Object.entries(options.checks).map(([path, check]) => [
path,
{
debounce: check.debounce,
run: (value, context) => {
const now = live.current.checks?.[path]
if (!now) throw new Error(`form: the check at ${path} is gone`)
return now.run(value, context)
},
},
]),
),
onSubmit: (values) => live.current.onSubmit?.(values),
})
const bound = core as FormHandle<T>
bound.Field = (props) => <BoundField form={core} {...props} />
bound.FieldArray = (props) => <BoundFieldArray form={core} {...props} />
return bound
})
// Timers and in-flight checks end with the mount; the form itself stays usable (a
// remount in development subscribes again).
React.useEffect(() => () => handle.dispose(), [handle])
return handle
}
export interface FormProps<T>
extends Omit<React.ComponentProps<"form">, "onSubmit"> {
form: FormCore<T>
/** The browser's own prompt before a reload or a closed tab with changes not saved.
* Default on. A link in the page asks through `FormLeaveGuard`. */
guard?: boolean
/** Heard after every submit, with what came of it. */
onSubmitted?: (result: Awaited<ReturnType<FormCore<T>["submit"]>>) => void
}
/** The fields' focusable things, for the first invalid one to be focused. */
const FOCUSABLE =
"input:not([type=hidden]),textarea,select,button,[tabindex]:not([tabindex='-1'])"
export function Form<T>({
form,
guard = true,
onSubmitted,
className,
children,
onKeyDown,
...props
}: FormProps<T>) {
const element = React.useRef<HTMLFormElement>(null)
const [places] = React.useState(placesOf)
const dirty = useFormState(form, (s) => isDirty(s) || s.submitting)
const submitting = useFormState(form, (s) => s.submitting)
const send = async () => {
const result = await form.submit()
onSubmitted?.(result)
if (result.ok || result.reason === "busy") return
// After the fields have drawn what they say: the first one to fix, in the
// order they stand, takes the focus, a `Field` or a headless control alike (a
// form-level refusal alone leaves it where it is; the summary says it).
requestAnimationFrame(() => {
const first = result.paths
.filter((path) => path !== "")
.sort((a, b) => places.rank(a) - places.rank(b))
.map((path) => places.get(path)?.element)
.find(Boolean)
if (first) focusableIn(first)?.focus()
})
}
// A reload or a closed tab with changes not saved: the browser's own prompt.
React.useEffect(() => {
if (!guard || !dirty) return
const unload = (e: BeforeUnloadEvent) => {
e.preventDefault()
}
window.addEventListener("beforeunload", unload)
return () => window.removeEventListener("beforeunload", unload)
}, [guard, dirty])
return (
<FormContext.Provider value={{ form, element, places }}>
<form
ref={element}
data-slot="form"
noValidate
aria-busy={submitting || undefined}
className={cn("flex flex-col gap-5", className)}
onSubmit={(e) => {
e.preventDefault()
void send()
}}
onKeyDown={(e) => {
onKeyDown?.(e)
if (e.defaultPrevented) return
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
element.current?.requestSubmit()
}
}}
{...props}
>
{children}
</form>
</FormContext.Provider>
)
}
// ── leaving with changes not saved ───────────────────────────────────────────────
/** A link pressed in the page while the form holds changes not saved: held, and
* `asking` until the person says `stay` or `leave` (which follows the link it held,
* as it would have gone). For a dialog of your own; `FormLeaveGuard` is one. A link
* to elsewhere in a new tab, with a modifier, or to a fragment of this page passes. */
export function useLeaveGuard(): {
asking: boolean
stay: () => void
leave: () => void
} {
const { form } = useFormContext("useLeaveGuard")
const dirty = useFormState(form, (s) => isDirty(s) || s.submitting)
const [held, setHeld] = React.useState<HTMLAnchorElement | null>(null)
// The press a `leave` makes again, let through once.
const through = React.useRef<HTMLAnchorElement | null>(null)
React.useEffect(() => {
if (!dirty) return
const link = (e: MouseEvent) => {
const a = (e.target as Element | null)?.closest?.("a[href]")
if (!(a instanceof HTMLAnchorElement)) return
if (through.current === a) {
through.current = null
return
}
if (e.defaultPrevented || e.button !== 0) return
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
if (a.target === "_blank" || a.hasAttribute("download")) return
if (a.origin !== window.location.origin) return
if (a.pathname === window.location.pathname && a.hash) return
e.preventDefault()
e.stopPropagation()
setHeld(a)
}
document.addEventListener("click", link, true)
return () => document.removeEventListener("click", link, true)
}, [dirty])
return {
asking: held !== null,
stay: () => setHeld(null),
leave: () => {
const a = held
setHeld(null)
if (!a) return
through.current = a
a.click()
},
}
}
/** Asks before a link in the page leaves changes not saved: a dialog with stay and
* leave, in the form's material. Inside a `<Form>`. */
export function FormLeaveGuard({
title = "leave without saving?",
description = "your changes here are not saved yet",
material,
}: {
title?: React.ReactNode
description?: React.ReactNode
material?: Material
}) {
const { asking, stay, leave } = useLeaveGuard()
const m = useMaterial(material)
return (
<Dialog open={asking} onOpenChange={(open) => !open && stay()}>
<DialogContent material={m}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={stay}>
stay
</Button>
<Button variant="destructive" onClick={leave}>
leave
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ── a field ──────────────────────────────────────────────────────────────────────
/** A path of the form bound without a `Field` round it: for a control that must
* look like something other than a field (a name written on a card, a cell in a
* table), whose label and error the page places itself. */
export interface HeadlessControl<V> {
name: string
value: V
onValueChange: (value: V) => void
/** Leaving it: its error may be said, its check runs. */
onBlur: () => void
/** What to show at it, when it is due (lib/form-core, `errorAt`). */
error: string | undefined
invalid: boolean
/** An async check is running for it. */
checking: boolean
/** Sending: hold still (read-only), keep the focus. */
readOnly: boolean
/** A ref callback for the control's element (or one round it): where the
* summary lists it and what a failed submit focuses. Not named `ref`: React's
* compiler reads an object with a `ref` as a ref and refuses its other reads
* during render. */
anchor: (element: HTMLElement | null) => void
}
/** A headless control's text props, only where the value is text. */
export interface HeadlessTextInput {
name: string
value: string
onChange: (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => void
onBlur: () => void
readOnly: boolean
"aria-invalid"?: true
}
export type HeadlessFieldControl<V> = HeadlessControl<V> &
([V] extends [string | null | undefined]
? { input: HeadlessTextInput }
: unknown)
/** Binds one path of `form`: its value, its change, leaving it, what is wrong with
* it, all from the form's state and re-rendering only when those change. What
* `form.Field` is made of, for a control that draws its own label and error. Inside
* a `<Form>`, `anchor` places it for the summary (by `label`, else the path). */
export function useFormField<T, P extends Path<T>>(
form: FormCore<T>,
name: P,
label?: string,
): HeadlessFieldControl<PathValue<T, P>> {
const context = React.useContext(FormContext)
const [value, error, checking, submitting] = useFormState(
form,
(s) =>
[
getAt(s.values, name) as PathValue<T, P>,
errorAt(s, name),
name in s.checking,
s.submitting,
] as const,
)
const words = label ?? name
const places = context?.places
// The summary lists it where it stands, by its words; gone with the element.
const anchor = React.useCallback(
(element: HTMLElement | null) => {
if (!places || !element) return
return places.place(name, words, element)
},
[places, name, words],
)
const readOnly = submitting && !form.autosave
const onValueChange = (v: PathValue<T, P>) => form.setValue(name, v)
const onBlur = () => form.blur(name)
const invalid = error !== undefined
const input: HeadlessTextInput = {
name,
value: (value ?? "") as string,
onChange: (e) => onValueChange(e.currentTarget.value as PathValue<T, P>),
onBlur,
readOnly,
...(invalid && { "aria-invalid": true as const }),
}
// `input` is offered at run time to every path and typed only where the value
// is text (HeadlessFieldControl), which keeps it off a number's.
return {
name,
value,
onValueChange,
onBlur,
error,
invalid,
checking,
readOnly,
anchor,
input,
} as HeadlessControl<PathValue<T, P>> as HeadlessFieldControl<PathValue<T, P>>
}
/** The focusable thing at or inside a placed element. */
function focusableIn(element: HTMLElement): HTMLElement | null {
return element.matches(FOCUSABLE)
? element
: element.querySelector<HTMLElement>(FOCUSABLE)
}
function BoundField<T, P extends Path<T>>({
form,
name,
label,
aside,
children,
onBlur,
...field
}: BoundFieldProps<T, P> & { form: FormCore<T> }) {
const words = typeof label === "string" ? label : name
const {
value,
error,
checking,
readOnly,
onValueChange,
onBlur: leave,
anchor,
} = useFormField(form, name, words)
return (
<Field
ref={anchor}
label={label}
error={error}
readOnly={readOnly}
aside={
checking ? (
<span className="inline-flex items-center gap-1.5">
<Spinner size="sm" label="checking" />
checking
</span>
) : (
aside
)
}
data-field-path={name}
// Focus leaving the field (not moving inside it): the field was left.
onBlur={(e) => {
onBlur?.(e)
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) leave()
}}
{...field}
>
{(wiring, { labelId }) => {
const input: TextInput = {
...wiring,
name,
value: (value ?? "") as string,
onChange: (e) =>
onValueChange(e.currentTarget.value as PathValue<T, P>),
onBlur: leave,
readOnly,
}
// `input` is offered at run time to every field and typed only where the
// value is text (FieldControl), which is what keeps it off a number's.
return children({
name,
value,
onValueChange,
onBlur: leave,
wiring,
labelId,
input,
label: words,
invalid: error !== undefined,
checking,
readOnly,
} as ControlBase<PathValue<T, P>> as FieldControl<PathValue<T, P>>)
}}
</Field>
)
}
function BoundFieldArray<T, P extends ArrayPath<T>>({
form,
name,
children,
}: BoundFieldArrayProps<T, P> & { form: FormCore<T> }) {
// The rows' values decide how many there are; the keys follow them.
useFormState(form, (s) => getAt(s.values, name))
const keys = form.keysOf(name)
const rows = keys.map((key, index) => ({
key,
index,
path: `${name}.${index}` as `${P}.${number}`,
}))
return children(rows, {
append: (row) => form.append(name, row),
insert: (index, row) => form.insert(name, index, row),
remove: (index) => form.remove(name, index),
move: (from, to) => form.move(name, from, to),
})
}
// ── what the form says ───────────────────────────────────────────────────────────
/** What is wrong, after a submit that could not go: every problem, in the order the
* fields stand, each a way to its field; the form's own reason first; a check that
* could not answer with a way to ask it again. Polite: a failed submit moves the
* focus to the first field to fix, whose own error is heard first, then this. It is
* always in the page (hidden while it has nothing to say), so what appears in it is
* heard. */
export function FormSummary({
material,
scheme,
className,
}: {
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's. */
scheme?: Scheme
className?: string
}) {
const { form, places } = useFormContext("FormSummary")
const m = useMaterial(material)
// Over a picture the summary's surface is read by the part of it under it.
const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme)
const { tried, formError, list } = useFormState(
form,
(s) => ({
tried: s.submitCount > 0 || Object.keys(s.refused).length > 0,
formError: s.formError,
list: problemsOf(s),
}),
deepEqual,
)
const sorted = [...list].sort(
(a, b) => places.rank(a.path) - places.rank(b.path),
)
const shown = tried && (formError !== null || sorted.length > 0)
const go = (path: string) => {
const element = places.get(path)?.element
if (element) focusableIn(element)?.focus()
}
const count = sorted.filter((p) => p.path !== "").length
const body = shown && (
<div className="flex items-start gap-2.5">
<CircleAlert aria-hidden className="mt-0.5 size-4 shrink-0" />
<div className="flex min-w-0 flex-col gap-1.5">
<p className="font-medium text-[13px]">
{formError ??
(count === 1 ? "one thing to fix" : `${count} things to fix`)}
</p>
{sorted.length > 0 && (
<ul className="flex flex-col gap-1 text-xs">
{sorted.map((p) => (
<li key={p.path} className="flex flex-wrap items-baseline gap-2">
{p.path === "" ? (
p.message
) : (
<button
type="button"
onClick={() => go(p.path)}
className="cursor-pointer text-left underline-offset-2 hover:underline"
>
<span className="font-medium">
{places.get(p.path)?.label ?? p.path}
</span>
: {p.message}
</button>
)}
{p.failed && (
<button
type="button"
onClick={() => void form.recheck(p.path)}
className="inline-flex cursor-pointer items-center gap-1 underline-offset-2 hover:underline"
>
<RotateCcw aria-hidden className="size-3" />
ask again
</button>
)}
</li>
))}
</ul>
)}
</div>
</div>
)
return (
<div
data-slot="form-summary"
aria-live="polite"
aria-atomic
className={cn(!shown && "sr-only", className)}
>
{shown &&
(m === "glass" ? (
<Glass
ref={here}
shape="surface"
scheme={s}
tint="var(--tone-bad)"
tintAmount={0.22}
data-material={m}
className="p-3.5 text-tone-bad [--glass-radius:--theme(--radius-2xl)]"
>
{body}
</Glass>
) : (
<div
ref={here}
data-material={m}
className={cn(
schemeOf(s),
"rounded-lg bg-tone-bad/8 p-3.5 text-tone-bad ring-1 ring-tone-bad/25 ring-inset",
)}
>
{body}
</div>
))}
</div>
)
}
/** How the form stands, in a line (metadata's voice): unsaved, saving, saved at a
* time, or why it failed with a retry (which asks again whatever could not be
* checked). For a form that sends by itself, the whole of what it says. */
export function FormStatus({
material,
className,
}: {
material?: Material
className?: string
}) {
const { form } = useFormContext("FormStatus")
const m = useMaterial(material)
const [status, savedAt, formError, invalid, unchecked] = useFormState(
form,
(s) =>
[
statusOf(s),
s.savedAt,
s.formError,
Object.keys(s.invalid).length > 0,
Object.keys(s.failed).length > 0,
] as const,
)
const time =
savedAt === null
? null
: new Date(savedAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
const why = formError ?? (unchecked ? "could not be checked" : null)
return (
<p
data-slot="form-status"
data-status={status}
data-material={m}
aria-live="polite"
className={cn(
"flex items-center gap-1.5 font-mono text-xs lowercase",
status === "failed"
? "text-tone-bad"
: m === "glass"
? "text-current/60"
: "text-muted-foreground",
className,
)}
>
{status === "saving" && <Spinner size="sm" label="saving" material={m} />}
{status === "clean" && "nothing changed"}
{status === "unsaved" &&
(invalid ? "not saved: something to fix" : "unsaved changes")}
{status === "saving" && "saving"}
{status === "saved" && `saved${time ? ` at ${time}` : ""}`}
{status === "failed" && (
<>
{`not saved${why ? `: ${why}` : ""}`}
<button
type="button"
onClick={() => void form.submit()}
className="ml-1 inline-flex cursor-pointer items-center gap-1 underline-offset-2 hover:underline"
>
<RotateCcw aria-hidden className="size-3" />
retry
</button>
</>
)}
</p>
)
}
/** The submit button: it turns while the form sends (keeping its focus), and shows
* the keys that send from anywhere in the form. */
export function FormSubmit({
keys = true,
children = "save",
...props
}: Omit<ButtonProps, "type" | "loading"> & {
/** Show ⌘↵ beside the words. */
keys?: boolean
}) {
const { form } = useFormContext("FormSubmit")
const submitting = useFormState(form, (s) => s.submitting)
return (
<Button type="submit" loading={submitting} {...props}>
{children}
{keys && <Kbd keys="mod+enter" className="ml-1 text-[11px] opacity-70" />}
</Button>
)
}
/** Back to the values as loaded; there only while something changed. The loaded rows
* keep their keys, so nothing they hold remounts and the focus stays. */
export function FormReset({
children = "discard",
variant = "ghost",
...props
}: Omit<ButtonProps, "type" | "onClick">) {
const { form } = useFormContext("FormReset")
const dirty = useFormState(form, (s) => isDirty(s))
return (
<Button
type="button"
variant={variant}
disabled={!dirty}
onClick={() => form.reset()}
{...props}
>
{children}
</Button>
)
}// THE FORM, framework-free: values, what is wrong with them and when to say so, what
// changed since they were loaded, and sending them. `ui/form` binds it to React and
// draws it; this file runs in bun (scripts/examples/form-core.ts) and in any UI.
//
// WHY ITS OWN, not react-hook-form or TanStack Form. The registry ships source a
// consumer owns, so a form is better as two small files than as two packages and a
// resolver. Validation is Standard Schema (zod, valibot and arktype all answer
// `~standard`), so there is nothing to adapt. The controls here are value controls
// (`value` + `onValueChange`: number, slider, people-picker, chip-input) with no DOM
// input to register, which react-hook-form's uncontrolled refs are built around. And
// the timing, async checks, server errors, autosave and optimistic rollback are one
// model here, the same one the table's optimistic and staged edits follow, rather than
// four options of a library's.
//
// WHEN AN ERROR IS SAID: reward early, punish late. Everything is validated on every
// change, silently; a field's error is shown once it has been left (`blur`) or a
// submit was tried, and from then on it follows every change, so it disappears the
// moment the value is fixed. A server's answer is shown at once, and cleared by the
// next change to its field.
//
// PATHS are dot strings (`tracks.0.title`), typed from the values (`Path<T>`). A field
// array keeps a stable key per row (`keysOf`), and its rows' touched marks, errors and
// check answers move with the rows when they are inserted, removed or moved; a reset
// gives the loaded rows back their own keys.
//
// WHAT IS SENT IS WHAT WAS CHECKED. A submit takes the values as they stand at the
// press and decides on exactly those: their rules, and every check answered for exactly
// those values (asked again where the answer is missing, stale or was cut short). A
// change typed while it settles or sends is the next change, never part of this send.
//
// A CHECK THAT CANNOT ANSWER (it threw, the network is down, an async schema rejected)
// is a state, `failed`, with its reason: never a verdict on the value, never a form left
// busy. The field says it could not be checked; a submit asks again, and `recheck`
// asks one field again.
//
// ONE CHANGE, ONE NOTICE: a mutation's state writes are batched, so a keystroke wakes
// each subscriber once, whatever the rules and checks it sets going.
/** Standard Schema v1 (standardschema.dev), copied as the spec asks: no dependency. */
export interface StandardSchemaV1<Input = unknown, Output = Input> {
readonly "~standard": {
readonly version: 1
readonly vendor: string
readonly validate: (
value: unknown,
) => StandardResult<Output> | Promise<StandardResult<Output>>
readonly types?:
| { readonly input: Input; readonly output: Output }
| undefined
}
}
export type StandardResult<Output> =
| { readonly value: Output; readonly issues?: undefined }
| { readonly issues: readonly StandardIssue[] }
export interface StandardIssue {
readonly message: string
readonly path?: readonly (PropertyKey | { readonly key: PropertyKey })[]
}
// ── paths ─────────────────────────────────────────────────────────────────────────
type Leaf =
| string
| number
| boolean
| bigint
| symbol
| null
| undefined
| Date
| Blob
/** Every path into `T`: `"name"`, `"tracks"`, `"tracks.0"`, `"tracks.0.title"`. */
export type Path<T> = T extends Leaf
? never
: T extends readonly (infer E)[]
? `${number}` | `${number}.${Path<E>}`
: {
[K in keyof T & string]: NonNullable<T[K]> extends Leaf
? K
: K | `${K}.${Path<NonNullable<T[K]>>}`
}[keyof T & string]
/** The value at a path. */
export type PathValue<T, P extends string> = P extends `${infer K}.${infer R}`
? T extends readonly (infer E)[]
? K extends `${number}`
? PathValue<E, R>
: never
: K extends keyof T
? PathValue<NonNullable<T[K]>, R>
: never
: T extends readonly (infer E)[]
? P extends `${number}`
? E
: never
: P extends keyof T
? T[P]
: never
/** The paths whose value is an array: what a field array binds. */
export type ArrayPath<T> = {
[P in Path<T>]: PathValue<T, P> extends readonly unknown[] ? P : never
}[Path<T>]
/** An array path's row. */
export type RowOf<T, P extends ArrayPath<T>> =
PathValue<T, P> extends readonly (infer E)[] ? E : never
const steps = (path: string): string[] => (path === "" ? [] : path.split("."))
/** The value at `path`, undefined where the way runs out. */
export function getAt(root: unknown, path: string): unknown {
let at: unknown = root
for (const s of steps(path)) {
if (at === null || typeof at !== "object") return undefined
at = (at as Record<string, unknown>)[s]
}
return at
}
/** `root` with `value` at `path`, copied along the way (arrays stay arrays). The way
* must exist: a path through nothing is a confused caller, and it screams. */
export function setAt<R>(root: R, path: string, value: unknown): R {
const [head, ...rest] = steps(path)
if (head === undefined) return value as R
assert(
root !== null && typeof root === "object",
`form: no object to set ${path} in`,
)
const next = rest.length
? setAt((root as Record<string, unknown>)[head], rest.join("."), value)
: value
if (Array.isArray(root)) {
const i = Number(head)
assert(
Number.isInteger(i) && i >= 0 && i <= root.length,
`form: ${head} is no index of a ${root.length}-row array`,
)
const copy = root.slice()
copy[i] = next
return copy as R
}
return { ...(root as object), [head]: next } as R
}
/** Structural equality of form values: plain objects, arrays, dates, primitives. */
export function deepEqual(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()
if (
a === null ||
b === null ||
typeof a !== "object" ||
typeof b !== "object"
)
return false
if (Array.isArray(a) !== Array.isArray(b)) return false
const ka = Object.keys(a)
const kb = Object.keys(b)
if (ka.length !== kb.length) return false
for (const k of ka)
if (
!deepEqual(
(a as Record<string, unknown>)[k],
(b as Record<string, unknown>)[k],
)
)
return false
return true
}
/** `path` is `prefix` or inside it. */
export const within = (path: string, prefix: string) =>
prefix === "" || path === prefix || path.startsWith(`${prefix}.`)
/** A record keyed by path, its entries under `array` renumbered by `map` (an index to
* its new index, or null where the row is gone). */
export function reindex<V>(
record: Readonly<Record<string, V>>,
array: string,
map: (index: number) => number | null,
): Record<string, V> {
const out: Record<string, V> = {}
const head = array === "" ? "" : `${array}.`
for (const [path, v] of Object.entries(record)) {
if (!path.startsWith(head)) {
out[path] = v
continue
}
const [index, ...rest] = path.slice(head.length).split(".")
const to = map(Number(index))
if (to === null) continue
out[[`${head}${to}`, ...rest].join(".")] = v
}
return out
}
/** A Standard Schema issue's path as a dot path. */
export const issuePath = (issue: StandardIssue): string =>
(issue.path ?? [])
.map((s) => String(typeof s === "object" ? s.key : s))
.join(".")
/** Issues as one message per path, the first said for each. */
export function errorsOf(
issues: readonly StandardIssue[],
): Record<string, string> {
const out: Record<string, string> = {}
for (const issue of issues) {
const path = issuePath(issue)
if (!(path in out)) out[path] = issue.message
}
return out
}
/** A check's path PATTERN: a path whose `*` steps stand for every row of an array
* (`tracks.*.title` is each track's title). `matches` says whether a concrete path is
* one of the pattern's. */
export function matches(pattern: string, path: string): boolean {
const a = steps(pattern)
const b = steps(path)
return (
a.length === b.length &&
a.every((s, i) => (s === "*" ? /^\d+$/.test(b[i] as string) : s === b[i]))
)
}
/** The concrete paths a pattern names in `values`: one per row where it has a `*`,
* each whose parent exists. */
export function expand(pattern: string, values: unknown): string[] {
const walk = (rest: string[], at: unknown, done: string[]): string[] => {
const [head, ...tail] = rest
if (head === undefined) return [done.join(".")]
if (at === null || typeof at !== "object") return []
if (head === "*")
return Array.isArray(at)
? at.flatMap((row, i) => walk(tail, row, [...done, String(i)]))
: []
return walk(tail, (at as Record<string, unknown>)[head], [...done, head])
}
return walk(steps(pattern), values, [])
}
function assert(cond: unknown, message: string): asserts cond {
if (!cond) throw new Error(message)
}
const reasonOf = (error: unknown) =>
error instanceof Error ? error.message : String(error)
const without = <V>(record: Readonly<Record<string, V>>, path: string) => {
if (!(path in record)) return record
const { [path]: _gone, ...rest } = record
return rest
}
// ── the form ──────────────────────────────────────────────────────────────────────
/** An async check of one field (is the name taken?): run after the field's own rules
* pass, `debounce` ms after its last change (default 300), cancelled by the next one.
* Answers the message to show, or nothing when it passes; a throw (or a rejection) is
* the check failing to answer, its reason kept in `failed`, never a verdict. */
export interface FieldCheck<T> {
run: (
value: unknown,
context: { values: T; signal: AbortSignal },
) => string | undefined | Promise<string | undefined>
debounce?: number
}
/** What a submission answers: nothing (saved), or what was refused: messages by path
* (or Standard Schema issues, a server's own validation), and a whole-form reason. */
// biome-ignore lint/suspicious/noConfusingVoidType: a handler that returns nothing (`async () => { … }`) is `Promise<void>`, which `undefined` refuses
export type SubmitAnswer = void | {
errors?: Readonly<Record<string, string>> | readonly StandardIssue[]
error?: string
}
export interface FormOptions<T> {
/** The values as loaded: what "changed" is measured against. */
initial: T
/** The rules, any Standard Schema (zod, valibot, arktype) over the whole values;
* its refinements are the cross-field rules. */
schema?: StandardSchemaV1<T, unknown>
/** Rules beyond the schema, over the whole values: messages by path. */
validate?: (values: T) => Readonly<Record<string, string>>
/** Async checks by path, or by a path pattern for every row of an array
* (`tracks.*.title`: each track's title checked on its own). */
checks?: Readonly<Record<string, FieldCheck<T>>>
/** Sends the values. Thrown, the error's message is the form's reason. */
onSubmit?: (values: T) => SubmitAnswer | Promise<SubmitAnswer>
/** Treat the send as done the moment it starts (what changed is no longer changed),
* and roll back, with the reason, if it is refused. */
optimistic?: boolean
/** Send by itself this many ms after the last change, when valid. */
autosave?: number
/** The clock (tests). */
now?: () => number
}
export interface FormState<T> {
values: T
initial: T
/** The schema's and `validate`'s messages by path, said or not yet. */
invalid: Readonly<Record<string, string>>
/** The async checks' messages by path. */
checked: Readonly<Record<string, string>>
/** Paths an async check is running (or waiting its debounce) for. */
checking: Readonly<Record<string, true>>
/** What could not be checked, by path, and why: a check that threw or rejected
* (`""`: the form's own rules, an async schema that rejected). Not a verdict on the
* value: a submit asks again; `recheck` asks one again. */
failed: Readonly<Record<string, string>>
/** What the last submission was refused with, by path. */
refused: Readonly<Record<string, string>>
/** A reason for the whole form (the server's, a thrown error, a rollback). */
formError: string | null
touched: Readonly<Record<string, true>>
submitCount: number
submitting: boolean
/** When the values were last saved, by this form. */
savedAt: number | null
}
export type SubmitResult =
| { ok: true }
| { ok: false; reason: "invalid"; paths: string[] }
/** A check could not answer (`failed`): nothing was sent. */
| { ok: false; reason: "unchecked"; paths: string[] }
| { ok: false; reason: "refused"; paths: string[] }
| { ok: false; reason: "busy" }
/** How the form stands, in one word: what a status line says. */
export type FormStatus = "clean" | "unsaved" | "saving" | "saved" | "failed"
export interface Form<T> {
/** It sends by itself (`autosave`): its fields never hold still while it sends. */
readonly autosave: boolean
get(): FormState<T>
subscribe(listener: () => void): () => void
setValue<P extends Path<T>>(path: P, value: PathValue<T, P>): void
/** The field was left: its error may be said, its check runs now. */
blur(path: string): void
submit(): Promise<SubmitResult>
/** Ask again what could not be checked: a field's check, or (`""`) the rules. */
recheck(path: string): Promise<void>
/** Back to the values as loaded, nothing touched or said; the loaded rows keep the
* keys they had. */
reset(): void
/** New loaded values (a save elsewhere, a refetch): what changed is measured from
* them; the values typed stay. */
setInitial(values: T): void
/** A refusal from elsewhere (a server action's answer). */
refuse(answer: SubmitAnswer): void
append<P extends ArrayPath<T>>(path: P, row: RowOf<T, P>): void
insert<P extends ArrayPath<T>>(path: P, index: number, row: RowOf<T, P>): void
remove(path: ArrayPath<T>, index: number): void
move(path: ArrayPath<T>, from: number, to: number): void
keysOf(path: ArrayPath<T>): readonly string[]
dispose(): void
}
/** The value at a path, typed. */
export const valueAt = <T, P extends Path<T>>(state: FormState<T>, path: P) =>
getAt(state.values, path) as PathValue<T, P>
/** Changed since loaded: a path, or the whole form. */
export const isDirty = <T>(state: FormState<T>, path = "") =>
!deepEqual(getAt(state.values, path), getAt(state.initial, path))
/** Whether a field's error may be said yet. */
const said = <T>(state: FormState<T>, path: string) =>
state.submitCount > 0 || path in state.touched
/** What a check that could not answer says, at its path. */
export const failureMessage = (path: string, reason: string) =>
path === ""
? `the rules could not run: ${reason}`
: `could not be checked: ${reason}`
/** The message to show at a path, if any: a refusal at once, its own rules (then its
* check, then a check that could not answer) once it has been left or a submit was
* tried. */
export function errorAt<T>(
state: FormState<T>,
path: string,
): string | undefined {
if (path in state.refused) return state.refused[path]
if (!said(state, path)) return undefined
const failed = state.failed[path]
return (
state.invalid[path] ??
state.checked[path] ??
(failed === undefined ? undefined : failureMessage(path, failed))
)
}
/** Every message to show, by path (the whole form's at `""`), each marked when it is
* a check that could not answer (it can be asked again), in no order: the page orders
* them as its fields stand. */
export function problemsOf<T>(
state: FormState<T>,
): { path: string; message: string; failed: boolean }[] {
const paths = new Set([
...Object.keys(state.invalid),
...Object.keys(state.checked),
...Object.keys(state.failed),
...Object.keys(state.refused),
])
return [...paths].flatMap((path) => {
const message = errorAt(state, path)
return message === undefined
? []
: [
{
path,
message,
failed:
path in state.failed &&
!(path in state.refused) &&
!(path in state.invalid) &&
!(path in state.checked),
},
]
})
}
export function statusOf<T>(state: FormState<T>): FormStatus {
if (state.submitting) return "saving"
if (
state.formError !== null ||
Object.keys(state.refused).length > 0 ||
Object.keys(state.failed).length > 0
)
return "failed"
if (isDirty(state)) return "unsaved"
return state.savedAt === null ? "clean" : "saved"
}
const DEBOUNCE = 300
/** What one ask of a check came to: its message (none: it passes), why it could not
* answer, or that it was cut short by a newer change (no answer at all). */
type Outcome =
| { message: string | undefined }
| { failure: string }
| { aborted: true }
export function createForm<T>(options: FormOptions<T>): Form<T> {
const now = options.now ?? Date.now
let state: FormState<T> = {
values: options.initial,
initial: options.initial,
invalid: {},
checked: {},
checking: {},
failed: {},
refused: {},
formError: null,
touched: {},
submitCount: 0,
submitting: false,
savedAt: null,
}
// Row keys of the field arrays, by path: identity, not state (nothing is shown
// differently for a key), so handing out keys for rows seen the first time, which
// happens while a UI draws them, changes nothing anyone is subscribed to. `loadedKeys`
// are the keys of the loaded rows, what a reset gives back.
const keys = new Map<string, readonly string[]>()
const loadedKeys = new Map<string, readonly string[]>()
const listeners = new Set<() => void>()
// ── one change, one notice: writes inside `batch` notify once, at its end.
let depth = 0
let unsaid = false
const notify = () => {
for (const l of [...listeners]) l()
}
const set = (patch: Partial<FormState<T>>) => {
state = { ...state, ...patch }
if (depth > 0) unsaid = true
else notify()
}
const batch = <R>(run: () => R): R => {
depth++
try {
return run()
} finally {
depth--
if (depth === 0 && unsaid) {
unsaid = false
notify()
}
}
}
// ── rules: the schema and `validate`. `rulesOf` answers for any values; `rule`
// keeps the shown state for the values as they stand, an async schema's answer
// applying only if no change came after it was asked.
const rulesOf = (values: T): Promise<Record<string, string>> => {
const extra = options.validate?.(values) ?? {}
const merge = (fromSchema: Record<string, string>) => {
const invalid = { ...fromSchema }
for (const [p, m] of Object.entries(extra)) invalid[p] ??= m
return invalid
}
const result = options.schema?.["~standard"].validate(values)
if (result instanceof Promise)
return result.then((r) => merge(r.issues ? errorsOf(r.issues) : {}))
return Promise.resolve(merge(result?.issues ? errorsOf(result.issues) : {}))
}
let asked = 0
const rule = () => {
const ask = ++asked
const values = state.values
const extra = options.validate?.(values) ?? {}
const apply = (fromSchema: Record<string, string>) => {
if (ask !== asked) return
const invalid = { ...fromSchema }
for (const [p, m] of Object.entries(extra)) invalid[p] ??= m
batch(() => {
set({ invalid, failed: without(state.failed, "") })
afterRules()
})
}
const result = options.schema?.["~standard"].validate(values)
if (result instanceof Promise)
void result.then(
(r) => apply(r.issues ? errorsOf(r.issues) : {}),
(error: unknown) => {
if (ask !== asked) return
set({ failed: { ...state.failed, "": reasonOf(error) } })
},
)
else apply(result?.issues ? errorsOf(result.issues) : {})
}
// ── async checks, per concrete path (a pattern's rows each their own): one running
// at a time, the stale one aborted, its answer kept with the value it was for.
const checks = options.checks ?? {}
const patterns = Object.keys(checks)
const checkAt = (path: string): FieldCheck<T> | undefined => {
const pattern = patterns.find((p) => matches(p, path))
return pattern === undefined ? undefined : checks[pattern]
}
const checkPaths = (values: T) => patterns.flatMap((p) => expand(p, values))
const timers = new Map<string, ReturnType<typeof setTimeout>>()
const running = new Map<string, { value: unknown; done: Promise<Outcome> }>()
const aborts = new Map<string, AbortController>()
/** What each check last answered, and for which value: an unchanged field is not
* asked again, a row moved keeps its answer. A failure is never kept as an answer. */
let answered = new Map<string, { value: unknown; outcome: Outcome }>()
const cancelCheck = (path: string) => {
clearTimeout(timers.get(path))
timers.delete(path)
aborts.get(path)?.abort()
aborts.delete(path)
running.delete(path)
}
/** One ask of a check, outside the shown state: for a snapshot's value. */
const ask = (
check: FieldCheck<T>,
value: unknown,
values: T,
signal: AbortSignal,
): Promise<Outcome> =>
new Promise<string | undefined>((resolve) =>
resolve(check.run(value, { values, signal })),
).then(
(message): Outcome => (signal.aborted ? { aborted: true } : { message }),
(error: unknown): Outcome =>
signal.aborted ? { aborted: true } : { failure: reasonOf(error) },
)
/** Ask a field's check now, for its value as it stands, into the shown state. */
const runCheck = (path: string): Promise<Outcome> => {
const check = checkAt(path)
assert(check, `form: no check at ${path}`)
cancelCheck(path)
const value = getAt(state.values, path)
if (path in state.invalid) {
// Its own rules fail first: nothing to ask the check about.
answered.delete(path)
set({
checked: without(state.checked, path),
checking: without(state.checking, path),
failed: without(state.failed, path),
})
return Promise.resolve({ message: undefined })
}
const abort = new AbortController()
aborts.set(path, abort)
set({
checked: without(state.checked, path),
failed: without(state.failed, path),
checking: { ...state.checking, [path]: true },
})
const done = ask(check, value, state.values, abort.signal).then(
(outcome) => {
if ("aborted" in outcome) return outcome
aborts.delete(path)
running.delete(path)
if ("failure" in outcome) answered.delete(path)
else answered.set(path, { value, outcome })
set({
checking: without(state.checking, path),
checked:
"message" in outcome && outcome.message !== undefined
? { ...state.checked, [path]: outcome.message }
: state.checked,
failed:
"failure" in outcome
? { ...state.failed, [path]: outcome.failure }
: state.failed,
})
return outcome
},
)
running.set(path, { value, done })
return done
}
const scheduleCheck = (path: string) => {
const check = checkAt(path)
if (!check) return
cancelCheck(path)
const value = getAt(state.values, path)
const known = answered.get(path)
const checked = without(state.checked, path)
const failed = without(state.failed, path)
if (known && deepEqual(known.value, value) && "message" in known.outcome) {
// Answered already for this very value (a row moved, a value put back): the
// answer stands, nobody is asked again.
const message = known.outcome.message
set({
checking: without(state.checking, path),
failed,
checked:
message === undefined ? checked : { ...checked, [path]: message },
})
return
}
answered.delete(path)
// Waiting out the debounce is checking too: the field's answer is not in, and a
// stale one (about a value no longer there) is gone.
set({ checked, failed, checking: { ...state.checking, [path]: true } })
timers.set(
path,
setTimeout(() => void runCheck(path), check.debounce ?? DEBOUNCE),
)
}
/** A check's answer for `path` at exactly `values`: the shown one when it is about
* the same value (running, answered, or asked now), else asked on its own. Never a
* stale or cut-short answer, never a failure taken as a pass. */
const checkFor = async (path: string, values: T): Promise<Outcome> => {
const check = checkAt(path)
assert(check, `form: no check at ${path}`)
const value = getAt(values, path)
const live = running.get(path)
if (live && deepEqual(live.value, value)) {
const outcome = await live.done
if (!("aborted" in outcome)) return outcome
}
const known = answered.get(path)
if (known && deepEqual(known.value, value)) return known.outcome
if (deepEqual(getAt(state.values, path), value)) {
const outcome = await runCheck(path)
if (!("aborted" in outcome)) return outcome
}
// The field moved on while this was asked: ask for these values on their own,
// uncancellable (it is this send's, not the field's).
return ask(check, value, values, new AbortController().signal)
}
/** Everything wrong with exactly `values`, and what could not be checked. */
const verdictOf = async (
values: T,
): Promise<{
invalid: Record<string, string>
failed: Record<string, string>
}> => {
let invalid: Record<string, string>
try {
invalid = await rulesOf(values)
} catch (error) {
return { invalid: {}, failed: { "": reasonOf(error) } }
}
const failed: Record<string, string> = {}
await Promise.all(
checkPaths(values).map(async (path) => {
if (path in invalid) return
const outcome = await checkFor(path, values)
if ("failure" in outcome) failed[path] = outcome.failure
else if ("message" in outcome && outcome.message !== undefined)
invalid[path] = outcome.message
}),
)
return { invalid, failed }
}
// ── autosave: after the rules settle on a change.
let saveTimer: ReturnType<typeof setTimeout> | undefined
let pendingSave = false
const afterRules = () => {
if (!pendingSave || options.autosave === undefined) return
clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
pendingSave = false
if (!isDirty(state)) return
if (Object.keys(state.invalid).length > 0) return
void form.submit()
}, options.autosave)
}
/** A change made: what it invalidates goes, its rules and checks run again. */
const changed = (path: string, patch: Partial<FormState<T>>) => {
const refused = Object.fromEntries(
Object.entries(state.refused).filter(
([p]) => !within(p, path) && !within(path, p),
),
)
pendingSave = true
set({ ...patch, refused, formError: null })
for (const p of checkPaths(state.values))
if (within(p, path) || within(path, p)) scheduleCheck(p)
rule()
}
let keyed = 0
const arrayAt = (path: string): unknown[] => {
const value = getAt(state.values, path)
assert(Array.isArray(value), `form: ${path} is not an array`)
return value
}
/** Rows moved: values, keys, touched marks, per-row messages and the checks' answers
* all follow. */
const rearrange = (
path: string,
rows: unknown[],
rowKeys: string[],
map: (index: number) => number | null,
) => {
for (const p of [...running.keys(), ...timers.keys()])
if (within(p, path)) cancelCheck(p)
keys.set(path, rowKeys)
// Keys of arrays inside the rows follow their rows too.
const inner = reindex(
Object.fromEntries([...keys].filter(([p]) => p !== path)),
path,
map,
)
for (const p of [...keys.keys()]) if (p !== path) keys.delete(p)
for (const [p, k] of Object.entries(inner)) keys.set(p, k)
answered = new Map(
Object.entries(reindex(Object.fromEntries(answered), path, map)),
)
changed(path, {
values: setAt(state.values, path, rows),
touched: reindex(state.touched, path, map),
checked: reindex(state.checked, path, map),
checking: reindex(state.checking, path, map),
failed: reindex(state.failed, path, map),
})
}
const snapshotKeys = () => new Map(keys)
/** The loaded rows' keys become those of `values`' rows where the rows line up. */
const loadKeys = (
values: T,
from: ReadonlyMap<string, readonly string[]>,
) => {
loadedKeys.clear()
for (const [p, k] of from) {
const rows = getAt(values, p)
if (Array.isArray(rows) && rows.length === k.length) loadedKeys.set(p, k)
}
}
const form: Form<T> = {
autosave: options.autosave !== undefined,
get: () => state,
subscribe(listener) {
listeners.add(listener)
return () => listeners.delete(listener)
},
setValue(path, value) {
batch(() => changed(path, { values: setAt(state.values, path, value) }))
},
blur(path) {
batch(() => {
if (!(path in state.touched))
set({ touched: { ...state.touched, [path]: true } })
// Leaving a field asks its check now rather than after the debounce.
if (checkAt(path) && timers.has(path)) void runCheck(path)
})
},
async submit() {
if (state.submitting) return { ok: false, reason: "busy" }
clearTimeout(saveTimer)
// What is sent is what is decided on: the values at the press, never a change
// typed while they are checked or sent.
const sent = state.values
const sentKeys = snapshotKeys()
// Busy from the press: settling the checks (is the name taken?) is part of
// sending, and the button says so.
set({ submitCount: state.submitCount + 1, submitting: true })
const before = { initial: state.initial, keys: new Map(loadedKeys) }
try {
const { invalid, failed } = await verdictOf(sent)
if (Object.keys(failed).length > 0) {
batch(() => {
set({ failed: { ...state.failed, ...failed }, submitting: false })
})
return { ok: false, reason: "unchecked", paths: Object.keys(failed) }
}
if (Object.keys(invalid).length > 0) {
set({ submitting: false })
return { ok: false, reason: "invalid", paths: Object.keys(invalid) }
}
batch(() => {
set({
formError: null,
refused: {},
...(options.optimistic && { initial: sent }),
})
if (options.optimistic) loadKeys(sent, sentKeys)
})
let answer: SubmitAnswer
try {
answer = await options.onSubmit?.(sent)
} catch (error) {
answer = { error: reasonOf(error) }
}
const refused = answer?.errors
? Array.isArray(answer.errors)
? errorsOf(answer.errors as readonly StandardIssue[])
: (answer.errors as Readonly<Record<string, string>>)
: {}
const formError = answer?.error ?? null
if (formError !== null || Object.keys(refused).length > 0) {
batch(() => {
set({
submitting: false,
refused,
formError,
// Optimistic: what was taken as saved was not.
...(options.optimistic && { initial: before.initial }),
})
if (options.optimistic) loadKeys(before.initial, before.keys)
})
return { ok: false, reason: "refused", paths: Object.keys(refused) }
}
// Saved: what was sent is what is loaded now (a change typed while it was
// sending stays a change).
batch(() => {
set({ submitting: false, initial: sent, savedAt: now() })
loadKeys(sent, sentKeys)
})
if (options.autosave !== undefined && isDirty(state)) {
pendingSave = true
afterRules()
}
return { ok: true }
} finally {
// Whatever happened (a bug in a handler included), the form is never left
// busy: the error, if any, goes on up.
if (state.submitting) set({ submitting: false })
}
},
async recheck(path) {
if (path === "") {
batch(() => {
set({ failed: without(state.failed, "") })
rule()
})
return
}
assert(checkAt(path), `form: no check at ${path} to ask again`)
await runCheck(path)
},
reset() {
batch(() => {
for (const p of [...running.keys(), ...timers.keys()]) cancelCheck(p)
answered.clear()
clearTimeout(saveTimer)
pendingSave = false
set({
values: state.initial,
touched: {},
checked: {},
checking: {},
failed: {},
refused: {},
formError: null,
submitCount: 0,
})
// The loaded rows take back their own keys: nothing they hold remounts.
keys.clear()
for (const [p, k] of loadedKeys) keys.set(p, k)
rule()
})
},
setInitial(values) {
batch(() => {
set({ initial: values })
loadKeys(values, keys)
})
},
refuse(answer) {
if (!answer) return
set({
refused: answer.errors
? Array.isArray(answer.errors)
? errorsOf(answer.errors as readonly StandardIssue[])
: (answer.errors as Readonly<Record<string, string>>)
: {},
formError: answer.error ?? null,
})
},
append(path, row) {
form.insert(path, arrayAt(path).length, row)
},
insert(path, index, row) {
const rows = arrayAt(path)
assert(
Number.isInteger(index) && index >= 0 && index <= rows.length,
`form: no row ${index} to insert at in ${path} (${rows.length} rows)`,
)
const rowKeys = [...form.keysOf(path)]
rowKeys.splice(index, 0, `k${keyed++}`)
const next = rows.slice()
next.splice(index, 0, row)
batch(() =>
rearrange(path, next, rowKeys, (i) => (i >= index ? i + 1 : i)),
)
},
remove(path, index) {
const rows = arrayAt(path)
assert(
index >= 0 && index < rows.length,
`form: no row ${index} in ${path} (${rows.length} rows)`,
)
batch(() =>
rearrange(
path,
rows.filter((_, i) => i !== index),
form.keysOf(path).filter((_, i) => i !== index),
(i) => (i === index ? null : i > index ? i - 1 : i),
),
)
},
move(path, from, to) {
const rows = arrayAt(path)
assert(
from >= 0 && from < rows.length && to >= 0 && to < rows.length,
`form: cannot move row ${from} to ${to} in ${path} (${rows.length} rows)`,
)
if (from === to) return
const order = rows.map((_, i) => i)
order.splice(to, 0, ...order.splice(from, 1))
const rowKeys = form.keysOf(path)
// order[newIndex] = oldIndex; the map asks the reverse.
const where = new Map(order.map((old, i) => [old, i]))
batch(() =>
rearrange(
path,
order.map((i) => rows[i]),
order.map((i) => rowKeys[i] as string),
(i) => where.get(i) ?? null,
),
)
},
keysOf(path) {
const length = arrayAt(path).length
const known = keys.get(path)
if (known && known.length === length) return known
// Rows the form has no keys for yet (first asked, or the array replaced whole):
// keys of their own; the loaded rows' keys when these are the loaded rows.
const made = Array.from({ length }, () => `k${keyed++}`)
keys.set(path, made)
if (deepEqual(getAt(state.values, path), getAt(state.initial, path)))
loadedKeys.set(path, made)
return made
},
dispose() {
for (const p of [...running.keys(), ...timers.keys()]) cancelCheck(p)
clearTimeout(saveTimer)
listeners.clear()
},
}
rule()
return form
}