Files
fusion/packages/dashboard/app/hooks/useColumnScrollSnap.ts
gsxdsm 119fcedcfe 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>
2026-07-18 09:05:51 -07:00

236 lines
8.7 KiB
TypeScript

import { useEffect, useState } from "react";
import { isMobileViewport } from "./useViewportMode";
const SCROLL_IDLE_DELAY_MS = 120;
const SNAP_RELEASE_DELAY_MS = 300;
const CENTER_TOLERANCE_PX = 1;
export interface UseColumnScrollSnapOptions {
/** Restrict magnetic snapping to phone-class viewports. */
mobileOnly?: boolean;
/** Test seam; production callers must use the default trusted-event predicate. */
isUserInteraction?: (event: Event) => boolean;
}
function defaultIsUserInteraction(event: Event): boolean {
return event.isTrusted;
}
function addMediaChangeListener(query: MediaQueryList, listener: () => void): () => void {
if (typeof query.addEventListener === "function") {
query.addEventListener("change", listener);
return () => query.removeEventListener("change", listener);
}
query.addListener(listener);
return () => query.removeListener(listener);
}
/**
* Adds a mobile-only, user-driven scroll-end snap to a horizontal column scroller.
*
* FNXC:BoardNavigation 2026-07-15-13:30:
* Mobile Kanban swipes must settle on one column, but CSS `scroll-snap-type: x mandatory`
* 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,
{ mobileOnly = false, isUserInteraction = defaultIsUserInteraction }: UseColumnScrollSnapOptions = {},
): void {
const [isEligibleViewport, setIsEligibleViewport] = useState(() => !mobileOnly || isMobileViewport());
useEffect(() => {
if (!mobileOnly || typeof window === "undefined") return;
const updateEligibility = () => setIsEligibleViewport(isMobileViewport());
const widthQuery = window.matchMedia("(max-width: 768px)");
const heightQuery = window.matchMedia("(max-height: 480px)");
const removeWidthListener = addMediaChangeListener(widthQuery, updateEligibility);
const removeHeightListener = addMediaChangeListener(heightQuery, updateEligibility);
const visualViewport = window.visualViewport;
window.addEventListener("resize", updateEligibility);
window.addEventListener("orientationchange", updateEligibility);
visualViewport?.addEventListener("resize", updateEligibility);
updateEligibility();
return () => {
removeWidthListener();
removeHeightListener();
window.removeEventListener("resize", updateEligibility);
window.removeEventListener("orientationchange", updateEligibility);
visualViewport?.removeEventListener("resize", updateEligibility);
};
}, [mobileOnly]);
useEffect(() => {
if (!scroller || !isEligibleViewport) return;
let interactionActive = false;
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;
const clearIdleTimer = () => {
if (idleTimer !== null) clearTimeout(idleTimer);
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 (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) {
restoreNativeSnap();
return;
}
const scrollerRect = scroller.getBoundingClientRect();
const viewportWidth = scroller.clientWidth || scrollerRect.width;
if (viewportWidth <= 0) {
restoreNativeSnap();
return;
}
const viewportCenter = scrollerRect.left + viewportWidth / 2;
let nearestColumn: HTMLElement | null = null;
let nearestDistance = Number.POSITIVE_INFINITY;
for (const column of columns) {
const rect = column.getBoundingClientRect();
const distance = Math.abs(rect.left + rect.width / 2 - viewportCenter);
if (distance < nearestDistance) {
nearestColumn = column;
nearestDistance = distance;
}
}
if (!nearestColumn || nearestDistance <= CENTER_TOLERANCE_PX) {
restoreNativeSnap();
return;
}
const columnRect = nearestColumn.getBoundingClientRect();
const targetLeft = scroller.scrollLeft + columnRect.left + columnRect.width / 2 - viewportCenter;
isSnapping = true;
if (typeof scroller.scrollTo === "function") {
scroller.scrollTo({ left: targetLeft, behavior: "smooth" });
} else {
scroller.scrollLeft = targetLeft;
}
releaseSnap();
};
const beginInteraction = (event: Event) => {
if (isSnapping || !isUserInteraction(event)) return;
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 = () => {
if (isSnapping || !interactionActive) return;
if (scroller.scrollLeft === interactionScrollLeft) return;
sawHorizontalMovement = true;
interactionScrollLeft = scroller.scrollLeft;
clearIdleTimer();
idleTimer = setTimeout(snapToNearestColumn, SCROLL_IDLE_DELAY_MS);
};
const handleInteractionEnd = () => {
if (!interactionActive || isSnapping) return;
if (!sawHorizontalMovement) {
clearIdleTimer();
finishWithoutSnap();
return;
}
clearIdleTimer();
idleTimer = setTimeout(snapToNearestColumn, SCROLL_IDLE_DELAY_MS);
};
const handleScrollEnd = () => {
if (isSnapping) {
isSnapping = false;
if (snapReleaseTimer !== null) clearTimeout(snapReleaseTimer);
snapReleaseTimer = null;
restoreNativeSnap();
return;
}
snapToNearestColumn();
};
scroller.addEventListener("pointerdown", beginInteraction);
scroller.addEventListener("touchstart", beginInteraction);
scroller.addEventListener("wheel", beginInteraction, { passive: true });
scroller.addEventListener("scroll", handleScroll, { passive: true });
scroller.addEventListener("scrollend", handleScrollEnd);
scroller.addEventListener("pointerup", handleInteractionEnd);
scroller.addEventListener("touchend", handleInteractionEnd);
return () => {
clearIdleTimer();
if (snapReleaseTimer !== null) clearTimeout(snapReleaseTimer);
restoreNativeSnap();
scroller.removeEventListener("pointerdown", beginInteraction);
scroller.removeEventListener("touchstart", beginInteraction);
scroller.removeEventListener("wheel", beginInteraction);
scroller.removeEventListener("scroll", handleScroll);
scroller.removeEventListener("scrollend", handleScrollEnd);
scroller.removeEventListener("pointerup", handleInteractionEnd);
scroller.removeEventListener("touchend", handleInteractionEnd);
};
}, [isEligibleViewport, isUserInteraction, scroller]);
}