Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
"name": "Total JS (gzip)",
"path": "dist/assets/*.js",
"gzip": true,
"limit": "1644 kB"
"limit": "1652 kB"
}
],
"dependencies": {
Expand Down
3 changes: 3 additions & 0 deletions scripts/design-system-allowlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ describe('DesignActions', () => {
onLoad: vi.fn(),
onDownloadJSON: vi.fn(),
onRename: vi.fn(),
onEditTags: vi.fn(),
onDuplicate: vi.fn(),
onDelete: vi.fn(),
};
Expand Down Expand Up @@ -92,6 +93,20 @@ describe('DesignActions', () => {
expect(onRename).toHaveBeenCalled();
});

it('calls onEditTags when Edit tags is clicked', async () => {
const onEditTags = vi.fn();
render(<DesignActions {...defaultProps} onEditTags={onEditTags} />);

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(<DesignActions {...defaultProps} onDuplicate={onDuplicate} />);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface DesignActionsProps {
onLoad: () => void;
onDownloadJSON: () => void;
onRename: () => void;
onEditTags: () => void;
onDuplicate: () => void;
onDelete: () => void;
}
Expand All @@ -24,6 +25,7 @@ export function DesignActions({
onLoad,
onDownloadJSON,
onRename,
onEditTags,
onDuplicate,
onDelete,
}: DesignActionsProps) {
Expand Down Expand Up @@ -199,6 +201,29 @@ export function DesignActions({
{t('common.rename')}
</button>

{/* Edit tags */}
<button
role="menuitem"
onClick={handleAction(onEditTags)}
className="w-full px-3 py-2.5 text-left text-sm text-content hover:bg-surface flex items-center gap-2"
>
<svg
className="w-4 h-4 text-content-secondary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 7h.01M7 3h5a1.99 1.99 0 011.414.586l7 7a2 2 0 010 2.828l-5 5a2 2 0 01-2.828 0l-7-7A1.99 1.99 0 013 7V4a1 1 0 011-1z"
/>
</svg>
{t('binDesigner.tags.editAction')}
</button>

{/* Duplicate */}
<button
role="menuitem"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ describe('DesignGridItem', () => {
onSelect: vi.fn(),
onDownloadJSON: vi.fn(),
onRename: vi.fn(),
onEditTags: vi.fn(),
onDuplicate: vi.fn(),
onDelete: vi.fn(),
onFocus: vi.fn(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useTranslation, useFormatting } from '@/i18n';
import { useInlineEdit } from '@/shared/hooks';
import { Checkbox } from '@/design-system';
import { BinDesignThumbnail } from '../BinDesignThumbnail';
import { DesignActions } from '../DesignActions';
import { DesignTagChips } from '../DesignTagChips';
import type { SavedDesign } from '../../types';

interface DesignGridItemProps {
Expand All @@ -11,10 +13,15 @@ interface DesignGridItemProps {
onSelect: () => void;
onDownloadJSON: () => void;
onRename: (newName: string) => void;
onEditTags: () => void;
onDuplicate: () => void;
onDelete: () => void;
onFocus: () => void;
itemRef: (el: HTMLDivElement | null) => void;
/** Bulk-selection mode: clicking toggles selection instead of loading. */
selectionActive?: boolean;
isSelected?: boolean;
onToggleSelect?: () => void;
}

/**
Expand All @@ -29,10 +36,14 @@ export function DesignGridItem({
onSelect,
onDownloadJSON,
onRename,
onEditTags,
onDuplicate,
onDelete,
onFocus,
itemRef,
selectionActive = false,
isSelected = false,
onToggleSelect,
}: DesignGridItemProps) {
const t = useTranslation();
const { formatRelativeDate } = useFormatting();
Expand All @@ -53,9 +64,14 @@ export function DesignGridItem({
const { width, depth, height, compartments } = design.params;
const numCompartments = new Set(compartments.cells).size;

const activate = () => {
if (selectionActive) onToggleSelect?.();
else onSelect();
};

const handleClick = () => {
if (!isEditing) {
onSelect();
activate();
}
};

Expand All @@ -66,7 +82,7 @@ export function DesignGridItem({
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect();
activate();
}
};

Expand All @@ -83,9 +99,32 @@ export function DesignGridItem({
group relative flex flex-col rounded-lg border-2
cursor-pointer transition-colors outline-none overflow-hidden
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-secondary
${isActive ? 'border-accent' : 'border-transparent hover:border-accent/50'}
${
isSelected
? 'border-accent ring-2 ring-accent/40'
: isActive
? 'border-accent'
: 'border-transparent hover:border-accent/50'
}
`}
>
{/* Selection checkbox (bulk mode) */}
{selectionActive && (
<div
className="absolute top-1.5 left-1.5 z-10"
role="presentation"
onClick={(e) => {
e.stopPropagation();
onToggleSelect?.();
}}
>
<Checkbox
checked={isSelected}
aria-label={t('binDesigner.selectDesign', { name: design.name })}
/>
</div>
)}

{/* Active badge */}
{isActive && (
<span className="absolute top-1.5 right-1.5 z-10 rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-surface">
Expand Down Expand Up @@ -136,8 +175,15 @@ export function DesignGridItem({
` · ${t('binDesigner.compartmentsShort', { count: numCompartments })}`}
</p>

{/* Date and actions row */}
<div className="flex items-center justify-between mt-1.5">
{design.tags && design.tags.length > 0 && (
<div className="mt-1">
<DesignTagChips tags={design.tags} />
</div>
)}

{/* Date and actions row — pinned to the bottom so dates align across a
row regardless of how many tags each card shows */}
<div className="flex items-center justify-between mt-auto pt-1.5">
<p className="text-[10px] text-content-tertiary">
{formatRelativeDate(design.updatedAt)}
</p>
Expand All @@ -150,6 +196,7 @@ export function DesignGridItem({
onLoad={onSelect}
onDownloadJSON={onDownloadJSON}
onRename={startEditing}
onEditTags={onEditTags}
onDuplicate={onDuplicate}
onDelete={onDelete}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<BulkActionBar count={count} {...handlers} />);
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();
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-stroke bg-surface px-3 py-2">
<span className="text-sm font-medium text-content">
{t('binDesigner.bulk.selected', { count })}
</span>
<button
type="button"
onClick={onSelectAll}
className="text-xs font-medium text-accent hover:underline"
>
{t('binDesigner.bulk.selectAll')}
</button>
<div className="ml-auto flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={onTag} disabled={none}>
{t('binDesigner.bulk.tag')}
</Button>
<Button variant="secondary" size="sm" onClick={onExport} disabled={none}>
{t('binDesigner.bulk.export')}
</Button>
<Button variant="danger" size="sm" onClick={onDelete} disabled={none}>
{t('binDesigner.bulk.delete')}
</Button>
<Button variant="ghost" size="sm" onClick={onCancel}>
{t('binDesigner.bulk.cancelSelection')}
</Button>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { BulkActionBar } from './BulkActionBar';
Loading
Loading