{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "undo-pill",
  "title": "Undo Pill",
  "description": "An inline undo pill with a draining countdown ring that pauses on hover and fires undo or expire callbacks",
  "dependencies": [
    "framer-motion"
  ],
  "files": [
    {
      "path": "app/registry/undo-pill/undo-pill.tsx",
      "content": "/**\n * Spectrum UI — UndoPill\n *\n * An inline undo pill with a draining countdown ring. When opened it springs\n * in from below, drains an SVG ring over the given duration while counting\n * the remaining seconds, and fires onExpire when time runs out. Hovering or\n * focusing the pill pauses the countdown; clicking Undo (or pressing Escape\n * while focus is within) fires onUndo and exits with a quick bounce. Honors\n * prefers-reduced-motion and announces itself politely to screen readers.\n *\n * Dependencies: framer-motion, @/lib/utils\n *\n * @example\n * <UndoPill\n *   open={open}\n *   label=\"Message deleted\"\n *   onUndo={() => restore()}\n *   onExpire={() => commit()}\n * />\n */\n\n\"use client\"\n\nimport React, { useCallback, useEffect, useRef, useState } from \"react\"\nimport {\n  AnimatePresence,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  type Variants,\n} from \"framer-motion\"\nimport { cn } from \"@/lib/utils\"\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface UndoPillProps {\n  /** Controls whether the pill is shown. Fully controlled */\n  open: boolean\n  /** Message shown inside the pill. Default \"Deleted\" */\n  label?: string\n  /** Countdown length in seconds. Default 5 */\n  duration?: number\n  /** Fires when the Undo button is clicked or Escape is pressed within */\n  onUndo: () => void\n  /** Fires once when the countdown completes */\n  onExpire: () => void\n  /** Pause the countdown while hovered or focused. Default true */\n  pauseOnHover?: boolean\n  /** Text of the undo button. Default \"Undo\" */\n  undoLabel?: string\n  /** Additional classes merged with the default pill styles */\n  className?: string\n}\n\ntype ExitReason = \"undo\" | \"expire\"\n\n// ─── Constants ───────────────────────────────────────────────────────────────\n\nconst RING_RADIUS = 7\nconst RING_SIZE = 18\nconst RING_CENTER = RING_SIZE / 2\nconst RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS\n\n/**\n * Longest elapsed time credited per frame. rAF stops in background tabs, so\n * without this clamp the first frame after returning would land the entire\n * hidden period at once and the pill could expire mid-jump.\n */\nconst MAX_FRAME_DELTA_MS = 100\n/** Micro-crossfade when the seconds digit swaps */\nconst SECONDS_SWAP_DURATION = 0.15\n/** Happy bounce-out after a successful undo */\nconst UNDO_EXIT_DURATION = 0.3\n/** Quiet fade once the countdown runs out */\nconst EXPIRE_EXIT_DURATION = 0.25\n\nconst ENTRANCE_SPRING = { type: \"spring\", stiffness: 500, damping: 30 } as const\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nexport function UndoPill({\n  open,\n  label = \"Deleted\",\n  duration = 5,\n  onUndo,\n  onExpire,\n  pauseOnHover = true,\n  undoLabel = \"Undo\",\n  className,\n}: UndoPillProps) {\n  const shouldReduceMotion = useReducedMotion()\n  const [secondsLeft, setSecondsLeft] = useState(() => Math.ceil(duration))\n  const [exitReason, setExitReason] = useState<ExitReason>(\"expire\")\n  // Mirrors the hover/focus pause into render so the ring can hint the pause\n  const [isPaused, setIsPaused] = useState(false)\n\n  // Drives the ring's stroke-dashoffset without re-rendering every frame\n  const ringOffset = useMotionValue(0)\n\n  // Timer bookkeeping — rAF + accumulated elapsed so pause/resume is smooth\n  const rafRef = useRef<number | null>(null)\n  const lastFrameRef = useRef(0)\n  const elapsedRef = useRef(0)\n  const expiredRef = useRef(false)\n  const hoveredRef = useRef(false)\n  const focusedRef = useRef(false)\n\n  // Latest props readable from the rAF loop without restarting it\n  const latestRef = useRef({ onExpire, pauseOnHover })\n  useEffect(() => {\n    latestRef.current = { onExpire, pauseOnHover }\n  })\n\n  useEffect(() => {\n    if (!open) return\n\n    const durationMs = Math.max(duration, 0.001) * 1000\n    elapsedRef.current = 0\n    expiredRef.current = false\n    hoveredRef.current = false\n    focusedRef.current = false\n    lastFrameRef.current = performance.now()\n    ringOffset.set(0)\n    setSecondsLeft(Math.ceil(duration))\n    setExitReason(\"expire\")\n    setIsPaused(false)\n\n    const tick = (now: number) => {\n      const { onExpire: expire, pauseOnHover: pausable } = latestRef.current\n      const paused = pausable && (hoveredRef.current || focusedRef.current)\n\n      // Accumulate elapsed instead of diffing wall-clock across pauses, and\n      // clamp the per-frame delta so background-tab time never lands at once\n      if (!paused) {\n        elapsedRef.current += Math.min(\n          now - lastFrameRef.current,\n          MAX_FRAME_DELTA_MS,\n        )\n      }\n      lastFrameRef.current = now\n\n      const progress = Math.min(elapsedRef.current / durationMs, 1)\n      ringOffset.set(RING_CIRCUMFERENCE * progress)\n\n      const remaining = Math.max(\n        0,\n        Math.ceil((durationMs - elapsedRef.current) / 1000),\n      )\n      setSecondsLeft((prev) => (prev === remaining ? prev : remaining))\n\n      if (progress >= 1) {\n        if (!expiredRef.current) {\n          expiredRef.current = true\n          expire()\n        }\n        return\n      }\n      rafRef.current = requestAnimationFrame(tick)\n    }\n\n    rafRef.current = requestAnimationFrame(tick)\n    return () => {\n      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)\n      rafRef.current = null\n    }\n  }, [open, duration, ringOffset])\n\n  const handleUndo = useCallback(() => {\n    if (expiredRef.current) return\n    expiredRef.current = true\n    if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)\n    setExitReason(\"undo\")\n    onUndo()\n  }, [onUndo])\n\n  const handleKeyDown = useCallback(\n    (event: React.KeyboardEvent<HTMLDivElement>) => {\n      if (event.key !== \"Escape\") return\n      event.stopPropagation()\n      handleUndo()\n    },\n    [handleUndo],\n  )\n\n  const syncPaused = useCallback(() => {\n    setIsPaused(pauseOnHover && (hoveredRef.current || focusedRef.current))\n  }, [pauseOnHover])\n\n  const pillVariants: Variants = {\n    hidden: shouldReduceMotion\n      ? { opacity: 0 }\n      : { opacity: 0, y: 16, scale: 0.95 },\n    visible: shouldReduceMotion\n      ? { opacity: 1, transition: { duration: 0.2 } }\n      : {\n          opacity: 1,\n          y: 0,\n          scale: 1,\n          transition: ENTRANCE_SPRING,\n        },\n    exit: (reason: ExitReason) =>\n      shouldReduceMotion\n        ? { opacity: 0, transition: { duration: 0.15 } }\n        : reason === \"undo\"\n          ? {\n              opacity: [1, 1, 0],\n              scale: [1, 1.04, 0.9],\n              transition: {\n                duration: UNDO_EXIT_DURATION,\n                times: [0, 0.4, 1],\n                ease: \"easeIn\",\n              },\n            }\n          : {\n              opacity: 0,\n              scale: 0.9,\n              y: 8,\n              transition: { duration: EXPIRE_EXIT_DURATION, ease: \"easeIn\" },\n            },\n  }\n\n  return (\n    <AnimatePresence custom={exitReason}>\n      {open && (\n        <motion.div\n          role=\"status\"\n          aria-live=\"polite\"\n          variants={pillVariants}\n          initial=\"hidden\"\n          animate=\"visible\"\n          exit=\"exit\"\n          custom={exitReason}\n          onPointerEnter={() => {\n            hoveredRef.current = true\n            syncPaused()\n          }}\n          onPointerLeave={() => {\n            hoveredRef.current = false\n            syncPaused()\n          }}\n          onFocus={() => {\n            focusedRef.current = true\n            syncPaused()\n          }}\n          onBlur={(event) => {\n            if (!event.currentTarget.contains(event.relatedTarget as Node)) {\n              focusedRef.current = false\n              syncPaused()\n            }\n          }}\n          onKeyDown={handleKeyDown}\n          className={cn(\n            \"pointer-events-auto inline-flex select-none items-center gap-2.5 rounded-full bg-neutral-900 py-2 pl-3 pr-2.5 text-sm text-white\",\n            \"shadow-[0px_0px_0px_1px_rgba(0,0,0,0.06),0px_1px_2px_0px_rgba(0,0,0,0.04),0px_2px_4px_0px_rgba(0,0,0,0.04)]\",\n            \"dark:bg-white dark:text-neutral-900 dark:shadow-none\",\n            className,\n          )}\n        >\n          <span\n            aria-hidden=\"true\"\n            className=\"relative inline-flex shrink-0 items-center justify-center\"\n            style={{ width: RING_SIZE, height: RING_SIZE }}\n          >\n            <svg\n              viewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`}\n              width={RING_SIZE}\n              height={RING_SIZE}\n              className=\"-rotate-90\"\n            >\n              <circle\n                cx={RING_CENTER}\n                cy={RING_CENTER}\n                r={RING_RADIUS}\n                fill=\"none\"\n                strokeWidth={2}\n                className={cn(\n                  \"transition-colors duration-200\",\n                  // Brightening the track hints that hover/focus paused it\n                  isPaused\n                    ? \"stroke-white/45 dark:stroke-neutral-900/40\"\n                    : \"stroke-neutral-500/50\",\n                )}\n              />\n              <motion.circle\n                cx={RING_CENTER}\n                cy={RING_CENTER}\n                r={RING_RADIUS}\n                fill=\"none\"\n                strokeWidth={2}\n                strokeLinecap=\"round\"\n                strokeDasharray={RING_CIRCUMFERENCE}\n                style={{ strokeDashoffset: ringOffset }}\n                className=\"stroke-white dark:stroke-neutral-900\"\n              />\n            </svg>\n            <span className=\"absolute inset-0 flex items-center justify-center text-[9px] font-medium leading-none tabular-nums\">\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                <motion.span\n                  key={secondsLeft}\n                  className=\"inline-block\"\n                  initial={\n                    shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: -5 }\n                  }\n                  animate={{ opacity: 1, y: 0 }}\n                  exit={\n                    shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 5 }\n                  }\n                  transition={{\n                    duration: shouldReduceMotion ? 0 : SECONDS_SWAP_DURATION,\n                    ease: \"easeOut\",\n                  }}\n                >\n                  {secondsLeft}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n          </span>\n\n          <span>{label}</span>\n\n          <span\n            aria-hidden=\"true\"\n            className=\"h-1 w-1 shrink-0 rounded-full bg-white/30 dark:bg-neutral-900/30\"\n          />\n\n          <button\n            type=\"button\"\n            onClick={handleUndo}\n            aria-label={`${label}, undo`}\n            className={cn(\n              // -my-1/py-1 grow the hit area past 24px without moving pixels\n              \"-my-1 touch-manipulation rounded-full px-1.5 py-1 font-medium underline-offset-2 transition-colors\",\n              \"hover:bg-white/10 hover:underline active:bg-white/20 dark:hover:bg-neutral-900/10 dark:active:bg-neutral-900/15\",\n              \"focus-visible:underline focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-white/70 dark:focus-visible:ring-neutral-900/60\",\n            )}\n          >\n            {undoLabel}\n          </button>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/spectrumui/undo-pill.tsx"
    }
  ],
  "type": "registry:component"
}
