feat(board): add multi-item selection actions

This commit is contained in:
Thomas Camlong committed 2026-08-21 02:15:57 +02:00
1 parent f7acf18b58
commit 3a7cad9e2f
13 files changed
+753 -290

No files matched your search

@@ -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;
}
}
@@ -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 (
<Card
@@ -80,8 +67,6 @@ const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCard
w="100%"
h="100%"
data-grid-item-content
data-board-item-selected={selected ? "true" : undefined}
onClickCapture={handleClickCapture}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
@@ -93,22 +78,12 @@ const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCard
root: {
"--opacity": board.opacity / 100,
containerType: "size",
outline: selected ? "3px solid var(--mantine-primary-color-filled)" : undefined,
outlineOffset: selected ? "2px" : undefined,
transition: "outline 150ms ease, box-shadow 150ms ease",
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
{selected && (
<Box pos="absolute" top={6} left={6} style={{ zIndex: 10, pointerEvents: "none" }}>
<Badge size="xs" variant="filled" color="primaryColor" radius="xl">
✓
</Badge>
</Box>
)}
{children}
</Card>
);
@@ -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 })}
>
@@ -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(
@@ -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";
@@ -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;
}
}
@@ -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<HTMLDivElement>(null);
const shellRef = useRef<HTMLDivElement>(null);
const placementRef = useRef(placement);
@@ -921,7 +926,12 @@ const DndGridEntryComponent = ({
<div
ref={setShellRef}
className="board-grid-entry"
style={getLogicalItemStyle(placement) as CSSProperties}
style={
{
...getLogicalItemStyle(placement),
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
} as CSSProperties
}
data-grid-item-id={placement.id}
data-grid-item-type={placement.type}
data-grid-x={placement.x}
@@ -930,6 +940,7 @@ const DndGridEntryComponent = ({
data-grid-h={placement.h}
data-dnd-drag-source={isDragSource ? "true" : undefined}
data-dnd-dropping={isDropping ? "true" : undefined}
data-board-item-selected={isSelectedEntry ? "true" : undefined}
>
<div ref={mountRef} className="board-grid-content-mount" />
<GridResizeHandles
@@ -1102,19 +1113,27 @@ const GridResizeHandles = ({
finishResize(event.pointerId, { x: event.clientX, y: event.clientY }, canceled);
};
return RESIZE_DIRECTIONS.map((direction) => (
<span
key={direction}
className="board-grid-resize-handle"
data-grid-resize-handle={direction}
aria-hidden="true"
onPointerDown={(event) => 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 (
<span
key={direction}
className="board-grid-resize-handle"
data-grid-resize-handle={direction}
aria-hidden="true"
onPointerDown={(event) => handlePointerDown(event, direction)}
onPointerMove={handlePointerMove}
onPointerUp={(event) => handlePointerEnd(event, false)}
onPointerCancel={(event) => handlePointerEnd(event, true)}
onLostPointerCapture={(event) => finishResize(event.pointerId, null, true)}
>
<GripIcon size={isHorizontalSide || isVerticalSide ? 13 : 15} stroke={2.25} />
</span>
);
});
};
const getProjectedDragShape = (
@@ -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",
}));
@@ -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<string, Map<string, ItemLayout>>();
for (const item of itemsToMove) {
updatedItemLayoutsMap.set(item.id, new Map());
}
const acceptedItemIds = new Set<string>();
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();
},
@@ -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",
}}
>
<Group gap="sm" wrap="nowrap">
<Badge size="lg" radius="xl" variant="filled" color="primaryColor" leftSection={<IconCheck size={14} />}>
{count === 1 ? "1 item" : `${count} items`}
</Badge>
<Group gap="xs" wrap="nowrap">
{/* Avatar Group with HoverCard Popover showing all items */}
<HoverCard position="top" withArrow shadow="xl" radius="md" openDelay={100} closeDelay={200} withinPortal>
<HoverCard.Target>
<Box style={{ cursor: "pointer", display: "inline-flex", alignItems: "center" }}>
<Avatar.Group>
{visibleItems.map((item) => {
const { displayName, iconUrl, IconComponent } = getItemDetails(item);
return (
<Avatar
key={item.id}
src={iconUrl}
radius="xl"
size={32}
color="primaryColor"
variant="light"
styles={{ image: { objectFit: "contain", padding: 2 } }}
alt={displayName}
>
{!iconUrl && <IconComponent size={16} />}
</Avatar>
);
})}
{overflowCount > 0 && (
<Avatar radius="xl" size={32} color="gray" variant="filled">
+{overflowCount}
</Avatar>
)}
</Avatar.Group>
</Box>
</HoverCard.Target>
{placementOptions.length > 1 && (
<Menu position="top" withArrow shadow="md">
<Menu.Target>
<Button size="xs" variant="light" color="primaryColor" leftSection={<IconFolderShare size={15} />}>
Move to section
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Choose destination section</Menu.Label>
{placementOptions.map((opt) => (
<Menu.Item key={opt.value} onClick={() => moveSelectedItemsToSection(opt.value)}>
{opt.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
<HoverCard.Dropdown p={6} style={{ width: 220 }}>
<Stack gap={4}>
<Text size="10px" fw={700} c="dimmed" tt="uppercase" px={4}>
{sortedSelectedItems.length} selected
</Text>
<Button
size="xs"
variant="light"
color="red"
leftSection={<IconTrash size={15} />}
onClick={removeSelectedItems}
>
Remove
</Button>
<ScrollArea.Autosize mah={176} type="auto">
<Stack gap={2}>
{sortedSelectedItems.map((item) => {
const { displayName, iconUrl, IconComponent } = getItemDetails(item);
return (
<Group key={item.id} gap={6} wrap="nowrap" px={4} py={2}>
<Avatar
src={iconUrl}
radius="sm"
size={22}
color="primaryColor"
variant="light"
styles={{ image: { objectFit: "contain", padding: 2 } }}
alt={displayName}
>
{!iconUrl && <IconComponent size={12} />}
</Avatar>
<Text size="xs" fw={500} truncate>
{displayName}
</Text>
</Group>
);
})}
</Stack>
</ScrollArea.Autosize>
</Stack>
</HoverCard.Dropdown>
</HoverCard>
<Tooltip label="Deselect (Escape)" withArrow>
<ActionIcon size="sm" variant="subtle" color="gray" onClick={clearSelection} aria-label="Clear selection">
<IconX size={14} />
</ActionIcon>
</Tooltip>
<Divider orientation="vertical" />
{/* Actions */}
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
{placementOptions.length > 1 && (
<Menu position="top" withArrow shadow="md">
<Menu.Target>
<Button size="xs" variant="light" color="primaryColor" leftSection={<IconFolderShare size={15} />}>
Move
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Move selected to section</Menu.Label>
{placementOptions.map((opt) => (
<Menu.Item key={opt.value} onClick={() => moveSelectedItemsToSection(opt.value)}>
{opt.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
<Button
size="xs"
variant="light"
color="red"
leftSection={<IconTrash size={15} />}
onClick={removeSelectedItems}
>
Delete
</Button>
<Tooltip label="Deselect (Escape)" withArrow>
<ActionIcon size="sm" variant="subtle" color="gray" onClick={clearSelection} aria-label="Clear selection">
<IconX size={14} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Paper>
);
@@ -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<typeof useBoardSelection>) => void }) => {
const selection = useBoardSelection();
onReady(selection);
return <div>Selection Ready</div>;
};
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<typeof useBoardSelection>;
await act(async () => {
root.render(
<MantineProvider>
<BoardSelectionProvider>
<SelectionConsumer
onReady={(ctx) => {
selectionContext = ctx;
}}
/>
</BoardSelectionProvider>
</MantineProvider>,
);
});
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<typeof useBoardSelection>;
await act(async () => {
root.render(
<MantineProvider>
<BoardSelectionProvider>
<SelectionConsumer
onReady={(ctx) => {
selectionContext = ctx;
}}
/>
</BoardSelectionProvider>
</MantineProvider>,
);
});
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);
});
});
@@ -53,126 +53,125 @@ const varsResolver = createVarsResolver<SelectableCardFactory>((_theme, { radius
},
}));
export const SelectableCard = factory<SelectableCardFactory>(
(_props: SelectableCardProps, ref: React.ForwardedRef<HTMLButtonElement>) => {
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<SelectableCardFactory>((_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<SelectableCardFactory>({
name: "SelectableCard",
classes,
props,
className,
style,
classNames,
styles,
unstyled,
vars,
attributes,
varsResolver,
});
const getStyles = useStyles<SelectableCardFactory>({
name: "SelectableCard",
classes,
props,
className,
style,
classNames,
styles,
unstyled,
vars,
attributes,
varsResolver,
});
return (
<Box
component="button"
type={type}
ref={ref}
disabled={disabled || loading}
data-selected={selected || undefined}
aria-pressed={selected !== undefined ? selected : undefined}
{...getStyles("root")}
{...others}
>
<LoadingOverlay visible={loading} loaderProps={{ size: "sm" }} />
return (
<Box
component="button"
{...others}
type={type}
ref={ref}
disabled={disabled || loading}
data-selected={selected || undefined}
aria-pressed={selected !== undefined ? selected : undefined}
{...getStyles("root")}
>
<LoadingOverlay visible={loading} loaderProps={{ size: "sm" }} />
{/* Header Section: Inset background with Title that never elides before the category */}
{(icon || title || topRight) && (
<Box {...getStyles("header")}>
<Group
justify="space-between"
align="center"
wrap="nowrap"
gap="xs"
style={{ width: "100%", overflow: "hidden" }}
>
<Group gap="xs" wrap="nowrap" style={{ flex: "1 1 auto", minWidth: 0, overflow: "hidden" }}>
{icon}
{typeof title === "string" ? (
<Text
fw={700}
size="md"
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{title}
</Text>
) : (
title
)}
</Group>
{topRight && (
<Box
{/* Header Section: Inset background with Title that never elides before the category */}
{(icon || title || topRight) && (
<Box {...getStyles("header")}>
<Group
justify="space-between"
align="center"
wrap="nowrap"
gap="xs"
style={{ width: "100%", overflow: "hidden" }}
>
<Group gap="xs" wrap="nowrap" style={{ flex: "1 1 auto", minWidth: 0, overflow: "hidden" }}>
{icon}
{typeof title === "string" ? (
<Text
fw={700}
size="md"
style={{
minWidth: 0,
flexShrink: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{topRight}
</Box>
{title}
</Text>
) : (
title
)}
</Group>
</Box>
)}
{topRight && (
<Box
style={{
minWidth: 0,
flexShrink: 0,
}}
>
{topRight}
</Box>
)}
</Group>
</Box>
)}
{/* Body: Custom Children or Default Description */}
{children ? (
<Box {...getStyles("body")}>{children}</Box>
) : description !== undefined ? (
<Box {...getStyles("body")}>
<Tooltip label={description} multiline w={260} disabled={!description}>
<Text size="sm" c="dimmed" lineClamp={2} lh={1.35}>
{description || "No description provided"}
</Text>
</Tooltip>
</Box>
) : null}
{/* Body: Custom Children or Default Description */}
{children ? (
<Box {...getStyles("body")}>{children}</Box>
) : description !== undefined ? (
<Box {...getStyles("body")}>
<Tooltip label={description} multiline w={260} disabled={!description}>
<Text size="sm" c="dimmed" lineClamp={2} lh={1.35}>
{description || "No description provided"}
</Text>
</Tooltip>
</Box>
) : null}
{/* Footer Section: Inset Dashed Bottom Meta */}
{(footerLeft || footerRight) && (
<Box {...getStyles("footer")}>
<Group justify="space-between" align="center" wrap="nowrap" gap="xs" style={{ width: "100%" }}>
<Box style={{ minWidth: 0, flexShrink: 1 }}>{footerLeft}</Box>
<Box style={{ minWidth: 0, flexShrink: 0 }}>{footerRight}</Box>
</Group>
</Box>
)}
</Box>
);
},
);
{/* Footer Section: Inset Dashed Bottom Meta */}
{(footerLeft || footerRight) && (
<Box {...getStyles("footer")}>
<Group justify="space-between" align="center" wrap="nowrap" gap="xs" style={{ width: "100%" }}>
<Box style={{ minWidth: 0, flexShrink: 1 }}>{footerLeft}</Box>
<Box style={{ minWidth: 0, flexShrink: 0 }}>{footerRight}</Box>
</Group>
</Box>
)}
</Box>
);
});
SelectableCard.displayName = "@homarr/ui/SelectableCard";
SelectableCard.classes = classes;
@@ -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<typeof import("@homarr/definitions")>();
return {
...actual,
getWidgetKindsForIntegration: (kind: IntegrationKind) => {
if (kind === "wud") return [];
return actual.getWidgetKindsForIntegration(kind);
},
};
});
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string) => {
const translations: Record<string, string> = {
@@ -102,7 +113,7 @@ describe("IntegrationSelectGrid", () => {
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["google"]} />
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["wud"]} />
</MantineProvider>,
),
);