{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "form",
  "title": "Form",
  "description": "Fields on the grid and one statement button. A failed send gives each field its callout and focuses the first; while sending, the button says so. Or write it as a letter with a postscript, or have it postmarked when it's sent.",
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json",
    "https://thedirectors.agency/ui/r/button.json",
    "https://thedirectors.agency/ui/r/field.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/form.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { FieldErrorsProvider } from \"@/components/ui/field\"\n\ntype Control = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement\n\nconst Busy = React.createContext(false)\nconst Sent = React.createContext(false)\n\n/** Words for a control that fails: its data-error if it has one, else the browser's own message. */\nconst problem = (el: Control) => el.dataset.error || el.validationMessage\n\ntype FormProps = Omit<React.ComponentProps<\"form\">, \"onSubmit\"> & {\n  /** Runs once every control is valid. The submit button is busy until it settles. */\n  onSubmit: (data: FormData) => Promise<void> | void\n  /**\n   * grid (default): fields on the grid, each error hung from its own line.\n   * letter: the fields are blanks in a letter you write; what's wrong is said once, in a postscript.\n   * postmark: once it's sent, a postmark with the day and the time comes down on the corner.\n   */\n  variant?: \"grid\" | \"letter\" | \"postmark\"\n}\n\n/**\n * Fields on the grid and one statement button to send. The browser's own bubbles are off;\n * instead each failing control gets its callout in its Field, and focus goes to the first.\n * Give a control `data-error=\"…\"` to say it in your own words.\n */\nfunction Form({ onSubmit, onInput, variant = \"grid\", className, children, ...props }: FormProps) {\n  const [errors, setErrors] = React.useState<Record<string, string>>({})\n  const [busy, setBusy] = React.useState(false)\n  const [sent, setSent] = React.useState<Date | null>(null)\n  const running = React.useRef(false)\n\n  return (\n    <FieldErrorsProvider value={errors}>\n      <Busy.Provider value={busy}>\n        <Sent.Provider value={Boolean(sent)}>\n          <form\n            data-slot=\"form\"\n            data-variant={variant === \"grid\" ? undefined : variant}\n            noValidate\n            aria-busy={busy || undefined}\n            className={cn(\"ot-form\", className)}\n            onInput={(e) => {\n              onInput?.(e)\n              setSent(null) // changed after sending: it's a draft again\n              // Once a control has an error, it clears the moment it's right (or says what's still wrong).\n              const el = e.target as Control\n              if (!el.id) return\n              setErrors((prev) => {\n                if (!(el.id in prev)) return prev\n                const next = { ...prev }\n                if (el.validity.valid) delete next[el.id]\n                else next[el.id] = problem(el)\n                return next\n              })\n            }}\n            onSubmit={async (e) => {\n              e.preventDefault()\n              const form = e.currentTarget\n              if (running.current) return\n              const bad = (Array.from(form.elements) as Control[]).filter((el) => el.willValidate && el.validity && !el.validity.valid)\n              setErrors(Object.fromEntries(bad.filter((el) => el.id).map((el) => [el.id, problem(el)])))\n              if (bad.length) return bad[0].focus()\n              running.current = true\n              setBusy(true)\n              setSent(null)\n              try {\n                await onSubmit(new FormData(form))\n                setSent(new Date())\n              } finally {\n                running.current = false\n                setBusy(false)\n              }\n            }}\n            {...props}\n          >\n            {children}\n            {variant === \"letter\" ? <FormPostscript errors={errors} /> : null}\n            {variant === \"postmark\" && sent ? <FormPostmark date={sent} /> : null}\n          </form>\n        </Sent.Provider>\n      </Busy.Provider>\n    </FieldErrorsProvider>\n  )\n}\n\ntype FormSubmitProps = Omit<React.ComponentProps<typeof Button>, \"busy\"> & {\n  /** What the button says while the form is sending, keeping the action's name: \"Send\" becomes \"Sending\". */\n  busy?: string\n  /** What it says once sent, until something changes: \"Send\" becomes \"Sent\". */\n  sent?: string\n}\n\n/** The one statement button that sends the form. It says what it's doing while it does it. */\nfunction FormSubmit({ busy, sent, variant = \"statement\", children, ...props }: FormSubmitProps) {\n  const sending = React.useContext(Busy)\n  const done = React.useContext(Sent)\n  return (\n    <Button type=\"submit\" variant={variant} busy={sending && (busy ?? true)} {...props}>\n      {done && sent ? sent : children}\n    </Button>\n  )\n}\n\n/**\n * The letter's postscript: after a failed send, everything still to fix, said once below the letter.\n * Each sentence takes you to its blank. The Form renders it for the letter; it's exported to pin in docs.\n */\nfunction FormPostscript({ errors, className, ...props }: React.ComponentProps<\"div\"> & { errors: Record<string, string> }) {\n  const entries = Object.entries(errors)\n  if (!entries.length) return null\n  return (\n    <div data-slot=\"form-postscript\" className={cn(\"ot-form-ps\", className)} {...props}>\n      <span className=\"ot-form-ps-mark\">P.S.</span>{\" \"}\n      {entries.map(([id, message], i) => (\n        <React.Fragment key={id}>\n          {i ? \" \" : null}\n          <a\n            href={`#${id}`}\n            className=\"ot-form-ps-fix\"\n            onClick={(e) => {\n              e.preventDefault()\n              document.getElementById(id)?.focus()\n            }}\n          >\n            {message}\n          </a>\n        </React.Fragment>\n      ))}\n    </div>\n  )\n}\n\n/**\n * The postmark: sent, and when, in a double ring set down askew on the form's corner. Exported to pin in docs.\n * The day and time are set in the reader's own locale and time zone. A postmark rendered on the server must\n * pin both, or the server's words and the browser's differ and React redraws it.\n */\nfunction FormPostmark({\n  date,\n  locale,\n  timeZone,\n  className,\n  ...props\n}: React.ComponentProps<\"p\"> & { date: Date; locale?: string; timeZone?: string }) {\n  const f = (o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(locale, { ...o, timeZone }).format(date)\n  return (\n    <p data-slot=\"form-postmark\" role=\"status\" className={cn(\"ot-form-postmark\", className)} {...props}>\n      <span>Sent</span>{\" \"}\n      <time dateTime={date.toISOString()}>\n        <span className=\"ot-form-postmark-day\">{f({ day: \"numeric\" })}</span> <span>{f({ month: \"short\", year: \"numeric\" })}</span>{\" \"}\n        <span>{f({ hour: \"numeric\", minute: \"2-digit\" })}</span>\n      </time>\n    </p>\n  )\n}\n\nexport { Form, FormSubmit, FormPostscript, FormPostmark, type FormProps }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/form.css",
      "content": "@layer components {\n  /* ── ot-form ── a composition: fields on the grid, one statement button to send */\n  .ot-form { position: relative; display: grid; gap: var(--ot-space-6); justify-items: start; }\n  .ot-form > .ot-field { width: 100%; }\n\n  /* ── letter ── the form as a letter, after Weingart's to AIGA: our words run as prose, in roman,\n     and each field is a blank in it that you fill in italic. The prose names the blanks, so their\n     labels, hints and callouts are kept for screen readers only; what's still wrong is said once,\n     in the postscript. A textarea stays a block: the letter's body, on its ruled lines. */\n  .ot-form[data-variant=\"letter\"] { display: block; max-width: var(--ot-measure); font-size: var(--ot-mp); line-height: 2.2; color: var(--ot-graphite); }\n  .ot-form[data-variant=\"letter\"] > * + * { margin-block-start: var(--ot-space-5); }\n  .ot-form[data-variant=\"letter\"] .ot-field { display: inline-grid; width: auto; row-gap: 0; vertical-align: baseline; }\n  .ot-form[data-variant=\"letter\"] .ot-field > :is(.ot-label, .ot-field-count, .ot-field-hint, .ot-field-error) {\n    position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap;\n  }\n  .ot-form[data-variant=\"letter\"] .ot-field > input.ot-input { field-sizing: content; min-inline-size: 7em; max-inline-size: 100%; line-height: 1.3; }\n  .ot-form[data-variant=\"letter\"] .ot-field:has(> textarea) { display: grid; width: 100%; margin-block: var(--ot-space-2) var(--ot-space-4); }\n  .ot-form[data-variant=\"letter\"] > .ot-btn { margin-block-start: var(--ot-space-6); }\n\n  /* The postscript: our \"P.S.\" in roman, then each thing to fix in crimson. Each sentence takes you to its blank. */\n  .ot-form-ps { max-width: var(--ot-measure); margin-block-start: var(--ot-space-6); font-size: var(--ot-p); line-height: var(--ot-p-lh); color: var(--ot-signal); animation: ot-arrive var(--ot-moderato) var(--ot-exhale); }\n  .ot-form-ps-mark { margin-inline-end: 0.2em; font-weight: 600; color: var(--ot-ink); }\n  .ot-form-ps-fix {\n    color: inherit; text-decoration: underline var(--ot-hairline) color-mix(in srgb, var(--ot-signal) 40%, transparent); text-underline-offset: 0.2em;\n  }\n  .ot-form-ps-fix:hover { text-decoration-color: currentColor; }\n\n  /* ── postmark ── once sent, a postmark comes down on the form's corner: sent, the day large\n     (as the dot calendar sets it), the month and year, the time, in a double ring set askew like a\n     hand stamp, beside the button that sent it and clear of what you wrote. Ink, so the one accent\n     stays where you are. It lands once, and lifts off as soon as anything is changed. */\n  .ot-form-postmark {\n    --ot-form-tilt: -9deg;\n    position: absolute; inset-block-end: calc(-1 * var(--ot-space-3)); inset-inline-end: 0; z-index: 1;\n    display: grid; place-content: center; justify-items: center; inline-size: 6.75rem; aspect-ratio: 1; margin: 0; border-radius: 50%;\n    background: color-mix(in srgb, var(--ot-paper) 70%, transparent);\n    box-shadow: inset 0 0 0 var(--ot-hairline) var(--ot-ink), inset 0 0 0 5px var(--ot-paper), inset 0 0 0 calc(5px + var(--ot-hairline)) var(--ot-ink);\n    rotate: var(--ot-form-tilt); pointer-events: none;\n    font-size: var(--ot-pp); line-height: 1.25; color: var(--ot-ink); text-align: center;\n    animation: ot-form-stamp var(--ot-moderato) var(--ot-spiccato) backwards;\n  }\n  .ot-form-postmark:is([dir=\"rtl\"], [dir=\"rtl\"] *) { --ot-form-tilt: 9deg; }\n  .ot-form-postmark > span { letter-spacing: 0.04em; }\n  .ot-form-postmark > time { display: grid; justify-items: center; }\n  .ot-form-postmark-day { font-size: var(--ot-mf); line-height: 1; font-weight: 600; letter-spacing: var(--ot-mf-tr); font-variant-numeric: lining-nums; }\n  @keyframes ot-form-stamp { from { opacity: 0; scale: 1.4; rotate: calc(var(--ot-form-tilt) + 6deg); } }\n\n  @media (forced-colors: active) {\n    .ot-form-postmark { forced-color-adjust: none; background: Canvas; color: CanvasText; box-shadow: inset 0 0 0 1px CanvasText, inset 0 0 0 5px Canvas, inset 0 0 0 6px CanvasText; }\n    .ot-form-ps { color: CanvasText; }\n    .ot-form-ps-fix { color: LinkText; }\n  }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/form.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/form.css\"": {}
  },
  "meta": {
    "movement": "VI",
    "underneath": "hook",
    "contract": "ot-form"
  },
  "type": "registry:ui"
}
