{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "form",
  "type": "registry:ui",
  "title": "Form",
  "description": "The pieces that turn fields into a page: settings sections, fieldsets, an error summary that takes focus and links to each field, and the action row.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/icons.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/form.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/form 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Form patterns (components.md, \"Forms\"): how the fields, groups and buttons make a page.\n * One left-aligned column, never centered (R9).\n *\n * - FormSection is a settings-page section: a title and one line of description on the left\n *   third from 1024 up, the fields on the rest, and one column below that. Sections are\n *   separated by a --litt-line hairline and --litt-space-12 of air.\n * - Fieldset groups related controls under one legend. Its legend and note look like the ones\n *   in CheckboxGroup and RadioGroup, so a group made of fields matches a group made of boxes. With\n *   rows it holds a settings list of switches, flush, with hairlines between the rows.\n * - ErrorSummary names every problem above the form and links to each field. It follows the\n *   GOV.UK error summary: a focusable panel named by its heading, and one link per error. It\n *   takes focus when a submit fails, which is what reads it out. It is not a live region: it\n *   updates as fields are fixed, and an alert would re-announce it on every keystroke.\n * - FormActions is the action row of a page form: submit first, then Cancel, both md (R9).\n *   They stack full width below 640, submit on top. align=\"fields\" lines the row up with the\n *   fields column of a FormSection from 1024 up.\n * - Spacing: fields sit --litt-space-6 (24) apart and groups --litt-space-12 (48) apart. A Fieldset adds\n *   24 above and below itself, so in a column of fields it sits 48 from its neighbours.\n * - Flat (proposals/flat-pill.md, rule 5): no hairline between sections and none between the rows of a Fieldset with\n *   rows. The air stays and does the parting.\n * - Optional fields are marked, not required ones: \"(optional)\" in --litt-fg-subtle after the label.\n *   There are no asterisks.\n * - Motion: none of its own. The summary appears at once, because it answers a submit and a\n *   layout value must not ease. Field edges and link colors fade on the instant tier.\n * - Nothing here validates. The form decides what is wrong and passes the messages in.\n */\nimport { useEffect, useId, useRef, type ReactNode } from \"react\";\nimport { AlertIcon } from \"./icons\";\n\n/* ------------------------------------------------------------------ focus helpers */\n\n/* What can take focus. A disabled control cannot, so it is left out. */\nconst FOCUSABLE =\n  \"input:not([type=hidden]):not(:disabled), select:not(:disabled), textarea:not(:disabled), button:not(:disabled), [tabindex]:not([tabindex='-1'])\";\n\n/* Keep the focused control clear of a sticky header and the screen's edge. Scrolls only when it has to. */\nconst EDGE = 96;\n\nfunction focusElement(el: HTMLElement) {\n  el.focus({ preventScroll: true });\n  const { top, bottom } = el.getBoundingClientRect();\n  if (top < EDGE || bottom > window.innerHeight - EDGE) el.scrollIntoView({ block: \"center\", behavior: \"instant\" });\n}\n\n/* Focus the element with this id, or its first focusable child when the id sits on a wrapper such as a fieldset. */\nfunction focusById(id: string) {\n  const el = document.getElementById(id);\n  const target = el?.matches(FOCUSABLE) ? el : el?.querySelector<HTMLElement>(FOCUSABLE);\n  if (target) focusElement(target);\n}\n\n/**\n * Moves focus to the first control marked aria-invalid inside a form, and scrolls it into view when it is\n * out of sight. Use it for a form of three fields or fewer, which skips the ErrorSummary. Call it after the\n * errors have rendered, such as in an effect, so a screen reader reads the field with its error.\n *\n * @param form The form, or any element that holds the fields. It does nothing for null or when no field is invalid.\n */\nexport function focusFirstInvalid(form: HTMLElement | null) {\n  const el = form?.querySelector<HTMLElement>('[aria-invalid=\"true\"]');\n  if (el) focusElement(el);\n}\n\n/* ------------------------------------------------------------------ small helpers */\n\n/**\n * The \"(optional)\" mark for a field label, in the subtle color. Put it right after the label text:\n * `label={<>Company<Optional /></>}`. Mark the optional fields, never the required ones, and use no asterisks.\n */\nexport function Optional() {\n  return <span className=\"text-litt-fg-subtle\">{\" (optional)\"}</span>;\n}\n\n/* ------------------------------------------------------------------ section */\n\n/* The two columns of a settings page from 1024 up: the left third, then the rest. FormSection and FormActions share them, so a row can line up with the fields. */\nconst COLUMNS = \"lg:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]\";\n\nexport type FormSectionProps = {\n  /** The section's name, at the body size in the foreground color. */\n  title: ReactNode;\n  /** One line under the title, in the subtle color, that says what the section holds. */\n  description?: ReactNode;\n  /**\n   * The title's heading level. 2 for a form that is the page; 3 or 4 when the form sits under a heading of its own,\n   * such as a card's or a dialog's, so the page's outline stays in order. It looks the same at every level.\n   */\n  headingLevel?: 2 | 3 | 4;\n  /** The section's fields. They sit 24px apart in one column. */\n  children: ReactNode;\n  /** Extra classes for the section. */\n  className?: string;\n};\n\n/**\n * One section of a settings page. From 1024 up the title and description take the left third and the fields the\n * rest. Below that they stack in one column. Every section after the first starts with a hairline and 48px of air,\n * so put sections one after another in the same parent.\n */\nexport function FormSection({ title, description, headingLevel = 2, children, className = \"\" }: FormSectionProps) {\n  const titleId = useId();\n  const Heading = `h${headingLevel}` as const;\n  return (\n    <section\n      aria-labelledby={titleId}\n      className={\n        \"grid gap-x-12 gap-y-6 \" +\n        COLUMNS +\n        \" \" +\n        \"not-first:mt-12 not-first:border-t not-first:border-litt-line not-first:pt-12 in-[[data-material=flat]]:not-first:border-t-0 \" +\n        className\n      }\n    >\n      <div className=\"grid content-start gap-1\">\n        <Heading id={titleId} className=\"text-litt-body font-litt-title text-litt-fg\">\n          {title}\n        </Heading>\n        {description && <p className=\"text-pretty text-litt-small text-litt-fg-subtle\">{description}</p>}\n      </div>\n      <div className=\"grid min-w-0 content-start gap-6\">{children}</div>\n    </section>\n  );\n}\n\n/* ------------------------------------------------------------------ fieldset */\n\nexport type FieldsetProps = {\n  /** The group's name, shown above the controls and read out with each one. Required. */\n  legend: ReactNode;\n  /** One line under the legend, in the subtle color, that says what the group is for. It is linked to the fieldset with aria-describedby. */\n  description?: ReactNode;\n  /** A hint under the group. An error replaces it. */\n  help?: ReactNode;\n  /** Shown in danger color under the group and linked to the fieldset. Say how to fix it. Each control still shows its own error. */\n  error?: ReactNode;\n  /** Disables every control in the group, through the fieldset. */\n  disabled?: boolean;\n  /** Set it when the children are rows that carry their own padding, such as Switches with align=\"end\". They sit flush as one list, with a hairline between them and at both ends, instead of 24px apart. */\n  rows?: boolean;\n  /** The controls. They sit 24px apart in one column, or flush as a list of rows when rows is set. */\n  children: ReactNode;\n  /** Extra classes for the fieldset. */\n  className?: string;\n};\n\n/**\n * Related controls under one legend, such as an invoice address. The legend is small text in the foreground color, like a\n * field's label. In a column of fields the group adds 24px above and below itself, so groups sit 48px apart while fields\n * sit 24px apart. For a group of checkboxes or radios use CheckboxGroup or RadioGroup, which are fieldsets already. For a settings\n * list of switches, set rows: the switches sit flush, with a hairline between them.\n */\nexport function Fieldset({ legend, description, help, error, disabled, rows = false, children, className = \"\" }: FieldsetProps) {\n  const id = useId();\n  const note = error ?? help;\n  const describedBy = [description ? `${id}-description` : undefined, note ? `${id}-note` : undefined].filter(Boolean).join(\" \");\n  return (\n    <fieldset\n      disabled={disabled}\n      aria-describedby={describedBy || undefined}\n      className={\"my-6 min-w-0 border-0 p-0 first:mt-0 last:mb-0 \" + className}\n    >\n      <legend className={\"p-0 text-litt-small font-litt-title \" + (description ? \"mb-1 \" : \"mb-4 \") + (disabled ? \"text-litt-fg-disabled\" : \"text-litt-fg\")}>{legend}</legend>\n      {description && (\n        <p id={`${id}-description`} className={\"mb-4 text-litt-small \" + (disabled ? \"text-litt-fg-disabled\" : \"text-litt-fg-subtle\")}>\n          {description}\n        </p>\n      )}\n      <div className={rows ? \"grid divide-y divide-litt-line border-y border-litt-line in-[[data-material=flat]]:divide-y-0 in-[[data-material=flat]]:border-y-0\" : \"grid gap-6\"}>{children}</div>\n      {note && (\n        <p id={`${id}-note`} className={\"mt-2 text-litt-small \" + (error ? \"text-litt-danger\" : \"text-litt-fg-subtle\")}>\n          {note}\n        </p>\n      )}\n    </fieldset>\n  );\n}\n\n/* ------------------------------------------------------------------ error summary */\n\nexport type FormError = {\n  /** The id of the control to focus: the input, textarea or button. An id on a wrapper such as a fieldset works too, and its first control gets focus. */\n  id: string;\n  /** What is wrong and how to fix it, the same words the field shows under itself. It is the link's text. */\n  message: ReactNode;\n};\n\nexport type ErrorSummaryProps = {\n  /** One entry per invalid field, in page order. The summary renders nothing when this is empty. */\n  errors: FormError[];\n  /** The panel's heading. The default counts the fields: \"Fix 1 field to save\" or \"Fix 3 fields to save\". */\n  title?: ReactNode;\n  /** Change this when a submit fails and the summary takes focus. A submit counter works. It only takes focus while errors exist. */\n  focusKey?: string | number;\n  /** Extra classes for the panel, such as a width. */\n  className?: string;\n};\n\nconst link =\n  \"rounded-litt-control text-litt-fg-muted underline decoration-litt-line-strong underline-offset-4 \" +\n  \"transition-colors duration-(--litt-dur-instant) ease-litt hover:text-litt-fg \" +\n  \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus\";\n\n/**\n * Names every problem at the top of a form. It is a raised panel with an inset danger edge, an alert icon, a heading and one\n * link per error. A link moves focus to its field and scrolls it into view, and it never changes the URL, so it is safe in an\n * app that routes by hash. Pass the same list the fields use, so a fixed field leaves the summary as it leaves the form.\n * Forms of three fields or fewer skip it; use focusFirstInvalid instead.\n */\nexport function ErrorSummary({ errors, title, focusKey, className = \"\" }: ErrorSummaryProps) {\n  const titleId = useId();\n  const panel = useRef<HTMLDivElement>(null);\n  const seen = useRef<string | number | undefined>(undefined);\n  const count = errors.length;\n\n  /* Focus once per focusKey, and only when there is something to read. The effect runs after the commit, so the panel exists. */\n  useEffect(() => {\n    if (focusKey === undefined || focusKey === seen.current) return;\n    seen.current = focusKey;\n    if (count) panel.current?.focus();\n  }, [focusKey, count]);\n\n  if (!count) return null;\n  const heading = title ?? `Fix ${count} ${count === 1 ? \"field\" : \"fields\"} to save`;\n\n  return (\n    /* The panel takes focus (tabindex -1), and focus reads it out by its heading. No role=\"alert\": the list changes as fields are fixed. */\n    <div\n      ref={panel}\n      tabIndex={-1}\n      role=\"region\"\n      aria-labelledby={titleId}\n      className={\n        \"litt-raised p-4 shadow-[inset_0_0_0_1px_var(--litt-danger),var(--litt-highlight)] forced-colors:border \" +\n        \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus \" +\n        className\n      }\n    >\n      <div className=\"grid gap-3\">\n        <div className=\"flex items-start gap-2\">\n          {/* The wrapper takes the heading's line height, so the 16px icon centres on its first line. */}\n          <span className=\"grid h-lh shrink-0 place-items-center text-litt-body text-litt-danger\">\n            <AlertIcon />\n          </span>\n          <h2 id={titleId} className=\"text-litt-body font-litt-title text-litt-fg\">\n            {heading}\n          </h2>\n        </div>\n        <ul role=\"list\" aria-labelledby={titleId} className=\"m-0 grid list-none gap-2 p-0 pl-6\">\n          {errors.map((e) => (\n            <li key={e.id} className=\"text-litt-small\">\n              {/* The href names the field for a screen reader's link list. The click moves focus by hand, because the hash belongs to the router. */}\n              <a\n                href={`#${e.id}`}\n                className={link}\n                onClick={(ev) => {\n                  ev.preventDefault();\n                  focusById(e.id);\n                }}\n              >\n                {e.message}\n              </a>\n            </li>\n          ))}\n        </ul>\n      </div>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ actions */\n\nexport type FormActionsProps = {\n  /** The buttons, submit first. Make the submit a primary Button and Cancel a ghost Button, both md. */\n  children: ReactNode;\n  /** \"start\" begins the row at the start of its container. \"fields\" lines it up with the fields column of a FormSection from 1024 up, and below 1024 it is the same plain row. */\n  align?: \"start\" | \"fields\";\n  /** Extra classes for the row. */\n  className?: string;\n};\n\n/**\n * The action row of a page form. The submit comes first, then Cancel, 12px apart. Below 640 they stack full width with the submit\n * on top. On a settings page with FormSections, set align=\"fields\" so Save lines up with the fields, not the section titles. Use it for a\n * page form; a dialog keeps its own order, bottom right with the primary last.\n */\nexport function FormActions({ children, align = \"start\", className = \"\" }: FormActionsProps) {\n  const row = \"flex gap-(--litt-control-group-gap) max-sm:flex-col\";\n  if (align === \"start\") return <div className={row + \" \" + className}>{children}</div>;\n  /* The same two columns and gap as FormSection, with the buttons in the second one. Below 1024 the wrapper is a plain block. */\n  return (\n    <div className={\"lg:grid lg:gap-x-12 \" + COLUMNS + \" \" + className}>\n      <div className={row + \" lg:col-start-2\"}>{children}</div>\n    </div>\n  );\n}\n",
      "target": "components/litt/form.tsx"
    }
  ],
  "docs": "Import FormSection, Fieldset, ErrorSummary and FormActions from components/litt/form. The fields themselves come from input, select, checkbox, switch and slider.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
