FN-8489: hard-settle mobile board column swipes
Ensure mobile board pans never stop between columns. - Detect centered columns before resolving a zero-direction pan - Snap off-center zero-direction pans to the nearest column center - Add regression coverage and document the mobile settle behavior Files changed: .changeset/fn-8489-mobile-column-settle.md | 7 +++++ docs/dashboard-guide.md | 2 +- .../app/components/__tests__/board-mobile.test.tsx | 4 +-- .../hooks/__tests__/useColumnScrollSnap.test.ts | 32 ++++++++++++++++++++++ .../dashboard/app/hooks/useColumnScrollSnap.ts | 29 ++++++++++++++++++-- 5 files changed, 69 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-8489 Fusion-Task-Lineage: 555bd906-0ac8-4c43-a00b-24d1e716488a Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8489-mobile-column-settle.md
Normal file
7
.changeset/fn-8489-mobile-column-settle.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Mobile board swipes always settle on a single centered column, never between columns.
|
||||||
|
category: fix
|
||||||
|
dev: Hardens useColumnScrollSnap settle to nearest/directional column center; keeps CSS proximity snap (no mandatory).
|
||||||
@@ -100,7 +100,7 @@ On mobile board-card detail, **Back to board** also restores the prior board/car
|
|||||||
|
|
||||||
### Mobile Kanban column snapping
|
### Mobile Kanban column snapping
|
||||||
|
|
||||||
On the mobile Kanban board, free-scroll while your finger is down and keep native fling/momentum after lift. Direction is locked at finger-up from the net swipe (not rubber-band ticks). When motion stops, Fusion hard-jumps to the next column **in that scroll direction** (right when scrolling forward, left when scrolling back) and pins until the next touch. CSS proximity stays suspended after a page. During the user pan, the board temporarily suspends native CSS `scroll-snap-type: x proximity`; the JavaScript scroll-end handler is the single magnetism authority and resolves drag-end to exactly one centered column before restoring the proximity baseline. This user-scroll-end behavior does not run for refreshes, resizes, or restored pages, which preserve the column position you chose. It supersedes FN-8235's competing native-drag/JS-drop behavior and intentionally avoids `x mandatory`, because mandatory snapping reintroduced the FN-001 iOS corner-rendering regression during layout changes.
|
On the mobile Kanban board, free-scroll while your finger is down and keep native fling/momentum after lift. Direction is locked at finger-up from the net swipe (not rubber-band ticks). When motion stops, Fusion hard-jumps to the next column **in that scroll direction** (right when scrolling forward, left when scrolling back) and pins until the next touch. If a user pan ends off-center without a usable direction, it hard-jumps to the nearest column instead, so it never rests between columns. CSS proximity stays suspended after a page. During the user pan, the board temporarily suspends native CSS `scroll-snap-type: x proximity`; the JavaScript scroll-end handler is the single magnetism authority and resolves drag-end to exactly one centered column before restoring the proximity baseline. This user-scroll-end behavior does not run for refreshes, resizes, or restored pages, which preserve the column position you chose. It supersedes FN-8235's competing native-drag/JS-drop behavior and intentionally avoids `x mandatory`, because mandatory snapping reintroduced the FN-001 iOS corner-rendering regression during layout changes.
|
||||||
|
|
||||||
<!-- FNXC:BoardNavigationDocs 2026-07-16-08:35: Issue #2245 / #2303 requires one mobile board magnetism: suspend native proximity during a user pan, let JS resolve exactly one centered column at drag-end, then restore proximity without using prohibited x mandatory so FN-001 corner rendering remains intact. -->
|
<!-- FNXC:BoardNavigationDocs 2026-07-16-08:35: Issue #2245 / #2303 requires one mobile board magnetism: suspend native proximity during a user pan, let JS resolve exactly one centered column at drag-end, then restore proximity without using prohibited x mandatory so FN-001 corner rendering remains intact. -->
|
||||||
<!-- FNXC:BoardNavigationDocs 2026-07-15-13:30: Mobile Kanban documentation must describe the user-only JS scroll-end snap and its FN-001 proximity-CSS rationale so operators understand why layout changes never force a column. -->
|
<!-- FNXC:BoardNavigationDocs 2026-07-15-13:30: Mobile Kanban documentation must describe the user-only JS scroll-end snap and its FN-001 proximity-CSS rationale so operators understand why layout changes never force a column. -->
|
||||||
|
|||||||
@@ -212,11 +212,11 @@ describe("Board and Column mobile CSS", () => {
|
|||||||
expectRuleToContain(mobileSection, ".board", "scroll-snap-type: x proximity;");
|
expectRuleToContain(mobileSection, ".board", "scroll-snap-type: x proximity;");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("contains .board scroll-behavior: smooth in the mobile media block", () => {
|
it("keeps .board scroll-behavior auto for the JavaScript hard settle", () => {
|
||||||
const css = loadAllAppCss();
|
const css = loadAllAppCss();
|
||||||
const mobileSection = getMainMobileSection(css);
|
const mobileSection = getMainMobileSection(css);
|
||||||
|
|
||||||
expectRuleToContain(mobileSection, ".board", "scroll-behavior: smooth;");
|
expectRuleToContain(mobileSection, ".board", "scroll-behavior: auto;");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("contains .board > .column width: 300px in the mobile media block", () => {
|
it("contains .board > .column width: 300px in the mobile media block", () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
isColumnCentered,
|
||||||
resolvePanDirection,
|
resolvePanDirection,
|
||||||
resolveTargetIndexInScrollDirection,
|
resolveTargetIndexInScrollDirection,
|
||||||
useColumnScrollSnap,
|
useColumnScrollSnap,
|
||||||
@@ -111,6 +112,17 @@ describe("resolvePanDirection", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isColumnCentered", () => {
|
||||||
|
it("recognizes only an integer column-centering target", () => {
|
||||||
|
const scroller = createScroller(3, COLUMN_WIDTH);
|
||||||
|
const columns = [...scroller.children] as HTMLElement[];
|
||||||
|
|
||||||
|
expect(isColumnCentered(scroller, columns)).toBe(true);
|
||||||
|
scroller.scrollLeft = 40;
|
||||||
|
expect(isColumnCentered(scroller, columns)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveTargetIndexInScrollDirection", () => {
|
describe("resolveTargetIndexInScrollDirection", () => {
|
||||||
it("forward from column 0 always goes right to column 1", () => {
|
it("forward from column 0 always goes right to column 1", () => {
|
||||||
const scroller = createScroller(3, 0);
|
const scroller = createScroller(3, 0);
|
||||||
@@ -169,6 +181,26 @@ describe("useColumnScrollSnap", () => {
|
|||||||
expect(scroller.scrollLeft).toBe(COLUMN_WIDTH);
|
expect(scroller.scrollLeft).toBe(COLUMN_WIDTH);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hard-settles a zero-direction pan at the nearest column center", () => {
|
||||||
|
const scroller = createScroller(3, 40);
|
||||||
|
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dispatchPointerEvent(scroller, "pointerdown", 200);
|
||||||
|
// A weak/reversed gesture can have a real pan but zero net direction at lift.
|
||||||
|
scroller.scrollLeft = 60;
|
||||||
|
scroller.dispatchEvent(new Event("scroll"));
|
||||||
|
scroller.scrollLeft = 40;
|
||||||
|
scroller.dispatchEvent(new Event("scroll"));
|
||||||
|
dispatchPointerEvent(scroller, "pointerup", 200);
|
||||||
|
});
|
||||||
|
settleAfterMomentum();
|
||||||
|
|
||||||
|
// Regression: proximity alone previously left this invalid mid-column rest at 40.
|
||||||
|
expect(scroller.scrollLeft).toBe(0);
|
||||||
|
expect(isColumnCentered(scroller, [...scroller.children] as HTMLElement[])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not reverse direction when post-lift scroll rubber-bands", () => {
|
it("does not reverse direction when post-lift scroll rubber-bands", () => {
|
||||||
const scroller = createScroller(3, 0);
|
const scroller = createScroller(3, 0);
|
||||||
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
||||||
|
|||||||
@@ -84,6 +84,17 @@ function scrollLeftToCenterColumn(scroller: HTMLElement, column: HTMLElement): n
|
|||||||
return Math.round(scroller.scrollLeft + columnRect.left + columnRect.width / 2 - viewportCenter);
|
return Math.round(scroller.scrollLeft + columnRect.left + columnRect.width / 2 - viewportCenter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the viewport is already centered on one of its eligible snap columns. */
|
||||||
|
export function isColumnCentered(
|
||||||
|
scroller: HTMLElement,
|
||||||
|
columns: HTMLElement[],
|
||||||
|
tolerance = CENTER_TOLERANCE_PX,
|
||||||
|
): boolean {
|
||||||
|
if (columns.length === 0) return false;
|
||||||
|
const nearest = nearestColumnIndex(scroller, columns);
|
||||||
|
return Math.abs(scroller.scrollLeft - scrollLeftToCenterColumn(scroller, columns[nearest])) <= tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve pan direction from the full gesture (net deltas only).
|
* Resolve pan direction from the full gesture (net deltas only).
|
||||||
* Do NOT pass last micro-tick direction for settle — rubber-band flips it.
|
* Do NOT pass last micro-tick direction for settle — rubber-band flips it.
|
||||||
@@ -307,7 +318,7 @@ export function useColumnScrollSnap(
|
|||||||
gestureStartClientX = null;
|
gestureStartClientX = null;
|
||||||
lastClientX = null;
|
lastClientX = null;
|
||||||
|
|
||||||
if (!hadPanIntent || direction === 0) {
|
if (!hadPanIntent) {
|
||||||
restoreNativeSnap();
|
restoreNativeSnap();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -324,7 +335,21 @@ export function useColumnScrollSnap(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetIndex = resolveTargetIndexInScrollDirection(scroller, columns, direction);
|
/*
|
||||||
|
FNXC:BoardNavigation 2026-07-22-18:30:
|
||||||
|
A user-driven mobile settle must rest at the integer center of exactly one `.column`,
|
||||||
|
never between columns. Keep CSS proximity (not prohibited mandatory snap) and free
|
||||||
|
scrolling while held: a locked direction pages in that direction, while an off-center
|
||||||
|
zero-direction settle hard-jumps to its nearest center and pins until the next touch.
|
||||||
|
*/
|
||||||
|
if (direction === 0 && isColumnCentered(scroller, columns)) {
|
||||||
|
restoreNativeSnap();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetIndex = direction === 0
|
||||||
|
? nearestColumnIndex(scroller, columns)
|
||||||
|
: resolveTargetIndexInScrollDirection(scroller, columns, direction);
|
||||||
const targetLeft = scrollLeftToCenterColumn(scroller, columns[targetIndex]);
|
const targetLeft = scrollLeftToCenterColumn(scroller, columns[targetIndex]);
|
||||||
applySnapTo(targetLeft);
|
applySnapTo(targetLeft);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user