fix(web): swallow click after a swipe so dragging a row does not navigate

Repro: on the reminders list, click-and-drag a card to swipe — the
shelf opened AND the wrapped Link fired its click, so the operator
landed on the reminder detail page mid-swipe.

Track a dragMoved ref in SwipeableRow that flips true when the
pointer travels past the standard 6 px tap threshold. On pointerup,
if dragMoved is set, register a one-shot capture-phase click handler
on the row container that preventDefault + stopPropagation. The
synthetic click the browser fires on pointerup is intercepted before
it reaches the anchor's onClick, so the row stays put after a swipe
and a real tap (under 6 px movement) still navigates as before.

A 350ms safety timeout strips the listener if no click materialises
(pointerup landed outside the element) so a later legitimate click
isn't accidentally swallowed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 19:54:31 +08:00
parent b293bbf142
commit 991b7ae0ab

View File

@ -67,6 +67,11 @@ export function SwipeableRow({
const [dragging, setDragging] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const dragStart = useRef<{ x: number; baseOffset: number } | null>(null);
// Tracks whether the pointer crossed the click-vs-drag threshold during
// the current gesture. If it did, we swallow the synthetic click that
// browsers fire on pointerup — otherwise a swipe on a Link-wrapped row
// both swipes the shelf open AND navigates to the link target.
const dragMoved = useRef(false);
// Close the shelf when the user taps anywhere outside an open row.
useEffect(() => {
@ -92,12 +97,17 @@ export function SwipeableRow({
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
if (e.button !== 0 && e.pointerType === "mouse") return;
dragStart.current = { x: e.clientX, baseOffset: offset };
dragMoved.current = false;
setDragging(true);
}
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!dragging || !dragStart.current) return;
const dx = e.clientX - dragStart.current.x;
// 6 px is the standard threshold below which a touch counts as a tap
// rather than a drag. Cross it once and the gesture commits to drag
// for the rest of the pointer's lifetime.
if (Math.abs(dx) > 6) dragMoved.current = true;
setOffset(clamp(dragStart.current.baseOffset + dx));
}
@ -113,6 +123,28 @@ export function SwipeableRow({
rightWidth,
}),
);
if (dragMoved.current) {
// The browser fires a synthetic `click` on the element under the
// pointer right after pointerup. If our row body wraps a <Link>,
// that click navigates away. Add a one-shot capture-phase handler
// that swallows the next click ANYWHERE in the row container
// before it can reach the anchor's onClick.
const swallow = (ev: Event) => {
ev.preventDefault();
ev.stopPropagation();
};
const node = containerRef.current;
if (node) {
node.addEventListener("click", swallow, { capture: true, once: true });
// Defensive: if for some reason no click fires (e.g. pointerup
// outside the element), strip the listener after a tick so it
// doesn't accidentally eat a future legitimate click.
window.setTimeout(() => {
node.removeEventListener("click", swallow, { capture: true });
}, 350);
}
}
dragMoved.current = false;
}
return (