fix: clarify board grid editing affordances

This commit is contained in:
ajnart committed 2026-08-01 14:13:07 +02:00
1 parent 9c8fa8158b
commit 50907c7289
10 files changed
+217 -22

No files matched your search

+3 -2
View File
@@ -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.
@@ -13,4 +13,11 @@
.editModeInertContent {
display: contents;
pointer-events: none;
user-select: none;
}
.editModeInertContent * {
pointer-events: none !important;
user-select: none !important;
}
@@ -137,6 +137,7 @@ export const FixedGridItem = ({
onBlur={isEditMode ? handleBlur : undefined}
onKeyDown={isEditMode ? handleKeyboard : undefined}
>
{isEditMode && <span className={classes.dragAffordance} data-testid="board-grid-drag-affordance" aria-hidden />}
{children}
{isEditMode && (
<span id={instructionsId} className={classes.liveRegion}>
@@ -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;
@@ -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;
}
}
@@ -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;
@@ -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;
}
}
@@ -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", () => {
@@ -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();
});
});
+10 -3
View File
@@ -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();