FN-8235: add mobile Kanban column snapping
Mobile Kanban now settles user swipes on the nearest column. - add a mobile-only, user-driven scroll-end snapping hook - wire snapping to every live Board rendering and cover interaction boundaries - document the behavior and add a patch changeset Files changed: .changeset/fn-8235-mobile-kanban-snap.md | 7 + docs/dashboard-guide.md | 6 + packages/dashboard/app/components/Board.tsx | 19 ++- .../app/components/__tests__/board-mobile.test.tsx | 14 +- .../hooks/__tests__/useColumnScrollSnap.test.ts | 159 ++++++++++++++++++ .../dashboard/app/hooks/useColumnScrollSnap.ts | 179 +++++++++++++++++++++ 6 files changed, 380 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-8235 Fusion-Task-Lineage: 46881e93-59f2-49d6-b29e-fa6150420f2a Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8235-mobile-kanban-snap.md
Normal file
7
.changeset/fn-8235-mobile-kanban-snap.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Mobile Kanban board now magnetically snaps to a single column when you swipe between columns.
|
||||||
|
category: fix
|
||||||
|
dev: New app/hooks/useColumnScrollSnap.ts scroll-end snap wired to Board #board; keeps CSS scroll-snap-type: x proximity (no mandatory) to preserve the FN-001 corner-rendering fix.
|
||||||
@@ -80,6 +80,12 @@ On mobile, an open navigation-bar **More** sheet or mailbox message detail is di
|
|||||||
When task detail is open from a board card, task popup, mobile list row, right-dock/activity/onboarding link, deep link, or another task detail link, one browser, iOS edge-swipe, or Android Back action closes the current detail first and restores the prior dashboard context (for example, nested task detail → previous task detail, or task detail → board/list).
|
When task detail is open from a board card, task popup, mobile list row, right-dock/activity/onboarding link, deep link, or another task detail link, one browser, iOS edge-swipe, or Android Back action closes the current detail first and restores the prior dashboard context (for example, nested task detail → previous task detail, or task detail → board/list).
|
||||||
<!-- FNXC:TaskDetailSwipeBackDocs 2026-07-15-10:36: Mobile task popups now register the same navigation entry as modal and full-panel task detail, so every Back delivery mechanism dismisses the popup before it can leave the originating Board or List. -->
|
<!-- FNXC:TaskDetailSwipeBackDocs 2026-07-15-10:36: Mobile task popups now register the same navigation entry as modal and full-panel task detail, so every Back delivery mechanism dismisses the popup before it can leave the originating Board or List. -->
|
||||||
On mobile board-card detail, **Back to board** also restores the prior board/card scroll position so the same lane context remains visible.
|
On mobile board-card detail, **Back to board** also restores the prior board/card scroll position so the same lane context remains visible.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
<!-- 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. -->
|
<!-- 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.
|
This behavior used to be mobile-only, and now applies across all viewports.
|
||||||
Task Detail modal opens from onboarding, activity log, and task-to-task navigation now all register navigation history entries, so Android back swipe/button dismisses them consistently.
|
Task Detail modal opens from onboarding, activity log, and task-to-task navigation now all register navigation history entries, so Android back swipe/button dismisses them consistently.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { promoteTask, type ModelInfo, type BoardWorkflowsPayload, type BoardWorkflowColumn, type RevertTaskOptions, type RevertTaskResult } from "../api";
|
import { promoteTask, type ModelInfo, type BoardWorkflowsPayload, type BoardWorkflowColumn, type RevertTaskOptions, type RevertTaskResult } from "../api";
|
||||||
import { useBlockerFanout } from "../hooks/useBlockerFanout";
|
import { useBlockerFanout } from "../hooks/useBlockerFanout";
|
||||||
|
import { useColumnScrollSnap } from "../hooks/useColumnScrollSnap";
|
||||||
import { MOBILE_MEDIA_QUERY, useViewportMode } from "../hooks/useViewportMode";
|
import { MOBILE_MEDIA_QUERY, useViewportMode } from "../hooks/useViewportMode";
|
||||||
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||||
import { getBoardCanDropTaskRejection } from "./boardCanDropTask";
|
import { getBoardCanDropTaskRejection } from "./boardCanDropTask";
|
||||||
@@ -178,6 +179,18 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
);
|
);
|
||||||
const archivedLoadedRef = useRef(false);
|
const archivedLoadedRef = useRef(false);
|
||||||
const boardRef = useRef<HTMLElement | null>(null);
|
const boardRef = useRef<HTMLElement | null>(null);
|
||||||
|
const [boardElement, setBoardElement] = useState<HTMLElement | null>(null);
|
||||||
|
/*
|
||||||
|
FNXC:BoardNavigation 2026-07-16-00:00:
|
||||||
|
The board can first render a workflow-loading skeleton, so a mutable ref alone would not
|
||||||
|
re-run the snap effect after the live board mounts. Mirror the callback ref in state to attach
|
||||||
|
user-only mobile snapping to every live Board variant without snapping the skeleton.
|
||||||
|
*/
|
||||||
|
const setBoardRef = useCallback((element: HTMLElement | null) => {
|
||||||
|
boardRef.current = element;
|
||||||
|
setBoardElement((current) => current === element ? current : element);
|
||||||
|
}, []);
|
||||||
|
useColumnScrollSnap(boardElement, { mobileOnly: true });
|
||||||
const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => {
|
const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => {
|
||||||
if (typeof document === "undefined") return null;
|
if (typeof document === "undefined") return null;
|
||||||
return document.getElementById("header-workflow-slot");
|
return document.getElementById("header-workflow-slot");
|
||||||
@@ -927,7 +940,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
return (
|
return (
|
||||||
<div className="board-workflow-view">
|
<div className="board-workflow-view">
|
||||||
{renderedWorkflowToolbar}
|
{renderedWorkflowToolbar}
|
||||||
<main className="board board-workflow-columns" id="board" ref={boardRef}>
|
<main className="board board-workflow-columns" id="board" ref={setBoardRef}>
|
||||||
{aggregateRenderedBoardColumns.map((columnDef) => {
|
{aggregateRenderedBoardColumns.map((columnDef) => {
|
||||||
const isCreateColumn = aggregateQuickCreateTarget?.columnId === columnDef.id;
|
const isCreateColumn = aggregateQuickCreateTarget?.columnId === columnDef.id;
|
||||||
const isDoneLikeColumn = columnDef.flags.complete === true && columnDef.flags.archived !== true;
|
const isDoneLikeColumn = columnDef.flags.complete === true && columnDef.flags.archived !== true;
|
||||||
@@ -999,7 +1012,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
<main
|
<main
|
||||||
className="board board-workflow-columns"
|
className="board board-workflow-columns"
|
||||||
id="board"
|
id="board"
|
||||||
ref={boardRef}
|
ref={setBoardRef}
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id");
|
const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id");
|
||||||
if (id) draggingTaskIdRef.current = id;
|
if (id) draggingTaskIdRef.current = id;
|
||||||
@@ -1134,7 +1147,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<main className="board" id="board" ref={boardRef}>
|
<main className="board" id="board" ref={setBoardRef}>
|
||||||
{COLUMNS.map((col) => (
|
{COLUMNS.map((col) => (
|
||||||
<Column
|
<Column
|
||||||
key={col}
|
key={col}
|
||||||
|
|||||||
@@ -89,7 +89,19 @@ function getMainMobileSection(css: string): string {
|
|||||||
return blocks.join("\n");
|
return blocks.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("getMainMobileSection — compound media queries", () => {
|
describe("mobile board magnetic column snap wiring (FN-8235)", () => {
|
||||||
|
it("shares the mobile scroll-end hook across legacy, selected, and aggregate live board renders", () => {
|
||||||
|
const boardSource = fs.readFileSync(path.join(process.cwd(), "app/components/Board.tsx"), "utf8");
|
||||||
|
|
||||||
|
expect(boardSource).toContain('import { useColumnScrollSnap } from "../hooks/useColumnScrollSnap";');
|
||||||
|
expect(boardSource).toContain("useColumnScrollSnap(boardElement, { mobileOnly: true });");
|
||||||
|
expect(boardSource.match(/ref=\{setBoardRef\}/g)).toHaveLength(3);
|
||||||
|
expect(boardSource.match(/className="board board-workflow-columns"/g)).toHaveLength(2);
|
||||||
|
expect(boardSource).toContain('<main className="board" id="board" ref={setBoardRef}>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getMainMobileSection — compound media queries", () => {
|
||||||
it("matches simple and compound 768px blocks but excludes other breakpoints", () => {
|
it("matches simple and compound 768px blocks but excludes other breakpoints", () => {
|
||||||
const syntheticCss = `
|
const syntheticCss = `
|
||||||
@media (max-width: 768px)
|
@media (max-width: 768px)
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useColumnScrollSnap } from "../useColumnScrollSnap";
|
||||||
|
import { isMobileViewport } from "../useViewportMode";
|
||||||
|
|
||||||
|
type Viewport = "mobile" | "wide-short-desktop";
|
||||||
|
|
||||||
|
function stubViewport(viewport: Viewport): void {
|
||||||
|
const isMobile = viewport === "mobile";
|
||||||
|
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||||
|
matches: query === "(max-width: 768px)" ? isMobile : query === "(max-height: 480px)",
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(() => true),
|
||||||
|
})));
|
||||||
|
Object.defineProperty(window, "screen", {
|
||||||
|
configurable: true,
|
||||||
|
value: viewport === "mobile" ? { width: 390, height: 844 } : { width: 1920, height: 1080 },
|
||||||
|
});
|
||||||
|
Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: viewport === "mobile" ? 1 : 0 });
|
||||||
|
vi.stubGlobal("visualViewport", {
|
||||||
|
width: viewport === "mobile" ? 390 : 1200,
|
||||||
|
height: viewport === "mobile" ? 844 : 400,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createScroller(columnCount = 2): HTMLElement {
|
||||||
|
const scroller = document.createElement("main");
|
||||||
|
Object.defineProperty(scroller, "clientWidth", { configurable: true, value: 100 });
|
||||||
|
scroller.getBoundingClientRect = () => new DOMRect(0, 0, 100, 200);
|
||||||
|
Object.defineProperty(scroller, "scrollTo", { configurable: true, value: vi.fn() });
|
||||||
|
for (let index = 0; index < columnCount; index++) {
|
||||||
|
const column = document.createElement("section");
|
||||||
|
const left = index === 0 ? -60 : 30;
|
||||||
|
column.getBoundingClientRect = () => new DOMRect(left, 0, 100, 200);
|
||||||
|
scroller.append(column);
|
||||||
|
}
|
||||||
|
document.body.append(scroller);
|
||||||
|
return scroller;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchUserPan(scroller: HTMLElement): void {
|
||||||
|
scroller.dispatchEvent(new Event("pointerdown"));
|
||||||
|
scroller.scrollLeft = 10;
|
||||||
|
scroller.dispatchEvent(new Event("scroll"));
|
||||||
|
scroller.dispatchEvent(new Event("scrollend"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useColumnScrollSnap", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
stubViewport("mobile");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.replaceChildren();
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("centers the nearest column after verified user horizontal movement ends", () => {
|
||||||
|
const scroller = createScroller();
|
||||||
|
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
||||||
|
|
||||||
|
act(() => dispatchUserPan(scroller));
|
||||||
|
|
||||||
|
expect(scroller.scrollTo).toHaveBeenCalledWith({ left: 40, behavior: "smooth" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches after a loading skeleton is replaced by the live board", () => {
|
||||||
|
const scroller = createScroller();
|
||||||
|
const { rerender } = renderHook(
|
||||||
|
({ element }) => useColumnScrollSnap(element, { mobileOnly: true, isUserInteraction: () => true }),
|
||||||
|
{ initialProps: { element: null as HTMLElement | null } },
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender({ element: scroller });
|
||||||
|
act(() => dispatchUserPan(scroller));
|
||||||
|
|
||||||
|
expect(scroller.scrollTo).toHaveBeenCalledWith({ left: 40, behavior: "smooth" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not snap on mount, viewport lifecycle events, or programmatic scrolling", () => {
|
||||||
|
const scroller = createScroller();
|
||||||
|
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
window.dispatchEvent(new Event("pageshow"));
|
||||||
|
scroller.scrollLeft = 40;
|
||||||
|
scroller.dispatchEvent(new Event("scroll"));
|
||||||
|
scroller.dispatchEvent(new Event("scrollend"));
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scroller.scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires horizontal movement after user input rather than a recent tap", () => {
|
||||||
|
const scroller = createScroller();
|
||||||
|
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
scroller.dispatchEvent(new Event("pointerdown"));
|
||||||
|
scroller.dispatchEvent(new Event("scrollend"));
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scroller.scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([0, 1])("does nothing with %s snap children", (columnCount) => {
|
||||||
|
const scroller = createScroller(columnCount);
|
||||||
|
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
||||||
|
|
||||||
|
act(() => dispatchUserPan(scroller));
|
||||||
|
|
||||||
|
expect(scroller.scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not attach magnetic snapping on a wide, short non-phone desktop", () => {
|
||||||
|
stubViewport("wide-short-desktop");
|
||||||
|
expect(window.screen.width).toBe(1920);
|
||||||
|
expect(window.screen.height).toBe(1080);
|
||||||
|
expect(window.visualViewport?.width).toBe(1200);
|
||||||
|
expect(window.matchMedia("(max-width: 768px)").matches).toBe(false);
|
||||||
|
expect(window.matchMedia("(max-height: 480px)").matches).toBe(true);
|
||||||
|
expect(isMobileViewport()).toBe(false);
|
||||||
|
const scroller = createScroller();
|
||||||
|
const addListener = vi.spyOn(scroller, "addEventListener");
|
||||||
|
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
||||||
|
|
||||||
|
act(() => dispatchUserPan(scroller));
|
||||||
|
|
||||||
|
expect(addListener).not.toHaveBeenCalledWith("scrollend", expect.any(Function));
|
||||||
|
expect(scroller.scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores scroll activity while its own smooth snap is in progress", () => {
|
||||||
|
const scroller = createScroller();
|
||||||
|
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dispatchUserPan(scroller);
|
||||||
|
scroller.dispatchEvent(new Event("scroll"));
|
||||||
|
scroller.dispatchEvent(new Event("scrollend"));
|
||||||
|
dispatchUserPan(scroller);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scroller.scrollTo).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
179
packages/dashboard/app/hooks/useColumnScrollSnap.ts
Normal file
179
packages/dashboard/app/hooks/useColumnScrollSnap.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
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.
|
||||||
|
*/
|
||||||
|
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 idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let snapReleaseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const clearIdleTimer = () => {
|
||||||
|
if (idleTimer !== null) clearTimeout(idleTimer);
|
||||||
|
idleTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const releaseSnap = () => {
|
||||||
|
if (snapReleaseTimer !== null) clearTimeout(snapReleaseTimer);
|
||||||
|
snapReleaseTimer = setTimeout(() => {
|
||||||
|
isSnapping = false;
|
||||||
|
snapReleaseTimer = null;
|
||||||
|
}, SNAP_RELEASE_DELAY_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapToNearestColumn = () => {
|
||||||
|
clearIdleTimer();
|
||||||
|
if (!interactionActive || !sawHorizontalMovement || isSnapping) return;
|
||||||
|
interactionActive = false;
|
||||||
|
sawHorizontalMovement = false;
|
||||||
|
|
||||||
|
const columns = Array.from(scroller.children) as HTMLElement[];
|
||||||
|
if (columns.length < 2) return;
|
||||||
|
|
||||||
|
const scrollerRect = scroller.getBoundingClientRect();
|
||||||
|
const viewportWidth = scroller.clientWidth || scrollerRect.width;
|
||||||
|
if (viewportWidth <= 0) 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) 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 || !sawHorizontalMovement || isSnapping) return;
|
||||||
|
clearIdleTimer();
|
||||||
|
idleTimer = setTimeout(snapToNearestColumn, SCROLL_IDLE_DELAY_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScrollEnd = () => {
|
||||||
|
if (isSnapping) {
|
||||||
|
isSnapping = false;
|
||||||
|
if (snapReleaseTimer !== null) clearTimeout(snapReleaseTimer);
|
||||||
|
snapReleaseTimer = null;
|
||||||
|
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);
|
||||||
|
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]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user