feat(shared-ui): add TagChip component and tag component tests

Add compact inline TagChip for list items/cards (smaller than TagBadge).
Set up vitest with jsdom for shared-ui package and add 44 tests covering
TagChip, TagBadge, TagColorPicker, TagSelector, and constants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 14:24:19 +02:00
parent f2af192172
commit 04fcbd15c9
18 changed files with 2017 additions and 1104 deletions

View file

@ -11,6 +11,7 @@ export { GlassCard, StatRow } from './molecules';
// Tags
export {
TagBadge,
TagChip,
TagColorPicker,
TagEditModal,
TagSelector,

View file

@ -13,6 +13,7 @@ export { GlassCard, StatRow } from './stats';
// Tag components
export {
TagBadge,
TagChip,
TagColorPicker,
TagEditModal,
TagSelector,

View file

@ -0,0 +1,92 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import TagBadge from './TagBadge.svelte';
describe('TagBadge', () => {
it('renders tag name from name field', () => {
render(TagBadge, { props: { tag: { name: 'Wichtig', color: '#ef4444' } } });
expect(screen.getByText('Wichtig')).toBeInTheDocument();
});
it('renders tag name from text field (compat)', () => {
render(TagBadge, { props: { tag: { text: 'Fallback' } } });
expect(screen.getByText('Fallback')).toBeInTheDocument();
});
it('reads color from style.color (new format)', () => {
const { container } = render(TagBadge, {
props: { tag: { name: 'Test', style: { color: '#22c55e' } } },
});
const badge = container.querySelector('span')!;
expect(badge.style.color).toBe('rgb(34, 197, 94)');
});
it('reads color from color field (old format)', () => {
const { container } = render(TagBadge, {
props: { tag: { name: 'Test', color: '#f97316' } },
});
const badge = container.querySelector('span')!;
expect(badge.style.color).toBe('rgb(249, 115, 22)');
});
it('defaults to blue when no color', () => {
const { container } = render(TagBadge, {
props: { tag: { name: 'NoColor' } },
});
const badge = container.querySelector('span')!;
expect(badge.style.color).toBe('rgb(59, 130, 246)');
});
it('shows color dot indicator', () => {
const { container } = render(TagBadge, {
props: { tag: { name: 'Test', color: '#ef4444' } },
});
const dot = container.querySelector('.rounded-full.h-2.w-2');
expect(dot).toBeInTheDocument();
});
it('shows remove button when removable', () => {
const onRemove = vi.fn();
render(TagBadge, {
props: { tag: { name: 'Remove Me' }, removable: true, onRemove },
});
const removeBtn = screen.getByRole('button', { name: 'Remove tag' });
expect(removeBtn).toBeInTheDocument();
});
it('calls onRemove when remove button clicked', async () => {
const onRemove = vi.fn();
render(TagBadge, {
props: { tag: { name: 'Remove Me' }, removable: true, onRemove },
});
const removeBtn = screen.getByRole('button', { name: 'Remove tag' });
await fireEvent.click(removeBtn);
expect(onRemove).toHaveBeenCalledOnce();
});
it('is clickable when clickable prop is set', () => {
const { container } = render(TagBadge, {
props: { tag: { name: 'Click' }, clickable: true, onClick: vi.fn() },
});
const badge = container.querySelector('[role="button"]');
expect(badge).toBeInTheDocument();
});
it('calls onClick when clicked in clickable mode', async () => {
const onClick = vi.fn();
const { container } = render(TagBadge, {
props: { tag: { name: 'Click' }, clickable: true, onClick },
});
const badge = container.querySelector('[role="button"]')!;
await fireEvent.click(badge);
expect(onClick).toHaveBeenCalledOnce();
});
it('is not clickable by default', () => {
const { container } = render(TagBadge, {
props: { tag: { name: 'Static' } },
});
const badge = container.querySelector('[role="button"]');
expect(badge).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { DEFAULT_TAG_COLOR } from './constants';
/**
* Compact inline tag chip for use in list items, cards, and metadata rows.
* Smaller than TagBadge — designed to sit alongside other metadata like dates and icons.
*/
interface Props {
name: string;
color?: string | null;
/** Extra CSS classes */
class?: string;
}
let { name, color, class: className = '' }: Props = $props();
const tagColor = $derived(color ?? DEFAULT_TAG_COLOR);
</script>
<span
class="inline-block rounded-full px-1.5 py-0.5 text-[0.625rem] font-medium leading-tight {className}"
style="background: color-mix(in srgb, {tagColor} 15%, transparent); color: {tagColor}"
>
{name}
</span>

View file

@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import TagChip from './TagChip.svelte';
describe('TagChip', () => {
it('renders tag name', () => {
render(TagChip, { props: { name: 'Arbeit' } });
expect(screen.getByText('Arbeit')).toBeInTheDocument();
});
it('renders as a span element', () => {
const { container } = render(TagChip, { props: { name: 'Test', color: '#ef4444' } });
const chip = container.querySelector('span');
expect(chip).toBeInTheDocument();
expect(chip!.textContent?.trim()).toBe('Test');
});
it('has compact chip styling classes', () => {
const { container } = render(TagChip, { props: { name: 'Tag' } });
const chip = container.querySelector('span')!;
expect(chip.classList.contains('rounded-full')).toBe(true);
expect(chip.classList.contains('text-[0.625rem]')).toBe(true);
expect(chip.classList.contains('font-medium')).toBe(true);
expect(chip.classList.contains('px-1.5')).toBe(true);
expect(chip.classList.contains('py-0.5')).toBe(true);
});
it('renders different tag names', () => {
render(TagChip, { props: { name: 'Arbeit' } });
expect(screen.getByText('Arbeit')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,76 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import TagColorPicker from './TagColorPicker.svelte';
import { TAG_COLORS, DEFAULT_TAG_COLOR } from './constants';
describe('TagColorPicker', () => {
it('renders all 12 color options', () => {
render(TagColorPicker, { props: { onColorChange: vi.fn() } });
const radioGroup = screen.getByRole('radiogroup');
const buttons = radioGroup.querySelectorAll('button');
expect(buttons).toHaveLength(12);
});
it('each button has correct aria-label', () => {
render(TagColorPicker, { props: { onColorChange: vi.fn() } });
for (const color of TAG_COLORS) {
expect(screen.getByRole('radio', { name: color.name })).toBeInTheDocument();
}
});
it('marks default color as selected', () => {
render(TagColorPicker, {
props: { selectedColor: DEFAULT_TAG_COLOR, onColorChange: vi.fn() },
});
const blueBtn = screen.getByRole('radio', { name: 'blue' });
expect(blueBtn.getAttribute('aria-checked')).toBe('true');
});
it('marks non-selected colors as unchecked', () => {
render(TagColorPicker, {
props: { selectedColor: '#ef4444', onColorChange: vi.fn() },
});
const blueBtn = screen.getByRole('radio', { name: 'blue' });
expect(blueBtn.getAttribute('aria-checked')).toBe('false');
const redBtn = screen.getByRole('radio', { name: 'red' });
expect(redBtn.getAttribute('aria-checked')).toBe('true');
});
it('calls onColorChange when a color is clicked', async () => {
const onColorChange = vi.fn();
render(TagColorPicker, { props: { onColorChange } });
const greenBtn = screen.getByRole('radio', { name: 'green' });
await fireEvent.click(greenBtn);
expect(onColorChange).toHaveBeenCalledWith('#22c55e');
});
it('supports keyboard selection with Enter', async () => {
const onColorChange = vi.fn();
render(TagColorPicker, { props: { onColorChange } });
const tealBtn = screen.getByRole('radio', { name: 'teal' });
await fireEvent.keyDown(tealBtn, { key: 'Enter' });
expect(onColorChange).toHaveBeenCalledWith('#14b8a6');
});
it('supports keyboard selection with Space', async () => {
const onColorChange = vi.fn();
render(TagColorPicker, { props: { onColorChange } });
const pinkBtn = screen.getByRole('radio', { name: 'pink' });
await fireEvent.keyDown(pinkBtn, { key: ' ' });
expect(onColorChange).toHaveBeenCalledWith('#ec4899');
});
it('renders different sizes', () => {
const { container: smContainer } = render(TagColorPicker, {
props: { onColorChange: vi.fn(), size: 'sm' },
});
const smBtn = smContainer.querySelector('button')!;
expect(smBtn.classList.contains('w-6')).toBe(true);
const { container: lgContainer } = render(TagColorPicker, {
props: { onColorChange: vi.fn(), size: 'lg' },
});
const lgBtn = lgContainer.querySelector('button')!;
expect(lgBtn.classList.contains('w-10')).toBe(true);
});
});

View file

@ -0,0 +1,147 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import TagSelector from './TagSelector.svelte';
import type { Tag } from './constants';
const mockTags: Tag[] = [
{ id: '1', name: 'Arbeit', color: '#3b82f6' },
{ id: '2', name: 'Persönlich', color: '#22c55e' },
{ id: '3', name: 'Familie', color: '#ec4899' },
{ id: '4', name: 'Wichtig', color: '#ef4444' },
];
describe('TagSelector', () => {
it('renders add-tag button', () => {
render(TagSelector, {
props: { tags: mockTags, selectedTags: [], onTagsChange: vi.fn() },
});
expect(screen.getByText('Tag hinzufügen')).toBeInTheDocument();
});
it('renders selected tags as badges', () => {
render(TagSelector, {
props: {
tags: mockTags,
selectedTags: [mockTags[0], mockTags[2]],
onTagsChange: vi.fn(),
},
});
expect(screen.getByText('Arbeit')).toBeInTheDocument();
expect(screen.getByText('Familie')).toBeInTheDocument();
});
it('opens dropdown on button click', async () => {
render(TagSelector, {
props: { tags: mockTags, selectedTags: [], onTagsChange: vi.fn() },
});
await fireEvent.click(screen.getByText('Tag hinzufügen'));
expect(screen.getByPlaceholderText('Tag suchen...')).toBeInTheDocument();
});
it('shows unselected tags in dropdown', async () => {
render(TagSelector, {
props: {
tags: mockTags,
selectedTags: [mockTags[0]],
onTagsChange: vi.fn(),
},
});
await fireEvent.click(screen.getByText('Tag hinzufügen'));
// Should not show already selected
const dropdownItems = screen.getAllByRole('button');
const itemNames = dropdownItems.map((b) => b.textContent?.trim());
expect(itemNames).not.toContain('Arbeit');
expect(itemNames).toContain('Persönlich');
});
it('calls onTagsChange when a tag is selected', async () => {
const onTagsChange = vi.fn();
render(TagSelector, {
props: { tags: mockTags, selectedTags: [], onTagsChange },
});
await fireEvent.click(screen.getByText('Tag hinzufügen'));
await fireEvent.click(screen.getByText('Wichtig'));
expect(onTagsChange).toHaveBeenCalledWith([mockTags[3]]);
});
it('calls onTagsChange when a tag is removed', async () => {
const onTagsChange = vi.fn();
render(TagSelector, {
props: {
tags: mockTags,
selectedTags: [mockTags[0], mockTags[1]],
onTagsChange,
},
});
// Click the remove button on Arbeit badge
const removeButtons = screen.getAllByRole('button', { name: 'Remove tag' });
await fireEvent.click(removeButtons[0]);
expect(onTagsChange).toHaveBeenCalledWith([mockTags[1]]);
});
it('filters tags by search query', async () => {
render(TagSelector, {
props: { tags: mockTags, selectedTags: [], onTagsChange: vi.fn() },
});
await fireEvent.click(screen.getByText('Tag hinzufügen'));
const searchInput = screen.getByPlaceholderText('Tag suchen...');
await fireEvent.input(searchInput, { target: { value: 'Wich' } });
expect(screen.getByText('Wichtig')).toBeInTheDocument();
expect(screen.queryByText('Arbeit')).not.toBeInTheDocument();
});
it('hides add button when maxTags reached', () => {
render(TagSelector, {
props: {
tags: mockTags,
selectedTags: [mockTags[0], mockTags[1]],
onTagsChange: vi.fn(),
maxTags: 2,
},
});
expect(screen.queryByText('Tag hinzufügen')).not.toBeInTheDocument();
});
it('shows create button when onCreateTag is provided', async () => {
render(TagSelector, {
props: {
tags: mockTags,
selectedTags: [],
onTagsChange: vi.fn(),
onCreateTag: vi.fn(),
},
});
await fireEvent.click(screen.getByText('Tag hinzufügen'));
expect(screen.getByText('Neuen Tag erstellen')).toBeInTheDocument();
});
it('does not show create button when onCreateTag is not provided', async () => {
render(TagSelector, {
props: { tags: mockTags, selectedTags: [], onTagsChange: vi.fn() },
});
await fireEvent.click(screen.getByText('Tag hinzufügen'));
expect(screen.queryByText('Neuen Tag erstellen')).not.toBeInTheDocument();
});
it('supports custom labels', () => {
render(TagSelector, {
props: {
tags: mockTags,
selectedTags: [],
onTagsChange: vi.fn(),
addTagLabel: 'Label hinzufügen',
},
});
expect(screen.getByText('Label hinzufügen')).toBeInTheDocument();
});
it('closes dropdown on Escape', async () => {
render(TagSelector, {
props: { tags: mockTags, selectedTags: [], onTagsChange: vi.fn() },
});
await fireEvent.click(screen.getByText('Tag hinzufügen'));
expect(screen.getByPlaceholderText('Tag suchen...')).toBeInTheDocument();
await fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.queryByPlaceholderText('Tag suchen...')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { TAG_COLORS, DEFAULT_TAG_COLOR, getRandomTagColor, getTagColorByName } from './constants';
describe('TAG_COLORS', () => {
it('contains 12 colors', () => {
expect(TAG_COLORS).toHaveLength(12);
});
it('each color has name and hex', () => {
for (const color of TAG_COLORS) {
expect(color.name).toBeTruthy();
expect(color.hex).toMatch(/^#[0-9a-f]{6}$/i);
}
});
it('has no duplicate names', () => {
const names = TAG_COLORS.map((c) => c.name);
expect(new Set(names).size).toBe(names.length);
});
it('has no duplicate hex values', () => {
const hexes = TAG_COLORS.map((c) => c.hex);
expect(new Set(hexes).size).toBe(hexes.length);
});
});
describe('DEFAULT_TAG_COLOR', () => {
it('is blue (#3b82f6)', () => {
expect(DEFAULT_TAG_COLOR).toBe('#3b82f6');
});
it('exists in the TAG_COLORS palette', () => {
expect(TAG_COLORS.some((c) => c.hex === DEFAULT_TAG_COLOR)).toBe(true);
});
});
describe('getRandomTagColor', () => {
it('returns a hex color from the palette', () => {
const validHexes = new Set(TAG_COLORS.map((c) => c.hex));
for (let i = 0; i < 50; i++) {
expect(validHexes.has(getRandomTagColor())).toBe(true);
}
});
});
describe('getTagColorByName', () => {
it('returns correct hex for known names', () => {
expect(getTagColorByName('red')).toBe('#ef4444');
expect(getTagColorByName('blue')).toBe('#3b82f6');
expect(getTagColorByName('green')).toBe('#22c55e');
});
it('returns default color for unknown names', () => {
expect(getTagColorByName('nonexistent' as any)).toBe(DEFAULT_TAG_COLOR);
});
});

View file

@ -1,5 +1,6 @@
// Components
export { default as TagBadge } from './TagBadge.svelte';
export { default as TagChip } from './TagChip.svelte';
export { default as TagColorPicker } from './TagColorPicker.svelte';
export { default as TagEditModal } from './TagEditModal.svelte';
export { default as TagSelector } from './TagSelector.svelte';

View file

@ -0,0 +1,5 @@
import '@testing-library/jest-dom/vitest';
import { expect } from 'vitest';
import * as matchers from '@testing-library/jest-dom/matchers';
expect.extend(matchers);