fix: improve board grid interactions

This commit is contained in:
ajnart committed 2026-08-14 11:07:06 +02:00
1 parent 915af9ae96
commit 2f28f45ff1
10 files changed
+189 -16

No files matched your search

@@ -219,8 +219,9 @@ Press and hold the handle, move the pointer, and release it when the tile has th
![](./img/resize-item.gif)
To move a tile in edit mode, press and hold anywhere on the tile except one of its buttons or controls, move it to the desired position, and release it.
The tile snaps to the grid and neighboring tiles move out of the way automatically.
To move a tile in edit mode, press and hold its content, move it to the desired position, and release it.
Widget content is disabled while editing so its controls do not intercept the drag; use the tile's settings button for edit actions.
The tile snaps to the grid. Equally-sized tiles swap when one is dropped into the other's slot, while other neighboring tiles move out of the way automatically.
![](./img/move-item.gif)
@@ -10,3 +10,7 @@
background-color: var(--background-color) !important;
border-color: var(--border-color) !important;
}
.editModeInertContent {
display: contents;
}
@@ -0,0 +1,53 @@
import type { SectionGridPlacement } from "./use-grid-layout-actions";
/**
* GridStack's floating mode lets Homarr keep intentional empty rows, but it
* disables GridStack's built-in swap path. Restore the intuitive subset: when
* an item lands exactly in an equally-sized item's former slot, exchange the
* two slots and leave every other item where it started.
*/
export const getDropSwapPlacements = (
initial: readonly SectionGridPlacement[],
current: readonly SectionGridPlacement[],
movedItemId: string,
): SectionGridPlacement[] | null => {
const movedInitially = initial.find((placement) => placement.id === movedItemId);
const movedCurrently = current.find((placement) => placement.id === movedItemId);
if (!movedInitially || !movedCurrently) return null;
if (
movedInitially.x === movedCurrently.x &&
movedInitially.y === movedCurrently.y &&
movedInitially.w === movedCurrently.w &&
movedInitially.h === movedCurrently.h
) {
return null;
}
const displaced = initial.find(
(placement) =>
placement.id !== movedItemId &&
placement.x === movedCurrently.x &&
placement.y === movedCurrently.y &&
placement.w === movedCurrently.w &&
placement.h === movedCurrently.h,
);
if (!displaced) return null;
return initial.map((placement) => {
if (placement.id === movedItemId) {
return {
...placement,
x: displaced.x,
y: displaced.y,
};
}
if (placement.id === displaced.id) {
return {
...placement,
x: movedInitially.x,
y: movedInitially.y,
};
}
return placement;
});
};
@@ -23,9 +23,10 @@
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se,
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw {
width: 28px;
height: 28px;
background-size: 12px 12px;
width: 44px;
height: 44px;
background-size: 16px 16px;
z-index: 9;
}
@media (prefers-reduced-motion: reduce) {
@@ -14,6 +14,7 @@ import { useI18n } from "@homarr/translation/client";
import { getLogicalTrackSize, LOGICAL_GRID_GAP, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import { useSectionContext } from "../section-context";
import { getDropSwapPlacements } from "./grid-drop-swap";
import { useBoardGridPortalHost } from "./grid-portal-host";
import { decorateGridResizeHandles } from "./grid-resize-handles";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
@@ -54,6 +55,11 @@ export default function GridEditor({
const gridRef = useRef<GridStack | null>(null);
const applyingControlledLayoutRef = useRef(false);
const placementsRef = useRef(placements);
const dragSnapshotRef = useRef<{
grid: GridStack;
itemId: string;
placements: SectionGridPlacement[];
} | null>(null);
const entriesRef = useRef([...items, ...innerSections]);
const acceptWidgetRef = useRef<(element: Element) => boolean>(() => false);
const dimensionsRef = useRef({ columnCount, fixedRowCount, rowCount });
@@ -205,12 +211,43 @@ export default function GridEditor({
wrapper.classList.add("grid-stack-editing");
const handleStop = (_event: Event, element: GridItemHTMLElement) => {
eventHandlersRef.current.commitGrid(element.gridstackNode?.grid ?? grid);
eventHandlersRef.current.announceNode(element.gridstackNode);
decorateGrid(element.gridstackNode?.grid ?? grid, placementsRef.current);
restoreContentHeight(grid);
const changedGrid = element.gridstackNode?.grid ?? grid;
const dragSnapshot = dragSnapshotRef.current;
dragSnapshotRef.current = null;
queueMicrotask(() => {
if (dragSnapshot?.grid === changedGrid) {
const swapped = getDropSwapPlacements(
dragSnapshot.placements,
getGridPlacements(changedGrid),
dragSnapshot.itemId,
);
if (swapped) {
applyingControlledLayoutRef.current = true;
try {
changedGrid.load(swapped.map(toGridStackWidget), shellCallback);
} finally {
applyingControlledLayoutRef.current = false;
}
}
}
eventHandlersRef.current.commitGrid(changedGrid);
eventHandlersRef.current.announceNode(element.gridstackNode);
decorateGrid(changedGrid, placementsRef.current);
restoreContentHeight(grid);
});
};
const handleDragStart = () => {
const handleDragStart = (_event: Event, element: GridItemHTMLElement) => {
const changedGrid = element.gridstackNode?.grid ?? grid;
const itemId = element.getAttribute("data-grid-item-id") ?? element.gridstackNode?.id;
if (itemId) {
dragSnapshotRef.current = {
grid: changedGrid,
itemId: String(itemId),
placements: getGridPlacements(changedGrid),
};
}
const currentDimensions = dimensionsRef.current;
if (currentDimensions.fixedRowCount) return;
@@ -224,6 +261,7 @@ export default function GridEditor({
previousNode: GridStackNode | undefined,
newNode: GridStackNode | undefined,
) => {
dragSnapshotRef.current = null;
queueMicrotask(() => {
if (applyingControlledLayoutRef.current) return;
const snapshots = uniqueGrids([previousNode?.grid, newNode?.grid ?? grid])
@@ -358,7 +396,7 @@ const decorateItem = (element: GridItemHTMLElement, type: SectionGridPlacement["
element.setAttribute("data-grid-y", String(node.y ?? 0));
element.setAttribute("data-grid-w", String(node.w ?? 1));
element.setAttribute("data-grid-h", String(node.h ?? 1));
decorateGridResizeHandles(element);
decorateGridResizeHandles(element, { minW: node.minW, minH: node.minH });
};
const getElementItemType = (element: Element): SectionGridPlacement["type"] | undefined => {
@@ -129,7 +129,10 @@ const GridPortalContent = ({ itemId, ownerSectionId, container, integrations, an
if (!itemElement) return;
itemElement.gridstackNode?.grid?.refreshDragHandles(itemElement);
decorateGridResizeHandles(itemElement);
decorateGridResizeHandles(itemElement, {
minW: itemElement.gridstackNode?.minW,
minH: itemElement.gridstackNode?.minH,
});
}, [container, ownerSectionId]);
if (!entry || !section || !currentLayout) return null;
@@ -1,4 +1,14 @@
export const decorateGridResizeHandles = (element: Element) => {
import { LOGICAL_GRID_PITCH } from "~/components/board/layout";
interface GridResizeMinimum {
minW?: number;
minH?: number;
}
export const decorateGridResizeHandles = (element: HTMLElement, minimum: GridResizeMinimum = {}) => {
element.style.minWidth = `${(minimum.minW ?? 1) * LOGICAL_GRID_PITCH}px`;
element.style.minHeight = `${(minimum.minH ?? 1) * LOGICAL_GRID_PITCH}px`;
for (const handle of element.querySelectorAll<HTMLElement>(".ui-resizable-handle")) {
const direction = Array.from(handle.classList).find((className) =>
/^ui-resizable-(?:n|ne|e|se|s|sw|w|nw)$/.test(className),
@@ -0,0 +1,29 @@
import { describe, expect, test } from "vitest";
import { getDropSwapPlacements } from "../grid-drop-swap";
import type { SectionGridPlacement } from "../use-grid-layout-actions";
const first: SectionGridPlacement = { id: "first", type: "item", x: 0, y: 0, w: 1, h: 1 };
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];
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]);
});
test("keeps push behavior for different sizes or partial placement", () => {
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();
});
test("does nothing when the item did not move", () => {
expect(getDropSwapPlacements(initial, initial, "first")).toBeNull();
});
});
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest";
import { LOGICAL_GRID_PITCH } from "../../../layout";
import { decorateGridResizeHandles } from "../grid-resize-handles";
describe("grid resize handle decoration", () => {
@@ -10,7 +11,7 @@ describe("grid resize handle decoration", () => {
<div class="ui-resizable-handle ui-resizable-se"></div>
`;
decorateGridResizeHandles(item);
decorateGridResizeHandles(item, { minW: 2, minH: 3 });
const east = item.querySelector<HTMLElement>(".ui-resizable-e");
const southeast = item.querySelector<HTMLElement>(".ui-resizable-se");
@@ -20,5 +21,7 @@ describe("grid resize handle decoration", () => {
expect(southeast?.dataset.testid).toBe("board-grid-resize-handle");
expect(southeast?.dataset.gridResizeDirection).toBe("se");
expect(southeast?.getAttribute("aria-hidden")).toBe("true");
expect(item.style.minWidth).toBe(`${2 * LOGICAL_GRID_PITCH}px`);
expect(item.style.minHeight).toBe(`${3 * LOGICAL_GRID_PITCH}px`);
});
});
+33 -2
View File
@@ -208,6 +208,8 @@ describe("Board grid", () => {
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", "");
await page.getByRole("button", { name: "Add board content" }).click();
await expect(page.getByRole("menuitem", { name: "New section", exact: true })).toBeVisible();
@@ -220,7 +222,11 @@ describe("Board grid", () => {
const railBoxBeforeMainDrag = await expectBoundingBoxAsync(rail);
const canvasScale = await readCanvasScaleAsync(canvas);
await dragLocatorByAsync(page, getEditorEntry(firstItem), logicalCellPitch * canvasScale * 4, 0);
await dragLocatorByAsync(page, getEditorEntry(firstItem), logicalCellPitch * canvasScale, 0);
await expect(firstItem).toHaveAttribute("data-grid-x", "1");
await expect(secondItem).toHaveAttribute("data-grid-x", "0");
await dragLocatorByAsync(page, getEditorEntry(firstItem), logicalCellPitch * canvasScale * 3, 0);
await expect(firstItem).toHaveAttribute("data-grid-x", "4");
expect(gridLayoutsOverlap(await readGridLayoutAsync(firstItem), await readGridLayoutAsync(secondItem))).toBe(
false,
@@ -279,7 +285,23 @@ describe("Board grid", () => {
await expect(railKeyboardEntry).toHaveAttribute("data-keyboard-editing", "false");
const dynamicResizeHandle = dynamicSection.locator(':scope > [data-testid="board-grid-resize-handle"]');
await dragLocatorByAsync(page, dynamicResizeHandle, 0, logicalCellPitch * canvasScale);
await expectLogicalTargetSizeAsync(dynamicResizeHandle, 44);
await dragLocatorByAsync(page, dynamicResizeHandle, 0, -logicalCellPitch * canvasScale);
await expect(dynamicSection).toHaveAttribute("data-grid-h", "1");
const minimumSectionBox = await expectBoundingBoxAsync(dynamicSection);
const minimumHandleBox = await expectBoundingBoxAsync(dynamicResizeHandle);
const resizeStartX = minimumHandleBox.x + minimumHandleBox.width / 2;
const resizeStartY = minimumHandleBox.y + minimumHandleBox.height / 2;
await page.mouse.move(resizeStartX, resizeStartY);
await page.mouse.down();
await page.mouse.move(resizeStartX, resizeStartY - logicalCellPitch * canvasScale * 2, { steps: 12 });
const constrainedSectionBox = await expectBoundingBoxAsync(dynamicSection);
expect(Math.abs(constrainedSectionBox.height - minimumSectionBox.height)).toBeLessThan(1);
await page.mouse.up();
await expect(dynamicSection).toHaveAttribute("data-grid-h", "1");
await dragLocatorByAsync(page, dynamicResizeHandle, 0, logicalCellPitch * canvasScale * 2);
await expect(dynamicSection).toHaveAttribute("data-grid-h", "3");
await expect(belowItem).toHaveAttribute("data-grid-y", "3");
expect(gridLayoutsOverlap(await readGridLayoutAsync(dynamicSection), await readGridLayoutAsync(belowItem))).toBe(
@@ -916,6 +938,15 @@ const expectTargetSizeAsync = async (locator: Locator) => {
expect(box.height).toBeGreaterThanOrEqual(24);
};
const expectLogicalTargetSizeAsync = async (locator: Locator, minimumSize: number) => {
const dimensions = await locator.evaluate((element) => ({
width: (element as HTMLElement).offsetWidth,
height: (element as HTMLElement).offsetHeight,
}));
expect(dimensions.width).toBeGreaterThanOrEqual(minimumSize);
expect(dimensions.height).toBeGreaterThanOrEqual(minimumSize);
};
const readGridLayoutAsync = async (locator: Locator) => {
const [x, y, width, height] = await Promise.all([
locator.getAttribute("data-grid-x"),