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

@@ -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`);
});
});