diff --git a/apps/nextjs/src/components/board/items/item-content.module.css b/apps/nextjs/src/components/board/items/item-content.module.css
index 7100f3197..7ec4b3a16 100644
--- a/apps/nextjs/src/components/board/items/item-content.module.css
+++ b/apps/nextjs/src/components/board/items/item-content.module.css
@@ -11,6 +11,19 @@
border-color: var(--border-color) !important;
}
+.settingsButton {
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 120ms ease;
+}
+
+:global([data-type="item"]:hover) .settingsButton,
+:global([data-type="item"]:focus-within) .settingsButton,
+.settingsButton[data-menu-open="true"] {
+ opacity: 1;
+ pointer-events: auto;
+}
+
.editModeInertContent {
display: contents;
}
@@ -39,3 +52,9 @@
opacity: 1;
pointer-events: auto;
}
+
+@media (prefers-reduced-motion: reduce) {
+ .settingsButton {
+ transition: none;
+ }
+}
diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx
index 0a28730b0..15d64af7a 100644
--- a/apps/nextjs/src/components/board/items/item-content.tsx
+++ b/apps/nextjs/src/components/board/items/item-content.tsx
@@ -25,7 +25,6 @@ import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { SectionItem } from "~/app/[locale]/boards/_types";
-import { useBoardSelection } from "../selection/board-selection-context";
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
import { useAdvancedFocus } from "../advanced-focus/context";
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
@@ -60,18 +59,6 @@ type BoardItemCardProps = CardProps & {
const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => {
const board = useRequiredBoard();
- const [isEditMode] = useEditMode();
- const { isSelected, toggleSelectItem, selectedItemIds } = useBoardSelection();
- const selected = isSelected(item.id);
-
- const handleClickCapture = (event: React.MouseEvent) => {
- if (!isEditMode) return;
- if (event.metaKey || event.ctrlKey || (selectedItemIds.size > 0 && !event.shiftKey)) {
- event.preventDefault();
- event.stopPropagation();
- toggleSelectItem(item.id);
- }
- };
return (
0),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
- {selected && (
-
-
- ✓
-
-
- )}
{children}
);
diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx
index 0679edde3..55e998125 100644
--- a/apps/nextjs/src/components/board/items/item-menu.tsx
+++ b/apps/nextjs/src/components/board/items/item-menu.tsx
@@ -14,6 +14,7 @@ import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-ite
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { useOpenItemMoveModal } from "./item-move-modal";
+import itemContentClasses from "./item-content.module.css";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
interface BoardItemMenuProps {
@@ -105,9 +106,11 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
size={24}
radius="sm"
pos="absolute"
- top={12}
- right={8}
+ top={4}
+ right={4}
style={{ zIndex: 30 }}
+ className={itemContentClasses.settingsButton}
+ data-menu-open={isMenuOpen || undefined}
data-board-widget-settings
aria-label={tItem("menu.label.settingsFor", { name: label })}
>
diff --git a/apps/nextjs/src/components/board/items/item-select-modal.spec.tsx b/apps/nextjs/src/components/board/items/item-select-modal.spec.tsx
index 9c52f61a5..9b429bcaf 100644
--- a/apps/nextjs/src/components/board/items/item-select-modal.spec.tsx
+++ b/apps/nextjs/src/components/board/items/item-select-modal.spec.tsx
@@ -118,7 +118,7 @@ describe("Advanced Add App - Modal Subcomponents", () => {
});
it("renders overflow indicator when 6 or more integrations are supported", async () => {
- const integrations: IntegrationKind[] = ["sonarr", "radarr", "plex", "jellyfin", "emby", "kavita"];
+ const integrations: IntegrationKind[] = ["sonarr", "radarr", "plex", "jellyfin", "emby", "deluge"];
await act(async () =>
root.render(
diff --git a/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx b/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx
index dd9d8100b..07dadd7b6 100644
--- a/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx
+++ b/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx
@@ -49,16 +49,15 @@ export const FixedGridItem = ({
const { section, items, innerSections, columnCount, maxRowCount, announce } = useSectionContext();
const { commitSectionGrid } = useGridLayoutActions();
const [isKeyboardEditing, setIsKeyboardEditing] = useState(false);
- const { isSelected, toggleSelectItem, selectedItemIds } = useBoardSelection();
+ const { isSelected, toggleSelectItem } = useBoardSelection();
const selected = item.type === "item" && isSelected(item.id);
const handleClickCapture = (event: React.MouseEvent) => {
- if (!isEditorActive || item.type !== "item") return;
- if (event.metaKey || event.ctrlKey || (selectedItemIds.size > 0 && !event.shiftKey)) {
- event.preventDefault();
- event.stopPropagation();
- toggleSelectItem(item.id);
- }
+ if (!isEditorActive || item.type !== "item" || (!event.metaKey && !event.ctrlKey)) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+ toggleSelectItem(item.id);
};
const instructionsId = useId();
const isEditorActive = isEditMode && runtimeStatus === "ready";
diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.css b/apps/nextjs/src/components/board/sections/grid/grid-editor.css
index 2cace7d36..d55282b3b 100644
--- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css
+++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css
@@ -32,6 +32,27 @@
opacity: 0.42;
}
+.board-grid-entry[data-board-item-selected="true"] {
+ z-index: 15;
+ opacity: 0.56;
+ filter: saturate(0.72) brightness(0.82);
+ transition:
+ opacity 140ms ease,
+ filter 140ms ease;
+}
+
+.board-grid-entry[data-board-item-selected="true"]::after {
+ position: absolute;
+ inset: 0;
+ z-index: 100;
+ content: "";
+ background: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
+ border: 0.125rem dashed var(--mantine-primary-color-filled);
+ border-radius: var(--board-item-radius, var(--mantine-radius-md));
+ box-shadow: inset 0 0 1.125rem color-mix(in srgb, var(--mantine-primary-color-filled) 20%, transparent);
+ pointer-events: none;
+}
+
.board-grid-drag-overlay[data-dnd-dragging] {
position: fixed !important;
z-index: 10000;
@@ -177,138 +198,115 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
.board-grid-resize-handle {
position: absolute;
- z-index: 20;
- display: block;
+ z-index: 25;
+ display: grid;
padding: 0;
+ color: var(--mantine-color-dimmed);
appearance: none;
- background: transparent;
- border: 0;
+ background: var(--mantine-color-body);
+ border: 0.0625rem solid var(--mantine-color-default-border);
+ box-shadow: var(--mantine-shadow-xs);
+ opacity: 0;
+ scale: 0.88;
+ place-items: center;
touch-action: none;
+ transition:
+ opacity 120ms ease,
+ scale 160ms cubic-bezier(0.22, 1, 0.36, 1),
+ border-color 120ms ease;
}
-body[data-board-grid-interacting] .board-grid-resize-handle {
+.board-grid-resize-handle::before {
+ position: absolute;
+ inset: -0.375rem;
+ content: "";
+}
+
+.board-grid-resize-handle > svg {
pointer-events: none;
}
-body[data-board-grid-interacting] .board-grid-resize-handle::after {
- opacity: 0;
-}
-
-.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::after,
-.board-grid-resize-handle:focus-visible::after {
+.board-grid-entry:has(> .board-grid-resize-handle:hover) > .board-grid-resize-handle,
+.board-grid-entry:has(> .board-grid-resize-handle:active) > .board-grid-resize-handle {
+ border-color: var(--mantine-color-dimmed);
opacity: 1;
-}
-
-.board-grid-resize-handle:focus-visible {
- outline: 2px solid var(--mantine-primary-color-filled);
- outline-offset: -8px;
+ scale: 1;
}
.board-grid-resize-handle[data-grid-resize-handle="n"],
.board-grid-resize-handle[data-grid-resize-handle="s"] {
- right: 44px;
- left: 44px;
- height: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
+ left: 50%;
+ width: 1.75rem;
+ height: 1rem;
+ border-radius: var(--mantine-radius-xl);
+ translate: -50% 0;
cursor: ns-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="n"] {
- top: 0;
+ top: -0.25rem;
}
.board-grid-resize-handle[data-grid-resize-handle="s"] {
- bottom: 0;
+ bottom: -0.25rem;
}
.board-grid-resize-handle[data-grid-resize-handle="e"],
.board-grid-resize-handle[data-grid-resize-handle="w"] {
- top: 44px;
- bottom: 44px;
- width: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
+ top: 50%;
+ width: 1rem;
+ height: 1.75rem;
+ border-radius: var(--mantine-radius-xl);
+ translate: 0 -50%;
cursor: ew-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="e"] {
- right: 0;
+ right: -0.25rem;
}
.board-grid-resize-handle[data-grid-resize-handle="w"] {
- left: 0;
-}
-
-.board-grid-resize-handle[data-grid-resize-handle="e"]::after,
-.board-grid-resize-handle[data-grid-resize-handle="w"]::after {
- width: 3px;
- height: 12px;
+ left: -0.25rem;
}
.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: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
- height: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
+ width: 1.875rem;
+ height: 1.875rem;
+ border-radius: var(--board-item-radius, var(--mantine-radius-md));
}
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
- top: 0;
- right: 0;
+ top: -0.625rem;
+ right: -0.625rem;
cursor: nesw-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="se"] {
- right: 0;
- bottom: 0;
+ right: -0.625rem;
+ bottom: -0.625rem;
cursor: nwse-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
- bottom: 0;
- left: 0;
+ bottom: -0.625rem;
+ left: -0.625rem;
cursor: nesw-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
- top: 0;
- left: 0;
+ top: -0.625rem;
+ left: -0.625rem;
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::after {
- opacity: 1;
- }
-}
-
@media (prefers-reduced-motion: reduce) {
.board-grid-entry,
.board-grid-placeholder,
.board-grid-resize-outline,
- .board-grid-resize-handle::after {
+ .board-grid-resize-handle {
transition: none;
}
}
diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx
index af6474a58..428e2a939 100644
--- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx
+++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx
@@ -32,6 +32,7 @@ import type {
DragStartEvent,
} from "@dnd-kit/react";
import { DragDropProvider, DragOverlay, useDragDropManager, useDraggable, useDroppable } from "@dnd-kit/react";
+import { IconGripHorizontal, IconGripVertical } from "@tabler/icons-react";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
@@ -61,6 +62,7 @@ import { useBoardGridPortalHost } from "./grid-portal-host";
import type { GridInteraction } from "./grid-preview-layer";
import { GridPreviewLayer } from "./grid-preview-layer";
import { createGridResizeOutlineController } from "./grid-resize-outline";
+import { useBoardSelection } from "~/components/board/selection/board-selection-context";
import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions";
import { useGridLayoutActions } from "./use-grid-layout-actions";
@@ -846,6 +848,9 @@ const DndGridEntryComponent = ({
}: DndGridEntryProps) => {
const { acquireContainer, structureRevision } = useBoardGridPortalHost();
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
+ const { isSelected } = useBoardSelection();
+ const board = useRequiredBoard();
+ const isSelectedEntry = placement.type === "item" && isSelected(placement.id);
const mountRef = useRef(null);
const shellRef = useRef(null);
const placementRef = useRef(placement);
@@ -921,7 +926,12 @@ const DndGridEntryComponent = ({
(
- handlePointerDown(event, direction)}
- onPointerMove={handlePointerMove}
- onPointerUp={(event) => handlePointerEnd(event, false)}
- onPointerCancel={(event) => handlePointerEnd(event, true)}
- onLostPointerCapture={(event) => finishResize(event.pointerId, null, true)}
- />
- ));
+ return RESIZE_DIRECTIONS.map((direction) => {
+ const isHorizontalSide = direction === "n" || direction === "s";
+ const isVerticalSide = direction === "e" || direction === "w";
+ const GripIcon = isHorizontalSide ? IconGripHorizontal : IconGripVertical;
+
+ return (
+ handlePointerDown(event, direction)}
+ onPointerMove={handlePointerMove}
+ onPointerUp={(event) => handlePointerEnd(event, false)}
+ onPointerCancel={(event) => handlePointerEnd(event, true)}
+ onLostPointerCapture={(event) => finishResize(event.pointerId, null, true)}
+ >
+
+
+ );
+ });
};
const getProjectedDragShape = (
diff --git a/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts
index 12ed7e84d..858d68cb3 100644
--- a/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts
+++ b/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts
@@ -10,6 +10,8 @@ import { FixedGridItem } from "../fixed-grid-item";
const mocks = vi.hoisted(() => ({
announce: vi.fn(),
commitSectionGrid: vi.fn(),
+ isSelected: vi.fn(() => false),
+ toggleSelectItem: vi.fn(),
editMode: true,
maxRowCount: null as number | null,
items: [
@@ -44,6 +46,13 @@ vi.mock("@homarr/boards/edit-mode", () => ({
useEditMode: () => [mocks.editMode],
}));
+vi.mock("~/components/board/selection/board-selection-context", () => ({
+ useBoardSelection: () => ({
+ isSelected: mocks.isSelected,
+ toggleSelectItem: mocks.toggleSelectItem,
+ }),
+}));
+
vi.mock("@homarr/boards/context", () => ({
useCurrentLayout: () => "layout",
}));
diff --git a/apps/nextjs/src/components/board/selection/board-selection-context.tsx b/apps/nextjs/src/components/board/selection/board-selection-context.tsx
index be78c668d..475ac0f83 100644
--- a/apps/nextjs/src/components/board/selection/board-selection-context.tsx
+++ b/apps/nextjs/src/components/board/selection/board-selection-context.tsx
@@ -8,9 +8,12 @@ import { notifications } from "@mantine/notifications";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useUpdateBoard } from "@homarr/boards/updater";
+import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
+import type { ContainerSection, ItemLayout } from "~/app/[locale]/boards/_types";
+import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position";
import { playPopSound, playTrashSound } from "../audio/board-sounds";
interface BoardSelectionContextValue {
@@ -119,24 +122,137 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
const idsToMove = Array.from(selectedItemIds);
- updateBoard((previous) => ({
- ...previous,
- items: previous.items.map((item) =>
- !idsToMove.includes(item.id)
- ? item
- : {
- ...item,
- layouts: item.layouts.map((layout) =>
- layout.layoutId !== currentLayoutId
- ? layout
- : {
- ...layout,
- sectionId: targetSectionId,
- },
- ),
- },
- ),
- }));
+ updateBoard((previous) => {
+ const targetSection = previous.sections.find((section) => section.id === targetSectionId);
+ if (!targetSection) return previous;
+
+ const itemsToMove = previous.items.filter((item) => idsToMove.includes(item.id));
+ const untouchedItems = previous.items.filter((item) => !idsToMove.includes(item.id));
+
+ // Map of itemId -> layoutId -> new ItemLayout
+ const updatedItemLayoutsMap = new Map>();
+ for (const item of itemsToMove) {
+ updatedItemLayoutsMap.set(item.id, new Map());
+ }
+
+ const acceptedItemIds = new Set();
+ const layoutsToProcess = previous.layouts.toSorted(
+ (left, right) => Number(right.id === currentLayoutId) - Number(left.id === currentLayoutId),
+ );
+
+ for (const boardLayout of layoutsToProcess) {
+ const containerLayout =
+ targetSection.kind === "container"
+ ? targetSection.layouts.find((layout) => layout.layoutId === boardLayout.id)
+ : undefined;
+ const columnCount =
+ targetSection.kind === "container"
+ ? (containerLayout?.width ?? 0)
+ : getBoardLaneColumnCount(boardLayout, getRootSectionLane(targetSection.xOffset));
+ const rowCount = containerLayout?.height ?? 9999;
+
+ if (columnCount === 0 || rowCount === 0) continue;
+
+ // Collect existing elements in targetSection for this layout (excluding items to move)
+ const existingContainers = previous.sections
+ .filter((section): section is ContainerSection => section.kind === "container")
+ .flatMap((section) => {
+ const layout = section.layouts.find(
+ (l) => l.layoutId === boardLayout.id && l.parentSectionId === targetSection.id,
+ );
+ return layout
+ ? [
+ {
+ id: section.id,
+ xOffset: layout.xOffset,
+ yOffset: layout.yOffset,
+ width: layout.width,
+ height: layout.height,
+ },
+ ]
+ : [];
+ });
+
+ const existingItems = previous.items.flatMap((item) => {
+ const layout = item.layouts.find((layout) => {
+ return layout.layoutId === boardLayout.id && layout.sectionId === targetSection.id;
+ });
+ return layout
+ ? [
+ {
+ id: item.id,
+ xOffset: layout.xOffset,
+ yOffset: layout.yOffset,
+ width: layout.width,
+ height: layout.height,
+ },
+ ]
+ : [];
+ });
+
+ const occupiedPositions = [...existingContainers, ...existingItems];
+
+ // Place each item being moved into the first available space. The current
+ // layout decides which selected items can participate in the move; items
+ // that do not fit there remain untouched in every layout.
+ for (const item of itemsToMove) {
+ if (boardLayout.id !== currentLayoutId && !acceptedItemIds.has(item.id)) continue;
+
+ const currentItemLayout = item.layouts.find((layout) => layout.layoutId === boardLayout.id);
+ if (!currentItemLayout) continue;
+
+ const previousPositionIndex = occupiedPositions.findIndex((position) => position.id === item.id);
+ const previousPosition =
+ previousPositionIndex >= 0 ? occupiedPositions.splice(previousPositionIndex, 1).at(0) : undefined;
+ const { width, height } = currentItemLayout;
+ if (width > columnCount || height > rowCount) {
+ if (previousPosition) occupiedPositions.push(previousPosition);
+ continue;
+ }
+
+ const emptyPosition = getFirstEmptyPosition(occupiedPositions, columnCount, rowCount, { width, height });
+ if (!emptyPosition) {
+ if (previousPosition) occupiedPositions.push(previousPosition);
+ continue;
+ }
+
+ if (boardLayout.id === currentLayoutId) acceptedItemIds.add(item.id);
+
+ const newLayout: ItemLayout = {
+ layoutId: boardLayout.id,
+ sectionId: targetSectionId,
+ xOffset: emptyPosition.xOffset,
+ yOffset: emptyPosition.yOffset,
+ width,
+ height,
+ };
+
+ occupiedPositions.push({
+ id: item.id,
+ xOffset: emptyPosition.xOffset,
+ yOffset: emptyPosition.yOffset,
+ width,
+ height,
+ });
+
+ updatedItemLayoutsMap.get(item.id)?.set(boardLayout.id, newLayout);
+ }
+ }
+
+ const updatedMovedItems = itemsToMove.map((item) => {
+ const itemLayoutsMap = updatedItemLayoutsMap.get(item.id);
+ const newLayouts = item.layouts.map((layout) => itemLayoutsMap?.get(layout.layoutId) ?? layout);
+ return {
+ ...item,
+ layouts: newLayouts,
+ };
+ });
+
+ return {
+ ...previous,
+ items: [...untouchedItems, ...updatedMovedItems],
+ };
+ });
clearSelection();
},
diff --git a/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx b/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx
index 18f7b85e8..c85c01f11 100644
--- a/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx
+++ b/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx
@@ -1,16 +1,34 @@
"use client";
-import { ActionIcon, Badge, Button, Group, Menu, Paper, Text, Tooltip } from "@mantine/core";
+import {
+ ActionIcon,
+ Avatar,
+ Box,
+ Button,
+ Divider,
+ Group,
+ HoverCard,
+ Menu,
+ Paper,
+ ScrollArea,
+ Stack,
+ Text,
+ Tooltip,
+} from "@mantine/core";
import { IconCheck, IconFolderShare, IconTrash, IconX } from "@tabler/icons-react";
+import { clientApi } from "@homarr/api/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
+import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useBoardSelection } from "./board-selection-context";
+const MAX_AVATARS_DISPLAYED = 5;
+
export const BoardSelectionToolbar = () => {
const [isEditMode] = useEditMode();
const { selectedItemIds, clearSelection, removeSelectedItems, moveSelectedItemsToSection } = useBoardSelection();
@@ -19,9 +37,48 @@ export const BoardSelectionToolbar = () => {
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
const t = useI18n();
+ const selectedItems = board.items.filter((item) => selectedItemIds.has(item.id));
+ const hasAppWidgets = selectedItems.some((item) => item.kind === "app");
+ const { data: selectableApps } = clientApi.app.selectable.useQuery(undefined, {
+ enabled: hasAppWidgets,
+ });
+
if (!isEditMode || selectedItemIds.size === 0) return null;
- const count = selectedItemIds.size;
+ const appMap = new Map((selectableApps ?? []).map((app) => [app.id, app]));
+
+ // Sort items so apps appear first
+ const sortedSelectedItems = [...selectedItems].sort((itemA, itemB) => {
+ if (itemA.kind === "app" && itemB.kind !== "app") return -1;
+ if (itemA.kind !== "app" && itemB.kind === "app") return 1;
+ return 0;
+ });
+
+ const getItemDetails = (item: (typeof selectedItems)[number]) => {
+ let displayName = item.advancedOptions.title?.trim();
+ let iconUrl: string | undefined = undefined;
+ const IconComponent = widgetCatalogIcons[item.kind] ?? IconCheck;
+
+ if (item.kind === "app") {
+ const appId = (item.options as { appId?: string })?.appId;
+ const app = appId ? appMap.get(appId) : undefined;
+ if (!displayName) {
+ displayName = app?.name || t("widget.app.name");
+ }
+ if (app?.iconUrl) {
+ iconUrl = app.iconUrl;
+ }
+ }
+
+ if (!displayName) {
+ displayName = t(`widget.${item.kind}.name`);
+ }
+
+ return { displayName, iconUrl, IconComponent, kind: item.kind };
+ };
+
+ const visibleItems = sortedSelectedItems.slice(0, MAX_AVATARS_DISPLAYED);
+ const overflowCount = sortedSelectedItems.length - visibleItems.length;
const mainCanvasSection = board.sections
.filter(
@@ -64,50 +121,118 @@ export const BoardSelectionToolbar = () => {
left: "50%",
transform: "translateX(-50%)",
zIndex: 1000,
- boxShadow: "0 8px 32px rgba(0, 0, 0, 0.24)",
- backdropFilter: "blur(12px)",
+ maxWidth: "min(92vw, 760px)",
+ boxShadow: "0 12px 40px rgba(0, 0, 0, 0.28)",
+ backdropFilter: "blur(14px)",
border: "1px solid var(--mantine-color-default-border)",
animation: "slideUp 200ms cubic-bezier(0.4, 0, 0.2, 1) forwards",
}}
>
-
- }>
- {count === 1 ? "1 item" : `${count} items`}
-
+
+ {/* Avatar Group with HoverCard Popover showing all items */}
+
+
+
+
+ {visibleItems.map((item) => {
+ const { displayName, iconUrl, IconComponent } = getItemDetails(item);
+ return (
+
+ {!iconUrl && }
+
+ );
+ })}
+ {overflowCount > 0 && (
+
+ +{overflowCount}
+
+ )}
+
+
+
- {placementOptions.length > 1 && (
-
- )}
+
+
+
+ {sortedSelectedItems.length} selected
+
- }
- onClick={removeSelectedItems}
- >
- Remove
-
+
+
+ {sortedSelectedItems.map((item) => {
+ const { displayName, iconUrl, IconComponent } = getItemDetails(item);
+ return (
+
+
+ {!iconUrl && }
+
+
+ {displayName}
+
+
+ );
+ })}
+
+
+
+
+
-
-
-
-
-
+
+
+ {/* Actions */}
+
+ {placementOptions.length > 1 && (
+
+ )}
+
+ }
+ onClick={removeSelectedItems}
+ >
+ Delete
+
+
+
+
+
+
+
+
);
diff --git a/apps/nextjs/src/components/board/selection/board-selection.spec.tsx b/apps/nextjs/src/components/board/selection/board-selection.spec.tsx
new file mode 100644
index 000000000..846b87943
--- /dev/null
+++ b/apps/nextjs/src/components/board/selection/board-selection.spec.tsx
@@ -0,0 +1,190 @@
+// @vitest-environment jsdom
+
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import type { Root } from "react-dom/client";
+import { MantineProvider } from "@mantine/core";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { Board } from "~/app/[locale]/boards/_types";
+import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-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 { LayoutMockBuilder } from "~/components/board/items/actions/test/mocks/layout-mock";
+import { BoardSelectionProvider, useBoardSelection } from "./board-selection-context";
+
+vi.mock("@homarr/translation/client", () => ({
+ useI18n: () => (key: string) => key,
+}));
+
+vi.mock("../audio/board-sounds", () => ({
+ playPopSound: vi.fn(),
+ playTrashSound: vi.fn(),
+}));
+
+let currentBoardState: Board;
+let mockUpdateBoard: (updater: (prev: Board) => Board) => void;
+
+vi.mock("@homarr/boards/context", () => ({
+ useRequiredBoard: () => currentBoardState,
+ useCurrentLayout: () => "layout-1",
+}));
+
+vi.mock("@homarr/boards/edit-mode", () => ({
+ useEditMode: () => [true, vi.fn()],
+}));
+
+vi.mock("@homarr/boards/updater", () => ({
+ useUpdateBoard: () => ({
+ updateBoard: (updater: (prev: Board) => Board) => {
+ mockUpdateBoard(updater);
+ },
+ }),
+}));
+
+let root: Root;
+let host: HTMLDivElement;
+
+beforeEach(() => {
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ window.matchMedia = vi.fn().mockImplementation(() => ({
+ matches: false,
+ media: "",
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }));
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+});
+
+afterEach(() => {
+ act(() => {
+ root.unmount();
+ });
+ host.remove();
+});
+
+const SelectionConsumer = ({ onReady }: { onReady: (ctx: ReturnType) => void }) => {
+ const selection = useBoardSelection();
+ onReady(selection);
+ return Selection Ready
;
+};
+
+describe("BoardSelectionContext", () => {
+ it("toggles item selection and clears on clearSelection", async () => {
+ const layout = new LayoutMockBuilder({ id: "layout-1" }).build();
+ const item1 = new ItemMockBuilder({ id: "item-1" }).addLayout({ layoutId: "layout-1" }).build();
+ currentBoardState = new BoardMockBuilder().addLayout(layout).addItem(item1).build();
+ mockUpdateBoard = vi.fn();
+
+ let selectionContext!: ReturnType;
+
+ await act(async () => {
+ root.render(
+
+
+ {
+ selectionContext = ctx;
+ }}
+ />
+
+ ,
+ );
+ });
+
+ expect(selectionContext.isSelected("item-1")).toBe(false);
+
+ await act(async () => {
+ selectionContext.toggleSelectItem("item-1");
+ });
+ expect(selectionContext.isSelected("item-1")).toBe(true);
+
+ await act(async () => {
+ selectionContext.clearSelection();
+ });
+ expect(selectionContext.isSelected("item-1")).toBe(false);
+ });
+
+ it("moves selected items to the first available positions in a container section", async () => {
+ const layout = new LayoutMockBuilder({ id: "layout-1" }).build();
+ const emptyRoot = new EmptySectionMockBuilder({ id: "empty-root", xOffset: 0 }).build();
+ const container = new ContainerSectionMockBuilder({ id: "container-1" })
+ .addLayout({ layoutId: "layout-1", parentSectionId: "empty-root", width: 4, height: 4 })
+ .build();
+
+ // Existing item inside the container at (0, 0) with 2x2
+ const existingContainerItem = new ItemMockBuilder({ id: "existing-1" })
+ .addLayout({ layoutId: "layout-1", sectionId: "container-1", xOffset: 0, yOffset: 0, width: 2, height: 2 })
+ .build();
+
+ // Two items to move from emptyRoot
+ const itemA = new ItemMockBuilder({ id: "item-a" })
+ .addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 5, yOffset: 5, width: 2, height: 1 })
+ .build();
+ const itemB = new ItemMockBuilder({ id: "item-b" })
+ .addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 8, yOffset: 8, width: 2, height: 1 })
+ .build();
+
+ currentBoardState = new BoardMockBuilder()
+ .addLayout(layout)
+ .addSection(emptyRoot)
+ .addSection(container)
+ .addItem(existingContainerItem)
+ .addItem(itemA)
+ .addItem(itemB)
+ .build();
+
+ let updatedBoard: Board = currentBoardState;
+ mockUpdateBoard = vi.fn((updater) => {
+ updatedBoard = updater(currentBoardState);
+ currentBoardState = updatedBoard;
+ });
+
+ let selectionContext!: ReturnType;
+
+ await act(async () => {
+ root.render(
+
+
+ {
+ selectionContext = ctx;
+ }}
+ />
+
+ ,
+ );
+ });
+
+ await act(async () => {
+ selectionContext.toggleSelectItem("item-a");
+ selectionContext.toggleSelectItem("item-b");
+ });
+
+ await act(async () => {
+ selectionContext.moveSelectedItemsToSection("container-1");
+ });
+
+ const movedA = updatedBoard.items.find((i) => i.id === "item-a")?.layouts.find((l) => l.layoutId === "layout-1");
+ const movedB = updatedBoard.items.find((i) => i.id === "item-b")?.layouts.find((l) => l.layoutId === "layout-1");
+
+ expect(movedA?.sectionId).toBe("container-1");
+ expect(movedB?.sectionId).toBe("container-1");
+
+ // Container width is 4. Existing item at (0, 0) occupies (0,0)-(1,1).
+ // itemA (2x1) is placed at first empty spot: (2, 0)
+ expect(movedA?.xOffset).toBe(2);
+ expect(movedA?.yOffset).toBe(0);
+
+ // itemB (2x1) is placed at next first empty spot: (2, 1)
+ expect(movedB?.xOffset).toBe(2);
+ expect(movedB?.yOffset).toBe(1);
+ });
+});
diff --git a/packages/ui/src/components/selectable-card/selectable-card.tsx b/packages/ui/src/components/selectable-card/selectable-card.tsx
index 93d133694..71fec564c 100644
--- a/packages/ui/src/components/selectable-card/selectable-card.tsx
+++ b/packages/ui/src/components/selectable-card/selectable-card.tsx
@@ -53,126 +53,125 @@ const varsResolver = createVarsResolver((_theme, { radius
},
}));
-export const SelectableCard = factory(
- (_props: SelectableCardProps, ref: React.ForwardedRef) => {
- const props = useProps("SelectableCard", defaultProps, _props);
- const {
- classNames,
- className,
- style,
- styles,
- unstyled,
- vars,
- attributes,
- icon,
- title,
- topRight,
- description,
- footerLeft,
- footerRight,
- selected,
- loading = false,
- disabled = false,
- children,
- type = "button",
- ...others
- } = props;
+export const SelectableCard = factory((_props) => {
+ const props = useProps("SelectableCard", defaultProps, _props);
+ const {
+ ref,
+ classNames,
+ className,
+ style,
+ styles,
+ unstyled,
+ vars,
+ attributes,
+ icon,
+ title,
+ topRight,
+ description,
+ footerLeft,
+ footerRight,
+ selected,
+ loading = false,
+ disabled = false,
+ children,
+ type = "button",
+ ...others
+ } = props;
- const getStyles = useStyles({
- name: "SelectableCard",
- classes,
- props,
- className,
- style,
- classNames,
- styles,
- unstyled,
- vars,
- attributes,
- varsResolver,
- });
+ const getStyles = useStyles({
+ name: "SelectableCard",
+ classes,
+ props,
+ className,
+ style,
+ classNames,
+ styles,
+ unstyled,
+ vars,
+ attributes,
+ varsResolver,
+ });
- return (
-
-
+ return (
+
+
- {/* Header Section: Inset background with Title that never elides before the category */}
- {(icon || title || topRight) && (
-
-
-
- {icon}
- {typeof title === "string" ? (
-
- {title}
-
- ) : (
- title
- )}
-
- {topRight && (
-
+
+
+ {icon}
+ {typeof title === "string" ? (
+
- {topRight}
-
+ {title}
+
+ ) : (
+ title
)}
-
- )}
+ {topRight && (
+
+ {topRight}
+
+ )}
+
+
+ )}
- {/* Body: Custom Children or Default Description */}
- {children ? (
- {children}
- ) : description !== undefined ? (
-
-
-
- {description || "No description provided"}
-
-
-
- ) : null}
+ {/* Body: Custom Children or Default Description */}
+ {children ? (
+ {children}
+ ) : description !== undefined ? (
+
+
+
+ {description || "No description provided"}
+
+
+
+ ) : null}
- {/* Footer Section: Inset Dashed Bottom Meta */}
- {(footerLeft || footerRight) && (
-
-
- {footerLeft}
- {footerRight}
-
-
- )}
-
- );
- },
-);
+ {/* Footer Section: Inset Dashed Bottom Meta */}
+ {(footerLeft || footerRight) && (
+
+
+ {footerLeft}
+ {footerRight}
+
+
+ )}
+
+ );
+});
SelectableCard.displayName = "@homarr/ui/SelectableCard";
SelectableCard.classes = classes;
diff --git a/packages/ui/src/integration-select-grid/integration-select-grid.spec.tsx b/packages/ui/src/integration-select-grid/integration-select-grid.spec.tsx
index 4ad740d0c..c7e736e1d 100644
--- a/packages/ui/src/integration-select-grid/integration-select-grid.spec.tsx
+++ b/packages/ui/src/integration-select-grid/integration-select-grid.spec.tsx
@@ -10,6 +10,17 @@ import type { IntegrationKind } from "@homarr/definitions";
import { IntegrationSelectGrid } from "./integration-select-grid";
+vi.mock("@homarr/definitions", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ getWidgetKindsForIntegration: (kind: IntegrationKind) => {
+ if (kind === "wud") return [];
+ return actual.getWidgetKindsForIntegration(kind);
+ },
+ };
+});
+
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string) => {
const translations: Record = {
@@ -102,7 +113,7 @@ describe("IntegrationSelectGrid", () => {
await act(async () =>
root.render(
-
+
,
),
);