170 lines
6.8 KiB
TypeScript
170 lines
6.8 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
type ConfirmSlideModalProps = {
|
|
isOpen: boolean;
|
|
title: string;
|
|
description?: string;
|
|
confirmLabel?: string;
|
|
onClose: () => void;
|
|
onConfirm: () => void;
|
|
};
|
|
|
|
export default function ConfirmSlideModal({
|
|
isOpen,
|
|
title,
|
|
description,
|
|
confirmLabel = "Confirm",
|
|
onClose,
|
|
onConfirm
|
|
}: ConfirmSlideModalProps) {
|
|
const trackRef = useRef<HTMLDivElement | null>(null);
|
|
const endFlashTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const reachedEndRef = useRef(false);
|
|
const [dragX, setDragX] = useState(0);
|
|
const [dragging, setDragging] = useState(false);
|
|
const [isAtEnd, setIsAtEnd] = useState(false);
|
|
const [endFlash, setEndFlash] = useState(false);
|
|
const handleSize = 40;
|
|
|
|
function getDragPositionFromClientX(clientX: number) {
|
|
const track = trackRef.current;
|
|
if (!track) return 0;
|
|
const rect = track.getBoundingClientRect();
|
|
return Math.min(Math.max(0, clientX - rect.left - handleSize / 2), rect.width - handleSize);
|
|
}
|
|
|
|
function isEndPosition(position: number) {
|
|
const track = trackRef.current;
|
|
if (!track) return false;
|
|
const maxDrag = track.clientWidth - handleSize;
|
|
const endTolerancePx = 1;
|
|
return position >= maxDrag - endTolerancePx;
|
|
}
|
|
|
|
function triggerEndFeedback() {
|
|
setEndFlash(true);
|
|
if (endFlashTimeoutRef.current) clearTimeout(endFlashTimeoutRef.current);
|
|
endFlashTimeoutRef.current = setTimeout(() => setEndFlash(false), 140);
|
|
|
|
if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
|
|
navigator.vibrate(16);
|
|
}
|
|
}
|
|
|
|
function handlePointerDown(event: React.PointerEvent<HTMLButtonElement>) {
|
|
event.preventDefault();
|
|
setDragging(true);
|
|
reachedEndRef.current = false;
|
|
setIsAtEnd(false);
|
|
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
|
}
|
|
|
|
function handlePointerMove(event: React.PointerEvent<HTMLButtonElement>) {
|
|
if (!dragging) return;
|
|
const next = getDragPositionFromClientX(event.clientX);
|
|
const nextAtEnd = isEndPosition(next);
|
|
|
|
setDragX(next);
|
|
setIsAtEnd(prev => (prev === nextAtEnd ? prev : nextAtEnd));
|
|
|
|
if (nextAtEnd && !reachedEndRef.current) {
|
|
reachedEndRef.current = true;
|
|
triggerEndFeedback();
|
|
}
|
|
if (!nextAtEnd) reachedEndRef.current = false;
|
|
}
|
|
|
|
function handlePointerUp(event: React.PointerEvent<HTMLButtonElement>) {
|
|
if (!dragging) return;
|
|
setDragging(false);
|
|
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
|
|
const track = trackRef.current;
|
|
if (!track) return;
|
|
const releaseX = getDragPositionFromClientX(event.clientX);
|
|
const releaseAtEnd = isEndPosition(releaseX);
|
|
|
|
setIsAtEnd(prev => (prev ? false : prev));
|
|
|
|
if (releaseAtEnd && !reachedEndRef.current) {
|
|
triggerEndFeedback();
|
|
}
|
|
|
|
if (releaseAtEnd) {
|
|
setDragX(0);
|
|
onConfirm();
|
|
} else {
|
|
setDragX(0);
|
|
}
|
|
}
|
|
|
|
function handlePointerCancel(event: React.PointerEvent<HTMLButtonElement>) {
|
|
if (!dragging) return;
|
|
setDragging(false);
|
|
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
|
|
setIsAtEnd(prev => (prev ? false : prev));
|
|
setDragX(0);
|
|
}
|
|
|
|
useEffect(() => () => {
|
|
if (endFlashTimeoutRef.current) clearTimeout(endFlashTimeoutRef.current);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
function handleKeyDown(event: KeyboardEvent) {
|
|
if (event.key === "Escape") onClose();
|
|
}
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [isOpen, onClose]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={onClose}>
|
|
<div className="w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5" onClick={event => event.stopPropagation()}>
|
|
<div className="text-lg font-semibold">{title}</div>
|
|
{description ? <p className="mt-2 text-sm text-muted">{description}</p> : null}
|
|
<div className="mt-4">
|
|
<div className="text-xs text-soft">Slide to confirm</div>
|
|
<div
|
|
ref={trackRef}
|
|
className={`relative mx-auto mt-2 h-10 w-4/5 overflow-hidden rounded-full border touch-none select-none transition-colors ${isAtEnd || endFlash ? "border-accent bg-accent-soft" : "border-accent-weak bg-surface"}`}
|
|
>
|
|
<div
|
|
className="absolute inset-y-0 left-0 rounded-full bg-accent-soft"
|
|
style={{ width: dragX + handleSize }}
|
|
/>
|
|
<div
|
|
className={`pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs font-semibold text-[color:var(--color-accent)] transition-all ${isAtEnd || endFlash ? "scale-100 opacity-100" : "scale-90 opacity-0"}`}
|
|
aria-hidden="true"
|
|
>
|
|
click
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={`absolute left-0 top-0 h-10 w-10 rounded-full border bg-panel text-lg font-semibold leading-none text-[color:var(--color-text)] touch-none select-none will-change-transform transition-[border-color,box-shadow] duration-100 ${isAtEnd || endFlash ? "border-accent-strong shadow-[0_0_0_2px_var(--color-accent-focus)]" : "border-accent"}`}
|
|
style={{ transform: `translateX(${dragX}px)` }}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp}
|
|
onPointerCancel={handlePointerCancel}
|
|
aria-label="Slide to confirm"
|
|
>
|
|
▸
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 flex items-center justify-between">
|
|
<div className="text-xs text-soft">{confirmLabel}</div>
|
|
<button type="button" className="rounded-lg btn-outline-accent px-3 py-1.5 text-sm" onClick={onClose}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|