feat: rebuild board grid with dnd-kit

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

No files matched your search

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