diff --git a/package.json b/package.json
index 512e2fb13..17f6fdfff 100644
--- a/package.json
+++ b/package.json
@@ -73,7 +73,7 @@
"name": "Total JS (gzip)",
"path": "dist/assets/*.js",
"gzip": true,
- "limit": "1644 kB"
+ "limit": "1652 kB"
}
],
"dependencies": {
diff --git a/scripts/design-system-allowlist.txt b/scripts/design-system-allowlist.txt
index 9276db2cc..f105df12f 100644
--- a/scripts/design-system-allowlist.txt
+++ b/scripts/design-system-allowlist.txt
@@ -165,3 +165,6 @@ src/shared/components/Toast/Toast.tsx
src/shared/components/ToolSwitcher/ToolSwitcher.tsx
src/shared/components/TwoClickDeleteButton/TwoClickDeleteButton.tsx
src/shared/components/ViewModeToggle/ViewModeToggle.tsx
+src/features/bin-designer/components/DesignListDialog/TagInput/TagInput.tsx
+src/features/bin-designer/components/DesignListDialog/TagFilterBar/TagFilterBar.tsx
+src/features/bin-designer/components/DesignListDialog/BulkActionBar/BulkActionBar.tsx
diff --git a/src/features/bin-designer/components/DesignActions/DesignActions.test.tsx b/src/features/bin-designer/components/DesignActions/DesignActions.test.tsx
index f108b34ce..4f224e466 100644
--- a/src/features/bin-designer/components/DesignActions/DesignActions.test.tsx
+++ b/src/features/bin-designer/components/DesignActions/DesignActions.test.tsx
@@ -20,6 +20,7 @@ describe('DesignActions', () => {
onLoad: vi.fn(),
onDownloadJSON: vi.fn(),
onRename: vi.fn(),
+ onEditTags: vi.fn(),
onDuplicate: vi.fn(),
onDelete: vi.fn(),
};
@@ -92,6 +93,20 @@ describe('DesignActions', () => {
expect(onRename).toHaveBeenCalled();
});
+ it('calls onEditTags when Edit tags is clicked', async () => {
+ const onEditTags = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: /more actions/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('menuitem', { name: /edit tags/i })).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByRole('menuitem', { name: /edit tags/i }));
+ expect(onEditTags).toHaveBeenCalled();
+ });
+
it('calls onDuplicate when Duplicate is clicked', async () => {
const onDuplicate = vi.fn();
render();
diff --git a/src/features/bin-designer/components/DesignActions/DesignActions.tsx b/src/features/bin-designer/components/DesignActions/DesignActions.tsx
index c3b161989..b1c687818 100644
--- a/src/features/bin-designer/components/DesignActions/DesignActions.tsx
+++ b/src/features/bin-designer/components/DesignActions/DesignActions.tsx
@@ -10,6 +10,7 @@ interface DesignActionsProps {
onLoad: () => void;
onDownloadJSON: () => void;
onRename: () => void;
+ onEditTags: () => void;
onDuplicate: () => void;
onDelete: () => void;
}
@@ -24,6 +25,7 @@ export function DesignActions({
onLoad,
onDownloadJSON,
onRename,
+ onEditTags,
onDuplicate,
onDelete,
}: DesignActionsProps) {
@@ -199,6 +201,29 @@ export function DesignActions({
{t('common.rename')}
+ {/* Edit tags */}
+
+
{/* Duplicate */}
- {/* Date and actions row */}
-
+ {design.tags && design.tags.length > 0 && (
+
+
+
+ )}
+
+ {/* Date and actions row — pinned to the bottom so dates align across a
+ row regardless of how many tags each card shows */}
+
{formatRelativeDate(design.updatedAt)}
@@ -150,6 +196,7 @@ export function DesignGridItem({
onLoad={onSelect}
onDownloadJSON={onDownloadJSON}
onRename={startEditing}
+ onEditTags={onEditTags}
onDuplicate={onDuplicate}
onDelete={onDelete}
/>
diff --git a/src/features/bin-designer/components/DesignListDialog/BulkActionBar/BulkActionBar.test.tsx b/src/features/bin-designer/components/DesignListDialog/BulkActionBar/BulkActionBar.test.tsx
new file mode 100644
index 000000000..9e1d75aef
--- /dev/null
+++ b/src/features/bin-designer/components/DesignListDialog/BulkActionBar/BulkActionBar.test.tsx
@@ -0,0 +1,48 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { BulkActionBar } from './BulkActionBar';
+
+function setup(count: number) {
+ const handlers = {
+ onSelectAll: vi.fn(),
+ onTag: vi.fn(),
+ onExport: vi.fn(),
+ onDelete: vi.fn(),
+ onCancel: vi.fn(),
+ };
+ render(
);
+ return handlers;
+}
+
+describe('BulkActionBar', () => {
+ it('shows the selected count', () => {
+ setup(3);
+ expect(screen.getByText('3 selected')).toBeInTheDocument();
+ });
+
+ it('disables bulk actions when nothing is selected', () => {
+ setup(0);
+ expect(screen.getByRole('button', { name: /^delete$/i })).toBeDisabled();
+ expect(screen.getByRole('button', { name: /^export$/i })).toBeDisabled();
+ expect(screen.getByRole('button', { name: /^tag$/i })).toBeDisabled();
+ });
+
+ it('fires actions when selected', () => {
+ const h = setup(2);
+ fireEvent.click(screen.getByRole('button', { name: /^delete$/i }));
+ fireEvent.click(screen.getByRole('button', { name: /^export$/i }));
+ fireEvent.click(screen.getByRole('button', { name: /^tag$/i }));
+ expect(h.onDelete).toHaveBeenCalled();
+ expect(h.onExport).toHaveBeenCalled();
+ expect(h.onTag).toHaveBeenCalled();
+ });
+
+ it('select-all and cancel are always available', () => {
+ const h = setup(0);
+ fireEvent.click(screen.getByText(/select all/i));
+ fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
+ expect(h.onSelectAll).toHaveBeenCalled();
+ expect(h.onCancel).toHaveBeenCalled();
+ });
+});
diff --git a/src/features/bin-designer/components/DesignListDialog/BulkActionBar/BulkActionBar.tsx b/src/features/bin-designer/components/DesignListDialog/BulkActionBar/BulkActionBar.tsx
new file mode 100644
index 000000000..057c39adf
--- /dev/null
+++ b/src/features/bin-designer/components/DesignListDialog/BulkActionBar/BulkActionBar.tsx
@@ -0,0 +1,53 @@
+import { useTranslation } from '@/i18n';
+import { Button } from '@/design-system';
+
+interface BulkActionBarProps {
+ count: number;
+ onSelectAll: () => void;
+ onTag: () => void;
+ onExport: () => void;
+ onDelete: () => void;
+ onCancel: () => void;
+}
+
+/** Action bar shown in bulk-selection mode. Bulk actions disable when nothing is selected. */
+export function BulkActionBar({
+ count,
+ onSelectAll,
+ onTag,
+ onExport,
+ onDelete,
+ onCancel,
+}: BulkActionBarProps) {
+ const t = useTranslation();
+ const none = count === 0;
+
+ return (
+
+
+ {t('binDesigner.bulk.selected', { count })}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/bin-designer/components/DesignListDialog/BulkActionBar/index.ts b/src/features/bin-designer/components/DesignListDialog/BulkActionBar/index.ts
new file mode 100644
index 000000000..d177e1ff9
--- /dev/null
+++ b/src/features/bin-designer/components/DesignListDialog/BulkActionBar/index.ts
@@ -0,0 +1 @@
+export { BulkActionBar } from './BulkActionBar';
diff --git a/src/features/bin-designer/components/DesignListDialog/DesignListDialog.tsx b/src/features/bin-designer/components/DesignListDialog/DesignListDialog.tsx
index 54eaaa2a8..a1dda1492 100644
--- a/src/features/bin-designer/components/DesignListDialog/DesignListDialog.tsx
+++ b/src/features/bin-designer/components/DesignListDialog/DesignListDialog.tsx
@@ -13,7 +13,14 @@ import {
deleteDesign,
duplicateDesign,
saveDesign,
+ updateDesignTags,
} from '@/features/bin-designer/storage/DesignerStorage';
+import { collectTags, filterByTags, toggleTag } from '@/features/bin-designer/utils/tagFilter';
+import { normalizeTags, tagsEqual } from '@/features/bin-designer/utils/tags';
+import { TagFilterBar } from './TagFilterBar';
+import { BulkActionBar } from './BulkActionBar';
+import { TagEditDialog } from './TagEditDialog';
+import { useDesignSelection } from './useDesignSelection';
import { removeRegistryEntry } from '../../store/customBinRegistry';
import { useDesignerStore } from '../../store';
import { useDesignerRouting } from '@/shared/hooks/useDesignerRouting';
@@ -61,6 +68,12 @@ export function DesignListDialog({ open, onClose }: DesignListDialogProps) {
const [sortBy, setSortBy] = useState
('recent');
const [focusedIndex, setFocusedIndex] = useState(0);
const [showImport, setShowImport] = useState(false);
+ const [activeTags, setActiveTags] = useState([]);
+ const [tagEdit, setTagEdit] = useState<{ mode: 'single' | 'bulk'; design?: SavedDesign } | null>(
+ null
+ );
+ const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
+ const selection = useDesignSelection();
const itemRefs = useRef