feat(board): add multi-item selection actions
This commit is contained in:
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>,
|
||||
),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user