{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "async-button",
  "type": "registry:ui",
  "title": "Async Button",
  "description": "Tracks the promise it starts: busy, tick or retry, without ever changing width.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/async-button.tsx",
      "content": "\"use client\";\nimport { Button } from \"@base-ui/react/button\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type AsyncStatus = \"idle\" | \"pending\" | \"success\" | \"error\";\n\ntype Options = {\n  /** Milliseconds the success state holds before returning to idle. */\n  successTimeout?: number;\n  /** Milliseconds before an error returns to idle. Omit to keep it until the next press. */\n  errorTimeout?: number;\n  /** Wait this long before showing the busy state, so fast requests never flash a spinner. */\n  pendingDelay?: number;\n  /** Once the busy state is visible, keep it at least this long so it never flickers. */\n  minPending?: number;\n  onSuccess?: (result: unknown) => void;\n  onError?: (error: unknown) => void;\n};\n\nconst isThenable = (v: unknown): v is PromiseLike<unknown> =>\n  !!v && (typeof v === \"object\" || typeof v === \"function\") && typeof (v as PromiseLike<unknown>).then === \"function\";\n\n/**\n * The async lifecycle on its own: idle → pending → success | error → idle.\n * `status` is the truth (use it for aria-busy and blocking); `visible` is what\n * to draw, which stays idle through the first `pendingDelay` ms of a request.\n */\nexport function useAsyncAction({\n  successTimeout = 1600,\n  errorTimeout,\n  pendingDelay = 150,\n  minPending = 450,\n  onSuccess,\n  onError,\n}: Options = {}) {\n  const [status, setStatus] = useState<AsyncStatus>(\"idle\");\n  const [shown, setShown] = useState(false);\n  const busy = useRef(false);\n  const alive = useRef(true);\n  const timers = useRef<number[]>([]);\n\n  const later = (fn: () => void, ms: number) => {\n    const id = window.setTimeout(fn, ms);\n    timers.current.push(id);\n    return id;\n  };\n  const clearAll = () => {\n    timers.current.forEach((id) => window.clearTimeout(id));\n    timers.current = [];\n  };\n\n  useEffect(() => {\n    alive.current = true;\n    return () => {\n      alive.current = false;\n      timers.current.forEach((id) => window.clearTimeout(id));\n    };\n  }, []);\n\n  const run = useCallback(\n    async (task: () => unknown) => {\n      // One request at a time: a second press while busy is a double submit.\n      if (busy.current) return;\n      let result: unknown;\n      try {\n        result = task();\n      } catch (error) {\n        result = Promise.reject(error);\n      }\n      // A plain synchronous handler is just a click; there is nothing to wait for.\n      if (!isThenable(result)) return;\n\n      busy.current = true;\n      clearAll();\n      setStatus(\"pending\");\n      setShown(false);\n      let shownAt = 0;\n      const reveal = later(() => {\n        shownAt = performance.now();\n        setShown(true);\n      }, pendingDelay);\n\n      let ok = false;\n      let value: unknown;\n      try {\n        value = await result;\n        ok = true;\n      } catch (error) {\n        value = error;\n      }\n      window.clearTimeout(reveal);\n      if (shownAt) {\n        const rest = minPending - (performance.now() - shownAt);\n        if (rest > 0) await new Promise((r) => later(() => r(null), rest));\n      }\n      if (!alive.current) return;\n\n      busy.current = false;\n      setShown(false);\n      if (ok) {\n        setStatus(\"success\");\n        onSuccess?.(value);\n        later(() => setStatus(\"idle\"), successTimeout);\n      } else {\n        setStatus(\"error\");\n        onError?.(value);\n        if (errorTimeout != null) later(() => setStatus(\"idle\"), errorTimeout);\n      }\n    },\n    [pendingDelay, minPending, successTimeout, errorTimeout, onSuccess, onError],\n  );\n\n  const reset = useCallback(() => {\n    clearAll();\n    setStatus(\"idle\");\n    setShown(false);\n  }, []);\n\n  const visible: AsyncStatus = status === \"pending\" && !shown ? \"idle\" : status;\n  return { status, visible, run, reset };\n}\n\nexport type AsyncButtonProps = Omit<React.ComponentProps<\"button\">, \"onClick\" | \"children\"> &\n  Options & {\n    /** The resting label. Also the button's accessible name in every state. */\n    children: React.ReactNode;\n    /** Return a promise and the button tracks it. Return nothing and it behaves like a plain button. */\n    onClick?: (event: React.MouseEvent<HTMLButtonElement>) => unknown;\n    pendingLabel?: string;\n    successLabel?: string;\n    /** Shown when the promise rejects. Pressing it runs the action again. */\n    errorLabel?: string;\n    /** Drive the state yourself, e.g. from a form action. The button stops running its own lifecycle. */\n    status?: AsyncStatus;\n    /** Optional icon shown before the resting label. */\n    icon?: React.ReactNode;\n    size?: \"sm\" | \"md\" | \"lg\";\n    variant?: \"primary\" | \"secondary\";\n  };\n\nexport function AsyncButton({\n  children,\n  onClick,\n  pendingLabel = \"Saving…\",\n  successLabel = \"Saved\",\n  errorLabel = \"Try again\",\n  status: controlled,\n  icon,\n  size = \"md\",\n  variant = \"primary\",\n  successTimeout,\n  errorTimeout,\n  pendingDelay,\n  minPending,\n  onSuccess,\n  onError,\n  disabled,\n  className,\n  ref,\n  ...rest\n}: AsyncButtonProps) {\n  const action = useAsyncAction({ successTimeout, errorTimeout, pendingDelay, minPending, onSuccess, onError });\n  const reduce = useReducedMotion();\n  const status = controlled ?? action.status;\n  const visible = controlled ?? action.visible;\n  const locked = status === \"pending\" || status === \"success\";\n\n  const rows: Record<AsyncStatus, { glyph: React.ReactNode; text: React.ReactNode }> = {\n    idle: { glyph: icon, text: children },\n    pending: { glyph: <Spinner />, text: pendingLabel },\n    success: { glyph: <Tick reduce={!!reduce} />, text: successLabel },\n    error: { glyph: <RetryGlyph reduce={!!reduce} />, text: errorLabel },\n  };\n  const announce = status === \"pending\" ? pendingLabel : status === \"success\" ? successLabel : status === \"error\" ? errorLabel : \"\";\n\n  return (\n    <>\n      <Button\n        ref={ref as React.Ref<HTMLElement>}\n        type=\"button\"\n        data-state={visible}\n        data-size={size}\n        data-variant={variant}\n        aria-busy={status === \"pending\" || undefined}\n        // Stays focusable while busy so focus never falls to the page mid-request.\n        disabled={disabled || locked}\n        focusableWhenDisabled={locked && !disabled}\n        onClick={(e: React.MouseEvent<HTMLButtonElement>) => {\n          if (locked) return;\n          if (controlled) {\n            onClick?.(e);\n            return;\n          }\n          action.run(() => onClick?.(e));\n        }}\n        className={cn(\n          \"group/async relative inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap font-medium tracking-[-0.005em]\",\n          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"transition-[background-color,border-color,color,scale,box-shadow] duration-150 ease-out active:duration-75\",\n          size === \"sm\" ? \"h-7 rounded-md px-2.5 text-[12px]\" : size === \"lg\" ? \"h-9 rounded-lg px-3.5 text-[13px]\" : \"h-8 rounded-lg px-3 text-[12.5px]\",\n          variant === \"secondary\" && \"border shadow-[var(--shadow)]\",\n          variant === \"primary\" && \"shadow-[var(--shadow)]\",\n          tone(variant, status, visible),\n          // Busy and done have already been pressed; they don't press again.\n          !locked && \"active:scale-[0.97]\",\n          disabled && \"pointer-events-none opacity-50\",\n          className,\n        )}\n        {...rest}\n      >\n        {/* The name never changes; the live region below says what is happening. */}\n        <span className=\"sr-only\">{children}</span>\n\n        {/* Every row sits in one grid cell, so the button is as wide as its widest state and nothing shifts. */}\n        <span aria-hidden className=\"grid place-items-center\">\n          {(Object.keys(rows) as AsyncStatus[]).map((k) => (\n            <Row key={k} glyph={rows[k].glyph ? <span /> : null} size={size} className=\"invisible col-start-1 row-start-1\">\n              {rows[k].text}\n            </Row>\n          ))}\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={visible}\n              className=\"col-start-1 row-start-1 flex\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, y: -8, filter: \"blur(2px)\", transition: { duration: 0.16, ease: ease.in } }}\n              transition={{ duration: reduce ? 0.15 : 0.24, ease: ease.out }}\n            >\n              <Row glyph={rows[visible].glyph} size={size}>{rows[visible].text}</Row>\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </Button>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </span>\n    </>\n  );\n}\n\n// One set of colors per state, never stacked, so no two utilities fight over a property.\nfunction tone(variant: \"primary\" | \"secondary\", status: AsyncStatus, visible: AsyncStatus) {\n  const primary = variant === \"primary\";\n  // Busy reads as held, not as unavailable: no dimming, no hover, the press already happened.\n  if (status === \"pending\") return primary ? \"bg-fg/85 text-frame\" : \"border-line-2 bg-hover text-fg\";\n  if (visible === \"error\")\n    return primary ? \"bg-danger text-frame hover:bg-danger/90\" : \"border-danger/40 bg-raised text-danger hover:border-danger/60 hover:bg-danger-soft\";\n  return primary ? \"bg-fg text-frame hover:bg-fg/90\" : \"border-line-2 bg-raised text-fg hover:border-fg-4 hover:bg-hover\";\n}\n\nfunction Row({ glyph, size, className, children }: { glyph: React.ReactNode; size: string; className?: string; children: React.ReactNode }) {\n  return (\n    <span className={cn(\"inline-flex items-center\", size === \"sm\" ? \"gap-1.5\" : \"gap-2\", className)}>\n      {glyph && <span className={cn(\"grid shrink-0 place-items-center\", size === \"sm\" ? \"size-3.5 [&_svg]:size-3.5\" : \"size-4\")}>{glyph}</span>}\n      <span>{children}</span>\n    </span>\n  );\n}\n\nconst svg = { width: 16, height: 16, viewBox: \"0 0 16 16\", fill: \"none\", stroke: \"currentColor\", strokeWidth: 1.4, strokeLinecap: \"round\" as const, strokeLinejoin: \"round\" as const, \"aria-hidden\": true };\n\nfunction Spinner() {\n  return (\n    <svg {...svg} className=\"animate-spin [animation-duration:0.8s]\">\n      <circle cx=\"8\" cy=\"8\" r=\"5.75\" opacity=\"0.22\" />\n      <path d=\"M8 2.25a5.75 5.75 0 0 1 5.75 5.75\" />\n    </svg>\n  );\n}\n\nfunction Tick({ reduce }: { reduce: boolean }) {\n  return (\n    <motion.svg {...svg} initial={reduce ? false : { scale: 0.6 }} animate={{ scale: 1 }} transition={spring.pop}>\n      <motion.path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        initial={reduce ? false : { pathLength: 0 }}\n        animate={{ pathLength: 1 }}\n        transition={{ duration: 0.32, ease: ease.out, delay: 0.06 }}\n      />\n    </motion.svg>\n  );\n}\n\nfunction RetryGlyph({ reduce }: { reduce: boolean }) {\n  // Winds back a quarter turn as it arrives, and leans further on hover:\n  // the icon says \"again\" before the label is read.\n  return (\n    <span className=\"grid place-items-center transition-transform duration-200 ease-out group-hover/async:-rotate-45\">\n      <motion.svg {...svg} initial={reduce ? false : { rotate: 90, scale: 0.7 }} animate={{ rotate: 0, scale: 1 }} transition={spring.pop}>\n        <path d=\"M13 8a5 5 0 1 1-1.5-3.55M13 2.75V5h-2.25\" />\n      </motion.svg>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/async-button"
}
