222 lines
6.9 KiB
TypeScript
222 lines
6.9 KiB
TypeScript
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
import { Archive } from "lucide-react";
|
|
import { cn } from "../lib/utils";
|
|
|
|
interface SwipeToArchiveProps {
|
|
children: ReactNode;
|
|
onArchive: () => void;
|
|
disabled?: boolean;
|
|
selected?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
const COMMIT_THRESHOLD = 0.32;
|
|
const MAX_SWIPE = 0.88;
|
|
const COMMIT_DELAY_MS = 140;
|
|
|
|
export function SwipeToArchive({
|
|
children,
|
|
onArchive,
|
|
disabled = false,
|
|
selected = false,
|
|
className,
|
|
}: SwipeToArchiveProps) {
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const startPointRef = useRef<{ x: number; y: number } | null>(null);
|
|
const widthRef = useRef(0);
|
|
const archiveTimeoutRef = useRef<number | null>(null);
|
|
const suppressClickTimeoutRef = useRef<number | null>(null);
|
|
const suppressClickRef = useRef(false);
|
|
const offsetXRef = useRef(0);
|
|
const [offsetX, setOffsetX] = useState(0);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [isCollapsing, setIsCollapsing] = useState(false);
|
|
const [lockedHeight, setLockedHeight] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (archiveTimeoutRef.current !== null) window.clearTimeout(archiveTimeoutRef.current);
|
|
if (suppressClickTimeoutRef.current !== null) window.clearTimeout(suppressClickTimeoutRef.current);
|
|
archiveTimeoutRef.current = null;
|
|
suppressClickTimeoutRef.current = null;
|
|
startPointRef.current = null;
|
|
suppressClickRef.current = false;
|
|
};
|
|
}, []);
|
|
|
|
const clearArchiveTimeout = () => {
|
|
if (archiveTimeoutRef.current === null) return;
|
|
window.clearTimeout(archiveTimeoutRef.current);
|
|
archiveTimeoutRef.current = null;
|
|
};
|
|
|
|
const clearClickSuppressionTimeout = () => {
|
|
if (suppressClickTimeoutRef.current === null) return;
|
|
window.clearTimeout(suppressClickTimeoutRef.current);
|
|
suppressClickTimeoutRef.current = null;
|
|
};
|
|
|
|
const clearClickSuppression = () => {
|
|
clearClickSuppressionTimeout();
|
|
suppressClickRef.current = false;
|
|
};
|
|
|
|
const releaseClickSuppressionSoon = () => {
|
|
clearClickSuppressionTimeout();
|
|
suppressClickTimeoutRef.current = window.setTimeout(() => {
|
|
suppressClickRef.current = false;
|
|
suppressClickTimeoutRef.current = null;
|
|
}, 350);
|
|
};
|
|
|
|
const reset = () => {
|
|
startPointRef.current = null;
|
|
offsetXRef.current = 0;
|
|
setIsDragging(false);
|
|
setIsCollapsing(false);
|
|
setLockedHeight(null);
|
|
setOffsetX(0);
|
|
};
|
|
|
|
const commitArchive = () => {
|
|
clearArchiveTimeout();
|
|
const node = containerRef.current;
|
|
if (!node) {
|
|
onArchive();
|
|
return;
|
|
}
|
|
startPointRef.current = null;
|
|
setIsDragging(false);
|
|
setLockedHeight(node.offsetHeight);
|
|
const commitOffset = -Math.max(widthRef.current, node.offsetWidth);
|
|
offsetXRef.current = commitOffset;
|
|
setOffsetX(commitOffset);
|
|
window.requestAnimationFrame(() => {
|
|
window.requestAnimationFrame(() => {
|
|
setIsCollapsing(true);
|
|
});
|
|
});
|
|
archiveTimeoutRef.current = window.setTimeout(() => {
|
|
archiveTimeoutRef.current = null;
|
|
onArchive();
|
|
}, COMMIT_DELAY_MS);
|
|
};
|
|
|
|
const handleTouchStart = (event: React.TouchEvent<HTMLDivElement>) => {
|
|
if (disabled || isCollapsing || archiveTimeoutRef.current !== null || event.touches.length !== 1) return;
|
|
clearArchiveTimeout();
|
|
clearClickSuppression();
|
|
const touch = event.touches[0];
|
|
const node = containerRef.current;
|
|
widthRef.current = node?.offsetWidth ?? 0;
|
|
setLockedHeight(node?.offsetHeight ?? null);
|
|
setIsCollapsing(false);
|
|
suppressClickRef.current = false;
|
|
startPointRef.current = { x: touch.clientX, y: touch.clientY };
|
|
};
|
|
|
|
const handleTouchMove = (event: React.TouchEvent<HTMLDivElement>) => {
|
|
if (disabled || isCollapsing) return;
|
|
const startPoint = startPointRef.current;
|
|
if (!startPoint || event.touches.length !== 1) return;
|
|
|
|
const touch = event.touches[0];
|
|
const deltaX = touch.clientX - startPoint.x;
|
|
const deltaY = touch.clientY - startPoint.y;
|
|
|
|
if (!isDragging) {
|
|
if (Math.abs(deltaX) < 6) return;
|
|
if (Math.abs(deltaY) > Math.abs(deltaX)) {
|
|
startPointRef.current = null;
|
|
return;
|
|
}
|
|
suppressClickRef.current = true;
|
|
}
|
|
|
|
if (deltaX >= 0) {
|
|
event.preventDefault();
|
|
setIsDragging(true);
|
|
offsetXRef.current = 0;
|
|
setOffsetX(0);
|
|
return;
|
|
}
|
|
|
|
const maxSwipe = widthRef.current > 0 ? widthRef.current * MAX_SWIPE : Number.POSITIVE_INFINITY;
|
|
event.preventDefault();
|
|
setIsDragging(true);
|
|
const nextOffset = Math.max(deltaX, -maxSwipe);
|
|
offsetXRef.current = nextOffset;
|
|
setOffsetX(nextOffset);
|
|
};
|
|
|
|
const handleTouchEnd = () => {
|
|
if (disabled || isCollapsing || archiveTimeoutRef.current !== null) return;
|
|
const shouldCommit =
|
|
widthRef.current > 0 && Math.abs(offsetXRef.current) >= widthRef.current * COMMIT_THRESHOLD;
|
|
if (shouldCommit) {
|
|
releaseClickSuppressionSoon();
|
|
commitArchive();
|
|
return;
|
|
}
|
|
if (isDragging) releaseClickSuppressionSoon();
|
|
reset();
|
|
};
|
|
|
|
const handleTouchCancel = () => {
|
|
if (disabled || isCollapsing || archiveTimeoutRef.current !== null) return;
|
|
clearClickSuppression();
|
|
reset();
|
|
};
|
|
|
|
const archiveReveal = widthRef.current > 0 ? Math.min(Math.abs(offsetX) / widthRef.current, 1) : 0;
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn("relative overflow-hidden touch-pan-y", className)}
|
|
style={{
|
|
height: lockedHeight === null ? undefined : isCollapsing ? 0 : lockedHeight,
|
|
opacity: isCollapsing ? 0 : 1,
|
|
transition: isCollapsing ? "height 200ms ease, opacity 200ms ease" : undefined,
|
|
}}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
onTouchCancel={handleTouchCancel}
|
|
onClickCapture={(event) => {
|
|
if (!suppressClickRef.current) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
clearClickSuppression();
|
|
}}
|
|
>
|
|
<div
|
|
aria-hidden="true"
|
|
// Visible only while actually swiping: the selected row surface is
|
|
// translucent (matching the task-list hover), so a resting opacity
|
|
// floor would tint the whole row green and ghost the label through.
|
|
className="pointer-events-none absolute inset-0 flex items-center justify-end rounded-lg bg-emerald-600 px-4 text-white"
|
|
style={{ opacity: archiveReveal }}
|
|
>
|
|
<span className="inline-flex items-center gap-2 text-sm font-medium">
|
|
<Archive className="h-4 w-4" />
|
|
Archive
|
|
</span>
|
|
</div>
|
|
<div
|
|
data-inbox-row-surface
|
|
className={cn(
|
|
"relative will-change-transform",
|
|
selected ? "rounded-lg bg-accent/50" : "bg-background",
|
|
)}
|
|
style={{
|
|
transform: `translate3d(${offsetX}px, 0, 0)`,
|
|
transition: isDragging ? "none" : "transform 180ms ease-out",
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|