{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "error-summary",
  "type": "registry:ui",
  "title": "Error Summary",
  "description": "Lists every problem on submit, links to each field, and thins out as you fix.",
  "categories": [
    "forms"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/error-summary.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Alert, ArrowRight, CircleCheck } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type ErrorSummaryItem = {\n  /** The control's id, or its name when it has no id. Used to focus it. */\n  id: string;\n  /** The field's label, as the user saw it. */\n  label: React.ReactNode;\n  /** What's wrong and how to fix it. */\n  message: React.ReactNode;\n};\n\nexport type ErrorSummaryProps = Omit<React.ComponentProps<\"div\">, \"title\" | \"onSelect\"> & {\n  /** The current problems, in the order the fields appear. An empty list closes the summary. */\n  errors: ErrorSummaryItem[];\n  /** Increment on every submit. Each new count with errors moves focus to the summary. */\n  submitCount?: number;\n  /** Heading. Receives the count; defaults to “Fix 3 problems to continue”. */\n  title?: (count: number) => React.ReactNode;\n  /** Shown briefly after the last problem is fixed, before the summary closes. Pass null to close at once. */\n  resolvedTitle?: React.ReactNode;\n  /** Space kept below the summary while it is open, animated with it so the form never jumps. */\n  gap?: number;\n  /** Called when a link is followed, after the field has focus. */\n  onSelect?: (item: ErrorSummaryItem) => void;\n};\n\n/** Moves to a field by id or name: scrolls its label into view, then focuses the control. */\nexport function focusField(id: string, smooth = true) {\n  const el =\n    document.getElementById(id) ??\n    (document.getElementsByName(id)[0] as HTMLElement | undefined) ??\n    null;\n  if (!el) return null;\n  const anchor = (el as HTMLInputElement).labels?.[0] ?? el;\n  anchor.scrollIntoView({ block: \"center\", behavior: smooth ? \"smooth\" : \"auto\" });\n  el.focus({ preventScroll: true });\n  if (el instanceof HTMLInputElement && /^(text|email|search|tel|url|password)$/.test(el.type)) el.select();\n  return el;\n}\n\nconst defaultTitle = (n: number) => (\n  <>\n    Fix <NumberFlow value={n} className=\"tabular\" /> {n === 1 ? \"problem\" : \"problems\"} to continue\n  </>\n);\n\nexport function ErrorSummary({\n  errors,\n  submitCount = 0,\n  title = defaultTitle,\n  resolvedTitle = \"Everything’s fixed\",\n  gap = 0,\n  onSelect,\n  className,\n  ...rest\n}: ErrorSummaryProps) {\n  const reduce = useReducedMotion();\n  const titleId = useId();\n  const card = useRef<HTMLDivElement>(null);\n  const count = errors.length;\n\n  // \"Resolved\" is the short moment after the last fix: the same card says so, then closes.\n  const [prevCount, setPrevCount] = useState(count);\n  const [resolved, setResolved] = useState(false);\n  // Rows stagger in only when the list opens, never when one is added or removed later.\n  const [opening, setOpening] = useState(count > 0);\n  if (prevCount !== count) {\n    setPrevCount(count);\n    setResolved(prevCount > 0 && count === 0 && resolvedTitle != null);\n    setOpening(prevCount === 0 && count > 0);\n  }\n  useEffect(() => {\n    if (!resolved) return;\n    const t = window.setTimeout(() => setResolved(false), 1800);\n    return () => window.clearTimeout(t);\n  }, [resolved]);\n\n  // A submit with errors takes focus, so screen readers read the list and keyboard users start from it.\n  const hasErrors = count > 0;\n  useEffect(() => {\n    if (submitCount > 0 && hasErrors) card.current?.focus({ preventScroll: false });\n  }, [submitCount, hasErrors]);\n\n  const open = count > 0 || resolved;\n  const tone = count > 0 ? \"danger\" : \"success\";\n\n  return (\n    <AnimatePresence initial={false}>\n      {open && (\n        <motion.div\n          key=\"summary\"\n          // Bleeds 3px past its box so the focus halo is never clipped by the height animation.\n          className=\"-mx-[3px] -mt-[3px] -mb-[3px] overflow-hidden\"\n          initial={{ height: 0, opacity: reduce ? 0 : 1 }}\n          animate={{ height: \"auto\", opacity: 1 }}\n          exit={{ height: 0, opacity: 0, transition: { duration: reduce ? 0.12 : 0.22, ease: ease.inOut } }}\n          transition={{ duration: reduce ? 0.15 : 0.32, ease: ease.out }}\n        >\n          <div style={{ paddingBottom: gap + 3 }} className={cn(\"px-[3px] pt-[3px]\", className)} {...rest}>\n            <motion.div\n              ref={card}\n              tabIndex={-1}\n              role=\"region\"\n              aria-labelledby={titleId}\n              data-tone={tone}\n              initial={reduce ? false : { y: -6, scale: 0.985 }}\n              animate={{ y: 0, scale: 1 }}\n              transition={reduce ? { duration: 0 } : spring.soft}\n              className={cn(\n                \"rounded-xl border p-1.5 outline-none transition-[background-color,border-color,box-shadow] duration-300 ease-out\",\n                \"data-[tone=danger]:border-danger/25 data-[tone=danger]:bg-danger-soft data-[tone=danger]:focus:ring-3 data-[tone=danger]:focus:ring-danger/15\",\n                \"data-[tone=success]:border-success/25 data-[tone=success]:bg-success-soft\",\n              )}\n            >\n              <div className={cn(\"flex items-center gap-2 px-2 pt-1.5 transition-[padding] duration-200\", count > 0 ? \"pb-1\" : \"pb-1.5\")}>\n                <span className=\"relative grid size-4 shrink-0 place-items-center\">\n                  <AnimatePresence initial={false}>\n                    <motion.span\n                      key={tone}\n                      className={cn(\"absolute inset-0 grid place-items-center\", tone === \"danger\" ? \"text-danger\" : \"text-success\")}\n                      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, rotate: -30 }}\n                      animate={{ opacity: 1, scale: 1, rotate: 0 }}\n                      exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, transition: { duration: 0.12 } }}\n                      transition={reduce ? { duration: 0.15 } : spring.pop}\n                    >\n                      {tone === \"danger\" ? <Alert /> : <CircleCheck />}\n                    </motion.span>\n                  </AnimatePresence>\n                </span>\n                <h2 id={titleId} className=\"min-w-0 text-[13px] font-medium tracking-[-0.01em] text-fg\">\n                  {count > 0 ? title(count) : resolvedTitle}\n                </h2>\n              </div>\n\n              <ul className=\"flex flex-col\">\n                <AnimatePresence initial={false}>\n                  {errors.map((item, i) => (\n                    <motion.li\n                      key={item.id}\n                      layout={reduce ? false : \"position\"}\n                      initial={{ opacity: 0, height: 0 }}\n                      animate={{ opacity: 1, height: \"auto\" }}\n                      exit={{ opacity: 0, height: 0, transition: { duration: reduce ? 0.1 : 0.2, ease: ease.inOut } }}\n                      transition={{ duration: reduce ? 0.15 : 0.26, ease: ease.out, delay: opening && !reduce ? 0.06 + Math.min(i, 7) * 0.025 : 0 }}\n                      className=\"overflow-hidden\"\n                    >\n                      <a\n                        href={`#${item.id}`}\n                        onClick={(e) => {\n                          e.preventDefault();\n                          if (focusField(item.id, !reduce)) onSelect?.(item);\n                        }}\n                        className={cn(\n                          \"group/row relative flex items-start gap-2 rounded-lg px-2 py-1.5 outline-none\",\n                          \"transition-[background-color,scale] duration-150 ease-out hover:bg-fg/[0.045] active:scale-[0.99] active:duration-75\",\n                          \"focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3 focus-visible:outline-solid\",\n                        )}\n                      >\n                        {/* Aligns row text with the title's text, past the icon. */}\n                        <span aria-hidden className=\"w-4 shrink-0\" />\n                        <span className=\"flex min-w-0 flex-1 flex-col\">\n                          <span className=\"text-[12.5px] font-medium text-fg underline decoration-fg-4 underline-offset-[3px] transition-[text-decoration-color] duration-150 group-hover/row:decoration-fg-2\">\n                            {item.label}\n                          </span>\n                          <span className=\"text-[12px] leading-[1.45] text-fg-2 [text-wrap:pretty]\">{item.message}</span>\n                        </span>\n                        <ArrowRight\n                          size={14}\n                          className=\"mt-[3px] shrink-0 text-fg-3 transition-[translate,color] duration-200 ease-out-expo group-hover/row:translate-x-0.5 group-hover/row:text-fg-2 group-focus-visible/row:translate-x-0.5 group-focus-visible/row:text-fg-2\"\n                        />\n                      </a>\n                    </motion.li>\n                  ))}\n                </AnimatePresence>\n              </ul>\n            </motion.div>\n          </div>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/error-summary"
}
