mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 11:41:23 +02:00
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:
parent
f2af192172
commit
04fcbd15c9
18 changed files with 2017 additions and 1104 deletions
|
|
@ -11,6 +11,7 @@ export { GlassCard, StatRow } from './molecules';
|
|||
// Tags
|
||||
export {
|
||||
TagBadge,
|
||||
TagChip,
|
||||
TagColorPicker,
|
||||
TagEditModal,
|
||||
TagSelector,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export { GlassCard, StatRow } from './stats';
|
|||
// Tag components
|
||||
export {
|
||||
TagBadge,
|
||||
TagChip,
|
||||
TagColorPicker,
|
||||
TagEditModal,
|
||||
TagSelector,
|
||||
|
|
|
|||
92
packages/shared-ui/src/molecules/tags/TagBadge.test.ts
Normal file
92
packages/shared-ui/src/molecules/tags/TagBadge.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
26
packages/shared-ui/src/molecules/tags/TagChip.svelte
Normal file
26
packages/shared-ui/src/molecules/tags/TagChip.svelte
Normal 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>
|
||||
32
packages/shared-ui/src/molecules/tags/TagChip.test.ts
Normal file
32
packages/shared-ui/src/molecules/tags/TagChip.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
76
packages/shared-ui/src/molecules/tags/TagColorPicker.test.ts
Normal file
76
packages/shared-ui/src/molecules/tags/TagColorPicker.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
147
packages/shared-ui/src/molecules/tags/TagSelector.test.ts
Normal file
147
packages/shared-ui/src/molecules/tags/TagSelector.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
56
packages/shared-ui/src/molecules/tags/constants.test.ts
Normal file
56
packages/shared-ui/src/molecules/tags/constants.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
5
packages/shared-ui/src/test/setup.ts
Normal file
5
packages/shared-ui/src/test/setup.ts
Normal 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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue