fix(dashboard): always settle mobile board on a column center
After tap-to-stop or zero-pan lifts, hard-jump to the nearest column so the board never rests between lanes, while directional paging still applies only when the settle gesture itself panned.
This commit is contained in:
@@ -344,35 +344,81 @@ describe("useColumnScrollSnap", () => {
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:BoardNavigation 2026-07-22-15:10:
|
||||
Tap-to-stop during post-lift momentum must not hard-jump using the original swipe direction.
|
||||
FNXC:BoardNavigation 2026-07-22-15:10 / 2026-07-22-15:26:
|
||||
Tap-to-stop during post-lift momentum must not page with the original swipe direction, and
|
||||
must hard-jump to the nearest column center so the board never rests between columns.
|
||||
*/
|
||||
it("does not jump when the user taps during momentum after a swipe", () => {
|
||||
it("tap during momentum settles to nearest column, not the cancelled swipe direction", () => {
|
||||
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.
|
||||
// Forward swipe arms a rightward directional settle (would page to column 1).
|
||||
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.
|
||||
// Coast only slightly — still nearest to column 0 — then tap to stop.
|
||||
scroller.scrollLeft = 40;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
dispatchPointerEvent(scroller, "pointerdown", 100);
|
||||
dispatchPointerEvent(scroller, "pointerup", 100);
|
||||
});
|
||||
|
||||
settleAfterMomentum();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Nearest is column 0; original rightward settle would have jumped to COLUMN_WIDTH.
|
||||
expect(scroller.scrollLeft).toBe(0);
|
||||
expect(isColumnCentered(scroller, [...scroller.children] as HTMLElement[])).toBe(true);
|
||||
});
|
||||
|
||||
it("tap during momentum past the midpoint snaps to the nearer column center", () => {
|
||||
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);
|
||||
|
||||
// Past the midpoint toward column 1 — nearest is column 1.
|
||||
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);
|
||||
expect(scroller.scrollLeft).toBe(COLUMN_WIDTH);
|
||||
expect(isColumnCentered(scroller, [...scroller.children] as HTMLElement[])).toBe(true);
|
||||
});
|
||||
|
||||
it("never rests between columns after a zero-direction settle", () => {
|
||||
const scroller = createScroller(3, 40);
|
||||
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent(scroller, "pointerdown", 200);
|
||||
scroller.scrollLeft = 40;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
dispatchPointerEvent(scroller, "pointerup", 200);
|
||||
});
|
||||
settleAfterMomentum();
|
||||
|
||||
const columns = [...scroller.children] as HTMLElement[];
|
||||
expect(isColumnCentered(scroller, columns)).toBe(true);
|
||||
expect([0, COLUMN_WIDTH, COLUMN_WIDTH * 2]).toContain(scroller.scrollLeft);
|
||||
});
|
||||
|
||||
it("starts a new directional settle after a pan that continues from a mid-momentum re-touch", () => {
|
||||
@@ -398,6 +444,7 @@ describe("useColumnScrollSnap", () => {
|
||||
settleAfterMomentum();
|
||||
|
||||
expect(scroller.scrollLeft).toBe(0);
|
||||
expect(isColumnCentered(scroller, [...scroller.children] as HTMLElement[])).toBe(true);
|
||||
});
|
||||
|
||||
it("does not attach on non-phone desktop", () => {
|
||||
|
||||
@@ -13,6 +13,12 @@ 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.
|
||||
|
||||
FNXC:BoardNavigation 2026-07-22-15:26:
|
||||
After any user touch sequence ends, the board must rest on exactly one column center — never
|
||||
between columns. Tap-to-stop and zero-pan lifts hard-jump to the nearest center (not the
|
||||
cancelled swipe's directional page). Directional paging still applies only when the settle
|
||||
gesture itself had pan intent.
|
||||
*/
|
||||
/** After lift/cancel/wheel: wait for scroll idle (momentum finished) before paging. */
|
||||
const SCROLL_IDLE_SETTLE_MS = 48;
|
||||
@@ -325,6 +331,31 @@ export function useColumnScrollSnap(
|
||||
reassertPinnedScrollLeft();
|
||||
};
|
||||
|
||||
/**
|
||||
* FNXC:BoardNavigation 2026-07-22-15:26:
|
||||
* Hard-jump to the nearest column center when off-center. Returns true when a snap
|
||||
* applied (or already centered); false only when there are no usable snap columns.
|
||||
*/
|
||||
const snapToNearestColumnIfNeeded = (): boolean => {
|
||||
const columns = getSnapColumns(scroller);
|
||||
if (columns.length < 2) {
|
||||
restoreNativeSnap();
|
||||
return false;
|
||||
}
|
||||
const viewportWidth = scroller.clientWidth || scroller.getBoundingClientRect().width;
|
||||
if (viewportWidth <= 0) {
|
||||
restoreNativeSnap();
|
||||
return false;
|
||||
}
|
||||
if (isColumnCentered(scroller, columns)) {
|
||||
restoreNativeSnap();
|
||||
return true;
|
||||
}
|
||||
const targetIndex = nearestColumnIndex(scroller, columns);
|
||||
applySnapTo(scrollLeftToCenterColumn(scroller, columns[targetIndex]));
|
||||
return true;
|
||||
};
|
||||
|
||||
const snapInScrollDirection = () => {
|
||||
clearIdleTimer();
|
||||
if (!interactionActive) return;
|
||||
@@ -353,8 +384,13 @@ export function useColumnScrollSnap(
|
||||
gestureStartClientX = null;
|
||||
lastClientX = null;
|
||||
|
||||
/*
|
||||
FNXC:BoardNavigation 2026-07-22-15:26:
|
||||
No pan on this settle gesture (tap-to-stop after re-baseline, pure tap): still never
|
||||
rest between columns — nearest-center only. Do not reuse a cancelled swipe's direction.
|
||||
*/
|
||||
if (!hadPanIntent) {
|
||||
restoreNativeSnap();
|
||||
snapToNearestColumnIfNeeded();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -522,8 +558,9 @@ export function useColumnScrollSnap(
|
||||
if (sawHorizontalMovement || lockedDirection !== 0) {
|
||||
armIdleSettle();
|
||||
} else {
|
||||
// FNXC:BoardNavigation 2026-07-22-15:26: Cancelled zero-pan touch must not leave mid-column.
|
||||
interactionActive = false;
|
||||
restoreNativeSnap();
|
||||
snapToNearestColumnIfNeeded();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user