diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx
index f5f389590..d939d19aa 100644
--- a/apps/docs/docs/getting-started/after-the-installation.mdx
+++ b/apps/docs/docs/getting-started/after-the-installation.mdx
@@ -219,8 +219,9 @@ Press and hold the handle, move the pointer, and release it when the tile has th

-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.

diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx
index 21a224f48..89a7917fb 100644
--- a/apps/docs/docs/management/boards/index.mdx
+++ b/apps/docs/docs/management/boards/index.mdx
@@ -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.
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 f0f6ce4bc..0c95d0b81 100644
--- a/apps/nextjs/src/components/board/items/item-content.module.css
+++ b/apps/nextjs/src/components/board/items/item-content.module.css
@@ -10,3 +10,7 @@
background-color: var(--background-color) !important;
border-color: var(--border-color) !important;
}
+
+.editModeInertContent {
+ display: contents;
+}
diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx
index 980f44834..a8f49f360 100644
--- a/apps/nextjs/src/components/board/items/item-content.tsx
+++ b/apps/nextjs/src/components/board/items/item-content.tsx
@@ -126,7 +126,13 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
fallbackRender={({ resetErrorBoundary, error }) => (
<>
{isEditMode && }
-
+
+
+
>
)}
>
@@ -139,22 +145,28 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
}
/>
{isEditMode && }
-
- updateOptions({
- newOptions: {
- ...options,
- ...partialNewOptions.newOptions,
- },
- })
- }
- {...dimensions}
- />
+
+
+ updateOptions({
+ newOptions: {
+ ...options,
+ ...partialNewOptions.newOptions,
+ },
+ })
+ }
+ {...dimensions}
+ />
+
)}
diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx
index ab22b22fc..18aec7ebf 100644
--- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx
+++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx
@@ -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);
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
new file mode 100644
index 000000000..6f0eee0cb
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/grid/grid-drop-swap.ts
@@ -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;
+ });
+};
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 cc21f2c16..bd51ec827 100644
--- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css
+++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css
@@ -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) {
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 d9dd92687..fe6f21f2f 100644
--- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx
+++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx
@@ -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(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 => {
diff --git a/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx b/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx
index fd5e4d4e6..de2c872f2 100644
--- a/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx
+++ b/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx
@@ -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;
diff --git a/apps/nextjs/src/components/board/sections/grid/grid-resize-handles.ts b/apps/nextjs/src/components/board/sections/grid/grid-resize-handles.ts
index 111b2d0d2..a5ee0efac 100644
--- a/apps/nextjs/src/components/board/sections/grid/grid-resize-handles.ts
+++ b/apps/nextjs/src/components/board/sections/grid/grid-resize-handles.ts
@@ -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(".ui-resizable-handle")) {
const direction = Array.from(handle.classList).find((className) =>
/^ui-resizable-(?:n|ne|e|se|s|sw|w|nw)$/.test(className),
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
new file mode 100644
index 000000000..edac286ff
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/grid/test/grid-drop-swap.spec.ts
@@ -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();
+ });
+});
diff --git a/apps/nextjs/src/components/board/sections/grid/test/grid-resize-handles.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/grid-resize-handles.spec.ts
index 3f93e7b7b..6efd9cdca 100644
--- a/apps/nextjs/src/components/board/sections/grid/test/grid-resize-handles.spec.ts
+++ b/apps/nextjs/src/components/board/sections/grid/test/grid-resize-handles.spec.ts
@@ -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", () => {
`;
- decorateGridResizeHandles(item);
+ decorateGridResizeHandles(item, { minW: 2, minH: 3 });
const east = item.querySelector(".ui-resizable-e");
const southeast = item.querySelector(".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`);
});
});
diff --git a/e2e/board-grid.spec.ts b/e2e/board-grid.spec.ts
index 2bc8483c9..f836ac229 100644
--- a/e2e/board-grid.spec.ts
+++ b/e2e/board-grid.spec.ts
@@ -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"),