From fe37673c950711b04fc90e1f20f60365c941da5e Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 10 Aug 2026 12:01:35 +0200 Subject: [PATCH] fix: stabilize rail and resize interactions --- apps/docs/docs/management/boards/index.mdx | 2 +- .../boards/(content)/_client.module.css | 5 +- .../board/sections/grid/grid-editor.css | 5 -- .../board/sections/grid/grid-editor.tsx | 34 ++++++--- .../board/sections/grid/section-grid.tsx | 4 +- e2e/board-grid.spec.ts | 73 ++++++++++++++++++- 6 files changed, 102 insertions(+), 21 deletions(-) diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index cfc02a4d5..f10ead270 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -134,7 +134,7 @@ For example, a 12-column layout with a two-column left rail leaves ten columns f Enabling or widening a rail reflows canvas items into the remaining columns. Disabling a rail moves its contents back into the next available canvas positions. -Rails fill the available window height and remain fixed while the page scrolls. +Rails fill the available window height and remain fixed while the page scrolls. They do not grow or scroll independently. Apps, widgets, and Containers inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode, where each rail shows its name and boundary. #### Breakpoint diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css b/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css index 8514e86d9..00ca097b5 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css @@ -21,10 +21,7 @@ height: var(--board-gutter-viewport-height); padding-top: 24px; margin-top: -24px; - overflow-x: hidden; - overflow-y: auto; - overscroll-behavior: contain; - scrollbar-gutter: stable; + overflow: clip; } .gutter[data-board-editing="true"] { 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 8ede12b64..457a04d29 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css @@ -116,11 +116,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr pointer-events: none; } -.board-grid-resize-outline[data-grid-resize-valid="false"] { - background: color-mix(in srgb, var(--mantine-color-red-filled) 8%, transparent); - border-color: var(--mantine-color-red-filled); -} - .board-grid-editor[data-dnd-drop-target="true"] { outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent); outline-offset: 4px; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx index ea80787b3..ff186c583 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx @@ -59,6 +59,7 @@ interface GridEditorProps { sectionId: string; columnCount: number; rowCount: number; + maxRowCount: number | null; placements: readonly SectionGridPlacement[]; className: string; } @@ -707,7 +708,14 @@ const GridCollisionSynchronizer = ({ interaction }: { interaction: GridInteracti }; /** A section-local dnd-kit canvas registered with the board transaction. */ -export default function GridEditor({ sectionId, columnCount, rowCount, placements, className }: GridEditorProps) { +export default function GridEditor({ + sectionId, + columnCount, + rowCount, + maxRowCount, + placements, + className, +}: GridEditorProps) { const t = useI18n(); const { section, items, innerSections } = useSectionContext(); const { interaction, registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditor(); @@ -744,7 +752,7 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement interaction && (interaction.sourceGridId === sectionId || interaction.targetGridId === sectionId); const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? renderPlacements); const renderedRowCount = - section.kind !== "container" && participatesInInteraction + maxRowCount === null && participatesInInteraction ? Math.max(rowCount, previewRowCount) + (growsDuringDrag ? 1 : 0) : rowCount; @@ -775,14 +783,14 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement return registerGrid({ id: sectionId, columnCount, - maxRowCount: section.kind === "container" ? rowCount : null, + maxRowCount, parentGridId, ownerPlacementId, placements, depth, element, }); - }, [columnCount, depth, ownerPlacementId, parentGridId, placements, registerGrid, rowCount, section.kind, sectionId]); + }, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, placements, registerGrid, sectionId]); useLayoutEffect(() => { const viewport = gridRef.current?.parentElement; @@ -824,7 +832,7 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement placement={placement} label={label} columnCount={columnCount} - maxRowCount={section.kind === "container" ? rowCount : null} + maxRowCount={maxRowCount} /> ); })} @@ -1044,6 +1052,7 @@ const GridResizeHandles = ({ y: number; direction: GridResizeDirection; visualScale: { x: number; y: number }; + placement: SectionGridPlacement; } | null>(null); const start = (direction: GridResizeDirection) => @@ -1077,7 +1086,14 @@ const GridResizeHandles = ({ cancelResize(); return; } - pointerStartRef.current = { id: event.pointerId, x: event.clientX, y: event.clientY, direction, visualScale }; + pointerStartRef.current = { + id: event.pointerId, + x: event.clientX, + y: event.clientY, + direction, + visualScale, + placement: { ...placement }, + }; }; const handlePointerMove = (event: ReactPointerEvent) => { @@ -1095,14 +1111,14 @@ const GridResizeHandles = ({ const valid = update(started.direction, snappedDelta.x, snappedDelta.y); setContinuousResize({ placement: getContinuousResizePlacement({ - placement, + placement: started.placement, direction: started.direction, deltaColumns: continuousDelta.x, deltaRows: continuousDelta.y, columnCount, maxRowCount, - minWidth: placement.minW ?? 1, - minHeight: placement.minH ?? 1, + minWidth: started.placement.minW ?? 1, + minHeight: started.placement.minH ?? 1, }), valid, }); diff --git a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx index ab1b1fb4b..664ea2d15 100644 --- a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx +++ b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx @@ -150,6 +150,7 @@ export const SectionGrid = ({ const minimumViewportRowCount = useMinimumViewportRowCount(section.kind === "empty", canvasScale); const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements)); const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount); + const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null; const logicalWidth = getLogicalTrackSize(columnCount); const logicalHeight = getLogicalTrackSize(rowCount); const canvasAttributes = isEditMode @@ -164,7 +165,7 @@ export const SectionGrid = ({ innerSections: displayedInnerSections, integrations, columnCount, - maxRowCount: section.kind === "container" ? rowCount : null, + maxRowCount, announce, }} > @@ -182,6 +183,7 @@ export const SectionGrid = ({ sectionId={section.id} columnCount={columnCount} rowCount={rowCount} + maxRowCount={maxRowCount} placements={displayPlacements} className={combineClasses("board-grid-editor", classes.editorGrid)} /> diff --git a/e2e/board-grid.spec.ts b/e2e/board-grid.spec.ts index 8a5926205..c6336e7e9 100644 --- a/e2e/board-grid.spec.ts +++ b/e2e/board-grid.spec.ts @@ -95,7 +95,8 @@ describe("Board grid", () => { await expect(rail).toBeVisible(); await expect(rail).toHaveAttribute("data-board-gutter", "left"); await expect(railRootSection).toHaveAttribute("data-rail-placement", "left"); - await expect(rail).toHaveCSS("overflow-x", "hidden"); + await expect(rail).toHaveCSS("overflow-x", "clip"); + await expect(rail).toHaveCSS("overflow-y", "clip"); const initialRailBox = await expectBoundingBoxAsync(rail); const initialRailRootBox = await expectBoundingBoxAsync(railRootSection); expect(initialRailRootBox.width).toBeCloseTo(initialRailBox.width, 1); @@ -240,6 +241,7 @@ describe("Board grid", () => { const viewMainSectionBox = await expectBoundingBoxAsync(mainSection); const viewFirstItemBox = await expectBoundingBoxAsync(logicalTile); + const viewScrollY = await page.evaluate(() => window.scrollY); runtimeResources.enterEditMode(); await editToggle.click(); await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(3); @@ -285,6 +287,39 @@ describe("Board grid", () => { await page.mouse.up(); await expect.poll(async () => await readEditorGridSnapshotAsync(railGrid)).toEqual(railSnapshot); await rail.locator('[data-e2e-rail-overflow-fixture="true"]').evaluate((element) => element.remove()); + await rail.evaluate((element) => { + const overflowFixture = document.createElement("div"); + overflowFixture.dataset.e2eRailOverflowFixture = "true"; + overflowFixture.style.position = "absolute"; + overflowFixture.style.insetBlockStart = "100%"; + overflowFixture.style.width = "1px"; + overflowFixture.style.height = "500px"; + overflowFixture.style.pointerEvents = "none"; + element.appendChild(overflowFixture); + }); + await expect + .poll(async () => await rail.evaluate((element) => element.scrollHeight - element.clientHeight)) + .toBeGreaterThan(0); + const fixedRailGridHeight = (await expectBoundingBoxAsync(railGrid)).height; + const fixedRailHeight = (await expectBoundingBoxAsync(rail)).height; + const verticalRailItemBox = await expectBoundingBoxAsync(railItem); + await page.mouse.move( + verticalRailItemBox.x + verticalRailItemBox.width / 2, + verticalRailItemBox.y + verticalRailItemBox.height / 2, + ); + await page.mouse.down(); + await page.mouse.move(verticalRailItemBox.x + verticalRailItemBox.width / 2 + 8, verticalRailItemBox.y); + await expect(railItem).toHaveAttribute("data-dnd-drag-source", "true"); + await page.mouse.move(railBox.x + railBox.width / 2, railBox.y + railBox.height - 2, { steps: 12 }); + await page.waitForTimeout(250); + expect(await rail.evaluate((element) => element.scrollTop)).toBe(0); + expect((await expectBoundingBoxAsync(rail)).height).toBeCloseTo(fixedRailHeight, 1); + expect((await expectBoundingBoxAsync(railGrid)).height).toBeCloseTo(fixedRailGridHeight, 1); + await page.keyboard.press("Escape"); + await page.mouse.up(); + await expect.poll(async () => await readEditorGridSnapshotAsync(railGrid)).toEqual(railSnapshot); + await rail.locator('[data-e2e-rail-overflow-fixture="true"]').evaluate((element) => element.remove()); + await page.evaluate((scrollY) => window.scrollTo({ top: scrollY, behavior: "auto" }), viewScrollY); await expect(logicalTile).toHaveCSS("overflow-x", "hidden"); await expect(logicalTile).toHaveCSS("overflow-y", "auto"); await logicalTile.evaluate((element) => { @@ -767,6 +802,42 @@ describe("Board grid", () => { await expect(eastResizeHandle).not.toHaveAttribute("data-grid-resize-disabled"); await resizeLocatorByImmediateReleaseAsync(page, eastResizeHandle, logicalCellPitch * canvasScale, 0); await expect(containerSection).toHaveAttribute("data-grid-w", "2"); + await expectGridEntryGeometrySettledAsync(containerSection); + await expect(eastResizeHandle).not.toHaveAttribute("data-grid-resize-disabled"); + + const expandingEastResizeBox = await expectBoundingBoxAsync(eastResizeHandle); + const expandingEastResizeStart = { + x: expandingEastResizeBox.x + expandingEastResizeBox.width / 2, + y: expandingEastResizeBox.y + expandingEastResizeBox.height / 2, + }; + const expandingContainerBox = await expectBoundingBoxAsync(containerSection); + const eastHandleGrabOffset = expandingContainerBox.x + expandingContainerBox.width - expandingEastResizeStart.x; + const continuousExpansion = logicalCellPitch * canvasScale * 1.2; + await page.mouse.move(expandingEastResizeStart.x, expandingEastResizeStart.y); + await page.mouse.down(); + await expect(page.locator("body")).toHaveAttribute("data-board-grid-interacting", "resize"); + await page.mouse.move(expandingEastResizeStart.x + continuousExpansion, expandingEastResizeStart.y, { + steps: 12, + }); + await expect(resizeOutline).toBeVisible(); + const expandedOutlineBox = await expectBoundingBoxAsync(resizeOutline); + expect(expandedOutlineBox.x + expandedOutlineBox.width).toBeCloseTo( + expandingEastResizeStart.x + continuousExpansion + eastHandleGrabOffset, + 1, + ); + expect( + await resizeOutline.evaluate((element) => { + const probe = document.createElement("span"); + probe.style.color = "var(--mantine-primary-color-filled)"; + element.appendChild(probe); + const usesPrimaryColor = getComputedStyle(element).borderTopColor === getComputedStyle(probe).color; + probe.remove(); + return usesPrimaryColor; + }), + ).toBe(true); + await page.keyboard.press("Escape"); + await page.mouse.up(); + await expect(containerSection).toHaveAttribute("data-grid-w", "2"); const northResizeHandle = containerSection.locator(':scope > [data-grid-resize-handle="n"]'); await expectTargetSizeAsync(northResizeHandle);