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:
gsxdsm
2026-07-22 15:38:42 -07:00
parent 3cd023fa43
commit 55c0b7532c
2 changed files with 93 additions and 9 deletions

View File

@@ -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", () => {

View File

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