fix(dashboard): stop mobile board swipes jumping two columns at the edges

Columns are narrower than a phone viewport, so the first/last column's ideal
centered scrollLeft is outside the reachable scroll range. isColumnCentered
compared against that unreachable value, so an edge rest never read as
centered and commitDirectionalPage took its origin at release (already moved
onto the next column) instead of at gesture start — paging two columns.

Clamp the centering target to the reachable range, and clamp the mid-transit
origin against the gesture-start column so drag travel is never counted twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-26 08:48:36 -07:00
parent 9afd88dd9b
commit c76f276266
3 changed files with 180 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Mobile board swipes from the first or last column now advance one column instead of two.
category: fix
dev: `scrollLeftToCenterColumn` clamps to the scroller's reachable range so edge columns (narrower than the phone viewport) count as centered; `commitDirectionalPage` also clamps its mid-transit origin against the gesture-start column so drag travel can never be counted twice.

View File

@@ -291,6 +291,139 @@ describe("resolveSettleTargetIndex", () => {
}); });
}); });
/*
FNXC:BoardNavigation 2026-07-26-09:15:
Phone geometry: board columns (min-width 300px) are NARROWER than the phone viewport, so the first
and last columns can never reach their ideal centered scrollLeft (it is negative / past max). This
is the geometry the two-column edge jump only reproduces under — the default `createScroller` makes
columns exactly viewport-wide, where every column is perfectly centerable.
*/
const NARROW_VIEWPORT_WIDTH = 390;
const NARROW_COLUMN_WIDTH = 300;
function createNarrowColumnScroller(columnCount: number, initialScrollLeft: number): HTMLElement {
const scroller = document.createElement("main");
const contentWidth = columnCount * NARROW_COLUMN_WIDTH;
Object.defineProperty(scroller, "clientWidth", { configurable: true, value: NARROW_VIEWPORT_WIDTH });
Object.defineProperty(scroller, "scrollWidth", { configurable: true, value: contentWidth });
scroller.getBoundingClientRect = () => new DOMRect(0, 0, NARROW_VIEWPORT_WIDTH, 200);
const maxScrollLeft = Math.max(0, contentWidth - NARROW_VIEWPORT_WIDTH);
let scrollLeft = initialScrollLeft;
Object.defineProperty(scroller, "scrollLeft", {
configurable: true,
get: () => scrollLeft,
// Mirror the browser: positions outside the scrollable range are clamped, never stored.
set: (value: number) => {
scrollLeft = Math.min(Math.max(value, 0), maxScrollLeft);
},
});
scroller.setPointerCapture = vi.fn();
scroller.releasePointerCapture = vi.fn();
scroller.hasPointerCapture = vi.fn(() => false);
for (let index = 0; index < columnCount; index++) {
const column = document.createElement("section");
column.className = "column";
column.getBoundingClientRect = () =>
new DOMRect(index * NARROW_COLUMN_WIDTH - scrollLeft, 0, NARROW_COLUMN_WIDTH, 200);
scroller.append(column);
}
document.body.append(scroller);
return scroller;
}
/** scrollLeft that rests column `index` at its (range-clamped) center. */
function narrowColumnRest(index: number, columnCount: number): number {
const ideal =
index * NARROW_COLUMN_WIDTH + NARROW_COLUMN_WIDTH / 2 - NARROW_VIEWPORT_WIDTH / 2;
const maxScrollLeft = Math.max(0, columnCount * NARROW_COLUMN_WIDTH - NARROW_VIEWPORT_WIDTH);
return Math.min(Math.max(Math.round(ideal), 0), maxScrollLeft);
}
/** One deliberate drag: finger travel plus the board scroll it produced, then lift. */
function dispatchDrag(
scroller: HTMLElement,
options: { scrollDelta: number; clientDelta: number },
): void {
const { scrollDelta, clientDelta } = options;
scroller.dispatchEvent(new Event("touchstart"));
dispatchPointerEvent(scroller, "pointerdown", 200);
dispatchPointerEvent(scroller, "pointermove", 200 - clientDelta);
scroller.scrollLeft = scroller.scrollLeft + scrollDelta;
scroller.dispatchEvent(new Event("scroll"));
dispatchPointerEvent(scroller, "pointerup", 200 - clientDelta);
}
describe("edge columns narrower than the viewport", () => {
beforeEach(() => {
vi.useFakeTimers();
stubViewport("mobile");
});
afterEach(() => {
document.body.replaceChildren();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("counts the far-left rest position as centered on the first column", () => {
const scroller = createNarrowColumnScroller(4, 0);
expect(isColumnCentered(scroller, [...scroller.children] as HTMLElement[])).toBe(true);
});
it("counts the far-right rest position as centered on the last column", () => {
const columnCount = 4;
const scroller = createNarrowColumnScroller(columnCount, narrowColumnRest(columnCount - 1, columnCount));
expect(isColumnCentered(scroller, [...scroller.children] as HTMLElement[])).toBe(true);
});
/*
FNXC:BoardNavigation 2026-07-26-09:15:
Original symptom: one swipe starting on the far-left column advanced TWO columns, while the same
swipe from a scrolled-over position advanced one. Assert one column of travel from every resting
position — both clamped edges and the interior.
*/
it.each([
{ label: "far-left edge", from: 0, expected: 1 },
{ label: "interior column", from: 1, expected: 2 },
])("advances exactly one column per forward swipe from the $label", ({ from, expected }) => {
const columnCount = 4;
const scroller = createNarrowColumnScroller(columnCount, narrowColumnRest(from, columnCount));
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
// Drag far enough that the NEAREST column has already flipped to the next one at lift.
act(() => dispatchDrag(scroller, { scrollDelta: 200, clientDelta: 200 }));
settleAfterMomentum();
expect(scroller.scrollLeft).toBe(narrowColumnRest(expected, columnCount));
});
it("advances exactly one column per backward swipe from the far-right edge", () => {
const columnCount = 4;
const scroller = createNarrowColumnScroller(columnCount, narrowColumnRest(columnCount - 1, columnCount));
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
act(() => dispatchDrag(scroller, { scrollDelta: -200, clientDelta: -200 }));
settleAfterMomentum();
expect(scroller.scrollLeft).toBe(narrowColumnRest(columnCount - 2, columnCount));
});
it("never pins an unreachable scroll position at an edge", () => {
const scroller = createNarrowColumnScroller(4, narrowColumnRest(1, 4));
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
act(() => dispatchDrag(scroller, { scrollDelta: -400, clientDelta: -400 }));
settleAfterMomentum();
const settled = scroller.scrollLeft;
// The pin watchdog must agree with the clamped position instead of fighting it forever.
act(() => vi.advanceTimersByTime(200));
expect(scroller.scrollLeft).toBe(settled);
expect(settled).toBe(0);
});
});
describe("useColumnScrollSnap", () => { describe("useColumnScrollSnap", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();

View File

@@ -222,13 +222,37 @@ export function nearestColumnIndex(scroller: HTMLElement, columns: HTMLElement[]
return nearestIndex; return nearestIndex;
} }
/** scrollLeft that centers `column` in the scroller viewport (integer pixels). */ /*
FNXC:BoardNavigation 2026-07-26-09:15:
A swipe starting on the FAR-LEFT column jumped two columns while the same swipe mid-board moved one.
Cause: columns are narrower than the phone viewport, so the edge columns' ideal centered scrollLeft
falls OUTSIDE the reachable range (negative at the left edge, past max at the right edge). The board
therefore never read as "centered" while resting at an edge, `gestureStartCentered` was false, and
`commitDirectionalPage` fell back to taking its origin at RELEASE — which had already advanced onto
the next column — so the +1 page landed two columns over.
Clamping the centering target to the reachable scroll range makes an edge rest count as centered
(rest position IS the column's reachable center), so edge swipes page exactly one column like every
other position. It also stops `applySnapTo` from pinning an unreachable value, which left the pin
watchdog hard-jumping to a scrollLeft the browser keeps clamping away.
*/
/**
* scrollLeft that centers `column` in the scroller viewport (integer pixels), clamped to the
* scroller's reachable range so edge columns resolve to the position they actually rest at.
*
* The upper clamp is skipped when `scrollWidth` is unusable (jsdom reports 0); the lower clamp at 0
* is always valid.
*/
function scrollLeftToCenterColumn(scroller: HTMLElement, column: HTMLElement): number { function scrollLeftToCenterColumn(scroller: HTMLElement, column: HTMLElement): number {
const scrollerRect = scroller.getBoundingClientRect(); const scrollerRect = scroller.getBoundingClientRect();
const viewportWidth = scroller.clientWidth || scrollerRect.width; const viewportWidth = scroller.clientWidth || scrollerRect.width;
const viewportCenter = scrollerRect.left + viewportWidth / 2; const viewportCenter = scrollerRect.left + viewportWidth / 2;
const columnRect = column.getBoundingClientRect(); const columnRect = column.getBoundingClientRect();
return Math.round(scroller.scrollLeft + columnRect.left + columnRect.width / 2 - viewportCenter); const ideal = Math.round(
scroller.scrollLeft + columnRect.left + columnRect.width / 2 - viewportCenter,
);
const maxScrollLeft = scroller.scrollWidth - viewportWidth;
const upperBound = maxScrollLeft > 0 ? maxScrollLeft : Number.POSITIVE_INFINITY;
return Math.min(Math.max(ideal, 0), upperBound);
} }
/** Whether the viewport is already centered on one of its eligible snap columns. */ /** Whether the viewport is already centered on one of its eligible snap columns. */
@@ -740,8 +764,20 @@ export function useColumnScrollSnap(
} }
const nearestIndex = nearestColumnIndex(scroller, columns); const nearestIndex = nearestColumnIndex(scroller, columns);
// A gesture begun mid-transit has no trustworthy origin: page from where it actually is. /*
const originIndex = gestureStartCentered ? gestureStartColumnIndex : nearestIndex; FNXC:BoardNavigation 2026-07-26-09:15:
A gesture begun mid-transit has no trustworthy rest origin, so it pages from where it actually
is — but that origin must never sit FURTHER ALONG the travel direction than the column the
gesture started on, or the drag gets counted twice (once as travel, once as a bumped origin)
and the board advances two columns for a one-column swipe. `resolveFlingTargetIndex` still
floors the result at `nearestIndex`, so a long drag keeps its own landing point.
*/
const startIndex = Math.min(Math.max(gestureStartColumnIndex, 0), columns.length - 1);
const originIndex = gestureStartCentered
? gestureStartColumnIndex
: direction > 0
? Math.min(nearestIndex, startIndex)
: Math.max(nearestIndex, startIndex);
/* /*
FNXC:BoardNavigation 2026-07-25-09:40: FNXC:BoardNavigation 2026-07-25-09:40:
Net gesture travel gates multi-column reach. Take the larger of the board's own scroll delta Net gesture travel gates multi-column reach. Take the larger of the board's own scroll delta