{
  "name": "undo-button",
  "type": "registry:component",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Undo Button\n * @version: 1.0.0\n * @date: 2026-10-16\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { Archive, Check, type LucideIcon, Undo2 } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type PointerEvent,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst STROKE = 1.5;\nconst APPLE_PLATFORM = /Mac|iPhone|iPad/;\n\ntype Phase = \"idle\" | \"pending\" | \"done\";\n\nexport interface UndoButtonProps {\n  /** What the button does, e.g. \"Archive\". */\n  label?: string;\n  /** The same action in the past tense, e.g. \"Archived\". */\n  doneLabel?: string;\n  icon?: LucideIcon;\n  /** How long the action can be undone, in ms. */\n  duration?: number;\n  /**\n   * After the action commits, go back to the start after this many ms.\n   * Pass `null` to stay on the done state.\n   */\n  resetAfter?: number | null;\n  /** Called when the button is pressed and the undo window opens. */\n  onAction?: () => void;\n  /** Called when the window closes without an undo: do the real work here. */\n  onCommit?: () => void;\n  onUndo?: () => void;\n  className?: string;\n}\n\n// A pill outline that starts at the top centre and runs clockwise, so the\n// countdown drains back toward where it began.\nfunction pillPath(width: number, height: number) {\n  const s = STROKE / 2;\n  const r = height / 2 - s;\n  const left = s + r;\n  const right = width - s - r;\n  return [\n    `M ${width / 2} ${s}`,\n    `H ${right}`,\n    `A ${r} ${r} 0 0 1 ${right} ${height - s}`,\n    `H ${left}`,\n    `A ${r} ${r} 0 0 1 ${left} ${s}`,\n    \"Z\",\n  ].join(\" \");\n}\n\nconst isEditable = (target: EventTarget | null) =>\n  target instanceof HTMLElement &&\n  (target.isContentEditable ||\n    [\"INPUT\", \"TEXTAREA\", \"SELECT\"].includes(target.tagName));\n\nexport default function UndoButton({\n  label = \"Archive\",\n  doneLabel = \"Archived\",\n  icon: Icon = Archive,\n  duration = 5000,\n  resetAfter = 2400,\n  onAction,\n  onCommit,\n  onUndo,\n  className,\n}: UndoButtonProps) {\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const [seconds, setSeconds] = useState(Math.ceil(duration / 1000));\n  const [width, setWidth] = useState<number>();\n  const [isMac, setIsMac] = useState(true);\n  const reduceMotion = useReducedMotion();\n\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  const contentRef = useRef<HTMLSpanElement>(null);\n  const pathRef = useRef<SVGPathElement>(null);\n  const hoveredRef = useRef(false);\n  const elapsedRef = useRef(0);\n  const remainingRef = useRef(1);\n\n  const callbacks = useRef({ onAction, onCommit, onUndo });\n  callbacks.current = { onAction, onCommit, onUndo };\n\n  useEffect(() => {\n    setIsMac(APPLE_PLATFORM.test(navigator.platform));\n  }, []);\n\n  // The button's width follows its label, so the pill grows into\n  // \"Archived | Undo 5\" instead of jumping.\n  useLayoutEffect(() => {\n    const content = contentRef.current;\n    if (!content) {\n      return;\n    }\n    // +2 for the 1px border on each side.\n    const observer = new ResizeObserver(() =>\n      setWidth(content.offsetWidth + 2)\n    );\n    observer.observe(content);\n    return () => observer.disconnect();\n  }, []);\n\n  // Dashes in real lengths rather than pathLength, which Chrome scales\n  // inconsistently once the path changes size.\n  const paint = useCallback(() => {\n    const path = pathRef.current;\n    if (!path) {\n      return;\n    }\n    const length = path.getTotalLength();\n    path.style.strokeDasharray = `${length * remainingRef.current} ${length}`;\n  }, []);\n\n  // Keep the outline glued to the button while its width animates.\n  useLayoutEffect(() => {\n    const button = buttonRef.current;\n    if (!button) {\n      return;\n    }\n    const observer = new ResizeObserver(() => {\n      pathRef.current?.setAttribute(\n        \"d\",\n        pillPath(button.offsetWidth, button.offsetHeight)\n      );\n      paint();\n    });\n    observer.observe(button);\n    return () => observer.disconnect();\n  }, [paint]);\n\n  const undo = useCallback(() => {\n    setPhase(\"idle\");\n    callbacks.current.onUndo?.();\n  }, []);\n\n  useEffect(() => {\n    if (phase !== \"pending\") {\n      return;\n    }\n    elapsedRef.current = 0;\n    remainingRef.current = 1;\n    paint();\n    setSeconds(Math.ceil(duration / 1000));\n\n    let frame = 0;\n    let last = performance.now();\n    const tick = (now: number) => {\n      // Hovering the button, or leaving the tab, holds the clock: nobody\n      // should lose their chance to undo while reading it.\n      if (!(hoveredRef.current || document.hidden)) {\n        elapsedRef.current += now - last;\n      }\n      last = now;\n      const left = Math.max(0, duration - elapsedRef.current);\n      remainingRef.current = left / duration;\n      paint();\n      setSeconds(Math.ceil(left / 1000));\n      if (left === 0) {\n        setPhase(\"done\");\n        callbacks.current.onCommit?.();\n        return;\n      }\n      frame = requestAnimationFrame(tick);\n    };\n    frame = requestAnimationFrame(tick);\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      const mod = event.metaKey || event.ctrlKey;\n      if (\n        mod &&\n        !event.shiftKey &&\n        event.key.toLowerCase() === \"z\" &&\n        !isEditable(event.target)\n      ) {\n        event.preventDefault();\n        undo();\n      }\n    };\n    window.addEventListener(\"keydown\", onKeyDown);\n\n    return () => {\n      cancelAnimationFrame(frame);\n      window.removeEventListener(\"keydown\", onKeyDown);\n    };\n  }, [phase, duration, paint, undo]);\n\n  useEffect(() => {\n    if (phase !== \"done\" || resetAfter === null) {\n      return;\n    }\n    const timer = setTimeout(() => setPhase(\"idle\"), resetAfter);\n    return () => clearTimeout(timer);\n  }, [phase, resetAfter]);\n\n  const handleClick = () => {\n    if (phase === \"idle\") {\n      setPhase(\"pending\");\n      callbacks.current.onAction?.();\n    } else if (phase === \"pending\") {\n      undo();\n    }\n  };\n\n  const setHovered = (event: PointerEvent, value: boolean) => {\n    if (event.pointerType === \"mouse\") {\n      hoveredRef.current = value;\n    }\n  };\n\n  const swap = reduceMotion\n    ? { duration: 0 }\n    : { duration: 0.22, ease: EASE_OUT };\n\n  const ariaLabel = {\n    idle: label,\n    pending: `${doneLabel}. Undo, ${seconds} second${seconds === 1 ? \"\" : \"s\"} left`,\n    done: doneLabel,\n  }[phase];\n\n  return (\n    <span className=\"relative inline-flex\">\n      <motion.button\n        animate={{ width: width ?? \"auto\" }}\n        aria-disabled={phase === \"done\"}\n        aria-label={ariaLabel}\n        className={cn(\n          \"relative inline-flex h-10 select-none items-center justify-center overflow-hidden whitespace-nowrap rounded-full border font-medium text-sm outline-none\",\n          \"transition-[background-color,border-color,color] duration-150 ease-out\",\n          \"focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-950\",\n          phase === \"done\"\n            ? \"cursor-default border-zinc-200 bg-zinc-50 text-zinc-500 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400\"\n            : \"border-zinc-200 bg-white text-zinc-900 hover:bg-zinc-50 active:bg-zinc-100 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100 dark:active:bg-zinc-800 dark:hover:bg-zinc-900\",\n          className\n        )}\n        initial={false}\n        onClick={handleClick}\n        onPointerEnter={(event) => setHovered(event, true)}\n        onPointerLeave={(event) => setHovered(event, false)}\n        ref={buttonRef}\n        transition={swap}\n        type=\"button\"\n      >\n        <span className=\"inline-flex px-4\" ref={contentRef}>\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              className=\"inline-flex items-center gap-2\"\n              exit={{ opacity: 0, y: -8, filter: \"blur(2px)\" }}\n              initial={{ opacity: 0, y: 8, filter: \"blur(2px)\" }}\n              key={phase}\n              transition={swap}\n            >\n              {phase === \"idle\" && (\n                <>\n                  <Icon aria-hidden className=\"size-4\" />\n                  {label}\n                </>\n              )}\n              {phase === \"pending\" && (\n                <>\n                  <span className=\"text-zinc-500 dark:text-zinc-400\">\n                    {doneLabel}\n                  </span>\n                  <span\n                    aria-hidden\n                    className=\"h-4 w-px bg-zinc-200 dark:bg-zinc-800\"\n                  />\n                  <Undo2 aria-hidden className=\"size-4\" />\n                  Undo\n                  <Countdown reduceMotion={!!reduceMotion} value={seconds} />\n                  <kbd className=\"hidden rounded border border-zinc-200 px-1 font-sans text-[11px] text-zinc-500 sm:inline dark:border-zinc-800 dark:text-zinc-400\">\n                    {isMac ? \"⌘Z\" : \"Ctrl Z\"}\n                  </kbd>\n                </>\n              )}\n              {phase === \"done\" && (\n                <>\n                  <Check aria-hidden className=\"size-4\" />\n                  {doneLabel}\n                </>\n              )}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </motion.button>\n      <svg\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 size-full overflow-visible\"\n      >\n        <title>Time left to undo</title>\n        <motion.path\n          animate={{ opacity: phase === \"pending\" ? 1 : 0 }}\n          className=\"stroke-zinc-900 dark:stroke-zinc-100\"\n          fill=\"none\"\n          initial={false}\n          ref={pathRef}\n          strokeLinecap=\"round\"\n          strokeWidth={STROKE}\n          transition={{ duration: phase === \"pending\" ? 0.15 : 0.2 }}\n        />\n      </svg>\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {phase === \"pending\" &&\n          `${doneLabel}. Press the button or ${isMac ? \"Command\" : \"Control\"} Z to undo.`}\n        {phase === \"done\" && doneLabel}\n      </span>\n    </span>\n  );\n}\n\nfunction Countdown({\n  value,\n  reduceMotion,\n}: {\n  value: number;\n  reduceMotion: boolean;\n}) {\n  return (\n    <span className=\"relative inline-flex h-5 w-[1ch] justify-center overflow-hidden text-zinc-500 tabular-nums dark:text-zinc-400\">\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          animate={{ y: 0, opacity: 1 }}\n          className=\"leading-5\"\n          exit={{ y: -10, opacity: 0 }}\n          initial={{ y: 10, opacity: 0 }}\n          key={value}\n          transition={\n            reduceMotion ? { duration: 0 } : { duration: 0.2, ease: EASE_OUT }\n          }\n        >\n          {value}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n",
      "path": "/components/kokonutui/undo-button.tsx",
      "target": "components/kokonutui/undo-button.tsx"
    }
  ]
}