fix: refine dashboard interactions and widget options (#6597)

* fix: refine dashboard item interactions

* fix board interactions and widget options

* feat: improve bookmark selection

* fix: address dashboard review feedback
This commit is contained in:
Thomas authored and GitHub committed 2026-08-15 10:44:37 +02:00
1 parent b491d45fdc
commit 4505ce96c9
37 files changed
+824 -503

No files matched your search

@@ -1,15 +1,16 @@
import { useEffect, useRef, useState } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { WidgetDefinition } from "@homarr/widgets/definition";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-item";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { useOpenItemMoveModal } from "./item-move-modal";
@@ -36,7 +37,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
const t = useI18n();
const { openModal } = useModalAction(LazyWidgetEditModal);
const openMoveModal = useOpenItemMoveModal();
const { openConfirmModal } = useConfirmModal();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
@@ -95,16 +95,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
);
};
const openRemoveModal = () => {
openConfirmModal({
title: tItem("remove.title"),
children: tItem("remove.message"),
onConfirm: () => {
removeItem({ itemId: item.id });
},
});
};
return (
<Menu withinPortal position="right-start" arrowPosition="center" opened={isMenuOpen} onChange={setIsMenuOpen}>
<Menu.Target>
@@ -123,7 +113,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={128}>
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
<Menu.Item
leftSection={<IconPencil size={16} />}
onClick={openEditModal}
@@ -149,10 +138,11 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
</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}>
{tItem("action.remove")}
</Menu.Item>
<BoardRemoveConfirmationMenuItem
label={tItem("action.remove")}
confirmationLabel={tItem("remove.message")}
onConfirm={() => removeItem({ itemId: item.id })}
/>
</Menu.Dropdown>
</Menu>
);
@@ -75,14 +75,14 @@ export const ScaledBoardCanvas = ({
data-canvas-initial-width={initialAvailableWidth}
aria-label={label}
>
<Box className={classes.sizer} w={visualWidth} h={resolvedLogicalHeight * scale}>
<Box className={classes.sizer} style={{ width: visualWidth, height: resolvedLogicalHeight * scale }}>
<Box
ref={canvasRef}
className={classes.canvas}
w={logicalWidth}
style={{
"--board-canvas-inverse-scale": inverseScale,
"--board-canvas-ui-scale": uiScale,
width: logicalWidth,
zoom: scale,
}}
>
@@ -0,0 +1,39 @@
"use client";
import type { ReactNode } from "react";
import { useState } from "react";
import { Menu } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
interface BoardRemoveConfirmationMenuItemProps {
label: ReactNode;
confirmationLabel: ReactNode;
onConfirm: () => void;
}
export const BoardRemoveConfirmationMenuItem = ({
label,
confirmationLabel,
onConfirm,
}: BoardRemoveConfirmationMenuItemProps) => {
const [isConfirmationRequired, setIsConfirmationRequired] = useState(false);
return (
<Menu.Item
c="red.6"
leftSection={<IconTrash size={16} />}
closeMenuOnClick={isConfirmationRequired}
onBlur={() => setIsConfirmationRequired(false)}
onClick={() => {
if (isConfirmationRequired) {
onConfirm();
return;
}
setIsConfirmationRequired(true);
}}
>
{isConfirmationRequired ? confirmationLabel : label}
</Menu.Item>
);
};
@@ -1,6 +1,6 @@
import dynamic from "next/dynamic";
import { ActionIcon, Badge, Box, Button, Card } from "@mantine/core";
import { IconChevronDown, IconChevronUp, IconExternalLink, IconGripVertical } from "@tabler/icons-react";
import { IconChevronDown, IconChevronUp, IconExternalLink } from "@tabler/icons-react";
import combineClasses from "clsx";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -38,7 +38,7 @@ export const BoardContainerSection = ({ section }: Props) => {
});
const label = options.title.trim() || t("untitled");
const contentId = `board-container-${section.id}-content`;
const labelLeft = isEditMode ? 40 : 8;
const labelLeft = 8;
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
return (
@@ -101,22 +101,6 @@ export const BoardContainerSection = ({ section }: Props) => {
)}
</Button>
)}
{isEditMode && (
<ActionIcon
component="span"
pos="absolute"
top={4}
left={4}
style={{ zIndex: 10, cursor: "grab", touchAction: "none" }}
variant="default"
size={24}
radius="sm"
data-grid-container-drag-handle
aria-hidden="true"
>
<IconGripVertical size={16} />
</ActionIcon>
)}
{!isVisuallyCollapsed && (!options.collapsible || isEditMode) && options.showLabel && options.title && (
<Badge
pos="absolute"
@@ -162,6 +146,7 @@ export const BoardContainerSection = ({ section }: Props) => {
id={contentId}
className={classes.containerBody}
h="100%"
data-board-container-body
data-collapsed={isVisuallyCollapsed ? "true" : "false"}
aria-hidden={isVisuallyCollapsed}
inert={isVisuallyCollapsed}
@@ -1,28 +1,27 @@
import { ActionIcon, Menu } from "@mantine/core";
import { IconArrowsMove, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { IconArrowsMove, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
import { BoardRemoveConfirmationMenuItem } from "../../remove-confirmation-menu-item";
import { useOpenItemMoveModal } from "../../items/item-move-modal";
import { useSectionContext } from "../section-context";
import { useContainerActions } from "./container-actions";
import { ContainerEditModal } from "./container-edit-modal";
export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem }) => {
const t = useI18n();
const tContainer = useScopedI18n("section.container");
const tItem = useScopedI18n("item");
const { openModal } = useModalAction(ContainerEditModal);
const openMoveModal = useOpenItemMoveModal();
const { updateContainer, removeContainer } = useContainerActions();
const { openConfirmModal } = useConfirmModal();
const [isEditMode] = useEditMode();
const { section: parentSection } = useSectionContext();
const label = section.options.title || tContainer("action.create");
const menuRightOffset = parentSection.kind === "container" ? 44 : 4;
const menuLeftOffset = parentSection.kind === "container" ? 36 : 4;
if (!isEditMode) return null;
@@ -33,14 +32,6 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
});
};
const openRemoveModal = () => {
openConfirmModal({
title: tContainer("remove.title"),
children: tContainer("remove.message"),
onConfirm: () => removeContainer({ id: section.id }),
});
};
return (
<Menu withinPortal position="right-start" arrowPosition="center">
<Menu.Target>
@@ -49,8 +40,8 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
size={24}
radius="sm"
pos="absolute"
top={-24}
right={menuRightOffset}
top={4}
left={menuLeftOffset}
style={{ zIndex: 10 }}
aria-label={tItem("menu.label.settingsFor", { name: label })}
>
@@ -58,7 +49,6 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={128}>
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
{tItem("action.edit")}
</Menu.Item>
@@ -69,10 +59,11 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
{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}>
{tContainer("action.remove")}
</Menu.Item>
<BoardRemoveConfirmationMenuItem
label={tContainer("action.remove")}
confirmationLabel={tContainer("remove.message")}
onConfirm={() => removeContainer({ id: section.id })}
/>
</Menu.Dropdown>
</Menu>
);
@@ -22,7 +22,7 @@ interface TestPlacement {
y: number;
w: number;
h: number;
kind: "container" | "item";
type: "section" | "item";
marker?: string;
}
@@ -32,8 +32,8 @@ const placement = (
y: number,
w = 1,
h = 1,
kind: TestPlacement["kind"] = "item",
): TestPlacement => ({ id, x, y, w, h, kind, marker: `metadata:${id}` });
type: TestPlacement["type"] = "item",
): TestPlacement => ({ id, x, y, w, h, type, marker: `metadata:${id}` });
const grid = (
id: string,
@@ -302,6 +302,55 @@ describe("same-grid movement", () => {
expect(getPlacement(moved.preview, "far")).toMatchObject({ x: 3, y: 8 });
});
test("keeps a container fixed when an item is moved onto it", () => {
const original = state(grid("root", 4, [placement("active", 0, 0), placement("container", 1, 0, 2, 2, "section")]));
const moved = accept(
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
targetGridId: "root",
x: 1,
y: 0,
}),
);
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 1, y: 0, w: 2, h: 2 });
expect(getPlacement(moved.preview, "active")).toMatchObject({ x: 1, y: 2 });
});
test("lets an active container push an item", () => {
const original = state(
grid("root", 4, [placement("container", 0, 0, 2, 2, "section"), placement("item", 2, 0, 2)]),
);
const moved = accept(
previewGridMove(beginGridTransaction(original, { activeId: "container" }), {
targetGridId: "root",
x: 2,
y: 0,
}),
);
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 2, y: 0, w: 2, h: 2 });
expect(getPlacement(moved.preview, "item")).toMatchObject({ x: 0, y: 0 });
});
test("does not let an active container displace another container", () => {
const original = state(
grid("root", 4, [
placement("active-container", 0, 0, 2, 2, "section"),
placement("fixed-container", 2, 0, 2, 2, "section"),
]),
);
const moved = accept(
previewGridMove(beginGridTransaction(original, { activeId: "active-container" }), {
targetGridId: "root",
x: 2,
y: 0,
}),
);
expect(getPlacement(moved.preview, "fixed-container")).toMatchObject({ x: 2, y: 0 });
expect(getPlacement(moved.preview, "active-container")).toMatchObject({ x: 2, y: 2 });
});
test.each([
{ requested: { x: -20, y: -10 }, expected: { x: 0, y: 0 } },
{ requested: { x: 99, y: 99 }, expected: { x: 2, y: 3 } },
@@ -408,9 +457,26 @@ describe("cross-grid movement", () => {
expect(getPlacement(second.preview, "second-neighbor")).toMatchObject({ x: 0, y: 1 });
});
test("keeps a destination container fixed when an item enters its grid", () => {
const original = state(
grid("source", 4, [placement("active", 0, 0)]),
grid("target", 4, [placement("container", 1, 0, 2, 2, "section")]),
);
const moved = accept(
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
targetGridId: "target",
x: 1,
y: 0,
}),
);
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 1, y: 0, w: 2, h: 2 });
expect(getPlacement(moved.preview, "active")).toMatchObject({ x: 1, y: 2 });
});
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("source", 3, [placement("container", 0, 0, 2, 2, "section")]),
grid("target", 3, []),
grid("container-content", 2, [placement("child", 0, 0)], {
parentGridId: "source",
@@ -421,7 +487,7 @@ describe("cross-grid movement", () => {
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(getPlacement(moved.preview, "container")).toMatchObject({ x: 1, y: 2, type: "section" });
expect(cancelGridTransaction(moved)).toEqual(original);
expect(commitGridTransaction(moved)).toEqual(moved.preview);
expect(commitGridTransaction(moved)).not.toBe(moved.preview);
@@ -429,8 +495,8 @@ describe("cross-grid movement", () => {
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")], {
grid("root", 4, [placement("container", 0, 0, 2, 2, "section")]),
grid("child", 3, [placement("nested-container", 0, 0, 2, 2, "section")], {
parentGridId: "root",
ownerPlacementId: "container",
}),
@@ -150,7 +150,8 @@ const previewSameGridMove = <TPlacement extends GridPlacement>(
const collisions = grid.placements.filter(
(placement) => placement.id !== active.id && doGridPlacementsOverlap(placement, nextActive),
);
const displaced = collisions.length === 1 ? collisions[0] : undefined;
const displacedCandidate = collisions.length === 1 ? collisions[0] : undefined;
const displaced = displacedCandidate && !isContainerPlacement(displacedCandidate) ? displacedCandidate : undefined;
const swapped = displaced
? grid.placements.map((placement) => {
@@ -209,8 +210,10 @@ const previewCrossGridMove = <TPlacement extends GridPlacement>(
};
/**
* Pins the active placement, keeps every non-colliding item at its persisted
* coordinates, and pushes only the deterministic collision chain downward.
* Keeps stationary containers fixed, resolves the active placement around
* those obstacles, and then pushes only the deterministic item collision chain
* downward. A container can still be the active placement and push items, but
* no active placement can displace another container.
*/
export const resolvePinnedGridCollisions = <TPlacement extends GridPlacement>(
placements: readonly TPlacement[],
@@ -223,8 +226,24 @@ export const resolvePinnedGridCollisions = <TPlacement extends GridPlacement>(
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);
const fixedContainers = normalized.filter(
(placement) => placement.id !== pinned.id && isContainerPlacement(placement),
);
const placed: TPlacement[] = [...fixedContainers];
let resolvedPinned = pinned;
for (;;) {
const collisions = placed.filter((placement) => doGridPlacementsOverlap(resolvedPinned, placement));
if (collisions.length === 0) break;
resolvedPinned = {
...resolvedPinned,
y: Math.max(...collisions.map((placement) => placement.y + placement.h)),
};
}
placed.push(resolvedPinned);
const candidates = normalized
.filter((placement) => placement.id !== pinned.id && !isContainerPlacement(placement))
.toSorted(comparePlacements);
for (const candidate of candidates) {
let resolved = candidate;
for (;;) {
@@ -245,6 +264,8 @@ export const resolvePinnedGridCollisions = <TPlacement extends GridPlacement>(
const comparePlacements = (first: GridPlacement, second: GridPlacement) =>
first.y - second.y || first.x - second.x || first.id.localeCompare(second.id);
const isContainerPlacement = (placement: GridPlacement) => "type" in placement && placement.type === "section";
const clampMovePlacement = <TPlacement extends GridPlacement>(
placement: TPlacement,
input: GridMoveInput,
@@ -14,7 +14,7 @@ export {
getSnappedGridDelta,
} from "./coordinates";
export type { DragProjectionOrigin } from "./coordinates";
export { getPreferredNestedExitTargetId } from "./targeting";
export { getGridTargetFallbackIds, getPreferredNestedExitTargetId } from "./targeting";
export type { GridTargetGeometry } from "./targeting";
export type {
BeginGridTransactionInput,
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import type { GridTargetGeometry } from "./targeting";
import { getPreferredNestedExitTargetId } from "./targeting";
import { getGridTargetFallbackIds, getPreferredNestedExitTargetId } from "./targeting";
const grid = (
id: string,
@@ -66,3 +66,30 @@ describe("nested grid exit targeting", () => {
).toBe("container");
});
});
describe("nested grid fallback targeting", () => {
const grids = [
grid("canvas", { left: 0, top: 0, width: 1200, height: 800 }),
grid("container", { left: 200, top: 100, width: 600, height: 500 }, "canvas"),
];
test("falls back from a blocked container to its parent canvas", () => {
expect(
getGridTargetFallbackIds({
grids,
targetGridId: "container",
isEligible: alwaysEligible,
}),
).toEqual(["container", "canvas"]);
});
test("does not cross an ineligible hierarchy boundary", () => {
expect(
getGridTargetFallbackIds({
grids,
targetGridId: "container",
isEligible: (gridId) => gridId !== "canvas",
}),
).toEqual(["container"]);
});
});
@@ -11,6 +11,12 @@ export interface GridTargetGeometry {
rectangle: RectangleLike;
}
interface GridTargetFallbackInput {
grids: readonly Pick<GridTargetGeometry, "id" | "parentGridId">[];
targetGridId: string;
isEligible: (gridId: string) => boolean;
}
interface PreferredNestedExitTargetInput {
grids: readonly GridTargetGeometry[];
pointerTargetId: string;
@@ -48,6 +54,26 @@ export const getPreferredNestedExitTargetId = ({
return current.id;
};
/**
* Tries the visible nested grid first, then its ancestors. A full or too-small
* container must not trap an item that can still move in the parent grid.
*/
export const getGridTargetFallbackIds = ({ grids, targetGridId, isEligible }: GridTargetFallbackInput) => {
const byId = new Map(grids.map((grid) => [grid.id, grid]));
const ids: string[] = [];
const visited = new Set<string>();
let current = byId.get(targetGridId);
while (current && !visited.has(current.id)) {
visited.add(current.id);
if (!isEligible(current.id)) break;
ids.push(current.id);
current = current.parentGridId ? byId.get(current.parentGridId) : undefined;
}
return ids;
};
const isSameGridOrAncestor = (
candidateId: string,
source: GridTargetGeometry,
@@ -93,6 +93,11 @@ body[data-board-grid-interacting] .board-grid-placeholder {
overflow: visible;
}
.board-grid-content-mount {
position: absolute;
inset: 0;
}
body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="true"] > .board-grid-content-mount {
overflow: clip;
}
@@ -124,14 +129,52 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
}
.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;
box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
}
.board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"] {
box-shadow: inset 0 0 0 2px var(--mantine-color-red-filled);
}
[data-section-kind="empty"][data-rail-placement="main"] > .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;
box-shadow: none;
}
[data-section-kind="empty"][data-rail-placement="main"]
> .board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"] {
outline-color: var(--mantine-color-red-filled);
}
[data-board-container-collapsed]
> [data-board-container-body]
> [data-section-id]
> .board-grid-editor[data-dnd-drop-target="true"] {
box-shadow: none;
}
[data-board-container-collapsed]:has(
> [data-board-container-body] > [data-section-id] > .board-grid-editor[data-dnd-drop-target="true"]
)::after {
position: absolute;
inset: 0;
z-index: 19;
box-sizing: border-box;
content: "";
border: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
border-radius: inherit;
pointer-events: none;
}
[data-board-container-collapsed]:has(
> [data-board-container-body]
> [data-section-id]
> .board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"]
)::after {
border-color: var(--mantine-color-red-filled);
}
.board-grid-resize-handle {
position: absolute;
z-index: 20;
@@ -143,10 +186,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
touch-action: none;
}
.board-grid-resize-handle[data-grid-resize-disabled="true"] {
pointer-events: none;
}
body[data-board-grid-interacting] .board-grid-resize-handle {
pointer-events: none;
}
@@ -155,16 +194,6 @@ body[data-board-grid-interacting] .board-grid-resize-handle::after {
opacity: 0;
}
.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;
}
.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;
}
.board-grid-resize-handle::after {
position: absolute;
inset: 50% auto auto 50%;
@@ -178,8 +207,8 @@ body[data-board-grid-interacting] .board-grid-resize-handle::after {
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 {
.board-grid-entry:hover > .board-grid-resize-handle::after,
.board-grid-resize-handle:focus-visible::after {
opacity: 1;
}
@@ -270,7 +299,7 @@ body[data-board-grid-interacting] .board-grid-resize-handle::after {
}
@media (hover: none), (pointer: coarse) {
.board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after {
.board-grid-resize-handle::after {
opacity: 1;
}
}
@@ -37,7 +37,6 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import { getLogicalItemStyle, getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
import { useSectionContext } from "../section-context";
import {
beginGridTransaction,
@@ -45,6 +44,7 @@ import {
commitGridTransaction,
getContinuousGridDelta,
getContinuousResizePlacement,
getGridTargetFallbackIds,
getPreferredNestedExitTargetId,
getPointerProjectedShape,
getSnappedGridCoordinates,
@@ -56,7 +56,6 @@ import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, Transa
import type { DndGridEntryProps } from "./grid-entry-memo";
import { areDndGridEntryPropsEqual } from "./grid-entry-memo";
import { getGridDepth } from "./grid-depth";
import { observeGridDragHandle } from "./grid-drag-handle";
import { createGridInteractionStore } from "./grid-interaction-store";
import { useBoardGridPortalHost } from "./grid-portal-host";
import type { GridInteraction } from "./grid-preview-layer";
@@ -195,7 +194,10 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
document.body.removeAttribute("data-board-grid-interacting");
}, [interactionStore]);
useEffect(() => clearInteraction, [clearInteraction, currentLayoutId]);
useLayoutEffect(() => {
clearInteraction();
return clearInteraction;
}, [board.items, board.sections, clearInteraction, currentLayoutId]);
const registerGrid = useCallback((grid: RegisteredGrid) => {
gridsRef.current.set(grid.id, grid);
@@ -348,66 +350,56 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
return;
}
const coordinates = getSnappedGridCoordinates({
dragShape: shape,
target: targetGrid.element.getBoundingClientRect(),
columnCount: targetGrid.columnCount,
const fallbackIds = getGridTargetFallbackIds({
grids: Array.from(gridsRef.current.values()),
targetGridId: targetGrid.id,
isEligible: (targetGridId) => isDropTargetEligible(source, targetGridId),
});
if (!coordinates) {
showInvalidPreview(targetGrid.id, `invalid:${targetGrid.id}:coordinates`);
return;
}
const { x, y } = coordinates;
const targetAcceptsSource = canAcceptDrop(source, targetGrid.id);
const previewKey = `${targetGrid.id}:${x}:${y}:${targetAcceptsSource ? "candidate" : "invalid"}`;
if (active.lastPreviewKey === previewKey) return;
if (!targetAcceptsSource) {
showInvalidPreview(targetGrid.id, previewKey);
return;
}
let invalidPreviewKey = `invalid:${targetGrid.id}:no-candidate`;
const result = previewGridMove(active.transaction, { targetGridId: targetGrid.id, x, y });
if (
result.accepted &&
doesPreviewDisplaceNestedGridOwner(
active.transaction.snapshot,
result.transaction.preview,
targetGrid.id,
active.transaction.activeId,
)
) {
showInvalidPreview(targetGrid.id, `${previewKey}:nested-owner`);
return;
}
active.lastPreviewKey = previewKey;
active.targetGridId = targetGrid.id;
active.valid = result.accepted;
if (!result.accepted) {
for (const candidateId of fallbackIds) {
const candidate = gridsRef.current.get(candidateId);
if (!candidate) continue;
const coordinates = getSnappedGridCoordinates({
dragShape: shape,
target: candidate.element.getBoundingClientRect(),
columnCount: candidate.columnCount,
});
if (!coordinates) {
invalidPreviewKey = `invalid:${candidate.id}:coordinates`;
continue;
}
const { x, y } = coordinates;
const targetAcceptsSource = canAcceptDrop(source, candidate.id);
const previewKey = `${candidate.id}:${x}:${y}:${targetAcceptsSource ? "candidate" : "invalid"}`;
invalidPreviewKey = previewKey;
if (!targetAcceptsSource) continue;
const result = previewGridMove(active.transaction, { targetGridId: candidate.id, x, y });
if (!result.accepted) continue;
if (active.lastPreviewKey === previewKey) return;
active.lastPreviewKey = previewKey;
active.targetGridId = candidate.id;
active.valid = true;
active.transaction = result.transaction;
const targetPlacement = result.transaction.preview.grids
.find((grid) => grid.id === candidate.id)
?.placements.find((placement) => placement.id === sourceData.placement.id);
publishPreview({
activeId: active.transaction.activeId,
sourceGridId: active.transaction.sourceGridId,
targetGridId: targetGrid.id,
targetPlacement: null,
state: cancelGridTransaction(active.transaction),
targetGridId: candidate.id,
targetPlacement: targetPlacement ?? null,
state: result.transaction.preview,
mode: "drag",
valid: false,
valid: true,
});
return;
}
active.transaction = result.transaction;
const targetPlacement = result.transaction.preview.grids
.find((grid) => grid.id === targetGrid.id)
?.placements.find((placement) => placement.id === sourceData.placement.id);
publishPreview({
activeId: active.transaction.activeId,
sourceGridId: active.transaction.sourceGridId,
targetGridId: targetGrid.id,
targetPlacement: targetPlacement ?? null,
state: result.transaction.preview,
mode: "drag",
valid: true,
});
showInvalidPreview(targetGrid.id, invalidPreviewKey);
},
[canAcceptDrop, interactionStore, isDropTargetEligible],
);
@@ -762,9 +754,9 @@ export default function GridEditor({
data: { kind: GRID_TARGET_TYPE, sectionId },
collisionDetector: pointerIntersection,
collisionPriority: CollisionPriority.Highest + depth,
// `accept` only removes the active container's own subtree. Size limits
// are validated after collision so an invalid nested target cannot fall
// through to an accepting parent grid underneath the pointer.
// `accept` removes only the active container's own subtree. Size limits
// are evaluated during preview targeting, which can fall back from an
// invalid nested grid to an eligible parent.
accept: (source) => isDropTargetEligible(source, sectionId),
});
@@ -852,9 +844,8 @@ const DndGridEntryComponent = ({
maxRowCount,
registerElement,
}: DndGridEntryProps) => {
const { acquireContainer } = useBoardGridPortalHost();
const { acquireContainer, structureRevision } = useBoardGridPortalHost();
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
const [isSectionChromeActive, setIsSectionChromeActive] = useState(false);
const mountRef = useRef<HTMLDivElement>(null);
const shellRef = useRef<HTMLDivElement>(null);
const placementRef = useRef(placement);
@@ -908,8 +899,9 @@ const DndGridEntryComponent = ({
shellRef.current = element;
registerElement(placement.id, element);
draggableRef(element);
handleRef(element);
},
[draggableRef, placement.id, registerElement],
[draggableRef, handleRef, placement.id, registerElement],
);
useEffect(() => () => resizeOutlineController.destroy(), [resizeOutlineController]);
@@ -918,35 +910,12 @@ const DndGridEntryComponent = ({
const mount = mountRef.current;
if (!mount) return;
const container = acquireContainer(placement.id);
mount.appendChild(container);
mount.replaceChildren(container);
const handleSelector =
placement.type === "section" ? "[data-grid-container-drag-handle]" : "[data-editor-grid-entry]";
return observeGridDragHandle(container, handleSelector, handleRef);
}, [acquireContainer, handleRef, placement.id, placement.type]);
useLayoutEffect(() => {
const shell = shellRef.current;
if (placement.type !== "section" || !shell) return;
const handlePointerDownCapture = (event: PointerEvent) => {
if (!(event.target instanceof Element)) return;
const grip = event.target.closest("[data-grid-container-drag-handle]");
if (grip) {
const owner = grip.closest('.board-grid-entry[data-grid-item-type="section"]');
setIsSectionChromeActive(owner === shell);
return;
}
const resizeHandle = event.target.closest(".board-grid-resize-handle");
if (!resizeHandle || resizeHandle.parentElement !== shell) {
setIsSectionChromeActive(false);
}
return () => {
if (container.parentElement === mount) container.remove();
};
shell.addEventListener("pointerdown", handlePointerDownCapture, { capture: true });
return () => shell.removeEventListener("pointerdown", handlePointerDownCapture, { capture: true });
}, [placement.type]);
}, [acquireContainer, placement.id, structureRevision]);
return (
<div
@@ -959,7 +928,6 @@ const DndGridEntryComponent = ({
data-grid-y={placement.y}
data-grid-w={placement.w}
data-grid-h={placement.h}
data-grid-section-chrome-active={placement.type === "section" ? String(isSectionChromeActive) : undefined}
data-dnd-drag-source={isDragSource ? "true" : undefined}
data-dnd-dropping={isDropping ? "true" : undefined}
>
@@ -968,7 +936,6 @@ const DndGridEntryComponent = ({
sectionId={sectionId}
placement={placement}
label={label}
disabled={placement.type === "section" && !isSectionChromeActive}
columnCount={columnCount}
maxRowCount={maxRowCount}
beginResize={beginResize}
@@ -987,7 +954,6 @@ interface GridResizeHandlesProps {
sectionId: string;
placement: SectionGridPlacement;
label: string;
disabled: boolean;
columnCount: number;
maxRowCount: number | null;
beginResize: BoardGridEditorActionsContextValue["beginResize"];
@@ -1003,7 +969,6 @@ const GridResizeHandles = ({
sectionId,
placement,
label,
disabled,
columnCount,
maxRowCount,
beginResize,
@@ -1083,15 +1048,7 @@ const GridResizeHandles = ({
const handlePointerDown = (event: ReactPointerEvent<HTMLSpanElement>, direction: GridResizeDirection) => {
const visualScale = getEntryVisualScale(event.currentTarget.parentElement, placement);
if (
disabled ||
!event.isPrimary ||
event.button !== 0 ||
pointerStartRef.current ||
!visualScale ||
!start(direction)
)
return;
if (!event.isPrimary || event.button !== 0 || pointerStartRef.current || !visualScale || !start(direction)) return;
event.preventDefault();
event.stopPropagation();
try {
@@ -1150,7 +1107,6 @@ const GridResizeHandles = ({
key={direction}
className="board-grid-resize-handle"
data-grid-resize-handle={direction}
data-grid-resize-disabled={disabled ? "true" : undefined}
aria-hidden="true"
onPointerDown={(event) => handlePointerDown(event, direction)}
onPointerMove={handlePointerMove}
@@ -1281,40 +1237,17 @@ const getPreferredRegisteredGridTarget = (
return registeredGrids.find((grid) => grid.id === preferredId) ?? pointerTarget;
};
const doesPreviewDisplaceNestedGridOwner = (
snapshot: TransactionalGridState<SectionGridPlacement>,
preview: TransactionalGridState<SectionGridPlacement>,
targetGridId: string,
activeId: string,
) => {
const ownerIds = new Set(
snapshot.grids
.filter((grid) => grid.parentGridId === targetGridId && grid.ownerPlacementId !== activeId)
.flatMap((grid) => (grid.ownerPlacementId ? [grid.ownerPlacementId] : [])),
);
if (ownerIds.size === 0) return false;
const original = snapshot.grids.find((grid) => grid.id === targetGridId);
const candidate = preview.grids.find((grid) => grid.id === targetGridId);
if (!original || !candidate) return false;
const originalById = new Map(original.placements.map((placement) => [placement.id, placement]));
return candidate.placements.some((placement) => {
const previous = originalById.get(placement.id);
return Boolean(previous && ownerIds.has(placement.id) && !areGridPlacementCoordinatesEqual(previous, placement));
});
};
const areGridPlacementCoordinatesEqual = (first: GridPlacement, second: GridPlacement) =>
first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
const preventGridDragActivation = (event: PointerEvent, source: DomDraggable) => {
if (document.body.hasAttribute("data-board-grid-interacting")) return true;
const target = event.target;
if (!(target instanceof Element) || target === source.handle) return false;
const activationElement = source.handle ?? source.element;
if (!(target instanceof Element) || target === activationElement) return false;
const targetShell = target.closest(".board-grid-entry");
if (targetShell && targetShell !== activationElement) return true;
const interactive = target.closest(
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag],.board-grid-resize-handle',
);
return Boolean(interactive && source.handle?.contains(interactive));
return Boolean(interactive && activationElement?.contains(interactive));
};
const getEntryVisualScale = (element: HTMLElement | null, placement: SectionGridPlacement) => {
@@ -12,7 +12,7 @@ import { useEditMode } from "@homarr/boards/edit-mode";
import { getRootSectionLane } from "@homarr/definitions";
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
import { getBoardLaneColumnCount } from "~/components/board/layout";
import { getBoardLaneColumnCount, normalizeGridPlacement } from "~/components/board/layout";
import { SectionContentItem } from "../content";
import { SectionProvider } from "../section-context";
import { useSectionItems } from "../use-section-items";
@@ -23,6 +23,7 @@ interface GridPortalHostContextValue {
integrations: RouterOutputs["integration"]["all"] | undefined;
containers: ReadonlyMap<string, HTMLElement>;
acquireContainer: (id: string) => HTMLElement;
structureRevision: string;
}
const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(null);
@@ -50,6 +51,7 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
() => new Set([...board.items.map((item) => item.id), ...board.sections.map((section) => section.id)]),
[board.items, board.sections],
);
const structureRevision = useMemo(() => [...liveEntryIds].toSorted().join(":"), [liveEntryIds]);
const acquireContainer = useCallback((id: string) => {
const existing = containersRef.current.get(id);
@@ -81,8 +83,9 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
integrations,
containers,
acquireContainer,
structureRevision,
}),
[acquireContainer, announce, containers, integrations],
[acquireContainer, announce, containers, integrations, structureRevision],
);
return (
@@ -143,26 +146,23 @@ const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: G
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const { items, innerSections } = useSectionItems(ownerSectionId);
const persistedEntry = [...items, ...innerSections].find((candidate) => candidate.id === itemId);
const entry = persistedEntry;
const rawSection = board.sections.find((section) => section.id === ownerSectionId);
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
const persistedSection = toGridSection(rawSection, currentLayoutId);
const section = persistedSection;
const section = currentLayout ? getRenderedSection(board, currentLayout, ownerSectionId) : null;
if (!entry || !section || !currentLayout) return null;
if (!section || !currentLayout) return null;
const columnCount = getSectionColumnCount(section, currentLayout);
if (columnCount < 1) return null;
const renderedItems = items.map((item) => normalizePortalEntry(item, columnCount));
const renderedInnerSections = innerSections.map((innerSection) => normalizePortalEntry(innerSection, columnCount));
const entry = [...renderedItems, ...renderedInnerSections].find((candidate) => candidate.id === itemId);
if (!entry) return null;
const columnCount =
section.kind === "container"
? section.width
: section.kind === "empty"
? getBoardLaneColumnCount(currentLayout, getRootSectionLane(section.xOffset))
: currentLayout.columnCount;
const configuredMaxRowCount = section.kind === "container" ? section.height : null;
const contentRowCount = Math.max(
1,
...items.map((item) => item.yOffset + item.height),
...innerSections.map((innerSection) => innerSection.yOffset + innerSection.height),
...renderedItems.map((item) => item.yOffset + item.height),
...renderedInnerSections.map((innerSection) => innerSection.yOffset + innerSection.height),
);
const maxRowCount = configuredMaxRowCount === null ? null : Math.max(configuredMaxRowCount, contentRowCount);
@@ -170,8 +170,8 @@ const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: G
<SectionProvider
value={{
section,
items,
innerSections,
items: renderedItems,
innerSections: renderedInnerSections,
integrations,
columnCount,
maxRowCount,
@@ -183,23 +183,66 @@ const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: G
);
};
const toGridSection = (
section: Section | undefined,
layoutId: string,
const getRenderedSection = (
board: ReturnType<typeof useRequiredBoard>,
currentLayout: ReturnType<typeof useRequiredBoard>["layouts"][number],
sectionId: string,
visited = new Set<string>(),
): Exclude<Section, { kind: "container" }> | ContainerSectionItem | null => {
const section = board.sections.find((candidate) => candidate.id === sectionId);
if (!section) return null;
if (section.kind !== "container") return section;
if (visited.has(section.id)) return null;
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
const layout = section.layouts.find((candidate) => candidate.layoutId === currentLayout.id);
if (!layout) return null;
const parentSection = getRenderedSection(
board,
currentLayout,
layout.parentSectionId,
new Set(visited).add(section.id),
);
if (!parentSection) return null;
const parentColumnCount = getSectionColumnCount(parentSection, currentLayout);
if (parentColumnCount < 1) return null;
const normalized = normalizePortalEntry(layout, parentColumnCount);
return {
...section,
...layout,
...normalized,
type: "section",
};
};
const getSectionColumnCount = (
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem,
currentLayout: ReturnType<typeof useRequiredBoard>["layouts"][number],
) =>
section.kind === "container"
? section.width
: section.kind === "empty"
? getBoardLaneColumnCount(currentLayout, getRootSectionLane(section.xOffset))
: currentLayout.columnCount;
const normalizePortalEntry = <
TEntry extends { id?: string; xOffset: number; yOffset: number; width: number; height: number },
>(
entry: TEntry,
columnCount: number,
) => {
const placement = normalizeGridPlacement(
{ id: entry.id ?? "portal-entry", x: entry.xOffset, y: entry.yOffset, w: entry.width, h: entry.height },
columnCount,
);
return {
...entry,
xOffset: placement.x,
yOffset: placement.y,
width: placement.w,
height: placement.h,
};
};
const getOwnerSectionId = (board: ReturnType<typeof useRequiredBoard>, layoutId: string, entryId: string) => {
const item = board.items.find((candidate) => candidate.id === entryId);
if (item) {
@@ -172,8 +172,10 @@ export const SectionGrid = ({
<Box
{...canvasAttributes}
className={combineClasses(classes.viewport, className)}
w={logicalWidth}
h={`var(--board-grid-drag-height, ${logicalHeight}px)`}
style={{
width: logicalWidth,
height: `var(--board-grid-drag-height, ${logicalHeight}px)`,
}}
data-section-id={section.id}
data-section-kind={section.kind}
data-rail-placement={railPlacement}