FN-8276: unify mobile board snap behavior
Make JavaScript the sole authority for mobile Kanban pan settlement. - Suspend native proximity snapping while a user pan settles, then restore its inline baseline. - Restore snapping after wheels with no horizontal scroll as well as pointer and touch completion. - Document the unified magnetism behavior and cover restoration paths with hook tests. Files changed: .changeset/fn-8276-unify-board-magnetism.md | 7 +++ docs/dashboard-guide.md | 3 +- .../hooks/__tests__/useColumnScrollSnap.test.ts | 53 +++++++++++++++-- .../dashboard/app/hooks/useColumnScrollSnap.ts | 66 ++++++++++++++++++++-- 4 files changed, 119 insertions(+), 10 deletions(-) Fusion-Task-Id: FN-8276 Fusion-Task-Lineage: 1ff3ae24-51e8-4c4f-aa5a-7a7b7c84d37c Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8276-unify-board-magnetism.md
Normal file
7
.changeset/fn-8276-unify-board-magnetism.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Mobile Kanban swipes now settle on exactly one column with no stuck-between-columns state.
|
||||
category: fix
|
||||
dev: useColumnScrollSnap suspends native scroll-snap (inline scroll-snap-type:none) during a user pan and restores the x proximity baseline after its JS scroll-end snap, unifying the two magnetism systems from FN-8235; never uses x mandatory (FN-001).
|
||||
@@ -84,8 +84,9 @@ On mobile board-card detail, **Back to board** also restores the prior board/car
|
||||
|
||||
### Mobile Kanban column snapping
|
||||
|
||||
After a horizontal swipe on the mobile Kanban board, Fusion smoothly settles the viewport on the nearest column so it does not rest between two columns. This is a user-scroll-end behavior only; refreshes, resizes, and restored pages preserve the column position you chose. The board intentionally keeps CSS `scroll-snap-type: x proximity` rather than using `x mandatory`, because mandatory snapping reintroduced the FN-001 iOS corner-rendering regression during layout changes.
|
||||
After a horizontal swipe on the mobile Kanban board, Fusion smoothly settles the viewport on the nearest column so it does not rest between two columns. 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-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-06-29-20:45: Mobile full-panel task detail temporarily replaces the board, so the user-facing navigation guide must document that Back to board restores the board/card scroll context instead of returning to the top of the board. -->
|
||||
This behavior used to be mobile-only, and now applies across all viewports.
|
||||
|
||||
@@ -65,13 +65,42 @@ describe("useColumnScrollSnap", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("centers the nearest column after verified user horizontal movement ends", () => {
|
||||
it("unifies a user pan into one JS snap and restores the CSS proximity baseline", () => {
|
||||
const scroller = createScroller();
|
||||
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
||||
|
||||
act(() => dispatchUserPan(scroller));
|
||||
act(() => scroller.dispatchEvent(new Event("pointerdown")));
|
||||
expect(scroller.style.scrollSnapType).toBe("none");
|
||||
|
||||
act(() => {
|
||||
scroller.scrollLeft = 10;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
scroller.dispatchEvent(new Event("scrollend"));
|
||||
});
|
||||
|
||||
expect(scroller.scrollTo).toHaveBeenCalledTimes(1);
|
||||
expect(scroller.scrollTo).toHaveBeenCalledWith({ left: 40, behavior: "smooth" });
|
||||
expect(scroller.style.scrollSnapType).toBe("none");
|
||||
|
||||
act(() => scroller.dispatchEvent(new Event("scrollend")));
|
||||
expect(scroller.style.scrollSnapType).toBe("");
|
||||
expect(scroller.scrollTo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("restores a pre-existing inline snap value after completion and cleanup", () => {
|
||||
const scroller = createScroller();
|
||||
scroller.style.scrollSnapType = "x proximity";
|
||||
const { unmount } = renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
||||
|
||||
act(() => dispatchUserPan(scroller));
|
||||
expect(scroller.style.scrollSnapType).toBe("none");
|
||||
act(() => scroller.dispatchEvent(new Event("scrollend")));
|
||||
expect(scroller.style.scrollSnapType).toBe("x proximity");
|
||||
|
||||
act(() => scroller.dispatchEvent(new Event("pointerdown")));
|
||||
expect(scroller.style.scrollSnapType).toBe("none");
|
||||
unmount();
|
||||
expect(scroller.style.scrollSnapType).toBe("x proximity");
|
||||
});
|
||||
|
||||
it("attaches after a loading skeleton is replaced by the live board", () => {
|
||||
@@ -107,13 +136,27 @@ describe("useColumnScrollSnap", () => {
|
||||
const scroller = createScroller();
|
||||
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
||||
|
||||
act(() => scroller.dispatchEvent(new Event("pointerdown")));
|
||||
expect(scroller.style.scrollSnapType).toBe("none");
|
||||
act(() => {
|
||||
scroller.dispatchEvent(new Event("pointerdown"));
|
||||
scroller.dispatchEvent(new Event("scrollend"));
|
||||
scroller.dispatchEvent(new Event("pointerup"));
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(scroller.scrollTo).not.toHaveBeenCalled();
|
||||
expect(scroller.style.scrollSnapType).toBe("");
|
||||
});
|
||||
|
||||
it("restores native proximity after a wheel that produces no horizontal scroll", () => {
|
||||
const scroller = createScroller();
|
||||
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
||||
|
||||
act(() => scroller.dispatchEvent(new Event("wheel")));
|
||||
expect(scroller.style.scrollSnapType).toBe("none");
|
||||
act(() => vi.advanceTimersByTime(120));
|
||||
|
||||
expect(scroller.scrollTo).not.toHaveBeenCalled();
|
||||
expect(scroller.style.scrollSnapType).toBe("");
|
||||
});
|
||||
|
||||
it.each([0, 1])("does nothing with %s snap children", (columnCount) => {
|
||||
@@ -123,6 +166,7 @@ describe("useColumnScrollSnap", () => {
|
||||
act(() => dispatchUserPan(scroller));
|
||||
|
||||
expect(scroller.scrollTo).not.toHaveBeenCalled();
|
||||
expect(scroller.style.scrollSnapType).toBe("");
|
||||
});
|
||||
|
||||
it("does not attach magnetic snapping on a wide, short non-phone desktop", () => {
|
||||
@@ -140,6 +184,7 @@ describe("useColumnScrollSnap", () => {
|
||||
act(() => dispatchUserPan(scroller));
|
||||
|
||||
expect(addListener).not.toHaveBeenCalledWith("scrollend", expect.any(Function));
|
||||
expect(scroller.style.scrollSnapType).toBe("");
|
||||
expect(scroller.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ function addMediaChangeListener(query: MediaQueryList, listener: () => void): ()
|
||||
* regressed FN-001 by snapping against stale iOS layout metrics. Preserve proximity CSS and
|
||||
* snap only after verified user horizontal movement has ended; mount, reflow, resize, pageshow,
|
||||
* and programmatic scrolling must never choose a board column.
|
||||
*
|
||||
* FNXC:BoardNavigation 2026-07-16-08:35:
|
||||
* Issue #2245 / #2303 unifies the native `x proximity` drag behavior and JS scroll-end drop
|
||||
* behavior by suspending native snap only during a verified user pan. The hook then owns the
|
||||
* one-column resolution and restores the prior inline value; `x mandatory` remains prohibited
|
||||
* to preserve the FN-001 corner-rendering fix.
|
||||
*/
|
||||
export function useColumnScrollSnap(
|
||||
scroller: HTMLElement | null,
|
||||
@@ -71,6 +77,8 @@ export function useColumnScrollSnap(
|
||||
let interactionScrollLeft = scroller.scrollLeft;
|
||||
let sawHorizontalMovement = false;
|
||||
let isSnapping = false;
|
||||
let nativeSnapSuspended = false;
|
||||
let priorInlineScrollSnapType = "";
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let snapReleaseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -79,26 +87,56 @@ export function useColumnScrollSnap(
|
||||
idleTimer = null;
|
||||
};
|
||||
|
||||
const restoreNativeSnap = () => {
|
||||
if (!nativeSnapSuspended) return;
|
||||
scroller.style.scrollSnapType = priorInlineScrollSnapType;
|
||||
nativeSnapSuspended = false;
|
||||
};
|
||||
|
||||
const suspendNativeSnap = () => {
|
||||
if (nativeSnapSuspended) return;
|
||||
priorInlineScrollSnapType = scroller.style.scrollSnapType;
|
||||
scroller.style.scrollSnapType = "none";
|
||||
nativeSnapSuspended = true;
|
||||
};
|
||||
|
||||
const finishWithoutSnap = () => {
|
||||
interactionActive = false;
|
||||
sawHorizontalMovement = false;
|
||||
restoreNativeSnap();
|
||||
};
|
||||
|
||||
const releaseSnap = () => {
|
||||
if (snapReleaseTimer !== null) clearTimeout(snapReleaseTimer);
|
||||
snapReleaseTimer = setTimeout(() => {
|
||||
isSnapping = false;
|
||||
snapReleaseTimer = null;
|
||||
restoreNativeSnap();
|
||||
}, SNAP_RELEASE_DELAY_MS);
|
||||
};
|
||||
|
||||
const snapToNearestColumn = () => {
|
||||
clearIdleTimer();
|
||||
if (!interactionActive || !sawHorizontalMovement || isSnapping) return;
|
||||
if (isSnapping) return;
|
||||
if (!interactionActive || !sawHorizontalMovement) {
|
||||
if (interactionActive) finishWithoutSnap();
|
||||
return;
|
||||
}
|
||||
interactionActive = false;
|
||||
sawHorizontalMovement = false;
|
||||
|
||||
const columns = Array.from(scroller.children) as HTMLElement[];
|
||||
if (columns.length < 2) return;
|
||||
if (columns.length < 2) {
|
||||
restoreNativeSnap();
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollerRect = scroller.getBoundingClientRect();
|
||||
const viewportWidth = scroller.clientWidth || scrollerRect.width;
|
||||
if (viewportWidth <= 0) return;
|
||||
if (viewportWidth <= 0) {
|
||||
restoreNativeSnap();
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportCenter = scrollerRect.left + viewportWidth / 2;
|
||||
let nearestColumn: HTMLElement | null = null;
|
||||
@@ -111,7 +149,10 @@ export function useColumnScrollSnap(
|
||||
nearestDistance = distance;
|
||||
}
|
||||
}
|
||||
if (!nearestColumn || nearestDistance <= CENTER_TOLERANCE_PX) return;
|
||||
if (!nearestColumn || nearestDistance <= CENTER_TOLERANCE_PX) {
|
||||
restoreNativeSnap();
|
||||
return;
|
||||
}
|
||||
|
||||
const columnRect = nearestColumn.getBoundingClientRect();
|
||||
const targetLeft = scroller.scrollLeft + columnRect.left + columnRect.width / 2 - viewportCenter;
|
||||
@@ -129,6 +170,14 @@ export function useColumnScrollSnap(
|
||||
interactionActive = true;
|
||||
sawHorizontalMovement = false;
|
||||
interactionScrollLeft = scroller.scrollLeft;
|
||||
suspendNativeSnap();
|
||||
// FNXC:BoardNavigation 2026-07-18-09:03: Wheel input has no end event. Arm the same idle
|
||||
// settlement path immediately so vertical and boundary wheels that produce no horizontal
|
||||
// scroll restore the proximity baseline instead of leaving native snapping disabled.
|
||||
if (event.type === "wheel") {
|
||||
clearIdleTimer();
|
||||
idleTimer = setTimeout(snapToNearestColumn, SCROLL_IDLE_DELAY_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
@@ -141,7 +190,12 @@ export function useColumnScrollSnap(
|
||||
};
|
||||
|
||||
const handleInteractionEnd = () => {
|
||||
if (!interactionActive || !sawHorizontalMovement || isSnapping) return;
|
||||
if (!interactionActive || isSnapping) return;
|
||||
if (!sawHorizontalMovement) {
|
||||
clearIdleTimer();
|
||||
finishWithoutSnap();
|
||||
return;
|
||||
}
|
||||
clearIdleTimer();
|
||||
idleTimer = setTimeout(snapToNearestColumn, SCROLL_IDLE_DELAY_MS);
|
||||
};
|
||||
@@ -151,6 +205,7 @@ export function useColumnScrollSnap(
|
||||
isSnapping = false;
|
||||
if (snapReleaseTimer !== null) clearTimeout(snapReleaseTimer);
|
||||
snapReleaseTimer = null;
|
||||
restoreNativeSnap();
|
||||
return;
|
||||
}
|
||||
snapToNearestColumn();
|
||||
@@ -167,6 +222,7 @@ export function useColumnScrollSnap(
|
||||
return () => {
|
||||
clearIdleTimer();
|
||||
if (snapReleaseTimer !== null) clearTimeout(snapReleaseTimer);
|
||||
restoreNativeSnap();
|
||||
scroller.removeEventListener("pointerdown", beginInteraction);
|
||||
scroller.removeEventListener("touchstart", beginInteraction);
|
||||
scroller.removeEventListener("wheel", beginInteraction);
|
||||
|
||||
Reference in New Issue
Block a user