diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx
index d939d19aa..94acc1fea 100644
--- a/apps/docs/docs/getting-started/after-the-installation.mdx
+++ b/apps/docs/docs/getting-started/after-the-installation.mdx
@@ -321,39 +321,34 @@ As with every change, don't forget to save your changes using the exit edit mode

-## Organizing items with sections
+## Organizing items with Containers
-Sections are resizable containers for apps, widgets, and other sections.
-The same section works on the main canvas and inside a fixed sidebar.
-Each section can configure:
+Containers are resizable areas for apps, widgets, and nested Containers.
+The same Container works on the main canvas, inside a fixed rail, or inside another Container.
+Each Container can configure:
-- Show or hide its label.
+- Its label and whether that label is visible.
- Allow or disable collapsing.
- Show or hide **Open all in new tabs**.
- Change its border color and custom CSS classes.
-
+Open the add-content menu and choose **New Container**.
+In edit mode, move and resize it like any other item, then drag items into or out of it.
+Nested content stays within the Container's bounds.
-After adding a section, drag and resize it like any other tile, then drag items into it:
+### Collapsing and opening apps
-
+Users can collapse an eligible Container like an accordion in view mode without changing its saved expanded size.
+Expanding it restores its bounds and makes room for its contents.
+If **Open all in new tabs** is enabled, the Container menu can open every app link it contains.
-### Labeled and collapsible sections
+### Fixed rails
-Open the add-content menu and choose **New section**.
-Use its settings menu to add a label and enable the behavior you want.
-In edit mode, move items into or out of the section directly.
-In view mode, each user can collapse eligible sections without changing the saved board layout.
-If **Open all in new tabs** is enabled, the section menu can open every app link it contains.
-
-### Fixed sidebars
-
-Open the board's **Layout** settings to enable the left or right sidebar for each responsive layout.
+Open the board's **Layout** settings to enable the left or right rail for each responsive layout.
Choose one, two, or three columns with the slider and use the preview to check the remaining canvas width.
-The sidebar reserves those columns from the dashboard, so Homarr moves canvas items into the next available positions.
-Sidebar content stays visible while the page scrolls and remains editable.
-
-Existing categories become sections automatically, and category rails become fixed sidebars.
+The rail reserves those columns from the dashboard, so Homarr moves canvas items into the next available positions.
+Rail content stays visible while the page scrolls and remains editable.
+Each responsive layout keeps its own rail widths, item positions, and item sizes.
## Guided tours
diff --git a/apps/docs/docs/getting-started/glossary.mdx b/apps/docs/docs/getting-started/glossary.mdx
index 8d690245c..ebfee3c87 100644
--- a/apps/docs/docs/getting-started/glossary.mdx
+++ b/apps/docs/docs/getting-started/glossary.mdx
@@ -1,12 +1,13 @@
# Glossary
+
In the glossary, you'll learn the different components and their names of Homarr.
-| Term | Definition |
-|-----------------|----------------------------------------------------------------------------------------------------------------|
-| Apps | An piece of software that you or a third party hosts (eg. Google) |
-| Integration | A connection to a service outside of Homarr |
-| Dynamic section | Resizeable area where items on your board can be placed and grouped |
-| Widget | A tile that uses your integrations to display data or enables you to interact with with them |
-| Edit mode | Temporary state that you can enter where you can drag and drop, edit or customize your board |
-| Board | Customizable page which can be edited using the edit mode. This is where apps, sections and widgets are placed |
-| Tasks | Workloads that are performed in the background - even if there is no user present |
+| Term | Definition |
+| ----------- | --------------------------------------------------------------------------------------- |
+| Apps | A piece of software that you or a third party hosts (for example, Google) |
+| Integration | A connection to a service outside of Homarr |
+| Container | A resizable area that can hold apps, widgets, and nested Containers |
+| Widget | A tile that uses your integrations to display data or enables you to interact with them |
+| Edit mode | A temporary state where you can drag, resize, edit, or customize your board |
+| Board | A customizable page where apps, widgets, and Containers are arranged |
+| Tasks | Workloads that are performed in the background, even if no user is present |
diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json
index 2c269d59c..30afbf34e 100644
--- a/apps/nextjs/package.json
+++ b/apps/nextjs/package.json
@@ -14,8 +14,12 @@
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
+ "@dnd-kit/abstract": "catalog:",
+ "@dnd-kit/collision": "catalog:",
"@dnd-kit/core": "catalog:",
+ "@dnd-kit/dom": "catalog:",
"@dnd-kit/modifiers": "catalog:",
+ "@dnd-kit/react": "catalog:",
"@dnd-kit/sortable": "catalog:",
"@dnd-kit/utilities": "catalog:",
"@gfazioli/mantine-onboarding-tour": "catalog:",
@@ -82,7 +86,6 @@
"drizzle-orm": "catalog:",
"flag-icons": "catalog:",
"glob": "catalog:",
- "gridstack": "catalog:",
"isomorphic-dompurify": "catalog:",
"jotai": "catalog:",
"jszip": "catalog:",
diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx
index 7aa71e98e..c869e0d62 100644
--- a/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx
@@ -10,7 +10,7 @@ import { useI18n } from "@homarr/translation/client";
import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
-import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
+import { useContainerActions } from "~/components/board/sections/container/container-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
@@ -23,7 +23,7 @@ const AddMenu = () => {
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
- const { addDynamicSection } = useDynamicSectionActions();
+ const { addContainer } = useContainerActions();
const { createItem } = useItemActions();
const t = useI18n();
@@ -68,8 +68,8 @@ const AddMenu = () => {
{t("integration.action.create")}
- } onClick={addDynamicSection}>
- {t("section.dynamic.action.create")}
+ } onClick={addContainer}>
+ {t("section.container.action.create")}
diff --git a/apps/nextjs/src/app/[locale]/boards/_types.ts b/apps/nextjs/src/app/[locale]/boards/_types.ts
index 7c202fe95..d1770d1e9 100644
--- a/apps/nextjs/src/app/[locale]/boards/_types.ts
+++ b/apps/nextjs/src/app/[locale]/boards/_types.ts
@@ -7,10 +7,9 @@ export type Item = Board["items"][number];
export type ItemLayout = Item["layouts"][number];
export type SectionItem = Omit- & ItemLayout & { type: "item" };
-export type CategorySection = Extract;
export type EmptySection = Extract;
-export type DynamicSection = Extract;
-export type DynamicSectionLayout = DynamicSection["layouts"][number];
-export type DynamicSectionItem = Omit & DynamicSectionLayout & { type: "section" };
+export type ContainerSection = Extract;
+export type ContainerSectionLayout = ContainerSection["layouts"][number];
+export type ContainerSectionItem = Omit & ContainerSectionLayout & { type: "section" };
export type ItemOfKind = Extract
- ;
diff --git a/apps/nextjs/src/components/board/items/actions/duplicate-item.ts b/apps/nextjs/src/components/board/items/actions/duplicate-item.ts
index da0ee9c2d..b793bbcaf 100644
--- a/apps/nextjs/src/components/board/items/actions/duplicate-item.ts
+++ b/apps/nextjs/src/components/board/items/actions/duplicate-item.ts
@@ -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,
diff --git a/apps/nextjs/src/components/board/items/actions/section-elements.ts b/apps/nextjs/src/components/board/items/actions/section-elements.ts
index 09efc67c8..0f84b7fcc 100644
--- a/apps/nextjs/src/components/board/items/actions/section-elements.ts
+++ b/apps/nextjs/src/components/board/items/actions/section-elements.ts
@@ -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];
};
diff --git a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts
index f9add8dc3..ea57dce5e 100644
--- a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts
+++ b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts
@@ -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();
diff --git a/apps/nextjs/src/components/board/items/actions/test/item-move-targets.spec.ts b/apps/nextjs/src/components/board/items/actions/test/item-move-targets.spec.ts
index 773cbafd8..fdf11db49 100644
--- a/apps/nextjs/src/components/board/items/actions/test/item-move-targets.spec.ts
+++ b/apps/nextjs/src/components/board/items/actions/test/item-move-targets.spec.ts
@@ -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}`,
diff --git a/apps/nextjs/src/components/board/items/actions/test/mocks/board-mock.ts b/apps/nextjs/src/components/board/items/actions/test/mocks/board-mock.ts
index 79f690fac..56f870336 100644
--- a/apps/nextjs/src/components/board/items/actions/test/mocks/board-mock.ts
+++ b/apps/nextjs/src/components/board/items/actions/test/mocks/board-mock.ts
@@ -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): BoardMockBuilder {
- return this.addSection(new DynamicSectionMockBuilder(dynamicSection).build());
+ public addContainer(container?: Partial): BoardMockBuilder {
+ return this.addSection(new ContainerSectionMockBuilder(container).build());
}
public addSection(section: Section): BoardMockBuilder {
diff --git a/apps/nextjs/src/components/board/items/actions/test/mocks/container-section-mock.ts b/apps/nextjs/src/components/board/items/actions/test/mocks/container-section-mock.ts
new file mode 100644
index 000000000..906d6167d
--- /dev/null
+++ b/apps/nextjs/src/components/board/items/actions/test/mocks/container-section-mock.ts
@@ -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, "options"> & {
+ options?: Partial;
+};
+
+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): 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);
+ }
+}
diff --git a/apps/nextjs/src/components/board/items/actions/test/mocks/dynamic-section-mock.ts b/apps/nextjs/src/components/board/items/actions/test/mocks/dynamic-section-mock.ts
deleted file mode 100644
index d3e188703..000000000
--- a/apps/nextjs/src/components/board/items/actions/test/mocks/dynamic-section-mock.ts
+++ /dev/null
@@ -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, "options"> & {
- options?: Partial;
-};
-
-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): 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;
- }
-}
diff --git a/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css b/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css
index 89b3ba93e..cd5104545 100644
--- a/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css
+++ b/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css
@@ -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);
}
diff --git a/apps/nextjs/src/components/board/layout/board-lanes.ts b/apps/nextjs/src/components/board/layout/board-lanes.ts
index edc190e67..b7345fa10 100644
--- a/apps/nextjs/src/components/board/layout/board-lanes.ts
+++ b/apps/nextjs/src/components/board/layout/board-lanes.ts
@@ -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),
diff --git a/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx b/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx
index 43e9b1e0a..3d5de2b72 100644
--- a/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx
+++ b/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx
@@ -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}
+ {children}
diff --git a/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts b/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts
index bf029f147..ecb537089 100644
--- a/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts
+++ b/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts
@@ -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 },
})
diff --git a/apps/nextjs/src/components/board/sections/category/filter.ts b/apps/nextjs/src/components/board/sections/category/filter.ts
deleted file mode 100644
index 3866a0075..000000000
--- a/apps/nextjs/src/components/board/sections/category/filter.ts
+++ /dev/null
@@ -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 (
- 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["options"],
- }));
-};
diff --git a/apps/nextjs/src/components/board/sections/dynamic-section.tsx b/apps/nextjs/src/components/board/sections/container-section.tsx
similarity index 72%
rename from apps/nextjs/src/components/board/sections/dynamic-section.tsx
rename to apps/nextjs/src/components/board/sections/container-section.tsx
index 5cc5987a1..b11d95032 100644
--- a/apps/nextjs/src/components/board/sections/dynamic-section.tsx
+++ b/apps/nextjs/src/components/board/sections/container-section.tsx
@@ -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 (
-
+
{
radius={board.itemRadius}
p={0}
>
+ {isEditMode && (
+
+
+
+ )}
{options.showLabel && options.title && (
{
)}
- {isEditMode && }
+ {isEditMode && }
);
};
diff --git a/apps/nextjs/src/components/board/sections/dynamic/actions/add-dynamic-section.ts b/apps/nextjs/src/components/board/sections/container/actions/add-container.ts
similarity index 68%
rename from apps/nextjs/src/components/board/sections/dynamic/actions/add-dynamic-section.ts
rename to apps/nextjs/src/components/board/sections/container/actions/add-container.ts
index 214ae300c..39f1f37dc 100644
--- a/apps/nextjs/src/components/board/sections/dynamic/actions/add-dynamic-section.ts
+++ b/apps/nextjs/src/components/board/sections/container/actions/add-container.ts
@@ -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,
diff --git a/apps/nextjs/src/components/board/sections/container/actions/container-actions.spec.ts b/apps/nextjs/src/components/board/sections/container/actions/container-actions.spec.ts
new file mode 100644
index 000000000..04f17354b
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/container/actions/container-actions.spec.ts
@@ -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 });
+ });
+});
diff --git a/apps/nextjs/src/components/board/sections/container/actions/remove-container.ts b/apps/nextjs/src/components/board/sections/container/actions/remove-container.ts
new file mode 100644
index 000000000..5e4a53b9c
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/container/actions/remove-container.ts
@@ -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();
+
+ 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;
+};
diff --git a/apps/nextjs/src/components/board/sections/container/container-actions.ts b/apps/nextjs/src/components/board/sections/container/container-actions.ts
new file mode 100644
index 000000000..7bbc975a8
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/container/container-actions.ts
@@ -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;
+}
+
+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 };
+};
diff --git a/apps/nextjs/src/components/board/sections/dynamic/dynamic-edit-modal.tsx b/apps/nextjs/src/components/board/sections/container/container-edit-modal.tsx
similarity index 76%
rename from apps/nextjs/src/components/board/sections/dynamic/dynamic-edit-modal.tsx
rename to apps/nextjs/src/components/board/sections/container/container-edit-modal.tsx
index e3553853a..cfa2a8e09 100644
--- a/apps/nextjs/src/components/board/sections/dynamic/dynamic-edit-modal.tsx
+++ b/apps/nextjs/src/components/board/sections/container/container-edit-modal.tsx
@@ -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;
- onSuccessfulEdit: (value: z.infer) => void;
+ value: z.infer;
+ onSuccessfulEdit: (value: z.infer) => void;
}
-export const DynamicSectionEditModal = createModal(({ actions, innerProps }) => {
+export const ContainerEditModal = createModal(({ 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(({ actions, inner
})}
>
-
+
(({ actions, inner
{...form.getInputProps("showOpenAll", { type: "checkbox" })}
/>
color[6])}
rightSection={
diff --git a/apps/nextjs/src/components/board/sections/dynamic/dynamic-menu.tsx b/apps/nextjs/src/components/board/sections/container/container-menu.tsx
similarity index 61%
rename from apps/nextjs/src/components/board/sections/dynamic/dynamic-menu.tsx
rename to apps/nextjs/src/components/board/sections/container/container-menu.tsx
index dd3f06c2c..f479077ba 100644
--- a/apps/nextjs/src/components/board/sections/dynamic/dynamic-menu.tsx
+++ b/apps/nextjs/src/components/board/sections/container/container-menu.tsx
@@ -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
-
+
@@ -70,19 +63,14 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
}
- onClick={() =>
- openMoveModal({
- entry: section,
- sourceSectionId: section.parentSectionId,
- })
- }
+ onClick={() => openMoveModal({ entry: section, sourceSectionId: section.parentSectionId })}
>
{tItem("action.moveResize")}
{t("common.dangerZone")}
} onClick={openRemoveModal}>
- {tDynamic("action.remove")}
+ {tContainer("action.remove")}
diff --git a/apps/nextjs/src/components/board/sections/dynamic/actions/remove-dynamic-section.ts b/apps/nextjs/src/components/board/sections/dynamic/actions/remove-dynamic-section.ts
deleted file mode 100644
index da329433f..000000000
--- a/apps/nextjs/src/components/board/sections/dynamic/actions/remove-dynamic-section.ts
+++ /dev/null
@@ -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,
- };
- }),
- })),
- };
- };
diff --git a/apps/nextjs/src/components/board/sections/dynamic/dynamic-actions.ts b/apps/nextjs/src/components/board/sections/dynamic/dynamic-actions.ts
deleted file mode 100644
index 0255ac697..000000000
--- a/apps/nextjs/src/components/board/sections/dynamic/dynamic-actions.ts
+++ /dev/null
@@ -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;
-}
-
-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,
- };
-};
diff --git a/apps/nextjs/src/components/board/sections/grid/board-grid-editor-boundary.tsx b/apps/nextjs/src/components/board/sections/grid/board-grid-editor-boundary.tsx
new file mode 100644
index 000000000..8a4afd423
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/grid/board-grid-editor-boundary.tsx
@@ -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;
+
+/**
+ * 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(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 {children};
+ }
+
+ if (hasLoadError) {
+ return {children};
+ }
+
+ if (!Provider) {
+ return {children};
+ }
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.spec.ts b/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.spec.ts
new file mode 100644
index 000000000..6cfc31361
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.spec.ts
@@ -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();
+ });
+});
diff --git a/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.ts b/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.ts
new file mode 100644
index 000000000..a6678d0ec
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.ts
@@ -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;
+ 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)),
+ };
+};
diff --git a/apps/nextjs/src/components/board/sections/grid/dnd/engine.spec.ts b/apps/nextjs/src/components/board/sections/grid/dnd/engine.spec.ts
new file mode 100644
index 000000000..d3f6c8255
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/grid/dnd/engine.spec.ts
@@ -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, "maxRowCount" | "ownerPlacementId" | "parentGridId"> = {},
+): TransactionalGrid => ({ id, columnCount, placements, ...options });
+
+const state = (...grids: readonly TransactionalGrid[]): TransactionalGridState => ({
+ grids,
+});
+
+const accept = (result: GridPreviewResult): GridTransaction => {
+ if (!result.accepted) throw new Error(`Expected preview to be accepted, received ${result.rejection.code}`);
+ return result.transaction;
+};
+
+const getGrid = (layout: TransactionalGridState, 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, 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, gridId: string) =>
+ Object.fromEntries(getGrid(layout, gridId).placements.map(({ id, x, y, w, h }) => [id, { x, y, w, h }]));
+
+const expectValidState = (layout: TransactionalGridState) => {
+ const allIds = new Set();
+
+ 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;
+ }>([
+ { 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");
+ }
+ }
+ }
+ });
+});
diff --git a/apps/nextjs/src/components/board/sections/grid/dnd/engine.ts b/apps/nextjs/src/components/board/sections/grid/dnd/engine.ts
new file mode 100644
index 000000000..e66aa88ce
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/grid/dnd/engine.ts
@@ -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 = (
+ state: TransactionalGridState,
+ input: BeginGridTransactionInput,
+): GridTransaction => {
+ 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 = (
+ transaction: GridTransaction,
+ input: GridMoveInput,
+ options: GridMoveOptions = {},
+): GridPreviewResult => {
+ 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 = (
+ transaction: GridTransaction,
+ input: GridResizeInput,
+): GridPreviewResult => {
+ 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 = (
+ transaction: GridTransaction,
+): TransactionalGridState => cloneGridState(transaction.preview);
+
+/** Restores the exact interaction-start snapshot. */
+export const cancelGridTransaction = (
+ transaction: GridTransaction,
+): TransactionalGridState => cloneGridState(transaction.snapshot);
+
+const previewSameGridMove = (
+ state: TransactionalGridState,
+ grid: TransactionalGrid,
+ 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 = (
+ placements: readonly TPlacement[],
+ grid: TransactionalGrid,
+) =>
+ 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 = (
+ state: TransactionalGridState,
+ sourceGrid: TransactionalGrid,
+ targetGrid: TransactionalGrid,
+ 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 = (
+ 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 = (
+ placement: TPlacement,
+ input: GridMoveInput,
+ grid: TransactionalGrid,
+) => {
+ 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 = (
+ input: GridResizeInput,
+ grid: TransactionalGrid,
+): 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 = (
+ placement: TPlacement,
+ direction: GridResizeDirection,
+ input: GridResizeInput,
+ constraints: ResolvedResizeConstraints,
+ grid: TransactionalGrid,
+): 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 = (
+ guards: readonly GridMoveGuard[],
+ context: Parameters>[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 = (
+ state: TransactionalGridState,
+ targetGridId: string,
+ activeId: string,
+) => {
+ let current: TransactionalGrid | undefined = findGrid(state, targetGridId);
+ const visited = new Set();
+ 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 = (
+ transaction: GridTransaction,
+ preview: TransactionalGridState,
+): GridPreviewResult => ({
+ accepted: true,
+ transaction: { ...transaction, preview },
+});
+
+const rejected = (
+ transaction: GridTransaction,
+ rejection: GridOperationRejection,
+): GridPreviewResult => ({ accepted: false, transaction, rejection });
+
+const fitsRowLimits = (state: TransactionalGridState) =>
+ state.grids.every((grid) => {
+ if (grid.maxRowCount === undefined || grid.maxRowCount === null) return true;
+ return getLayoutRowCount(grid.placements) <= grid.maxRowCount;
+ });
+
+const getMaximumRows = (grid: TransactionalGrid) =>
+ grid.maxRowCount ?? Number.MAX_SAFE_INTEGER;
+
+const cloneGridState = (
+ state: TransactionalGridState,
+): TransactionalGridState => ({
+ grids: state.grids.map((grid) => ({
+ ...grid,
+ placements: grid.placements.map((placement) => ({ ...placement })),
+ })),
+});
+
+const findGrid = (state: TransactionalGridState, gridId: string) =>
+ state.grids.find((grid) => grid.id === gridId);
+
+const getGrid = (state: TransactionalGridState, gridId: string) => {
+ const grid = findGrid(state, gridId);
+ if (!grid) throw new Error(`Grid "${gridId}" does not exist`);
+ return grid;
+};
+
+const getPlacement = (grid: TransactionalGrid, 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 = (state: TransactionalGridState, 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 = (state: TransactionalGridState) => {
+ const gridIds = new Set();
+ const placementIds = new Set();
+ const ownerIds = new Set();
+
+ 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();
+ let current: TransactionalGrid | 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 = (
+ placement: TPlacement,
+ grid: TransactionalGrid,
+) => {
+ 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`);
+ }
+};
diff --git a/apps/nextjs/src/components/board/sections/grid/dnd/index.ts b/apps/nextjs/src/components/board/sections/grid/dnd/index.ts
new file mode 100644
index 000000000..7c02a3c68
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/grid/dnd/index.ts
@@ -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";
diff --git a/apps/nextjs/src/components/board/sections/grid/dnd/types.ts b/apps/nextjs/src/components/board/sections/grid/dnd/types.ts
new file mode 100644
index 000000000..ec77c42df
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/grid/dnd/types.ts
@@ -0,0 +1,98 @@
+import type { GridPlacement } from "~/components/board/layout";
+
+/** A positioned grid with optional ownership metadata for nested containers. */
+export interface TransactionalGrid {
+ 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 {
+ grids: readonly TransactionalGrid[];
+}
+
+/**
+ * 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 {
+ activeId: string;
+ sourceGridId: string;
+ snapshot: TransactionalGridState;
+ preview: TransactionalGridState;
+}
+
+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 {
+ active: TPlacement;
+ sourceGrid: TransactionalGrid;
+ targetGrid: TransactionalGrid;
+ snapshot: TransactionalGridState;
+ /** 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 = (
+ context: GridMoveGuardContext,
+) => GridMoveGuardResult;
+
+export interface GridMoveOptions {
+ guards?: readonly GridMoveGuard[];
+}
+
+export type GridPreviewResult =
+ | {
+ accepted: true;
+ transaction: GridTransaction;
+ }
+ | {
+ accepted: false;
+ transaction: GridTransaction;
+ rejection: GridOperationRejection;
+ };
diff --git a/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx b/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx
index 6a560693e..d1caa633a 100644
--- a/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx
+++ b/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx
@@ -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) => {
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) => {
- 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) => {
- 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 (
- {isEditMode && }
+ {isEditorActive && (
+
+ )}
{children}
- {isEditMode && (
+ {isEditorActive && (
{t("item.moveResize.keyboard.instructions")}
@@ -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 => {
- 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);
+ });
diff --git a/apps/nextjs/src/components/board/sections/grid/grid-drop-swap.ts b/apps/nextjs/src/components/board/sections/grid/grid-drop-swap.ts
deleted file mode 100644
index 0afeadfe2..000000000
--- a/apps/nextjs/src/components/board/sections/grid/grid-drop-swap.ts
+++ /dev/null
@@ -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;
diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor-runtime.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor-runtime.tsx
new file mode 100644
index 000000000..7b89c9fcc
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/grid/grid-editor-runtime.tsx
@@ -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("idle");
+const GridResizePreviewContext = createContext(null);
+
+export const GridEditorRuntimeProvider = ({
+ children,
+ status,
+}: PropsWithChildren<{ status: GridEditorRuntimeStatus }>) => (
+ {children}
+);
+
+export const useGridEditorRuntimeStatus = () => useContext(GridEditorRuntimeContext);
+
+export const GridResizePreviewProvider = ({
+ children,
+ value,
+}: PropsWithChildren<{ value: GridResizePreview | null }>) => (
+ {children}
+);
+
+export const useGridResizePreview = (entryId: string) => {
+ const preview = useContext(GridResizePreviewContext);
+ return preview?.id === entryId ? preview : null;
+};
diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.css b/apps/nextjs/src/components/board/sections/grid/grid-editor.css
index 6b60f891c..5c2b9211a 100644
--- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css
+++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css
@@ -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;
}
}
diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx
index f8a9a450c..6123b5abf 100644
--- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx
+++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx
@@ -1,26 +1,51 @@
"use client";
-import type { CSSProperties } from "react";
-import { useCallback, useLayoutEffect, useMemo, useRef } from "react";
+import type { CSSProperties, PointerEvent as ReactPointerEvent, PropsWithChildren } from "react";
+import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
-import "gridstack/dist/gridstack.min.css"; // oxlint-disable-line import/no-unassigned-import
import "./grid-editor.css"; // oxlint-disable-line import/no-unassigned-import
-import type { AddRemoveFcn, GridItemHTMLElement, GridStackNode, GridStackWidget } from "gridstack";
-import { GridStack } from "gridstack";
+import type { Draggable as AbstractDraggable, Plugins } from "@dnd-kit/abstract";
+import { CollisionPriority } from "@dnd-kit/abstract";
+import { pointerIntersection } from "@dnd-kit/collision";
+import type { DragDropManager, Draggable as DomDraggable } from "@dnd-kit/dom";
+import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints, PointerSensor } from "@dnd-kit/dom";
+import { getEventCoordinates, getFrameTransform, isPointerEvent } from "@dnd-kit/dom/utilities";
+import type {
+ BeforeDragStartEvent,
+ CollisionEvent,
+ DragEndEvent,
+ DragMoveEvent,
+ DragOverEvent,
+ DragStartEvent,
+} from "@dnd-kit/react";
+import { DragDropProvider, DragOverlay, useDragDropManager, useDraggable, useDroppable } from "@dnd-kit/react";
-import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
+import { useCurrentLayout } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
-import { getLogicalTrackSize, LOGICAL_GRID_GAP, LOGICAL_GRID_PITCH } from "~/components/board/layout";
+import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize } from "~/components/board/layout";
+import type { GridPlacement } from "~/components/board/layout";
import { useSectionContext } from "../section-context";
-import { getDropSwapPlacements } from "./grid-drop-swap";
+import {
+ beginGridTransaction,
+ cancelGridTransaction,
+ commitGridTransaction,
+ getPointerProjectedShape,
+ getSnappedGridCoordinates,
+ getSnappedGridDelta,
+ previewGridMove,
+ previewGridResize,
+} from "./dnd";
+import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, TransactionalGridState } from "./dnd";
+import { GridResizePreviewProvider } from "./grid-editor-runtime";
import { useBoardGridPortalHost } from "./grid-portal-host";
-import { decorateGridResizeHandles } from "./grid-resize-handles";
-import type { SectionGridPlacement } from "./use-grid-layout-actions";
+import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions";
import { useGridLayoutActions } from "./use-grid-layout-actions";
-const AUTO_EXPAND_ROW_BUFFER = 8;
+const GRID_ENTRY_TYPE = "homarr-board-grid-entry";
+const GRID_TARGET_TYPE = "homarr-board-grid-target";
+const EDIT_ROW_BUFFER = 8;
interface GridEditorProps {
sectionId: string;
@@ -31,12 +56,632 @@ interface GridEditorProps {
className: string;
}
-/**
- * The only board module that imports GridStack. The library owns every
- * positioned shell, while one board-level portal host owns React content.
- * Moving a shell between independent grids therefore never reparents a
- * React-owned element.
- */
+interface GridEntryData {
+ kind: typeof GRID_ENTRY_TYPE;
+ sectionId: string;
+ placement: SectionGridPlacement;
+ label: string;
+}
+
+interface GridTargetData {
+ kind: typeof GRID_TARGET_TYPE;
+ sectionId: string;
+}
+
+interface RegisteredGrid {
+ id: string;
+ columnCount: number;
+ maxRowCount: number | null;
+ parentGridId: string | null;
+ ownerPlacementId: string | null;
+ placements: readonly SectionGridPlacement[];
+ depth: number;
+ element: HTMLElement;
+}
+
+interface GridInteraction {
+ activeId: string;
+ sourceGridId: string;
+ targetGridId: string | null;
+ targetPlacement: SectionGridPlacement | null;
+ state: TransactionalGridState;
+ mode: "drag" | "resize";
+ valid: boolean;
+ previewRevision: number;
+}
+
+interface ActiveTransaction {
+ transaction: GridTransaction;
+ label: string;
+ mode: "drag" | "resize";
+ valid: boolean;
+ targetGridId: string | null;
+ dragProjection: DragProjectionOrigin | null;
+ lastProcessedPointer: { x: number; y: number } | null;
+ lastPreviewKey: string | null;
+ previewRevision: number;
+}
+
+interface ResizeStartInput {
+ activeId: string;
+ sourceGridId: string;
+ label: string;
+}
+
+interface ResizePreviewInput {
+ direction: GridResizeDirection;
+ deltaColumns: number;
+ deltaRows: number;
+ minWidth: number;
+ minHeight: number;
+}
+
+interface BoardGridEditorContextValue {
+ interaction: GridInteraction | null;
+ registerGrid: (grid: RegisteredGrid) => () => void;
+ isDropTargetEligible: (source: AbstractDraggable, targetGridId: string) => boolean;
+ canAcceptDrop: (source: AbstractDraggable, targetGridId: string) => boolean;
+ beginResize: (input: ResizeStartInput) => boolean;
+ previewResize: (input: ResizePreviewInput) => boolean;
+ completeResize: () => void;
+ cancelResize: () => void;
+}
+
+const BoardGridEditorContext = createContext(null);
+
+const useBoardGridEditor = () => {
+ const value = useContext(BoardGridEditorContext);
+ if (!value) throw new Error("BoardGridEditorProvider is required");
+ return value;
+};
+
+/** One provider coordinates every root and nested grid on the board. */
+export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
+ const currentLayoutId = useCurrentLayout();
+ const t = useI18n();
+ const { announce } = useBoardGridPortalHost();
+ const { commitSectionGrids } = useGridLayoutActions();
+ const gridsRef = useRef