fix: contain collapsed layout and widget overflow

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:07 +02:00
1 parent 498481d539
commit 90cf0e1127
5 files changed
+124 -12

No files matched your search

@@ -77,7 +77,7 @@ export const getCollapsedDisplayLayout = <TPlacement extends GridPlacement>(
: placement,
);
return reflowDisplayLayout(displayPlacements);
return reflowCollapsedDisplayLayout(normalized, displayPlacements, collapsedItemIds, collapsedRowCount);
};
/**
@@ -118,19 +118,71 @@ const findFirstAvailableRow = (candidate: GridPlacement, placed: readonly GridPl
}
};
const reflowDisplayLayout = <TPlacement extends GridPlacement>(placements: readonly TPlacement[]): TPlacement[] => {
assertUniqueIds(placements);
const reflowCollapsedDisplayLayout = <TPlacement extends GridPlacement>(
expandedPlacements: readonly TPlacement[],
displayPlacements: readonly TPlacement[],
collapsedItemIds: ReadonlySet<string>,
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) =>
@@ -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 },
]);
});
@@ -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;
@@ -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));
+27 -1
View File
@@ -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);