FN-7258: restore mobile board scroll on return
Preserve the board lane position when mobile users return from full-panel task detail. - Capture and restore board, column, project-content, and document scroll offsets for board detail navigation. - Retry board scroll restoration across bounded animation frames after remount and hydration. - Cover mobile and desktop Back to board behavior with focused tests and document the mobile scroll contract. - Add a patch changeset for the published CLI package. Files changed: .changeset/fn-7258-mobile-board-scroll.md | 7 ++ docs/dashboard-guide.md | 2 + .../__tests__/navigation-history.test.tsx | 111 ++++++++++++++++++--- .../hooks/__tests__/useBoardScrollRestore.test.ts | 43 +++++++- .../dashboard/app/hooks/useBoardScrollRestore.ts | 45 ++++++--- .../utils/__tests__/boardScrollSnapshot.test.ts | 91 +++++++++++++++-- .../dashboard/app/utils/boardScrollSnapshot.ts | 41 +++++++- 7 files changed, 305 insertions(+), 35 deletions(-) Fusion-Task-Id: FN-7258 Fusion-Task-Lineage: 97faf031-4244-4117-9948-45491c364134 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7258-mobile-board-scroll.md
Normal file
7
.changeset/fn-7258-mobile-board-scroll.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Preserve mobile board scroll after returning from task detail.
|
||||||
|
category: fix
|
||||||
|
dev: Restores the mobile board/card scroll snapshot after Back to board remounts the board.
|
||||||
@@ -17,6 +17,8 @@ The installed mobile/PWA home-screen icons are generated from `packages/dashboar
|
|||||||
The dashboard now handles browser back navigation consistently on desktop and mobile.
|
The dashboard now handles browser back navigation consistently on desktop and mobile.
|
||||||
Using Back will first dismiss open modals and then step back through in-app view changes before leaving the app.
|
Using Back will first dismiss open modals and then step back through in-app view changes before leaving the app.
|
||||||
When task detail is open from a board card, mobile list row, right-dock/activity/onboarding link, deep link, or another task detail link, one browser/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).
|
When task detail is open from a board card, mobile list row, right-dock/activity/onboarding link, deep link, or another task detail link, one browser/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).
|
||||||
|
On mobile board-card detail, **Back to board** also restores the prior board/card scroll position so the same lane context remains visible.
|
||||||
|
<!-- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -155,20 +155,24 @@ vi.mock("../../components/Board", () => ({
|
|||||||
onOpenDetail: (task: Task) => void;
|
onOpenDetail: (task: Task) => void;
|
||||||
onOpenDetailWithTab?: (task: Task, initialTab: "changes" | "retries" | "workflow") => void;
|
onOpenDetailWithTab?: (task: Task, initialTab: "changes" | "retries" | "workflow") => void;
|
||||||
}) => (
|
}) => (
|
||||||
<div data-testid="board-view">
|
<main id="board" className="board" data-testid="board-view">
|
||||||
{tasks.map((task) => (
|
<section className="column" data-column="todo">
|
||||||
<div key={task.id}>
|
<div className="column-body" data-testid="todo-column-body">
|
||||||
<button type="button" data-testid={`open-task-${task.id}`} onClick={() => onOpenDetail(task)}>
|
{tasks.map((task) => (
|
||||||
{task.title}
|
<div key={task.id}>
|
||||||
</button>
|
<button type="button" data-testid={`open-task-${task.id}`} onClick={() => onOpenDetail(task)}>
|
||||||
{task.modifiedFiles && task.modifiedFiles.length > 0 ? (
|
{task.title}
|
||||||
<button type="button" data-testid={`open-task-changes-${task.id}`} onClick={() => onOpenDetailWithTab?.(task, "changes")}>
|
</button>
|
||||||
Files changed
|
{task.modifiedFiles && task.modifiedFiles.length > 0 ? (
|
||||||
</button>
|
<button type="button" data-testid={`open-task-changes-${task.id}`} onClick={() => onOpenDetailWithTab?.(task, "changes")}>
|
||||||
) : null}
|
Files changed
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</section>
|
||||||
</div>
|
</main>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -674,6 +678,87 @@ describe("Navigation history integration", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns desktop board-detail Back to board to the board without breaking history", async () => {
|
||||||
|
mockUseViewportMode.mockReturnValue("desktop");
|
||||||
|
const task = makeTask("FN-1", "Desktop Detail Card");
|
||||||
|
mockUseTasks.mockImplementation(() => ({
|
||||||
|
tasks: [task],
|
||||||
|
createTask: mockCreateTask,
|
||||||
|
moveTask: vi.fn(),
|
||||||
|
deleteTask: vi.fn(),
|
||||||
|
mergeTask: vi.fn(),
|
||||||
|
retryTask: vi.fn(),
|
||||||
|
updateTask: vi.fn(),
|
||||||
|
duplicateTask: vi.fn(),
|
||||||
|
archiveTask: vi.fn(),
|
||||||
|
unarchiveTask: vi.fn(),
|
||||||
|
archiveAllDone: vi.fn(),
|
||||||
|
refreshTasks: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await renderAppAndWait();
|
||||||
|
const pushCallsBefore = (window.history.pushState as any).mock.calls.length;
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("open-task-FN-1"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("task-detail-main-panel-content")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("board-view")).toBeNull();
|
||||||
|
});
|
||||||
|
expect((window.history.pushState as any).mock.calls.length).toBeGreaterThan(pushCallsBefore);
|
||||||
|
|
||||||
|
// FNXC:BoardNavigation 2026-06-29-20:45: Desktop board-card detail keeps the same full-panel Back-to-board history contract while mobile adds scroll restoration coverage.
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Back to board" }));
|
||||||
|
dispatchPopState({ navIndex: 0 });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull();
|
||||||
|
expect(screen.getByTestId("board-view")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores mobile board scroll after Back to board", async () => {
|
||||||
|
mockUseViewportMode.mockReturnValue("mobile");
|
||||||
|
const task = makeTask("FN-1", "Scrolled Mobile Card");
|
||||||
|
mockUseTasks.mockImplementation(() => ({
|
||||||
|
tasks: [task],
|
||||||
|
createTask: mockCreateTask,
|
||||||
|
moveTask: vi.fn(),
|
||||||
|
deleteTask: vi.fn(),
|
||||||
|
mergeTask: vi.fn(),
|
||||||
|
retryTask: vi.fn(),
|
||||||
|
updateTask: vi.fn(),
|
||||||
|
duplicateTask: vi.fn(),
|
||||||
|
archiveTask: vi.fn(),
|
||||||
|
unarchiveTask: vi.fn(),
|
||||||
|
archiveAllDone: vi.fn(),
|
||||||
|
refreshTasks: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await renderMobileAppAndWait();
|
||||||
|
|
||||||
|
const board = screen.getByTestId("board-view");
|
||||||
|
const todoBody = screen.getByTestId("todo-column-body");
|
||||||
|
board.scrollLeft = 240;
|
||||||
|
todoBody.scrollTop = 380;
|
||||||
|
|
||||||
|
// FNXC:BoardNavigation 2026-06-29-20:45: Mobile Back-to-board must return to the clicked card's horizontal board offset and vertical lane offset after the full-panel detail replaces the board.
|
||||||
|
fireEvent.click(screen.getByTestId("open-task-FN-1"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("task-detail-main-panel-content")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Back to board" }));
|
||||||
|
dispatchPopState({ navIndex: 0 });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull();
|
||||||
|
expect(screen.getByTestId("board-view").scrollLeft).toBe(240);
|
||||||
|
expect(screen.getByTestId("todo-column-body").scrollTop).toBe(380);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("opens board files-changed actions inline on the changes tab instead of in a modal", async () => {
|
it("opens board files-changed actions inline on the changes tab instead of in a modal", async () => {
|
||||||
const task = {
|
const task = {
|
||||||
...makeTask("FN-1", "Inline Changes Detail"),
|
...makeTask("FN-1", "Inline Changes Detail"),
|
||||||
|
|||||||
@@ -34,7 +34,15 @@ describe("useBoardScrollRestore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("restores the captured snapshot after returning to the board view", () => {
|
it("restores the captured snapshot after returning to the board view", () => {
|
||||||
const sentinel = { boardLeft: 42, boardTop: 7, columnTops: { c1: 3 } };
|
const sentinel = {
|
||||||
|
boardLeft: 42,
|
||||||
|
boardTop: 7,
|
||||||
|
columnTops: { c1: 3 },
|
||||||
|
projectContentLeft: 0,
|
||||||
|
projectContentTop: 0,
|
||||||
|
documentLeft: 0,
|
||||||
|
documentTop: 0,
|
||||||
|
};
|
||||||
mockedCapture.mockReturnValue(sentinel);
|
mockedCapture.mockReturnValue(sentinel);
|
||||||
|
|
||||||
// Make the double requestAnimationFrame fire synchronously so the restore
|
// Make the double requestAnimationFrame fire synchronously so the restore
|
||||||
@@ -67,4 +75,37 @@ describe("useBoardScrollRestore", () => {
|
|||||||
expect(mockedRestore).toHaveBeenCalledTimes(1);
|
expect(mockedRestore).toHaveBeenCalledTimes(1);
|
||||||
expect(mockedRestore).toHaveBeenCalledWith(sentinel);
|
expect(mockedRestore).toHaveBeenCalledWith(sentinel);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("retries while the board is not yet ready after returning to the board view", () => {
|
||||||
|
const sentinel = {
|
||||||
|
boardLeft: 240,
|
||||||
|
boardTop: 0,
|
||||||
|
columnTops: { todo: 380 },
|
||||||
|
projectContentLeft: 0,
|
||||||
|
projectContentTop: 0,
|
||||||
|
documentLeft: 0,
|
||||||
|
documentTop: 0,
|
||||||
|
};
|
||||||
|
mockedCapture.mockReturnValue(sentinel);
|
||||||
|
mockedRestore.mockReturnValueOnce(false).mockReturnValueOnce(true);
|
||||||
|
|
||||||
|
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => {
|
||||||
|
cb(0);
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ taskView }: { taskView: TaskView }) => useBoardScrollRestore(taskView),
|
||||||
|
{ initialProps: { taskView: "task-detail" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.capture();
|
||||||
|
result.current.requestRestore();
|
||||||
|
rerender({ taskView: "board" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockedRestore).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockedRestore).toHaveBeenLastCalledWith(sentinel);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/*
|
/*
|
||||||
FNXC:BoardNavigation 2026-06-24-00:00:
|
FNXC:BoardNavigation 2026-06-24-00:00:
|
||||||
Preserves horizontal board scroll and per-column vertical scroll across a board → task-detail → back-to-board round trip. capture() snapshots before opening detail; requestRestore() schedules a restore that fires (double requestAnimationFrame, after the board remounts) once the view returns to "board". Extracted from AppInner.
|
Preserves horizontal board scroll and per-column vertical scroll across a board → task-detail → back-to-board round trip. capture() snapshots before opening detail; requestRestore() schedules a restore that fires (double requestAnimationFrame, after the board remounts) once the view returns to "board". Extracted from AppInner.
|
||||||
|
|
||||||
|
FNXC:BoardNavigation 2026-06-29-20:45:
|
||||||
|
Mobile Back-to-board must restore the clicked-card board position after the full-panel detail unmounts. Retry the restore for a bounded sequence of animation frames because mobile board layout stabilization and workflow-board hydration can temporarily leave #board unavailable or reset its offsets after the first post-return frame.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
@@ -11,6 +14,8 @@ import {
|
|||||||
} from "../utils/boardScrollSnapshot";
|
} from "../utils/boardScrollSnapshot";
|
||||||
import type { TaskView } from "./useViewState";
|
import type { TaskView } from "./useViewState";
|
||||||
|
|
||||||
|
const MAX_RESTORE_ATTEMPTS = 6;
|
||||||
|
|
||||||
export interface UseBoardScrollRestoreResult {
|
export interface UseBoardScrollRestoreResult {
|
||||||
capture: () => void;
|
capture: () => void;
|
||||||
requestRestore: () => void;
|
requestRestore: () => void;
|
||||||
@@ -20,12 +25,6 @@ export function useBoardScrollRestore(taskView: TaskView): UseBoardScrollRestore
|
|||||||
const boardScrollSnapshotRef = useRef<BoardScrollSnapshot | null>(null);
|
const boardScrollSnapshotRef = useRef<BoardScrollSnapshot | null>(null);
|
||||||
const pendingBoardScrollRestoreRef = useRef(false);
|
const pendingBoardScrollRestoreRef = useRef(false);
|
||||||
|
|
||||||
const restore = useCallback(() => {
|
|
||||||
if (restoreBoardScrollSnapshot(boardScrollSnapshotRef.current)) {
|
|
||||||
pendingBoardScrollRestoreRef.current = false;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const capture = useCallback(() => {
|
const capture = useCallback(() => {
|
||||||
boardScrollSnapshotRef.current = captureBoardScrollSnapshot();
|
boardScrollSnapshotRef.current = captureBoardScrollSnapshot();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -42,16 +41,36 @@ export function useBoardScrollRestore(taskView: TaskView): UseBoardScrollRestore
|
|||||||
const cancelFrame = typeof window.cancelAnimationFrame === "function"
|
const cancelFrame = typeof window.cancelAnimationFrame === "function"
|
||||||
? window.cancelAnimationFrame.bind(window)
|
? window.cancelAnimationFrame.bind(window)
|
||||||
: window.clearTimeout.bind(window);
|
: window.clearTimeout.bind(window);
|
||||||
let firstFrame = 0;
|
const frameIds: number[] = [];
|
||||||
let secondFrame = 0;
|
let attempts = 0;
|
||||||
firstFrame = scheduleFrame(() => {
|
let cancelled = false;
|
||||||
secondFrame = scheduleFrame(restore);
|
|
||||||
|
const schedule = (callback: FrameRequestCallback) => {
|
||||||
|
const id = scheduleFrame(callback);
|
||||||
|
frameIds.push(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const attemptRestore = () => {
|
||||||
|
if (cancelled || !pendingBoardScrollRestoreRef.current) return;
|
||||||
|
attempts += 1;
|
||||||
|
const restored = restoreBoardScrollSnapshot(boardScrollSnapshotRef.current);
|
||||||
|
if (restored) {
|
||||||
|
pendingBoardScrollRestoreRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempts < MAX_RESTORE_ATTEMPTS) {
|
||||||
|
schedule(attemptRestore);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
schedule(() => {
|
||||||
|
schedule(attemptRestore);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelFrame(firstFrame);
|
cancelled = true;
|
||||||
cancelFrame(secondFrame);
|
frameIds.forEach(cancelFrame);
|
||||||
};
|
};
|
||||||
}, [restore, taskView]);
|
}, [taskView]);
|
||||||
|
|
||||||
return { capture, requestRestore };
|
return { capture, requestRestore };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,31 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { captureBoardScrollSnapshot, restoreBoardScrollSnapshot } from "../boardScrollSnapshot";
|
import { captureBoardScrollSnapshot, restoreBoardScrollSnapshot } from "../boardScrollSnapshot";
|
||||||
|
|
||||||
describe("boardScrollSnapshot", () => {
|
describe("boardScrollSnapshot", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
delete (document as Document & { scrollingElement?: Element | null }).scrollingElement;
|
||||||
|
Object.defineProperty(window, "scrollX", { configurable: true, writable: true, value: 0 });
|
||||||
|
Object.defineProperty(window, "scrollY", { configurable: true, writable: true, value: 0 });
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
it("round-trips board horizontal scroll and per-column vertical scroll", () => {
|
it("round-trips board horizontal scroll and per-column vertical scroll", () => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<main id="board">
|
<div class="project-content">
|
||||||
<section class="column" data-column="todo"><div class="column-body"></div></section>
|
<main id="board">
|
||||||
<section class="column" data-column="in-progress"><div class="column-body"></div></section>
|
<section class="column" data-column="todo"><div class="column-body"></div></section>
|
||||||
</main>
|
<section class="column" data-column="in-progress"><div class="column-body"></div></section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
const projectContent = document.querySelector(".project-content") as HTMLElement;
|
||||||
const board = document.getElementById("board") as HTMLElement;
|
const board = document.getElementById("board") as HTMLElement;
|
||||||
const todoBody = document.querySelector('[data-column="todo"] .column-body') as HTMLElement;
|
const todoBody = document.querySelector('[data-column="todo"] .column-body') as HTMLElement;
|
||||||
const activeBody = document.querySelector('[data-column="in-progress"] .column-body') as HTMLElement;
|
const activeBody = document.querySelector('[data-column="in-progress"] .column-body') as HTMLElement;
|
||||||
|
|
||||||
|
projectContent.scrollLeft = 4;
|
||||||
|
projectContent.scrollTop = 22;
|
||||||
board.scrollLeft = 240;
|
board.scrollLeft = 240;
|
||||||
board.scrollTop = 12;
|
board.scrollTop = 12;
|
||||||
todoBody.scrollTop = 380;
|
todoBody.scrollTop = 380;
|
||||||
@@ -20,22 +33,88 @@ describe("boardScrollSnapshot", () => {
|
|||||||
|
|
||||||
const snapshot = captureBoardScrollSnapshot();
|
const snapshot = captureBoardScrollSnapshot();
|
||||||
|
|
||||||
|
projectContent.scrollLeft = 0;
|
||||||
|
projectContent.scrollTop = 0;
|
||||||
board.scrollLeft = 0;
|
board.scrollLeft = 0;
|
||||||
board.scrollTop = 0;
|
board.scrollTop = 0;
|
||||||
todoBody.scrollTop = 0;
|
todoBody.scrollTop = 0;
|
||||||
activeBody.scrollTop = 0;
|
activeBody.scrollTop = 0;
|
||||||
|
|
||||||
expect(restoreBoardScrollSnapshot(snapshot)).toBe(true);
|
expect(restoreBoardScrollSnapshot(snapshot)).toBe(true);
|
||||||
|
expect(projectContent.scrollLeft).toBe(4);
|
||||||
|
expect(projectContent.scrollTop).toBe(22);
|
||||||
expect(board.scrollLeft).toBe(240);
|
expect(board.scrollLeft).toBe(240);
|
||||||
expect(board.scrollTop).toBe(12);
|
expect(board.scrollTop).toBe(12);
|
||||||
expect(todoBody.scrollTop).toBe(380);
|
expect(todoBody.scrollTop).toBe(380);
|
||||||
expect(activeBody.scrollTop).toBe(95);
|
expect(activeBody.scrollTop).toBe(95);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("round-trips document scroll without requiring the project-content shell", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<main id="board">
|
||||||
|
<section class="column" data-column="todo"><div class="column-body"></div></section>
|
||||||
|
</main>
|
||||||
|
`;
|
||||||
|
Object.defineProperty(document, "scrollingElement", {
|
||||||
|
configurable: true,
|
||||||
|
value: document.documentElement,
|
||||||
|
});
|
||||||
|
Object.defineProperty(window, "scrollX", { configurable: true, writable: true, value: 0 });
|
||||||
|
Object.defineProperty(window, "scrollY", { configurable: true, writable: true, value: 0 });
|
||||||
|
const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation((leftOrOptions?: number | ScrollToOptions, top?: number) => {
|
||||||
|
if (typeof leftOrOptions === "number") {
|
||||||
|
Object.defineProperty(window, "scrollX", { configurable: true, writable: true, value: leftOrOptions });
|
||||||
|
Object.defineProperty(window, "scrollY", { configurable: true, writable: true, value: top ?? window.scrollY });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object.defineProperty(window, "scrollX", { configurable: true, writable: true, value: leftOrOptions?.left ?? window.scrollX });
|
||||||
|
Object.defineProperty(window, "scrollY", { configurable: true, writable: true, value: leftOrOptions?.top ?? window.scrollY });
|
||||||
|
});
|
||||||
|
const board = document.getElementById("board") as HTMLElement;
|
||||||
|
|
||||||
|
board.scrollLeft = 40;
|
||||||
|
board.scrollTop = 8;
|
||||||
|
document.documentElement.scrollLeft = 13;
|
||||||
|
document.documentElement.scrollTop = 144;
|
||||||
|
|
||||||
|
const snapshot = captureBoardScrollSnapshot();
|
||||||
|
|
||||||
|
expect(snapshot).toMatchObject({
|
||||||
|
boardLeft: 40,
|
||||||
|
boardTop: 8,
|
||||||
|
projectContentLeft: 0,
|
||||||
|
projectContentTop: 0,
|
||||||
|
documentLeft: 13,
|
||||||
|
documentTop: 144,
|
||||||
|
});
|
||||||
|
|
||||||
|
board.scrollLeft = 0;
|
||||||
|
board.scrollTop = 0;
|
||||||
|
document.documentElement.scrollLeft = 0;
|
||||||
|
document.documentElement.scrollTop = 0;
|
||||||
|
Object.defineProperty(window, "scrollX", { configurable: true, writable: true, value: 0 });
|
||||||
|
Object.defineProperty(window, "scrollY", { configurable: true, writable: true, value: 0 });
|
||||||
|
|
||||||
|
expect(restoreBoardScrollSnapshot(snapshot)).toBe(true);
|
||||||
|
expect(document.documentElement.scrollLeft).toBe(13);
|
||||||
|
expect(document.documentElement.scrollTop).toBe(144);
|
||||||
|
expect(board.scrollLeft).toBe(40);
|
||||||
|
expect(board.scrollTop).toBe(8);
|
||||||
|
expect(scrollTo).toHaveBeenCalledWith(13, 144);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns false when the board is not mounted", () => {
|
it("returns false when the board is not mounted", () => {
|
||||||
document.body.innerHTML = "";
|
document.body.innerHTML = "";
|
||||||
|
|
||||||
expect(captureBoardScrollSnapshot()).toBeNull();
|
expect(captureBoardScrollSnapshot()).toBeNull();
|
||||||
expect(restoreBoardScrollSnapshot({ boardLeft: 10, boardTop: 0, columnTops: {} })).toBe(false);
|
expect(restoreBoardScrollSnapshot({
|
||||||
|
boardLeft: 10,
|
||||||
|
boardTop: 0,
|
||||||
|
columnTops: {},
|
||||||
|
projectContentLeft: 0,
|
||||||
|
projectContentTop: 0,
|
||||||
|
documentLeft: 0,
|
||||||
|
documentTop: 0,
|
||||||
|
})).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ export interface BoardScrollSnapshot {
|
|||||||
boardLeft: number;
|
boardLeft: number;
|
||||||
boardTop: number;
|
boardTop: number;
|
||||||
columnTops: Record<string, number>;
|
columnTops: Record<string, number>;
|
||||||
|
projectContentLeft: number;
|
||||||
|
projectContentTop: number;
|
||||||
|
documentLeft: number;
|
||||||
|
documentTop: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBoardDocument(doc?: Document): Document | null {
|
function getBoardDocument(doc?: Document): Document | null {
|
||||||
@@ -12,12 +16,19 @@ function getBoardDocument(doc?: Document): Document | null {
|
|||||||
/*
|
/*
|
||||||
FNXC:BoardNavigation 2026-06-22-20:15:
|
FNXC:BoardNavigation 2026-06-22-20:15:
|
||||||
Board-card task detail replaces the board instead of overlaying it. Capture horizontal board scroll and per-column vertical scroll before opening detail, then restore after Back to board remounts the board so users return to the same lane/card context.
|
Board-card task detail replaces the board instead of overlaying it. Capture horizontal board scroll and per-column vertical scroll before opening detail, then restore after Back to board remounts the board so users return to the same lane/card context.
|
||||||
|
|
||||||
|
FNXC:BoardNavigation 2026-06-29-20:45:
|
||||||
|
Mobile Back-to-board must restore the clicked-card board position even when the browser parks scroll on the project-content/document shell during the full-panel task-detail transition. Snapshot the shell offsets alongside #board and .column-body; CSS keeps #board as the horizontal scroller and .column-body as the vertical lane scroller, but restoring the shell defensively prevents mobile viewport drift from hiding the clicked card after return.
|
||||||
*/
|
*/
|
||||||
export function captureBoardScrollSnapshot(doc?: Document): BoardScrollSnapshot | null {
|
export function captureBoardScrollSnapshot(doc?: Document): BoardScrollSnapshot | null {
|
||||||
const ownerDocument = getBoardDocument(doc);
|
const ownerDocument = getBoardDocument(doc);
|
||||||
const board = ownerDocument?.getElementById("board") as HTMLElement | null;
|
if (!ownerDocument) return null;
|
||||||
|
const board = ownerDocument.getElementById("board") as HTMLElement | null;
|
||||||
if (!board) return null;
|
if (!board) return null;
|
||||||
|
|
||||||
|
const projectContent = ownerDocument.querySelector<HTMLElement>(".project-content");
|
||||||
|
const scrollingElement = ownerDocument.scrollingElement as HTMLElement | null;
|
||||||
|
const defaultView = ownerDocument.defaultView;
|
||||||
const columnTops: Record<string, number> = {};
|
const columnTops: Record<string, number> = {};
|
||||||
board.querySelectorAll<HTMLElement>(".column[data-column]").forEach((column) => {
|
board.querySelectorAll<HTMLElement>(".column[data-column]").forEach((column) => {
|
||||||
const columnId = column.dataset.column;
|
const columnId = column.dataset.column;
|
||||||
@@ -31,15 +42,41 @@ export function captureBoardScrollSnapshot(doc?: Document): BoardScrollSnapshot
|
|||||||
boardLeft: board.scrollLeft,
|
boardLeft: board.scrollLeft,
|
||||||
boardTop: board.scrollTop,
|
boardTop: board.scrollTop,
|
||||||
columnTops,
|
columnTops,
|
||||||
|
projectContentLeft: projectContent?.scrollLeft ?? 0,
|
||||||
|
projectContentTop: projectContent?.scrollTop ?? 0,
|
||||||
|
documentLeft: scrollingElement?.scrollLeft ?? defaultView?.scrollX ?? 0,
|
||||||
|
documentTop: scrollingElement?.scrollTop ?? defaultView?.scrollY ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restoreBoardScrollSnapshot(snapshot: BoardScrollSnapshot | null, doc?: Document): boolean {
|
export function restoreBoardScrollSnapshot(snapshot: BoardScrollSnapshot | null, doc?: Document): boolean {
|
||||||
if (!snapshot) return false;
|
if (!snapshot) return false;
|
||||||
const ownerDocument = getBoardDocument(doc);
|
const ownerDocument = getBoardDocument(doc);
|
||||||
const board = ownerDocument?.getElementById("board") as HTMLElement | null;
|
if (!ownerDocument) return false;
|
||||||
|
const board = ownerDocument.getElementById("board") as HTMLElement | null;
|
||||||
if (!board) return false;
|
if (!board) return false;
|
||||||
|
|
||||||
|
const projectContent = ownerDocument.querySelector<HTMLElement>(".project-content");
|
||||||
|
const scrollingElement = ownerDocument.scrollingElement as HTMLElement | null;
|
||||||
|
const defaultView = ownerDocument.defaultView;
|
||||||
|
if (projectContent) {
|
||||||
|
projectContent.scrollLeft = snapshot.projectContentLeft ?? 0;
|
||||||
|
projectContent.scrollTop = snapshot.projectContentTop ?? 0;
|
||||||
|
}
|
||||||
|
if (scrollingElement) {
|
||||||
|
scrollingElement.scrollLeft = snapshot.documentLeft ?? 0;
|
||||||
|
scrollingElement.scrollTop = snapshot.documentTop ?? 0;
|
||||||
|
}
|
||||||
|
const documentLeft = snapshot.documentLeft ?? 0;
|
||||||
|
const documentTop = snapshot.documentTop ?? 0;
|
||||||
|
if (defaultView && (defaultView.scrollX !== documentLeft || defaultView.scrollY !== documentTop)) {
|
||||||
|
try {
|
||||||
|
defaultView.scrollTo(documentLeft, documentTop);
|
||||||
|
} catch {
|
||||||
|
// Test DOMs may expose scrollTo without implementing it; element offsets above still cover the restore contract.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
board.scrollLeft = snapshot.boardLeft;
|
board.scrollLeft = snapshot.boardLeft;
|
||||||
board.scrollTop = snapshot.boardTop;
|
board.scrollTop = snapshot.boardTop;
|
||||||
board.querySelectorAll<HTMLElement>(".column[data-column]").forEach((column) => {
|
board.querySelectorAll<HTMLElement>(".column[data-column]").forEach((column) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user