feat: rebuild board grid with dnd-kit

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:52:59 +02:00
1 parent 5bde4d3cc5
commit 1f5311e93b
107 files changed
+5628 -3209

No files matched your search

@@ -76,11 +76,8 @@ const getEmptySectionPosition = (
if (!boardLayout) return;
const sectionElements = getSectionElements(board, { sectionId: section.id, layoutId: layout.layoutId });
if (section.kind !== "dynamic") {
const columnCount =
section.kind === "empty"
? getBoardLaneColumnCount(boardLayout, getRootSectionLane(section.xOffset))
: boardLayout.columnCount;
if (section.kind === "empty") {
const columnCount = getBoardLaneColumnCount(boardLayout, getRootSectionLane(section.xOffset));
const size = {
width: layout.width,
@@ -1,8 +1,8 @@
import type { Board } from "~/app/[locale]/boards/_types";
export const getSectionElements = (board: Board, { sectionId, layoutId }: { sectionId: string; layoutId: string }) => {
const dynamicSectionsOfFirstSection = board.sections
.filter((section) => section.kind === "dynamic")
const containersOfSection = board.sections
.filter((section) => section.kind === "container")
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.map(({ layouts, ...section }) => ({ ...section, ...layouts.find((layout) => layout.layoutId === layoutId)! }))
.filter((section) => section.parentSectionId === sectionId);
@@ -14,5 +14,5 @@ export const getSectionElements = (board: Board, { sectionId, layoutId }: { sect
}))
.filter((item) => item.sectionId === sectionId);
return [...items, ...dynamicSectionsOfFirstSection];
return [...items, ...containersOfSection];
};
@@ -5,7 +5,7 @@ import * as boardContext from "@homarr/boards/context";
import { createItemCallback } from "../create-item";
import * as emptyPositionModule from "../empty-position";
import { BoardMockBuilder } from "./mocks/board-mock";
import { DynamicSectionMockBuilder } from "./mocks/dynamic-section-mock";
import { ContainerSectionMockBuilder } from "./mocks/container-section-mock";
import { ItemMockBuilder } from "./mocks/item-mock";
import { LayoutMockBuilder } from "./mocks/layout-mock";
@@ -58,7 +58,7 @@ describe("item actions create-item", () => {
);
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 });
});
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
test("should correctly pass containers and items to getFirstEmptyPosition", () => {
// Arrange
const itemKind = "clock";
const emptyPosition = { xOffset: 5, yOffset: 5 };
@@ -67,21 +67,21 @@ describe("item actions create-item", () => {
const itemAndSectionPosition = { height: 2, width: 3, yOffset: 2, xOffset: 1 };
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
const dynamicSectionInFirstSection = new DynamicSectionMockBuilder({ id: "4" })
const containerInFirstSection = new ContainerSectionMockBuilder({ id: "4" })
.addLayout({ ...itemAndSectionPosition, layoutId, parentSectionId: firstSectionId })
.build();
const itemInFirstSection = new ItemMockBuilder({ id: "12" })
.addLayout({ ...itemAndSectionPosition, layoutId, sectionId: firstSectionId })
.build();
const otherDynamicSection = new DynamicSectionMockBuilder({ id: "5" }).addLayout({ layoutId }).build();
const otherContainer = new ContainerSectionMockBuilder({ id: "5" }).addLayout({ layoutId }).build();
const otherItem = new ItemMockBuilder({ id: "13" }).addLayout({ layoutId }).build();
const board = new BoardMockBuilder()
.addLayout(layout)
.addEmptySection({ id: "1", yOffset: 2 })
.addEmptySection({ id: firstSectionId, yOffset: 0 })
.addEmptySection({ id: "3", yOffset: 1 })
.addSection(dynamicSectionInFirstSection)
.addSection(otherDynamicSection)
.addSection(containerInFirstSection)
.addSection(otherContainer)
.addItem(itemInFirstSection)
.addItem(otherItem)
.build();
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import { getMoveTargets } from "../../item-move-modal";
import { BoardMockBuilder } from "./mocks/board-mock";
import { DynamicSectionMockBuilder } from "./mocks/dynamic-section-mock";
import { ContainerSectionMockBuilder } from "./mocks/container-section-mock";
import { EmptySectionMockBuilder } from "./mocks/empty-section-mock";
import { ItemMockBuilder } from "./mocks/item-mock";
@@ -20,8 +20,8 @@ describe("item move targets", () => {
new EmptySectionMockBuilder({ id: "canvas", xOffset: 0 }).build(),
new EmptySectionMockBuilder({ id: "left-gutter", xOffset: -1 }).build(),
new EmptySectionMockBuilder({ id: "right-gutter", xOffset: 1 }).build(),
new DynamicSectionMockBuilder({
id: "dynamic",
new ContainerSectionMockBuilder({
id: "container",
options: {
title: "",
customCssClasses: [],
@@ -53,7 +53,7 @@ describe("item move targets", () => {
},
{
canvas: "Dashboard canvas",
dynamicSection: "New section",
container: "New section",
leftRail: "Left dashboard rail",
rightRail: "Right dashboard rail",
numbered: (name, index) => `${name}, destination ${index}`,
@@ -78,7 +78,7 @@ describe("item move targets", () => {
layout.columnCount = 5;
layout.leftGutterColumnCount = 2;
const section = new DynamicSectionMockBuilder({ id: "source" })
const section = new ContainerSectionMockBuilder({ id: "source" })
.addLayout({
layoutId: layout.id,
parentSectionId: "canvas",
@@ -110,7 +110,7 @@ describe("item move targets", () => {
{ ...sourceLayout, id: section.id, type: "section" },
{
canvas: "Canvas",
dynamicSection: "Section",
container: "Section",
leftRail: "Left",
rightRail: "Right",
numbered: (name, index) => `${name} ${index}`,
@@ -1,7 +1,7 @@
import { createId } from "@homarr/common";
import type { Board, DynamicSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
import { DynamicSectionMockBuilder } from "./dynamic-section-mock";
import type { Board, ContainerSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
import { ContainerSectionMockBuilder } from "./container-section-mock";
import { EmptySectionMockBuilder } from "./empty-section-mock";
import { ItemMockBuilder } from "./item-mock";
import { LayoutMockBuilder } from "./layout-mock";
@@ -58,8 +58,8 @@ export class BoardMockBuilder {
return this.addSection(new EmptySectionMockBuilder(emptySection).build());
}
public addDynamicSection(dynamicSection?: Partial<DynamicSection>): BoardMockBuilder {
return this.addSection(new DynamicSectionMockBuilder(dynamicSection).build());
public addContainer(container?: Partial<ContainerSection>): BoardMockBuilder {
return this.addSection(new ContainerSectionMockBuilder(container).build());
}
public addSection(section: Section): BoardMockBuilder {
@@ -0,0 +1,38 @@
import { createId } from "@homarr/common";
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
import type { ContainerSection } from "~/app/[locale]/boards/_types";
type ContainerSectionMock = Omit<Partial<ContainerSection>, "options"> & {
options?: Partial<ContainerSection["options"]>;
};
export class ContainerSectionMockBuilder {
private readonly section: ContainerSection;
constructor(section?: ContainerSectionMock) {
this.section = {
id: section?.id ?? createId(),
kind: "container",
collapsed: section?.collapsed ?? false,
layouts: section?.layouts ?? [],
options: containerSectionOptionsSchema.parse(section?.options),
};
}
public addLayout(layout?: Partial<ContainerSection["layouts"][0]>): ContainerSectionMockBuilder {
this.section.layouts.push({
layoutId: layout?.layoutId ?? createId(),
parentSectionId: layout?.parentSectionId ?? createId(),
xOffset: layout?.xOffset ?? 0,
yOffset: layout?.yOffset ?? 0,
width: layout?.width ?? 1,
height: layout?.height ?? 1,
});
return this;
}
public build(): ContainerSection {
return structuredClone(this.section);
}
}
@@ -1,40 +0,0 @@
import { createId } from "@homarr/common";
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
import type { DynamicSection } from "~/app/[locale]/boards/_types";
type DynamicSectionMock = Omit<Partial<DynamicSection>, "options"> & {
options?: Partial<DynamicSection["options"]>;
};
export class DynamicSectionMockBuilder {
private readonly section: DynamicSection;
constructor(section?: DynamicSectionMock) {
this.section = {
id: createId(),
kind: "dynamic",
layouts: [],
...section,
collapsed: section?.collapsed ?? false,
options: dynamicSectionOptionsSchema.parse(section?.options),
} satisfies DynamicSection;
}
public addLayout(layout?: Partial<DynamicSection["layouts"][0]>): DynamicSectionMockBuilder {
this.section.layouts.push({
layoutId: "1",
height: 1,
width: 1,
xOffset: 0,
yOffset: 0,
parentSectionId: "0",
...layout,
} satisfies DynamicSection["layouts"][0]);
return this;
}
public build(): DynamicSection {
return this.section;
}
}
@@ -13,10 +13,8 @@
transform 150ms ease;
}
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover)
.wrapper,
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within)
.wrapper {
:global([data-type="item"]:not([data-kind="app"]):hover) .wrapper,
:global([data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
opacity: 1;
transform: translateY(0);
}
@@ -50,7 +50,7 @@ export const getInitialBoardLogicalHeight = (board: Board, layoutId: string) =>
: [];
}),
...board.sections.flatMap((section) => {
if (section.kind !== "dynamic") return [];
if (section.kind !== "container") return [];
const sectionLayout = section.layouts.find(
(candidate) => candidate.layoutId === layoutId && candidate.parentSectionId === root.id,
);
@@ -75,7 +75,7 @@ export const getInitialBoardLogicalHeight = (board: Board, layoutId: string) =>
board.sections
.filter(
(section) =>
section.kind === "dynamic" &&
section.kind === "container" &&
section.collapsed &&
section.options.collapsible &&
placements.some((placement) => placement.id === section.id),
@@ -1,7 +1,7 @@
"use client";
import type { PropsWithChildren } from "react";
import { useEffect, useMemo, useState } from "react";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { Box } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
@@ -10,6 +10,10 @@ import classes from "./scaled-board-canvas.module.css";
export { MIN_ACCESSIBLE_CANVAS_SCALE };
const BoardCanvasScaleContext = createContext(1);
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth: number) => {
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
@@ -84,7 +88,7 @@ export const ScaledBoardCanvas = ({
transform: `scale(${scale})`,
}}
>
{children}
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
</Box>
</Box>
</Box>
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import type { Board } from "~/app/[locale]/boards/_types";
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
import { DynamicSectionMockBuilder } from "~/components/board/items/actions/test/mocks/dynamic-section-mock";
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
import { getBoardLaneColumnCount, getInitialBoardLogicalHeight, getRootSectionForLane } from "../index";
@@ -51,13 +51,13 @@ describe("board lanes", () => {
const layoutId = "layout";
const mainSectionId = "main";
const leftSectionId = "left";
const dynamicSectionId = "dynamic";
const containerSectionId = "container";
const board = new BoardMockBuilder()
.addSection(new EmptySectionMockBuilder({ id: mainSectionId }).build())
.addSection(new EmptySectionMockBuilder({ id: leftSectionId, xOffset: -1 }).build())
.addSection(
new DynamicSectionMockBuilder({
id: dynamicSectionId,
new ContainerSectionMockBuilder({
id: containerSectionId,
collapsed: true,
options: { collapsible: true },
})
@@ -1,25 +0,0 @@
import type { WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings/creator";
import type { WidgetComponentProps } from "@homarr/widgets/definition";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { Item } from "~/app/[locale]/boards/_types";
export const filterByItemKind = async <TKind extends WidgetKind>(
items: Item[],
settings: SettingsContextProps,
kind: TKind,
) => {
const definition = await loadWidgetDefinition(kind);
return items
.filter((item) => item.kind === kind)
.map((item) => ({
...item,
options: reduceWidgetOptionsWithDefinition(
definition,
settings,
item.options,
) as WidgetComponentProps<TKind>["options"],
}));
};
@@ -1,31 +1,31 @@
import dynamic from "next/dynamic";
import { ActionIcon, Badge, Box, Card } from "@mantine/core";
import { IconChevronDown, IconChevronUp, IconExternalLink } from "@tabler/icons-react";
import { IconChevronDown, IconChevronUp, IconExternalLink, IconGripVertical } from "@tabler/icons-react";
import combineClasses from "clsx";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
import { SectionGrid } from "./grid/section-grid";
import { useSectionCollapse } from "./section-collapse";
import { useOpenSectionApps } from "./use-open-section-apps";
import classes from "./item.module.css";
const BoardDynamicSectionMenu = dynamic(
() => import("./dynamic/dynamic-menu").then((module) => module.BoardDynamicSectionMenu),
const BoardContainerMenu = dynamic(
() => import("./container/container-menu").then((module) => module.BoardContainerMenu),
{ ssr: false },
);
interface Props {
section: DynamicSectionItem;
section: ContainerSectionItem;
}
export const BoardDynamicSection = ({ section }: Props) => {
export const BoardContainerSection = ({ section }: Props) => {
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const t = useScopedI18n("section.dynamic");
const t = useScopedI18n("section.container");
const tAll = useI18n();
const options = section.options;
const { open: openAllInNewTabs, isLoading: areAppsLoading } = useOpenSectionApps(
@@ -37,22 +37,14 @@ export const BoardDynamicSection = ({ section }: Props) => {
collapsible: options.collapsible,
});
const label = options.title || t("action.create");
const contentId = `board-section-${section.id}-content`;
const labelLeft = !isEditMode && options.collapsible ? 40 : 10;
const contentId = `board-container-${section.id}-content`;
const labelLeft = isEditMode || options.collapsible ? 40 : 10;
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
return (
<Box
className="grid-stack-item-content"
data-grid-item-content
w="100%"
h="100%"
style={{
overflow: "visible",
}}
>
<Box className="board-grid-item-content" data-grid-item-content w="100%" h="100%" style={{ overflow: "visible" }}>
<Card
className={combineClasses(classes.itemCard, section.options.customCssClasses.join(" "))}
className={combineClasses(classes.itemCard, options.customCssClasses.join(" "))}
w="100%"
h="100%"
styles={{
@@ -65,6 +57,21 @@ export const BoardDynamicSection = ({ section }: Props) => {
radius={board.itemRadius}
p={0}
>
{isEditMode && (
<ActionIcon
component="span"
pos="absolute"
top={4}
left={4}
style={{ zIndex: 10, cursor: "grab", touchAction: "none" }}
variant="default"
radius="xl"
data-grid-container-drag-handle
aria-hidden="true"
>
<IconGripVertical size={16} />
</ActionIcon>
)}
{options.showLabel && options.title && (
<Badge
pos="absolute"
@@ -123,7 +130,7 @@ export const BoardDynamicSection = ({ section }: Props) => {
)}
</Box>
</Card>
{isEditMode && <BoardDynamicSectionMenu section={section} />}
{isEditMode && <BoardContainerMenu section={section} />}
</Box>
);
};
@@ -1,14 +1,14 @@
import { getBoardLayouts } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import { getRootSectionLane } from "@homarr/definitions";
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
import type { Board, DynamicSection, DynamicSectionLayout, EmptySection } from "~/app/[locale]/boards/_types";
import type { Board, ContainerSection, ContainerSectionLayout, EmptySection } from "~/app/[locale]/boards/_types";
import { getBoardLaneColumnCount } from "~/components/board/layout";
import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position";
import { getSectionElements } from "~/components/board/items/actions/section-elements";
export const addDynamicSectionCallback = () => (board: Board) => {
export const addContainerCallback = () => (board: Board) => {
const firstSection = board.sections
.filter(
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
@@ -18,22 +18,22 @@ export const addDynamicSectionCallback = () => (board: Board) => {
if (!firstSection) return board;
const sectionLayouts = createDynamicSectionLayouts(board, firstSection);
const sectionLayouts = createContainerLayouts(board, firstSection);
const newSection = {
id: createId(),
kind: "dynamic",
kind: "container",
collapsed: false,
options: dynamicSectionOptionsSchema.parse(undefined),
options: containerSectionOptionsSchema.parse(undefined),
layouts: sectionLayouts,
} satisfies DynamicSection;
} satisfies ContainerSection;
return {
...board,
sections: board.sections.concat(newSection as unknown as DynamicSection),
sections: board.sections.concat(newSection),
};
};
const createDynamicSectionLayouts = (board: Board, currentSection: EmptySection): DynamicSectionLayout[] => {
const createContainerLayouts = (board: Board, currentSection: EmptySection): ContainerSectionLayout[] => {
const layouts = getBoardLayouts(board);
return layouts.map((layoutId) => {
@@ -49,9 +49,7 @@ const createDynamicSectionLayouts = (board: Board, currentSection: EmptySection)
? getFirstEmptyPosition(elements, columnCount, undefined, size)
: { xOffset: 0, yOffset: 0 };
if (!emptyPosition) {
throw new Error("Your board is full");
}
if (!emptyPosition) throw new Error("Your board is full");
return {
...size,
@@ -0,0 +1,117 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
import type { ContainerSection } from "~/app/[locale]/boards/_types";
import * as emptyPositionModule from "~/components/board/items/actions/empty-position";
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
import { addContainerCallback } from "./add-container";
import { removeContainerCallback } from "./remove-container";
describe("container actions", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("creates a container in the first main root for every responsive layout", () => {
const board = new BoardMockBuilder().addEmptySection({ id: "root", yOffset: 0 }).build();
const layout = board.layouts[0];
if (!layout) throw new Error("Expected a board layout");
layout.id = "layout";
layout.columnCount = 2;
vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([layout.id]);
vi.spyOn(emptyPositionModule, "getFirstEmptyPosition").mockReturnValue({ xOffset: 0, yOffset: 4 });
const result = addContainerCallback()(board);
const created = result.sections.find((section) => section.kind === "container");
expect(created).toMatchObject({
kind: "container",
collapsed: false,
options: {
showLabel: true,
collapsible: false,
showOpenAll: false,
},
layouts: [
{
layoutId: "layout",
parentSectionId: "root",
xOffset: 0,
yOffset: 4,
width: 2,
height: 3,
},
],
});
});
test("removes a container and flattens its direct children into the parent", () => {
const removed = new ContainerSectionMockBuilder({ id: "removed" })
.addLayout({ layoutId: "layout", parentSectionId: "root", xOffset: 3, yOffset: 4, width: 6, height: 5 })
.build();
const child = new ContainerSectionMockBuilder({ id: "child" })
.addLayout({ layoutId: "layout", parentSectionId: removed.id, xOffset: 1, yOffset: 2, width: 2, height: 2 })
.build();
const nestedItem = new ItemMockBuilder({ id: "nested-item" })
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1, width: 1, height: 1 })
.build();
const board = new BoardMockBuilder().addSection(removed).addSection(child).addItem(nestedItem).build();
const result = removeContainerCallback({ id: removed.id })(board);
const flattenedChild = result.sections.find(
(section): section is ContainerSection => section.kind === "container" && section.id === child.id,
);
expect(result.sections.some((section) => section.id === removed.id)).toBe(false);
expect(flattenedChild?.layouts[0]).toMatchObject({
parentSectionId: "root",
xOffset: 4,
yOffset: 6,
});
expect(result.items.find((item) => item.id === nestedItem.id)?.layouts[0]).toMatchObject({
sectionId: "root",
xOffset: 5,
yOffset: 5,
});
});
test("normalizes and resolves collisions while flattening malformed child geometry", () => {
const removed = new ContainerSectionMockBuilder({ id: "removed" })
.addLayout({ layoutId: "layout", parentSectionId: "root", xOffset: 2, yOffset: 0, width: 2, height: 2 })
.build();
const existing = new ItemMockBuilder({ id: "existing" })
.addLayout({ layoutId: "layout", sectionId: "root", xOffset: 0, yOffset: 0, width: 2, height: 1 })
.build();
const childA = new ItemMockBuilder({ id: "a" })
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 1, yOffset: 0, width: 2, height: 1 })
.build();
const childB = new ItemMockBuilder({ id: "b" })
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 0, yOffset: 0, width: 2, height: 1 })
.build();
const board = new BoardMockBuilder()
.addEmptySection({ id: "root" })
.addSection(removed)
.addItems([existing, childA, childB])
.build();
const layout = board.layouts[0];
if (!layout) throw new Error("Expected a board layout");
layout.id = "layout";
layout.columnCount = 4;
const result = removeContainerCallback({ id: removed.id })(board);
const layouts = Object.fromEntries(
result.items.map((item) => {
const itemLayout = item.layouts[0];
if (!itemLayout) throw new Error("Expected an item layout");
return [item.id, itemLayout];
}),
);
expect(layouts.existing).toMatchObject({ xOffset: 0, yOffset: 0, width: 2, height: 1 });
expect(layouts.a).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 0, width: 2, height: 1 });
expect(layouts.b).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 1, width: 2, height: 1 });
});
});
@@ -0,0 +1,179 @@
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import type { Board, ContainerSection } from "~/app/[locale]/boards/_types";
import { resolvePinnedGridCollisions } from "~/components/board/sections/grid/dnd";
import type { SectionGridPlacement } from "~/components/board/sections/grid/use-grid-layout-actions";
export interface RemoveContainerInput {
id: string;
}
export const removeContainerCallback =
({ id }: RemoveContainerInput) =>
(board: Board): Board => {
const sectionToRemove = board.sections.find(
(section): section is ContainerSection => section.id === id && section.kind === "container",
);
if (!sectionToRemove) return board;
const flattened = {
...board,
sections: board.sections
.filter((section) => section.id !== id)
.map((section) => {
if (section.kind !== "container") return section;
return {
...section,
layouts: section.layouts.map((layout) => {
if (layout.parentSectionId !== sectionToRemove.id) return layout;
const removedSectionLayout = sectionToRemove.layouts.find(
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
);
if (!removedSectionLayout) throw new Error("Layout not found");
return {
...layout,
xOffset: layout.xOffset + removedSectionLayout.xOffset,
yOffset: layout.yOffset + removedSectionLayout.yOffset,
parentSectionId: removedSectionLayout.parentSectionId,
};
}),
};
}),
items: board.items.map((item) => ({
...item,
layouts: item.layouts.map((layout) => {
if (layout.sectionId !== sectionToRemove.id) return layout;
const removedSectionLayout = sectionToRemove.layouts.find(
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
);
if (!removedSectionLayout) throw new Error("Layout not found");
return {
...layout,
xOffset: layout.xOffset + removedSectionLayout.xOffset,
yOffset: layout.yOffset + removedSectionLayout.yOffset,
sectionId: removedSectionLayout.parentSectionId,
};
}),
})),
};
return resolveFlattenedLayouts(flattened, sectionToRemove, board);
};
const resolveFlattenedLayouts = (board: Board, removed: ContainerSection, original: Board): Board => {
const resolvedByLayoutAndId = new Map<string, SectionGridPlacement>();
for (const removedLayout of removed.layouts) {
const movedIds = [
...original.items.flatMap((item) => {
const layout = item.layouts.find((candidate) => candidate.layoutId === removedLayout.layoutId);
return layout?.sectionId === removed.id ? [item.id] : [];
}),
...original.sections.flatMap((section) => {
if (section.kind !== "container") return [];
const layout = section.layouts.find((candidate) => candidate.layoutId === removedLayout.layoutId);
return layout?.parentSectionId === removed.id ? [section.id] : [];
}),
];
const pinnedId = movedIds.toSorted()[0];
if (!pinnedId) continue;
const columnCount = getParentColumnCount(board, removedLayout.layoutId, removedLayout.parentSectionId);
if (!columnCount) continue;
const placements = getSectionPlacements(board, removedLayout.layoutId, removedLayout.parentSectionId);
const pinned = placements.find((placement) => placement.id === pinnedId);
if (!pinned) continue;
for (const placement of resolvePinnedGridCollisions(placements, pinned, columnCount)) {
resolvedByLayoutAndId.set(`${removedLayout.layoutId}:${placement.id}`, placement);
}
}
if (resolvedByLayoutAndId.size === 0) return board;
return {
...board,
items: board.items.map((item) => ({
...item,
layouts: item.layouts.map((layout) => {
const placement = resolvedByLayoutAndId.get(`${layout.layoutId}:${item.id}`);
return placement?.type === "item"
? {
...layout,
xOffset: placement.x,
yOffset: placement.y,
width: placement.w,
height: placement.h,
}
: layout;
}),
})),
sections: board.sections.map((section) =>
section.kind !== "container"
? section
: {
...section,
layouts: section.layouts.map((layout) => {
const placement = resolvedByLayoutAndId.get(`${layout.layoutId}:${section.id}`);
return placement?.type === "section"
? {
...layout,
xOffset: placement.x,
yOffset: placement.y,
width: placement.w,
height: placement.h,
}
: layout;
}),
},
),
};
};
const getSectionPlacements = (board: Board, layoutId: string, sectionId: string): SectionGridPlacement[] => [
...board.items.flatMap((item) => {
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
return layout?.sectionId === sectionId
? [
{
id: item.id,
type: "item" as const,
x: layout.xOffset,
y: layout.yOffset,
w: layout.width,
h: layout.height,
},
]
: [];
}),
...board.sections.flatMap((section) => {
if (section.kind !== "container") return [];
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
return layout?.parentSectionId === sectionId
? [
{
id: section.id,
type: "section" as const,
x: layout.xOffset,
y: layout.yOffset,
w: layout.width,
h: layout.height,
},
]
: [];
}),
];
const getParentColumnCount = (board: Board, layoutId: string, sectionId: string) => {
const section = board.sections.find((candidate) => candidate.id === sectionId);
if (!section) return null;
if (section.kind === "container") {
return section.layouts.find((layout) => layout.layoutId === layoutId)?.width ?? null;
}
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
return layout ? getBoardLaneColumnCount(layout, getRootSectionLane(section.xOffset)) : null;
};
@@ -0,0 +1,43 @@
import { useCallback } from "react";
import type { z } from "zod/v4";
import { useUpdateBoard } from "@homarr/boards/updater";
import type { containerSectionOptionsSchema } from "@homarr/validation/shared";
import { addContainerCallback } from "./actions/add-container";
import type { RemoveContainerInput } from "./actions/remove-container";
import { removeContainerCallback } from "./actions/remove-container";
interface UpdateContainerOptions {
containerId: string;
newOptions: z.infer<typeof containerSectionOptionsSchema>;
}
export const useContainerActions = () => {
const { updateBoard } = useUpdateBoard();
const addContainer = useCallback(() => {
updateBoard(addContainerCallback());
}, [updateBoard]);
const updateContainer = useCallback(
({ containerId, newOptions }: UpdateContainerOptions) => {
updateBoard((previous) => ({
...previous,
sections: previous.sections.map((item) =>
item.id !== containerId || item.kind !== "container" ? item : { ...item, options: newOptions },
),
}));
},
[updateBoard],
);
const removeContainer = useCallback(
(input: RemoveContainerInput) => {
updateBoard(removeContainerCallback(input));
},
[updateBoard],
);
return { addContainer, updateContainer, removeContainer };
};
@@ -7,18 +7,17 @@ import { useZodForm } from "@homarr/form";
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { TextMultiSelect } from "@homarr/ui";
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
interface ModalProps {
value: z.infer<typeof dynamicSectionOptionsSchema>;
onSuccessfulEdit: (value: z.infer<typeof dynamicSectionOptionsSchema>) => void;
value: z.infer<typeof containerSectionOptionsSchema>;
onSuccessfulEdit: (value: z.infer<typeof containerSectionOptionsSchema>) => void;
}
export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, innerProps }) => {
export const ContainerEditModal = createModal<ModalProps>(({ actions, innerProps }) => {
const t = useI18n();
const theme = useMantineTheme();
const form = useZodForm(dynamicSectionOptionsSchema.unwrap(), {
const form = useZodForm(containerSectionOptionsSchema.unwrap(), {
mode: "controlled",
initialValues: { ...innerProps.value },
});
@@ -31,7 +30,7 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
})}
>
<Stack>
<TextInput label={t("section.dynamic.option.title.label")} data-autofocus {...form.getInputProps("title")} />
<TextInput label={t("section.container.option.title.label")} data-autofocus {...form.getInputProps("title")} />
<Switch
label={t("section.option.showLabel.label")}
description={t("section.option.showLabel.description")}
@@ -48,11 +47,11 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
{...form.getInputProps("showOpenAll", { type: "checkbox" })}
/>
<TextMultiSelect
label={t("section.dynamic.option.customCssClasses.label")}
label={t("section.container.option.customCssClasses.label")}
{...form.getInputProps("customCssClasses")}
/>
<ColorInput
label={t("section.dynamic.option.borderColor.label")}
label={t("section.container.option.borderColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
rightSection={
@@ -5,46 +5,39 @@ import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
import { useOpenItemMoveModal } from "../../items/item-move-modal";
import { useSectionContext } from "../section-context";
import { useDynamicSectionActions } from "./dynamic-actions";
import { DynamicSectionEditModal } from "./dynamic-edit-modal";
import { useContainerActions } from "./container-actions";
import { ContainerEditModal } from "./container-edit-modal";
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionItem }) => {
export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem }) => {
const t = useI18n();
const tDynamic = useScopedI18n("section.dynamic");
const tContainer = useScopedI18n("section.container");
const tItem = useScopedI18n("item");
const { openModal } = useModalAction(DynamicSectionEditModal);
const { openModal } = useModalAction(ContainerEditModal);
const openMoveModal = useOpenItemMoveModal();
const { updateDynamicSection, removeDynamicSection } = useDynamicSectionActions();
const { updateContainer, removeContainer } = useContainerActions();
const { openConfirmModal } = useConfirmModal();
const [isEditMode] = useEditMode();
const { section: parentSection } = useSectionContext();
const label = section.options.title || tDynamic("action.create");
const menuRightOffset = parentSection.kind === "dynamic" ? 44 : 4;
const label = section.options.title || tContainer("action.create");
const menuRightOffset = parentSection.kind === "container" ? 44 : 4;
if (!isEditMode) return null;
const openEditModal = () => {
openModal({
value: section.options,
onSuccessfulEdit: (options) => {
updateDynamicSection({
itemId: section.id,
newOptions: options,
});
},
onSuccessfulEdit: (options) => updateContainer({ containerId: section.id, newOptions: options }),
});
};
const openRemoveModal = () => {
openConfirmModal({
title: tDynamic("remove.title"),
children: tDynamic("remove.message"),
onConfirm: () => {
removeDynamicSection({ id: section.id });
},
title: tContainer("remove.title"),
children: tContainer("remove.message"),
onConfirm: () => removeContainer({ id: section.id }),
});
};
@@ -53,14 +46,14 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
<Menu.Target>
<ActionIcon
variant="default"
radius={"xl"}
radius="xl"
pos="absolute"
top={4}
right={menuRightOffset}
style={{ zIndex: 10 }}
aria-label={tItem("menu.label.settingsFor", { name: label })}
>
<IconLayoutKanban size={"1rem"} />
<IconLayoutKanban size="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={128}>
@@ -70,19 +63,14 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
</Menu.Item>
<Menu.Item
leftSection={<IconArrowsMove size={16} />}
onClick={() =>
openMoveModal({
entry: section,
sourceSectionId: section.parentSectionId,
})
}
onClick={() => openMoveModal({ entry: section, sourceSectionId: section.parentSectionId })}
>
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Divider />
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
{tDynamic("action.remove")}
{tContainer("action.remove")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
@@ -1,62 +0,0 @@
import type { Board, DynamicSection } from "~/app/[locale]/boards/_types";
export interface RemoveDynamicSectionInput {
id: string;
}
export const removeDynamicSectionCallback =
({ id }: RemoveDynamicSectionInput) =>
(board: Board): Board => {
const sectionToRemove = board.sections.find(
(section): section is DynamicSection => section.id === id && section.kind === "dynamic",
);
if (!sectionToRemove) return board;
return {
...board,
sections: board.sections
.filter((section) => section.id !== id)
.map((section) => {
if (section.kind !== "dynamic") return section;
// Change xOffset and yOffset of sections that were below the removed section and set parentSectionId to the parent of the removed section
return {
...section,
layouts: section.layouts.map((layout) => {
if (layout.parentSectionId !== sectionToRemove.id) return layout;
const removedSectionLayout = sectionToRemove.layouts.find(
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
);
if (!removedSectionLayout) throw new Error("Layout not found");
return {
...layout,
xOffset: layout.xOffset + removedSectionLayout.xOffset,
yOffset: layout.yOffset + removedSectionLayout.yOffset,
parentSectionId: removedSectionLayout.parentSectionId,
};
}),
};
}),
// Move all items in dynamic section to parent of the removed section
items: board.items.map((item) => ({
...item,
layouts: item.layouts.map((layout) => {
if (layout.sectionId !== sectionToRemove.id) return layout;
const removedSectionLayout = sectionToRemove.layouts.find(
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
);
if (!removedSectionLayout) throw new Error("Layout not found");
return {
...layout,
xOffset: layout.xOffset + removedSectionLayout.xOffset,
yOffset: layout.yOffset + removedSectionLayout.yOffset,
sectionId: removedSectionLayout.parentSectionId,
};
}),
})),
};
};
@@ -1,47 +0,0 @@
import { useCallback } from "react";
import type { z } from "zod/v4";
import { useUpdateBoard } from "@homarr/boards/updater";
import type { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
import { addDynamicSectionCallback } from "./actions/add-dynamic-section";
import type { RemoveDynamicSectionInput } from "./actions/remove-dynamic-section";
import { removeDynamicSectionCallback } from "./actions/remove-dynamic-section";
interface UpdateDynamicOptions {
itemId: string;
newOptions: z.infer<typeof dynamicSectionOptionsSchema>;
}
export const useDynamicSectionActions = () => {
const { updateBoard } = useUpdateBoard();
const addDynamicSection = useCallback(() => {
updateBoard(addDynamicSectionCallback());
}, [updateBoard]);
const updateDynamicSection = useCallback(
({ itemId, newOptions }: UpdateDynamicOptions) => {
updateBoard((previous) => ({
...previous,
sections: previous.sections.map((item) =>
item.id !== itemId || item.kind !== "dynamic" ? item : { ...item, options: newOptions },
),
}));
},
[updateBoard],
);
const removeDynamicSection = useCallback(
(input: RemoveDynamicSectionInput) => {
updateBoard(removeDynamicSectionCallback(input));
},
[updateBoard],
);
return {
addDynamicSection,
updateDynamicSection,
removeDynamicSection,
};
};
@@ -0,0 +1,57 @@
"use client";
import type { ComponentType, PropsWithChildren } from "react";
import { useEffect, useState } from "react";
import { useEditMode } from "@homarr/boards/edit-mode";
import { loadGridEditorAsync } from "./grid-editor-loader";
import { GridEditorRuntimeProvider } from "./grid-editor-runtime";
type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>;
/**
* Keeps the dnd-kit runtime outside the read-only bundle while guaranteeing
* that every root and nested grid shares one drag-and-drop provider.
*/
export const BoardGridEditorBoundary = ({ children }: PropsWithChildren) => {
const [isEditMode] = useEditMode();
const [Provider, setProvider] = useState<BoardGridEditorProviderComponent | null>(null);
const [hasLoadError, setHasLoadError] = useState(false);
useEffect(() => {
if (!isEditMode || Provider) return;
let cancelled = false;
setHasLoadError(false);
void loadGridEditorAsync()
.then((module) => {
if (!cancelled) setProvider(() => module.BoardGridEditorProvider);
})
.catch(() => {
if (!cancelled) setHasLoadError(true);
});
return () => {
cancelled = true;
};
}, [Provider, isEditMode]);
if (!isEditMode) {
return <GridEditorRuntimeProvider status="idle">{children}</GridEditorRuntimeProvider>;
}
if (hasLoadError) {
return <GridEditorRuntimeProvider status="error">{children}</GridEditorRuntimeProvider>;
}
if (!Provider) {
return <GridEditorRuntimeProvider status="loading">{children}</GridEditorRuntimeProvider>;
}
return (
<GridEditorRuntimeProvider status="ready">
<Provider>{children}</Provider>
</GridEditorRuntimeProvider>
);
};
@@ -0,0 +1,86 @@
import { describe, expect, test } from "vitest";
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import { getPointerProjectedShape, getSnappedGridCoordinates, getSnappedGridDelta } from "./coordinates";
describe("dnd grid coordinates", () => {
test.each([0.75, 1, 1.25, 2])("maps transformed drag geometry at %sx scale", (scale) => {
const target = { left: 137, top: -42, width: getLogicalTrackSize(8) * scale };
const dragShape = {
left: target.left + 3 * LOGICAL_GRID_PITCH * scale,
top: target.top + 7 * LOGICAL_GRID_PITCH * scale,
};
expect(getSnappedGridCoordinates({ dragShape, target, columnCount: 8 })).toEqual({ x: 3, y: 7 });
});
test("uses the dragged shape origin so a non-center grab retains its pointer offset", () => {
const scale = 0.75;
const target = { left: -280, top: 390, width: getLogicalTrackSize(5) * scale };
const initialShape = { left: target.left, top: target.top };
const pointerDelta = { x: LOGICAL_GRID_PITCH * scale * 2, y: LOGICAL_GRID_PITCH * scale * 3 };
expect(
getSnappedGridCoordinates({
dragShape: {
left: initialShape.left + pointerDelta.x,
top: initialShape.top + pointerDelta.y,
},
target,
columnCount: 5,
}),
).toEqual({ x: 2, y: 3 });
});
test.each([0.75, 1, 1.25, 2])("projects a pointer drag in visual coordinates at %sx scale", (scale) => {
const target = { left: 496, top: -938, width: getLogicalTrackSize(8) * scale };
const initialShape = {
left: target.left + 4 * LOGICAL_GRID_PITCH * scale,
top: target.top + 8 * LOGICAL_GRID_PITCH * scale,
};
const initialPointer = { x: initialShape.left + 134 * scale, y: initialShape.top + 89 * scale };
const dragShape = getPointerProjectedShape(
{ initialPointer, initialShape },
{ x: initialPointer.x, y: initialPointer.y - LOGICAL_GRID_PITCH * scale },
);
expect(dragShape.left).toBe(initialShape.left);
expect(getSnappedGridCoordinates({ dragShape, target, columnCount: 8 })).toEqual({ x: 4, y: 7 });
});
test.each([0.75, 1, 1.25, 2])("snaps resize pointer deltas at %sx scale", (visualScale) => {
expect(
getSnappedGridDelta({
initial: { x: 19, y: 31 },
current: {
x: 19 - 2 * LOGICAL_GRID_PITCH * visualScale,
y: 31 + 4 * LOGICAL_GRID_PITCH * visualScale,
},
visualScale,
}),
).toEqual({ x: -2, y: 4 });
});
test("uses independently measured visual axes for transformed resize geometry", () => {
expect(
getSnappedGridDelta({
initial: { x: 10, y: 20 },
current: { x: 10 + LOGICAL_GRID_PITCH * 1.25, y: 20 - LOGICAL_GRID_PITCH * 1.5 },
visualScale: { x: 1.25, y: 1.5 },
}),
).toEqual({ x: 1, y: -1 });
});
test("rejects non-positive or non-finite scales", () => {
expect(
getSnappedGridCoordinates({
dragShape: { left: 0, top: 0 },
target: { left: 0, top: 0, width: 0 },
columnCount: 4,
}),
).toBeNull();
expect(
getSnappedGridDelta({ initial: { x: 0, y: 0 }, current: { x: 1, y: 1 }, visualScale: Number.NaN }),
).toBeNull();
});
});
@@ -0,0 +1,58 @@
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
interface RectangleLike {
left: number;
top: number;
width: number;
}
export interface DragProjectionOrigin {
initialPointer: { x: number; y: number };
initialShape: { left: number; top: number };
}
/**
* Projects the dragged shape from the grab point instead of dnd-kit's
* feedback element. The latter can live in a CSS-zoomed overlay whose
* bounding rectangle is in a different coordinate space from pointer events.
*/
export const getPointerProjectedShape = (origin: DragProjectionOrigin, currentPointer: { x: number; y: number }) => ({
left: origin.initialShape.left + currentPointer.x - origin.initialPointer.x,
top: origin.initialShape.top + currentPointer.y - origin.initialPointer.y,
});
export const getSnappedGridCoordinates = ({
dragShape,
target,
columnCount,
}: {
dragShape: Pick<RectangleLike, "left" | "top">;
target: RectangleLike;
columnCount: number;
}) => {
const logicalWidth = getLogicalTrackSize(columnCount);
const visualScale = logicalWidth > 0 ? target.width / logicalWidth : 0;
if (!Number.isFinite(visualScale) || visualScale <= 0) return null;
return {
x: Math.round((dragShape.left - target.left) / (LOGICAL_GRID_PITCH * visualScale)),
y: Math.round((dragShape.top - target.top) / (LOGICAL_GRID_PITCH * visualScale)),
};
};
export const getSnappedGridDelta = ({
current,
initial,
visualScale,
}: {
current: { x: number; y: number };
initial: { x: number; y: number };
visualScale: number | { x: number; y: number };
}) => {
const scale = typeof visualScale === "number" ? { x: visualScale, y: visualScale } : visualScale;
if (!Number.isFinite(scale.x) || scale.x <= 0 || !Number.isFinite(scale.y) || scale.y <= 0) return null;
return {
x: Math.round((current.x - initial.x) / (LOGICAL_GRID_PITCH * scale.x)),
y: Math.round((current.y - initial.y) / (LOGICAL_GRID_PITCH * scale.y)),
};
};
@@ -0,0 +1,743 @@
import { describe, expect, test } from "vitest";
import { doGridPlacementsOverlap } from "~/components/board/layout";
import {
beginGridTransaction,
cancelGridTransaction,
commitGridTransaction,
previewGridMove,
previewGridResize,
} from "./engine";
import type {
GridPreviewResult,
GridResizeDirection,
GridTransaction,
TransactionalGrid,
TransactionalGridState,
} from "./types";
interface TestPlacement {
id: string;
x: number;
y: number;
w: number;
h: number;
kind: "container" | "item";
marker?: string;
}
const placement = (
id: string,
x: number,
y: number,
w = 1,
h = 1,
kind: TestPlacement["kind"] = "item",
): TestPlacement => ({ id, x, y, w, h, kind, marker: `metadata:${id}` });
const grid = (
id: string,
columnCount: number,
placements: readonly TestPlacement[],
options: Pick<TransactionalGrid<TestPlacement>, "maxRowCount" | "ownerPlacementId" | "parentGridId"> = {},
): TransactionalGrid<TestPlacement> => ({ id, columnCount, placements, ...options });
const state = (...grids: readonly TransactionalGrid<TestPlacement>[]): TransactionalGridState<TestPlacement> => ({
grids,
});
const accept = (result: GridPreviewResult<TestPlacement>): GridTransaction<TestPlacement> => {
if (!result.accepted) throw new Error(`Expected preview to be accepted, received ${result.rejection.code}`);
return result.transaction;
};
const getGrid = (layout: TransactionalGridState<TestPlacement>, id: string) => {
const found = layout.grids.find((candidate) => candidate.id === id);
if (!found) throw new Error(`Missing test grid ${id}`);
return found;
};
const getPlacement = (layout: TransactionalGridState<TestPlacement>, id: string) => {
for (const candidateGrid of layout.grids) {
const found = candidateGrid.placements.find((candidate) => candidate.id === id);
if (found) return found;
}
throw new Error(`Missing test placement ${id}`);
};
const coordinatesById = (layout: TransactionalGridState<TestPlacement>, gridId: string) =>
Object.fromEntries(getGrid(layout, gridId).placements.map(({ id, x, y, w, h }) => [id, { x, y, w, h }]));
const expectValidState = (layout: TransactionalGridState<TestPlacement>) => {
const allIds = new Set<string>();
for (const candidateGrid of layout.grids) {
for (const [index, candidate] of candidateGrid.placements.entries()) {
expect(allIds.has(candidate.id), `duplicate placement ${candidate.id}`).toBe(false);
allIds.add(candidate.id);
expect([candidate.x, candidate.y, candidate.w, candidate.h].every(Number.isInteger)).toBe(true);
expect(candidate.x).toBeGreaterThanOrEqual(0);
expect(candidate.y).toBeGreaterThanOrEqual(0);
expect(candidate.w).toBeGreaterThanOrEqual(1);
expect(candidate.h).toBeGreaterThanOrEqual(1);
expect(candidate.x + candidate.w).toBeLessThanOrEqual(candidateGrid.columnCount);
if (candidateGrid.maxRowCount !== undefined && candidateGrid.maxRowCount !== null) {
expect(candidate.y + candidate.h).toBeLessThanOrEqual(candidateGrid.maxRowCount);
}
for (const other of candidateGrid.placements.slice(index + 1)) {
expect(
doGridPlacementsOverlap(candidate, other),
`${candidate.id} overlaps ${other.id} in ${candidateGrid.id}`,
).toBe(false);
}
}
}
for (const candidateGrid of layout.grids) {
if (!candidateGrid.ownerPlacementId) continue;
const parent = getGrid(layout, candidateGrid.parentGridId ?? "");
expect(parent.placements.some(({ id }) => id === candidateGrid.ownerPlacementId)).toBe(true);
}
};
describe("grid transaction setup", () => {
test("captures detached snapshot and preview copies without mutating caller state", () => {
const original = state(grid("root", 4, [placement("active", 0, 0), placement("other", 2, 3)]));
const transaction = beginGridTransaction(original, { activeId: "active", sourceGridId: "root" });
expect(transaction.snapshot).toEqual(original);
expect(transaction.preview).toEqual(original);
expect(transaction.snapshot).not.toBe(original);
expect(transaction.snapshot.grids[0]).not.toBe(original.grids[0]);
expect(transaction.snapshot.grids[0]?.placements[0]).not.toBe(original.grids[0]?.placements[0]);
expect(transaction.preview).not.toBe(transaction.snapshot);
});
test("rejects a missing active item or stale source-grid hint", () => {
const original = state(grid("root", 2, [placement("active", 0, 0)]));
expect(() => beginGridTransaction(original, { activeId: "missing" })).toThrow('Grid item "missing" does not exist');
expect(() => beginGridTransaction(original, { activeId: "active", sourceGridId: "stale" })).toThrow(
'belongs to grid "root", not "stale"',
);
});
test.each([
{
name: "duplicate grid ids",
layout: state(grid("root", 2, [placement("a", 0, 0)]), grid("root", 2, [placement("b", 0, 0)])),
message: "Duplicate grid id",
},
{
name: "duplicate placement ids across grids",
layout: state(grid("first", 2, [placement("a", 0, 0)]), grid("second", 2, [placement("a", 0, 0)])),
message: "Duplicate grid item id",
},
{
name: "overlaps",
layout: state(grid("root", 3, [placement("a", 0, 0, 2), placement("b", 1, 0, 2)])),
message: "overlap",
},
{
name: "column overflow",
layout: state(grid("root", 2, [placement("a", 1, 0, 2)])),
message: "exceeds grid",
},
{
name: "row overflow",
layout: state(grid("root", 2, [placement("a", 0, 1, 1, 2)], { maxRowCount: 2 })),
message: "exceeds grid",
},
{
name: "missing hierarchy owner",
layout: state(
grid("root", 2, [placement("a", 0, 0)]),
grid("child", 2, [], { parentGridId: "root", ownerPlacementId: "missing" }),
),
message: "Owner item",
},
{
name: "cyclic hierarchy",
layout: state(
grid("first", 2, [placement("owns-second", 0, 0)], {
parentGridId: "second",
ownerPlacementId: "owns-first",
}),
grid("second", 2, [placement("owns-first", 0, 0)], {
parentGridId: "first",
ownerPlacementId: "owns-second",
}),
),
message: "cycle",
},
])("rejects invalid initial state: $name", ({ layout, message }) => {
expect(() => beginGridTransaction(layout, { activeId: layout.grids[0]?.placements[0]?.id ?? "missing" })).toThrow(
message,
);
});
});
describe("same-grid movement", () => {
test("keeps the exact snapshot for a no-op instead of compacting intentional gaps", () => {
const original = state(grid("root", 4, [placement("active", 0, 3), placement("other", 2, 5)]));
const transaction = beginGridTransaction(original, { activeId: "active" });
const moved = accept(previewGridMove(transaction, { targetGridId: "root", x: 0, y: 3 }));
expect(moved.preview).toEqual(original);
expect(moved.preview).not.toBe(original);
});
test("swaps an equal-size item occupying the requested exact slot", () => {
const original = state(
grid("root", 4, [placement("active", 0, 0), placement("target", 1, 0), placement("untouched", 2, 0)]),
);
const moved = accept(
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
targetGridId: "root",
x: 1,
y: 0,
}),
);
expect(coordinatesById(moved.preview, "root")).toEqual({
active: { x: 1, y: 0, w: 1, h: 1 },
target: { x: 0, y: 0, w: 1, h: 1 },
untouched: { x: 2, y: 0, w: 1, h: 1 },
});
});
test("swaps different-size items when both resulting placements remain valid", () => {
const original = state(grid("root", 4, [placement("wide", 0, 0, 2), placement("compact", 2, 0)]));
const moved = accept(
previewGridMove(beginGridTransaction(original, { activeId: "wide" }), {
targetGridId: "root",
x: 2,
y: 0,
}),
);
expect(coordinatesById(moved.preview, "root")).toEqual({
wide: { x: 2, y: 0, w: 2, h: 1 },
compact: { x: 0, y: 0, w: 1, h: 1 },
});
});
test("uses pinned reflow when an exact-slot swap would exceed the grid bounds", () => {
const original = state(grid("root", 4, [placement("compact", 3, 0), placement("wide", 0, 0, 2)]));
const moved = accept(
previewGridMove(beginGridTransaction(original, { activeId: "compact" }), {
targetGridId: "root",
x: 0,
y: 0,
}),
);
expect(coordinatesById(moved.preview, "root")).toEqual({
compact: { x: 0, y: 0, w: 1, h: 1 },
wide: { x: 0, y: 1, w: 2, h: 1 },
});
});
test("pushes a different-size collision without compacting unrelated gaps", () => {
const original = state(
grid("root", 4, [placement("active", 0, 0), placement("wide", 1, 0, 2), placement("lower-left", 0, 1)]),
);
const moved = accept(
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
targetGridId: "root",
x: 1,
y: 0,
}),
);
expect(coordinatesById(moved.preview, "root")).toEqual({
active: { x: 1, y: 0, w: 1, h: 1 },
wide: { x: 1, y: 1, w: 2, h: 1 },
"lower-left": { x: 0, y: 1, w: 1, h: 1 },
});
});
test("uses pinned reflow for a partial overlap even when sizes match", () => {
const original = state(grid("root", 5, [placement("active", 0, 0, 2), placement("target", 2, 0, 2)]));
const moved = accept(
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
targetGridId: "root",
x: 1,
y: 0,
}),
);
expect(getPlacement(moved.preview, "active")).toMatchObject({ x: 1, y: 0, w: 2, h: 1 });
expect(getPlacement(moved.preview, "target")).toMatchObject({ x: 2, y: 1, w: 2, h: 1 });
});
test("preserves intentional empty rows outside the collision chain", () => {
const original = state(
grid("root", 4, [placement("active", 0, 0), placement("collision", 1, 0, 2), placement("far", 3, 8)]),
);
const moved = accept(
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
targetGridId: "root",
x: 1,
y: 0,
}),
);
expect(getPlacement(moved.preview, "collision")).toMatchObject({ x: 1, y: 1 });
expect(getPlacement(moved.preview, "far")).toMatchObject({ x: 3, y: 8 });
});
test.each([
{ requested: { x: -20, y: -10 }, expected: { x: 0, y: 0 } },
{ requested: { x: 99, y: 99 }, expected: { x: 2, y: 3 } },
{ requested: { x: 2.9, y: 1.9 }, expected: { x: 2, y: 1 } },
])("clamps and quantizes requested coordinates: $requested", ({ requested, expected }) => {
const original = state(grid("root", 4, [placement("active", 1, 1, 2, 2)], { maxRowCount: 5 }));
const moved = accept(
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
targetGridId: "root",
...requested,
}),
);
expect(getPlacement(moved.preview, "active")).toMatchObject(expected);
});
test("produces the same result regardless of backing-array order", () => {
const placements = [
placement("active", 3, 0),
placement("left", 0, 2),
placement("middle", 1, 2),
placement("right", 2, 2),
];
const forward = state(grid("root", 4, placements));
const reversed = state(grid("root", 4, placements.toReversed()));
const input = { targetGridId: "root", x: 0, y: 0 };
const first = accept(previewGridMove(beginGridTransaction(forward, { activeId: "active" }), input));
const second = accept(previewGridMove(beginGridTransaction(reversed, { activeId: "active" }), input));
expect(coordinatesById(first.preview, "root")).toEqual(coordinatesById(second.preview, "root"));
});
test("recomputes every chained preview from the interaction snapshot", () => {
const original = state(
grid("root", 3, [placement("active", 0, 0), placement("middle", 1, 0), placement("last", 2, 0)]),
);
const transaction = beginGridTransaction(original, { activeId: "active" });
const first = accept(previewGridMove(transaction, { targetGridId: "root", x: 1, y: 0 }));
const second = accept(previewGridMove(first, { targetGridId: "root", x: 2, y: 0 }));
expect(coordinatesById(first.preview, "root")).toEqual({
active: { x: 1, y: 0, w: 1, h: 1 },
middle: { x: 0, y: 0, w: 1, h: 1 },
last: { x: 2, y: 0, w: 1, h: 1 },
});
expect(coordinatesById(second.preview, "root")).toEqual({
active: { x: 2, y: 0, w: 1, h: 1 },
middle: { x: 1, y: 0, w: 1, h: 1 },
last: { x: 0, y: 0, w: 1, h: 1 },
});
});
});
describe("cross-grid movement", () => {
test("updates source and target atomically without compacting the source gap", () => {
const original = state(
grid("source", 2, [placement("active", 0, 0), placement("source-neighbor", 0, 1)]),
grid("target", 2, [placement("target-neighbor", 0, 0)]),
);
const moved = accept(
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
targetGridId: "target",
x: 0,
y: 0,
}),
);
expect(coordinatesById(moved.preview, "source")).toEqual({
"source-neighbor": { x: 0, y: 1, w: 1, h: 1 },
});
expect(coordinatesById(moved.preview, "target")).toEqual({
"target-neighbor": { x: 0, y: 1, w: 1, h: 1 },
active: { x: 0, y: 0, w: 1, h: 1 },
});
expect(getPlacement(moved.preview, "active").marker).toBe("metadata:active");
expect(original).toEqual(
state(
grid("source", 2, [placement("active", 0, 0), placement("source-neighbor", 0, 1)]),
grid("target", 2, [placement("target-neighbor", 0, 0)]),
),
);
expectValidState(moved.preview);
});
test("chained target changes do not leak an earlier cross-grid preview", () => {
const original = state(
grid("source", 2, [placement("active", 0, 0)]),
grid("first-target", 2, [placement("first-neighbor", 0, 0)]),
grid("second-target", 2, [placement("second-neighbor", 0, 0)]),
);
const first = accept(
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
targetGridId: "first-target",
x: 0,
y: 0,
}),
);
const second = accept(previewGridMove(first, { targetGridId: "second-target", x: 0, y: 0 }));
expect(getGrid(second.preview, "first-target").placements).toEqual(getGrid(original, "first-target").placements);
expect(getGrid(second.preview, "source").placements).toHaveLength(0);
expect(getPlacement(second.preview, "active")).toMatchObject({ x: 0, y: 0 });
expect(getPlacement(second.preview, "second-neighbor")).toMatchObject({ x: 0, y: 1 });
});
test("moves a container's owned grid with it and supports commit/cancel", () => {
const original = state(
grid("source", 3, [placement("container", 0, 0, 2, 2, "container")]),
grid("target", 3, []),
grid("container-content", 2, [placement("child", 0, 0)], {
parentGridId: "source",
ownerPlacementId: "container",
}),
);
const transaction = beginGridTransaction(original, { activeId: "container" });
const moved = accept(previewGridMove(transaction, { targetGridId: "target", x: 1, y: 2 }));
expect(getGrid(moved.preview, "container-content").parentGridId).toBe("target");
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 1, y: 2, kind: "container" });
expect(cancelGridTransaction(moved)).toEqual(original);
expect(commitGridTransaction(moved)).toEqual(moved.preview);
expect(commitGridTransaction(moved)).not.toBe(moved.preview);
});
test.each(["child", "grandchild"])("rejects a container move into its %s grid", (targetGridId) => {
const original = state(
grid("root", 4, [placement("container", 0, 0, 2, 2, "container")]),
grid("child", 3, [placement("nested-container", 0, 0, 2, 2, "container")], {
parentGridId: "root",
ownerPlacementId: "container",
}),
grid("grandchild", 2, [], {
parentGridId: "child",
ownerPlacementId: "nested-container",
}),
);
const transaction = beginGridTransaction(original, { activeId: "container" });
const result = previewGridMove(transaction, { targetGridId, x: 0, y: 0 });
expect(result.accepted).toBe(false);
if (result.accepted) throw new Error("Expected descendant move rejection");
expect(result.rejection.code).toBe("descendant-target");
expect(result.transaction).toBe(transaction);
});
test("runs caller guards with hierarchy context and preserves custom rejection details", () => {
const original = state(grid("source", 2, [placement("active", 0, 0)]), grid("target", 2, []));
const transaction = beginGridTransaction(original, { activeId: "active" });
let observedTarget = "";
const guarded = previewGridMove(
transaction,
{ targetGridId: "target", x: 0, y: 0 },
{
guards: [
(context) => {
observedTarget = context.targetGrid.id;
expect(context.active.id).toBe("active");
expect(context.sourceGrid.id).toBe("source");
expect(context.targetIsDescendant).toBe(false);
expect(context.snapshot).toBe(transaction.snapshot);
return true;
},
() => ({ code: "locked-container", message: "Unlock the container first" }),
],
},
);
expect(observedTarget).toBe("target");
expect(guarded.accepted).toBe(false);
if (guarded.accepted) throw new Error("Expected custom guard rejection");
expect(guarded.rejection).toEqual({ code: "locked-container", message: "Unlock the container first" });
const booleanRejection = previewGridMove(
transaction,
{ targetGridId: "target", x: 0, y: 0 },
{
guards: [() => false],
},
);
expect(booleanRejection.accepted).toBe(false);
if (booleanRejection.accepted) throw new Error("Expected boolean guard rejection");
expect(booleanRejection.rejection.code).toBe("guard-rejected");
});
test.each([
{
name: "unknown grid",
original: state(grid("source", 2, [placement("active", 0, 0)])),
input: { targetGridId: "missing", x: 0, y: 0 },
code: "unknown-target-grid",
},
{
name: "destination narrower than the item",
original: state(grid("source", 3, [placement("active", 0, 0, 2)]), grid("target", 1, [])),
input: { targetGridId: "target", x: 0, y: 0 },
code: "item-too-large",
},
{
name: "reflow beyond destination row limit",
original: state(
grid("source", 2, [placement("active", 0, 0, 2)]),
grid("target", 2, [placement("target", 0, 0, 2)], { maxRowCount: 1 }),
),
input: { targetGridId: "target", x: 0, y: 0 },
code: "row-limit",
},
{
name: "non-finite coordinate",
original: state(grid("source", 2, [placement("active", 0, 0)])),
input: { targetGridId: "source", x: Number.NaN, y: 0 },
code: "invalid-coordinate",
},
])("rejects $name without changing the transaction", ({ original, input, code }) => {
const transaction = beginGridTransaction(original, { activeId: "active" });
const result = previewGridMove(transaction, input);
expect(result.accepted).toBe(false);
if (result.accepted) throw new Error("Expected move rejection");
expect(result.rejection.code).toBe(code);
expect(result.transaction).toBe(transaction);
expect(result.transaction.preview).toEqual(original);
});
});
describe("eight-direction resize", () => {
test.each<{
direction: GridResizeDirection;
deltaColumns: number;
deltaRows: number;
expected: Pick<TestPlacement, "x" | "y" | "w" | "h">;
}>([
{ direction: "n", deltaColumns: 0, deltaRows: -1, expected: { x: 3, y: 2, w: 4, h: 5 } },
{ direction: "ne", deltaColumns: 1, deltaRows: -1, expected: { x: 3, y: 2, w: 5, h: 5 } },
{ direction: "e", deltaColumns: 1, deltaRows: 0, expected: { x: 3, y: 3, w: 5, h: 4 } },
{ direction: "se", deltaColumns: 1, deltaRows: 1, expected: { x: 3, y: 3, w: 5, h: 5 } },
{ direction: "s", deltaColumns: 0, deltaRows: 1, expected: { x: 3, y: 3, w: 4, h: 5 } },
{ direction: "sw", deltaColumns: -1, deltaRows: 1, expected: { x: 2, y: 3, w: 5, h: 5 } },
{ direction: "w", deltaColumns: -1, deltaRows: 0, expected: { x: 2, y: 3, w: 5, h: 4 } },
{ direction: "nw", deltaColumns: -1, deltaRows: -1, expected: { x: 2, y: 2, w: 5, h: 5 } },
])("resizes from $direction while anchoring the opposite edge", ({ expected, ...input }) => {
const original = state(grid("root", 12, [placement("active", 3, 3, 4, 4)], { maxRowCount: 12 }));
const resized = accept(previewGridResize(beginGridTransaction(original, { activeId: "active" }), input));
expect(getPlacement(resized.preview, "active")).toMatchObject(expected);
});
test("clamps a north-west shrink to minimum size while keeping right and bottom anchored", () => {
const original = state(grid("root", 12, [placement("active", 3, 3, 4, 4)], { maxRowCount: 12 }));
const resized = accept(
previewGridResize(beginGridTransaction(original, { activeId: "active" }), {
direction: "nw",
deltaColumns: 10,
deltaRows: 10,
minWidth: 2,
minHeight: 2,
}),
);
expect(getPlacement(resized.preview, "active")).toMatchObject({ x: 5, y: 5, w: 2, h: 2 });
});
test.each([
{ direction: "nw" as const, deltaColumns: -99, deltaRows: -99, expected: { x: 0, y: 0, w: 7, h: 7 } },
{ direction: "se" as const, deltaColumns: 99, deltaRows: 99, expected: { x: 3, y: 3, w: 9, h: 9 } },
])("clamps $direction growth to grid bounds", ({ expected, ...input }) => {
const original = state(grid("root", 12, [placement("active", 3, 3, 4, 4)], { maxRowCount: 12 }));
const resized = accept(previewGridResize(beginGridTransaction(original, { activeId: "active" }), input));
expect(getPlacement(resized.preview, "active")).toMatchObject(expected);
});
test("honors explicit maximum dimensions", () => {
const original = state(grid("root", 12, [placement("active", 3, 3, 4, 4)], { maxRowCount: 12 }));
const resized = accept(
previewGridResize(beginGridTransaction(original, { activeId: "active" }), {
direction: "se",
deltaColumns: 99,
deltaRows: 99,
maxWidth: 6,
maxHeight: 5,
}),
);
expect(getPlacement(resized.preview, "active")).toMatchObject({ x: 3, y: 3, w: 6, h: 5 });
});
test("pins the resized item and deterministically pushes collisions", () => {
const original = state(
grid("root", 3, [placement("active", 0, 0, 2), placement("neighbor", 0, 1, 2), placement("side", 2, 2)]),
);
const resized = accept(
previewGridResize(beginGridTransaction(original, { activeId: "active" }), {
direction: "s",
deltaColumns: 0,
deltaRows: 2,
}),
);
expect(coordinatesById(resized.preview, "root")).toEqual({
active: { x: 0, y: 0, w: 2, h: 3 },
neighbor: { x: 0, y: 3, w: 2, h: 1 },
side: { x: 2, y: 2, w: 1, h: 1 },
});
});
test("rejects resize when collision reflow would exceed the row limit", () => {
const original = state(
grid("root", 2, [placement("active", 0, 0, 2), placement("neighbor", 0, 1, 2, 2)], {
maxRowCount: 3,
}),
);
const transaction = beginGridTransaction(original, { activeId: "active" });
const result = previewGridResize(transaction, {
direction: "s",
deltaColumns: 0,
deltaRows: 1,
});
expect(result.accepted).toBe(false);
if (result.accepted) throw new Error("Expected row-limit rejection");
expect(result.rejection.code).toBe("row-limit");
expect(result.transaction).toBe(transaction);
});
test("rejects non-finite deltas and geometrically impossible constraints", () => {
const original = state(grid("root", 4, [placement("active", 0, 0, 2, 2)]));
const transaction = beginGridTransaction(original, { activeId: "active" });
const invalidDelta = previewGridResize(transaction, {
direction: "e",
deltaColumns: Number.POSITIVE_INFINITY,
deltaRows: 0,
});
const impossible = previewGridResize(transaction, {
direction: "w",
deltaColumns: 0,
deltaRows: 0,
minWidth: 5,
maxWidth: 5,
});
expect(invalidDelta.accepted).toBe(false);
if (invalidDelta.accepted) throw new Error("Expected invalid-coordinate rejection");
expect(invalidDelta.rejection.code).toBe("invalid-coordinate");
expect(impossible.accepted).toBe(false);
if (impossible.accepted) throw new Error("Expected resize-constraints rejection");
expect(impossible.rejection.code).toBe("resize-constraints");
});
test.each([{ minWidth: 0 }, { minHeight: 1.5 }, { minWidth: 3, maxWidth: 2 }, { minHeight: 3, maxHeight: 2 }])(
"throws on invalid programmer-supplied constraints: $minWidth $minHeight",
(constraints) => {
const original = state(grid("root", 4, [placement("active", 0, 0, 2, 2)]));
const transaction = beginGridTransaction(original, { activeId: "active" });
expect(() =>
previewGridResize(transaction, {
direction: "se",
deltaColumns: 0,
deltaRows: 0,
...constraints,
}),
).toThrow(RangeError);
},
);
});
describe("transaction completion", () => {
test("a rejection keeps the latest accepted preview, cancel restores start, and commit detaches output", () => {
const original = state(grid("root", 3, [placement("active", 0, 0), placement("other", 1, 0)]));
const started = beginGridTransaction(original, { activeId: "active" });
const moved = accept(previewGridMove(started, { targetGridId: "root", x: 1, y: 0 }));
const rejected = previewGridMove(moved, { targetGridId: "missing", x: 0, y: 0 });
expect(rejected.accepted).toBe(false);
if (rejected.accepted) throw new Error("Expected rejection");
expect(rejected.transaction).toBe(moved);
expect(rejected.transaction.preview).toEqual(moved.preview);
const committed = commitGridTransaction(rejected.transaction);
const cancelled = cancelGridTransaction(rejected.transaction);
expect(committed).toEqual(moved.preview);
expect(committed).not.toBe(moved.preview);
expect(cancelled).toEqual(original);
expect(cancelled).not.toBe(rejected.transaction.snapshot);
});
});
describe("interaction invariant matrix", () => {
test("preserves invariants across every sampled same-grid and cross-grid coordinate", () => {
const original = state(
grid(
"source",
6,
[
placement("active", 0, 0, 2, 2),
placement("source-a", 2, 0, 2),
placement("source-b", 4, 0, 2, 2),
placement("source-c", 2, 1, 2),
],
{ maxRowCount: 10 },
),
grid("target", 5, [placement("target-a", 0, 0), placement("target-b", 1, 0, 2, 2)], {
maxRowCount: 10,
}),
);
for (const targetGridId of ["source", "target"]) {
for (let x = -3; x <= 8; x += 1) {
for (let y = -2; y <= 11; y += 1) {
const result = previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
targetGridId,
x,
y,
});
if (!result.accepted) {
expect(result.rejection.code).toBe("row-limit");
continue;
}
expectValidState(result.transaction.preview);
expect(getPlacement(result.transaction.preview, "active").marker).toBe("metadata:active");
}
}
}
});
test("preserves invariants across every direction and representative signed deltas", () => {
const original = state(grid("root", 8, [placement("active", 2, 2, 3, 3)], { maxRowCount: 9 }));
const directions: readonly GridResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
const deltas = [-20, -3, -1, 0, 1, 3, 20];
for (const direction of directions) {
for (const deltaColumns of deltas) {
for (const deltaRows of deltas) {
const result = previewGridResize(beginGridTransaction(original, { activeId: "active" }), {
direction,
deltaColumns,
deltaRows,
minWidth: 2,
minHeight: 2,
maxWidth: 6,
maxHeight: 7,
});
expect(result.accepted).toBe(true);
if (!result.accepted) throw new Error(`Unexpected ${result.rejection.code} for ${direction}`);
expectValidState(result.transaction.preview);
expect(getPlacement(result.transaction.preview, "active").marker).toBe("metadata:active");
}
}
}
});
});
@@ -0,0 +1,497 @@
import { doGridPlacementsOverlap, getLayoutRowCount, normalizeGridPlacement } from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
import type {
BeginGridTransactionInput,
GridMoveGuard,
GridMoveInput,
GridMoveOptions,
GridOperationRejection,
GridPreviewResult,
GridResizeDirection,
GridResizeInput,
GridTransaction,
TransactionalGrid,
TransactionalGridState,
} from "./types";
/**
* Starts an immutable interaction transaction. The returned snapshot is the
* rollback source for dnd-kit's `onDragCancel` and the baseline for every
* `onDragMove` / `onDragOver` preview.
*/
export const beginGridTransaction = <TPlacement extends GridPlacement>(
state: TransactionalGridState<TPlacement>,
input: BeginGridTransactionInput,
): GridTransaction<TPlacement> => {
validateGridState(state);
const located = findPlacement(state, input.activeId);
if (!located) throw new Error(`Grid item "${input.activeId}" does not exist`);
if (input.sourceGridId !== undefined && located.grid.id !== input.sourceGridId) {
throw new Error(`Grid item "${input.activeId}" belongs to grid "${located.grid.id}", not "${input.sourceGridId}"`);
}
const snapshot = cloneGridState(state);
return {
activeId: input.activeId,
sourceGridId: located.grid.id,
snapshot,
preview: cloneGridState(snapshot),
};
};
/**
* Computes a move preview from the transaction snapshot. Same-grid exact-slot
* drops swap items when both resulting placements remain valid; every other
* move pins the active item and pushes only actual collisions. Intentional
* empty rows remain untouched.
*/
export const previewGridMove = <TPlacement extends GridPlacement>(
transaction: GridTransaction<TPlacement>,
input: GridMoveInput,
options: GridMoveOptions<TPlacement> = {},
): GridPreviewResult<TPlacement> => {
const sourceGrid = getGrid(transaction.snapshot, transaction.sourceGridId);
const targetGrid = findGrid(transaction.snapshot, input.targetGridId);
if (!targetGrid) {
return rejected(transaction, {
code: "unknown-target-grid",
message: `Grid "${input.targetGridId}" does not exist`,
});
}
const active = getPlacement(sourceGrid, transaction.activeId);
const targetIsDescendant = isTargetWithinActiveSubtree(transaction.snapshot, targetGrid.id, transaction.activeId);
if (targetIsDescendant) {
return rejected(transaction, {
code: "descendant-target",
message: `Grid "${targetGrid.id}" is inside item "${active.id}"`,
});
}
const guardRejection = evaluateMoveGuards(options.guards ?? [], {
active,
sourceGrid,
targetGrid,
snapshot: transaction.snapshot,
targetIsDescendant,
});
if (guardRejection) return rejected(transaction, guardRejection);
if (!Number.isFinite(input.x) || !Number.isFinite(input.y)) {
return rejected(transaction, { code: "invalid-coordinate" });
}
if (active.w > targetGrid.columnCount || active.h > getMaximumRows(targetGrid)) {
return rejected(transaction, { code: "item-too-large" });
}
const nextActive = clampMovePlacement(active, input, targetGrid);
const preview =
sourceGrid.id === targetGrid.id
? previewSameGridMove(transaction.snapshot, sourceGrid, active, nextActive)
: previewCrossGridMove(transaction.snapshot, sourceGrid, targetGrid, active, nextActive);
if (!fitsRowLimits(preview)) return rejected(transaction, { code: "row-limit" });
validateGridState(preview);
return accepted(transaction, preview);
};
/**
* Computes an eight-direction resize preview. Deltas describe movement of the
* selected handle, while the opposite edge stays anchored. Bounds and caller
* constraints are applied before pinned collision reflow.
*/
export const previewGridResize = <TPlacement extends GridPlacement>(
transaction: GridTransaction<TPlacement>,
input: GridResizeInput,
): GridPreviewResult<TPlacement> => {
const grid = getGrid(transaction.snapshot, transaction.sourceGridId);
const active = getPlacement(grid, transaction.activeId);
if (!Number.isFinite(input.deltaColumns) || !Number.isFinite(input.deltaRows)) {
return rejected(transaction, { code: "invalid-coordinate" });
}
const constraints = getResizeConstraints(input, grid);
const resized = resizePlacement(active, input.direction, input, constraints, grid);
if (!resized) return rejected(transaction, { code: "resize-constraints" });
const preview = cloneGridState(transaction.snapshot);
const previewGrid = getGrid(preview, grid.id);
previewGrid.placements = areCoordinatesEqual(active, resized)
? previewGrid.placements
: resolvePinnedGridCollisions(previewGrid.placements, resized, previewGrid.columnCount);
if (!fitsRowLimits(preview)) return rejected(transaction, { code: "row-limit" });
validateGridState(preview);
return accepted(transaction, preview);
};
/** Returns the latest accepted preview as one atomic state update. */
export const commitGridTransaction = <TPlacement extends GridPlacement>(
transaction: GridTransaction<TPlacement>,
): TransactionalGridState<TPlacement> => cloneGridState(transaction.preview);
/** Restores the exact interaction-start snapshot. */
export const cancelGridTransaction = <TPlacement extends GridPlacement>(
transaction: GridTransaction<TPlacement>,
): TransactionalGridState<TPlacement> => cloneGridState(transaction.snapshot);
const previewSameGridMove = <TPlacement extends GridPlacement>(
state: TransactionalGridState<TPlacement>,
grid: TransactionalGrid<TPlacement>,
active: TPlacement,
nextActive: TPlacement,
) => {
const preview = cloneGridState(state);
const previewGrid = getGrid(preview, grid.id);
if (areCoordinatesEqual(active, nextActive)) return preview;
const displaced = grid.placements.find(
(placement) => placement.id !== active.id && placement.x === nextActive.x && placement.y === nextActive.y,
);
const swapped = displaced
? grid.placements.map((placement) => {
if (placement.id === active.id) return { ...placement, x: nextActive.x, y: nextActive.y };
if (placement.id === displaced.id) return { ...placement, x: active.x, y: active.y };
return { ...placement };
})
: null;
previewGrid.placements =
swapped && placementsFitGrid(swapped, grid)
? swapped
: resolvePinnedGridCollisions(grid.placements, nextActive, grid.columnCount);
return preview;
};
const placementsFitGrid = <TPlacement extends GridPlacement>(
placements: readonly TPlacement[],
grid: TransactionalGrid<TPlacement>,
) =>
placements.every(
(placement) =>
placement.x >= 0 &&
placement.y >= 0 &&
placement.x + placement.w <= grid.columnCount &&
(grid.maxRowCount === undefined || grid.maxRowCount === null || placement.y + placement.h <= grid.maxRowCount),
) &&
placements.every((placement, index) =>
placements.slice(index + 1).every((other) => !doGridPlacementsOverlap(placement, other)),
);
const previewCrossGridMove = <TPlacement extends GridPlacement>(
state: TransactionalGridState<TPlacement>,
sourceGrid: TransactionalGrid<TPlacement>,
targetGrid: TransactionalGrid<TPlacement>,
active: TPlacement,
nextActive: TPlacement,
) => {
const preview = cloneGridState(state);
const previewSource = getGrid(preview, sourceGrid.id);
const previewTarget = getGrid(preview, targetGrid.id);
previewSource.placements = sourceGrid.placements
.filter((placement) => placement.id !== active.id)
.map((placement) => ({ ...placement }));
previewTarget.placements = resolvePinnedGridCollisions(
[...targetGrid.placements, nextActive],
nextActive,
targetGrid.columnCount,
);
preview.grids = preview.grids.map((grid) =>
grid.ownerPlacementId === active.id ? { ...grid, parentGridId: targetGrid.id } : grid,
);
return preview;
};
/**
* Pins the active placement, keeps every non-colliding item at its persisted
* coordinates, and pushes only the deterministic collision chain downward.
*/
export const resolvePinnedGridCollisions = <TPlacement extends GridPlacement>(
placements: readonly TPlacement[],
pinnedPlacement: TPlacement,
columnCount: number,
): TPlacement[] => {
const normalized = placements.map((placement) =>
normalizeGridPlacement(placement.id === pinnedPlacement.id ? pinnedPlacement : placement, columnCount),
);
const pinned = normalized.find((placement) => placement.id === pinnedPlacement.id);
if (!pinned) throw new Error(`Pinned grid item "${pinnedPlacement.id}" does not exist`);
const placed: TPlacement[] = [pinned];
const candidates = normalized.filter((placement) => placement.id !== pinned.id).toSorted(comparePlacements);
for (const candidate of candidates) {
let resolved = candidate;
for (;;) {
const collisions = placed.filter((placement) => doGridPlacementsOverlap(resolved, placement));
if (collisions.length === 0) break;
resolved = {
...resolved,
y: Math.max(...collisions.map((placement) => placement.y + placement.h)),
};
}
placed.push(resolved);
}
const byId = new Map(placed.map((placement) => [placement.id, placement]));
return normalized.map((placement) => byId.get(placement.id) ?? placement);
};
const comparePlacements = (first: GridPlacement, second: GridPlacement) =>
first.y - second.y || first.x - second.x || first.id.localeCompare(second.id);
const clampMovePlacement = <TPlacement extends GridPlacement>(
placement: TPlacement,
input: GridMoveInput,
grid: TransactionalGrid<TPlacement>,
) => {
const maximumX = grid.columnCount - placement.w;
const maximumY = getMaximumRows(grid) - placement.h;
return {
...placement,
x: clamp(Math.trunc(input.x), 0, maximumX),
y: clamp(Math.trunc(input.y), 0, maximumY),
};
};
interface ResolvedResizeConstraints {
minWidth: number;
minHeight: number;
maxWidth: number;
maxHeight: number;
}
const getResizeConstraints = <TPlacement extends GridPlacement>(
input: GridResizeInput,
grid: TransactionalGrid<TPlacement>,
): ResolvedResizeConstraints => {
const minWidth = toPositiveInteger(input.minWidth ?? 1, "minWidth");
const minHeight = toPositiveInteger(input.minHeight ?? 1, "minHeight");
const maxWidth = toPositiveInteger(input.maxWidth ?? grid.columnCount, "maxWidth");
const maxHeight = toPositiveInteger(input.maxHeight ?? getMaximumRows(grid), "maxHeight");
if (maxWidth < minWidth) throw new RangeError("maxWidth must be greater than or equal to minWidth");
if (maxHeight < minHeight) throw new RangeError("maxHeight must be greater than or equal to minHeight");
return { minWidth, minHeight, maxWidth, maxHeight };
};
const resizePlacement = <TPlacement extends GridPlacement>(
placement: TPlacement,
direction: GridResizeDirection,
input: GridResizeInput,
constraints: ResolvedResizeConstraints,
grid: TransactionalGrid<TPlacement>,
): TPlacement | null => {
const deltaX = Math.trunc(input.deltaColumns);
const deltaY = Math.trunc(input.deltaRows);
const resizeWest = direction.includes("w");
const resizeEast = direction.includes("e");
const resizeNorth = direction.includes("n");
const resizeSouth = direction.includes("s");
let x = placement.x;
let y = placement.y;
let width = placement.w;
let height = placement.h;
if (resizeWest) {
const fixedRight = placement.x + placement.w;
const maximumWidth = Math.min(constraints.maxWidth, fixedRight);
if (constraints.minWidth > maximumWidth) return null;
width = clamp(placement.w - deltaX, constraints.minWidth, maximumWidth);
x = fixedRight - width;
} else if (resizeEast) {
const maximumWidth = Math.min(constraints.maxWidth, grid.columnCount - placement.x);
if (constraints.minWidth > maximumWidth) return null;
width = clamp(placement.w + deltaX, constraints.minWidth, maximumWidth);
}
if (resizeNorth) {
const fixedBottom = placement.y + placement.h;
const maximumHeight = Math.min(constraints.maxHeight, fixedBottom);
if (constraints.minHeight > maximumHeight) return null;
height = clamp(placement.h - deltaY, constraints.minHeight, maximumHeight);
y = fixedBottom - height;
} else if (resizeSouth) {
const maximumHeight = Math.min(constraints.maxHeight, getMaximumRows(grid) - placement.y);
if (constraints.minHeight > maximumHeight) return null;
height = clamp(placement.h + deltaY, constraints.minHeight, maximumHeight);
}
return { ...placement, x, y, w: width, h: height };
};
const evaluateMoveGuards = <TPlacement extends GridPlacement>(
guards: readonly GridMoveGuard<TPlacement>[],
context: Parameters<GridMoveGuard<TPlacement>>[0],
): GridOperationRejection | null => {
for (const guard of guards) {
const result = guard(context);
if (result === true) continue;
if (result === false) return { code: "guard-rejected" };
return result;
}
return null;
};
const isTargetWithinActiveSubtree = <TPlacement extends GridPlacement>(
state: TransactionalGridState<TPlacement>,
targetGridId: string,
activeId: string,
) => {
let current: TransactionalGrid<TPlacement> | undefined = findGrid(state, targetGridId);
const visited = new Set<string>();
while (current) {
if (current.ownerPlacementId === activeId) return true;
if (!current.parentGridId || visited.has(current.id)) return false;
visited.add(current.id);
current = findGrid(state, current.parentGridId);
}
return false;
};
const accepted = <TPlacement extends GridPlacement>(
transaction: GridTransaction<TPlacement>,
preview: TransactionalGridState<TPlacement>,
): GridPreviewResult<TPlacement> => ({
accepted: true,
transaction: { ...transaction, preview },
});
const rejected = <TPlacement extends GridPlacement>(
transaction: GridTransaction<TPlacement>,
rejection: GridOperationRejection,
): GridPreviewResult<TPlacement> => ({ accepted: false, transaction, rejection });
const fitsRowLimits = <TPlacement extends GridPlacement>(state: TransactionalGridState<TPlacement>) =>
state.grids.every((grid) => {
if (grid.maxRowCount === undefined || grid.maxRowCount === null) return true;
return getLayoutRowCount(grid.placements) <= grid.maxRowCount;
});
const getMaximumRows = <TPlacement extends GridPlacement>(grid: TransactionalGrid<TPlacement>) =>
grid.maxRowCount ?? Number.MAX_SAFE_INTEGER;
const cloneGridState = <TPlacement extends GridPlacement>(
state: TransactionalGridState<TPlacement>,
): TransactionalGridState<TPlacement> => ({
grids: state.grids.map((grid) => ({
...grid,
placements: grid.placements.map((placement) => ({ ...placement })),
})),
});
const findGrid = <TPlacement extends GridPlacement>(state: TransactionalGridState<TPlacement>, gridId: string) =>
state.grids.find((grid) => grid.id === gridId);
const getGrid = <TPlacement extends GridPlacement>(state: TransactionalGridState<TPlacement>, gridId: string) => {
const grid = findGrid(state, gridId);
if (!grid) throw new Error(`Grid "${gridId}" does not exist`);
return grid;
};
const getPlacement = <TPlacement extends GridPlacement>(grid: TransactionalGrid<TPlacement>, itemId: string) => {
const placement = grid.placements.find((candidate) => candidate.id === itemId);
if (!placement) throw new Error(`Grid item "${itemId}" does not exist in grid "${grid.id}"`);
return placement;
};
const findPlacement = <TPlacement extends GridPlacement>(state: TransactionalGridState<TPlacement>, itemId: string) => {
for (const grid of state.grids) {
const placement = grid.placements.find((candidate) => candidate.id === itemId);
if (placement) return { grid, placement };
}
return null;
};
const areCoordinatesEqual = (first: GridPlacement, second: GridPlacement) =>
first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
const clamp = (value: number, minimum: number, maximum: number) => Math.min(Math.max(value, minimum), maximum);
const toPositiveInteger = (value: number, name: string) => {
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
throw new RangeError(`${name} must be a positive integer`);
}
return value;
};
const validateGridState = <TPlacement extends GridPlacement>(state: TransactionalGridState<TPlacement>) => {
const gridIds = new Set<string>();
const placementIds = new Set<string>();
const ownerIds = new Set<string>();
for (const grid of state.grids) {
if (gridIds.has(grid.id)) throw new Error(`Duplicate grid id "${grid.id}"`);
gridIds.add(grid.id);
toPositiveInteger(grid.columnCount, `columnCount for grid "${grid.id}"`);
if (grid.maxRowCount !== undefined && grid.maxRowCount !== null) {
toPositiveInteger(grid.maxRowCount, `maxRowCount for grid "${grid.id}"`);
}
for (const placement of grid.placements) {
if (placementIds.has(placement.id)) throw new Error(`Duplicate grid item id "${placement.id}"`);
placementIds.add(placement.id);
validatePlacementBounds(placement, grid);
}
for (let index = 0; index < grid.placements.length; index += 1) {
const placement = grid.placements[index];
if (!placement) continue;
for (const other of grid.placements.slice(index + 1)) {
if (doGridPlacementsOverlap(placement, other)) {
throw new Error(`Grid items "${placement.id}" and "${other.id}" overlap in grid "${grid.id}"`);
}
}
}
}
for (const grid of state.grids) {
if (grid.parentGridId && !gridIds.has(grid.parentGridId)) {
throw new Error(`Parent grid "${grid.parentGridId}" for grid "${grid.id}" does not exist`);
}
if (grid.ownerPlacementId) {
if (!grid.parentGridId) throw new Error(`Owned grid "${grid.id}" must have a parent grid`);
if (ownerIds.has(grid.ownerPlacementId)) {
throw new Error(`Grid item "${grid.ownerPlacementId}" owns more than one grid`);
}
ownerIds.add(grid.ownerPlacementId);
const parent = getGrid(state, grid.parentGridId);
if (!parent.placements.some((placement) => placement.id === grid.ownerPlacementId)) {
throw new Error(`Owner item "${grid.ownerPlacementId}" does not exist in parent grid "${grid.parentGridId}"`);
}
}
}
for (const grid of state.grids) {
const visited = new Set<string>();
let current: TransactionalGrid<TPlacement> | undefined = grid;
while (current?.parentGridId) {
if (visited.has(current.id)) throw new Error(`Grid hierarchy contains a cycle at "${current.id}"`);
visited.add(current.id);
current = getGrid(state, current.parentGridId);
}
}
};
const validatePlacementBounds = <TPlacement extends GridPlacement>(
placement: TPlacement,
grid: TransactionalGrid<TPlacement>,
) => {
const values = [placement.x, placement.y, placement.w, placement.h];
if (values.some((value) => !Number.isFinite(value) || !Number.isInteger(value))) {
throw new Error(`Grid item "${placement.id}" must use finite integer coordinates`);
}
if (placement.x < 0 || placement.y < 0 || placement.w < 1 || placement.h < 1) {
throw new Error(`Grid item "${placement.id}" has invalid bounds`);
}
if (placement.x + placement.w > grid.columnCount) {
throw new Error(`Grid item "${placement.id}" exceeds grid "${grid.id}" columns`);
}
if (placement.y + placement.h > getMaximumRows(grid)) {
throw new Error(`Grid item "${placement.id}" exceeds grid "${grid.id}" rows`);
}
};
@@ -0,0 +1,25 @@
export {
beginGridTransaction,
cancelGridTransaction,
commitGridTransaction,
previewGridMove,
previewGridResize,
resolvePinnedGridCollisions,
} from "./engine";
export { getPointerProjectedShape, getSnappedGridCoordinates, getSnappedGridDelta } from "./coordinates";
export type { DragProjectionOrigin } from "./coordinates";
export type {
BeginGridTransactionInput,
GridMoveGuard,
GridMoveGuardContext,
GridMoveGuardResult,
GridMoveInput,
GridMoveOptions,
GridOperationRejection,
GridPreviewResult,
GridResizeDirection,
GridResizeInput,
GridTransaction,
TransactionalGrid,
TransactionalGridState,
} from "./types";
@@ -0,0 +1,98 @@
import type { GridPlacement } from "~/components/board/layout";
/** A positioned grid with optional ownership metadata for nested containers. */
export interface TransactionalGrid<TPlacement extends GridPlacement = GridPlacement> {
id: string;
columnCount: number;
maxRowCount?: number | null;
/** The grid containing the placement that owns this nested grid. */
parentGridId?: string | null;
/** The container placement whose content is rendered by this grid. */
ownerPlacementId?: string | null;
placements: readonly TPlacement[];
}
/** The complete layout that must change atomically for cross-grid operations. */
export interface TransactionalGridState<TPlacement extends GridPlacement = GridPlacement> {
grids: readonly TransactionalGrid<TPlacement>[];
}
/**
* A drag or resize transaction. Every preview is recomputed from `snapshot`,
* never from the previous preview, so noisy drag-over events cannot accumulate
* coordinate drift.
*/
export interface GridTransaction<TPlacement extends GridPlacement = GridPlacement> {
activeId: string;
sourceGridId: string;
snapshot: TransactionalGridState<TPlacement>;
preview: TransactionalGridState<TPlacement>;
}
export interface BeginGridTransactionInput {
activeId: string;
/** Optional disambiguation and stale-event protection. */
sourceGridId?: string;
}
export interface GridMoveInput {
targetGridId: string;
x: number;
y: number;
}
export type GridResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
export interface GridResizeInput {
direction: GridResizeDirection;
/** Signed displacement of the resize handle in grid columns. */
deltaColumns: number;
/** Signed displacement of the resize handle in grid rows. */
deltaRows: number;
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
}
export interface GridOperationRejection {
code:
| "descendant-target"
| "guard-rejected"
| "invalid-coordinate"
| "item-too-large"
| "resize-constraints"
| "row-limit"
| "unknown-target-grid"
| (string & {});
message?: string;
}
export interface GridMoveGuardContext<TPlacement extends GridPlacement = GridPlacement> {
active: TPlacement;
sourceGrid: TransactionalGrid<TPlacement>;
targetGrid: TransactionalGrid<TPlacement>;
snapshot: TransactionalGridState<TPlacement>;
/** True when the target is owned by the active container or one of its descendants. */
targetIsDescendant: boolean;
}
export type GridMoveGuardResult = boolean | GridOperationRejection;
export type GridMoveGuard<TPlacement extends GridPlacement = GridPlacement> = (
context: GridMoveGuardContext<TPlacement>,
) => GridMoveGuardResult;
export interface GridMoveOptions<TPlacement extends GridPlacement = GridPlacement> {
guards?: readonly GridMoveGuard<TPlacement>[];
}
export type GridPreviewResult<TPlacement extends GridPlacement = GridPlacement> =
| {
accepted: true;
transaction: GridTransaction<TPlacement>;
}
| {
accepted: false;
transaction: GridTransaction<TPlacement>;
rejection: GridOperationRejection;
};
@@ -1,7 +1,7 @@
"use client";
import type { FocusEvent, KeyboardEvent, PropsWithChildren } from "react";
import { useId, useState } from "react";
import { useId, useRef, useState } from "react";
import { Box } from "@mantine/core";
import combineClasses from "clsx";
@@ -9,26 +9,31 @@ import { useCurrentLayout } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useI18n } from "@homarr/translation/client";
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import type { ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import {
getEditableGridCellAttributes,
getLayoutRowCount,
getLogicalItemStyle,
getReadonlyGridItemAttributes,
placeGridItem,
} from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
import { useSectionContext } from "../section-context";
import classes from "./section-grid.module.css";
import { beginGridTransaction, commitGridTransaction, previewGridMove, previewGridResize } from "./dnd";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
import { useGridLayoutActions } from "./use-grid-layout-actions";
import classes from "./section-grid.module.css";
interface FixedGridItemProps {
item: DynamicSectionItem | SectionItem;
item: ContainerSectionItem | SectionItem;
minWidth?: number;
minHeight?: number;
}
/**
* Static SSR geometry in view mode and the stable content/keyboard handle in
* edit mode. The focused tile owns grid-based keyboard movement while dnd-kit
* handles pointer and touch input on the outer editor shell.
*/
export const FixedGridItem = ({
item,
minWidth = 1,
@@ -36,22 +41,35 @@ export const FixedGridItem = ({
children,
}: PropsWithChildren<FixedGridItemProps>) => {
const [isEditMode] = useEditMode();
const runtimeStatus = useGridEditorRuntimeStatus();
const currentLayoutId = useCurrentLayout();
const t = useI18n();
const { section, items, innerSections, columnCount, maxRowCount, announce } = useSectionContext();
const { commitSectionGrid } = useGridLayoutActions();
const [isKeyboardEditing, setIsKeyboardEditing] = useState(false);
const instructionsId = useId();
const isEditorActive = isEditMode && runtimeStatus === "ready";
const placement = toPlacement(item);
const renderedPlacements = [...items, ...innerSections].map(toPlacement);
const keyboardGridRef = useRef<{
source: readonly SectionGridPlacement[];
current: readonly SectionGridPlacement[];
}>({
source: renderedPlacements,
current: renderedPlacements,
});
const displayName =
item.type === "item"
? item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`)
: item.options.title.trim() || t("section.dynamic.untitled");
: item.options.title.trim() || t("section.container.untitled");
const accessibleLabel = t("item.moveResize.entryLabel", {
name: displayName,
column: String(placement.x + 1),
row: String(placement.y + 1),
});
const semantics = isEditorActive
? getEditableGridCellAttributes({ label: accessibleLabel, placement })
: getReadonlyGridItemAttributes(placement);
const setKeyboardEditing = (next: boolean) => {
setIsKeyboardEditing(next);
@@ -61,7 +79,7 @@ export const FixedGridItem = ({
};
const handleKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.target !== event.currentTarget || !isEditMode || event.altKey || event.ctrlKey || event.metaKey) {
if (event.target !== event.currentTarget || !isEditorActive || event.altKey || event.ctrlKey || event.metaKey) {
return;
}
if (event.key === "Enter" || event.key === " ") {
@@ -77,27 +95,60 @@ export const FixedGridItem = ({
if (!isKeyboardEditing || !isArrowKey(event.key)) return;
event.preventDefault();
const allPlacements = [...items, ...innerSections].map(toPlacement);
const current = allPlacements.find((candidate) => candidate.id === item.id);
if (document.body.hasAttribute("data-board-grid-interacting")) {
setKeyboardEditing(false);
return;
}
if (!arePlacementListsEqual(keyboardGridRef.current.source, renderedPlacements)) {
keyboardGridRef.current = {
source: renderedPlacements,
current: renderedPlacements,
};
}
const placements = keyboardGridRef.current.current;
const current = placements.find((candidate) => candidate.id === item.id);
if (!current) return;
const next = getKeyboardPlacement(current, event.key, event.shiftKey, {
columnCount,
minWidth,
minHeight,
maxRowCount,
});
if (next.x === current.x && next.y === current.y && next.w === current.w && next.h === current.h) {
const transaction = beginGridTransaction(
{
grids: [
{
id: section.id,
columnCount,
maxRowCount,
placements,
},
],
},
{ activeId: current.id, sourceGridId: section.id },
);
const delta = getKeyboardDelta(event.key);
const result = event.shiftKey
? previewGridResize(transaction, {
direction: "se",
deltaColumns: delta.x,
deltaRows: delta.y,
minWidth,
minHeight,
})
: previewGridMove(transaction, {
targetGridId: section.id,
x: current.x + delta.x,
y: current.y + delta.y,
});
const next = result.transaction.preview.grids[0]?.placements.find((candidate) => candidate.id === item.id);
if (!result.accepted || !next || arePlacementsEqual(current, next)) {
announce(`${accessibleLabel}: ${t("item.moveResize.keyboard.boundary")}`);
return;
}
const resolved = placeGridItem(allPlacements, next, columnCount) as SectionGridPlacement[];
if (maxRowCount !== null && getLayoutRowCount(resolved) > maxRowCount) {
announce(`${accessibleLabel}: ${t("item.moveResize.keyboard.boundary")}`);
return;
}
commitSectionGrid({ layoutId: currentLayoutId, sectionId: section.id, placements: resolved });
const committed = commitGridTransaction(result.transaction).grids[0];
if (!committed) return;
keyboardGridRef.current = {
...keyboardGridRef.current,
current: committed.placements,
};
commitSectionGrid({ layoutId: currentLayoutId, sectionId: section.id, placements: committed.placements });
announce(
`${displayName}: ${t("item.moveResize.field.xOffset.label")} ${next.x + 1}, ${t(
"item.moveResize.field.yOffset.label",
@@ -108,38 +159,36 @@ export const FixedGridItem = ({
};
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
if (isKeyboardEditing && !event.currentTarget.contains(event.relatedTarget)) {
setIsKeyboardEditing(false);
if (isKeyboardEditing && event.target === event.currentTarget) {
setKeyboardEditing(false);
}
};
const semantics = isEditMode
? getEditableGridCellAttributes({ label: accessibleLabel, placement })
: getReadonlyGridItemAttributes(placement);
return (
<Box
{...semantics}
className={combineClasses(isEditMode ? classes.editorEntry : classes.staticItem)}
style={isEditMode ? { position: "relative", width: "100%", height: "100%" } : getLogicalItemStyle(placement)}
data-grid-item-id={isEditMode ? undefined : item.id}
data-grid-item-type={isEditMode ? undefined : item.type}
data-editor-grid-entry={isEditMode ? "true" : undefined}
className={combineClasses(isEditorActive ? classes.editorEntry : classes.staticItem)}
style={isEditorActive ? { position: "relative", width: "100%", height: "100%" } : getLogicalItemStyle(placement)}
data-grid-item-id={isEditorActive ? undefined : item.id}
data-grid-item-type={isEditorActive ? undefined : item.type}
data-editor-grid-entry={isEditorActive ? "true" : undefined}
data-type={item.type}
data-kind={item.kind}
data-keyboard-editing={isEditMode ? String(isKeyboardEditing) : undefined}
aria-describedby={isEditMode ? instructionsId : undefined}
data-keyboard-editing={isEditorActive ? String(isKeyboardEditing) : undefined}
aria-describedby={isEditorActive ? instructionsId : undefined}
aria-keyshortcuts={
isEditMode
isEditorActive
? "Enter Space Escape ArrowLeft ArrowRight ArrowUp ArrowDown Shift+ArrowLeft Shift+ArrowRight Shift+ArrowUp Shift+ArrowDown"
: undefined
}
onBlur={isEditMode ? handleBlur : undefined}
onKeyDown={isEditMode ? handleKeyboard : undefined}
onBlur={isEditorActive ? handleBlur : undefined}
onKeyDown={isEditorActive ? handleKeyboard : undefined}
>
{isEditMode && <span className={classes.dragAffordance} data-testid="board-grid-drag-affordance" aria-hidden />}
{isEditorActive && (
<span className={classes.dragAffordance} data-testid="board-grid-drag-affordance" aria-hidden />
)}
{children}
{isEditMode && (
{isEditorActive && (
<span id={instructionsId} className={classes.liveRegion}>
{t("item.moveResize.keyboard.instructions")}
</span>
@@ -148,7 +197,7 @@ export const FixedGridItem = ({
);
};
const toPlacement = (item: DynamicSectionItem | SectionItem): SectionGridPlacement => ({
const toPlacement = (item: ContainerSectionItem | SectionItem): SectionGridPlacement => ({
id: item.id,
type: item.type,
x: item.xOffset,
@@ -160,43 +209,17 @@ const toPlacement = (item: DynamicSectionItem | SectionItem): SectionGridPlaceme
const isArrowKey = (key: string): key is "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" =>
key === "ArrowLeft" || key === "ArrowRight" || key === "ArrowUp" || key === "ArrowDown";
const getKeyboardPlacement = (
current: SectionGridPlacement,
key: "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown",
resize: boolean,
{
columnCount,
minWidth,
minHeight,
maxRowCount,
}: {
columnCount: number;
minWidth: number;
minHeight: number;
maxRowCount: number | null;
},
): GridPlacement & Pick<SectionGridPlacement, "type"> => {
if (resize) {
const widthDelta = key === "ArrowLeft" ? -1 : key === "ArrowRight" ? 1 : 0;
const heightDelta = key === "ArrowUp" ? -1 : key === "ArrowDown" ? 1 : 0;
return {
...current,
w: Math.max(minWidth, Math.min(columnCount - current.x, current.w + widthDelta)),
h: Math.max(
minHeight,
Math.min(maxRowCount === null ? Number.MAX_SAFE_INTEGER : maxRowCount - current.y, current.h + heightDelta),
),
};
}
const getKeyboardDelta = (key: "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown") => ({
x: key === "ArrowLeft" ? -1 : key === "ArrowRight" ? 1 : 0,
y: key === "ArrowUp" ? -1 : key === "ArrowDown" ? 1 : 0,
});
const xDelta = key === "ArrowLeft" ? -1 : key === "ArrowRight" ? 1 : 0;
const yDelta = key === "ArrowUp" ? -1 : key === "ArrowDown" ? 1 : 0;
return {
...current,
x: Math.max(0, Math.min(columnCount - current.w, current.x + xDelta)),
y: Math.max(
0,
Math.min(maxRowCount === null ? Number.MAX_SAFE_INTEGER : maxRowCount - current.h, current.y + yDelta),
),
};
};
const arePlacementsEqual = (first: GridPlacement, second: GridPlacement) =>
first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
const arePlacementListsEqual = (first: readonly GridPlacement[], second: readonly GridPlacement[]) =>
first.length === second.length &&
first.every((placement, index) => {
const candidate = second[index];
return candidate?.id === placement.id && arePlacementsEqual(placement, candidate);
});
@@ -1,72 +0,0 @@
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,
bounds: { columnCount: number; rowCount?: number },
): 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,
);
if (!displaced) return null;
const swapped = 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;
});
return placementsFit(swapped, bounds) ? swapped : null;
};
const placementsFit = (
placements: readonly SectionGridPlacement[],
{ columnCount, rowCount }: { columnCount: number; rowCount?: number },
) =>
placements.every(
(placement) =>
placement.x >= 0 &&
placement.y >= 0 &&
placement.x + placement.w <= columnCount &&
(rowCount === undefined || placement.y + placement.h <= rowCount),
) &&
placements.every((placement, index) =>
placements.slice(index + 1).every((other) => !placementsOverlap(placement, other)),
);
const placementsOverlap = (first: SectionGridPlacement, second: SectionGridPlacement) =>
first.x < second.x + second.w &&
first.x + first.w > second.x &&
first.y < second.y + second.h &&
first.y + first.h > second.y;
@@ -0,0 +1,38 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext } from "react";
export type GridEditorRuntimeStatus = "idle" | "loading" | "ready" | "error";
export interface GridResizePreview {
id: string;
x: number;
y: number;
w: number;
h: number;
}
const GridEditorRuntimeContext = createContext<GridEditorRuntimeStatus>("idle");
const GridResizePreviewContext = createContext<GridResizePreview | null>(null);
export const GridEditorRuntimeProvider = ({
children,
status,
}: PropsWithChildren<{ status: GridEditorRuntimeStatus }>) => (
<GridEditorRuntimeContext.Provider value={status}>{children}</GridEditorRuntimeContext.Provider>
);
export const useGridEditorRuntimeStatus = () => useContext(GridEditorRuntimeContext);
export const GridResizePreviewProvider = ({
children,
value,
}: PropsWithChildren<{ value: GridResizePreview | null }>) => (
<GridResizePreviewContext.Provider value={value}>{children}</GridResizePreviewContext.Provider>
);
export const useGridResizePreview = (entryId: string) => {
const preview = useContext(GridResizePreviewContext);
return preview?.id === entryId ? preview : null;
};
@@ -1,130 +1,256 @@
.grid-stack.grid-stack-editing > .grid-stack-placeholder > .placeholder-content {
background: color-mix(in srgb, var(--mantine-primary-color-filled) 20%, transparent);
.board-grid-editor {
position: absolute;
inset: 0 auto auto 0;
overflow: visible;
background-image:
linear-gradient(to right, color-mix(in srgb, var(--mantine-color-dimmed) 18%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in srgb, var(--mantine-color-dimmed) 18%, transparent) 1px, transparent 1px);
background-size: 224px 224px;
border-radius: var(--mantine-radius-md);
}
.board-grid-entry {
z-index: 1;
overflow: visible;
transition:
left 100ms ease,
top 100ms ease,
width 100ms ease,
height 100ms ease;
}
.board-grid-entry[data-dnd-active="true"],
.board-grid-entry[data-dnd-drag-source="true"] {
z-index: 1000;
transition: none;
}
.board-grid-entry[data-dnd-drag-source="true"] {
opacity: 0.42;
}
.board-grid-drag-overlay[data-dnd-dragging] {
position: fixed !important;
z-index: 10000;
top: var(--dnd-top, 0) !important;
left: var(--dnd-left, 0) !important;
width: var(--dnd-width, auto);
max-width: var(--dnd-width, auto);
height: var(--dnd-height, auto);
max-height: var(--dnd-height, auto);
padding: 0;
margin: 0;
overflow: visible;
color: inherit;
pointer-events: none !important;
background: transparent;
border: 0;
transform: var(--dnd-transform, none) !important;
transition: var(--dnd-transition) !important;
will-change: translate;
}
.board-grid-drag-overlay[data-dnd-dragging]:not([data-dnd-dropping]) {
translate: var(--dnd-translate) !important;
}
.board-grid-drag-overlay[data-dnd-dragging][style*="--dnd-scale"] {
scale: var(--dnd-scale) !important;
transform-origin: var(--dnd-transform-origin) !important;
}
.board-grid-drag-overlay-card {
display: grid;
width: 100%;
height: 100%;
padding: var(--mantine-spacing-md);
overflow: hidden;
font-weight: 600;
color: var(--mantine-color-text);
text-align: center;
overflow-wrap: anywhere;
place-items: center;
background: color-mix(in srgb, var(--mantine-color-body) 92%, transparent);
border: 2px solid var(--mantine-primary-color-filled);
border-radius: var(--mantine-radius-md);
box-shadow: var(--mantine-shadow-lg);
zoom: var(--board-grid-overlay-content-zoom, 1);
}
body[data-board-grid-interacting] .board-grid-entry,
body[data-board-grid-interacting] .board-grid-placeholder {
transition: none;
}
.board-grid-content-mount,
.board-grid-item-content,
.board-grid-item-content > [data-editor-grid-entry] {
width: 100%;
height: 100%;
overflow: visible;
}
.board-grid-placeholder {
z-index: 0;
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
border: 2px dashed var(--mantine-primary-color-filled);
border-radius: var(--mantine-radius-md);
}
.grid-stack.grid-stack-editing > .grid-stack-item.ui-draggable-dragging {
filter: drop-shadow(var(--mantine-shadow-lg));
}
.grid-stack.grid-stack-editing > .grid-stack-item > .grid-stack-item-content {
border-radius: var(--mantine-radius-md);
outline: 1px solid transparent;
outline-offset: 2px;
pointer-events: none;
transition:
outline-color 160ms ease,
background-color 160ms ease;
left 100ms ease,
top 100ms ease,
width 100ms ease,
height 100ms ease;
}
.grid-stack.grid-stack-editing > .grid-stack-item:hover > .grid-stack-item-content,
.grid-stack.grid-stack-editing > .grid-stack-item:focus-within > .grid-stack-item-content {
outline-color: color-mix(in srgb, var(--mantine-primary-color-filled) 72%, transparent);
.board-grid-editor[data-dnd-drop-target="true"] {
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
outline-offset: 4px;
}
.grid-stack.grid-stack-editing > .grid-stack-item.ui-draggable-dragging > .grid-stack-item-content,
.grid-stack.grid-stack-editing > .grid-stack-item.ui-resizable-resizing > .grid-stack-item-content {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 3px;
.board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"] {
outline-color: var(--mantine-color-red-filled);
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-handle {
.board-grid-resize-handle {
position: absolute;
z-index: 8;
display: block;
padding: 0;
appearance: none;
background: transparent;
border: 0;
touch-action: none;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e,
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w {
width: 28px;
.board-grid-resize-handle[data-grid-resize-disabled="true"] {
pointer-events: none;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s {
height: 28px;
.board-grid-entry[data-grid-item-type="section"][data-grid-section-chrome-active="true"] {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 4px;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se,
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw {
width: 44px;
height: 44px;
color: var(--mantine-color-dimmed);
background-image: none;
border-radius: var(--mantine-radius-sm);
transform: none;
z-index: 9;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se::after,
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw::after {
position: absolute;
bottom: 8px;
width: 14px;
height: 14px;
content: "";
border-bottom: 2px solid currentColor;
opacity: 0.9;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se::after {
right: 8px;
border-right: 2px solid currentColor;
border-radius: 0 0 3px;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw::after {
left: 8px;
border-left: 2px solid currentColor;
border-radius: 0 0 0 3px;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se:hover,
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw:hover {
color: var(--mantine-primary-color-filled);
background-color: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e::after,
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w::after,
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s::after {
position: absolute;
content: "";
background-color: var(--mantine-primary-color-filled);
border-radius: var(--mantine-radius-xl);
opacity: 0;
transition: opacity 160ms ease;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e::after,
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w::after {
top: 25%;
bottom: 25%;
width: 3px;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e::after {
right: 5px;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w::after {
left: 5px;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s::after {
right: 25%;
bottom: 5px;
left: 25%;
height: 3px;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e:hover::after,
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w:hover::after,
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s:hover::after {
.board-grid-entry[data-grid-item-type="section"][data-grid-section-chrome-active="true"]
> .board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.grid-stack.grid-stack-editing,
.grid-stack.grid-stack-editing .grid-stack-item,
.grid-stack.grid-stack-editing .grid-stack-item-content,
.grid-stack.grid-stack-editing .ui-resizable-handle::after {
transition: none !important;
.board-grid-resize-handle::after {
position: absolute;
inset: 50% auto auto 50%;
width: 12px;
height: 3px;
content: "";
background: color-mix(in srgb, var(--mantine-color-text) 70%, transparent);
border-radius: var(--mantine-radius-xl);
opacity: 0;
transform: translate(-50%, -50%);
transition: opacity 100ms ease;
}
.board-grid-entry:hover > .board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after,
.board-grid-resize-handle:not([data-grid-resize-disabled="true"]):focus-visible::after {
opacity: 1;
}
.board-grid-resize-handle:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -8px;
}
.board-grid-resize-handle[data-grid-resize-handle="n"],
.board-grid-resize-handle[data-grid-resize-handle="s"] {
right: 44px;
left: 44px;
height: 32px;
cursor: ns-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="n"] {
top: -12px;
}
.board-grid-resize-handle[data-grid-resize-handle="s"] {
bottom: -12px;
}
.board-grid-resize-handle[data-grid-resize-handle="e"],
.board-grid-resize-handle[data-grid-resize-handle="w"] {
top: 44px;
bottom: 44px;
width: 32px;
cursor: ew-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="e"] {
right: -12px;
}
.board-grid-resize-handle[data-grid-resize-handle="w"] {
left: -12px;
}
.board-grid-resize-handle[data-grid-resize-handle="e"]::after,
.board-grid-resize-handle[data-grid-resize-handle="w"]::after {
width: 3px;
height: 12px;
}
.board-grid-resize-handle[data-grid-resize-handle="ne"],
.board-grid-resize-handle[data-grid-resize-handle="se"],
.board-grid-resize-handle[data-grid-resize-handle="sw"],
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
z-index: 9;
width: 44px;
height: 44px;
}
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
top: -12px;
right: -12px;
cursor: nesw-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="se"] {
right: -12px;
bottom: -12px;
cursor: nwse-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
bottom: -12px;
left: -12px;
cursor: nesw-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
top: -12px;
left: -12px;
cursor: nwse-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="ne"]::after,
.board-grid-resize-handle[data-grid-resize-handle="sw"]::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
.board-grid-resize-handle[data-grid-resize-handle="se"]::after,
.board-grid-resize-handle[data-grid-resize-handle="nw"]::after {
transform: translate(-50%, -50%) rotate(45deg);
}
@media (hover: none), (pointer: coarse) {
.board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.board-grid-entry,
.board-grid-placeholder,
.board-grid-resize-handle::after {
transition: none;
}
}
File diff suppressed because it is too large. Load diff
@@ -1,40 +1,38 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { GridItemHTMLElement } from "gridstack";
import type { RouterOutputs } from "@homarr/api";
import { useIntegrations } from "@homarr/auth/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { getRootSectionLane } from "@homarr/definitions";
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
import { getBoardLaneColumnCount } from "~/components/board/layout";
import { SectionContentItem } from "../content";
import { SectionProvider } from "../section-context";
import { useSectionItems } from "../use-section-items";
import { decorateGridResizeHandles } from "./grid-resize-handles";
import { useGridResizePreview } from "./grid-editor-runtime";
import classes from "./section-grid.module.css";
interface GridPortalHostContextValue {
announce: (message: string) => void;
integrations: RouterOutputs["integration"]["all"] | undefined;
containers: ReadonlyMap<string, HTMLElement>;
acquireContainer: (id: string) => HTMLElement;
releaseContainer: (id: string, container: HTMLElement) => void;
}
const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(null);
/**
* One portal registry owns every edit-mode item on the board. Its content
* containers are stable even when GridStack replaces or transfers the outer
* positioning shell, so moving between grids does not remount widget content.
* containers are stable even when drag-and-drop replaces or transfers the
* outer positioning shell, so moving between grids does not remount widget
* content.
*/
export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const integrations = useIntegrations();
const containersRef = useRef<Map<string, HTMLElement>>(new Map());
const [containers, setContainers] = useState<Map<string, HTMLElement>>(() => new Map());
@@ -48,36 +46,49 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
if (existing) return existing;
const container = document.createElement("div");
container.className = "grid-stack-item-content";
container.style.overflow = "visible";
container.className = "board-grid-item-content";
container.dataset.boardGridPortal = id;
containersRef.current.set(id, container);
setContainers(new Map(containersRef.current));
return container;
}, []);
const releaseContainer = useCallback((id: string, container: HTMLElement) => {
// Controlled cross-grid moves remove the source shell before the target
// adopts this container. Wait until all grid effects in the commit ran.
queueMicrotask(() => {
if (container.isConnected || containersRef.current.get(id) !== container) return;
containersRef.current.delete(id);
setContainers(new Map(containersRef.current));
});
}, []);
// Keep acquired nodes for the host's lifetime. Cross-grid commits can
// transiently render neither shell, and detaching the node must not remount
// live widget state before the destination shell adopts it.
const value = useMemo<GridPortalHostContextValue>(
() => ({
announce,
integrations,
containers,
acquireContainer,
releaseContainer,
}),
[acquireContainer, announce, integrations, releaseContainer],
[acquireContainer, announce, containers, integrations],
);
return (
<GridPortalHostContext.Provider value={value}>
{children}
<div className={classes.liveRegion} aria-live="polite" aria-atomic="true">
<span key={announcement.id}>{announcement.message}</span>
</div>
</GridPortalHostContext.Provider>
);
};
/**
* Renders stable portal content at this component's logical React position.
* Keep it inside the editor runtime and dnd-kit providers so portaled entries
* inherit the same keyboard, transaction, and nested-grid contexts as shells.
*/
export const BoardGridPortalRenderer = () => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const { announce, containers, integrations } = useBoardGridPortalHost();
return (
<>
{Array.from(containers, ([id, container]) => {
const ownerSectionId = getOwnerSectionId(board, currentLayoutId, id);
return ownerSectionId
@@ -85,7 +96,6 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
<GridPortalContent
itemId={id}
ownerSectionId={ownerSectionId}
container={container}
integrations={integrations}
announce={announce}
/>,
@@ -94,10 +104,7 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
)
: null;
})}
<div className={classes.liveRegion} aria-live="polite" aria-atomic="true">
<span key={announcement.id}>{announcement.message}</span>
</div>
</GridPortalHostContext.Provider>
</>
);
};
@@ -110,40 +117,33 @@ export const useBoardGridPortalHost = () => {
interface GridPortalContentProps {
itemId: string;
ownerSectionId: string;
container: HTMLElement;
integrations: RouterOutputs["integration"]["all"] | undefined;
announce: (message: string) => void;
}
const GridPortalContent = ({ itemId, ownerSectionId, container, integrations, announce }: GridPortalContentProps) => {
const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: GridPortalContentProps) => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const { items, innerSections } = useSectionItems(ownerSectionId);
const entry = [...items, ...innerSections].find((candidate) => candidate.id === itemId);
const entryPreview = useGridResizePreview(itemId);
const ownerPreview = useGridResizePreview(ownerSectionId);
const persistedEntry = [...items, ...innerSections].find((candidate) => candidate.id === itemId);
const entry = withResizePreview(persistedEntry, entryPreview);
const rawSection = board.sections.find((section) => section.id === ownerSectionId);
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
const section = toGridSection(rawSection, currentLayoutId);
useLayoutEffect(() => {
const itemElement = container.parentElement as GridItemHTMLElement | null;
if (!itemElement) return;
itemElement.gridstackNode?.grid?.refreshDragHandles(itemElement);
decorateGridResizeHandles(itemElement, {
minW: itemElement.gridstackNode?.minW,
minH: itemElement.gridstackNode?.minH,
});
}, [container, ownerSectionId]);
const persistedSection = toGridSection(rawSection, currentLayoutId);
const section =
persistedSection?.kind === "container" ? withResizePreview(persistedSection, ownerPreview) : persistedSection;
if (!entry || !section || !currentLayout) return null;
const columnCount =
section.kind === "dynamic"
section.kind === "container"
? section.width
: section.kind === "empty"
? getBoardLaneColumnCount(currentLayout, getRootSectionLane(section.xOffset))
: currentLayout.columnCount;
const configuredMaxRowCount = section.kind === "dynamic" ? section.height : null;
const configuredMaxRowCount = section.kind === "container" ? section.height : null;
const contentRowCount = Math.max(
1,
...items.map((item) => item.yOffset + item.height),
@@ -168,12 +168,28 @@ const GridPortalContent = ({ itemId, ownerSectionId, container, integrations, an
);
};
const withResizePreview = <
TValue extends { xOffset: number; yOffset: number; width: number; height: number } | null | undefined,
>(
value: TValue,
preview: { x: number; y: number; w: number; h: number } | null,
): TValue =>
value && preview
? ({
...value,
xOffset: preview.x,
yOffset: preview.y,
width: preview.w,
height: preview.h,
} as TValue)
: value;
const toGridSection = (
section: Section | undefined,
layoutId: string,
): Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem | null => {
): Exclude<Section, { kind: "container" }> | ContainerSectionItem | null => {
if (!section) return null;
if (section.kind !== "dynamic") return section;
if (section.kind !== "container") return section;
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
if (!layout) return null;
@@ -191,7 +207,7 @@ const getOwnerSectionId = (board: ReturnType<typeof useRequiredBoard>, layoutId:
return item.layouts.find((layout) => layout.layoutId === layoutId)?.sectionId ?? null;
}
const section = board.sections.find((candidate) => candidate.kind === "dynamic" && candidate.id === entryId);
if (!section || section.kind !== "dynamic") return null;
const section = board.sections.find((candidate) => candidate.kind === "container" && candidate.id === entryId);
if (!section || section.kind !== "container") return null;
return section.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId ?? null;
};
@@ -1,26 +0,0 @@
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),
);
if (!direction) continue;
const directionName = direction.replace("ui-resizable-", "");
handle.setAttribute(
"data-testid",
directionName === "se" ? "board-grid-resize-handle" : `board-grid-resize-handle-${directionName}`,
);
handle.setAttribute("data-grid-resize-direction", directionName);
handle.setAttribute("aria-hidden", "true");
}
};
@@ -14,10 +14,6 @@
inset: 0 auto auto 0;
}
.editorGrid:not(:global(.grid-stack-editing)) {
visibility: hidden;
}
.staticItem {
overflow: visible;
transition:
@@ -9,7 +9,7 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useI18n } from "@homarr/translation/client";
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
import {
getCollapsedDisplayLayout,
getEditableCanvasAttributes,
@@ -20,6 +20,7 @@ import {
normalizeGridPlacement,
} from "~/components/board/layout";
import { loadGridEditorAsync } from "./grid-editor-loader";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
import { SectionContent } from "../content";
import { useCollapsedSectionIds } from "../section-collapse";
@@ -63,7 +64,7 @@ function GridEditorLoading() {
}
interface SectionGridProps {
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
columnCount: number;
requestedRowCount?: number;
label: string;
@@ -80,6 +81,7 @@ export const SectionGrid = ({
className,
}: SectionGridProps) => {
const [isEditMode] = useEditMode();
const editorRuntimeStatus = useGridEditorRuntimeStatus();
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const { items, innerSections } = useSectionItems(section.id);
@@ -90,7 +92,7 @@ export const SectionGrid = ({
new Map(
innerSections.map((innerSection) => [
innerSection.id,
getDynamicSectionMinimumSize(board, currentLayoutId, innerSection.id),
getContainerMinimumSize(board, currentLayoutId, innerSection.id),
]),
),
[board, currentLayoutId, innerSections],
@@ -144,7 +146,7 @@ export const SectionGrid = ({
const displayedInnerSections = innerSections.map((item) => withPlacement(item, placementById.get(item.id)));
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
const rowCount = Math.max(contentRowCount, requestedRowCount);
const fixedRowCount = section.kind === "dynamic";
const fixedRowCount = section.kind === "container";
const logicalWidth = getLogicalTrackSize(columnCount);
const logicalHeight = getLogicalTrackSize(rowCount);
const canvasAttributes = isEditMode
@@ -172,21 +174,24 @@ export const SectionGrid = ({
data-section-kind={section.kind}
data-rail-placement={railPlacement}
>
{isEditMode ? (
{isEditMode && editorRuntimeStatus === "ready" ? (
<GridEditor
sectionId={section.id}
columnCount={columnCount}
rowCount={rowCount}
fixedRowCount={fixedRowCount}
placements={displayPlacements}
className={combineClasses("grid-stack", classes.editorGrid)}
className={combineClasses("board-grid-editor", classes.editorGrid)}
/>
) : isEditMode && editorRuntimeStatus === "loading" ? (
<GridEditorLoading />
) : (
<Box
className={classes.staticGrid}
style={{ width: logicalWidth, height: logicalHeight }}
data-grid-section-id={section.id}
data-kind={section.kind}
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
>
<SectionContent />
</Box>
@@ -196,19 +201,30 @@ export const SectionGrid = ({
);
};
const getDynamicSectionMinimumSize = (
const getContainerMinimumSize = (
board: ReturnType<typeof useRequiredBoard>,
layoutId: string,
sectionId: string,
) => {
const directItems = board.items.flatMap((item) => {
visited = new Set<string>(),
): { width: number; height: number } => {
if (visited.has(sectionId)) return { width: 1, height: 1 };
const nextVisited = new Set(visited).add(sectionId);
const directItems: PlacementBounds[] = board.items.flatMap((item) => {
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
return layout?.sectionId === sectionId ? [layout] : [];
});
const directSections = board.sections.flatMap((candidate) => {
if (candidate.kind !== "dynamic") return [];
const directSections: PlacementBounds[] = board.sections.flatMap((candidate) => {
if (candidate.kind !== "container") return [];
const layout = candidate.layouts.find((candidateLayout) => candidateLayout.layoutId === layoutId);
return layout?.parentSectionId === sectionId ? [layout] : [];
if (!layout || layout.parentSectionId !== sectionId) return [];
const minimum = getContainerMinimumSize(board, layoutId, candidate.id, nextVisited);
return [
{
...layout,
width: Math.max(layout.width, minimum.width),
height: Math.max(layout.height, minimum.height),
},
];
});
const children = [...directItems, ...directSections];
@@ -218,6 +234,13 @@ const getDynamicSectionMinimumSize = (
};
};
interface PlacementBounds {
xOffset: number;
yOffset: number;
width: number;
height: number;
}
const withPlacement = <
TItem extends {
id: string;
@@ -59,6 +59,10 @@ vi.mock("../use-grid-layout-actions", () => ({
}),
}));
vi.mock("../grid-editor-runtime", () => ({
useGridEditorRuntimeStatus: () => "ready",
}));
vi.mock("../../section-context", () => ({
useSectionContext: () => ({
section: { id: "root", kind: "empty", options: {} },
@@ -98,7 +102,7 @@ describe("fixed grid item behavior", () => {
container.remove();
});
test("moves with the keyboard and deterministically pushes a collision down", () => {
test("moves with the keyboard and swaps an equal-sized destination", () => {
renderWeather(root);
const entry = getEditorEntry(container);
@@ -110,7 +114,7 @@ describe("fixed grid item behavior", () => {
sectionId: "root",
placements: [
{ id: "weather", type: "item", x: 1, y: 0, w: 1, h: 1 },
{ id: "clock", type: "item", x: 1, y: 1, w: 1, h: 1 },
{ id: "clock", type: "item", x: 0, y: 0, w: 1, h: 1 },
],
});
expect(mocks.announce).toHaveBeenCalledTimes(2);
@@ -140,6 +144,25 @@ describe("fixed grid item behavior", () => {
],
});
mocks.commitSectionGrid.mockReset();
act(() =>
entry.dispatchEvent(
new KeyboardEvent("keydown", {
key: "ArrowLeft",
shiftKey: true,
bubbles: true,
}),
),
);
expect(mocks.commitSectionGrid).toHaveBeenCalledWith({
layoutId: "layout",
sectionId: "root",
placements: [
{ id: "weather", type: "item", x: 0, y: 0, w: 1, h: 1 },
{ id: "clock", type: "item", x: 1, y: 1, w: 1, h: 1 },
],
});
mocks.commitSectionGrid.mockReset();
act(() =>
entry.dispatchEvent(
@@ -156,6 +179,27 @@ describe("fixed grid item behavior", () => {
);
});
test("applies repeated keyboard resizes before board props rerender", () => {
renderWeather(root);
const entry = getEditorEntry(container);
startKeyboardEditing(entry);
act(() => {
entry.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", shiftKey: true, bubbles: true }));
entry.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", shiftKey: true, bubbles: true }));
});
expect(mocks.commitSectionGrid).toHaveBeenCalledTimes(2);
expect(mocks.commitSectionGrid).toHaveBeenLastCalledWith({
layoutId: "layout",
sectionId: "root",
placements: [
{ id: "weather", type: "item", x: 0, y: 0, w: 1, h: 3 },
{ id: "clock", type: "item", x: 1, y: 0, w: 1, h: 1 },
],
});
});
test("uses the whole tile as the focusable move surface with a visual drag affordance", () => {
getWeatherMock().advancedOptions.title = " Balcony forecast ";
renderWeather(root);
@@ -1,49 +0,0 @@
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];
const bounds = { columnCount: 4 };
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", bounds)).toEqual([
{ ...first, x: 1 },
{ ...second, x: 0 },
third,
]);
});
test("swaps different-sized items when both destinations remain valid", () => {
const wide: SectionGridPlacement = { id: "wide", type: "item", x: 0, y: 0, w: 2, h: 1 };
const compact: SectionGridPlacement = { id: "compact", type: "item", x: 2, y: 0, w: 1, h: 1 };
const current = [
{ ...wide, x: 2 },
{ ...compact, y: 1 },
];
expect(getDropSwapPlacements([wide, compact], current, "wide", bounds)).toEqual([
{ ...wide, x: 2 },
{ ...compact, x: 0 },
]);
});
test("keeps push behavior when a swap would overlap another item or exceed the section", () => {
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", bounds)).toBeNull();
expect(getDropSwapPlacements(initial, partialPlacement, "third", bounds)).toBeNull();
expect(getDropSwapPlacements(initial, [{ ...third, x: 1 }, first, second], "third", { columnCount: 2 })).toBeNull();
});
test("does nothing when the item did not move", () => {
expect(getDropSwapPlacements(initial, initial, "first", bounds)).toBeNull();
});
});
@@ -9,10 +9,10 @@ const sectionGridPath = resolve(gridDirectory, "section-grid.tsx");
const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts");
describe("board grid import boundary", () => {
test("keeps every GridStack runtime import inside the edit-only module", () => {
test("keeps every current dnd-kit runtime import inside the edit-only module", () => {
const runtimeImports = getSourceFiles(nextjsSourceDirectory)
.flatMap(getRuntimeImports)
.filter(({ specifier }) => specifier === "gridstack" || specifier.startsWith("gridstack/"))
.filter(({ specifier }) => CURRENT_DND_KIT_PACKAGES.has(specifier))
.map(({ filePath, kind, specifier }) => ({
file: toSourceRelativePath(filePath),
kind,
@@ -28,12 +28,22 @@ describe("board grid import boundary", () => {
{
file: "components/board/sections/grid/grid-editor.tsx",
kind: "static",
specifier: "gridstack",
specifier: "@dnd-kit/abstract",
},
{
file: "components/board/sections/grid/grid-editor.tsx",
kind: "static",
specifier: "gridstack/dist/gridstack.min.css",
specifier: "@dnd-kit/collision",
},
{
file: "components/board/sections/grid/grid-editor.tsx",
kind: "static",
specifier: "@dnd-kit/dom",
},
{
file: "components/board/sections/grid/grid-editor.tsx",
kind: "static",
specifier: "@dnd-kit/react",
},
]);
});
@@ -88,15 +98,14 @@ describe("board grid import boundary", () => {
}
const editConditional = findAncestor(gridEditorUsage, ts.isConditionalExpression);
expect(
editConditional !== undefined &&
ts.isIdentifier(editConditional.condition) &&
editConditional.condition.text === "isEditMode" &&
isDescendantOf(gridEditorUsage, editConditional.whenTrue),
).toBe(true);
expect(editConditional && isDescendantOf(gridEditorUsage, editConditional.whenTrue)).toBe(true);
expect(editConditional?.condition.getText(sourceFile)).toContain("isEditMode");
expect(editConditional?.condition.getText(sourceFile)).toContain('editorRuntimeStatus === "ready"');
});
});
const CURRENT_DND_KIT_PACKAGES = new Set(["@dnd-kit/abstract", "@dnd-kit/collision", "@dnd-kit/dom", "@dnd-kit/react"]);
interface RuntimeImport {
filePath: string;
kind: "dynamic" | "require" | "static";
@@ -1,27 +0,0 @@
import { describe, expect, test } from "vitest";
import { LOGICAL_GRID_PITCH } from "../../../layout";
import { decorateGridResizeHandles } from "../grid-resize-handles";
describe("grid resize handle decoration", () => {
test("restores accessible hit-target markers after GridStack refreshes its handles", () => {
const item = document.createElement("div");
item.innerHTML = `
<div class="ui-resizable-handle ui-resizable-e"></div>
<div class="ui-resizable-handle ui-resizable-se"></div>
`;
decorateGridResizeHandles(item, { minW: 2, minH: 3 });
const east = item.querySelector<HTMLElement>(".ui-resizable-e");
const southeast = item.querySelector<HTMLElement>(".ui-resizable-se");
expect(east?.dataset.testid).toBe("board-grid-resize-handle-e");
expect(east?.dataset.gridResizeDirection).toBe("e");
expect(east?.getAttribute("aria-hidden")).toBe("true");
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`);
});
});
@@ -19,9 +19,9 @@ export interface CommitSectionGridInput {
}
/**
* Commits a complete section snapshot in one cache update. GridStack can move
* several neighbours for one gesture, so persisting only the active item would
* lose its collision result.
* Commits complete section snapshots in one cache update. One drag or resize
* can move several neighbours and can cross section boundaries, so the whole
* transaction must remain atomic.
*/
export const useGridLayoutActions = () => {
const { updateBoard } = useUpdateBoard();
@@ -34,48 +34,68 @@ export const useGridLayoutActions = () => {
),
);
updateBoard((previous) => {
return {
...previous,
items: previous.items.map((item) => {
const layouts = item.layouts.map((layout) => {
const resolved = byId.get(`${layout.layoutId}:${item.id}`);
if (!resolved || resolved.placement.type !== "item") return layout;
const { placement, sectionId } = resolved;
let hasChanges = false;
const items = previous.items.map((item) => {
const layouts = item.layouts.map((layout) => {
const resolved = byId.get(`${layout.layoutId}:${item.id}`);
if (!resolved || resolved.placement.type !== "item") return layout;
const { placement, sectionId } = resolved;
if (
layout.sectionId === sectionId &&
layout.xOffset === placement.x &&
layout.yOffset === placement.y &&
layout.width === placement.w &&
layout.height === placement.h
) {
return layout;
}
return {
...layout,
sectionId,
xOffset: placement.x,
yOffset: placement.y,
width: placement.w,
height: placement.h,
};
});
return {
...layout,
sectionId,
xOffset: placement.x,
yOffset: placement.y,
width: placement.w,
height: placement.h,
};
});
return layouts.some((layout, index) => layout !== item.layouts[index]) ? { ...item, layouts } : item;
}),
sections: previous.sections.map((innerSection) => {
if (innerSection.kind !== "dynamic") return innerSection;
const layouts = innerSection.layouts.map((layout) => {
const resolved = byId.get(`${layout.layoutId}:${innerSection.id}`);
if (!resolved || resolved.placement.type !== "section") return layout;
const { placement, sectionId } = resolved;
if (!layouts.some((layout, index) => layout !== item.layouts[index])) return item;
hasChanges = true;
return { ...item, layouts };
});
const sections = previous.sections.map((innerSection) => {
if (innerSection.kind !== "container") return innerSection;
const layouts = innerSection.layouts.map((layout) => {
const resolved = byId.get(`${layout.layoutId}:${innerSection.id}`);
if (!resolved || resolved.placement.type !== "section") return layout;
const { placement, sectionId } = resolved;
if (
layout.parentSectionId === sectionId &&
layout.xOffset === placement.x &&
layout.yOffset === placement.y &&
layout.width === placement.w &&
layout.height === placement.h
) {
return layout;
}
return {
...layout,
parentSectionId: sectionId,
xOffset: placement.x,
yOffset: placement.y,
width: placement.w,
height: placement.h,
};
});
return {
...layout,
parentSectionId: sectionId,
xOffset: placement.x,
yOffset: placement.y,
width: placement.w,
height: placement.h,
};
});
return layouts.some((layout, index) => layout !== innerSection.layouts[index])
? { ...innerSection, layouts }
: innerSection;
}),
};
if (!layouts.some((layout, index) => layout !== innerSection.layouts[index])) return innerSection;
hasChanges = true;
return { ...innerSection, layouts };
});
return hasChanges ? { ...previous, items, sections } : previous;
});
},
[updateBoard],
@@ -29,7 +29,7 @@ export const useSectionActions = () => {
sections: previous.sections.map((section) => {
// Return same section if section is not the one we're moving
if (section.id !== innerSectionId) return section;
if (section.kind !== "dynamic") return section;
if (section.kind !== "container") return section;
const currentLayout = getCurrentLayout(previous);
@@ -57,7 +57,7 @@ export const useSectionActions = () => {
sections: previous.sections.map((section) => {
// Return section without changes when not the section we're moving
if (section.id !== innerSectionId) return section;
if (section.kind !== "dynamic") return section;
if (section.kind !== "container") return section;
const currentLayout = getCurrentLayout(previous);
@@ -2,11 +2,11 @@ import { createContext, useContext } from "react";
import type { RouterOutputs } from "@homarr/api";
import type { DynamicSectionItem, Section, SectionItem } from "~/app/[locale]/boards/_types";
import type { ContainerSectionItem, Section, SectionItem } from "~/app/[locale]/boards/_types";
interface SectionContextProps {
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
innerSections: DynamicSectionItem[];
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
innerSections: ContainerSectionItem[];
items: SectionItem[];
integrations: RouterOutputs["integration"]["all"] | undefined;
columnCount: number;
@@ -24,8 +24,8 @@ describe("section collapse storage", () => {
test("uses the corrected fallback when no browser preference exists", () => {
const storage = createStorage();
expect(readSectionCollapsedFromStorage(storage, "category", true)).toBe(true);
expect(readSectionCollapsedFromStorage(storage, "dynamic", false)).toBe(false);
expect(readSectionCollapsedFromStorage(storage, "first", true)).toBe(true);
expect(readSectionCollapsedFromStorage(storage, "second", false)).toBe(false);
});
test("writes only the versioned corrected state", () => {
@@ -3,40 +3,40 @@ import { describe, expect, test } from "vitest";
import { getSectionItemsForLayout } from "../use-open-section-apps";
describe("getSectionItemsForLayout", () => {
test("includes items in recursively nested dynamic sections", () => {
test("includes items in recursively nested containers", () => {
const board = {
sections: [
{ id: "category", kind: "category" },
{ id: "root", kind: "empty" },
{
id: "first",
kind: "dynamic",
layouts: [{ layoutId: "desktop", parentSectionId: "category" }],
kind: "container",
layouts: [{ layoutId: "desktop", parentSectionId: "root" }],
},
{
id: "second",
kind: "dynamic",
kind: "container",
layouts: [{ layoutId: "desktop", parentSectionId: "first" }],
},
],
items: [
{ id: "direct", layouts: [{ layoutId: "desktop", sectionId: "category" }] },
{ id: "direct", layouts: [{ layoutId: "desktop", sectionId: "root" }] },
{ id: "nested", layouts: [{ layoutId: "desktop", sectionId: "second" }] },
],
};
expect(getSectionItemsForLayout(board, "category", "desktop").map((item) => item.id)).toEqual(["direct", "nested"]);
expect(getSectionItemsForLayout(board, "root", "desktop").map((item) => item.id)).toEqual(["direct", "nested"]);
});
test("uses containment from only the requested responsive layout", () => {
const board = {
sections: [
{ id: "category", kind: "category" },
{ id: "other", kind: "category" },
{ id: "root", kind: "empty" },
{ id: "other", kind: "empty" },
{
id: "dynamic",
kind: "dynamic",
id: "container",
kind: "container",
layouts: [
{ layoutId: "mobile", parentSectionId: "category" },
{ layoutId: "mobile", parentSectionId: "root" },
{ layoutId: "desktop", parentSectionId: "other" },
],
},
@@ -45,15 +45,15 @@ describe("getSectionItemsForLayout", () => {
{
id: "app",
layouts: [
{ layoutId: "mobile", sectionId: "dynamic" },
{ layoutId: "desktop", sectionId: "dynamic" },
{ layoutId: "mobile", sectionId: "container" },
{ layoutId: "desktop", sectionId: "container" },
],
},
],
};
expect(getSectionItemsForLayout(board, "category", "mobile").map((item) => item.id)).toEqual(["app"]);
expect(getSectionItemsForLayout(board, "category", "desktop")).toEqual([]);
expect(getSectionItemsForLayout(board, "root", "mobile").map((item) => item.id)).toEqual(["app"]);
expect(getSectionItemsForLayout(board, "root", "desktop")).toEqual([]);
});
test("does not loop when malformed section containment is cyclic", () => {
@@ -61,12 +61,12 @@ describe("getSectionItemsForLayout", () => {
sections: [
{
id: "root",
kind: "dynamic",
kind: "container",
layouts: [{ layoutId: "desktop", parentSectionId: "child" }],
},
{
id: "child",
kind: "dynamic",
kind: "container",
layouts: [{ layoutId: "desktop", parentSectionId: "root" }],
},
],
@@ -7,7 +7,7 @@ import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import type { Item } from "~/app/[locale]/boards/_types";
import { filterByItemKind } from "./category/filter";
import { filterByItemKind } from "./filter-by-item-kind";
interface SectionItemLayout {
layoutId: string;
@@ -35,8 +35,8 @@ interface SectionTree<TItem extends SectionTreeItem> {
}
/**
* Finds every item contained by a section in one responsive layout. Dynamic
* sections can be nested, so their descendants are traversed recursively.
* Finds every item contained by a section in one responsive layout. Containers
* can be nested, so their descendants are traversed recursively.
*/
export const getSectionItemsForLayout = <TItem extends SectionTreeItem>(
board: SectionTree<TItem>,
@@ -46,7 +46,7 @@ export const getSectionItemsForLayout = <TItem extends SectionTreeItem>(
const childrenByParent = new Map<string, string[]>();
for (const section of board.sections) {
if (section.kind !== "dynamic") continue;
if (section.kind !== "container") continue;
const layout = section.layouts?.find((candidate) => candidate.layoutId === layoutId);
if (!layout) continue;
@@ -2,16 +2,16 @@ import { useMemo } from "react";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import type { ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
export const useSectionItems = (sectionId: string): { innerSections: DynamicSectionItem[]; items: SectionItem[] } => {
export const useSectionItems = (sectionId: string): { innerSections: ContainerSectionItem[]; items: SectionItem[] } => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const innerSections = useMemo(
() =>
board.sections
.filter((innerSection) => innerSection.kind === "dynamic")
.filter((innerSection) => innerSection.kind === "container")
.map(({ layouts, ...innerSection }) => {
const layout = layouts.find((layout) => layout.layoutId === currentLayoutId);