From 203711e570363dfbeaef3fcef638e3ca1ceb4599 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 9 Aug 2026 09:43:10 +0200 Subject: [PATCH] Fix board grid bounds and live growth --- .../src/components/board/layout/README.md | 4 +- .../src/components/board/layout/constants.ts | 1 - .../src/components/board/layout/geometry.ts | 7 ++ .../layout/scaled-board-canvas.module.css | 4 +- .../board/layout/scaled-board-canvas.tsx | 12 ++- .../board/layout/test/geometry.spec.ts | 9 +++ .../layout/test/scaled-board-canvas.spec.ts | 16 ++-- .../board/sections/empty-section.tsx | 2 +- .../board/sections/grid/grid-editor.css | 4 + .../board/sections/grid/grid-editor.tsx | 22 +++++- .../board/sections/grid/section-grid.tsx | 29 ++++++- e2e/board-grid.spec.ts | 79 ++++++++++++------- 12 files changed, 134 insertions(+), 55 deletions(-) diff --git a/apps/nextjs/src/components/board/layout/README.md b/apps/nextjs/src/components/board/layout/README.md index 8a12bc180..be60ab237 100644 --- a/apps/nextjs/src/components/board/layout/README.md +++ b/apps/nextjs/src/components/board/layout/README.md @@ -10,7 +10,9 @@ The board uses fixed logical units: Items keep the same logical dimensions at every viewport. The viewport reserves the painted size while the inner canvas remains at its logical dimensions, so a widget's `clientWidth`, `clientHeight`, and `ResizeObserver` measurements stay -stable. +stable. The visual canvas always fits the available width, the main root keeps +at least one viewport of height, and edit-mode previews extend it downward as +needed. The read-only renderer should: diff --git a/apps/nextjs/src/components/board/layout/constants.ts b/apps/nextjs/src/components/board/layout/constants.ts index 9c3103b4b..26e3f0765 100644 --- a/apps/nextjs/src/components/board/layout/constants.ts +++ b/apps/nextjs/src/components/board/layout/constants.ts @@ -5,6 +5,5 @@ export const LOGICAL_GRID_CELL_SIZE = 200; export const LOGICAL_GRID_GAP = 24; export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP; -export const MIN_ACCESSIBLE_CANVAS_SCALE = 0.75; export const COLLAPSED_SECTION_ROW_COUNT = 0.5; diff --git a/apps/nextjs/src/components/board/layout/geometry.ts b/apps/nextjs/src/components/board/layout/geometry.ts index 32b7fced4..449e74d4d 100644 --- a/apps/nextjs/src/components/board/layout/geometry.ts +++ b/apps/nextjs/src/components/board/layout/geometry.ts @@ -21,6 +21,13 @@ export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSPropert export const getLayoutRowCount = (placements: readonly GridCoordinates[]) => placements.reduce((rowCount, placement) => Math.max(rowCount, placement.y + placement.h), 0); +export const getGridRowCountForVisualHeight = (visualHeight: number, visualScale: number) => { + if (!Number.isFinite(visualHeight) || visualHeight <= 0) return 1; + if (!Number.isFinite(visualScale) || visualScale <= 0) return 1; + + return Math.max(1, Math.ceil((visualHeight / visualScale + LOGICAL_GRID_GAP) / LOGICAL_GRID_PITCH)); +}; + export const normalizeGridPlacement = ( placement: TPlacement, columnCount: number, diff --git a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css index 6c98e40a2..447cb638d 100644 --- a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css +++ b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css @@ -2,9 +2,7 @@ width: 100%; max-width: 100%; min-width: 0; - overflow: visible; - overflow-x: auto; - overscroll-behavior-inline: contain; + overflow: clip; } .viewport[data-board-hydrated="false"] { diff --git a/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx b/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx index 3d5de2b72..5844a9bd8 100644 --- a/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx +++ b/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx @@ -5,11 +5,8 @@ import { createContext, useContext, useEffect, useMemo, useState } from "react"; import { Box } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; -import { MIN_ACCESSIBLE_CANVAS_SCALE } from "./constants"; import classes from "./scaled-board-canvas.module.css"; -export { MIN_ACCESSIBLE_CANVAS_SCALE }; - const BoardCanvasScaleContext = createContext(1); export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext); @@ -18,7 +15,7 @@ export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth: if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1; if (availableWidth <= 0 || logicalWidth <= 0) return 1; - return Math.max(MIN_ACCESSIBLE_CANVAS_SCALE, availableWidth / logicalWidth); + return availableWidth / logicalWidth; }; export const calculateBoardUiScale = (canvasScale: number) => { @@ -36,9 +33,8 @@ interface ScaledBoardCanvasProps { /** * Scales the complete board as one surface while keeping widget layout in - * fixed logical pixels. The scale floor deliberately allows horizontal - * overflow so browser zoom still magnifies content instead of being cancelled - * by responsive downscaling. + * fixed logical pixels. The complete canvas always fits its viewport so board + * content can never widen the document or require horizontal scrolling. */ export const ScaledBoardCanvas = ({ logicalWidth, @@ -62,6 +58,7 @@ export const ScaledBoardCanvas = ({ [logicalWidth, resolvedAvailableWidth], ); const uiScale = calculateBoardUiScale(scale); + const inverseScale = scale > 0 ? 1 / scale : 1; const visualWidth = logicalWidth * scale; const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5; @@ -84,6 +81,7 @@ export const ScaledBoardCanvas = ({ className={classes.canvas} w={logicalWidth} style={{ + "--board-canvas-inverse-scale": inverseScale, "--board-canvas-ui-scale": uiScale, transform: `scale(${scale})`, }} diff --git a/apps/nextjs/src/components/board/layout/test/geometry.spec.ts b/apps/nextjs/src/components/board/layout/test/geometry.spec.ts index ed4c773cb..3d46398bb 100644 --- a/apps/nextjs/src/components/board/layout/test/geometry.spec.ts +++ b/apps/nextjs/src/components/board/layout/test/geometry.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { + getGridRowCountForVisualHeight, getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize, @@ -29,4 +30,12 @@ describe("fixed dashboard geometry", () => { expect(placement).toEqual({ id: "wide", x: 0, y: 0, w: 4, h: 2 }); expect(getLayoutRowCount([placement, { x: 0, y: 4, w: 1, h: 3 }])).toBe(7); }); + + test("derives enough logical rows to fill the visual viewport at any canvas scale", () => { + expect(getGridRowCountForVisualHeight(1200, 1)).toBe(6); + expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 1))).toBeGreaterThanOrEqual(1200); + + expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(11); + expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 0.5)) * 0.5).toBeGreaterThanOrEqual(1200); + }); }); diff --git a/apps/nextjs/src/components/board/layout/test/scaled-board-canvas.spec.ts b/apps/nextjs/src/components/board/layout/test/scaled-board-canvas.spec.ts index 83e14ea02..50fae3420 100644 --- a/apps/nextjs/src/components/board/layout/test/scaled-board-canvas.spec.ts +++ b/apps/nextjs/src/components/board/layout/test/scaled-board-canvas.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "vitest"; -import { calculateBoardCanvasScale, calculateBoardUiScale, MIN_ACCESSIBLE_CANVAS_SCALE } from "../scaled-board-canvas"; +import { calculateBoardCanvasScale, calculateBoardUiScale } from "../scaled-board-canvas"; describe("board canvas scale policy", () => { - test("fits the logical canvas exactly when the viewport can preserve the minimum scale", () => { + test("fits the logical canvas exactly", () => { const scale = calculateBoardCanvasScale(840, 1000); expect(scale).toBe(0.84); @@ -14,16 +14,16 @@ describe("board canvas scale policy", () => { expect(calculateBoardCanvasScale(1800, 1000)).toBe(1.8); }); - test("stops counter-scaling at 0.75 and lets the canvas overflow", () => { + test("keeps fitting narrow viewports without horizontal overflow", () => { const availableWidth = 600; const logicalWidth = 1000; const scale = calculateBoardCanvasScale(availableWidth, logicalWidth); - expect(scale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE); - expect(logicalWidth * scale).toBeGreaterThan(availableWidth); + expect(scale).toBe(0.6); + expect(logicalWidth * scale).toBe(availableWidth); }); - test("keeps content materially magnified at 200% effective zoom", () => { + test("keeps the board fitted at 200% effective zoom", () => { const logicalWidth = 1696; const normalAvailableWidth = 1872; const normalScale = calculateBoardCanvasScale(normalAvailableWidth, logicalWidth); @@ -31,8 +31,8 @@ describe("board canvas scale policy", () => { const normalPaintedCellSize = 200 * normalScale; const zoomedPaintedCellSize = 200 * zoomedScale * 2; - expect(zoomedScale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE); - expect(zoomedPaintedCellSize).toBeGreaterThan(normalPaintedCellSize * 1.25); + expect(zoomedScale).toBeCloseTo(normalScale / 2); + expect(zoomedPaintedCellSize).toBeCloseTo(normalPaintedCellSize); }); test.each([ diff --git a/apps/nextjs/src/components/board/sections/empty-section.tsx b/apps/nextjs/src/components/board/sections/empty-section.tsx index 5e4ef1d65..ab958e75f 100644 --- a/apps/nextjs/src/components/board/sections/empty-section.tsx +++ b/apps/nextjs/src/components/board/sections/empty-section.tsx @@ -18,7 +18,7 @@ export const BoardEmptySection = ({ section, columnCount, requestedRowCount, rai const [isEditMode] = useEditMode(); const t = useScopedI18n("board.landmark"); - if (totalLength === 0 && !isEditMode && requestedRowCount === 0) return null; + if (totalLength === 0 && !isEditMode && requestedRowCount === 0 && railPlacement !== "main") return null; return ( .board-grid-content-mount { + overflow: clip; +} + .board-grid-placeholder { z-index: 0; background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent); 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 192cbf7f9..d4d0782ab 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx @@ -10,6 +10,7 @@ import { CollisionPriority } from "@dnd-kit/abstract"; import { pointerIntersection } from "@dnd-kit/collision"; import type { DragDropManager, Draggable as DomDraggable } from "@dnd-kit/dom"; import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints, PointerSensor } from "@dnd-kit/dom"; +import { RestrictToElement } from "@dnd-kit/dom/modifiers"; import { getEventCoordinates, getFrameTransform, isPointerEvent } from "@dnd-kit/dom/utilities"; import type { BeforeDragStartEvent, @@ -720,6 +721,7 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement if (!previewGrid || !interaction) return placements; if (interaction.sourceGridId === sectionId) { + if (interaction.mode === "resize") return previewGrid.placements; const original = controlledById.get(interaction.activeId); if (!original) return previewGrid.placements; return previewGrid.placements.some((placement) => placement.id === interaction.activeId) @@ -734,9 +736,16 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement ); const targetPlacement = interaction?.valid && interaction.targetGridId === sectionId ? interaction.targetPlacement : null; + const growsDuringDrag = + interaction?.mode === "drag" && + (interaction.targetGridId === sectionId || + (interaction.targetGridId === null && interaction.sourceGridId === sectionId)); + const participatesInInteraction = + interaction && (interaction.sourceGridId === sectionId || interaction.targetGridId === sectionId); + const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? renderPlacements); const renderedRowCount = - section.kind !== "container" && interaction?.mode === "resize" - ? Math.max(rowCount, getLayoutRowCount(renderPlacements)) + section.kind !== "container" && participatesInInteraction + ? Math.max(rowCount, previewRowCount) + (growsDuringDrag ? 1 : 0) : rowCount; const { ref: droppableRef, isDropTarget } = useDroppable({ @@ -876,6 +885,14 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }: ], [], ); + const modifiers = useMemo( + () => [ + RestrictToElement.configure({ + element: () => shellRef.current?.closest('[data-testid="board-canvas"]') ?? null, + }), + ], + [], + ); const { ref: draggableRef, handleRef, @@ -886,6 +903,7 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }: type: GRID_ENTRY_TYPE, data: sourceData, sensors, + modifiers, }); const setShellRef = useCallback( (element: HTMLDivElement | null) => { 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 3ab7b292b..6b98efc0c 100644 --- a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx +++ b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import dynamic from "next/dynamic"; import { Box, LoadingOverlay } from "@mantine/core"; import combineClasses from "clsx"; @@ -13,12 +13,14 @@ import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types import { getCollapsedDisplayLayout, getEditableCanvasAttributes, + getGridRowCountForVisualHeight, getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize, getReadonlyCanvasAttributes, normalizeGridPlacement, } from "~/components/board/layout"; +import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas"; import { loadGridEditorAsync } from "./grid-editor-loader"; import { useGridEditorRuntimeStatus } from "./grid-editor-runtime"; import type { SectionGridPlacement } from "./use-grid-layout-actions"; @@ -81,6 +83,7 @@ export const SectionGrid = ({ className, }: SectionGridProps) => { const [isEditMode] = useEditMode(); + const canvasScale = useBoardCanvasScale(); const editorRuntimeStatus = useGridEditorRuntimeStatus(); const board = useRequiredBoard(); const currentLayoutId = useCurrentLayout(); @@ -144,8 +147,12 @@ export const SectionGrid = ({ ); const displayedItems = items.map((item) => withPlacement(item, placementById.get(item.id))); const displayedInnerSections = innerSections.map((item) => withPlacement(item, placementById.get(item.id))); + const minimumViewportRowCount = useMinimumViewportRowCount( + section.kind === "empty" && railPlacement === "main", + canvasScale, + ); const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements)); - const rowCount = Math.max(contentRowCount, requestedRowCount); + const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount); const logicalWidth = getLogicalTrackSize(columnCount); const logicalHeight = getLogicalTrackSize(rowCount); const canvasAttributes = isEditMode @@ -199,6 +206,24 @@ export const SectionGrid = ({ ); }; +const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => { + const [visualHeight, setVisualHeight] = useState(0); + + useEffect(() => { + if (!enabled) return; + const update = () => setVisualHeight(window.visualViewport?.height ?? window.innerHeight); + update(); + window.addEventListener("resize", update, { passive: true }); + window.visualViewport?.addEventListener("resize", update, { passive: true }); + return () => { + window.removeEventListener("resize", update); + window.visualViewport?.removeEventListener("resize", update); + }; + }, [enabled]); + + return enabled ? getGridRowCountForVisualHeight(visualHeight, canvasScale) : 0; +}; + const getContainerMinimumSize = ( board: ReturnType, layoutId: string, diff --git a/e2e/board-grid.spec.ts b/e2e/board-grid.spec.ts index 47d734263..512fddf08 100644 --- a/e2e/board-grid.spec.ts +++ b/e2e/board-grid.spec.ts @@ -9,7 +9,6 @@ import { describe, test } from "vitest"; import { LOGICAL_GRID_CELL_SIZE, LOGICAL_GRID_GAP, - MIN_ACCESSIBLE_CANVAS_SCALE, } from "../apps/nextjs/src/components/board/layout/constants"; import * as sqliteSchema from "../packages/db/schema/sqlite"; import { createHomarrContainer } from "./shared/create-homarr-container"; @@ -26,7 +25,6 @@ const gridRuntimeMarker = "homarr-dnd-kit-v1"; const dndKitPayloadMarker = "dnd-kit-description"; const logicalCellSize = LOGICAL_GRID_CELL_SIZE; const logicalCellPitch = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP; -const minimumCanvasScale = MIN_ACCESSIBLE_CANVAS_SCALE; describe("Board grid", () => { test("supports fixed, nested, rail, pointer, keyboard and reconciliation workflows", async () => { @@ -174,21 +172,17 @@ describe("Board grid", () => { expect(await readLayoutShiftScoreAsync(page)).toBeLessThan(0.01); // CSS zoom exercises Chromium's browser-zoom layout path without relying - // on desktop browser chrome. At 200% the scale floor must preserve - // magnification and expose horizontal scrolling instead of shrinking the - // board by the inverse zoom factor. + // on desktop browser chrome. The board must keep fitting without adding + // horizontal scrolling at any effective zoom. await setDocumentZoomAsync(page, 2); - await expect(canvas).toHaveAttribute("data-canvas-scale", String(minimumCanvasScale)); - await expect(canvas).toHaveAttribute("data-canvas-overflow", "true"); + const zoomedCanvasScale = await readCanvasScaleAsync(canvas); + expect(zoomedCanvasScale).toBeCloseTo(normalCanvasScale / 2, 2); + await expect(canvas).toHaveAttribute("data-canvas-overflow", "false"); await expectFixedLogicalTileAsync(logicalTile); await expectUniformVisualScaleAsync(logicalTile); - const fittedClockFontSize = await firstItem.locator(".clock-time-text").evaluate((element) => { - return Number.parseFloat(getComputedStyle(element).fontSize); - }); - expect(fittedClockFontSize * minimumCanvasScale).toBeGreaterThanOrEqual(25); const zoomedTileBox = await expectBoundingBoxAsync(logicalTile); - expect(zoomedTileBox.width).toBeGreaterThan(normalZoomTileBox.width * 1.25); - await expectHorizontalOverflowAsync(canvas); + expect(zoomedTileBox.width).toBeCloseTo(normalZoomTileBox.width, 1); + await expectNoHorizontalOverflowAsync(canvas); await expectDocumentNotHorizontallyScrollableAsync(page); const overflowRailY = (await expectBoundingBoxAsync(rail)).y; await page.evaluate(() => window.scrollTo({ top: 300, behavior: "auto" })); @@ -337,8 +331,11 @@ describe("Board grid", () => { await expect(itemSelectDialog).toHaveCount(0); const mainGrid = page.locator(`[data-grid-section-id="${fixture.sectionId}"]`); + const editorViewport = page.viewportSize(); + if (!editorViewport) throw new Error("Expected a browser viewport"); + expect((await expectBoundingBoxAsync(mainGrid)).height).toBeGreaterThanOrEqual(editorViewport.height - 1); const interactionSnapshot = await readEditorGridSnapshotAsync(mainGrid); - await expectGridBoundaryRejectsOverflowAsync(page, mainGrid, firstItem); + await expectGridGrowsAtBoundaryAsync(page, canvas, mainGrid, firstItem); await firstItem.locator("[data-grid-item-content]").evaluate((element) => { const iframe = document.createElement("iframe"); iframe.dataset.e2eDragIframe = "true"; @@ -367,8 +364,8 @@ describe("Board grid", () => { .evaluateAll((elements) => elements.forEach((element) => element.remove())); await page.setViewportSize({ width: 600, height: 1200 }); - await expect(canvas).toHaveAttribute("data-canvas-scale", String(minimumCanvasScale)); - await expectHorizontalOverflowAsync(canvas); + await expect(canvas).toHaveAttribute("data-canvas-overflow", "false"); + await expectNoHorizontalOverflowAsync(canvas); await expectDocumentNotHorizontallyScrollableAsync(page); for (const direction of ["n", "ne", "e", "se", "s", "sw", "w", "nw"] as const) { await expectTargetSizeAsync(firstItem.locator(`:scope > [data-grid-resize-handle="${direction}"]`)); @@ -718,13 +715,12 @@ describe("Board grid", () => { expect(continuousResizeWidth).toBeGreaterThan(logicalCellSize * canvasScale); await page.mouse.move(eastResizeStart.x - logicalCellPitch * canvasScale, eastResizeStart.y, { steps: 6 }); - await expect(containerSection).toHaveAttribute("data-grid-w", "2"); + await expect(containerSection).toHaveAttribute("data-grid-w", "1"); await expect(resizePlaceholder).toHaveAttribute("data-grid-w", "1"); expect((await expectBoundingBoxAsync(resizeOutline)).width).toBeCloseTo(logicalCellSize * canvasScale, 1); - const liveNestedGridBox = await expectBoundingBoxAsync(nestedEditor); const liveContainerBox = await expectBoundingBoxAsync(containerSection); - expect(liveNestedGridBox.width).toBeCloseTo(originalResizeWidth, 1); - expect(liveNestedGridBox.width).toBeLessThanOrEqual(liveContainerBox.width + 1); + expect(liveContainerBox.width).toBeCloseTo(logicalCellSize * canvasScale, 1); + await expect(containerSection.locator(":scope > .board-grid-content-mount")).toHaveCSS("overflow", "clip"); await page.mouse.up(); await expect(containerSection).toHaveAttribute("data-grid-w", "1"); await expect(resizeOutline).toHaveCount(0); @@ -759,15 +755,26 @@ describe("Board grid", () => { await expect(containerSection).toHaveAttribute("data-grid-h", "2"); const southResizeHandle = containerSection.locator(':scope > [data-grid-resize-handle="s"]'); - await dragLocatorByAsync(page, southResizeHandle, 0, logicalCellPitch * canvasScale); + const gridHeightBeforeSouthResize = (await expectBoundingBoxAsync(mainGrid)).height; + const southResizeBox = await expectBoundingBoxAsync(southResizeHandle); + const southResizeStart = { + x: southResizeBox.x + southResizeBox.width / 2, + y: southResizeBox.y + southResizeBox.height / 2, + }; + await page.mouse.move(southResizeStart.x, southResizeStart.y); + await page.mouse.down(); + await page.mouse.move(southResizeStart.x, southResizeStart.y + logicalCellPitch * canvasScale, { steps: 8 }); await expect(containerSection).toHaveAttribute("data-grid-h", "3"); await expect(belowItem).toHaveAttribute("data-grid-y", "3"); + await expect + .poll(async () => (await expectBoundingBoxAsync(mainGrid)).height) + .toBeGreaterThan(gridHeightBeforeSouthResize); + await page.mouse.up(); expect( gridLayoutsOverlap(await readGridLayoutAsync(containerSection), await readGridLayoutAsync(belowItem)), ).toBe(false); - // Auto-scroll needs real document overflow. Grow the canvas through an - // explicit resize instead of relying on edit mode to add empty rows. + // Grow the canvas further to exercise sustained document auto-scroll. await dragLocatorByAsync(page, southResizeHandle, 0, logicalCellPitch * canvasScale * 6); await expect(containerSection).toHaveAttribute("data-grid-h", "9"); await expect(belowItem).toHaveAttribute("data-grid-y", "9"); @@ -1713,9 +1720,10 @@ const expectImmediateOutsideReleaseRestoresSnapshotAsync = async (page: Page, gr await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot); }; -const expectGridBoundaryRejectsOverflowAsync = async (page: Page, grid: Locator, locator: Locator) => { +const expectGridGrowsAtBoundaryAsync = async (page: Page, canvas: Locator, grid: Locator, locator: Locator) => { const snapshot = await readEditorGridSnapshotAsync(grid); const gridBox = await expectBoundingBoxAsync(grid); + const canvasBox = await expectBoundingBoxAsync(canvas); const itemBox = await expectBoundingBoxAsync(locator); const start = { x: itemBox.x + itemBox.width * 0.63, y: itemBox.y + itemBox.height * 0.42 }; @@ -1723,11 +1731,22 @@ const expectGridBoundaryRejectsOverflowAsync = async (page: Page, grid: Locator, await page.mouse.down(); await page.mouse.move(start.x + 8, start.y); await expect(locator).toHaveAttribute("data-dnd-drag-source", "true"); - await page.mouse.move(gridBox.x + gridBox.width / 2, gridBox.y + gridBox.height + logicalCellPitch, { steps: 12 }); - await expect(page.locator("[data-grid-placeholder-for]")).toHaveCount(0); - const activeGridBox = await expectBoundingBoxAsync(grid); - expect(activeGridBox.height).toBeCloseTo(gridBox.height, 1); + await expect + .poll(async () => (await expectBoundingBoxAsync(grid)).height) + .toBeGreaterThan(gridBox.height); + await page.mouse.move(gridBox.x + gridBox.width / 2, gridBox.y + gridBox.height + logicalCellPitch / 2, { + steps: 12, + }); + await expect(page.locator("[data-grid-placeholder-for]")).toHaveCount(1); + const activeGridBox = await expectBoundingBoxAsync(grid); + expect(activeGridBox.height).toBeGreaterThan(gridBox.height); + + await page.mouse.move(canvasBox.x + canvasBox.width + 1000, start.y, { steps: 12 }); + const overlayBox = await expectBoundingBoxAsync(page.locator(".board-grid-drag-overlay[data-dnd-dragging]")); + expect(overlayBox.x + overlayBox.width).toBeLessThanOrEqual(canvasBox.x + canvasBox.width + 1); + + await page.keyboard.press("Escape"); await page.mouse.up(); await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting"); await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot); @@ -2036,7 +2055,7 @@ const expectUniformVisualScaleAsync = async (locator: Locator) => { expect(scale.horizontal).toBeCloseTo(scale.vertical, 3); }; -const expectHorizontalOverflowAsync = async (locator: Locator) => { +const expectNoHorizontalOverflowAsync = async (locator: Locator) => { const dimensions = await locator.evaluate((element) => { const htmlElement = element as HTMLElement; return { @@ -2045,7 +2064,7 @@ const expectHorizontalOverflowAsync = async (locator: Locator) => { }; }); - expect(dimensions.scrollWidth).toBeGreaterThan(dimensions.clientWidth); + expect(dimensions.scrollWidth - dimensions.clientWidth).toBeLessThanOrEqual(1); }; const expectDocumentNotHorizontallyScrollableAsync = async (page: Page) => {