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:
parent
b293bbf142
commit
991b7ae0ab
@ -67,6 +67,11 @@ export function SwipeableRow({
|
|||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const dragStart = useRef<{ x: number; baseOffset: number } | 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.
|
// Close the shelf when the user taps anywhere outside an open row.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -92,12 +97,17 @@ export function SwipeableRow({
|
|||||||
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
|
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
|
||||||
if (e.button !== 0 && e.pointerType === "mouse") return;
|
if (e.button !== 0 && e.pointerType === "mouse") return;
|
||||||
dragStart.current = { x: e.clientX, baseOffset: offset };
|
dragStart.current = { x: e.clientX, baseOffset: offset };
|
||||||
|
dragMoved.current = false;
|
||||||
setDragging(true);
|
setDragging(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
||||||
if (!dragging || !dragStart.current) return;
|
if (!dragging || !dragStart.current) return;
|
||||||
const dx = e.clientX - dragStart.current.x;
|
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));
|
setOffset(clamp(dragStart.current.baseOffset + dx));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,6 +123,28 @@ export function SwipeableRow({
|
|||||||
rightWidth,
|
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 (
|
return (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user