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.
This commit is contained in:
gsxdsm
2026-07-22 15:16:02 -07:00
parent 875d057d16
commit 8cb12b95a9
2 changed files with 85 additions and 0 deletions

View File

@@ -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);

View File

@@ -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);