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);