{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "settings-form",
  "title": "Settings Form",
  "author": "@fma-ds/ds",
  "description": "A long edit form in sections, saved as a whole (account, company, preferences).\n\n- **`SettingsForm`**: a react-aria `Form` that only shows state the app owns, so react-hook-form or any other form state fits: `isDirty` (enables Save and Discard), `isPending` (spinner on Save, `aria-busy`) and `onReset` (adds a Discard button that should restore the saved values).\n- **`SettingsSection`**: groups fields under a `title` and `description` (a labelled group) stacked above them, leaving the width to the fields next to an app sidebar. The fields fall into a two-column grid when there's room (container query); `col-span-full` takes the whole row.\n- **`SettingsFormFooter`**: a save bar stuck to the bottom of the viewport with a polite status line (no changes / unsaved / saving), optional children before the buttons, Discard and Save.\n\nWarning before leaving with unsaved changes stays in the app, since it depends on the router; use `confirm-dialog`.\n\nBuilt-in text is in English; translate it with `i18n.labels`.",
  "dependencies": [
    "cn@^0.5.0",
    "react-aria-components@^1.22.1"
  ],
  "registryDependencies": [
    "@fma-ds/button",
    "@fma-ds/spinner"
  ],
  "files": [
    {
      "path": "src/blocks/settings-form/settings-form.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"cn\";\nimport * as React from \"react\";\nimport { Form } from \"react-aria-components\";\nimport { Button } from \"@/core/button/button\";\nimport { Spinner } from \"@/core/spinner/spinner\";\n\ntype SettingsFormLabels = {\n  save: string;\n  discard: string;\n  /** Status line while `isPending`. */\n  saving: string;\n  /** Status line while `isDirty`. */\n  unsaved: string;\n  /** Status line with nothing to save. */\n  saved: string;\n};\n\ntype SettingsFormI18n = { labels?: Partial<SettingsFormLabels> };\n\nconst defaultSettingsFormLabels: SettingsFormLabels = {\n  save: \"Save changes\",\n  discard: \"Discard\",\n  saving: \"Saving…\",\n  unsaved: \"You have unsaved changes\",\n  saved: \"No unsaved changes\",\n};\n\ntype SettingsFormContextValue = {\n  isDirty: boolean;\n  isPending: boolean;\n  onReset?: () => void;\n  labels: SettingsFormLabels;\n};\n\nconst SettingsFormContext = React.createContext<SettingsFormContextValue | null>(null);\n\nfunction useSettingsForm() {\n  const context = React.useContext(SettingsFormContext);\n  if (!context) throw new Error(\"SettingsForm parts must be used within a SettingsForm.\");\n  return context;\n}\n\ntype SettingsFormProps = Omit<React.ComponentProps<typeof Form>, \"onReset\"> & {\n  /** Whether anything changed since the last save; enables Save and Discard. */\n  isDirty?: boolean;\n  /** While saving: Save shows a spinner and both actions wait. */\n  isPending?: boolean;\n  /** Shows a Discard button that calls it (restore the saved values here). */\n  onReset?: () => void;\n  i18n?: SettingsFormI18n;\n};\n\n/**\n * A long edit form split into sections, saved as a whole. The state stays in\n * the app (react-hook-form's `formState.isDirty`, a mutation's `isPending`);\n * the form only shows it. Put `SettingsSection`s inside and end with a\n * `SettingsFormFooter`.\n */\nfunction SettingsForm({\n  isDirty = false,\n  isPending = false,\n  onReset,\n  i18n,\n  className,\n  ...props\n}: SettingsFormProps) {\n  const overrides = i18n?.labels;\n  const labels = React.useMemo(() => ({ ...defaultSettingsFormLabels, ...overrides }), [overrides]);\n  const context = React.useMemo(\n    () => ({ isDirty, isPending, onReset, labels }),\n    [isDirty, isPending, onReset, labels],\n  );\n\n  return (\n    <SettingsFormContext.Provider value={context}>\n      <Form\n        data-slot=\"settings-form\"\n        aria-busy={isPending || undefined}\n        className={cn(\"@container/settings-form flex w-full min-w-0 flex-col\", className)}\n        {...props}\n      />\n    </SettingsFormContext.Provider>\n  );\n}\n\n/**\n * One group of settings: title and description above its fields, which sit\n * in a two-column grid when there's room; give a field `col-span-full` to\n * take the whole row.\n */\nfunction SettingsSection({\n  title,\n  description,\n  headingLevel = 2,\n  className,\n  contentClassName,\n  children,\n  ...props\n}: Omit<React.ComponentProps<\"div\">, \"title\"> & {\n  title: React.ReactNode;\n  description?: React.ReactNode;\n  headingLevel?: 2 | 3 | 4;\n  contentClassName?: string;\n}) {\n  const titleId = React.useId();\n  const descriptionId = React.useId();\n  const Heading = `h${headingLevel}` as const;\n\n  return (\n    <div\n      role=\"group\"\n      aria-labelledby={titleId}\n      aria-describedby={description ? descriptionId : undefined}\n      data-slot=\"settings-section\"\n      className={cn(\"grid gap-4 border-b py-8 first:pt-0 last-of-type:border-b-0\", className)}\n      {...props}\n    >\n      <div data-slot=\"settings-section-header\" className=\"flex flex-col gap-1\">\n        <Heading id={titleId} className=\"text-base font-medium\">\n          {title}\n        </Heading>\n        {description && (\n          <p id={descriptionId} className=\"text-sm text-muted-foreground\">\n            {description}\n          </p>\n        )}\n      </div>\n      <div className=\"@container/settings-section min-w-0\">\n        <div\n          data-slot=\"settings-section-content\"\n          className={cn(\"grid gap-4 @md/settings-section:grid-cols-2\", contentClassName)}\n        >\n          {children}\n        </div>\n      </div>\n    </div>\n  );\n}\n\n/**\n * The save bar, stuck to the bottom of the viewport while the form scrolls:\n * the save state (announced politely), Discard when the form has `onReset`,\n * and Save. `children` go before the buttons (a \"last saved\" note, a help link).\n */\nfunction SettingsFormFooter({ className, children, ...props }: React.ComponentProps<\"div\">) {\n  const { isDirty, isPending, onReset, labels } = useSettingsForm();\n  const status = isPending ? labels.saving : isDirty ? labels.unsaved : labels.saved;\n\n  return (\n    <div\n      data-slot=\"settings-form-footer\"\n      className={cn(\n        \"sticky bottom-0 z-10 flex flex-wrap items-center gap-3 border-t bg-background py-3\",\n        className,\n      )}\n      {...props}\n    >\n      <p\n        role=\"status\"\n        data-dirty={isDirty || undefined}\n        className=\"mr-auto text-sm text-muted-foreground data-dirty:text-foreground\"\n      >\n        {status}\n      </p>\n      {children}\n      <div className=\"ml-auto flex items-center gap-2\">\n        {onReset && (\n          <Button variant=\"outline\" isDisabled={!isDirty || isPending} onPress={onReset}>\n            {labels.discard}\n          </Button>\n        )}\n        <Button type=\"submit\" isDisabled={!isDirty} isPending={isPending}>\n          {isPending && <Spinner data-icon=\"inline-start\" aria-hidden=\"true\" />}\n          {labels.save}\n        </Button>\n      </div>\n    </div>\n  );\n}\n\nexport type { SettingsFormI18n, SettingsFormLabels, SettingsFormProps };\nexport { SettingsForm, SettingsFormFooter, SettingsSection };\n",
      "type": "registry:component",
      "target": "components/fma-ds/settings-form/settings-form.tsx"
    }
  ],
  "meta": {
    "status": "experimental",
    "source": "original",
    "license": "MIT",
    "tags": [
      "block",
      "form",
      "settings",
      "fields"
    ],
    "contentHash": "2d3831fee4d1"
  },
  "categories": [
    "blocks"
  ],
  "type": "registry:block"
}