{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "form-wizard",
  "title": "Form Wizard",
  "author": "@fma-ds/ds",
  "description": "A form in steps, in a dialog or on a page (a new customer, an onboarding).\n\n`FormWizard` takes `steps` (`{ title, description }` per step) and the current step, controlled (`step`/`onStepChange`) or not (`defaultStep`).\n\n- **Navigation**: it's a react-aria `Form` whose submit is Next, so Enter advances and the current step's fields validate natively (only that step is mounted). Then `onNext(step)` runs — validate or save there (react-hook-form's `trigger`). Returning `false` or throwing keeps the user on the step, and a promise shows Next as pending. Finish runs `onNext` of the last step and then `onComplete`.\n- **`FormWizardSteps`**: the `stepper` list. Earlier steps are reachable, later ones only through Next. Titles show when the wizard is wide enough for that many steps; narrower, only the numbers, with the current title under them (container query).\n- **`FormWizardStep`**: holds a step's fields; after Back or Next, focus moves to its first field.\n- **`FormWizardFooter`**: the progress (\"Step 2 of 4\", announced politely), `children` before Back (a dialog's Cancel), Back and Next/Finish.\n\nThe values stay in the app; with react-hook-form, keep `shouldUnregister` off.\n\nBuilt-in text is in English; translate it with `i18n.labels`.",
  "dependencies": [
    "cn@^0.5.0",
    "lucide-react@^1.61.0",
    "react-aria-components@^1.22.1"
  ],
  "registryDependencies": [
    "@fma-ds/button",
    "@fma-ds/spinner",
    "@fma-ds/stepper"
  ],
  "files": [
    {
      "path": "src/blocks/form-wizard/form-wizard.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"cn\";\nimport { CheckIcon } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Form } from \"react-aria-components\";\nimport { Button } from \"@/core/button/button\";\nimport { Spinner } from \"@/core/spinner/spinner\";\nimport {\n  Stepper,\n  StepperContent,\n  type StepperContentProps,\n  StepperDescription,\n  StepperIndicator,\n  StepperItem,\n  StepperNav,\n  StepperSeparator,\n  StepperTitle,\n  StepperTrigger,\n} from \"@/core/stepper/stepper\";\n\ntype FormWizardLabels = {\n  /** The step list's accessible name. */\n  steps: string;\n  back: string;\n  next: string;\n  finish: string;\n  progress: (step: number, total: number) => string;\n};\n\ntype FormWizardI18n = { labels?: Partial<FormWizardLabels> };\n\nconst defaultFormWizardLabels: FormWizardLabels = {\n  steps: \"Steps\",\n  back: \"Back\",\n  next: \"Next\",\n  finish: \"Finish\",\n  progress: (step, total) => `Step ${step} of ${total}`,\n};\n\ntype FormWizardStepInfo = { title: React.ReactNode; description?: React.ReactNode };\n\ntype FormWizardContextValue = {\n  step: number;\n  steps: FormWizardStepInfo[];\n  isPending: boolean;\n  back: () => void;\n  goTo: (step: number) => void;\n  labels: FormWizardLabels;\n};\n\nconst FormWizardContext = React.createContext<FormWizardContextValue | null>(null);\n\nfunction useFormWizard() {\n  const context = React.useContext(FormWizardContext);\n  if (!context) throw new Error(\"FormWizard parts must be used within a FormWizard.\");\n  return context;\n}\n\ntype FormWizardProps = Omit<React.ComponentProps<typeof Form>, \"onSubmit\"> & {\n  /** One entry per step, in order: titles for the step list. */\n  steps: FormWizardStepInfo[];\n  /** The current step, from 1 (controlled). */\n  step?: number;\n  defaultStep?: number;\n  onStepChange?: (step: number) => void;\n  /**\n   * Runs on Next (and on Finish) after the step's fields pass their own\n   * validation: validate the step here (react-hook-form's `trigger`), or save\n   * it. Returning `false` or throwing keeps the user on the step; a promise\n   * shows the button as pending until it settles.\n   */\n  onNext?: (step: number) => boolean | undefined | Promise<boolean | undefined>;\n  /** Runs on Finish, after `onNext` of the last step. */\n  onComplete?: () => void | Promise<void>;\n  i18n?: FormWizardI18n;\n};\n\n/** Whatever `onNext` decided: only an explicit `false` or a throw blocks. */\nasync function allowsAdvance(run: () => boolean | undefined | Promise<boolean | undefined>) {\n  try {\n    return (await run()) !== false;\n  } catch {\n    return false;\n  }\n}\n\n/**\n * A form split into steps, in a dialog or on a page. It's a react-aria `Form`\n * whose submit is Next: the current step's fields validate natively (only the\n * current step is mounted), then `onNext` may block the advance. Earlier steps\n * stay reachable from the step list; later ones only through Next. Lay out\n * `FormWizardSteps`, one `FormWizardStep` per step and a `FormWizardFooter`.\n */\nfunction FormWizard({\n  steps,\n  step: stepProp,\n  defaultStep = 1,\n  onStepChange,\n  onNext,\n  onComplete,\n  i18n,\n  className,\n  children,\n  ...props\n}: FormWizardProps) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultStep);\n  const [isPending, setIsPending] = React.useState(false);\n  const step = stepProp ?? uncontrolled;\n  const total = steps.length;\n  const rootRef = React.useRef<HTMLFormElement>(null);\n  const shouldFocusStep = React.useRef(false);\n  const overrides = i18n?.labels;\n  const labels = React.useMemo(() => ({ ...defaultFormWizardLabels, ...overrides }), [overrides]);\n\n  const goTo = React.useCallback(\n    (next: number) => {\n      if (next === step || next < 1 || next > total) return;\n      shouldFocusStep.current = true;\n      if (stepProp === undefined) setUncontrolled(next);\n      onStepChange?.(next);\n    },\n    [step, total, stepProp, onStepChange],\n  );\n\n  // After Back/Next, move focus into the new step so keyboard and screen\n  // reader users don't stay on a button that changed meaning.\n  React.useEffect(() => {\n    if (!shouldFocusStep.current) return;\n    shouldFocusStep.current = false;\n    // The previous panel may still be mounted (inert) while it exits.\n    const panel = rootRef.current?.querySelector<HTMLElement>(\n      `[data-slot=form-wizard-step][data-step=\"${step}\"]`,\n    );\n    const field = panel?.querySelector<HTMLElement>(\n      \"input:not([type=hidden]):not(:disabled), select:not(:disabled), textarea:not(:disabled), button:not(:disabled), [tabindex]:not([tabindex='-1'])\",\n    );\n    (field ?? panel)?.focus();\n  }, [step]);\n\n  async function submit() {\n    if (isPending) return;\n    setIsPending(true);\n    try {\n      if (!(await allowsAdvance(() => onNext?.(step)))) return;\n      if (step < total) goTo(step + 1);\n      else await onComplete?.();\n    } finally {\n      setIsPending(false);\n    }\n  }\n\n  const context = {\n    step,\n    steps,\n    isPending,\n    back: () => goTo(step - 1),\n    goTo,\n    labels,\n  };\n\n  return (\n    <FormWizardContext.Provider value={context}>\n      <Form\n        ref={rootRef}\n        data-slot=\"form-wizard\"\n        aria-busy={isPending || undefined}\n        className={cn(\"@container/form-wizard w-full min-w-0\", className)}\n        onSubmit={(event) => {\n          event.preventDefault();\n          void submit();\n        }}\n        {...props}\n      >\n        <Stepper\n          value={step}\n          onValueChange={goTo}\n          indicators={{ completed: <CheckIcon className=\"size-3.5\" /> }}\n          className=\"flex flex-col gap-6\"\n        >\n          {children}\n        </Stepper>\n      </Form>\n    </FormWizardContext.Provider>\n  );\n}\n\n/** The wizard width from which the step titles fit beside the numbers. */\nfunction titleWidthFor(count: number) {\n  if (count <= 4)\n    return {\n      show: \"@xl/form-wizard:not-sr-only @xl/form-wizard:flex\",\n      hide: \"@xl/form-wizard:hidden\",\n    };\n  if (count <= 6)\n    return {\n      show: \"@3xl/form-wizard:not-sr-only @3xl/form-wizard:flex\",\n      hide: \"@3xl/form-wizard:hidden\",\n    };\n  return {\n    show: \"@5xl/form-wizard:not-sr-only @5xl/form-wizard:flex\",\n    hide: \"@5xl/form-wizard:hidden\",\n  };\n}\n\n/**\n * The step list. Titles show when the wizard is wide enough; narrower, only\n * the numbers do, with the current title under them. Steps after the current\n * one are disabled.\n */\nfunction FormWizardSteps({\n  showDescriptions = false,\n  className,\n}: {\n  /** Show each step's `description` under its title (wide wizards only). */\n  showDescriptions?: boolean;\n  className?: string;\n}) {\n  const { step, steps, isPending, labels } = useFormWizard();\n  const current = steps[step - 1];\n  const titleWidth = titleWidthFor(steps.length);\n\n  return (\n    <div data-slot=\"form-wizard-steps\" className={cn(\"flex flex-col gap-3\", className)}>\n      <StepperNav aria-label={labels.steps}>\n        {steps.map(({ title, description }, index) => {\n          const number = index + 1;\n          return (\n            <StepperItem key={number} step={number} isDisabled={number > step || isPending}>\n              <StepperTrigger className=\"items-start rounded-md pr-1\">\n                <StepperIndicator>{number}</StepperIndicator>\n                <span className={cn(\"sr-only flex-col gap-1 pt-[5px] text-left\", titleWidth.show)}>\n                  <StepperTitle className=\"group-data-[state=inactive]/step:text-muted-foreground\">\n                    {title}\n                  </StepperTitle>\n                  {showDescriptions && description && (\n                    <StepperDescription className=\"text-xs\">{description}</StepperDescription>\n                  )}\n                </span>\n              </StepperTrigger>\n              {number < steps.length && <StepperSeparator className=\"mx-2\" />}\n            </StepperItem>\n          );\n        })}\n      </StepperNav>\n      {current && (\n        // The tab already names the step; this repeats it for sighted users\n        // while the list only shows numbers.\n        <p aria-hidden=\"true\" className={cn(\"text-sm font-medium\", titleWidth.hide)}>\n          {current.title}\n        </p>\n      )}\n    </div>\n  );\n}\n\n/** One step's fields, mounted only while it's the current step. */\nfunction FormWizardStep({\n  step,\n  className,\n  ...props\n}: Omit<StepperContentProps, \"value\"> & { step: number }) {\n  return (\n    <StepperContent\n      value={step}\n      data-slot=\"form-wizard-step\"\n      data-step={step}\n      className={cn(\"flex flex-col gap-4\", className)}\n      {...props}\n    />\n  );\n}\n\n/**\n * Progress (\"Step 2 of 4\", announced politely), then Back (from the second\n * step), Next, or Finish on the last step. `children` go before Back (a\n * Cancel that closes the dialog).\n */\nfunction FormWizardFooter({ className, children, ...props }: React.ComponentProps<\"div\">) {\n  const { step, steps, isPending, back, labels } = useFormWizard();\n  const isLast = step === steps.length;\n\n  return (\n    <div\n      data-slot=\"form-wizard-footer\"\n      className={cn(\"flex flex-wrap items-center gap-3 border-t pt-4\", className)}\n      {...props}\n    >\n      <p role=\"status\" className=\"mr-auto text-sm text-muted-foreground\">\n        {labels.progress(step, steps.length)}\n      </p>\n      <div className=\"ml-auto flex items-center gap-2\">\n        {children}\n        {step > 1 && (\n          <Button variant=\"outline\" isDisabled={isPending} onPress={back}>\n            {labels.back}\n          </Button>\n        )}\n        <Button type=\"submit\" isPending={isPending}>\n          {isPending && <Spinner data-icon=\"inline-start\" aria-hidden=\"true\" />}\n          {isLast ? labels.finish : labels.next}\n        </Button>\n      </div>\n    </div>\n  );\n}\n\nexport type { FormWizardI18n, FormWizardLabels, FormWizardProps, FormWizardStepInfo };\nexport { FormWizard, FormWizardFooter, FormWizardStep, FormWizardSteps, useFormWizard };\n",
      "type": "registry:component",
      "target": "components/fma-ds/form-wizard/form-wizard.tsx"
    }
  ],
  "meta": {
    "status": "experimental",
    "source": "original",
    "license": "MIT",
    "tags": [
      "block",
      "form",
      "wizard",
      "stepper",
      "steps"
    ],
    "contentHash": "6ab43968d4da"
  },
  "categories": [
    "blocks"
  ],
  "type": "registry:block"
}