{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stepper-form",
  "title": "Stepper Form",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A guided multistep form shell with gated progression, review states, and keyboard-accessible navigation.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/stepper-form.tsx",
      "content": "// Ballmac UI: Stepper Form. https://ui.ballmac.com/components/stepper-form\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowLeft, ArrowRight, Check } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype StepperStep = {\n  /** Stable step ID. */\n  id: string\n  /** Short step title. */\n  title: string\n  /** Optional supporting detail. */\n  description?: string\n  /** Fields or content for this step. */\n  content: React.ReactNode\n  /** Return false to block moving forward. */\n  validate?: () => boolean\n}\ntype StepperFormProps = React.ComponentProps<\"div\"> & {\n  /** Ordered steps in the flow. */\n  steps: StepperStep[]\n  /** Controlled zero-based step index. */\n  value?: number\n  /** Initial zero-based step index. */\n  defaultValue?: number\n  /** Called when the step changes. */\n  onValueChange?: (index: number) => void\n  /** Called after the last step is completed. */\n  onComplete?: () => void\n  /** Label for the final action. */\n  completeLabel?: string\n}\nfunction StepperForm({\n  className,\n  steps,\n  value,\n  defaultValue = 0,\n  onValueChange,\n  onComplete,\n  completeLabel,\n  ...props\n}: StepperFormProps) {\n  const msg = useMessages()\n  completeLabel ??= msg(\"stepper-form.completeLabel\", \"Complete\")\n  const [internal, setInternal] = React.useState(defaultValue)\n  const [furthest, setFurthest] = React.useState(defaultValue)\n  const [error, setError] = React.useState(\"\")\n  const current = Math.max(0, Math.min(steps.length - 1, value ?? internal))\n  const step = steps[current]\n  function change(index: number) {\n    if (value === undefined) setInternal(index)\n    setFurthest((previous) => Math.max(previous, index))\n    setError(\"\")\n    onValueChange?.(index)\n  }\n  function next() {\n    if (!step) return\n    if (step.validate && !step.validate()) {\n      setError(\"Complete this step before continuing.\")\n      return\n    }\n    if (current === steps.length - 1) onComplete?.()\n    else change(current + 1)\n  }\n  if (!step) return null\n  return (\n    <div\n      data-slot=\"stepper-form\"\n      className={cn(\n        \"bg-card min-w-0 rounded-xl border border-border p-5 shadow-sm\",\n        className,\n      )}\n      {...props}\n    >\n      <ol aria-label={msg(\"stepper-form.progress\", \"Progress\")} className=\"flex items-start gap-1\">\n        {steps.map((item, index) => (\n          <li key={item.id} className=\"flex min-w-0 flex-1 items-start gap-2\">\n            <button\n              type=\"button\"\n              aria-current={index === current ? \"step\" : undefined}\n              aria-label={index < current ? msg(\"stepper-form.stepCompleted\", \"{title}, step {n} of {total}, completed\", { title: item.title, n: index + 1, total: steps.length }) : msg(\"stepper-form.stepLabel\", \"{title}, step {n} of {total}\", { title: item.title, n: index + 1, total: steps.length })}\n              disabled={index > furthest}\n              onClick={() => change(index)}\n              className={cn(\n                \"border-border bg-background text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold outline-none transition-colors duration-150 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed motion-reduce:transition-none\",\n                index === current &&\n                  \"border-primary bg-primary text-primary-foreground\",\n                index < current && \"border-primary text-primary\",\n              )}\n            >\n              {index < current ? (\n                <Check aria-hidden=\"true\" className=\"size-3.5\" />\n              ) : (\n                index + 1\n              )}\n            </button>\n            <span className=\"hidden min-w-0 pt-1 text-xs font-medium sm:block\">\n              {item.title}\n            </span>\n            {index < steps.length - 1 && (\n              <span\n                aria-hidden=\"true\"\n                className=\"bg-border mt-3 h-px min-w-2 flex-1\"\n              />\n            )}\n          </li>\n        ))}\n      </ol>\n      <div className=\"mt-6 border-t border-border pt-5\">\n        <p className=\"text-muted-foreground text-xs font-medium uppercase tracking-wide\">\n          {msg(\"stepper-form.stepOf\", \"Step {n} of {total}\", { n: current + 1, total: steps.length })}\n        </p>\n        <h3 className=\"mt-1 text-lg font-semibold\">{step.title}</h3>\n        {step.description && (\n          <p className=\"text-muted-foreground mt-1 text-sm\">\n            {step.description}\n          </p>\n        )}\n        <div data-slot=\"stepper-form-content\" className=\"mt-5\">\n          {step.content}\n        </div>\n        <p\n          role=\"status\"\n          aria-live=\"polite\"\n          className={cn(\"text-destructive mt-3 text-xs\", !error && \"sr-only\")}\n        >\n          {error || msg(\"stepper-form.ready\", \"Ready\")}\n        </p>\n      </div>\n      <div className=\"mt-5 flex items-center justify-between gap-3 border-t border-border pt-4\">\n        <button\n          type=\"button\"\n          onClick={() => change(current - 1)}\n          disabled={current === 0}\n          className=\"text-muted-foreground hover:text-foreground inline-flex h-9 items-center gap-2 rounded-md px-2 text-sm font-medium outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-40\"\n        >\n          <ArrowLeft aria-hidden=\"true\" className=\"size-4 rtl:rotate-180\" />\n          {msg(\"stepper-form.back\", \"Back\")}\n        </button>\n        <button\n          type=\"button\"\n          onClick={next}\n          className=\"bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-9 items-center gap-2 rounded-md px-4 text-sm font-medium outline-none transition-colors duration-150 focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none\"\n        >\n          {current === steps.length - 1 ? completeLabel : \"Continue\"}\n          {current < steps.length - 1 && (\n            <ArrowRight aria-hidden=\"true\" className=\"size-4 rtl:rotate-180\" />\n          )}\n        </button>\n      </div>\n    </div>\n  )\n}\nexport { StepperForm, type StepperFormProps, type StepperStep }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/stepper-form.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "wizard",
      "form",
      "steps"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "A guided multistep form shell with gated progression, review states, and keyboard-accessible navigation.",
      "whenToUse": [
        "Guide a long onboarding or setup process"
      ],
      "whenNotToUse": [
        "Use a single form for short tasks"
      ],
      "composesWith": [
        "progress"
      ],
      "a11y": [
        {
          "keys": "Tab / Enter / Space",
          "action": "Navigates steps and completes the flow"
        }
      ],
      "customization": [
        "Controlled and uncontrolled value",
        "Tokens and state styling"
      ]
    },
    "examples": [
      "stepper-form-demo",
      "stepper-form-states"
    ],
    "url": "https://ui.ballmac.com/components/stepper-form"
  },
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}