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>
92 lines
4.0 KiB
TypeScript
92 lines
4.0 KiB
TypeScript
export interface BoardScrollSnapshot {
|
|
boardLeft: number;
|
|
boardTop: number;
|
|
columnTops: Record<string, number>;
|
|
projectContentLeft: number;
|
|
projectContentTop: number;
|
|
documentLeft: number;
|
|
documentTop: number;
|
|
}
|
|
|
|
function getBoardDocument(doc?: Document): Document | null {
|
|
if (doc) return doc;
|
|
return typeof document === "undefined" ? null : document;
|
|
}
|
|
|
|
/*
|
|
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.
|
|
|
|
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 {
|
|
const ownerDocument = getBoardDocument(doc);
|
|
if (!ownerDocument) return null;
|
|
const board = ownerDocument.getElementById("board") as HTMLElement | 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> = {};
|
|
board.querySelectorAll<HTMLElement>(".column[data-column]").forEach((column) => {
|
|
const columnId = column.dataset.column;
|
|
const body = column.querySelector<HTMLElement>(".column-body");
|
|
if (columnId && body) {
|
|
columnTops[columnId] = body.scrollTop;
|
|
}
|
|
});
|
|
|
|
return {
|
|
boardLeft: board.scrollLeft,
|
|
boardTop: board.scrollTop,
|
|
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 {
|
|
if (!snapshot) return false;
|
|
const ownerDocument = getBoardDocument(doc);
|
|
if (!ownerDocument) return false;
|
|
const board = ownerDocument.getElementById("board") as HTMLElement | null;
|
|
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.scrollTop = snapshot.boardTop;
|
|
board.querySelectorAll<HTMLElement>(".column[data-column]").forEach((column) => {
|
|
const columnId = column.dataset.column;
|
|
const body = column.querySelector<HTMLElement>(".column-body");
|
|
if (columnId && body && Object.prototype.hasOwnProperty.call(snapshot.columnTops, columnId)) {
|
|
body.scrollTop = snapshot.columnTops[columnId];
|
|
}
|
|
});
|
|
|
|
return true;
|
|
}
|