{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "settings-panel",
  "title": "Settings Panel",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A sectioned settings card with label-and-control rows and a save bar that slides in when something changes, with a status announced to screen readers.",
  "dependencies": [
    "motion@^12",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/motion-presets.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/settings-panel.tsx",
      "content": "// Ballmac UI: Settings Panel. https://ui.ballmac.com/components/settings-panel\n\"use client\";\n\nimport * as React from \"react\";\nimport { Check, LoaderCircle } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { spring } from \"@/lib/ballmac/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { useMessages } from \"@/lib/ballmac/i18n\";\n\ntype SettingsStatus = \"idle\" | \"dirty\" | \"saving\" | \"saved\";\n\ntype SettingsPanelProps = Omit<React.ComponentProps<\"form\">, \"onSubmit\" | \"onReset\"> & {\n  /** Save state. `dirty` shows the save bar; `saving` disables it and shows a spinner; `saved` confirms briefly. */\n  status?: SettingsStatus;\n  /** Called when the form is submitted (Save button or Enter in a field). */\n  onSave?: () => void;\n  /** Called when Discard is pressed. Reset your own state here. */\n  onDiscard?: () => void;\n  /** Text next to the buttons while `dirty`. */\n  dirtyMessage?: string;\n  /** Label of the save button. */\n  saveLabel?: string;\n  /** Accessible name of the form. */\n  label?: string;\n};\n\n/**\n * A card of grouped settings with a save bar that slides in when something changes. The bar's status is announced to\n * screen readers. Compose with SettingsSection and SettingsRow, and drive `status` from your form state.\n */\nfunction SettingsPanel({\n  status = \"idle\",\n  onSave,\n  onDiscard,\n  dirtyMessage,\n  saveLabel,\n  label,\n  className,\n  children,\n  ...props\n}: SettingsPanelProps) {\n  const msg = useMessages()\n  dirtyMessage ??= msg(\"settings-panel.dirtyMessage\", \"You have unsaved changes\")\n  saveLabel ??= msg(\"settings-panel.saveLabel\", \"Save changes\")\n  label ??= msg(\"settings-panel.label\", \"Settings\")\n  const reduce = useReducedMotion();\n  const showBar = status !== \"idle\";\n  return (\n    <form\n      data-slot=\"settings-panel\"\n      aria-label={label}\n      onSubmit={(event) => {\n        event.preventDefault();\n        if (status !== \"saving\") onSave?.();\n      }}\n      className={cn(\n        \"relative w-full overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-16px_rgb(0_0_0/0.14)]\",\n        className,\n      )}\n      {...props}\n    >\n      <div className=\"divide-y\">{children}</div>\n      <div aria-live=\"polite\" role=\"status\" className=\"sr-only\">\n        {status === \"dirty\" ? dirtyMessage : status === \"saving\" ? \"Saving\" : status === \"saved\" ? \"Changes saved\" : \"\"}\n      </div>\n      <AnimatePresence initial={false}>\n        {showBar && (\n          <motion.div\n            key=\"bar\"\n            data-slot=\"settings-panel-bar\"\n            initial={reduce ? { opacity: 0 } : { y: \"100%\", opacity: 0 }}\n            animate={{ y: 0, opacity: 1 }}\n            exit={reduce ? { opacity: 0 } : { y: \"100%\", opacity: 0 }}\n            transition={reduce ? { duration: 0.1 } : spring.snappy}\n            className=\"sticky bottom-0 flex flex-wrap items-center gap-3 border-t bg-card/90 px-5 py-3 backdrop-blur supports-[backdrop-filter]:bg-card/75\"\n          >\n            <p\n              aria-hidden=\"true\"\n              className={cn(\n                \"flex items-center gap-2 text-sm\",\n                status === \"saved\" ? \"text-foreground\" : \"text-muted-foreground\",\n              )}\n            >\n              {status === \"saved\" ? (\n                <>\n                  <Check className=\"size-4 text-chart-2\" /> Changes saved\n                </>\n              ) : status === \"saving\" ? (\n                <>\n                  <LoaderCircle className=\"size-4 animate-spin motion-reduce:animate-none\" /> Saving…\n                </>\n              ) : (\n                <>\n                  <span className=\"size-1.5 rounded-full bg-chart-3\" /> {dirtyMessage}\n                </>\n              )}\n            </p>\n            <div className=\"ms-auto flex items-center gap-2\">\n              <button\n                type=\"button\"\n                onClick={onDiscard}\n                disabled={status !== \"dirty\"}\n                className=\"inline-flex h-8 items-center rounded-md px-3 text-[13px] font-medium text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\"\n              >\n                {msg(\"settings-panel.discard\", \"Discard\")}\n              </button>\n              <button\n                type=\"submit\"\n                disabled={status !== \"dirty\"}\n                className=\"inline-flex h-8 items-center rounded-md bg-primary px-3.5 text-[13px] font-medium text-primary-foreground shadow-xs outline-none transition-colors hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\"\n              >\n                {saveLabel}\n              </button>\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </form>\n  );\n}\n\ntype SettingsSectionProps = Omit<React.ComponentProps<\"section\">, \"title\"> & {\n  /** Section heading. */\n  title: string;\n  /** One line under the heading. */\n  description?: string;\n};\n\n/** A titled group of rows. The title names the section for assistive technology. */\nfunction SettingsSection({ title, description, className, children, ...props }: SettingsSectionProps) {\n  const id = React.useId();\n  return (\n    <section\n      data-slot=\"settings-section\"\n      aria-labelledby={id}\n      className={cn(\"grid gap-1 px-5 py-5 md:grid-cols-[minmax(0,14rem)_1fr] md:gap-x-10\", className)}\n      {...props}\n    >\n      <div className=\"pb-3 md:pb-0\">\n        <h3 id={id} className=\"text-sm font-semibold tracking-tight\">\n          {title}\n        </h3>\n        {description && <p className=\"mt-1 text-sm leading-snug text-muted-foreground\">{description}</p>}\n      </div>\n      <div className=\"grid min-w-0 divide-y rounded-xl border bg-background/60\">{children}</div>\n    </section>\n  );\n}\n\ntype SettingsRowProps = Omit<React.ComponentProps<\"div\">, \"title\"> & {\n  /** Setting name. */\n  label: string;\n  /** Explains what the setting does. */\n  description?: string;\n  /** The id of the control, so the label is clickable. */\n  htmlFor?: string;\n  /** Stack the control under the label instead of beside it. Good for wide inputs. */\n  stacked?: boolean;\n};\n\n/** One setting: label and help text on the left, the control (switch, select, input) as children on the right. */\nfunction SettingsRow({ label, description, htmlFor, stacked = false, className, children, ...props }: SettingsRowProps) {\n  const descId = React.useId();\n  return (\n    <div\n      data-slot=\"settings-row\"\n      className={cn(\n        \"flex gap-x-6 gap-y-3 px-4 py-3.5\",\n        stacked ? \"flex-col\" : \"flex-col sm:flex-row sm:items-center sm:justify-between\",\n        className,\n      )}\n      {...props}\n    >\n      <div className=\"min-w-0\">\n        <label htmlFor={htmlFor} className=\"text-sm font-medium\">\n          {label}\n        </label>\n        {description && (\n          <p id={descId} className=\"mt-0.5 text-[13px] leading-snug text-muted-foreground\">\n            {description}\n          </p>\n        )}\n      </div>\n      <div className={cn(\"shrink-0\", stacked && \"w-full\")}>{children}</div>\n    </div>\n  );\n}\n\nexport {\n  SettingsPanel,\n  SettingsSection,\n  SettingsRow,\n  type SettingsPanelProps,\n  type SettingsSectionProps,\n  type SettingsRowProps,\n  type SettingsStatus,\n};\n",
      "type": "registry:ui",
      "target": "@components/ballmac/settings-panel.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "settings",
      "form",
      "preferences",
      "save bar"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "<SettingsPanel status onSave onDiscard><SettingsSection title><SettingsRow label htmlFor>{control}</SettingsRow></SettingsSection></SettingsPanel>. Drive status: idle | dirty | saving | saved.",
      "whenToUse": [
        "Account, workspace and notification settings",
        "Any form where people expect Save and Discard"
      ],
      "whenNotToUse": [
        "Single-field inline edits",
        "Wizards; use stepper-form"
      ],
      "composesWith": [
        "switch",
        "input",
        "select",
        "field"
      ],
      "a11y": [
        {
          "keys": "Enter",
          "action": "Submits the form from any field"
        },
        {
          "keys": "Tab",
          "action": "Save bar buttons are in the tab order after the fields"
        },
        {
          "keys": "Screen readers",
          "action": "Sections are labelled groups; save status is a polite live region"
        }
      ],
      "customization": [
        "status",
        "dirtyMessage and saveLabel",
        "stacked rows for wide controls"
      ]
    },
    "examples": [
      "settings-panel-demo",
      "settings-panel-states"
    ],
    "url": "https://ui.ballmac.com/components/settings-panel"
  },
  "categories": [
    "saas"
  ],
  "type": "registry:ui"
}