fix: improve board grid interactions

This commit is contained in:
ajnart committed 2026-08-01 00:43:36 +02:00
1 parent e6e413bcb7
commit 9c8fa8158b
13 files changed
+223 -36

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)
+4 -2
View File
@@ -142,8 +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.
Drag from anywhere on a tile except its buttons or controls.
When a tile grows or moves into occupied space, nearby tiles are compacted out of the way.
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.
For other collisions, or when a tile grows into occupied space, nearby tiles are compacted out of the way.
Resize handles stop at the tile's minimum allowed size while the pointer is still moving.
Exit edit mode to save the resulting positions.
Keyboard users can focus a tile, press Enter or Space, then use arrow keys to move and Shift plus arrow keys to resize.
@@ -10,3 +10,7 @@
background-color: var(--background-color) !important;
border-color: var(--border-color) !important;
}
.editModeInertContent {
display: contents;
}
@@ -126,7 +126,13 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
fallbackRender={({ resetErrorBoundary, error }) => (
<>
{isEditMode && <BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />}
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
<div
className={itemContentClasses.editModeInertContent}
data-board-grid-inert-content={isEditMode ? "true" : undefined}
inert={isEditMode}
>
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
</div>
</>
)}
>
@@ -139,22 +145,28 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
}
/>
{isEditMode && <BoardItemMenu offset={4} item={newItem} />}
<Comp
options={options as never}
integrationIds={item.integrationIds}
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}
setOptions={(partialNewOptions) =>
updateOptions({
newOptions: {
...options,
...partialNewOptions.newOptions,
},
})
}
{...dimensions}
/>
<div
className={itemContentClasses.editModeInertContent}
data-board-grid-inert-content={isEditMode ? "true" : undefined}
inert={isEditMode}
>
<Comp
options={options as never}
integrationIds={item.integrationIds}
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}
setOptions={(partialNewOptions) =>
updateOptions({
newOptions: {
...options,
...partialNewOptions.newOptions,
},
})
}
{...dimensions}
/>
</div>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
@@ -169,7 +169,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
);
if (!session) return <>{children}</>;
if (!isRightClickEnabled) return <>{children}</>;
if (!isRightClickEnabled || isEditMode) return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
@@ -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"),