Skip to content

Commit 49d355f

Browse files
ryan-williamsclaude
andcommitted
fix(touch): defer tap-select so swipe-to-nav isn't hijacked
On touch, a tap on an unselected v-drag element used to immediately select it and `stopPropagation`, so a tap+slide became a body-drag from the first touch and swipe-to-nav never reached `useSwipeControls`. Defer the decision: track pointer movement and only select on pointerup if movement stayed under the threshold (a true tap); otherwise no-op and let the swipe handler take the gesture. No `stopPropagation`/`preventDefault` on the deferred path so events bubble and the browser's own scroll-degrades-to-tap still works. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 846b576 commit 49d355f

1 file changed

Lines changed: 49 additions & 0 deletions

File tree

packages/client/composables/useBodyDragHandler.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,55 @@ export function handleBodyDragPointerdown(ev: PointerEvent, opts: BodyDragHandle
7575
if (ev.pointerType === 'touch' && activeTouchCount.value > 1)
7676
return
7777

78+
// Touch tap-vs-swipe disambiguation. On touch, a tap+slide on an unselected element
79+
// used to (a) immediately select it and (b) `stopPropagation`, which hid the gesture
80+
// from `useSwipeControls` and turned what felt like a swipe-to-nav into a
81+
// body-drag-from-the-first-touch. Defer the decision: track movement, and on
82+
// pointerup with no significant movement, select (a true tap). Otherwise no-op and
83+
// let the swipe handler take over for slide nav. Touch drag is then a two-gesture
84+
// flow — tap to select, then tap-and-drag to move — which falls through to the
85+
// standard drag path below since `wasAlreadySelected` is now true.
86+
const isTouchTap = ev.pointerType === 'touch' && !isSelected(state) && !ev.shiftKey
87+
if (isTouchTap) {
88+
// Deliberately NO stopPropagation: events bubble to the swipe handler on the
89+
// slide root. NO preventDefault either, so the browser's own gesture handling
90+
// (vertical scroll degrading to a tap on the original spot) keeps working.
91+
const startX = ev.clientX
92+
const startY = ev.clientY
93+
const pointerId = ev.pointerId
94+
let moved = false
95+
function onMove(moveEv: PointerEvent) {
96+
if (moveEv.pointerId !== pointerId)
97+
return
98+
if (Math.hypot(moveEv.clientX - startX, moveEv.clientY - startY) >= TOUCH_MOVE_THRESHOLD_PX) {
99+
moved = true
100+
cleanup()
101+
}
102+
}
103+
function onUp(upEv: PointerEvent) {
104+
if (upEv.pointerId !== pointerId)
105+
return
106+
cleanup()
107+
if (!moved) {
108+
// True tap on the element — select it. Stop propagation so the tap doesn't
109+
// also reach an underlying anchor / click handler.
110+
upEv.preventDefault()
111+
upEv.stopPropagation()
112+
state.startDragging()
113+
}
114+
// If moved: do nothing. The swipe handler (or browser default) gets the gesture.
115+
}
116+
function cleanup() {
117+
document.removeEventListener('pointermove', onMove)
118+
document.removeEventListener('pointerup', onUp)
119+
document.removeEventListener('pointercancel', cleanup)
120+
}
121+
document.addEventListener('pointermove', onMove)
122+
document.addEventListener('pointerup', onUp)
123+
document.addEventListener('pointercancel', cleanup)
124+
return
125+
}
126+
78127
ev.preventDefault()
79128
ev.stopPropagation()
80129
// The image consumer (`<img v-drag>`) sits on top of an anchor that has its own

0 commit comments

Comments
 (0)