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 && (
+ }
+ onClick={toggle}
+ aria-expanded={false}
+ aria-controls={contentId}
+ aria-label={`${t("action.expand")}: ${label}`}
+ data-board-container-collapsed-control
+ styles={{
+ label: {
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ },
+ }}
+ >
+ {label}
+
+ )}
{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);