{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "form-error-summary",
  "title": "Form Error Summary",
  "description": "The block that appears above a form after a failed submit — \"There are 3 problems with your submission\" followed by one link per error that jumps focus straight to the field it came from. Use it on any form long enough that the broken field can be off screen: signup and checkout, account or billing settings, a multi-step wizard, an onboarding or application form, an admin create/edit page, or anywhere a server action returns field errors. Common asks it answers: \"error summary\", \"validation summary\", \"show all form errors at the top\", \"list validation errors with links to fields\", \"focus the first invalid field on submit\", \"accessible form errors\", \"GOV.UK-style error summary\", \"react-hook-form errors object to a summary\". shadcn/ui's form ships per-field messages only — the summary, the focus move, and the field links are left to you, and they are the parts that decide whether a keyboard or screen-reader user can actually find what broke. Pass an `errors` array of `{ fieldId, message }` mapped straight from react-hook-form's formState.errors, a zod flatten(), or a server action's fieldErrors; an empty array renders nothing, so it can sit in the JSX unconditionally. Give it `focusKey={formState.submitCount}` and a second submit that fails identically still announces. Accessibility is the whole point: it announces by moving focus to a container labelled by its heading, rather than through a live region — a live region reads the messages but leaves focus behind, so the links the user needs are somewhere they must go hunting for, and doing both reads everything twice. Each message links to its field and focuses it on click, falling back to the first focusable control inside when the id names a wrapper (radio group, checkbox group, custom combobox); errors with no fieldId render as plain text for form-level failures like a declined card. The container uses a plain focus ring, not focus-visible, because focus arrives programmatically and browsers do not reliably paint it otherwise. headingLevel keeps the heading in your page outline. Styled with shadcn destructive/ring tokens for light and dark themes; lucide-react is the only dependency. Distinct from toast, which pops a transient message, and from an inline field message, which only helps once you have already found the field.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/form-error-summary.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { CircleAlert } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface FormErrorSummaryItem {\n  /**\n   * `id` of the invalid control, or of the wrapper for a grouped control (radio\n   * group, custom select). Omit it for form-level errors that belong to no\n   * single field — those render as plain text instead of a link.\n   */\n  fieldId?: string\n  /** The message, worded the same as the field's own inline error. */\n  message: string\n}\n\ninterface FormErrorSummaryProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"title\"> {\n  /**\n   * The failed validations, in the order the fields appear in the form. An\n   * empty list renders nothing, so the summary can be rendered unconditionally.\n   */\n  errors: FormErrorSummaryItem[]\n  /** Heading. Defaults to a sentence that counts the problems. */\n  title?: React.ReactNode\n  /** Heading level, so the summary fits the outline of the page it sits in. */\n  headingLevel?: 2 | 3 | 4\n  /** Move focus to the summary when it appears. */\n  focusOnError?: boolean\n  /**\n   * Bump on every submit attempt (react-hook-form's `formState.submitCount`) so\n   * a retry that fails the same way still moves focus back here.\n   */\n  focusKey?: number | string\n}\n\nconst FOCUSABLE = [\n  \"input:not([disabled])\",\n  \"select:not([disabled])\",\n  \"textarea:not([disabled])\",\n  \"button:not([disabled])\",\n  '[tabindex]:not([tabindex=\"-1\"])',\n].join(\",\")\n\n/**\n * Focuses the field a message points at. `fieldId` may name the control itself\n * or a wrapper — radio groups, checkbox groups and custom comboboxes describe\n * their error on a container, so fall back to the first focusable thing inside\n * it. Returns false when nothing focusable is found, which lets the link fall\n * back to plain fragment navigation.\n */\nfunction focusField(fieldId: string) {\n  const el = document.getElementById(fieldId)\n  if (!el) return false\n  const target = el.matches(FOCUSABLE)\n    ? el\n    : el.querySelector<HTMLElement>(FOCUSABLE)\n  if (!target) return false\n  target.focus()\n  return true\n}\n\n/**\n * The block that appears above a form after a failed submit: \"There are 3\n * problems with your submission\", then one link per error that focuses the\n * field it came from.\n *\n * It announces by moving focus to itself, not through a live region. A live\n * region would read the messages out but leave focus where it was, so the links\n * that lead to the offending fields are somewhere the user then has to hunt\n * for; doing both instead announces the same text twice. Focus lands on a\n * container labelled by the heading, so the count is read first and the list is\n * the very next thing in reading order.\n *\n * The container therefore uses a plain `focus:` ring rather than\n * `focus-visible:` — focus arrives programmatically here, which browsers do not\n * reliably treat as visible, and a sighted keyboard user would otherwise have\n * no idea where their focus went.\n */\nexport function FormErrorSummary({\n  errors,\n  title,\n  headingLevel = 2,\n  focusOnError = true,\n  focusKey,\n  className,\n  ...props\n}: FormErrorSummaryProps) {\n  const ref = React.useRef<HTMLDivElement>(null)\n  const titleId = React.useId()\n  const count = errors.length\n  const Heading = `h${headingLevel}` as const\n\n  // What counts as \"a new failed attempt\". With `focusKey` that is the submit\n  // itself, so submitting twice with the same errors still re-focuses; without\n  // it, only a change in the messages can be detected.\n  const attempt =\n    focusKey !== undefined\n      ? String(focusKey)\n      : errors.map((error) => `${error.fieldId ?? \"\"}:${error.message}`).join(\"|\")\n\n  React.useEffect(() => {\n    if (!focusOnError || count === 0) return\n    ref.current?.focus()\n  }, [attempt, count, focusOnError])\n\n  if (count === 0) return null\n\n  return (\n    <div\n      ref={ref}\n      tabIndex={-1}\n      aria-labelledby={titleId}\n      className={cn(\n        \"rounded-lg border border-destructive/50 bg-destructive/5 p-4 text-sm\",\n        \"focus:outline-none focus:ring-2 focus:ring-destructive\",\n        className\n      )}\n      {...props}\n    >\n      <Heading\n        id={titleId}\n        className=\"flex items-center gap-2 font-medium text-destructive\"\n      >\n        <CircleAlert className=\"h-4 w-4 shrink-0\" aria-hidden=\"true\" />\n        {title ??\n          (count === 1\n            ? \"There is 1 problem with your submission\"\n            : `There are ${count} problems with your submission`)}\n      </Heading>\n      <ul className=\"mt-2 list-disc space-y-1 pl-10 text-destructive\">\n        {errors.map(({ fieldId, message }, index) => (\n          <li key={`${fieldId ?? \"form\"}-${index}`}>\n            {fieldId ? (\n              <a\n                href={`#${fieldId}`}\n                onClick={(event) => {\n                  if (focusField(fieldId)) event.preventDefault()\n                }}\n                className=\"rounded-sm underline underline-offset-2 hover:no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive\"\n              >\n                {message}\n              </a>\n            ) : (\n              message\n            )}\n          </li>\n        ))}\n      </ul>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}