grocery-app/frontend/src/components/modals/ConfirmSlideModal.jsx
Nico 77ae5be445
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 1m10s
Build & Deploy Costco Grocery List / verify-images (push) Successful in 3s
Build & Deploy Costco Grocery List / deploy (push) Successful in 11s
Build & Deploy Costco Grocery List / notify (push) Successful in 1s
refactor
2026-02-22 01:27:03 -08:00

192 lines
5.1 KiB
JavaScript

import { useEffect, useRef, useState } from "react";
import "../../styles/components/ConfirmSlideModal.css";
const HANDLE_SIZE = 40;
export default function ConfirmSlideModal({
isOpen,
title,
description,
confirmLabel = "Confirm",
onClose,
onConfirm
}) {
const trackRef = useRef(null);
const endFlashTimeoutRef = useRef(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 getDragPositionFromClientX = (clientX) => {
const track = trackRef.current;
if (!track) return 0;
const rect = track.getBoundingClientRect();
return Math.min(
Math.max(0, clientX - rect.left - HANDLE_SIZE / 2),
rect.width - HANDLE_SIZE
);
};
const isEndPosition = (position) => {
const track = trackRef.current;
if (!track) return false;
const maxDrag = track.clientWidth - HANDLE_SIZE;
const endTolerancePx = 1;
return position >= maxDrag - endTolerancePx;
};
const 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);
}
};
const handlePointerDown = (event) => {
event.preventDefault();
setDragging(true);
reachedEndRef.current = false;
setIsAtEnd(false);
event.currentTarget.setPointerCapture(event.pointerId);
};
const handlePointerMove = (event) => {
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;
}
};
const handlePointerUp = (event) => {
if (!dragging) return;
setDragging(false);
event.currentTarget.releasePointerCapture(event.pointerId);
const releaseX = getDragPositionFromClientX(event.clientX);
const releaseAtEnd = isEndPosition(releaseX);
setIsAtEnd((prev) => (prev ? false : prev));
if (releaseAtEnd && !reachedEndRef.current) {
triggerEndFeedback();
}
setDragX(0);
if (releaseAtEnd) {
onConfirm();
}
};
const handlePointerCancel = (event) => {
if (!dragging) return;
setDragging(false);
event.currentTarget.releasePointerCapture(event.pointerId);
setIsAtEnd((prev) => (prev ? false : prev));
setDragX(0);
};
useEffect(() => {
return () => {
if (endFlashTimeoutRef.current) {
clearTimeout(endFlashTimeoutRef.current);
}
};
}, []);
useEffect(() => {
if (!isOpen) return undefined;
const handleKeyDown = (event) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
useEffect(() => {
if (!isOpen) {
setDragX(0);
setDragging(false);
setIsAtEnd(false);
setEndFlash(false);
reachedEndRef.current = false;
}
}, [isOpen]);
if (!isOpen) return null;
const isActive = isAtEnd || endFlash;
return (
<div className="confirm-slide-overlay" onClick={onClose}>
<div className="confirm-slide-modal" onClick={(event) => event.stopPropagation()}>
<h2 className="confirm-slide-title">{title}</h2>
{description ? <p className="confirm-slide-description">{description}</p> : null}
<div className="confirm-slide-track-wrap">
<div className="confirm-slide-helper">Slide to confirm</div>
<div
ref={trackRef}
className={`confirm-slide-track ${isActive ? "is-active" : ""}`}
>
<div
className="confirm-slide-progress"
style={{ width: dragX + HANDLE_SIZE }}
/>
<div className={`confirm-slide-ready ${isActive ? "is-visible" : ""}`}>
release
</div>
<button
type="button"
className={`confirm-slide-handle ${isActive ? "is-active" : ""}`}
style={{ transform: `translateX(${dragX}px)` }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
aria-label="Slide to confirm"
>
>
</button>
</div>
</div>
<div className="confirm-slide-footer">
<span className="confirm-slide-label">{confirmLabel}</span>
<button
type="button"
className="confirm-slide-cancel btn btn-outline"
onClick={onClose}
>
Cancel
</button>
</div>
</div>
</div>
);
}