From 94074c368d713df548f0c0287c0896b6abc4fe9a Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 1 Aug 2026 14:13:07 +0200 Subject: [PATCH] fix: clarify board grid editing affordances --- .../board/items/item-content.module.css | 7 ++ .../board/sections/grid/fixed-grid-item.tsx | 1 + .../board/sections/grid/grid-drop-swap.ts | 33 +++++-- .../board/sections/grid/grid-editor.css | 97 ++++++++++++++++++- .../board/sections/grid/grid-editor.tsx | 4 + .../sections/grid/section-grid.module.css | 45 ++++++++- .../grid/test/fixed-grid-item.spec.ts | 4 +- .../sections/grid/test/grid-drop-swap.spec.ts | 30 +++++- e2e/board-grid.spec.ts | 13 ++- 9 files changed, 214 insertions(+), 20 deletions(-) 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();