{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "settings-dialog",
  "type": "registry:component",
  "title": "Settings dialog",
  "description": "Settings in a large dialog after shadcn's sidebar-13: sections by group on the left, breadcrumb and scrolling content on the right, full screen with a stacked list on phones; SettingsBlock for each card, SettingsForm for one preference edited in place with its own Save.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "@wibo/utils",
    "@wibo/use-sliding-indicator",
    "@wibo/breadcrumb",
    "@wibo/button",
    "@wibo/dialog"
  ],
  "files": [
    {
      "path": "src/patterns/settings-dialog.tsx",
      "type": "registry:component",
      "content": "import { useRef, useState, type ComponentProps, type FormEvent, type ReactNode } from \"react\";\nimport { ChevronLeft, ChevronRight, type LucideIcon } from \"lucide-react\";\nimport {\n  Breadcrumb,\n  BreadcrumbItem,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\nimport { Button } from \"@/components/ui/button\";\nimport { Dialog, DialogContent, DialogDescription, DialogTitle } from \"@/components/ui/dialog\";\nimport { cn } from \"@/lib/utils\";\nimport { indicatorClass, useSlidingIndicator } from \"@/lib/use-sliding-indicator\";\n\ninterface SettingsDialogSection {\n  id: string;\n  label: string;\n  icon?: LucideIcon;\n}\n\ninterface SettingsDialogGroup {\n  id: string;\n  label: string;\n  sections: SettingsDialogSection[];\n}\n\ninterface SettingsDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  /** Dialog title and the first crumb (\"Settings\"). */\n  title: string;\n  /** Read by screen readers when the dialog opens. */\n  description?: string;\n  groups: SettingsDialogGroup[];\n  /** The section shown; one of the groups' section ids. */\n  section: string;\n  onSectionChange: (section: string) => void;\n  /** The current section's content. */\n  children: ReactNode;\n  closeLabel?: string;\n  /** Phone only: the button that goes from a section back to the list. */\n  backLabel?: string;\n  navigationLabel?: string;\n}\n\nconst ROW = \"[data-settings-row]\";\nconst ACTIVE_ROW = \"[data-settings-row][data-active=true]\";\n\n/**\n * Settings in a dialog, after shadcn's sidebar-13 block: a large dialog\n * with the sections listed on the left by group, the breadcrumb of the\n * current section on the right and its content scrolling under it. Below\n * `md` it fills the screen: the section with a back button, and behind it\n * the stacked list of every section. Escape closes it.\n *\n * The dialog shows one section at a time and leaves loading and saving to\n * the children; keep the section in the URL (`?settings=profile`) so links\n * open a given section.\n */\nfunction SettingsDialog({\n  open,\n  onOpenChange,\n  title,\n  description,\n  groups,\n  section,\n  onSectionChange,\n  children,\n  closeLabel = \"Close\",\n  backLabel = \"All settings\",\n  navigationLabel = \"Settings sections\",\n}: SettingsDialogProps) {\n  // Phone: the list of sections, or the current section. Reset to the section whenever the dialog opens.\n  const [list, setList] = useState(false);\n  const [wasOpen, setWasOpen] = useState(open);\n  if (open !== wasOpen) {\n    setWasOpen(open);\n    if (open) setList(false);\n  }\n\n  // Focus lands on the dialog itself, not on the first section row, so nothing looks selected twice.\n  const content = useRef<HTMLDivElement>(null);\n  const current = groups.flatMap((group) => group.sections.map((s) => ({ ...s, group: group.label }))).find((s) => s.id === section);\n  const choose = (id: string) => {\n    setList(false);\n    onSectionChange(id);\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent\n        ref={content}\n        tabIndex={-1}\n        onOpenAutoFocus={(event) => {\n          event.preventDefault();\n          content.current?.focus();\n        }}\n        closeLabel={closeLabel}\n        className={cn(\n          \"flex gap-0 overflow-hidden p-0\",\n          // Phone: the whole screen.\n          \"max-md:inset-0 max-md:h-dvh max-md:max-w-none max-md:translate-x-0 max-md:translate-y-0 max-md:rounded-none max-md:border-0\",\n          \"md:h-[min(680px,calc(100dvh-4rem))] md:max-w-[min(960px,calc(100%-4rem))]\",\n        )}\n      >\n        <DialogTitle className=\"sr-only\">{title}</DialogTitle>\n        {description ? <DialogDescription className=\"sr-only\">{description}</DialogDescription> : null}\n        <SettingsNav\n          groups={groups}\n          section={section}\n          onChoose={choose}\n          label={navigationLabel}\n          title={title}\n          className={cn(\"max-md:w-full\", list ? \"max-md:flex\" : \"max-md:hidden\")}\n        />\n        <div className={cn(\"flex min-w-0 flex-1 flex-col\", list && \"max-md:hidden\")}>\n          <header className=\"flex h-15 shrink-0 items-center gap-1 border-b border-border pr-14 pl-2 md:pl-6\">\n            <button\n              type=\"button\"\n              onClick={() => setList(true)}\n              className=\"press-icon inline-flex size-8 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-foreground/4 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 md:hidden\"\n            >\n              <ChevronLeft aria-hidden=\"true\" className=\"size-4\" />\n              <span className=\"sr-only\">{backLabel}</span>\n            </button>\n            <Breadcrumb aria-label={title}>\n              <BreadcrumbList>\n                <BreadcrumbItem className=\"max-md:hidden\">{title}</BreadcrumbItem>\n                {current ? (\n                  <>\n                    <BreadcrumbSeparator className=\"max-md:hidden\" />\n                    <BreadcrumbItem className=\"max-md:hidden\">{current.group}</BreadcrumbItem>\n                    <BreadcrumbSeparator className=\"max-md:hidden\" />\n                    <BreadcrumbItem>\n                      <BreadcrumbPage className=\"text-sm font-medium\">{current.label}</BreadcrumbPage>\n                    </BreadcrumbItem>\n                  </>\n                ) : null}\n              </BreadcrumbList>\n            </Breadcrumb>\n          </header>\n          <div data-slot=\"settings-dialog-body\" className=\"min-h-0 flex-1 overflow-y-auto\">\n            <div key={section} className=\"grid animate-content-in gap-6 p-4 md:p-6\">\n              {children}\n            </div>\n          </div>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n\n/** The section list: a column on the left from `md`, the stacked list on a phone. */\nfunction SettingsNav({\n  groups,\n  section,\n  onChoose,\n  label,\n  title,\n  className,\n}: {\n  groups: SettingsDialogGroup[];\n  section: string;\n  onChoose: (id: string) => void;\n  label: string;\n  title: string;\n  className?: string;\n}) {\n  const indicator = useSlidingIndicator<HTMLElement>({ item: ROW, active: ACTIVE_ROW });\n  return (\n    <nav\n      ref={indicator.containerRef}\n      data-slot=\"settings-dialog-nav\"\n      data-indicator=\"off\"\n      aria-label={label}\n      {...indicator.handlers}\n      className={cn(\"relative isolate flex w-60 shrink-0 flex-col gap-4 overflow-y-auto border-r border-border bg-sidebar p-2 max-md:border-r-0\", className)}\n    >\n      <span ref={indicator.activeRef} aria-hidden=\"true\" className={cn(indicatorClass, \"rounded-lg bg-sidebar-active max-md:hidden\")} />\n      <span ref={indicator.hoverRef} aria-hidden=\"true\" className={cn(indicatorClass, \"rounded-lg bg-sidebar-hover duration-150 max-md:hidden\")} />\n      <p className=\"flex h-11 items-center px-2 text-base font-semibold text-foreground md:hidden\">{title}</p>\n      {groups.map((group) => (\n        <div key={group.id} className=\"grid gap-0.5\">\n          <p className=\"px-3 pt-2 pb-1 text-xs font-medium text-muted-foreground\">{group.label}</p>\n          {group.sections.map((item) => {\n            const Icon = item.icon;\n            const active = item.id === section;\n            return (\n              <button\n                key={item.id}\n                type=\"button\"\n                data-settings-row=\"\"\n                data-active={active}\n                aria-current={active ? \"page\" : undefined}\n                onClick={() => onChoose(item.id)}\n                className={cn(\n                  \"press-item relative flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-lg px-3 text-left text-label font-medium text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-[active=true]:text-foreground [&_svg]:size-4 [&_svg]:shrink-0\",\n                  \"h-7.5 in-data-[indicator=off]:hover:bg-sidebar-hover in-data-[indicator=off]:data-[active=true]:bg-sidebar-active\",\n                  // Phone: taller rows on the stacked list, no highlight for the section behind it.\n                  \"max-md:h-11 max-md:data-[active=true]:text-muted-foreground max-md:hover:bg-foreground/4\",\n                )}\n              >\n                {Icon ? <Icon aria-hidden=\"true\" /> : null}\n                <span className=\"min-w-0 flex-1 truncate\">{item.label}</span>\n                <ChevronRight aria-hidden=\"true\" className=\"text-muted-foreground md:hidden\" />\n              </button>\n            );\n          })}\n        </div>\n      ))}\n    </nav>\n  );\n}\n\ninterface SettingsBlockProps extends Omit<ComponentProps<\"section\">, \"title\"> {\n  title: ReactNode;\n  description?: ReactNode;\n  /** Bottom bar: a note on the left (`hint`), actions on the right, usually Save. */\n  footer?: ReactNode;\n  hint?: ReactNode;\n  /** Actions in the title row, for a list block (\"Invite\", \"Create key\"). */\n  actions?: ReactNode;\n  /** Content flush with the card edges (a table). */\n  flush?: boolean;\n}\n\n/**\n * One block of a settings section: a card with its heading, a muted line,\n * the fields or the list, and a footer with the block's own Save. Simple\n * preferences are edited in place here; a list block shows data only and\n * opens its create and edit forms in a nested dialog.\n */\nfunction SettingsBlock({ title, description, footer, hint, actions, flush = false, className, children, ...props }: SettingsBlockProps) {\n  return (\n    <section\n      data-slot=\"settings-block\"\n      className={cn(\"overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-card\", className)}\n      {...props}\n    >\n      <div className={cn(\"grid gap-4\", flush ? \"pt-4 sm:pt-5\" : \"p-4 sm:p-5\")}>\n        <div className={cn(\"flex flex-wrap items-start justify-between gap-3\", flush && \"px-4 sm:px-5\")}>\n          <div className=\"grid min-w-0 gap-1\">\n            <h3 className=\"text-sm font-semibold text-foreground\">{title}</h3>\n            {description ? <p className=\"text-sm text-muted-foreground\">{description}</p> : null}\n          </div>\n          {actions ? <div className=\"flex shrink-0 gap-2\">{actions}</div> : null}\n        </div>\n        {children}\n      </div>\n      {footer || hint ? (\n        <div className=\"flex flex-wrap items-center justify-between gap-3 border-t border-border bg-muted/40 px-4 py-3 sm:px-5\">\n          <div className=\"min-w-0 text-xs text-muted-foreground\">{hint}</div>\n          {footer ? <div className=\"flex shrink-0 items-center gap-2\">{footer}</div> : null}\n        </div>\n      ) : null}\n    </section>\n  );\n}\n\ninterface SettingsFormLabels {\n  save: string;\n  saved: string;\n  unsaved: string;\n}\n\ninterface SettingsFormProps {\n  title: ReactNode;\n  description?: ReactNode;\n  /** Line under the card while nothing changed (a format, a limit). */\n  hint?: ReactNode;\n  /** Whether the fields differ from what is saved: Save is held until they do. */\n  dirty: boolean;\n  /** Saves the form's fields. Resolve to an error message, or null when saved. */\n  onSave: (data: FormData) => Promise<string | null>;\n  /** After a save: update what counts as saved, show a toast. */\n  onSaved?: () => void;\n  labels?: Partial<SettingsFormLabels>;\n  children: ReactNode;\n}\n\n/**\n * A `SettingsBlock` that edits one preference in place: the fields, then\n * the block's own Save, held until something changed; the footer says\n * whether there are unsaved changes, that the save worked, or why it did\n * not. Submits from the submit event (not a form action), so React does not\n * reset the fields to stale defaults afterwards.\n */\nfunction SettingsForm({ title, description, hint, dirty, onSave, onSaved, labels: labelOverrides, children }: SettingsFormProps) {\n  const labels = { save: \"Save\", saved: \"Saved\", unsaved: \"Unsaved changes\", ...labelOverrides };\n  // Plain state, not a transition: a refresh the caller starts after the save must not keep Save spinning.\n  const [pending, setPending] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const [saved, setSaved] = useState(false);\n\n  const submit = async (event: FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    if (!dirty || pending) return;\n    const data = new FormData(event.currentTarget);\n    setError(null);\n    setPending(true);\n    try {\n      const failure = await onSave(data);\n      if (failure) {\n        setError(failure);\n        return;\n      }\n      setSaved(true);\n      onSaved?.();\n    } catch (err) {\n      setError(err instanceof Error ? err.message : String(err));\n    } finally {\n      setPending(false);\n    }\n  };\n\n  const status = error ? <span className=\"font-medium text-danger\">{error}</span> : dirty ? labels.unsaved : saved ? labels.saved : hint;\n  return (\n    <form onSubmit={(event) => void submit(event)}>\n      <SettingsBlock\n        title={title}\n        description={description}\n        hint={<span role=\"status\">{status}</span>}\n        footer={\n          <Button type=\"submit\" size=\"sm\" loading={pending} disabled={!dirty}>\n            {labels.save}\n          </Button>\n        }\n      >\n        {children}\n      </SettingsBlock>\n    </form>\n  );\n}\n\nexport { SettingsBlock, SettingsDialog, SettingsForm };\nexport type { SettingsBlockProps, SettingsDialogGroup, SettingsDialogProps, SettingsDialogSection, SettingsFormLabels, SettingsFormProps };\n"
    }
  ]
}
