fix: improve board grid interactions
This commit is contained in:
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
|
||||
|
||||

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

|
||||
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
Reference in new issue
Block a user