{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stateful-button",
  "title": "Stateful Button",
  "description": "Button that morphs through loading, success and error states while a Promise settles.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/velora/stateful-button.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\ntype ButtonState = \"idle\" | \"loading\" | \"success\" | \"error\";\n\ninterface StatefulButtonProps\n  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, \"onClick\"> {\n  /** Click handler; return a Promise to show loading, then success or error */\n  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => unknown;\n  /** Milliseconds the success or error state stays before returning to idle */\n  resetAfter?: number;\n  /** Shown beside the check and announced when the promise resolves */\n  successText?: string;\n  /** Shown beside the cross and announced when the promise rejects */\n  errorText?: string;\n}\n\n// Every layer shares one grid cell, so the widest label sets a stable width.\nconst layer =\n  \"relative col-start-1 row-start-1 flex items-center justify-center gap-2 transition-[opacity,scale,visibility] duration-200 motion-reduce:transition-none\";\nconst hidden = \"invisible scale-75 opacity-0\";\n\n/**\n * A button for async actions. When `onClick` returns a Promise the button\n * shrinks to a spinner, then shows a check (or a cross if it rejects) and\n * returns to idle after `resetAfter` ms. Its layout width never changes.\n * Recolour it with `[--surface:var(--color-brand)]`; style states with\n * `data-[state=success]:` and friends.\n */\nexport function StatefulButton({\n  onClick,\n  resetAfter = 2000,\n  successText = \"Done\",\n  errorText = \"Failed\",\n  className,\n  children,\n  type = \"button\",\n  ...props\n}: StatefulButtonProps) {\n  const [state, setState] = useState<ButtonState>(\"idle\");\n  const [height, setHeight] = useState(0);\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const pending = useRef(false);\n  const busy = state === \"loading\";\n\n  useEffect(() => () => clearTimeout(timer.current), []);\n\n  const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {\n    // aria-disabled (not disabled) keeps focus on the button while pending.\n    if (pending.current) return event.preventDefault();\n    const result = onClick?.(event);\n    if (!(result instanceof Promise)) return;\n    pending.current = true;\n    clearTimeout(timer.current);\n    setHeight(event.currentTarget.offsetHeight);\n    setState(\"loading\");\n    let next: ButtonState = \"success\";\n    try {\n      await result;\n    } catch {\n      next = \"error\";\n    }\n    pending.current = false;\n    setState(next);\n    timer.current = setTimeout(() => setState(\"idle\"), resetAfter);\n  };\n\n  return (\n    <>\n      <button\n        type={type}\n        {...props}\n        data-state={state}\n        aria-busy={busy || undefined}\n        aria-disabled={busy ? true : props[\"aria-disabled\"]}\n        onClick={handleClick}\n        className={cn(\n          \"group/stateful-button relative inline-grid h-10 cursor-pointer rounded-full px-5 text-sm font-medium text-primary-foreground outline-none select-none [--surface:var(--color-primary)] disabled:cursor-not-allowed disabled:opacity-50 aria-busy:cursor-progress data-[state=error]:text-white data-[state=error]:[--surface:var(--color-destructive)]\",\n          className\n        )}\n      >\n        {/* The visible pill: full width, or a circle while loading */}\n        <span\n          aria-hidden\n          className=\"absolute inset-0 mx-auto rounded-[inherit] bg-(--surface) transition-[width,background-color] duration-300 ease-out group-hover/stateful-button:opacity-90 group-focus-visible/stateful-button:ring-2 group-focus-visible/stateful-button:ring-ring group-focus-visible/stateful-button:ring-offset-2 group-focus-visible/stateful-button:ring-offset-background motion-reduce:w-full! motion-reduce:transition-colors\"\n          style={{ width: busy && height ? height : \"100%\" }}\n        />\n        {/* Stays in the accessibility tree so the button's name never changes */}\n        <span className={cn(layer, state !== \"idle\" && \"scale-75 opacity-0\")}>\n          {children}\n        </span>\n        <span aria-hidden className={cn(layer, !busy && hidden)}>\n          <svg\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"3\"\n            strokeLinecap=\"round\"\n            className=\"size-4 motion-safe:animate-spin\"\n          >\n            <circle cx=\"12\" cy=\"12\" r=\"9\" opacity=\"0.3\" />\n            <path d=\"M21 12a9 9 0 0 0-9-9\" />\n          </svg>\n        </span>\n        <span aria-hidden className={cn(layer, state !== \"success\" && hidden)}>\n          <svg\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"3\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            className=\"size-4\"\n          >\n            {/* Draws itself in via the dash offset */}\n            <path\n              d=\"m5 12.5 4.5 4.5L19 7.5\"\n              pathLength={1}\n              strokeDasharray={1}\n              strokeDashoffset={state === \"success\" ? 0 : 1}\n              className=\"transition-[stroke-dashoffset] delay-100 duration-300 motion-reduce:transition-none\"\n            />\n          </svg>\n          {successText}\n        </span>\n        <span aria-hidden className={cn(layer, state !== \"error\" && hidden)}>\n          <svg\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"3\"\n            strokeLinecap=\"round\"\n            className=\"size-4\"\n          >\n            <path d=\"M7 7l10 10M17 7 7 17\" />\n          </svg>\n          {errorText}\n        </span>\n      </button>\n      <span role=\"status\" className=\"sr-only\">\n        {state === \"success\" ? successText : state === \"error\" ? errorText : \"\"}\n      </span>\n    </>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/velora/stateful-button.tsx"
    }
  ],
  "type": "registry:ui"
}