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 0c95d0b81..d820dabba 100644
--- a/apps/nextjs/src/components/board/items/item-content.module.css
+++ b/apps/nextjs/src/components/board/items/item-content.module.css
@@ -13,4 +13,11 @@
.editModeInertContent {
display: contents;
+ pointer-events: none;
+ user-select: none;
+}
+
+.editModeInertContent * {
+ pointer-events: none !important;
+ user-select: none !important;
}
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 a9a01a5e5..6a560693e 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
@@ -137,6 +137,7 @@ export const FixedGridItem = ({
onBlur={isEditMode ? handleBlur : undefined}
onKeyDown={isEditMode ? handleKeyboard : undefined}
>
+ {isEditMode && }
{children}
{isEditMode && (
diff --git a/apps/nextjs/src/components/board/sections/grid/grid-drop-swap.ts b/apps/nextjs/src/components/board/sections/grid/grid-drop-swap.ts
index 6f0eee0cb..0afeadfe2 100644
--- a/apps/nextjs/src/components/board/sections/grid/grid-drop-swap.ts
+++ b/apps/nextjs/src/components/board/sections/grid/grid-drop-swap.ts
@@ -10,6 +10,7 @@ export const getDropSwapPlacements = (
initial: readonly SectionGridPlacement[],
current: readonly SectionGridPlacement[],
movedItemId: string,
+ bounds: { columnCount: number; rowCount?: number },
): SectionGridPlacement[] | null => {
const movedInitially = initial.find((placement) => placement.id === movedItemId);
const movedCurrently = current.find((placement) => placement.id === movedItemId);
@@ -24,16 +25,11 @@ export const getDropSwapPlacements = (
}
const displaced = initial.find(
- (placement) =>
- placement.id !== movedItemId &&
- placement.x === movedCurrently.x &&
- placement.y === movedCurrently.y &&
- placement.w === movedCurrently.w &&
- placement.h === movedCurrently.h,
+ (placement) => placement.id !== movedItemId && placement.x === movedCurrently.x && placement.y === movedCurrently.y,
);
if (!displaced) return null;
- return initial.map((placement) => {
+ const swapped = initial.map((placement) => {
if (placement.id === movedItemId) {
return {
...placement,
@@ -50,4 +46,27 @@ export const getDropSwapPlacements = (
}
return placement;
});
+
+ return placementsFit(swapped, bounds) ? swapped : null;
};
+
+const placementsFit = (
+ placements: readonly SectionGridPlacement[],
+ { columnCount, rowCount }: { columnCount: number; rowCount?: number },
+) =>
+ placements.every(
+ (placement) =>
+ placement.x >= 0 &&
+ placement.y >= 0 &&
+ placement.x + placement.w <= columnCount &&
+ (rowCount === undefined || placement.y + placement.h <= rowCount),
+ ) &&
+ placements.every((placement, index) =>
+ placements.slice(index + 1).every((other) => !placementsOverlap(placement, other)),
+ );
+
+const placementsOverlap = (first: SectionGridPlacement, second: SectionGridPlacement) =>
+ first.x < second.x + second.w &&
+ first.x + first.w > second.x &&
+ first.y < second.y + second.h &&
+ first.y + first.h > second.y;
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 bd51ec827..6b60f891c 100644
--- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css
+++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css
@@ -8,6 +8,26 @@
filter: drop-shadow(var(--mantine-shadow-lg));
}
+.grid-stack.grid-stack-editing > .grid-stack-item > .grid-stack-item-content {
+ border-radius: var(--mantine-radius-md);
+ outline: 1px solid transparent;
+ outline-offset: 2px;
+ transition:
+ outline-color 160ms ease,
+ background-color 160ms ease;
+}
+
+.grid-stack.grid-stack-editing > .grid-stack-item:hover > .grid-stack-item-content,
+.grid-stack.grid-stack-editing > .grid-stack-item:focus-within > .grid-stack-item-content {
+ outline-color: color-mix(in srgb, var(--mantine-primary-color-filled) 72%, transparent);
+}
+
+.grid-stack.grid-stack-editing > .grid-stack-item.ui-draggable-dragging > .grid-stack-item-content,
+.grid-stack.grid-stack-editing > .grid-stack-item.ui-resizable-resizing > .grid-stack-item-content {
+ outline: 2px solid var(--mantine-primary-color-filled);
+ outline-offset: 3px;
+}
+
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-handle {
z-index: 8;
}
@@ -25,13 +45,86 @@
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw {
width: 44px;
height: 44px;
- background-size: 16px 16px;
+ color: var(--mantine-color-dimmed);
+ background-image: none;
+ border-radius: var(--mantine-radius-sm);
+ transform: none;
z-index: 9;
}
+.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se::after,
+.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw::after {
+ position: absolute;
+ bottom: 8px;
+ width: 14px;
+ height: 14px;
+ content: "";
+ border-bottom: 2px solid currentColor;
+ opacity: 0.9;
+}
+
+.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se::after {
+ right: 8px;
+ border-right: 2px solid currentColor;
+ border-radius: 0 0 3px;
+}
+
+.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw::after {
+ left: 8px;
+ border-left: 2px solid currentColor;
+ border-radius: 0 0 0 3px;
+}
+
+.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se:hover,
+.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw:hover {
+ color: var(--mantine-primary-color-filled);
+ background-color: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
+}
+
+.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e::after,
+.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w::after,
+.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s::after {
+ position: absolute;
+ content: "";
+ background-color: var(--mantine-primary-color-filled);
+ border-radius: var(--mantine-radius-xl);
+ opacity: 0;
+ transition: opacity 160ms ease;
+}
+
+.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e::after,
+.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w::after {
+ top: 25%;
+ bottom: 25%;
+ width: 3px;
+}
+
+.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e::after {
+ right: 5px;
+}
+
+.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w::after {
+ left: 5px;
+}
+
+.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s::after {
+ right: 25%;
+ bottom: 5px;
+ left: 25%;
+ height: 3px;
+}
+
+.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e:hover::after,
+.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w:hover::after,
+.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s:hover::after {
+ opacity: 1;
+}
+
@media (prefers-reduced-motion: reduce) {
.grid-stack.grid-stack-editing,
- .grid-stack.grid-stack-editing .grid-stack-item {
+ .grid-stack.grid-stack-editing .grid-stack-item,
+ .grid-stack.grid-stack-editing .grid-stack-item-content,
+ .grid-stack.grid-stack-editing .ui-resizable-handle::after {
transition: none !important;
}
}
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 fe6f21f2f..f8a9a450c 100644
--- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx
+++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx
@@ -220,6 +220,10 @@ export default function GridEditor({
dragSnapshot.placements,
getGridPlacements(changedGrid),
dragSnapshot.itemId,
+ {
+ columnCount: dimensionsRef.current.columnCount,
+ rowCount: dimensionsRef.current.fixedRowCount ? dimensionsRef.current.rowCount : undefined,
+ },
);
if (swapped) {
applyingControlledLayoutRef.current = true;
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 046437de9..16ab96599 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
@@ -28,10 +28,12 @@
}
.editorEntry {
+ position: relative;
width: 100%;
height: 100%;
overflow: visible;
cursor: grab;
+ user-select: none;
}
.editorEntry:active {
@@ -45,6 +47,46 @@
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;
@@ -71,7 +113,8 @@
}
@media (prefers-reduced-motion: reduce) {
- .staticItem {
+ .staticItem,
+ .dragAffordance {
transition: none;
}
}
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 0a4a9ae44..8b9f3ef09 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
@@ -156,7 +156,7 @@ describe("fixed grid item behavior", () => {
);
});
- test("uses the whole tile as the focusable move surface without a grab button", () => {
+ test("uses the whole tile as the focusable move surface with a visual drag affordance", () => {
getWeatherMock().advancedOptions.title = " Balcony forecast ";
renderWeather(root);
@@ -165,7 +165,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-handle"]')).toBeNull();
+ expect(container.querySelector('[data-testid="board-grid-drag-affordance"]')).not.toBeNull();
});
test("renders a view-only 1x1 card at the fixed 200px logical size", () => {
diff --git a/apps/nextjs/src/components/board/sections/grid/test/grid-drop-swap.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/grid-drop-swap.spec.ts
index edac286ff..95c120363 100644
--- a/apps/nextjs/src/components/board/sections/grid/test/grid-drop-swap.spec.ts
+++ b/apps/nextjs/src/components/board/sections/grid/test/grid-drop-swap.spec.ts
@@ -7,23 +7,43 @@ const first: SectionGridPlacement = { id: "first", type: "item", x: 0, y: 0, w:
const second: SectionGridPlacement = { id: "second", type: "item", x: 1, y: 0, w: 1, h: 1 };
const third: SectionGridPlacement = { id: "third", type: "item", x: 2, y: 0, w: 2, h: 1 };
const initial = [first, second, third];
+const bounds = { columnCount: 4 };
describe("grid drop swapping", () => {
test("swaps equally-sized items when one takes the other's exact slot", () => {
const current: SectionGridPlacement[] = [{ ...first, x: 1 }, { ...second, y: 1 }, third];
- expect(getDropSwapPlacements(initial, current, "first")).toEqual([{ ...first, x: 1 }, { ...second, x: 0 }, third]);
+ expect(getDropSwapPlacements(initial, current, "first", bounds)).toEqual([
+ { ...first, x: 1 },
+ { ...second, x: 0 },
+ third,
+ ]);
});
- test("keeps push behavior for different sizes or partial placement", () => {
+ test("swaps different-sized items when both destinations remain valid", () => {
+ const wide: SectionGridPlacement = { id: "wide", type: "item", x: 0, y: 0, w: 2, h: 1 };
+ const compact: SectionGridPlacement = { id: "compact", type: "item", x: 2, y: 0, w: 1, h: 1 };
+ const current = [
+ { ...wide, x: 2 },
+ { ...compact, y: 1 },
+ ];
+
+ expect(getDropSwapPlacements([wide, compact], current, "wide", bounds)).toEqual([
+ { ...wide, x: 2 },
+ { ...compact, x: 0 },
+ ]);
+ });
+
+ test("keeps push behavior when a swap would overlap another item or exceed the section", () => {
const differentSize: SectionGridPlacement[] = [{ ...first, x: 2 }, second, { ...third, y: 1 }];
const partialPlacement: SectionGridPlacement[] = [{ ...third, x: 1 }, first, { ...second, y: 1 }];
- expect(getDropSwapPlacements(initial, differentSize, "first")).toBeNull();
- expect(getDropSwapPlacements(initial, partialPlacement, "third")).toBeNull();
+ expect(getDropSwapPlacements(initial, differentSize, "first", bounds)).toBeNull();
+ expect(getDropSwapPlacements(initial, partialPlacement, "third", bounds)).toBeNull();
+ expect(getDropSwapPlacements(initial, [{ ...third, x: 1 }, first, second], "third", { columnCount: 2 })).toBeNull();
});
test("does nothing when the item did not move", () => {
- expect(getDropSwapPlacements(initial, initial, "first")).toBeNull();
+ expect(getDropSwapPlacements(initial, initial, "first", bounds)).toBeNull();
});
});
diff --git a/e2e/board-grid.spec.ts b/e2e/board-grid.spec.ts
index f836ac229..a5ec575cf 100644
--- a/e2e/board-grid.spec.ts
+++ b/e2e/board-grid.spec.ts
@@ -207,9 +207,16 @@ describe("Board grid", () => {
await expect(mainSection).toHaveAttribute("data-grid-editable", "true");
await expect(firstItem.locator(`[data-grid-id="${fixture.firstItemId}"]`)).toHaveAttribute("role", "group");
await expectTargetSizeAsync(getEditorEntry(firstItem));
- await expectTargetSizeAsync(firstItem.locator(':scope > [data-testid="board-grid-resize-handle"]'));
- await expectLogicalTargetSizeAsync(firstItem.locator(':scope > [data-testid="board-grid-resize-handle"]'), 44);
- await expect(firstItem.locator("[data-board-grid-inert-content]")).toHaveAttribute("inert", "");
+ const dragAffordance = firstItem.getByTestId("board-grid-drag-affordance");
+ const diagonalResizeHandle = firstItem.locator(':scope > [data-testid="board-grid-resize-handle"]');
+ const inertContent = firstItem.locator("[data-board-grid-inert-content]");
+ await expect(dragAffordance).toBeVisible();
+ await expect(dragAffordance).toHaveCSS("pointer-events", "none");
+ await expectTargetSizeAsync(diagonalResizeHandle);
+ await expectLogicalTargetSizeAsync(diagonalResizeHandle, 44);
+ await expect(diagonalResizeHandle).toHaveCSS("transform", "none");
+ await expect(inertContent).toHaveAttribute("inert", "");
+ await expect(inertContent).toHaveCSS("pointer-events", "none");
await page.getByRole("button", { name: "Add board content" }).click();
await expect(page.getByRole("menuitem", { name: "New section", exact: true })).toBeVisible();