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).
|
||||
<!-- 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.
|
||||
|
||||
### 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. -->
|
||||
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.
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { promoteTask, type ModelInfo, type BoardWorkflowsPayload, type BoardWorkflowColumn, type RevertTaskOptions, type RevertTaskResult } from "../api";
|
||||
import { useBlockerFanout } from "../hooks/useBlockerFanout";
|
||||
import { useColumnScrollSnap } from "../hooks/useColumnScrollSnap";
|
||||
import { MOBILE_MEDIA_QUERY, useViewportMode } from "../hooks/useViewportMode";
|
||||
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||
import { getBoardCanDropTaskRejection } from "./boardCanDropTask";
|
||||
@@ -178,6 +179,18 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
);
|
||||
const archivedLoadedRef = useRef(false);
|
||||
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>(() => {
|
||||
if (typeof document === "undefined") return null;
|
||||
return document.getElementById("header-workflow-slot");
|
||||
@@ -927,7 +940,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
return (
|
||||
<div className="board-workflow-view">
|
||||
{renderedWorkflowToolbar}
|
||||
<main className="board board-workflow-columns" id="board" ref={boardRef}>
|
||||
<main className="board board-workflow-columns" id="board" ref={setBoardRef}>
|
||||
{aggregateRenderedBoardColumns.map((columnDef) => {
|
||||
const isCreateColumn = aggregateQuickCreateTarget?.columnId === columnDef.id;
|
||||
const isDoneLikeColumn = columnDef.flags.complete === true && columnDef.flags.archived !== true;
|
||||
@@ -999,7 +1012,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
<main
|
||||
className="board board-workflow-columns"
|
||||
id="board"
|
||||
ref={boardRef}
|
||||
ref={setBoardRef}
|
||||
onDragStart={(e) => {
|
||||
const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id");
|
||||
if (id) draggingTaskIdRef.current = id;
|
||||
@@ -1134,7 +1147,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="board" id="board" ref={boardRef}>
|
||||
<main className="board" id="board" ref={setBoardRef}>
|
||||
{COLUMNS.map((col) => (
|
||||
<Column
|
||||
key={col}
|
||||
|
||||
@@ -89,7 +89,19 @@ function getMainMobileSection(css: string): string {
|
||||
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", () => {
|
||||
const syntheticCss = `
|
||||
@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