{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "promise-toast",
  "type": "registry:ui",
  "title": "Promise Toast",
  "description": "One toast follows async work: spinner, live progress, then a tick drawn in place.",
  "categories": [
    "notifications"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/toast.json"
  ],
  "files": [
    {
      "path": "components/ui/promise-toast.tsx",
      "content": "\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport { type ToastOptions, useToast } from \"@/components/ui/toast\";\n\nexport { ToastProvider, Toaster } from \"@/components/ui/toast\";\n\ntype Message = string | Omit<ToastOptions, \"id\" | \"tone\" | \"action\" | \"progress\">;\ntype Resolver<A> = Message | ((arg: A) => Message);\n\nexport type PromiseToastMessages<T> = {\n  loading: Message;\n  success: Resolver<T>;\n  error: Resolver<unknown>;\n  /** Shown when the person cancels. */\n  canceled?: Message;\n};\n\nexport type PromiseTaskContext = {\n  /** Report 0–1, optionally with a new description (\"2.4 of 6.1 MB\"). */\n  progress: (value: number, description?: React.ReactNode) => void;\n  /** Aborted when the person presses Cancel. Pass it to fetch. */\n  signal: AbortSignal;\n};\n\n/** A promise, or a function that starts the work; only a function can report progress, be canceled or be retried. */\nexport type PromiseTask<T> = Promise<T> | ((ctx: PromiseTaskContext) => Promise<T>);\n\nexport type PromiseToastOptions<T> = {\n  /** Reuse an id to run again in the same toast. */\n  id?: string;\n  /** Show a Cancel action while loading. A string sets its label. Needs a task function. */\n  cancel?: boolean | string;\n  /** Show a Try again action on failure that reruns the task in place. A string sets its label. Needs a task function. */\n  retry?: boolean | string;\n  /** The spinner stays at least this long, so a fast success still reads as loading then done. */\n  minDuration?: number;\n  onSuccess?: (value: T) => void;\n  onError?: (error: unknown) => void;\n};\n\n// Each state replaces the copy of the one before it, so a loading description never lingers on the result.\nconst resolve = <A,>(r: Resolver<A>, arg: A): Exclude<Message, string> => {\n  const m = typeof r === \"function\" ? r(arg) : r;\n  return { title: undefined, description: undefined, icon: undefined, ...(typeof m === \"string\" ? { title: m } : m) };\n};\n\nconst wait = (ms: number) => new Promise((r) => setTimeout(r, ms));\n\n/**\n * Binds a toast to a piece of async work. The toast opens with a spinner,\n * follows progress if the task reports it, and turns into a tick or a cross\n * in place when the work settles. Retry and Cancel keep the same toast.\n */\nexport function usePromiseToast() {\n  const toast = useToast();\n  // Keep the latest API for tasks that outlive the render that started them.\n  const api = useRef(toast);\n  useEffect(() => {\n    api.current = toast;\n  });\n  const frames = useRef(new Set<number>());\n  useEffect(() => {\n    const pending = frames.current;\n    return () => pending.forEach((f) => cancelAnimationFrame(f));\n  }, []);\n\n  function promise<T>(task: PromiseTask<T>, messages: PromiseToastMessages<T>, options: PromiseToastOptions<T> = {}): Promise<T> {\n    const { cancel, retry, minDuration = 600, onSuccess, onError } = options;\n    const rerunnable = typeof task === \"function\";\n    const id = options.id ?? `promise-${Math.random().toString(36).slice(2)}`;\n    let attempt = 0;\n\n    const run = (first: boolean): Promise<T> => {\n      const current = ++attempt;\n      const controller = new AbortController();\n      const started = performance.now();\n      let canceled = false;\n      let frame = 0;\n      let latest: { value: number; description?: React.ReactNode } | null = null;\n\n      const loading = resolve(messages.loading, undefined);\n      const cancelAction =\n        cancel && rerunnable\n          ? {\n              label: typeof cancel === \"string\" ? cancel : \"Cancel\",\n              onClick: (e: React.MouseEvent) => {\n                e.preventDefault();\n                canceled = true;\n                controller.abort();\n                api.current.update(id, { ...resolve(messages.canceled ?? \"Canceled\", undefined), tone: \"neutral\", progress: undefined, action: undefined, timeout: 3000 });\n              },\n            }\n          : undefined;\n\n      const opening = { ...loading, tone: \"loading\" as const, progress: undefined, action: cancelAction, timeout: 0 };\n      if (first) api.current.add({ ...opening, id });\n      else api.current.update(id, opening);\n\n      // Progress can fire hundreds of times a second; paint it once per frame.\n      const progress = (value: number, description?: React.ReactNode) => {\n        latest = { value, description };\n        if (frame) return;\n        frame = requestAnimationFrame(() => {\n          frames.current.delete(frame);\n          frame = 0;\n          if (canceled || current !== attempt || !latest) return;\n          api.current.update(id, { progress: latest.value, ...(latest.description !== undefined && { description: latest.description }) });\n        });\n        frames.current.add(frame);\n      };\n\n      const work = typeof task === \"function\" ? task({ progress, signal: controller.signal }) : task;\n\n      const settle = async (apply: () => void) => {\n        await wait(Math.max(0, minDuration - (performance.now() - started)));\n        if (frame) cancelAnimationFrame(frame);\n        if (canceled || current !== attempt) return;\n        apply();\n      };\n\n      work.then(\n        (value) =>\n          settle(() => {\n            api.current.update(id, { ...resolve(messages.success, value), tone: \"success\", action: undefined, ...(latest && { progress: 1 }) });\n            onSuccess?.(value);\n          }),\n        (error) =>\n          settle(() => {\n            const retryAction =\n              retry && rerunnable\n                ? {\n                    label: typeof retry === \"string\" ? retry : \"Try again\",\n                    onClick: (e: React.MouseEvent) => {\n                      e.preventDefault();\n                      run(false).catch(() => {});\n                    },\n                  }\n                : undefined;\n            api.current.update(id, { ...resolve(messages.error, error), tone: \"error\", action: retryAction, ...(latest && { progress: latest.value }) });\n            onError?.(error);\n          }),\n      );\n      return work;\n    };\n\n    return run(true);\n  }\n\n  return { promise, close: toast.close };\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/promise-toast"
}
