From 8cb12b95a943fb58f3be4e7b895e3b15c99e119d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 22 Jul 2026 15:16:02 -0700 Subject: [PATCH] fix(dashboard): stop mobile board jump on tap during momentum Re-touch while the board is coasting now cancels the pending directional settle and re-baselines the gesture at the current scrollLeft, so a stop-tap no longer hard-jumps using the original swipe. --- .../__tests__/useColumnScrollSnap.test.ts | 57 +++++++++++++++++++ .../app/hooks/useColumnScrollSnap.ts | 28 +++++++++ 2 files changed, 85 insertions(+) diff --git a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts index cd531d998f..f05a088bde 100644 --- a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts @@ -343,6 +343,63 @@ describe("useColumnScrollSnap", () => { expect(scroller.scrollLeft).toBe(0); }); + /* + FNXC:BoardNavigation 2026-07-22-15:10: + Tap-to-stop during post-lift momentum must not hard-jump using the original swipe direction. + */ + it("does not jump when the user taps during momentum after a swipe", () => { + const scroller = createScroller(3, 0); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => { + // Start a forward swipe and lift so the idle settle is armed. + dispatchPointerEvent(scroller, "pointerdown", 200); + dispatchPointerEvent(scroller, "pointermove", 160); + scroller.scrollLeft = 30; + scroller.dispatchEvent(new Event("scroll")); + dispatchPointerEvent(scroller, "pointerup", 160); + + // Coast a bit more (native fling), then the user taps to stop mid-travel. + scroller.scrollLeft = 55; + scroller.dispatchEvent(new Event("scroll")); + dispatchPointerEvent(scroller, "pointerdown", 100); + dispatchPointerEvent(scroller, "pointerup", 100); + }); + + // Without re-baselining the second touch, the original rightward settle would jump to COLUMN_WIDTH. + settleAfterMomentum(); + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(scroller.scrollLeft).toBe(55); + }); + + it("starts a new directional settle after a pan that continues from a mid-momentum re-touch", () => { + const scroller = createScroller(3, 0); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => { + dispatchPointerEvent(scroller, "pointerdown", 200); + dispatchPointerEvent(scroller, "pointermove", 160); + scroller.scrollLeft = 30; + scroller.dispatchEvent(new Event("scroll")); + dispatchPointerEvent(scroller, "pointerup", 160); + + // Interrupt fling, then pan back left so settle must use the new gesture only. + scroller.scrollLeft = 55; + scroller.dispatchEvent(new Event("scroll")); + dispatchPointerEvent(scroller, "pointerdown", 100); + dispatchPointerEvent(scroller, "pointermove", 140); + scroller.scrollLeft = 20; + scroller.dispatchEvent(new Event("scroll")); + dispatchPointerEvent(scroller, "pointerup", 140); + }); + settleAfterMomentum(); + + expect(scroller.scrollLeft).toBe(0); + }); + it("does not attach on non-phone desktop", () => { stubViewport("wide-short-desktop"); expect(isMobileViewport()).toBe(false); diff --git a/packages/dashboard/app/hooks/useColumnScrollSnap.ts b/packages/dashboard/app/hooks/useColumnScrollSnap.ts index e74c58cff9..ae6f2d4b5f 100644 --- a/packages/dashboard/app/hooks/useColumnScrollSnap.ts +++ b/packages/dashboard/app/hooks/useColumnScrollSnap.ts @@ -8,6 +8,11 @@ rubber-band/fling end often reverses for a frame — and (2) origin±nearest hyb Direction is locked at finger-up from net gesture delta only (never post-lift ticks). Target is always the next column in that scroll direction from the current viewport (classic directional page snap). Pin until next touch; hard-jump kills residual fling. + +FNXC:BoardNavigation 2026-07-22-15:10: +A tap during post-lift momentum must cancel the pending directional settle and re-baseline +the gesture at the current scrollLeft (pointerHeld true). Otherwise the original swipe's +idle timer still hard-jumps the board away from where the user stopped. */ /** After lift/cancel/wheel: wait for scroll idle (momentum finished) before paging. */ const SCROLL_IDLE_SETTLE_MS = 48; @@ -389,12 +394,35 @@ export function useColumnScrollSnap( idleTimer = setTimeout(snapInScrollDirection, SCROLL_IDLE_SETTLE_MS); }; + /* + FNXC:BoardNavigation 2026-07-22-15:10: + A second touch during post-lift momentum must cancel the pending directional settle and start a fresh gesture at the current scrollLeft. + Previously, re-touch while interactionActive only re-captured the pointer and returned early — pointerHeld stayed false, the idle timer kept the original swipe direction, and the board hard-jumped away from where the user tapped to stop. + */ const beginInteraction = (event: Event) => { if (!isUserInteraction(event)) return; clearPin(); + // Mid-momentum re-touch (or duplicate pointerdown+touchstart): cancel pending snap and re-baseline. if (interactionActive) { + clearIdleTimer(); + lockedDirection = 0; + sawHorizontalMovement = false; + gestureStartScrollLeft = scroller.scrollLeft; + lastScrollLeft = scroller.scrollLeft; + const clientX = getClientX(event); + gestureStartClientX = clientX; + lastClientX = clientX; + + if (event.type === "wheel") { + pointerHeld = false; + suspendNativeSnap(); + armIdleSettle(); + return; + } + + pointerHeld = true; if (event.type === "pointerdown" && "pointerId" in event) { try { scroller.setPointerCapture((event as PointerEvent).pointerId);