feat(board): add Cmd/Ctrl multi-item selection, floating action bar, and procedural delete audio synthesis
This commit is contained in:
5 files changed
+486
-61
No files matched your search
@@ -23,6 +23,8 @@ import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-
|
||||
import { BoardGridPortalHost, BoardGridPortalRenderer } from "~/components/board/sections/grid/grid-portal-host";
|
||||
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
|
||||
import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||
import { BoardSelectionProvider } from "~/components/board/selection/board-selection-context";
|
||||
import { BoardSelectionToolbar } from "~/components/board/selection/board-selection-toolbar";
|
||||
import classes from "./_client.module.css";
|
||||
|
||||
const APP_SHELL_INLINE_PADDING = 32;
|
||||
@@ -63,71 +65,74 @@ export const ClientBoard = () => {
|
||||
.join(" ");
|
||||
|
||||
const content = (
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
<BoardEmptyState />
|
||||
<BoardSetupChecklist />
|
||||
<BoardSectionCollapseProvider>
|
||||
<BoardGridPortalHost>
|
||||
<ScaledBoardCanvas
|
||||
logicalWidth={logicalWidth}
|
||||
initialLogicalHeight={initialLogicalHeight}
|
||||
initialAvailableWidth={initialAvailableWidth}
|
||||
label={board.name}
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
<BoardGridPortalRenderer />
|
||||
<div className={classes.columns} style={{ gridTemplateColumns }}>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("leftRail")}
|
||||
data-board-gutter="left"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${leftSection.id}`}
|
||||
section={leftSection}
|
||||
columnCount={leftColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="left"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
<BoardSelectionProvider>
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
<BoardEmptyState />
|
||||
<BoardSetupChecklist />
|
||||
<BoardSectionCollapseProvider>
|
||||
<BoardGridPortalHost>
|
||||
<ScaledBoardCanvas
|
||||
logicalWidth={logicalWidth}
|
||||
initialLogicalHeight={initialLogicalHeight}
|
||||
initialAvailableWidth={initialAvailableWidth}
|
||||
label={board.name}
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
<BoardGridPortalRenderer />
|
||||
<div className={classes.columns} style={{ gridTemplateColumns }}>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("leftRail")}
|
||||
data-board-gutter="left"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${leftSection.id}`}
|
||||
section={leftSection}
|
||||
columnCount={leftColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="left"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<section className={classes.lane} aria-label={t("canvas")}>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${mainSection.id}`}
|
||||
section={mainSection}
|
||||
columnCount={mainColumnCount}
|
||||
requestedRowCount={0}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{rightColumnCount > 0 && rightSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("rightRail")}
|
||||
data-board-gutter="right"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<section className={classes.lane} aria-label={t("canvas")}>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${rightSection.id}`}
|
||||
section={rightSection}
|
||||
columnCount={rightColumnCount}
|
||||
key={`${currentLayoutId}-${mainSection.id}`}
|
||||
section={mainSection}
|
||||
columnCount={mainColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="right"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</BoardGridEditorBoundary>
|
||||
</ScaledBoardCanvas>
|
||||
</BoardGridPortalHost>
|
||||
</BoardSectionCollapseProvider>
|
||||
</Box>
|
||||
</BoardAdvancedFocusProvider>
|
||||
</section>
|
||||
|
||||
{rightColumnCount > 0 && rightSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("rightRail")}
|
||||
data-board-gutter="right"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${rightSection.id}`}
|
||||
section={rightSection}
|
||||
columnCount={rightColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="right"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</BoardGridEditorBoundary>
|
||||
</ScaledBoardCanvas>
|
||||
</BoardGridPortalHost>
|
||||
</BoardSectionCollapseProvider>
|
||||
<BoardSelectionToolbar />
|
||||
</Box>
|
||||
</BoardAdvancedFocusProvider>
|
||||
</BoardSelectionProvider>
|
||||
);
|
||||
|
||||
if (representativeWidth === null) return content;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Procedural UI sounds for Homarr using the Web Audio API.
|
||||
* Zero audio files shipped. Pure runtime synthesis with oscillators, noise buffers, and gain envelopes.
|
||||
*/
|
||||
|
||||
let audioContext: AudioContext | null = null;
|
||||
|
||||
const getAudioContext = (): AudioContext | null => {
|
||||
if (typeof window === "undefined") return null;
|
||||
if (!audioContext) {
|
||||
const AudioContextClass =
|
||||
window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
if (AudioContextClass) {
|
||||
audioContext = new AudioContextClass();
|
||||
}
|
||||
}
|
||||
if (audioContext && audioContext.state === "suspended") {
|
||||
void audioContext.resume();
|
||||
}
|
||||
return audioContext;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a white noise audio buffer for percussive textures
|
||||
*/
|
||||
const createNoiseBuffer = (ctx: AudioContext, durationSeconds = 0.2): AudioBuffer => {
|
||||
const bufferSize = Math.floor(ctx.sampleRate * durationSeconds);
|
||||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
return buffer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Procedural trash / delete crunch sound.
|
||||
* Synthesizes a crunchy bin/paper crumple noise burst and downward pitch thud.
|
||||
*/
|
||||
export const playTrashSound = (volume = 0.35) => {
|
||||
const ctx = getAudioContext();
|
||||
if (!ctx) return;
|
||||
|
||||
const now = ctx.currentTime;
|
||||
|
||||
// Master Gain
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.gain.setValueAtTime(volume, now);
|
||||
masterGain.connect(ctx.destination);
|
||||
|
||||
// 1. Downward sweep sub-oscillator (physical thud)
|
||||
const osc = ctx.createOscillator();
|
||||
const oscGain = ctx.createGain();
|
||||
osc.type = "triangle";
|
||||
osc.frequency.setValueAtTime(260, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(35, now + 0.18);
|
||||
oscGain.gain.setValueAtTime(0.7, now);
|
||||
oscGain.gain.exponentialRampToValueAtTime(0.001, now + 0.18);
|
||||
osc.connect(oscGain);
|
||||
oscGain.connect(masterGain);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.2);
|
||||
|
||||
// 2. High-crunch noise burst (crush / crumple texture)
|
||||
try {
|
||||
const noiseBuffer = createNoiseBuffer(ctx, 0.22);
|
||||
const noiseSource = ctx.createBufferSource();
|
||||
noiseSource.buffer = noiseBuffer;
|
||||
|
||||
const noiseFilter = ctx.createBiquadFilter();
|
||||
noiseFilter.type = "bandpass";
|
||||
noiseFilter.frequency.setValueAtTime(1400, now);
|
||||
noiseFilter.frequency.exponentialRampToValueAtTime(320, now + 0.2);
|
||||
noiseFilter.Q.setValueAtTime(2.2, now);
|
||||
|
||||
const noiseGain = ctx.createGain();
|
||||
noiseGain.gain.setValueAtTime(0.9, now);
|
||||
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
|
||||
|
||||
noiseSource.connect(noiseFilter);
|
||||
noiseFilter.connect(noiseGain);
|
||||
noiseGain.connect(masterGain);
|
||||
|
||||
noiseSource.start(now);
|
||||
noiseSource.stop(now + 0.22);
|
||||
} catch {
|
||||
// Ignore if audio buffer creation is constrained
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Procedural select / pop sound.
|
||||
*/
|
||||
export const playPopSound = (volume = 0.25) => {
|
||||
const ctx = getAudioContext();
|
||||
if (!ctx) return;
|
||||
|
||||
const now = ctx.currentTime;
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.type = "sine";
|
||||
osc.frequency.setValueAtTime(460, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(780, now + 0.05);
|
||||
|
||||
gain.gain.setValueAtTime(volume, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.07);
|
||||
};
|
||||
@@ -25,6 +25,7 @@ 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";
|
||||
@@ -59,6 +60,18 @@ 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
|
||||
@@ -67,6 +80,8 @@ 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`,
|
||||
@@ -78,12 +93,22 @@ 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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Group, Text } from "@mantine/core";
|
||||
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 { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { playPopSound, playTrashSound } from "../audio/board-sounds";
|
||||
|
||||
interface BoardSelectionContextValue {
|
||||
selectedItemIds: Set<string>;
|
||||
toggleSelectItem: (id: string) => void;
|
||||
clearSelection: () => void;
|
||||
isSelected: (id: string) => boolean;
|
||||
removeSelectedItems: () => void;
|
||||
moveSelectedItemsToSection: (sectionId: string) => void;
|
||||
}
|
||||
|
||||
const BoardSelectionContext = createContext<BoardSelectionContextValue | null>(null);
|
||||
|
||||
export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
|
||||
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
const t = useI18n();
|
||||
|
||||
// Clear selection if leaving edit mode
|
||||
useEffect(() => {
|
||||
if (!isEditMode) {
|
||||
setSelectedItemIds(new Set());
|
||||
}
|
||||
}, [isEditMode]);
|
||||
|
||||
// Clear selection on Escape key
|
||||
useEffect(() => {
|
||||
if (selectedItemIds.size === 0) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setSelectedItemIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedItemIds.size]);
|
||||
|
||||
const toggleSelectItem = useCallback((id: string) => {
|
||||
setSelectedItemIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
playPopSound();
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedItemIds(new Set());
|
||||
}, []);
|
||||
|
||||
const isSelected = useCallback((id: string) => selectedItemIds.has(id), [selectedItemIds]);
|
||||
|
||||
const removeSelectedItems = useCallback(() => {
|
||||
if (selectedItemIds.size === 0) return;
|
||||
|
||||
const idsToRemove = Array.from(selectedItemIds);
|
||||
const count = idsToRemove.length;
|
||||
const removedItems = board.items.filter((item) => idsToRemove.includes(item.id));
|
||||
|
||||
playTrashSound();
|
||||
clearSelection();
|
||||
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
items: previous.items.filter((item) => !idsToRemove.includes(item.id)),
|
||||
}));
|
||||
|
||||
const notificationId = `batch-remove-${Date.now()}`;
|
||||
showSuccessNotification({
|
||||
id: notificationId,
|
||||
title: t("common.success"),
|
||||
message: (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm">{count === 1 ? "1 item removed" : `${count} items removed`}</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
onClick={() => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
items: [...previous.items, ...removedItems],
|
||||
}));
|
||||
notifications.hide(notificationId);
|
||||
}}
|
||||
>
|
||||
{t("common.action.undo")}
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
autoClose: 10_000,
|
||||
});
|
||||
}, [board.items, clearSelection, selectedItemIds, t, updateBoard]);
|
||||
|
||||
const moveSelectedItemsToSection = useCallback(
|
||||
(targetSectionId: string) => {
|
||||
if (selectedItemIds.size === 0) return;
|
||||
|
||||
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,
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
clearSelection();
|
||||
},
|
||||
[clearSelection, currentLayoutId, selectedItemIds, updateBoard],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
selectedItemIds,
|
||||
toggleSelectItem,
|
||||
clearSelection,
|
||||
isSelected,
|
||||
removeSelectedItems,
|
||||
moveSelectedItemsToSection,
|
||||
}),
|
||||
[clearSelection, isSelected, moveSelectedItemsToSection, removeSelectedItems, selectedItemIds, toggleSelectItem],
|
||||
);
|
||||
|
||||
return <BoardSelectionContext.Provider value={value}>{children}</BoardSelectionContext.Provider>;
|
||||
};
|
||||
|
||||
export const useBoardSelection = () => {
|
||||
const context = useContext(BoardSelectionContext);
|
||||
if (!context) {
|
||||
throw new Error("useBoardSelection must be used within a BoardSelectionProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Badge, Button, Group, Menu, Paper, Text, Tooltip } from "@mantine/core";
|
||||
import { IconCheck, IconFolderShare, IconTrash, IconX } from "@tabler/icons-react";
|
||||
|
||||
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 type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useBoardSelection } from "./board-selection-context";
|
||||
|
||||
export const BoardSelectionToolbar = () => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const { selectedItemIds, clearSelection, removeSelectedItems, moveSelectedItemsToSection } = useBoardSelection();
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
|
||||
const t = useI18n();
|
||||
|
||||
if (!isEditMode || selectedItemIds.size === 0) return null;
|
||||
|
||||
const count = selectedItemIds.size;
|
||||
|
||||
const mainCanvasSection = board.sections
|
||||
.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
const placementOptions = [
|
||||
...(mainCanvasSection ? [{ value: mainCanvasSection.id, label: t("item.create.destination.mainCanvas") }] : []),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "empty") return [];
|
||||
const lane = getRootSectionLane(section.xOffset);
|
||||
if (lane === "main" || !currentLayout || getBoardLaneColumnCount(currentLayout, lane) === 0) return [];
|
||||
return [
|
||||
{
|
||||
value: section.id,
|
||||
label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
|
||||
},
|
||||
];
|
||||
}),
|
||||
...board.sections
|
||||
.filter((section) => section.kind === "container")
|
||||
.map((section) => ({
|
||||
value: section.id,
|
||||
label: section.options.title.trim() || t("section.container.untitled"),
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="xl"
|
||||
radius="xl"
|
||||
p="xs"
|
||||
bg="light-dark(var(--mantine-color-body), var(--mantine-color-dark-7))"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 28,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 1000,
|
||||
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.24)",
|
||||
backdropFilter: "blur(12px)",
|
||||
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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={15} />}
|
||||
onClick={removeSelectedItems}
|
||||
>
|
||||
Remove
|
||||
</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>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user