"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}
{confirmed}
);
}
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}
>
);
}