163 lines
4.0 KiB
TypeScript
163 lines
4.0 KiB
TypeScript
"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<HTMLButtonElement>(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 (
|
|
<motion.button
|
|
className="PressToConfirm"
|
|
ref={ref}
|
|
onPointerDown={startCountdown}
|
|
onPointerUp={pointerUp}
|
|
onPointerCancel={cancelCountdown}
|
|
onPointerLeave={(e) => {
|
|
// 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"}
|
|
>
|
|
<motion.div
|
|
className="filler"
|
|
style={{
|
|
left: fillLeftOffset,
|
|
right: fillRightOffset,
|
|
}}
|
|
/>
|
|
<AnimatePresence custom={textDirection} initial={false} mode="popLayout">
|
|
<motion.div
|
|
key={text}
|
|
className="text"
|
|
variants={textVariants}
|
|
custom={textDirection}
|
|
initial="initial"
|
|
animate="target"
|
|
exit="exit"
|
|
>
|
|
{text}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</motion.button>
|
|
);
|
|
};
|