Files
desktop-app/components/confirm-button.tsx
T

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>
);
};