{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"hold-to-confirm","type":"registry:component","title":"Hold to Confirm","description":"Button for destructive actions that fills while you hold it and only fires once the fill completes. Letting go early drains it back quickly.","author":"Ryan","dependencies":["clsx","lucide-react","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/hold-to-confirm.tsx","type":"registry:component","target":"@components/motion/hold-to-confirm.tsx","content":"\"use client\";\n// easeui.dev/components/motion/hold-to-confirm\n\nimport { Check } from \"lucide-react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactNode,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/** How long the confirmed state stays before the button resets, in ms. */\nconst RESET_MS = 2000;\nconst EASE_OUT = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n\ntype HoldState = \"idle\" | \"holding\" | \"done\";\n\nexport interface HoldToConfirmProps {\n  /** Runs once the hold completes. */\n  onConfirm: () => void;\n  /** Button label, such as \"Hold to delete\". */\n  children: ReactNode;\n  /** Label shown with a check after confirming. Default \"Done\". */\n  confirmedLabel?: ReactNode;\n  /** How long to hold, in ms. Default 1500. */\n  duration?: number;\n  disabled?: boolean;\n  className?: string;\n}\n\nconst LABEL =\n  \"col-start-1 row-start-1 inline-flex items-center justify-center gap-1.5 whitespace-nowrap transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none\";\n\n/**\n * Both labels share one grid cell, so the button is always as wide as the\n * longer one and never jumps in size. They trade places with a crossfade.\n */\nfunction Labels({ done, idle, confirmed }: { done: boolean; idle: ReactNode; confirmed: ReactNode }) {\n  return (\n    <span className=\"grid\">\n      <span aria-hidden={done} className={cn(LABEL, done ? \"scale-[0.97] opacity-0\" : \"opacity-100\")}>\n        {idle}\n      </span>\n      <span aria-hidden={!done} className={cn(LABEL, done ? \"opacity-100\" : \"scale-[0.97] opacity-0\")}>\n        <Check aria-hidden=\"true\" className=\"h-4 w-4\" />\n        {confirmed}\n      </span>\n    </span>\n  );\n}\n\nexport function HoldToConfirm({\n  onConfirm,\n  children,\n  confirmedLabel = \"Done\",\n  duration = 1500,\n  disabled,\n  className,\n}: HoldToConfirmProps) {\n  const hintId = useId();\n  const [state, setState] = useState<HoldState>(\"idle\");\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  useEffect(\n    () => () => {\n      if (timer.current) clearTimeout(timer.current);\n    },\n    [],\n  );\n\n  const start = () => {\n    if (disabled || state !== \"idle\") return;\n    setState(\"holding\");\n    timer.current = setTimeout(() => {\n      setState(\"done\");\n      onConfirm();\n      timer.current = setTimeout(() => setState(\"idle\"), RESET_MS);\n    }, duration);\n  };\n\n  const release = () => {\n    if (state !== \"holding\") return;\n    if (timer.current) clearTimeout(timer.current);\n    timer.current = null;\n    setState(\"idle\");\n  };\n\n  const isConfirmKey = (event: KeyboardEvent) => event.key === \" \" || event.key === \"Enter\";\n  const done = state === \"done\";\n\n  // Holding fills at a steady rate so progress reads honestly. Letting go early drains fast,\n  // and the reset after confirming drains a little slower so the change never feels abrupt.\n  const fillTransition =\n    state === \"holding\"\n      ? `clip-path ${duration}ms linear`\n      : `clip-path ${done ? 150 : 300}ms ${EASE_OUT}`;\n\n  return (\n    <>\n      <button\n        type=\"button\"\n        disabled={disabled}\n        aria-describedby={hintId}\n        data-state={state}\n        onPointerDown={(event: PointerEvent<HTMLButtonElement>) => {\n          if (event.button !== 0) return;\n          // Keep receiving the release even if the finger slides off the button.\n          event.currentTarget.setPointerCapture(event.pointerId);\n          start();\n        }}\n        onPointerUp={release}\n        onPointerCancel={release}\n        onKeyDown={(event) => {\n          if (!isConfirmKey(event) || event.repeat) return;\n          event.preventDefault();\n          start();\n        }}\n        onKeyUp={(event) => {\n          if (!isConfirmKey(event)) return;\n          event.preventDefault();\n          release();\n        }}\n        // A long press on touch screens would otherwise open the context menu.\n        onContextMenu={(event) => event.preventDefault()}\n        className={cn(\n          \"relative inline-flex h-10 touch-manipulation select-none items-center justify-center overflow-hidden rounded-full bg-card px-5 text-sm font-medium text-foreground outline-none [-webkit-touch-callout:none]\",\n          \"shadow-[0_0_0_1px_var(--border-strong)] transition-[scale] duration-150 ease-out active:scale-[0.97] motion-reduce:active:scale-100\",\n          \"focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          done && \"pointer-events-none\",\n          className,\n        )}\n      >\n        <Labels done={done} idle={children} confirmed={confirmedLabel} />\n        {/* The fill is a copy of the labels in the accent color, revealed left to right. */}\n        <span\n          aria-hidden=\"true\"\n          className=\"absolute inset-0 flex items-center justify-center bg-accent px-5 text-accent-foreground\"\n          style={{\n            clipPath: state === \"idle\" ? \"inset(0 100% 0 0)\" : \"inset(0 0 0 0)\",\n            transition: fillTransition,\n          }}\n        >\n          <Labels done={done} idle={children} confirmed={confirmedLabel} />\n        </span>\n      </button>\n      <span id={hintId} className=\"sr-only\">\n        Press and hold to confirm\n      </span>\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {done ? confirmedLabel : null}\n      </span>\n    </>\n  );\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}