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
192 lines
5.1 KiB
JavaScript
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>
|
|
);
|
|
}
|