"use client"; import { Check } from "lucide-react"; import { type KeyboardEvent, type PointerEvent, type ReactNode, useEffect, useId, useRef, useState, } from "react"; import { cn } from "@/lib/utils"; /** How long the confirmed state stays before the button resets, in ms. */ const RESET_MS = 2000; const EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)"; type HoldState = "idle" | "holding" | "done"; export interface HoldToConfirmProps { /** Runs once the hold completes. */ onConfirm: () => void; /** Button label, such as "Hold to delete". */ children: ReactNode; /** Label shown with a check after confirming. Default "Done". */ confirmedLabel?: ReactNode; /** How long to hold, in ms. Default 1500. */ duration?: number; disabled?: boolean; className?: string; } const LABEL = "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"; /** * Both labels share one grid cell, so the button is always as wide as the * longer one and never jumps in size. They trade places with a crossfade. */ function Labels({ done, idle, confirmed }: { done: boolean; idle: ReactNode; confirmed: ReactNode }) { return ( {idle} ); } export function HoldToConfirm({ onConfirm, children, confirmedLabel = "Done", duration = 1500, disabled, className, }: HoldToConfirmProps) { const hintId = useId(); const [state, setState] = useState("idle"); const timer = useRef | null>(null); useEffect( () => () => { if (timer.current) clearTimeout(timer.current); }, [], ); const start = () => { if (disabled || state !== "idle") return; setState("holding"); timer.current = setTimeout(() => { setState("done"); onConfirm(); timer.current = setTimeout(() => setState("idle"), RESET_MS); }, duration); }; const release = () => { if (state !== "holding") return; if (timer.current) clearTimeout(timer.current); timer.current = null; setState("idle"); }; const isConfirmKey = (event: KeyboardEvent) => event.key === " " || event.key === "Enter"; const done = state === "done"; // Holding fills at a steady rate so progress reads honestly. Letting go early drains fast, // and the reset after confirming drains a little slower so the change never feels abrupt. const fillTransition = state === "holding" ? `clip-path ${duration}ms linear` : `clip-path ${done ? 150 : 300}ms ${EASE_OUT}`; return ( <> Press and hold to confirm {done ? confirmedLabel : null} ); }