"use client"; import { useRef, useState, PointerEvent } from "react"; import { AnimatePresence, Variants, animate, motion, useMotionValue, useTransform, } from "framer-motion"; import "@/app/hold-confirm.css"; type Direction = "back" | "forward"; const textVariants: Variants = { initial: (direction: Direction) => ({ y: direction === "forward" ? "-30%" : "30%", opacity: 0, }), target: { y: "0%", opacity: 1, }, exit: (direction: Direction) => ({ y: direction === "forward" ? "30%" : "-30%", opacity: 0, }), }; const buttonVariants: Variants = { idle: { x: 0, rotate: 0, transition: { duration: 0.1, }, }, }; type HoldToConfirmProps = { text: string; confirmTimeout?: number; onConfirm?: () => void; }; export const ConfirmButton = ({ text: textFromProps, confirmTimeout = 2, onConfirm, }: HoldToConfirmProps) => { const startCountdown = () => { setState("inProgress"); const pattern = new Array(confirmTimeout * 10) .fill(0) .map((_, ind) => (ind % 2 === 0 ? 100 : 50)); animate(progress, 1, { duration: confirmTimeout, ease: "linear" }).then( () => { if (progress.get() !== 1) return; setState("complete"); }, ); }; const cancelCountdown = () => { progress.stop(); setState("idle"); animate(progress, 0, { duration: 0.2, ease: "linear" }); }; const pointerUp = (e: PointerEvent) => { const target = document.elementFromPoint(e.clientX, e.clientY); if (progress.get() === 1 && ref.current?.contains(target)) { animate(fillerConfirmAnimationProgress, 1, { duration: 0.2, ease: "linear", }).then(() => { fillerConfirmAnimationProgress.jump(0); progress.jump(0); setState("idle"); onConfirm?.(); }); } else { cancelCountdown(); } }; const pointerMove = (e: PointerEvent) => { // Mouse will be handled by onPointerLeave if (e.pointerType === "mouse") return; const target = document.elementFromPoint(e.clientX, e.clientY); if (!ref.current?.contains(target)) { cancelCountdown(); } }; const [state, setState] = useState<"idle" | "inProgress" | "complete">( "idle", ); const ref = useRef(null); const progress = useMotionValue(0); const fillRightOffset = useTransform(progress, (v) => `${(1 - v) * 100}%`); // This is used in 'completion' animation const fillerConfirmAnimationProgress = useMotionValue(0); const fillLeftOffset = useTransform( fillerConfirmAnimationProgress, (v) => `${v * 100}%`, ); const text = state === "idle" ? textFromProps : state === "inProgress" ? "Hold to confirm" : "Release to confirm"; const textDirection: Direction = state === "idle" ? "back" : "forward"; return ( { // For touchscreen browser always generates PointerLeave at // the end of touch, even if it ended on the element, so // we handle only mouse leave here if (e.pointerType === "mouse") cancelCountdown(); }} onPointerMove={pointerMove} // Prevent context menu on mobiles (caused by long touch) onContextMenuCapture={(e) => e.preventDefault()} variants={buttonVariants} animate={state === "inProgress" ? "shaking" : "idle"} > {text} ); };