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>
236 lines
8.7 KiB
TypeScript
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]);
|
|
}
|