← ui

form

ui

a form engine over any schema: typed fields, errors when due, async checks

npx shadcn add @ag/form
Already installed? Preview an update
npx shadcn add @ag/form --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-14
  • useFormField(form, path, label?) binds one path with no Field round it: value, change, leaving, the error when it is due, checking, read-only while sending, and anchor, a ref callback that places the control for the summary. input spreads on a text control, as c.input does. For a control that draws its own label and error (a name written on a card, a cell in a table). form.Field is 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.
Updated 2026-10-14 · release notes

also called validation

shelf controls

uses button, dialog, field, kbd, material, spinner

used by components

the demo above, verbatim

form.demo.tsx
"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

form.tsx
"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>
  )
}
form-core.ts
// 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
}