FN-067: restore desktop Board drag navigation
Restore safe Board panning for desktop and tablet without changing mobile touch behavior. - Restore primary-mouse dragging from safe noninteractive Board surfaces, including empty-column text. - Keep cards, controls, editable content, native draggables, and mobile scrolling native. - Preserve no edge auto-scroll behavior and document the updated navigation contract. Files changed: .changeset/fn-067-board-safe-surface-pan.md | 7 + docs/dashboard-guide.md | 4 +- packages/dashboard/app/components/Board.css | 6 +- packages/dashboard/app/components/Board.tsx | 12 +- .../app/components/__tests__/Board.test.tsx | 73 +++++---- .../app/components/__tests__/board-mobile.test.tsx | 4 +- .../app/hooks/__tests__/useBoardMousePan.test.tsx | 163 +++++++++++---------- packages/dashboard/app/hooks/useBoardMousePan.ts | 37 +++-- 8 files changed, 172 insertions(+), 134 deletions(-) Fusion-Task-Id: FN-067 Fusion-Task-Lineage: 410d3401-1d41-40f1-b025-26248ecd37ff Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-067-board-safe-surface-pan.md
Normal file
7
.changeset/fn-067-board-safe-surface-pan.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Restore desktop Board dragging from safe empty-column surfaces.
|
||||
category: fix
|
||||
dev: Keeps mobile Board scrolling and column snapping unchanged while retaining no edge auto-scroll.
|
||||
@@ -326,8 +326,8 @@ Features:
|
||||
|
||||
<!-- FNXC:TaskCardMovement 2026-08-19-18:52: Board and List task relocation is intentionally menu-only, so native card dragging cannot bypass the same explicit Move to choice on desktop, keyboard, and touch surfaces. -->
|
||||
- Move a task from its context menu: right-click on desktop, use the overflow button or keyboard Context Menu/Shift+F10, or long-press on touch. When several legal destinations exist, open the grouped **Move to** submenu; a single destination remains a direct action. This changes task movement only—subtasks still use drag-to-reorder.
|
||||
<!-- FNXC:BoardNavigation 2026-08-20-02:44: Desktop Board background dragging is intentional only when the primary mouse starts directly on the bare overflowing Board root; text, cards, and controls stay native, and edge proximity can never start or continue scrolling. -->
|
||||
- On desktop, click-hold and drag the bare Board background to pan columns by actual pointer movement. Text, task cards, and controls retain their normal selection and interaction behavior; approaching an edge never auto-scrolls. Native wheel, trackpad, scrollbar, and keyboard scrolling remain available. Phones continue to use native touch scrolling and mobile-only column snapping.
|
||||
<!-- FNXC:BoardNavigation 2026-08-20-04:52: Desktop and tablet Board panning restores safe noninteractive descendants, including empty-column text, while cards and controls remain native. Edge proximity never starts or continues scrolling, and phones retain their separate native touch and column-snap ownership. -->
|
||||
- On desktop and tablet, click-hold and drag a safe Board surface, including empty-column text, to pan columns by actual pointer movement. Task cards, controls, editable content, and native-draggable targets retain their normal behavior; approaching an edge never auto-scrolls. Native wheel, trackpad, scrollbar, and keyboard scrolling remain available. Phones continue to use unchanged native touch scrolling and mobile-only column snapping.
|
||||
- Search/filter tasks (including working-branch and base-branch dropdown filters with explicit **No working branch** / **No base branch** options)
|
||||
- Working-branch and base-branch filter selections are persisted per project and restored across refresh/navigation
|
||||
- Column visibility controls
|
||||
|
||||
@@ -45,9 +45,9 @@ The board needs a neutral first-paint shell whenever workflow lanes are enabled
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:BoardNavigation 2026-08-20-02:44:
|
||||
Feedback appears only after a qualifying direct-root desktop drag crosses horizontal intent. It
|
||||
cannot suppress selection for text, cards, controls, the loading skeleton, or mobile touch.
|
||||
FNXC:BoardNavigation 2026-08-20-04:47:
|
||||
Feedback begins only after an eligible desktop/tablet safe-surface drag crosses horizontal intent.
|
||||
It never activates for excluded cards or controls, the unbound skeleton, or the disabled mobile path.
|
||||
*/
|
||||
.board.board-workflow-columns.is-mouse-panning {
|
||||
cursor: grabbing;
|
||||
|
||||
@@ -225,20 +225,20 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
boardRef.current = element;
|
||||
setBoardElement((current) => current === element ? current : element);
|
||||
}, []);
|
||||
const viewportMode = useViewportMode();
|
||||
useColumnScrollSnap(boardElement, { mobileOnly: true });
|
||||
/*
|
||||
FNXC:BoardNavigation 2026-08-20-02:44:
|
||||
Selected and All-workflows Board roots share one bounded mouse-pan hook, while the loading
|
||||
skeleton remains unbound and mobile touch remains owned by the separate snap hook. Direct-root
|
||||
drags alone can pan; this wiring must not turn cards, text, or controls into navigation surfaces.
|
||||
FNXC:BoardNavigation 2026-08-20-04:47:
|
||||
Selected and All-workflows live roots share safe-surface primary-mouse panning outside mobile;
|
||||
empty-column text may pan, while cards and controls remain native. The skeleton stays unbound,
|
||||
and the separate mobile-only snap owner plus mobile styles remain unchanged.
|
||||
*/
|
||||
const { isPanning: isBoardMousePanning, ...boardMousePanBindings } = useBoardMousePan(boardElement);
|
||||
const { isPanning: isBoardMousePanning, ...boardMousePanBindings } = useBoardMousePan(boardElement, viewportMode !== "mobile");
|
||||
const boardClassName = `board board-workflow-columns${isBoardMousePanning ? " is-mouse-panning" : ""}`;
|
||||
const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => {
|
||||
if (typeof document === "undefined") return null;
|
||||
return document.getElementById("header-workflow-slot");
|
||||
});
|
||||
const viewportMode = useViewportMode();
|
||||
// Normalized search-active signal: trimmed and non-empty
|
||||
const isSearchActive = searchQuery.trim() !== "";
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2041,7 +2041,7 @@ describe("Board", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("pans selected and All-workflows Board roots only while the pointer moves", async () => {
|
||||
it("restores safe empty-column descendant panning in selected and All-workflows Boards", async () => {
|
||||
enableFlag(
|
||||
{ "FN-1": "builtin:coding", "FN-2": "wf-custom" },
|
||||
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
|
||||
@@ -2050,45 +2050,41 @@ describe("Board", () => {
|
||||
|
||||
const selectedBoard = screen.getByRole("main") as HTMLElement;
|
||||
makeBoardHorizontallyScrollable(selectedBoard);
|
||||
fireEvent.pointerDown(selectedBoard, { button: 0, clientX: 100, clientY: 50, pointerId: 1, pointerType: "mouse" });
|
||||
fireEvent.pointerMove(selectedBoard, { clientX: 40, clientY: 50, pointerId: 1, pointerType: "mouse" });
|
||||
const selectedEmptyText = within(selectedBoard).getAllByText("No tasks")[0];
|
||||
fireEvent.pointerDown(selectedEmptyText, { button: 0, clientX: 100, clientY: 50, pointerId: 1, pointerType: "mouse" });
|
||||
fireEvent.pointerMove(selectedEmptyText, { clientX: 40, clientY: 50, pointerId: 1, pointerType: "mouse" });
|
||||
expect(selectedBoard.scrollLeft).toBe(160);
|
||||
expect(selectedBoard).toHaveClass("is-mouse-panning");
|
||||
fireEvent.pointerUp(selectedBoard, { pointerId: 1, pointerType: "mouse" });
|
||||
fireEvent.pointerUp(selectedEmptyText, { pointerId: 1, pointerType: "mouse" });
|
||||
expect(selectedBoard).not.toHaveClass("is-mouse-panning");
|
||||
expect(selectedBoard.scrollLeft).toBe(160);
|
||||
|
||||
await selectWorkflow(ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
const aggregateBoard = screen.getByRole("main") as HTMLElement;
|
||||
makeBoardHorizontallyScrollable(aggregateBoard);
|
||||
fireEvent.pointerDown(aggregateBoard, { button: 0, clientX: 100, clientY: 50, pointerId: 2, pointerType: "mouse" });
|
||||
fireEvent.pointerMove(aggregateBoard, { clientX: 40, clientY: 50, pointerId: 2, pointerType: "mouse" });
|
||||
expect(aggregateBoard.scrollLeft).toBe(160);
|
||||
fireEvent.pointerUp(aggregateBoard, { pointerId: 2, pointerType: "mouse" });
|
||||
const aggregateEmptyText = within(aggregateBoard).getAllByText("No tasks")[0];
|
||||
fireEvent.pointerDown(aggregateEmptyText, { button: 0, clientX: 100, clientY: 50, pointerId: 2, pointerType: "mouse" });
|
||||
fireEvent.pointerMove(aggregateEmptyText, { clientX: 40, clientY: 50, pointerId: 2, pointerType: "mouse" });
|
||||
expect(aggregateBoard.scrollLeft).toBe(160);
|
||||
fireEvent.pointerUp(aggregateEmptyText, { pointerId: 2, pointerType: "mouse" });
|
||||
expect(aggregateBoard).not.toHaveClass("is-mouse-panning");
|
||||
});
|
||||
|
||||
it("keeps empty text and non-overflow Board surfaces outside mouse panning", async () => {
|
||||
enableFlag(
|
||||
{ "FN-1": "builtin:coding", "FN-2": "wf-custom" },
|
||||
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
|
||||
);
|
||||
renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "intake" })] });
|
||||
it("keeps non-overflow Boards and stationary edgeward pointers outside continued panning", () => {
|
||||
enableFlag({});
|
||||
renderBoard();
|
||||
const board = screen.getByRole("main") as HTMLElement;
|
||||
makeBoardHorizontallyScrollable(board);
|
||||
|
||||
const selectedBoard = screen.getByRole("main") as HTMLElement;
|
||||
makeBoardHorizontallyScrollable(selectedBoard);
|
||||
dispatchMouseDrag(within(selectedBoard).getAllByText("No tasks")[0]);
|
||||
expect(selectedBoard.scrollLeft).toBe(100);
|
||||
fireEvent.pointerDown(within(board).getAllByText("No tasks")[0], { button: 0, clientX: 100, clientY: 50, pointerId: 1, pointerType: "mouse" });
|
||||
fireEvent.pointerMove(board, { clientX: 190, clientY: 50, pointerId: 1, pointerType: "mouse" });
|
||||
const scrollAfterPointerMove = board.scrollLeft;
|
||||
fireEvent.pointerUp(board, { pointerId: 1, pointerType: "mouse" });
|
||||
expect(board.scrollLeft).toBe(scrollAfterPointerMove);
|
||||
|
||||
Object.defineProperty(selectedBoard, "scrollWidth", { configurable: true, value: 200 });
|
||||
dispatchMouseDrag(selectedBoard, 2);
|
||||
expect(selectedBoard.scrollLeft).toBe(100);
|
||||
|
||||
await selectWorkflow(ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
const aggregateBoard = screen.getByRole("main") as HTMLElement;
|
||||
makeBoardHorizontallyScrollable(aggregateBoard);
|
||||
dispatchMouseDrag(within(aggregateBoard).getAllByText("No tasks")[0], 3);
|
||||
expect(aggregateBoard.scrollLeft).toBe(100);
|
||||
Object.defineProperty(board, "scrollWidth", { configurable: true, value: 200 });
|
||||
dispatchMouseDrag(board, 2);
|
||||
expect(board.scrollLeft).toBe(scrollAfterPointerMove);
|
||||
});
|
||||
|
||||
it("keeps touch and task-card interactions native", async () => {
|
||||
@@ -2123,6 +2119,29 @@ describe("Board", () => {
|
||||
expect(onQuickCreate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disables desktop mouse panning at the mobile viewport without changing touch ownership", () => {
|
||||
const harness = installMobileBoardStabilizationHarness();
|
||||
try {
|
||||
enableFlag({});
|
||||
renderBoard();
|
||||
const board = screen.getByRole("main") as HTMLElement;
|
||||
makeBoardHorizontallyScrollable(board);
|
||||
const emptyText = within(board).getAllByText("No tasks")[0];
|
||||
|
||||
fireEvent.pointerDown(emptyText, { button: 0, clientX: 100, clientY: 50, pointerId: 1, pointerType: "mouse" });
|
||||
fireEvent.pointerMove(emptyText, { clientX: 40, clientY: 50, pointerId: 1, pointerType: "mouse" });
|
||||
fireEvent.pointerUp(emptyText, { pointerId: 1, pointerType: "mouse" });
|
||||
fireEvent.pointerDown(emptyText, { button: 0, clientX: 100, clientY: 50, pointerId: 2, pointerType: "touch" });
|
||||
fireEvent.pointerMove(emptyText, { clientX: 40, clientY: 50, pointerId: 2, pointerType: "touch" });
|
||||
fireEvent.pointerUp(emptyText, { pointerId: 2, pointerType: "touch" });
|
||||
|
||||
expect(board.scrollLeft).toBe(100);
|
||||
expect(board).not.toHaveClass("is-mouse-panning");
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves all-workflows board scroll during mobile visualViewport refresh stabilization", async () => {
|
||||
const harness = installMobileBoardStabilizationHarness();
|
||||
try {
|
||||
|
||||
@@ -98,13 +98,13 @@ describe("mobile board magnetic column snap wiring (FN-8235)", () => {
|
||||
remain are the selected and aggregate workflow-column renders, and the point of this guard is that
|
||||
BOTH share the one scroll-snap hook, which still holds.
|
||||
*/
|
||||
it("shares mobile snap and bounded mouse-pan wiring across the selected and aggregate live boards", () => {
|
||||
it("shares mobile snap and explicitly disabled mouse-pan wiring across the selected and aggregate live boards", () => {
|
||||
const boardSource = readAppFile("components/Board.tsx");
|
||||
|
||||
expect(boardSource).toContain('import { useColumnScrollSnap } from "../hooks/useColumnScrollSnap";');
|
||||
expect(boardSource).toContain('import { useBoardMousePan } from "../hooks/useBoardMousePan";');
|
||||
expect(boardSource).toContain("useColumnScrollSnap(boardElement, { mobileOnly: true });");
|
||||
expect(boardSource).toContain("useBoardMousePan(boardElement)");
|
||||
expect(boardSource).toContain('useBoardMousePan(boardElement, viewportMode !== "mobile")');
|
||||
expect(boardSource.match(/ref=\{setBoardRef\}/g)).toHaveLength(2);
|
||||
expect(boardSource.match(/\{\.\.\.boardMousePanBindings\}/g)).toHaveLength(2);
|
||||
expect(boardSource.match(/className=\{boardClassName\}/g)).toHaveLength(2);
|
||||
|
||||
@@ -3,31 +3,29 @@ import { fireEvent, render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useBoardMousePan } from "../useBoardMousePan";
|
||||
|
||||
function PanHarness({ onClick = vi.fn() }: { onClick?: () => void }) {
|
||||
function PanHarness({ enabled = true, onClick = vi.fn() }: { enabled?: boolean; onClick?: () => void }) {
|
||||
const [boardElement, setBoardElement] = useState<HTMLElement | null>(null);
|
||||
const { isPanning, ...bindings } = useBoardMousePan(boardElement);
|
||||
const { isPanning, ...bindings } = useBoardMousePan(boardElement, enabled);
|
||||
return (
|
||||
<main
|
||||
ref={setBoardElement}
|
||||
className={isPanning ? "is-mouse-panning" : ""}
|
||||
data-panning={isPanning ? "true" : "false"}
|
||||
data-testid="board"
|
||||
onClick={onClick}
|
||||
{...bindings}
|
||||
>
|
||||
<main ref={setBoardElement} className={isPanning ? "is-mouse-panning" : ""} data-panning={String(isPanning)} data-testid="board" onClick={onClick} {...bindings}>
|
||||
<p data-testid="empty-text">No tasks</p>
|
||||
<div data-testid="surface">Safe surface</div>
|
||||
<button type="button" data-testid="button">Button</button>
|
||||
<input aria-label="Editable" data-testid="input" />
|
||||
<article data-testid="card">Card</article>
|
||||
<div contentEditable data-testid="contenteditable">Editable content</div>
|
||||
<div draggable data-testid="draggable">Draggable</div>
|
||||
<article data-id="FN-1" data-testid="card">Card</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function renderPanHarness(onClick = vi.fn()) {
|
||||
const result = render(<PanHarness onClick={onClick} />);
|
||||
function renderPanHarness(enabled = true, onClick = vi.fn()) {
|
||||
const result = render(<PanHarness enabled={enabled} onClick={onClick} />);
|
||||
const board = result.getByTestId("board");
|
||||
Object.defineProperty(board, "clientWidth", { configurable: true, value: 200 });
|
||||
Object.defineProperty(board, "scrollWidth", { configurable: true, value: 600 });
|
||||
Object.defineProperties(board, {
|
||||
clientWidth: { configurable: true, value: 200 },
|
||||
scrollWidth: { configurable: true, value: 600 },
|
||||
});
|
||||
return { ...result, board };
|
||||
}
|
||||
|
||||
@@ -44,15 +42,15 @@ function pointerUp(target: HTMLElement, pointerId = 1, pointerType = "mouse") {
|
||||
}
|
||||
|
||||
describe("useBoardMousePan", () => {
|
||||
it("pans direct Board-root primary mouse drags by inverse horizontal delta in both directions", () => {
|
||||
const { board } = renderPanHarness();
|
||||
it("pans safe descendants and the root by inverse horizontal delta in both directions", () => {
|
||||
const { board, getByTestId } = renderPanHarness();
|
||||
board.scrollLeft = 100;
|
||||
|
||||
pointerDown(board);
|
||||
pointerMove(board, 140);
|
||||
pointerDown(getByTestId("empty-text"));
|
||||
pointerMove(getByTestId("empty-text"), 140);
|
||||
expect(board.scrollLeft).toBe(60);
|
||||
expect(board).toHaveAttribute("data-panning", "true");
|
||||
pointerUp(board);
|
||||
pointerUp(getByTestId("empty-text"));
|
||||
|
||||
board.scrollLeft = 100;
|
||||
pointerDown(board, 100, 50, 2);
|
||||
@@ -60,79 +58,75 @@ describe("useBoardMousePan", () => {
|
||||
expect(board.scrollLeft).toBe(130);
|
||||
});
|
||||
|
||||
it("does not pan before horizontal intent, vertically, or without overflow", () => {
|
||||
const { board } = renderPanHarness();
|
||||
board.scrollLeft = 100;
|
||||
|
||||
pointerDown(board);
|
||||
pointerMove(board, 103);
|
||||
pointerMove(board, 104, 110);
|
||||
pointerUp(board);
|
||||
expect(board.scrollLeft).toBe(100);
|
||||
|
||||
Object.defineProperty(board, "scrollWidth", { configurable: true, value: 200 });
|
||||
pointerDown(board, 100, 50, 2);
|
||||
pointerMove(board, 140, 50, 2);
|
||||
pointerUp(board, 2);
|
||||
expect(board.scrollLeft).toBe(100);
|
||||
});
|
||||
|
||||
it("never starts from text, cards, or controls", () => {
|
||||
it("leaves excluded interactive, editable, native-draggable, and task surfaces native", () => {
|
||||
const { board, getByTestId } = renderPanHarness();
|
||||
board.scrollLeft = 100;
|
||||
|
||||
for (const [index, target] of [
|
||||
getByTestId("empty-text"),
|
||||
getByTestId("card"),
|
||||
getByTestId("button"),
|
||||
getByTestId("input"),
|
||||
].entries()) {
|
||||
const pointerId = index + 1;
|
||||
pointerDown(target, 100, 50, pointerId);
|
||||
pointerMove(target, 40, 50, pointerId);
|
||||
pointerUp(target, pointerId);
|
||||
for (const [index, target] of ["button", "input", "contenteditable", "draggable", "card"].map(getByTestId).entries()) {
|
||||
pointerDown(target, 100, 50, index + 1);
|
||||
pointerMove(target, 40, 50, index + 1);
|
||||
pointerUp(target, index + 1);
|
||||
}
|
||||
|
||||
expect(board.scrollLeft).toBe(100);
|
||||
expect(board).toHaveAttribute("data-panning", "false");
|
||||
});
|
||||
|
||||
it("ignores touch, pen, and non-primary mouse input", () => {
|
||||
const { board } = renderPanHarness();
|
||||
board.scrollLeft = 100;
|
||||
|
||||
for (const [pointerType, pointerId] of [["touch", 1], ["pen", 2]] as const) {
|
||||
pointerDown(board, 100, 50, pointerId, pointerType);
|
||||
pointerMove(board, 40, 50, pointerId, pointerType);
|
||||
pointerUp(board, pointerId, pointerType);
|
||||
}
|
||||
pointerDown(board, 100, 50, 3, "mouse", 2);
|
||||
pointerMove(board, 40, 50, 3);
|
||||
pointerUp(board, 3);
|
||||
|
||||
expect(board.scrollLeft).toBe(100);
|
||||
expect(board).toHaveAttribute("data-panning", "false");
|
||||
});
|
||||
|
||||
it("only changes scrollLeft during pointer moves and suppresses one compatibility click after panning", () => {
|
||||
it("is inert when disabled for mobile and for touch, pen, or non-primary input", () => {
|
||||
const onClick = vi.fn();
|
||||
const { board } = renderPanHarness(onClick);
|
||||
const { board, getByTestId, rerender } = renderPanHarness(false, onClick);
|
||||
board.scrollLeft = 100;
|
||||
const surface = getByTestId("surface");
|
||||
|
||||
pointerDown(board);
|
||||
pointerMove(board, 190);
|
||||
const scrollAfterMove = board.scrollLeft;
|
||||
pointerUp(board);
|
||||
expect(board.scrollLeft).toBe(scrollAfterMove);
|
||||
fireEvent.click(board);
|
||||
fireEvent.click(board);
|
||||
|
||||
pointerDown(surface);
|
||||
pointerMove(surface, 40);
|
||||
pointerUp(surface);
|
||||
fireEvent.click(surface);
|
||||
expect(board.scrollLeft).toBe(100);
|
||||
expect(board).toHaveAttribute("data-panning", "false");
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<PanHarness />);
|
||||
board.scrollLeft = 100;
|
||||
for (const [pointerType, pointerId] of [["touch", 1], ["pen", 2]] as const) {
|
||||
pointerDown(getByTestId("surface"), 100, 50, pointerId, pointerType);
|
||||
pointerMove(getByTestId("surface"), 40, 50, pointerId, pointerType);
|
||||
pointerUp(getByTestId("surface"), pointerId, pointerType);
|
||||
}
|
||||
pointerDown(getByTestId("surface"), 100, 50, 3, "mouse", 2);
|
||||
pointerMove(getByTestId("surface"), 40, 50, 3);
|
||||
expect(board.scrollLeft).toBe(100);
|
||||
});
|
||||
|
||||
it("cleans active feedback and click guards on cancellation, lost capture, and unmount", () => {
|
||||
it("does not pan before horizontal intent, without overflow, or after a stationary edgeward pointer", () => {
|
||||
const { board, getByTestId } = renderPanHarness();
|
||||
const surface = getByTestId("surface");
|
||||
board.scrollLeft = 100;
|
||||
|
||||
pointerDown(surface);
|
||||
pointerMove(surface, 103);
|
||||
pointerMove(surface, 104, 110);
|
||||
pointerUp(surface);
|
||||
expect(board.scrollLeft).toBe(100);
|
||||
|
||||
Object.defineProperty(board, "scrollWidth", { configurable: true, value: 200 });
|
||||
pointerDown(surface, 100, 50, 2);
|
||||
pointerMove(surface, 190, 50, 2);
|
||||
pointerUp(surface, 2);
|
||||
expect(board.scrollLeft).toBe(100);
|
||||
|
||||
Object.defineProperty(board, "scrollWidth", { configurable: true, value: 600 });
|
||||
pointerDown(surface, 100, 50, 3);
|
||||
pointerMove(surface, 190, 50, 3);
|
||||
const scrollAfterMove = board.scrollLeft;
|
||||
pointerUp(surface, 3);
|
||||
expect(board.scrollLeft).toBe(scrollAfterMove);
|
||||
});
|
||||
|
||||
it("suppresses one compatibility click after a pan and cleans up cancellation, lost capture, and unmount", () => {
|
||||
const onClick = vi.fn();
|
||||
const { board, unmount } = renderPanHarness(onClick);
|
||||
const { board, getByTestId, unmount } = renderPanHarness(true, onClick);
|
||||
const surface = getByTestId("surface");
|
||||
const setPointerCapture = vi.fn();
|
||||
const hasPointerCapture = vi.fn(() => true);
|
||||
const releasePointerCapture = vi.fn();
|
||||
@@ -142,22 +136,29 @@ describe("useBoardMousePan", () => {
|
||||
releasePointerCapture: { configurable: true, value: releasePointerCapture },
|
||||
});
|
||||
|
||||
pointerDown(board);
|
||||
pointerMove(board, 140);
|
||||
fireEvent.pointerCancel(board, { pointerId: 1 });
|
||||
expect(board).toHaveAttribute("data-panning", "false");
|
||||
fireEvent.click(board);
|
||||
pointerDown(surface);
|
||||
pointerMove(surface, 140);
|
||||
pointerUp(surface);
|
||||
fireEvent.click(surface);
|
||||
fireEvent.click(surface);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
|
||||
pointerDown(board, 100, 50, 2);
|
||||
pointerMove(board, 140, 50, 2);
|
||||
fireEvent.lostPointerCapture(board, { pointerId: 2 });
|
||||
pointerDown(surface, 100, 50, 2);
|
||||
pointerMove(surface, 140, 50, 2);
|
||||
fireEvent.pointerCancel(surface, { pointerId: 2 });
|
||||
expect(board).toHaveAttribute("data-panning", "false");
|
||||
fireEvent.click(board);
|
||||
fireEvent.click(surface);
|
||||
|
||||
pointerDown(board, 100, 50, 3);
|
||||
pointerDown(surface, 100, 50, 3);
|
||||
pointerMove(surface, 140, 50, 3);
|
||||
fireEvent.lostPointerCapture(surface, { pointerId: 3 });
|
||||
expect(board).toHaveAttribute("data-panning", "false");
|
||||
fireEvent.click(surface);
|
||||
|
||||
pointerDown(surface, 100, 50, 4);
|
||||
unmount();
|
||||
expect(setPointerCapture).toHaveBeenCalledWith(3);
|
||||
expect(releasePointerCapture).toHaveBeenCalledWith(3);
|
||||
expect(onClick).toHaveBeenCalledTimes(2);
|
||||
expect(setPointerCapture).toHaveBeenCalledWith(4);
|
||||
expect(releasePointerCapture).toHaveBeenCalledWith(4);
|
||||
expect(onClick).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,12 +29,21 @@ export interface BoardMousePanBindings {
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:BoardNavigation 2026-08-20-02:44:
|
||||
Desktop Board navigation intentionally pans only from a direct primary-mouse drag on the bare
|
||||
overflowing Board root. Descendant text and controls remain native, edge proximity can never
|
||||
continue scrolling after pointer movement stops, and mobile touch remains owned by its separate
|
||||
column-snap hook.
|
||||
FNXC:BoardNavigation 2026-08-20-04:47:
|
||||
Desktop and tablet Board navigation restores primary-mouse click-drag panning from safe,
|
||||
noninteractive descendants such as empty-column text. Interactive, editable, native-draggable,
|
||||
and task-card descendants keep their native behavior. Mobile owns touch scrolling and column snap,
|
||||
so this hook is disabled there; edge proximity and stationary pointers never continue scrolling.
|
||||
*/
|
||||
function isExcludedBoardPanTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) return true;
|
||||
return Boolean(
|
||||
target.closest(
|
||||
"button, a, input, textarea, select, option, label, summary, [contenteditable='true'], [draggable='true'], [data-id], [role='button'], [role='link'], [role='textbox'], [role='menuitem'], [role='checkbox'], [role='combobox'], [role='radio'], [role='slider'], [role='switch']",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function releasePointerCapture(session: BoardMousePanSession): void {
|
||||
const { element, pointerId } = session;
|
||||
try {
|
||||
@@ -46,7 +55,7 @@ function releasePointerCapture(session: BoardMousePanSession): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function useBoardMousePan(boardElement: HTMLElement | null): BoardMousePanBindings {
|
||||
export function useBoardMousePan(boardElement: HTMLElement | null, enabled: boolean): BoardMousePanBindings {
|
||||
const sessionRef = useRef<BoardMousePanSession | null>(null);
|
||||
const didPanRef = useRef(false);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
@@ -63,9 +72,10 @@ export function useBoardMousePan(boardElement: HTMLElement | null): BoardMousePa
|
||||
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
||||
const element = event.currentTarget;
|
||||
if (
|
||||
event.pointerType !== "mouse"
|
||||
!enabled
|
||||
|| event.pointerType !== "mouse"
|
||||
|| event.button !== 0
|
||||
|| event.target !== element
|
||||
|| isExcludedBoardPanTarget(event.target)
|
||||
|| element.scrollWidth <= element.clientWidth
|
||||
) {
|
||||
return;
|
||||
@@ -81,9 +91,10 @@ export function useBoardMousePan(boardElement: HTMLElement | null): BoardMousePa
|
||||
isPanning: false,
|
||||
};
|
||||
element.setPointerCapture?.(event.pointerId);
|
||||
}, []);
|
||||
}, [enabled]);
|
||||
|
||||
const onPointerMove = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (!enabled) return;
|
||||
const session = sessionRef.current;
|
||||
if (!session || session.pointerId !== event.pointerId) return;
|
||||
|
||||
@@ -100,7 +111,7 @@ export function useBoardMousePan(boardElement: HTMLElement | null): BoardMousePa
|
||||
|
||||
event.preventDefault();
|
||||
session.element.scrollLeft = session.startScrollLeft - deltaX;
|
||||
}, []);
|
||||
}, [enabled]);
|
||||
|
||||
const onPointerUp = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
||||
endSession(event.pointerId, false);
|
||||
@@ -115,11 +126,11 @@ export function useBoardMousePan(boardElement: HTMLElement | null): BoardMousePa
|
||||
}, [endSession]);
|
||||
|
||||
const onClickCapture = useCallback((event: ReactMouseEvent<HTMLElement>) => {
|
||||
if (!didPanRef.current) return;
|
||||
if (!enabled || !didPanRef.current) return;
|
||||
didPanRef.current = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => () => {
|
||||
const session = sessionRef.current;
|
||||
@@ -127,7 +138,7 @@ export function useBoardMousePan(boardElement: HTMLElement | null): BoardMousePa
|
||||
sessionRef.current = null;
|
||||
didPanRef.current = false;
|
||||
setIsPanning(false);
|
||||
}, [boardElement]);
|
||||
}, [boardElement, enabled]);
|
||||
|
||||
return {
|
||||
isPanning,
|
||||
|
||||
Reference in New Issue
Block a user