{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "popconfirm",
  "type": "registry:ui",
  "title": "Popconfirm",
  "description": "Asks where you pressed, starts on Cancel, and waits out async confirms.",
  "categories": [
    "menus"
  ],
  "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/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/popconfirm.tsx",
      "content": "\"use client\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Loader } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype Side = \"top\" | \"bottom\" | \"left\" | \"right\";\ntype Align = \"start\" | \"center\" | \"end\";\nexport type ConfirmStatus = \"idle\" | \"pending\" | \"success\" | \"error\";\n\n/**\n * The confirm lifecycle on its own: runs the action, tracks pending, holds a success\n * beat before `onDone`, and keeps the error so the caller can say what went wrong.\n */\nexport function useConfirmAction({\n  onConfirm,\n  onDone,\n  successHold = 650,\n}: {\n  onConfirm: () => void | Promise<unknown>;\n  /** Called once the action succeeded (after the success beat for async actions). */\n  onDone?: () => void;\n  successHold?: number;\n}) {\n  const [status, setStatus] = useState<ConfirmStatus>(\"idle\");\n  const [error, setError] = useState<unknown>(null);\n  const timer = useRef<number>(undefined);\n  const alive = useRef(true);\n  useEffect(() => {\n    alive.current = true;\n    return () => {\n      alive.current = false;\n      window.clearTimeout(timer.current);\n    };\n  }, []);\n\n  const run = useCallback(async () => {\n    if (status === \"pending\" || status === \"success\") return;\n    let result: void | Promise<unknown>;\n    try {\n      result = onConfirm();\n    } catch (e) {\n      setError(e);\n      setStatus(\"error\");\n      return;\n    }\n    // A plain function confirms on the spot: no spinner, no beat.\n    if (!(result instanceof Promise)) {\n      onDone?.();\n      return;\n    }\n    setError(null);\n    setStatus(\"pending\");\n    try {\n      await result;\n      if (!alive.current) return;\n      setStatus(\"success\");\n      timer.current = window.setTimeout(() => onDone?.(), successHold);\n    } catch (e) {\n      if (!alive.current) return;\n      setError(e);\n      setStatus(\"error\");\n    }\n  }, [status, onConfirm, onDone, successHold]);\n\n  const reset = useCallback(() => {\n    window.clearTimeout(timer.current);\n    setStatus(\"idle\");\n    setError(null);\n  }, []);\n\n  return { status, error, run, reset };\n}\n\nexport type PopconfirmProps = {\n  /** The question, naming the thing: \"Delete q3-forecast.xlsx?\" */\n  title: React.ReactNode;\n  /** One line on the consequence. */\n  description?: React.ReactNode;\n  /** The verb. Never \"Yes\" or \"OK\". */\n  confirmLabel?: string;\n  cancelLabel?: string;\n  /** Shown with a drawn tick when an async action resolves, just before the popover closes. */\n  successLabel?: string;\n  /** Replaces the confirm label after a failure. */\n  retryLabel?: string;\n  /** What to say when the action throws or rejects. A function receives the error. */\n  errorMessage?: React.ReactNode | ((error: unknown) => React.ReactNode);\n  tone?: \"default\" | \"danger\";\n  /** Optional mark before the title, e.g. a trash or warning icon. */\n  icon?: React.ReactNode;\n  /** Return a promise to keep the popover open with a spinner until it settles. */\n  onConfirm: () => void | Promise<unknown>;\n  onCancel?: () => void;\n  /** Called after a confirmed action has finished and the popover has closed: the moment to remove the row. */\n  onConfirmed?: () => void;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  side?: Side;\n  align?: Align;\n  /** Where to portal the popover. Defaults to document.body. */\n  container?: Popover.Portal.Props[\"container\"];\n  /** Milliseconds the success state holds before closing. */\n  successHold?: number;\n  /** Class for the popover surface. */\n  className?: string;\n  /** The trigger: a single button-like element. It stays pressed while the question is open. */\n  children: React.ReactElement<Record<string, unknown>>;\n};\n\n/**\n * A small question anchored to the button that asked it. Focus starts on Cancel, Escape and\n * outside presses back out, and an async confirm keeps the question open until it settles.\n */\nexport function Popconfirm({\n  title,\n  description,\n  confirmLabel = \"Confirm\",\n  cancelLabel = \"Cancel\",\n  successLabel = \"Done\",\n  retryLabel = \"Try again\",\n  errorMessage = \"That didn’t go through. Try again.\",\n  tone = \"default\",\n  icon,\n  onConfirm,\n  onCancel,\n  onConfirmed,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  side = \"bottom\",\n  align = \"center\",\n  container,\n  successHold,\n  className,\n  children,\n}: PopconfirmProps) {\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n  const confirmed = useRef(false);\n  const { status, error, run, reset } = useConfirmAction({\n    onConfirm,\n    onDone: () => {\n      confirmed.current = true;\n      setOpen(false);\n    },\n    successHold,\n  });\n  const cancelRef = useRef<HTMLButtonElement>(null);\n  const reduce = useReducedMotion();\n  const busy = status === \"pending\" || status === \"success\";\n  const label = status === \"success\" ? successLabel : status === \"error\" ? retryLabel : confirmLabel;\n\n  return (\n    <Popover.Root\n      open={open}\n      modal=\"trap-focus\"\n      onOpenChange={(next, details) => {\n        // Nothing dismisses the question while the action is in flight or finishing.\n        if (!next && busy) {\n          details.cancel();\n          return;\n        }\n        if (!next && status !== \"success\") onCancel?.();\n        setOpen(next);\n      }}\n      onOpenChangeComplete={(isOpen) => {\n        if (isOpen) return;\n        reset();\n        // Only now, with the popover gone, is it safe to unmount the trigger's row.\n        if (confirmed.current) {\n          confirmed.current = false;\n          onConfirmed?.();\n        }\n      }}\n    >\n      <Popover.Trigger render={children} />\n      <Popover.Portal container={container}>\n        <Popover.Positioner side={side} align={align} sideOffset={10} collisionPadding={8} arrowPadding={14} className=\"z-(--z-popover)\">\n          <Popover.Popup\n            role=\"alertdialog\"\n            initialFocus={cancelRef}\n            aria-busy={status === \"pending\" || undefined}\n            data-status={status}\n            data-tone={tone}\n            className={cn(\n              \"relative w-72 max-w-(--available-width) rounded-xl border border-line-2 bg-raised p-3.5 text-[13px] text-fg shadow-pop outline-none\",\n              // Grows out of the trigger and drifts 4px away from it; leaves with a quick fade.\n              \"origin-(--transform-origin) transition-[opacity,scale,translate,filter] duration-200 ease-out-expo\",\n              \"data-starting-style:scale-96 data-starting-style:opacity-0 data-starting-style:blur-[2px]\",\n              \"data-[side=bottom]:data-starting-style:-translate-y-1 data-[side=top]:data-starting-style:translate-y-1\",\n              \"data-[side=left]:data-starting-style:translate-x-1 data-[side=right]:data-starting-style:-translate-x-1\",\n              \"data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-140 data-ending-style:ease-out-quart\",\n              \"data-instant:transition-none\",\n              \"motion-reduce:data-starting-style:translate-0 motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:blur-none motion-reduce:data-ending-style:scale-100\",\n              className,\n            )}\n          >\n            <Arrow />\n            <div className=\"flex gap-2.5\">\n              {icon && (\n                <span\n                  aria-hidden\n                  className={cn(\n                    \"mt-px grid size-6 shrink-0 place-items-center rounded-full [&_svg]:size-3.5\",\n                    tone === \"danger\" ? \"bg-danger-soft text-danger\" : \"bg-hover text-fg-2\",\n                  )}\n                >\n                  {icon}\n                </span>\n              )}\n              <div className=\"min-w-0 flex-1 pt-0.5\">\n                <Popover.Title className=\"text-[13px] font-medium leading-5 tracking-[-0.01em] text-fg [overflow-wrap:anywhere] text-balance\">{title}</Popover.Title>\n                {description && (\n                  <Popover.Description className=\"mt-0.5 text-[12.5px] leading-[18px] text-fg-2 text-pretty\">{description}</Popover.Description>\n                )}\n              </div>\n            </div>\n\n            {/* The error slot opens by grid rows so the popover grows smoothly instead of jumping. */}\n            <div\n              className={cn(\n                \"grid transition-[grid-template-rows,opacity] duration-200 ease-out-expo motion-reduce:transition-none\",\n                status === \"error\" ? \"grid-rows-[1fr] opacity-100\" : \"grid-rows-[0fr] opacity-0\",\n              )}\n            >\n              <div className=\"min-h-0 overflow-hidden\">\n                <p role=\"alert\" className=\"mt-3 rounded-lg bg-danger-soft px-2.5 py-2 text-[12px] leading-[17px] text-danger text-pretty\">\n                  {status === \"error\" ? (typeof errorMessage === \"function\" ? errorMessage(error) : errorMessage) : null}\n                </p>\n              </div>\n            </div>\n\n            <div className=\"mt-3.5 flex items-center justify-end gap-2\">\n              <Popover.Close\n                ref={cancelRef}\n                aria-disabled={busy || undefined}\n                className={cn(\n                  \"relative inline-flex h-7 items-center justify-center rounded-md border border-line-2 bg-raised px-2.5 text-[12px] font-medium text-fg shadow-[var(--shadow)] outline-none\",\n                  \"transition-[background-color,border-color,opacity,scale] duration-150 hover:border-fg-4 hover:bg-hover active:scale-[0.97] active:duration-75\",\n                  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                  \"aria-disabled:pointer-events-none aria-disabled:opacity-50\",\n                  \"before:absolute before:-inset-x-1 before:-inset-y-2 before:content-[''] pointer-fine:before:hidden\",\n                )}\n              >\n                {cancelLabel}\n              </Popover.Close>\n              <button\n                type=\"button\"\n                aria-disabled={busy || undefined}\n                onClick={() => {\n                  if (!busy) run();\n                }}\n                className={cn(\n                  \"relative inline-flex h-7 items-center justify-center gap-1.5 overflow-hidden rounded-md px-2.5 text-[12px] font-medium shadow-[var(--shadow)] outline-none\",\n                  \"transition-[background-color,scale] duration-150 active:duration-75\",\n                  !busy && \"active:scale-[0.97]\",\n                  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                  \"before:absolute before:-inset-x-1 before:-inset-y-2 before:content-[''] pointer-fine:before:hidden\",\n                  tone === \"danger\" ? \"bg-danger text-frame\" : \"bg-fg text-frame\",\n                  !busy && (tone === \"danger\" ? \"hover:bg-danger/90\" : \"hover:bg-fg/90\"),\n                )}\n              >\n                {/* Every label shares one grid cell, so the button is as wide as the longest and never shifts. */}\n                <span className=\"relative grid\">\n                  {[confirmLabel, successLabel, retryLabel].map((l) => (\n                    <span key={l} aria-hidden className=\"invisible col-start-1 row-start-1 flex items-center gap-1.5 whitespace-nowrap\">\n                      {l === successLabel && <span className=\"size-3.5\" />}\n                      {l}\n                    </span>\n                  ))}\n                  {/* A spinner that flashes for 80ms reads as a glitch: the label only gives way after 150ms. */}\n                  <span\n                    className={cn(\n                      \"col-start-1 row-start-1 grid transition-opacity duration-150\",\n                      status === \"pending\" && \"opacity-0 delay-150\",\n                    )}\n                  >\n                    <AnimatePresence initial={false}>\n                      <motion.span\n                        key={status === \"pending\" ? \"idle\" : status}\n                        className=\"col-start-1 row-start-1 flex items-center justify-center gap-1.5 whitespace-nowrap\"\n                        initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n                        animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                        exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\" }}\n                        transition={{ duration: reduce ? 0.12 : 0.2, ease: ease.out }}\n                      >\n                        {status === \"success\" && <Tick reduce={!!reduce} />}\n                        {label}\n                      </motion.span>\n                    </AnimatePresence>\n                  </span>\n                  <span\n                    aria-hidden\n                    className={cn(\n                      \"absolute inset-0 grid place-items-center transition-opacity duration-150\",\n                      status === \"pending\" ? \"opacity-100 delay-150\" : \"opacity-0\",\n                    )}\n                  >\n                    <Loader size={14} className=\"animate-spin\" />\n                  </span>\n                </span>\n              </button>\n            </div>\n            <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n              {status === \"pending\" ? `${confirmLabel}…` : status === \"success\" ? successLabel : \"\"}\n            </span>\n          </Popover.Popup>\n        </Popover.Positioner>\n      </Popover.Portal>\n    </Popover.Root>\n  );\n}\n\nfunction Tick({ reduce }: { reduce: boolean }) {\n  return (\n    <motion.svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.6}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden\n      initial={reduce ? false : { scale: 0.6 }}\n      animate={{ scale: 1 }}\n      transition={spring.pop}\n    >\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.3, ease: ease.out, delay: 0.05 }}\n      />\n    </motion.svg>\n  );\n}\n\n// Curved arrow drawn as fill plus stroke so it joins the hairline border without a seam.\nfunction Arrow() {\n  return (\n    <Popover.Arrow\n      className={cn(\n        \"pointer-events-none flex h-2.5 w-5\",\n        \"data-[side=bottom]:-top-[9px] data-[side=top]:-bottom-[9px] data-[side=top]:rotate-180\",\n        \"data-[side=left]:-right-3.5 data-[side=left]:rotate-90 data-[side=right]:-left-3.5 data-[side=right]:-rotate-90\",\n      )}\n    >\n      <svg width=\"20\" height=\"10\" viewBox=\"0 0 20 10\" fill=\"none\" aria-hidden className=\"block overflow-visible\">\n        <path d=\"M0 10c3.6 0 5.7-1.4 7.4-5.2L8.2 3c.8-1.6 2.8-1.6 3.6 0l.8 1.8c1.7 3.8 3.8 5.2 7.4 5.2z\" className=\"fill-raised\" />\n        <path d=\"M0 10c3.6 0 5.7-1.4 7.4-5.2L8.2 3c.8-1.6 2.8-1.6 3.6 0l.8 1.8c1.7 3.8 3.8 5.2 7.4 5.2\" className=\"stroke-line-2\" vectorEffect=\"non-scaling-stroke\" />\n      </svg>\n    </Popover.Arrow>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/popconfirm"
}
