From 90cf0e112716707c150797690b850a237be42c73 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 3 Aug 2026 14:27:45 +0200 Subject: [PATCH] fix: contain collapsed layout and widget overflow --- .../src/components/board/layout/reflow.ts | 64 +++++++++++++++++-- .../board/layout/test/reflow.spec.ts | 38 ++++++++++- .../board/sections/grid/grid-editor.css | 4 +- .../board/sections/grid/grid-portal-host.tsx | 2 +- e2e/board-grid.spec.ts | 28 +++++++- 5 files changed, 124 insertions(+), 12 deletions(-) diff --git a/apps/nextjs/src/components/board/layout/reflow.ts b/apps/nextjs/src/components/board/layout/reflow.ts index fa53f147f..4aafd7611 100644 --- a/apps/nextjs/src/components/board/layout/reflow.ts +++ b/apps/nextjs/src/components/board/layout/reflow.ts @@ -77,7 +77,7 @@ export const getCollapsedDisplayLayout = ( : placement, ); - return reflowDisplayLayout(displayPlacements); + return reflowCollapsedDisplayLayout(normalized, displayPlacements, collapsedItemIds, collapsedRowCount); }; /** @@ -118,19 +118,71 @@ const findFirstAvailableRow = (candidate: GridPlacement, placed: readonly GridPl } }; -const reflowDisplayLayout = (placements: readonly TPlacement[]): TPlacement[] => { - assertUniqueIds(placements); +const reflowCollapsedDisplayLayout = ( + expandedPlacements: readonly TPlacement[], + displayPlacements: readonly TPlacement[], + collapsedItemIds: ReadonlySet, + collapsedRowCount: number, +): TPlacement[] => { + assertUniqueIds(expandedPlacements); + const displayById = new Map(displayPlacements.map((placement) => [placement.id, placement])); + const collapsedPlacements = expandedPlacements.filter((placement) => collapsedItemIds.has(placement.id)); const placed: TPlacement[] = []; - for (const candidate of placements.toSorted(comparePlacements)) { + for (const expandedPlacement of expandedPlacements.toSorted(comparePlacements)) { + const candidate = getRequiredPlacement(displayById, expandedPlacement.id); + const verticalShift = getCollapsedVerticalShift(expandedPlacement, collapsedPlacements, collapsedRowCount); placed.push({ ...candidate, - y: findFirstAvailableRow(candidate, placed), + y: findFirstAvailableRowAtOrAfter(candidate, placed, Math.max(0, candidate.y - verticalShift)), }); } const byId = new Map(placed.map((placement) => [placement.id, placement])); - return placements.map((placement) => getRequiredPlacement(byId, placement.id)); + return displayPlacements.map((placement) => getRequiredPlacement(byId, placement.id)); +}; + +const getCollapsedVerticalShift = ( + candidate: GridPlacement, + collapsedPlacements: readonly GridPlacement[], + collapsedRowCount: number, +) => { + const removedIntervals = collapsedPlacements + .filter( + (collapsed) => + collapsed.id !== candidate.id && + collapsed.y + collapsed.h <= candidate.y && + doGridPlacementsOverlapHorizontally(candidate, collapsed), + ) + .map((collapsed) => ({ start: collapsed.y + collapsedRowCount, end: collapsed.y + collapsed.h })) + .toSorted((first, second) => first.start - second.start || first.end - second.end); + + let shift = 0; + let intervalEnd = Number.NEGATIVE_INFINITY; + for (const interval of removedIntervals) { + const start = Math.max(interval.start, intervalEnd); + if (interval.end > start) shift += interval.end - start; + intervalEnd = Math.max(intervalEnd, interval.end); + } + return shift; +}; + +const doGridPlacementsOverlapHorizontally = (first: GridPlacement, second: GridPlacement) => + first.x < second.x + second.w && first.x + first.w > second.x; + +const findFirstAvailableRowAtOrAfter = ( + candidate: GridPlacement, + placed: readonly GridPlacement[], + minimumRow: number, +) => { + let y = minimumRow; + for (;;) { + const atRow = { ...candidate, y }; + const collisions = placed.filter((placement) => doGridPlacementsOverlap(atRow, placement)); + if (collisions.length === 0) return y; + + y = Math.max(...collisions.map((placement) => placement.y + placement.h)); + } }; const comparePlacements = (first: GridPlacement, second: GridPlacement) => diff --git a/apps/nextjs/src/components/board/layout/test/reflow.spec.ts b/apps/nextjs/src/components/board/layout/test/reflow.spec.ts index c841792ef..739c747f9 100644 --- a/apps/nextjs/src/components/board/layout/test/reflow.spec.ts +++ b/apps/nextjs/src/components/board/layout/test/reflow.spec.ts @@ -28,7 +28,7 @@ describe("dashboard layout reflow", () => { expect(hasOverlap(result)).toBe(false); }); - test("shrinks a collapsed inline container and pulls neighbors up", () => { + test("shrinks a collapsed inline container without compacting unrelated columns", () => { const result = getCollapsedDisplayLayout( [ { id: "section", x: 0, y: 0, w: 2, h: 3 }, @@ -44,7 +44,41 @@ describe("dashboard layout reflow", () => { expect(result).toEqual([ { id: "section", x: 0, y: 0, w: 2, h: 0.5 }, { id: "below", x: 0, y: 0.5, w: 2, h: 1 }, - { id: "other-lane", x: 2, y: 0, w: 1, h: 1 }, + { id: "other-lane", x: 2, y: 4, w: 1, h: 1 }, + ]); + }); + + test("preserves intentional gaps below a collapsed container", () => { + const result = getCollapsedDisplayLayout( + [ + { id: "section", x: 0, y: 1, w: 2, h: 3 }, + { id: "below", x: 0, y: 6, w: 2, h: 1 }, + { id: "above", x: 0, y: 0, w: 2, h: 1 }, + ], + { columnCount: 2, collapsedItemIds: new Set(["section"]) }, + ); + + expect(result).toEqual([ + { id: "section", x: 0, y: 1, w: 2, h: 0.5 }, + { id: "below", x: 0, y: 3.5, w: 2, h: 1 }, + { id: "above", x: 0, y: 0, w: 2, h: 1 }, + ]); + }); + + test("does not double-count side-by-side collapsed space", () => { + const result = getCollapsedDisplayLayout( + [ + { id: "left", x: 0, y: 0, w: 1, h: 3 }, + { id: "right", x: 1, y: 0, w: 1, h: 3 }, + { id: "below", x: 0, y: 3, w: 2, h: 1 }, + ], + { columnCount: 2, collapsedItemIds: new Set(["left", "right"]) }, + ); + + expect(result).toEqual([ + { id: "left", x: 0, y: 0, w: 1, h: 0.5 }, + { id: "right", x: 1, y: 0, w: 1, h: 0.5 }, + { id: "below", x: 0, y: 0.5, w: 2, h: 1 }, ]); }); diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.css b/apps/nextjs/src/components/board/sections/grid/grid-editor.css index 9d02c48c2..82a004f73 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css @@ -79,8 +79,8 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder { } .board-grid-content-mount, -.board-grid-item-content, -.board-grid-item-content > [data-editor-grid-entry] { +.board-grid-portal-content, +.board-grid-portal-content > [data-editor-grid-entry] { width: 100%; height: 100%; overflow: visible; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx b/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx index 8f23db2d1..5e5e9a297 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx @@ -50,7 +50,7 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => { if (existing) return existing; const container = document.createElement("div"); - container.className = "board-grid-item-content"; + container.className = "board-grid-portal-content"; container.dataset.boardGridPortal = id; containersRef.current.set(id, container); setContainers(new Map(containersRef.current)); diff --git a/e2e/board-grid.spec.ts b/e2e/board-grid.spec.ts index 8296b41b8..f8c99e44b 100644 --- a/e2e/board-grid.spec.ts +++ b/e2e/board-grid.spec.ts @@ -126,7 +126,8 @@ describe("Board grid", () => { const logicalTile = firstItem.locator("[data-grid-item-content]"); await expect(logicalTile).toHaveCount(1); await expectFixedLogicalTileAsync(logicalTile); - await expect(logicalTile).toHaveCSS("overflow", "hidden"); + await expect(logicalTile).toHaveCSS("overflow-x", "hidden"); + await expect(logicalTile).toHaveCSS("overflow-y", "auto"); await page.addStyleTag({ content: ".e2e-widget-overflow { overflow: visible; }" }); await expect(secondItem.locator("[data-grid-item-content]")).toHaveCSS("overflow", "visible"); await expect(canvas).toHaveAttribute("data-canvas-overflow", "false"); @@ -230,6 +231,30 @@ describe("Board grid", () => { await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(3); await expect(page.getByTestId("board-grid-editor-loading")).toHaveCount(0); await expect(page.getByTestId("board-canvas-row-count-button")).toHaveCount(0); + await expect(logicalTile).toHaveCSS("overflow-x", "hidden"); + await expect(logicalTile).toHaveCSS("overflow-y", "auto"); + await logicalTile.evaluate((element) => { + const overflowFixture = document.createElement("div"); + overflowFixture.dataset.e2eWidgetOverflowFixture = "true"; + overflowFixture.style.width = "800px"; + overflowFixture.style.minWidth = "800px"; + overflowFixture.style.height = "800px"; + overflowFixture.style.minHeight = "800px"; + overflowFixture.style.flex = "0 0 800px"; + element.appendChild(overflowFixture); + }); + await expect + .poll(async () => await logicalTile.evaluate((element) => element.scrollWidth - element.clientWidth)) + .toBeGreaterThan(0); + await expect + .poll(async () => await logicalTile.evaluate((element) => element.scrollHeight - element.clientHeight)) + .toBeGreaterThan(0); + await logicalTile.evaluate((element) => { + element.scrollTop = 100; + }); + await expect.poll(async () => await logicalTile.evaluate((element) => element.scrollTop)).toBeGreaterThan(0); + await expectDocumentNotHorizontallyScrollableAsync(page); + await logicalTile.locator('[data-e2e-widget-overflow-fixture="true"]').evaluate((element) => element.remove()); await expectBoundingBoxToMatchAsync(mainSection, viewMainSectionBox); await expectBoundingBoxToMatchAsync(logicalTile, viewFirstItemBox); await runtimeResources.waitForQuietAsync(); @@ -691,6 +716,7 @@ describe("Board grid", () => { await dragLocatorByAsync(page, northResizeHandle, 0, logicalCellPitch * canvasScale); await expect(containerSection).toHaveAttribute("data-grid-y", "1"); await expect(containerSection).toHaveAttribute("data-grid-h", "1"); + await expectGridEntryGeometrySettledAsync(containerSection); const minimumSectionBox = await expectBoundingBoxAsync(containerSection); const minimumHandleBox = await expectBoundingBoxAsync(northResizeHandle);