From 34f2a0bd24ce174824ce9609533f7b33c6d9e3fd Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 2 Aug 2026 09:36:33 +0200 Subject: [PATCH] fix: polish board grid interactions --- .../boards/(content)/_client.module.css | 2 + .../board/items/item-content.module.css | 5 +- .../board/items/widget-hover-overlay.tsx | 2 +- .../src/components/board/layout/constants.ts | 2 +- .../src/components/board/layout/geometry.ts | 2 +- .../src/components/board/layout/reflow.ts | 39 ++++-- .../board/layout/test/geometry.spec.ts | 1 + .../board/layout/test/reflow.spec.ts | 4 +- .../board/sections/container-section.tsx | 76 +++++++--- .../sections/container/container-menu.tsx | 5 +- .../sections/grid/dnd/coordinates.spec.ts | 5 +- .../board/sections/grid/dnd/coordinates.ts | 8 +- .../board/sections/grid/dnd/engine.spec.ts | 20 ++- .../board/sections/grid/dnd/engine.ts | 15 +- .../board/sections/grid/dnd/index.ts | 2 + .../board/sections/grid/dnd/targeting.spec.ts | 68 +++++++++ .../board/sections/grid/dnd/targeting.ts | 90 ++++++++++++ .../board/sections/grid/fixed-grid-item.tsx | 3 - .../board/sections/grid/grid-editor.css | 28 ++-- .../board/sections/grid/grid-editor.tsx | 125 ++++++++++------- .../sections/grid/section-grid.module.css | 43 +----- .../board/sections/grid/section-grid.tsx | 4 +- .../grid/test/fixed-grid-item.spec.ts | 8 +- .../components/board/sections/item.module.css | 6 + e2e/board-grid.spec.ts | 132 ++++++++++++++++-- 25 files changed, 517 insertions(+), 178 deletions(-) create mode 100644 apps/nextjs/src/components/board/sections/grid/dnd/targeting.spec.ts create mode 100644 apps/nextjs/src/components/board/sections/grid/dnd/targeting.ts 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 fe7d183d2..3dbf87f39 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css @@ -12,6 +12,8 @@ position: sticky; top: 0; align-self: start; + padding-top: 24px; + margin-top: -24px; max-height: calc(100dvh - var(--app-shell-header-offset, 0px) - var(--mantine-spacing-md)); overflow-y: auto; overscroll-behavior: contain; diff --git a/apps/nextjs/src/components/board/items/item-content.module.css b/apps/nextjs/src/components/board/items/item-content.module.css index d820dabba..bead20b70 100644 --- a/apps/nextjs/src/components/board/items/item-content.module.css +++ b/apps/nextjs/src/components/board/items/item-content.module.css @@ -13,11 +13,14 @@ .editModeInertContent { display: contents; +} + +.editModeInertContent[data-board-grid-inert-content="true"] { pointer-events: none; user-select: none; } -.editModeInertContent * { +.editModeInertContent[data-board-grid-inert-content="true"] * { pointer-events: none !important; user-select: none !important; } diff --git a/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx b/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx index 6529eed12..0c6f74af5 100644 --- a/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx +++ b/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx @@ -41,7 +41,7 @@ const LoadedWidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProp ); return ( -
+
diff --git a/apps/nextjs/src/components/board/layout/constants.ts b/apps/nextjs/src/components/board/layout/constants.ts index d83a0441f..9c3103b4b 100644 --- a/apps/nextjs/src/components/board/layout/constants.ts +++ b/apps/nextjs/src/components/board/layout/constants.ts @@ -7,4 +7,4 @@ 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 = 1; +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 4b24a7c66..32b7fced4 100644 --- a/apps/nextjs/src/components/board/layout/geometry.ts +++ b/apps/nextjs/src/components/board/layout/geometry.ts @@ -4,7 +4,7 @@ import { LOGICAL_GRID_CELL_SIZE, LOGICAL_GRID_GAP, LOGICAL_GRID_PITCH } from "./ import type { GridCoordinates, GridPlacement } from "./types"; export const getLogicalTrackSize = (trackCount: number) => { - const normalizedTrackCount = toNonNegativeInteger(trackCount); + const normalizedTrackCount = Math.max(0, toFiniteNumber(trackCount, "trackCount")); if (normalizedTrackCount === 0) return 0; return normalizedTrackCount * LOGICAL_GRID_CELL_SIZE + (normalizedTrackCount - 1) * LOGICAL_GRID_GAP; diff --git a/apps/nextjs/src/components/board/layout/reflow.ts b/apps/nextjs/src/components/board/layout/reflow.ts index 01e40a039..fa53f147f 100644 --- a/apps/nextjs/src/components/board/layout/reflow.ts +++ b/apps/nextjs/src/components/board/layout/reflow.ts @@ -63,21 +63,21 @@ export const getCollapsedDisplayLayout = ( placements: readonly TPlacement[], { columnCount, collapsedItemIds, collapsedRowCount = COLLAPSED_SECTION_ROW_COUNT }: CollapsedLayoutOptions, ): TPlacement[] => { - if (collapsedRowCount < 1 || !Number.isInteger(collapsedRowCount)) { - throw new RangeError("collapsedRowCount must be a positive integer"); + if (!Number.isFinite(collapsedRowCount) || collapsedRowCount <= 0) { + throw new RangeError("collapsedRowCount must be positive and finite"); } - return reflowVerticalLayout( - placements.map((placement) => - collapsedItemIds.has(placement.id) - ? { - ...placement, - h: collapsedRowCount, - } - : placement, - ), - { columnCount }, + const normalized = placements.map((placement) => normalizeGridPlacement(placement, columnCount)); + const displayPlacements = normalized.map((placement) => + collapsedItemIds.has(placement.id) + ? { + ...placement, + h: collapsedRowCount, + } + : placement, ); + + return reflowDisplayLayout(displayPlacements); }; /** @@ -118,6 +118,21 @@ const findFirstAvailableRow = (candidate: GridPlacement, placed: readonly GridPl } }; +const reflowDisplayLayout = (placements: readonly TPlacement[]): TPlacement[] => { + assertUniqueIds(placements); + const placed: TPlacement[] = []; + + for (const candidate of placements.toSorted(comparePlacements)) { + placed.push({ + ...candidate, + y: findFirstAvailableRow(candidate, placed), + }); + } + + const byId = new Map(placed.map((placement) => [placement.id, placement])); + return placements.map((placement) => getRequiredPlacement(byId, placement.id)); +}; + const comparePlacements = (first: GridPlacement, second: GridPlacement) => first.y - second.y || first.x - second.x || (first.id === second.id ? 0 : first.id < second.id ? -1 : 1); 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 9fe79533c..ed4c773cb 100644 --- a/apps/nextjs/src/components/board/layout/test/geometry.spec.ts +++ b/apps/nextjs/src/components/board/layout/test/geometry.spec.ts @@ -10,6 +10,7 @@ import { describe("fixed dashboard geometry", () => { test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => { expect(getLogicalTrackSize(1)).toBe(200); + expect(getLogicalTrackSize(0.5)).toBe(88); expect(getLogicalTrackSize(2)).toBe(424); expect(getLogicalTrackSize(3)).toBe(648); 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 424910282..c841792ef 100644 --- a/apps/nextjs/src/components/board/layout/test/reflow.spec.ts +++ b/apps/nextjs/src/components/board/layout/test/reflow.spec.ts @@ -42,8 +42,8 @@ describe("dashboard layout reflow", () => { ); expect(result).toEqual([ - { id: "section", x: 0, y: 0, w: 2, h: 1 }, - { id: "below", x: 0, y: 1, w: 2, h: 1 }, + { 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 }, ]); }); diff --git a/apps/nextjs/src/components/board/sections/container-section.tsx b/apps/nextjs/src/components/board/sections/container-section.tsx index b11d95032..5fd2a9831 100644 --- a/apps/nextjs/src/components/board/sections/container-section.tsx +++ b/apps/nextjs/src/components/board/sections/container-section.tsx @@ -1,5 +1,5 @@ import dynamic from "next/dynamic"; -import { ActionIcon, Badge, Box, Card } from "@mantine/core"; +import { ActionIcon, Badge, Box, Button, Card } from "@mantine/core"; import { IconChevronDown, IconChevronUp, IconExternalLink, IconGripVertical } from "@tabler/icons-react"; import combineClasses from "clsx"; @@ -36,17 +36,22 @@ export const BoardContainerSection = ({ section }: Props) => { sectionId: section.id, collapsible: options.collapsible, }); - const label = options.title || t("action.create"); + const label = options.title.trim() || t("untitled"); const contentId = `board-container-${section.id}-content`; - const labelLeft = isEditMode || options.collapsible ? 40 : 10; + const labelLeft = options.collapsible && !isEditMode ? 40 : 8; const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8; return ( { radius={board.itemRadius} p={0} > + {isVisuallyCollapsed && ( + + )} {isEditMode && ( { left={4} style={{ zIndex: 10, cursor: "grab", touchAction: "none" }} variant="default" - radius="xl" + size={24} + radius="sm" data-grid-container-drag-handle aria-hidden="true" > )} - {options.showLabel && options.title && ( + {!isVisuallyCollapsed && options.showLabel && options.title && ( {options.title} )} - {options.collapsible && !isEditMode && ( + {options.collapsible && !isEditMode && !isVisuallyCollapsed && ( - {isVisuallyCollapsed ? : } + )} {options.showOpenAll && !isEditMode && ( { test("uses the dragged shape origin so a non-center grab retains its pointer offset", () => { const scale = 0.75; const target = { left: -280, top: 390, width: getLogicalTrackSize(5) * scale }; - const initialShape = { left: target.left, top: target.top }; + const initialShape = { left: target.left, top: target.top, width: 424 * scale, height: 200 * scale }; const pointerDelta = { x: LOGICAL_GRID_PITCH * scale * 2, y: LOGICAL_GRID_PITCH * scale * 3 }; expect( @@ -37,6 +37,8 @@ describe("dnd grid coordinates", () => { const initialShape = { left: target.left + 4 * LOGICAL_GRID_PITCH * scale, top: target.top + 8 * LOGICAL_GRID_PITCH * scale, + width: getLogicalTrackSize(2) * scale, + height: getLogicalTrackSize(2) * scale, }; const initialPointer = { x: initialShape.left + 134 * scale, y: initialShape.top + 89 * scale }; const dragShape = getPointerProjectedShape( @@ -45,6 +47,7 @@ describe("dnd grid coordinates", () => { ); expect(dragShape.left).toBe(initialShape.left); + expect(dragShape).toMatchObject({ width: initialShape.width, height: initialShape.height }); expect(getSnappedGridCoordinates({ dragShape, target, columnCount: 8 })).toEqual({ x: 4, y: 7 }); }); diff --git a/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.ts b/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.ts index a6678d0ec..3944b8373 100644 --- a/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.ts +++ b/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.ts @@ -6,9 +6,13 @@ interface RectangleLike { width: number; } +interface ProjectedRectangle extends RectangleLike { + height: number; +} + export interface DragProjectionOrigin { initialPointer: { x: number; y: number }; - initialShape: { left: number; top: number }; + initialShape: ProjectedRectangle; } /** @@ -19,6 +23,8 @@ export interface DragProjectionOrigin { export const getPointerProjectedShape = (origin: DragProjectionOrigin, currentPointer: { x: number; y: number }) => ({ left: origin.initialShape.left + currentPointer.x - origin.initialPointer.x, top: origin.initialShape.top + currentPointer.y - origin.initialPointer.y, + width: origin.initialShape.width, + height: origin.initialShape.height, }); export const getSnappedGridCoordinates = ({ diff --git a/apps/nextjs/src/components/board/sections/grid/dnd/engine.spec.ts b/apps/nextjs/src/components/board/sections/grid/dnd/engine.spec.ts index d3f6c8255..7e839c526 100644 --- a/apps/nextjs/src/components/board/sections/grid/dnd/engine.spec.ts +++ b/apps/nextjs/src/components/board/sections/grid/dnd/engine.spec.ts @@ -258,7 +258,7 @@ describe("same-grid movement", () => { }); }); - test("uses pinned reflow for a partial overlap even when sizes match", () => { + test("swaps laterally as soon as a move pushes into one compatible item", () => { const original = state(grid("root", 5, [placement("active", 0, 0, 2), placement("target", 2, 0, 2)])); const moved = accept( previewGridMove(beginGridTransaction(original, { activeId: "active" }), { @@ -268,8 +268,22 @@ describe("same-grid movement", () => { }), ); - expect(getPlacement(moved.preview, "active")).toMatchObject({ x: 1, y: 0, w: 2, h: 1 }); - expect(getPlacement(moved.preview, "target")).toMatchObject({ x: 2, y: 1, w: 2, h: 1 }); + expect(getPlacement(moved.preview, "active")).toMatchObject({ x: 2, y: 0, w: 2, h: 1 }); + expect(getPlacement(moved.preview, "target")).toMatchObject({ x: 0, y: 0, w: 2, h: 1 }); + }); + + test("swaps the pushed item into the vacated slot when moving from right to left", () => { + const original = state(grid("root", 5, [placement("target", 0, 0, 2), placement("active", 2, 0, 2)])); + const moved = accept( + previewGridMove(beginGridTransaction(original, { activeId: "active" }), { + targetGridId: "root", + x: 1, + y: 0, + }), + ); + + expect(getPlacement(moved.preview, "active")).toMatchObject({ x: 0, y: 0, w: 2, h: 1 }); + expect(getPlacement(moved.preview, "target")).toMatchObject({ x: 2, y: 0, w: 2, h: 1 }); }); test("preserves intentional empty rows outside the collision chain", () => { diff --git a/apps/nextjs/src/components/board/sections/grid/dnd/engine.ts b/apps/nextjs/src/components/board/sections/grid/dnd/engine.ts index e66aa88ce..946f43423 100644 --- a/apps/nextjs/src/components/board/sections/grid/dnd/engine.ts +++ b/apps/nextjs/src/components/board/sections/grid/dnd/engine.ts @@ -41,10 +41,10 @@ export const beginGridTransaction = ( }; /** - * Computes a move preview from the transaction snapshot. Same-grid exact-slot - * drops swap items when both resulting placements remain valid; every other - * move pins the active item and pushes only actual collisions. Intentional - * empty rows remain untouched. + * Computes a move preview from the transaction snapshot. Same-grid moves that + * collide with one item swap into that item's slot when both resulting + * placements remain valid; every other move pins the active item and pushes + * only actual collisions. Intentional empty rows remain untouched. */ export const previewGridMove = ( transaction: GridTransaction, @@ -147,13 +147,14 @@ const previewSameGridMove = ( const previewGrid = getGrid(preview, grid.id); if (areCoordinatesEqual(active, nextActive)) return preview; - const displaced = grid.placements.find( - (placement) => placement.id !== active.id && placement.x === nextActive.x && placement.y === nextActive.y, + const collisions = grid.placements.filter( + (placement) => placement.id !== active.id && doGridPlacementsOverlap(placement, nextActive), ); + const displaced = collisions.length === 1 ? collisions[0] : undefined; const swapped = displaced ? grid.placements.map((placement) => { - if (placement.id === active.id) return { ...placement, x: nextActive.x, y: nextActive.y }; + if (placement.id === active.id) return { ...placement, x: displaced.x, y: displaced.y }; if (placement.id === displaced.id) return { ...placement, x: active.x, y: active.y }; return { ...placement }; }) diff --git a/apps/nextjs/src/components/board/sections/grid/dnd/index.ts b/apps/nextjs/src/components/board/sections/grid/dnd/index.ts index 7c02a3c68..e59fdd150 100644 --- a/apps/nextjs/src/components/board/sections/grid/dnd/index.ts +++ b/apps/nextjs/src/components/board/sections/grid/dnd/index.ts @@ -8,6 +8,8 @@ export { } from "./engine"; export { getPointerProjectedShape, getSnappedGridCoordinates, getSnappedGridDelta } from "./coordinates"; export type { DragProjectionOrigin } from "./coordinates"; +export { getPreferredNestedExitTargetId } from "./targeting"; +export type { GridTargetGeometry } from "./targeting"; export type { BeginGridTransactionInput, GridMoveGuard, diff --git a/apps/nextjs/src/components/board/sections/grid/dnd/targeting.spec.ts b/apps/nextjs/src/components/board/sections/grid/dnd/targeting.spec.ts new file mode 100644 index 000000000..704ef9628 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/grid/dnd/targeting.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "vitest"; + +import type { GridTargetGeometry } from "./targeting"; +import { getPreferredNestedExitTargetId } from "./targeting"; + +const grid = ( + id: string, + rectangle: GridTargetGeometry["rectangle"], + parentGridId: string | null = null, +): GridTargetGeometry => ({ id, parentGridId, rectangle }); + +const alwaysEligible = () => true; + +describe("nested grid exit targeting", () => { + const grids = [ + grid("canvas", { left: 0, top: 0, width: 1200, height: 800 }), + grid("container", { left: 200, top: 100, width: 600, height: 500 }, "canvas"), + ]; + + test("keeps the nested grid while most of a large item remains inside", () => { + expect( + getPreferredNestedExitTargetId({ + grids, + pointerTargetId: "container", + sourceGridId: "container", + dragShape: { left: 100, top: 150, width: 400, height: 200 }, + isEligible: alwaysEligible, + }), + ).toBe("container"); + }); + + test("selects the parent before the pointer leaves when most of the item is outside", () => { + expect( + getPreferredNestedExitTargetId({ + grids, + pointerTargetId: "container", + sourceGridId: "container", + dragShape: { left: 0, top: 150, width: 400, height: 200 }, + isEligible: alwaysEligible, + }), + ).toBe("canvas"); + }); + + test("preserves a sibling container selected by the pointer", () => { + const withSibling = [...grids, grid("sibling", { left: 820, top: 100, width: 300, height: 500 }, "canvas")]; + expect( + getPreferredNestedExitTargetId({ + grids: withSibling, + pointerTargetId: "sibling", + sourceGridId: "container", + dragShape: { left: 820, top: 150, width: 300, height: 200 }, + isEligible: alwaysEligible, + }), + ).toBe("sibling"); + }); + + test("does not escape to an ineligible parent", () => { + expect( + getPreferredNestedExitTargetId({ + grids, + pointerTargetId: "container", + sourceGridId: "container", + dragShape: { left: 0, top: 150, width: 400, height: 200 }, + isEligible: (gridId) => gridId !== "canvas", + }), + ).toBe("container"); + }); +}); diff --git a/apps/nextjs/src/components/board/sections/grid/dnd/targeting.ts b/apps/nextjs/src/components/board/sections/grid/dnd/targeting.ts new file mode 100644 index 000000000..dbc5aabc9 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/grid/dnd/targeting.ts @@ -0,0 +1,90 @@ +interface RectangleLike { + left: number; + top: number; + width: number; + height: number; +} + +export interface GridTargetGeometry { + id: string; + parentGridId?: string | null; + rectangle: RectangleLike; +} + +interface PreferredNestedExitTargetInput { + grids: readonly GridTargetGeometry[]; + pointerTargetId: string; + sourceGridId: string; + dragShape: RectangleLike; + isEligible: (gridId: string) => boolean; +} + +const NESTED_EXIT_RETAINED_AREA = 0.7; + +/** + * Keeps pointer precision for entering containers, but lets a large item leave + * once a clear portion of its projected shape is visibly outside. + */ +export const getPreferredNestedExitTargetId = ({ + grids, + pointerTargetId, + sourceGridId, + dragShape, + isEligible, +}: PreferredNestedExitTargetInput) => { + const byId = new Map(grids.map((grid) => [grid.id, grid])); + const source = byId.get(sourceGridId); + let current = byId.get(pointerTargetId); + if (!source || !current || !isSameGridOrAncestor(current.id, source, byId)) return pointerTargetId; + + while (current.parentGridId) { + if (getCoveredAreaRatio(dragShape, current.rectangle) > NESTED_EXIT_RETAINED_AREA) break; + + const parent = byId.get(current.parentGridId); + if (!parent || !isEligible(parent.id) || !containsCenter(parent.rectangle, dragShape)) break; + current = parent; + } + + return current.id; +}; + +const isSameGridOrAncestor = ( + candidateId: string, + source: GridTargetGeometry, + byId: ReadonlyMap, +) => { + let current: GridTargetGeometry | undefined = source; + const visited = new Set(); + while (current) { + if (current.id === candidateId) return true; + if (!current.parentGridId || visited.has(current.id)) return false; + visited.add(current.id); + current = byId.get(current.parentGridId); + } + return false; +}; + +const getCoveredAreaRatio = (shape: RectangleLike, target: RectangleLike) => { + const shapeArea = shape.width * shape.height; + if (!Number.isFinite(shapeArea) || shapeArea <= 0) return 1; + + const width = Math.max( + 0, + Math.min(shape.left + shape.width, target.left + target.width) - Math.max(shape.left, target.left), + ); + const height = Math.max( + 0, + Math.min(shape.top + shape.height, target.top + target.height) - Math.max(shape.top, target.top), + ); + return (width * height) / shapeArea; +}; + +const containsCenter = (target: RectangleLike, shape: RectangleLike) => { + const center = { x: shape.left + shape.width / 2, y: shape.top + shape.height / 2 }; + return ( + center.x >= target.left && + center.x <= target.left + target.width && + center.y >= target.top && + center.y <= target.top + target.height + ); +}; diff --git a/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx b/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx index b52aba851..17a8ddafb 100644 --- a/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx +++ b/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx @@ -195,9 +195,6 @@ export const FixedGridItem = ({ onBlur={isEditorActive ? handleBlur : undefined} onKeyDown={isEditorActive ? handleKeyboard : undefined} > - {isEditorActive && ( - - )} {children} {isEditorActive && ( 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 6c9a1f1a9..825b4505b 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css @@ -2,10 +2,6 @@ position: absolute; inset: 0 auto auto 0; overflow: visible; - background-image: - linear-gradient(to right, color-mix(in srgb, var(--mantine-color-dimmed) 18%, transparent) 1px, transparent 1px), - linear-gradient(to bottom, color-mix(in srgb, var(--mantine-color-dimmed) 18%, transparent) 1px, transparent 1px); - background-size: var(--board-grid-pitch) var(--board-grid-pitch); border-radius: var(--mantine-radius-md); } @@ -169,11 +165,11 @@ body[data-board-grid-interacting] .board-grid-placeholder { } .board-grid-resize-handle[data-grid-resize-handle="n"] { - top: -12px; + top: 0; } .board-grid-resize-handle[data-grid-resize-handle="s"] { - bottom: -12px; + bottom: 0; } .board-grid-resize-handle[data-grid-resize-handle="e"], @@ -185,11 +181,11 @@ body[data-board-grid-interacting] .board-grid-placeholder { } .board-grid-resize-handle[data-grid-resize-handle="e"] { - right: -12px; + right: 0; } .board-grid-resize-handle[data-grid-resize-handle="w"] { - left: -12px; + left: 0; } .board-grid-resize-handle[data-grid-resize-handle="e"]::after, @@ -208,26 +204,26 @@ body[data-board-grid-interacting] .board-grid-placeholder { } .board-grid-resize-handle[data-grid-resize-handle="ne"] { - top: -12px; - right: -12px; + top: 0; + right: 0; cursor: nesw-resize; } .board-grid-resize-handle[data-grid-resize-handle="se"] { - right: -12px; - bottom: -12px; + right: 0; + bottom: 0; cursor: nwse-resize; } .board-grid-resize-handle[data-grid-resize-handle="sw"] { - bottom: -12px; - left: -12px; + bottom: 0; + left: 0; cursor: nesw-resize; } .board-grid-resize-handle[data-grid-resize-handle="nw"] { - top: -12px; - left: -12px; + top: 0; + left: 0; cursor: nwse-resize; } 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 d401fc0d5..bc16af38a 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx @@ -36,6 +36,7 @@ import { beginGridTransaction, cancelGridTransaction, commitGridTransaction, + getPreferredNestedExitTargetId, getPointerProjectedShape, getSnappedGridCoordinates, getSnappedGridDelta, @@ -51,13 +52,11 @@ import { useGridLayoutActions } from "./use-grid-layout-actions"; const GRID_ENTRY_TYPE = "homarr-board-grid-entry"; const GRID_TARGET_TYPE = "homarr-board-grid-target"; -const EDIT_ROW_BUFFER = 8; interface GridEditorProps { sectionId: string; columnCount: number; rowCount: number; - fixedRowCount: boolean; placements: readonly SectionGridPlacement[]; className: string; } @@ -286,18 +285,11 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { const updateDragPreview = useCallback( ( operation: DragMoveEvent["operation"], - projectedShape?: { left: number; top: number }, + projectedShape?: { left: number; top: number; width: number; height: number }, targetOverride?: RegisteredGrid | null, ) => { const active = activeRef.current; const source = operation.source; - const targetData = operation.target ? getGridTargetData(operation.target) : null; - const targetGrid = - targetOverride === undefined - ? targetData - ? gridsRef.current.get(targetData.sectionId) - : undefined - : (targetOverride ?? undefined); if (!active || active.mode !== "drag") return; const publishPreview = (preview: Omit) => { @@ -321,7 +313,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { }); }; - if (!source || !targetGrid) { + if (!source) { showInvalidPreview(null, "invalid:no-target"); return; } @@ -329,7 +321,24 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { const sourceData = getGridEntryData(source); const shape = projectedShape ?? operation.shape?.current.boundingRectangle; if (!sourceData || !shape) { - showInvalidPreview(targetGrid.id, `invalid:${targetGrid.id}:shape`); + showInvalidPreview(null, "invalid:shape"); + return; + } + + const targetData = operation.target ? getGridTargetData(operation.target) : null; + const pointerTargetGrid = targetData ? gridsRef.current.get(targetData.sectionId) : undefined; + const targetGrid = + targetOverride === undefined + ? getPreferredRegisteredGridTarget( + gridsRef.current.values(), + sourceData.sectionId, + pointerTargetGrid, + shape, + (targetGridId) => isDropTargetEligible(source, targetGridId), + ) + : (targetOverride ?? undefined); + if (!targetGrid) { + showInvalidPreview(null, "invalid:no-target"); return; } @@ -394,7 +403,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { valid: true, }); }, - [canAcceptDrop], + [canAcceptDrop, isDropTargetEligible], ); const commitActiveTransaction = useCallback( @@ -507,11 +516,12 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { if (!canceled) { const source = operation.source; const pointer = getPointerCoordinates(source?.element, nativeEvent); + const active = activeRef.current; + const finalShape = getProjectedDragShape(operation, active?.dragProjection ?? null, pointer); const finalTarget = source && pointer - ? getPointerTargetGrid(gridsRef.current.values(), source, pointer, isDropTargetEligible) + ? getPointerTargetGrid(gridsRef.current.values(), source, pointer, finalShape, isDropTargetEligible) : undefined; - const active = activeRef.current; // The rendered placeholder is the drop contract. During auto-scroll, // dnd-kit's final geometry can advance between the last collision pass @@ -703,14 +713,7 @@ const GridCollisionSynchronizer = ({ interaction }: { interaction: GridInteracti }; /** A section-local dnd-kit canvas registered with the board transaction. */ -export default function GridEditor({ - sectionId, - columnCount, - rowCount, - fixedRowCount, - placements, - className, -}: GridEditorProps) { +export default function GridEditor({ sectionId, columnCount, rowCount, placements, className }: GridEditorProps) { const t = useI18n(); const { section, items, innerSections } = useSectionContext(); const { interaction, registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditor(); @@ -740,14 +743,9 @@ export default function GridEditor({ interaction?.mode === "drag" && interaction.valid && interaction.targetGridId === sectionId ? interaction.targetPlacement : null; - const renderedRowCount = fixedRowCount - ? rowCount - : interaction - ? Math.max( - rowCount + EDIT_ROW_BUFFER, - getLayoutRowCount(previewGrid?.placements ?? placements) + EDIT_ROW_BUFFER, - targetPlacement ? targetPlacement.y + targetPlacement.h + EDIT_ROW_BUFFER : 0, - ) + const renderedRowCount = + section.kind !== "container" && interaction?.mode === "resize" + ? Math.max(rowCount, getLayoutRowCount(renderPlacements)) : rowCount; const { ref: droppableRef, isDropTarget } = useDroppable({ @@ -761,6 +759,7 @@ export default function GridEditor({ // through to an accepting parent grid underneath the pointer. accept: (source) => isDropTargetEligible(source, sectionId), }); + const isIntentTarget = interaction?.mode === "drag" ? interaction.targetGridId === sectionId : isDropTarget; const setGridRef = useCallback( (element: HTMLDivElement | null) => { @@ -776,24 +775,14 @@ export default function GridEditor({ return registerGrid({ id: sectionId, columnCount, - maxRowCount: fixedRowCount ? rowCount : null, + maxRowCount: section.kind === "container" ? rowCount : null, parentGridId, ownerPlacementId, placements, depth, element, }); - }, [ - columnCount, - depth, - fixedRowCount, - ownerPlacementId, - parentGridId, - placements, - registerGrid, - rowCount, - sectionId, - ]); + }, [columnCount, depth, ownerPlacementId, parentGridId, placements, registerGrid, rowCount, section.kind, sectionId]); useLayoutEffect(() => { const viewport = gridRef.current?.parentElement; @@ -810,8 +799,8 @@ export default function GridEditor({ className={className} data-grid-section-id={sectionId} data-grid-depth={depth} - data-dnd-drop-target={isDropTarget ? "true" : "false"} - data-dnd-drop-valid={isDropTarget ? String(targetPlacement !== null) : undefined} + data-dnd-drop-target={isIntentTarget ? "true" : "false"} + data-dnd-drop-valid={isIntentTarget ? String(targetPlacement !== null) : undefined} data-dnd-preview-revision={interaction?.targetGridId === sectionId ? interaction.previewRevision : undefined} style={ { @@ -1097,7 +1086,7 @@ const getProjectedDragShape = ( ) => { if (origin && pointer) return getPointerProjectedShape(origin, pointer); const shape = operation.shape?.current.boundingRectangle; - return shape ? { left: shape.left, top: shape.top } : undefined; + return shape ? { left: shape.left, top: shape.top, width: shape.width, height: shape.height } : undefined; }; const getInitialDragProjection = ( @@ -1112,6 +1101,8 @@ const getInitialDragProjection = ( initialShape: { left: rectangle.left * frame.scaleX + frame.x, top: rectangle.top * frame.scaleY + frame.y, + width: rectangle.width * frame.scaleX, + height: rectangle.height * frame.scaleY, }, }; }; @@ -1150,9 +1141,11 @@ const getPointerTargetGrid = ( grids: Iterable, source: AbstractDraggable, pointer: { x: number; y: number }, + projectedShape: { left: number; top: number; width: number; height: number } | undefined, isTargetEligible: (source: AbstractDraggable, targetGridId: string) => boolean, -) => - Array.from(grids) +) => { + const registeredGrids = Array.from(grids); + const pointerTarget = registeredGrids .filter((grid) => { const rectangle = grid.element.getBoundingClientRect(); return ( @@ -1170,7 +1163,41 @@ const getPointerTargetGrid = ( const firstRectangle = first.element.getBoundingClientRect(); const secondRectangle = second.element.getBoundingClientRect(); return firstRectangle.width * firstRectangle.height - secondRectangle.width * secondRectangle.height; - })[0] ?? null; + })[0]; + const sourceData = getGridEntryData(source); + if (!pointerTarget || !projectedShape || !sourceData) return pointerTarget ?? null; + + return getPreferredRegisteredGridTarget( + registeredGrids, + sourceData.sectionId, + pointerTarget, + projectedShape, + (targetGridId) => isTargetEligible(source, targetGridId), + ); +}; + +const getPreferredRegisteredGridTarget = ( + grids: Iterable, + sourceGridId: string, + pointerTarget: RegisteredGrid | undefined, + dragShape: { left: number; top: number; width: number; height: number }, + isEligible: (gridId: string) => boolean, +) => { + if (!pointerTarget) return undefined; + const registeredGrids = Array.from(grids); + const preferredId = getPreferredNestedExitTargetId({ + grids: registeredGrids.map((grid) => ({ + id: grid.id, + parentGridId: grid.parentGridId, + rectangle: grid.element.getBoundingClientRect(), + })), + pointerTargetId: pointerTarget.id, + sourceGridId, + dragShape, + isEligible, + }); + return registeredGrids.find((grid) => grid.id === preferredId) ?? pointerTarget; +}; const doesPreviewDisplaceNestedGridOwner = ( snapshot: TransactionalGridState, diff --git a/apps/nextjs/src/components/board/sections/grid/section-grid.module.css b/apps/nextjs/src/components/board/sections/grid/section-grid.module.css index 99be51d65..6c619a4c7 100644 --- a/apps/nextjs/src/components/board/sections/grid/section-grid.module.css +++ b/apps/nextjs/src/components/board/sections/grid/section-grid.module.css @@ -43,46 +43,6 @@ border-radius: var(--mantine-radius-md); } -.dragAffordance { - position: absolute; - top: 6px; - left: 50%; - z-index: 7; - display: grid; - width: 28px; - height: 18px; - color: var(--mantine-color-dimmed); - pointer-events: none; - place-items: center; - background-color: color-mix(in srgb, var(--mantine-color-body) 88%, transparent); - border: 1px solid var(--mantine-color-default-border); - border-radius: var(--mantine-radius-xl); - opacity: 0.8; - transform: translateX(-50%); - transition: - color 160ms ease, - background-color 160ms ease, - border-color 160ms ease, - opacity 160ms ease; -} - -.dragAffordance::before { - width: 12px; - height: 8px; - content: ""; - background-image: radial-gradient(circle, currentColor 1.25px, transparent 1.5px); - background-size: 6px 4px; -} - -.editorEntry:hover .dragAffordance, -.editorEntry:focus-visible .dragAffordance, -.editorEntry[data-keyboard-editing="true"] .dragAffordance { - color: var(--mantine-primary-color-filled); - background-color: var(--mantine-color-body); - border-color: var(--mantine-primary-color-filled); - opacity: 1; -} - .editorLoading { position: absolute; inset: 0; @@ -109,8 +69,7 @@ } @media (prefers-reduced-motion: reduce) { - .staticItem, - .dragAffordance { + .staticItem { transition: none; } } 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 3fb866537..3ab7b292b 100644 --- a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx +++ b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx @@ -146,7 +146,6 @@ export const SectionGrid = ({ const displayedInnerSections = innerSections.map((item) => withPlacement(item, placementById.get(item.id))); const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements)); const rowCount = Math.max(contentRowCount, requestedRowCount); - const fixedRowCount = section.kind === "container"; const logicalWidth = getLogicalTrackSize(columnCount); const logicalHeight = getLogicalTrackSize(rowCount); const canvasAttributes = isEditMode @@ -161,7 +160,7 @@ export const SectionGrid = ({ innerSections: displayedInnerSections, integrations, columnCount, - maxRowCount: fixedRowCount ? rowCount : null, + maxRowCount: section.kind === "container" ? rowCount : null, announce, }} > @@ -179,7 +178,6 @@ export const SectionGrid = ({ sectionId={section.id} columnCount={columnCount} rowCount={rowCount} - fixedRowCount={fixedRowCount} placements={displayPlacements} className={combineClasses("board-grid-editor", classes.editorGrid)} /> diff --git a/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts index 2ae6fcf60..12ed7e84d 100644 --- a/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts +++ b/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts @@ -217,7 +217,7 @@ describe("fixed grid item behavior", () => { ); }); - test("uses the whole tile as the focusable move surface with a visual drag affordance", () => { + test("uses the whole tile as the focusable move surface without extra drag chrome", () => { getWeatherMock().advancedOptions.title = " Balcony forecast "; renderWeather(root); @@ -226,7 +226,7 @@ describe("fixed grid item behavior", () => { expect(entry.getAttribute("aria-label")).toBe("Balcony forecast, column 1, row 1"); expect(entry.tabIndex).toBe(0); expect(entry.getAttribute("aria-describedby")).not.toBeNull(); - expect(container.querySelector('[data-testid="board-grid-drag-affordance"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="board-grid-drag-affordance"]')).toBeNull(); }); test("renders a view-only 1x1 card at the fixed 200px logical size", () => { @@ -275,8 +275,8 @@ describe("fixed grid item behavior", () => { }); expect(displayed).toEqual([ - { ...persisted[0], h: 1 }, - { ...persisted[1], y: 1 }, + { ...persisted[0], h: 0.5 }, + { ...persisted[1], y: 0.5 }, ]); expect(persisted[0]?.h).toBe(4); expect(persisted[1]?.y).toBe(4); diff --git a/apps/nextjs/src/components/board/sections/item.module.css b/apps/nextjs/src/components/board/sections/item.module.css index a7168659e..aa814566a 100644 --- a/apps/nextjs/src/components/board/sections/item.module.css +++ b/apps/nextjs/src/components/board/sections/item.module.css @@ -15,3 +15,9 @@ background-color: transparent !important; border-color: transparent !important; } + +.collapsedContainerCard { + background-color: transparent !important; + border-color: transparent !important; + box-shadow: none !important; +} diff --git a/e2e/board-grid.spec.ts b/e2e/board-grid.spec.ts index 4479dc264..44a68793e 100644 --- a/e2e/board-grid.spec.ts +++ b/e2e/board-grid.spec.ts @@ -102,9 +102,13 @@ describe("Board grid", () => { await expect(railItem).toHaveAttribute("data-grid-x", "0"); await expect(containerSection).toHaveAttribute("data-grid-w", "2"); await expect(containerSection).toHaveAttribute("data-grid-h", "2"); - await expect(containerSection.getByText("Nested box", { exact: true })).toBeVisible(); + const containerLabel = containerSection.locator("[data-board-container-label]"); + await expect(containerLabel).toHaveText("Nested box"); await expect(nestedItem).toHaveAttribute("data-grid-x", "0"); await expect(belowItem).toHaveAttribute("data-grid-y", "2"); + const containerLabelBox = await expectBoundingBoxAsync(containerLabel); + const nestedItemBox = await expectBoundingBoxAsync(nestedItem.locator("[data-grid-item-content]")); + expect(containerLabelBox.y + containerLabelBox.height).toBeLessThanOrEqual(nestedItemBox.y + 0.5); const firstItem = page.locator(`[data-grid-item-id="${fixture.firstItemId}"]`); const secondItem = page.locator(`[data-grid-item-id="${fixture.secondItemId}"]`); @@ -118,7 +122,14 @@ 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(canvas).toHaveAttribute("data-canvas-overflow", "false"); + await expectDocumentNotHorizontallyScrollableAsync(page); + const nestedAppLink = nestedItem.locator('a[href="#board-app-opened"]'); + await expect(nestedAppLink).toBeVisible(); + await nestedAppLink.click(); + await expect(page).toHaveURL(/#board-app-opened$/); + await page.evaluate(() => history.replaceState(null, "", `${location.pathname}${location.search}`)); const normalZoomTileBox = await expectBoundingBoxAsync(logicalTile); const normalCanvasScale = await readCanvasScaleAsync(canvas); const secondItemBox = await expectBoundingBoxAsync(secondItem); @@ -172,9 +183,16 @@ describe("Board grid", () => { exact: true, }); await collapseContainer.click(); - await expect(containerSection).toHaveAttribute("data-grid-h", "1"); - await expect(belowItem).toHaveAttribute("data-grid-y", "1"); + await expect(containerSection).toHaveAttribute("data-grid-h", "0.5"); + await expect(belowItem).toHaveAttribute("data-grid-y", "0.5"); await expect(nestedItem).toHaveCount(0); + const collapsedControl = containerSection.getByRole("button", { name: "Expand: Nested box", exact: true }); + await expect(collapsedControl).toBeVisible(); + await expect(collapsedControl).toHaveAttribute("data-board-container-collapsed-control", "true"); + const expectedCollapsedHeight = (logicalCellPitch * 0.5 - LOGICAL_GRID_GAP) * normalCanvasScale; + await expect + .poll(async () => (await expectBoundingBoxAsync(containerSection)).height) + .toBeCloseTo(expectedCollapsedHeight, 1); await page .getByRole("button", { @@ -209,9 +227,7 @@ describe("Board grid", () => { await expect(firstKeyboardEntry).not.toHaveAttribute("aria-pressed"); await expect(firstKeyboardEntry).not.toHaveAttribute("aria-grabbed"); await expectTargetSizeAsync(firstItem); - const dragAffordance = firstItem.getByTestId("board-grid-drag-affordance"); - await expect(dragAffordance).toBeVisible(); - await expect(dragAffordance).toHaveCSS("pointer-events", "none"); + await expect(firstItem.getByTestId("board-grid-drag-affordance")).toHaveCount(0); const firstResizeHandles = firstItem.locator(":scope > [data-grid-resize-handle]"); await expect(firstResizeHandles).toHaveCount(8); expect( @@ -259,6 +275,7 @@ describe("Board grid", () => { const mainGrid = page.locator(`[data-grid-section-id="${fixture.sectionId}"]`); const interactionSnapshot = await readEditorGridSnapshotAsync(mainGrid); + await expectGridBoundaryRejectsOverflowAsync(page, mainGrid, firstItem); await firstItem.locator("[data-grid-item-content]").evaluate((element) => { const iframe = document.createElement("iframe"); iframe.dataset.e2eDragIframe = "true"; @@ -295,10 +312,21 @@ describe("Board grid", () => { await page.setViewportSize({ width: 1920, height: 1200 }); await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(3); await expect(canvas).toHaveAttribute("data-canvas-overflow", "false"); + await expectDocumentNotHorizontallyScrollableAsync(page); await expect.poll(async () => await readEditorGridSnapshotAsync(mainGrid)).toEqual(interactionSnapshot); const settingsButton = firstItem.getByRole("button", { name: /^Settings for / }); + await firstItem.hover(); + const widgetHeader = firstItem.locator("[data-board-widget-header]"); + await expect(widgetHeader).toBeVisible(); const settingsBox = await expectBoundingBoxAsync(settingsButton); + const widgetHeaderBox = await expectBoundingBoxAsync(widgetHeader); + const firstItemBox = await expectBoundingBoxAsync(firstItem); + expect(settingsBox.y).toBeGreaterThanOrEqual(firstItemBox.y); + expect(settingsBox.y + settingsBox.height).toBeLessThanOrEqual(firstItemBox.y + firstItemBox.height); + expect(settingsBox.y).toBeLessThan(widgetHeaderBox.y + widgetHeaderBox.height); + expect(settingsBox.y + settingsBox.height).toBeGreaterThan(widgetHeaderBox.y); + await expect(settingsButton).toHaveCSS("border-radius", "4px"); await page.mouse.move(settingsBox.x + settingsBox.width / 2, settingsBox.y + settingsBox.height / 2); await page.mouse.down(); await page.mouse.move(settingsBox.x + settingsBox.width / 2 + 8, settingsBox.y + settingsBox.height / 2 + 8); @@ -419,6 +447,37 @@ describe("Board grid", () => { await packedSecondKeyboardEntry.press("Escape"); await expect(secondItem).toHaveAttribute("data-grid-h", "2"); + const packedSecondBox = await expectBoundingBoxAsync(secondItem); + const initialMainGridBox = await expectBoundingBoxAsync(mainGrid); + await page.mouse.move( + packedSecondBox.x + packedSecondBox.width / 2, + packedSecondBox.y + packedSecondBox.height / 2, + ); + await page.mouse.down(); + await page.mouse.move(nestedGridBox.x + 1, packedSecondBox.y + packedSecondBox.height / 2, { steps: 8 }); + await expect(mainGrid).toHaveAttribute("data-dnd-drop-target", "true"); + await expect(nestedEditor).toHaveAttribute("data-dnd-drop-target", "false"); + await page.mouse.move( + initialMainGridBox.x + logicalCellSize * canvasScale * 0.5, + initialMainGridBox.y + logicalCellSize * canvasScale * 0.5, + { steps: 8 }, + ); + await page.mouse.up(); + await expect(secondItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute( + "data-grid-section-id", + fixture.sectionId, + ); + await dragLocatorToImmediateReleaseAsync( + page, + secondItem, + nestedGridBox.x + logicalCellPitch * canvasScale * 1.5, + nestedGridBox.y + logicalCellSize * canvasScale * 0.5, + ); + await expect(secondItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute( + "data-grid-section-id", + fixture.containerSectionId, + ); + const nestedSouthResizeHandle = nestedItem.locator(':scope > [data-grid-resize-handle="s"]'); await expect(containerSection).toHaveAttribute("data-grid-section-chrome-active", "false"); await expect(nestedSouthResizeHandle).not.toHaveAttribute("data-grid-resize-disabled"); @@ -458,10 +517,10 @@ describe("Board grid", () => { await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(0); await page.reload(); await expect(canvas).toHaveAttribute("data-board-hydrated", "true"); - await expect(secondItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute( - "data-grid-section-id", - fixture.containerSectionId, - ); + await expect(secondItem).toHaveCount(1); + const reloadedSecondItemGrid = secondItem.locator("xpath=ancestor::*[@data-grid-section-id][1]"); + await expect(reloadedSecondItemGrid).toHaveCount(1); + await expect(reloadedSecondItemGrid).toHaveAttribute("data-grid-section-id", fixture.containerSectionId); await expect(containerSection.locator("xpath=ancestor::aside[1]")).toHaveAttribute("data-board-gutter", "left"); await editToggle.click(); await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(3); @@ -620,6 +679,12 @@ describe("Board grid", () => { 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. + await dragLocatorByAsync(page, southResizeHandle, 0, logicalCellPitch * canvasScale * 6); + await expect(containerSection).toHaveAttribute("data-grid-h", "9"); + await expect(belowItem).toHaveAttribute("data-grid-y", "9"); + await firstItem.evaluate((element) => element.scrollIntoView({ block: "center", behavior: "instant" })); const autoScrollStartBox = await expectBoundingBoxAsync(firstItem); const autoScrollStart = { @@ -846,6 +911,7 @@ const seedBoardGridAsync = async (db: SqliteDatabase, creatorId: string) => { const belowItemId = createId(); const nestedItemId = createId(); const railItemId = createId(); + const nestedAppId = createId(); await db.insert(sqliteSchema.boards).values({ id: boardId, @@ -926,6 +992,12 @@ const seedBoardGridAsync = async (db: SqliteDatabase, creatorId: string) => { height: 2, }, ]); + await db.insert(sqliteSchema.apps).values({ + id: nestedAppId, + name: "Interactive nested app", + iconUrl: "/favicon.ico", + href: "#board-app-opened", + }); await db.insert(sqliteSchema.items).values([ { id: firstItemId, @@ -948,8 +1020,15 @@ const seedBoardGridAsync = async (db: SqliteDatabase, creatorId: string) => { { id: nestedItemId, boardId, - kind: "clock", - options: stringifySuperJSON({ is24HourFormat: true }), + kind: "app", + options: stringifySuperJSON({ + appId: nestedAppId, + descriptionDisplayMode: "hidden", + layout: "column", + openInNewTab: false, + pingEnabled: false, + showTitle: true, + }), }, { id: railItemId, @@ -1536,6 +1615,26 @@ 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 snapshot = await readEditorGridSnapshotAsync(grid); + const gridBox = await expectBoundingBoxAsync(grid); + const itemBox = await expectBoundingBoxAsync(locator); + const start = { x: itemBox.x + itemBox.width * 0.63, y: itemBox.y + itemBox.height * 0.42 }; + + await page.mouse.move(start.x, start.y); + 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 page.mouse.up(); + await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting"); + await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot); +}; + const expectInvalidNestedDropRestoresSnapshotAsync = async ( page: Page, sourceGrid: Locator, @@ -1849,6 +1948,15 @@ const expectHorizontalOverflowAsync = async (locator: Locator) => { expect(dimensions.scrollWidth).toBeGreaterThan(dimensions.clientWidth); }; +const expectDocumentNotHorizontallyScrollableAsync = async (page: Page) => { + await expect + .poll( + async () => + await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth), + ) + .toBeLessThanOrEqual(1); +}; + const expectTargetSizeAsync = async (locator: Locator) => { const box = await expectBoundingBoxAsync(locator); expect(box.width).toBeGreaterThanOrEqual(24);