From 50907c728972006cf877f61621b2b83238d8440f 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 --- apps/docs/docs/management/boards/index.mdx | 5 +- .../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 ++- 10 files changed, 217 insertions(+), 22 deletions(-) diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 89a7917fb..4663792fc 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -142,9 +142,10 @@ Once the minimum of the next layout is reached, the layout will change. View mode renders a static board without executing the drag-and-drop editor. After the board is interactive, Homarr prepares the editor in the background for users who can modify it. Entering edit mode keeps the static board visible until that preparation finishes, then enables collision-aware movement and resizing without changing the board geometry. -Widget content is inert in edit mode, so dragging anywhere on the content moves the tile instead of activating the widget; the tile's settings button remains available. -Dropping a tile into the exact slot of an equally-sized tile swaps their positions. +Widget content is inert in edit mode, so dragging anywhere on the content moves the tile instead of activating the widget; the visible grip and tile outline indicate the active move surface, while the tile's settings button remains available. +Dropping a tile into another tile's exact slot swaps their positions when both resulting placements fit without overlapping other content. For other collisions, or when a tile grows into occupied space, nearby tiles are compacted out of the way. +The visible corner controls provide a 44-pixel resize target, and edge controls appear on hover for horizontal or vertical resizing. Resize handles stop at the tile's minimum allowed size while the pointer is still moving. Exit edit mode to save the resulting positions. 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();