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

@ -25,6 +25,30 @@ const PUBLIC_CONTACTS_API_URL_CLIENT =
process.env.PUBLIC_CONTACTS_API_URL_CLIENT || process.env.PUBLIC_CONTACTS_API_URL || '';
const PUBLIC_GLITCHTIP_DSN = process.env.PUBLIC_GLITCHTIP_DSN || '';
// Sync server URL (WebSocket)
const PUBLIC_SYNC_SERVER_URL_CLIENT =
process.env.PUBLIC_SYNC_SERVER_URL_CLIENT || process.env.PUBLIC_SYNC_SERVER_URL || '';
// Additional backend URLs
const PUBLIC_CHAT_API_URL_CLIENT =
process.env.PUBLIC_CHAT_API_URL_CLIENT || process.env.PUBLIC_CHAT_API_URL || '';
const PUBLIC_STORAGE_API_URL_CLIENT =
process.env.PUBLIC_STORAGE_API_URL_CLIENT || process.env.PUBLIC_STORAGE_API_URL || '';
const PUBLIC_CARDS_API_URL_CLIENT =
process.env.PUBLIC_CARDS_API_URL_CLIENT || process.env.PUBLIC_CARDS_API_URL || '';
const PUBLIC_MUKKE_API_URL_CLIENT =
process.env.PUBLIC_MUKKE_API_URL_CLIENT || process.env.PUBLIC_MUKKE_API_URL || '';
const PUBLIC_NUTRIPHI_API_URL_CLIENT =
process.env.PUBLIC_NUTRIPHI_API_URL_CLIENT || process.env.PUBLIC_NUTRIPHI_API_URL || '';
const PUBLIC_ULOAD_SERVER_URL_CLIENT =
process.env.PUBLIC_ULOAD_SERVER_URL_CLIENT || process.env.PUBLIC_ULOAD_SERVER_URL || '';
const PUBLIC_MEMORO_SERVER_URL_CLIENT =
process.env.PUBLIC_MEMORO_SERVER_URL_CLIENT || process.env.PUBLIC_MEMORO_SERVER_URL || '';
const PUBLIC_MANA_MEDIA_URL_CLIENT =
process.env.PUBLIC_MANA_MEDIA_URL_CLIENT || process.env.PUBLIC_MANA_MEDIA_URL || '';
const PUBLIC_MANA_LLM_URL_CLIENT =
process.env.PUBLIC_MANA_LLM_URL_CLIENT || process.env.PUBLIC_MANA_LLM_URL || '';
// Map of app subdomains to internal paths
const APP_SUBDOMAINS = new Set([
'todo',
@ -72,6 +96,16 @@ window.__PUBLIC_TODO_API_URL__ = ${JSON.stringify(PUBLIC_TODO_API_URL_CLIENT)};
window.__PUBLIC_CALENDAR_API_URL__ = ${JSON.stringify(PUBLIC_CALENDAR_API_URL_CLIENT)};
window.__PUBLIC_CLOCK_API_URL__ = ${JSON.stringify(PUBLIC_CLOCK_API_URL_CLIENT)};
window.__PUBLIC_CONTACTS_API_URL__ = ${JSON.stringify(PUBLIC_CONTACTS_API_URL_CLIENT)};
window.__PUBLIC_SYNC_SERVER_URL__ = ${JSON.stringify(PUBLIC_SYNC_SERVER_URL_CLIENT)};
window.__PUBLIC_CHAT_API_URL__ = ${JSON.stringify(PUBLIC_CHAT_API_URL_CLIENT)};
window.__PUBLIC_STORAGE_API_URL__ = ${JSON.stringify(PUBLIC_STORAGE_API_URL_CLIENT)};
window.__PUBLIC_CARDS_API_URL__ = ${JSON.stringify(PUBLIC_CARDS_API_URL_CLIENT)};
window.__PUBLIC_MUKKE_API_URL__ = ${JSON.stringify(PUBLIC_MUKKE_API_URL_CLIENT)};
window.__PUBLIC_NUTRIPHI_API_URL__ = ${JSON.stringify(PUBLIC_NUTRIPHI_API_URL_CLIENT)};
window.__PUBLIC_ULOAD_SERVER_URL__ = ${JSON.stringify(PUBLIC_ULOAD_SERVER_URL_CLIENT)};
window.__PUBLIC_MEMORO_SERVER_URL__ = ${JSON.stringify(PUBLIC_MEMORO_SERVER_URL_CLIENT)};
window.__PUBLIC_MANA_MEDIA_URL__ = ${JSON.stringify(PUBLIC_MANA_MEDIA_URL_CLIENT)};
window.__PUBLIC_MANA_LLM_URL__ = ${JSON.stringify(PUBLIC_MANA_LLM_URL_CLIENT)};
window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
</script>`;
return injectUmamiAnalytics(html.replace('<head>', `<head>${envScript}`));
@ -85,7 +119,18 @@ window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
PUBLIC_CALENDAR_API_URL_CLIENT,
PUBLIC_CLOCK_API_URL_CLIENT,
PUBLIC_CONTACTS_API_URL_CLIENT,
],
PUBLIC_SYNC_SERVER_URL_CLIENT,
PUBLIC_CHAT_API_URL_CLIENT,
PUBLIC_STORAGE_API_URL_CLIENT,
PUBLIC_CARDS_API_URL_CLIENT,
PUBLIC_MUKKE_API_URL_CLIENT,
PUBLIC_NUTRIPHI_API_URL_CLIENT,
PUBLIC_ULOAD_SERVER_URL_CLIENT,
PUBLIC_MEMORO_SERVER_URL_CLIENT,
PUBLIC_MANA_MEDIA_URL_CLIENT,
PUBLIC_MANA_LLM_URL_CLIENT,
'wss://sync.mana.how',
].filter(Boolean),
});
return response;

View file

@ -2,9 +2,16 @@
import { getContext, onMount, tick } from 'svelte';
import { getDefaultCalendar, getCalendarColor } from '../queries';
import type { Calendar } from '../types';
import { format } from 'date-fns';
import { format, addMinutes } from 'date-fns';
import { de } from 'date-fns/locale';
import { X } from '@manacore/shared-icons';
import {
X,
Clock,
CalendarBlank,
MapPin,
ArrowsClockwise,
TextAlignLeft,
} from '@manacore/shared-icons';
interface Props {
startTime: Date;
@ -17,48 +24,76 @@
endTime: string;
isAllDay: boolean;
location: string | null;
description: string | null;
recurrenceRule: string | null;
}) => void;
onClose: () => void;
onExpand?: () => void;
}
let { startTime, endTime, position, onSave, onClose, onExpand }: Props = $props();
let { startTime, endTime, position, onSave, onClose }: Props = $props();
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
let title = $state('');
let location = $state('');
let description = $state('');
let isAllDay = $state(false);
let recurrenceRule = $state<string | null>(null);
let startDateStr = $state(format(startTime, 'yyyy-MM-dd'));
let startTimeStr = $state(format(startTime, 'HH:mm'));
let endDateStr = $state(format(endTime, 'yyyy-MM-dd'));
let endTimeStr = $state(format(endTime, 'HH:mm'));
let titleInput: HTMLInputElement;
let popoverEl: HTMLDivElement;
// Calculated popover position (adjusted to stay in viewport)
let popoverPos = $state({ top: 0, left: 0 });
const defaultCalendar = $derived(getDefaultCalendar(calendarsCtx.value));
const calendarColor = $derived(getCalendarColor(calendarsCtx.value, defaultCalendar?.id || ''));
let calendarId = $state('');
const timeLabel = $derived(
`${format(startTime, 'EE d. MMM', { locale: de })} ${format(startTime, 'HH:mm')} ${format(endTime, 'HH:mm')}`
);
$effect(() => {
if (defaultCalendar && !calendarId) {
calendarId = defaultCalendar.id;
}
});
function handleSubmit() {
const calendarColor = $derived(getCalendarColor(calendarsCtx.value, calendarId || ''));
const RECURRENCE_OPTIONS = [
{ value: '', label: 'Keine Wiederholung' },
{ value: 'FREQ=DAILY', label: 'Täglich' },
{ value: 'FREQ=WEEKLY', label: 'Wöchentlich' },
{ value: 'FREQ=WEEKLY;INTERVAL=2', label: 'Alle 2 Wochen' },
{ value: 'FREQ=MONTHLY', label: 'Monatlich' },
{ value: 'FREQ=YEARLY', label: 'Jährlich' },
];
function handleSubmit(e: Event) {
e.preventDefault();
if (!title.trim()) return;
const start = isAllDay
? new Date(`${startDateStr}T00:00:00`)
: new Date(`${startDateStr}T${startTimeStr}`);
const end = isAllDay
? new Date(`${endDateStr}T23:59:59`)
: new Date(`${endDateStr}T${endTimeStr}`);
onSave({
title: title.trim(),
calendarId: defaultCalendar?.id || '',
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
isAllDay: false,
calendarId,
startTime: start.toISOString(),
endTime: end.toISOString(),
isAllDay,
location: location.trim() || null,
description: description.trim() || null,
recurrenceRule: recurrenceRule || null,
});
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
} else if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}
@ -70,9 +105,8 @@
const vh = window.innerHeight;
let left = position.x + 12;
let top = position.y - rect.height / 2;
let top = position.y - 100;
// Keep in viewport
if (left + rect.width > vw - 16) left = position.x - rect.width - 12;
if (left < 16) left = 16;
if (top < 16) top = 16;
@ -86,7 +120,7 @@
<svelte:window onkeydown={handleKeydown} />
<!-- Backdrop -->
<!-- Backdrop (transparent - allows seeing calendar) -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="popover-backdrop" onclick={onClose}></div>
@ -101,43 +135,120 @@
<!-- Color accent bar -->
<div class="accent-bar" style="background-color: {calendarColor};"></div>
<div class="popover-content">
<!-- Title input -->
<input
bind:this={titleInput}
bind:value={title}
type="text"
placeholder="Termin hinzufügen"
class="title-input"
/>
<!-- Time display -->
<div class="time-row">
<span class="time-label">{timeLabel}</span>
<form onsubmit={handleSubmit}>
<!-- Header -->
<div class="popover-header">
<span class="header-title">Neuer Termin</span>
<button type="button" class="close-btn" onclick={onClose} aria-label="Schließen">
<X size={16} />
</button>
</div>
<!-- Location (optional) -->
<input bind:value={location} type="text" placeholder="Ort hinzufügen" class="location-input" />
<!-- Scrollable content -->
<div class="popover-content">
<!-- Title input -->
<input
bind:this={titleInput}
bind:value={title}
type="text"
placeholder="Titel hinzufügen"
class="title-input"
required
/>
<!-- Actions -->
<div class="action-row">
{#if onExpand}
<button type="button" onclick={onExpand} class="expand-btn"> Weitere Optionen </button>
<!-- Calendar pills -->
{#if calendarsCtx.value.length > 1}
<div class="calendar-pills">
{#each calendarsCtx.value as cal (cal.id)}
<button
type="button"
class="calendar-pill"
class:active={calendarId === cal.id}
onclick={() => (calendarId = cal.id)}
>
<span class="pill-dot" style="background-color: {cal.color || '#3b82f6'}"></span>
<span class="pill-name">{cal.name}</span>
</button>
{/each}
</div>
{/if}
<div class="action-right">
<button type="button" onclick={onClose} class="cancel-btn"> Abbrechen </button>
<button
type="button"
onclick={handleSubmit}
disabled={!title.trim()}
class="save-btn"
style="background-color: {calendarColor};"
<!-- All-day toggle -->
<label class="form-row clickable">
<CalendarBlank size={16} class="row-icon-el" />
<span class="row-label">Ganztägig</span>
<input type="checkbox" bind:checked={isAllDay} class="toggle-cb" />
</label>
<!-- Start date/time -->
<div class="form-row">
<Clock size={16} class="row-icon-el" />
<div class="datetime-fields">
<div class="dt-group">
<span class="dt-label">Beginn</span>
<input type="date" bind:value={startDateStr} class="dt-input" />
{#if !isAllDay}
<input type="time" bind:value={startTimeStr} class="dt-input time" />
{/if}
</div>
<div class="dt-group">
<span class="dt-label">Ende</span>
<input type="date" bind:value={endDateStr} class="dt-input" />
{#if !isAllDay}
<input type="time" bind:value={endTimeStr} class="dt-input time" />
{/if}
</div>
</div>
</div>
<!-- Recurrence -->
<div class="form-row">
<ArrowsClockwise size={16} class="row-icon-el" />
<select
class="field-select"
value={recurrenceRule || ''}
onchange={(e) => {
const v = (e.target as HTMLSelectElement).value;
recurrenceRule = v || null;
}}
>
Speichern
</button>
{#each RECURRENCE_OPTIONS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<!-- Location -->
<div class="form-row">
<MapPin size={16} class="row-icon-el" />
<input bind:value={location} type="text" placeholder="Ort hinzufügen" class="field-input" />
</div>
<!-- Description -->
<div class="form-row">
<TextAlignLeft size={16} class="row-icon-el" />
<textarea
bind:value={description}
placeholder="Beschreibung"
rows="2"
class="field-input field-textarea"
></textarea>
</div>
</div>
</div>
<!-- Actions -->
<div class="popover-actions">
<button type="button" onclick={onClose} class="cancel-btn">Abbrechen</button>
<button
type="submit"
disabled={!title.trim()}
class="save-btn"
style="background-color: {calendarColor};"
>
Speichern
</button>
</div>
</form>
</div>
<style>
@ -150,7 +261,10 @@
.popover {
position: fixed;
z-index: 100;
width: 320px;
width: 340px;
max-height: 80vh;
display: flex;
flex-direction: column;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
@ -175,10 +289,41 @@
.accent-bar {
height: 4px;
width: 100%;
flex-shrink: 0;
}
.popover-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 0.875rem;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
flex-shrink: 0;
}
.header-title {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.close-btn {
padding: 0.25rem;
border: none;
background: none;
color: hsl(var(--color-muted-foreground));
border-radius: 0.25rem;
cursor: pointer;
}
.close-btn:hover {
background: hsl(var(--color-muted));
}
.popover-content {
padding: 0.875rem;
flex: 1;
overflow-y: auto;
padding: 0.75rem 0.875rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
@ -188,7 +333,7 @@
width: 100%;
border: none;
background: none;
font-size: 1.0625rem;
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-foreground));
outline: none;
@ -200,19 +345,122 @@
font-weight: 400;
}
.time-row {
/* Calendar pills */
.calendar-pills {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.calendar-pill {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
border: 1px solid hsl(var(--color-border));
background: none;
font-size: 0.6875rem;
color: hsl(var(--color-foreground));
cursor: pointer;
transition: all 0.1s;
}
.calendar-pill.active {
border-color: hsl(var(--color-primary) / 0.5);
background: hsl(var(--color-primary) / 0.1);
}
.pill-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.pill-name {
font-weight: 500;
}
/* Form rows */
.form-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.25rem 0;
}
.time-label {
font-size: 0.8125rem;
.form-row.clickable {
cursor: pointer;
align-items: center;
border-radius: 0.375rem;
padding: 0.375rem 0.25rem;
}
.form-row.clickable:hover {
background: hsl(var(--color-muted) / 0.5);
}
.form-row :global(.row-icon-el) {
flex-shrink: 0;
color: hsl(var(--color-muted-foreground));
margin-top: 0.125rem;
}
.location-input {
width: 100%;
.row-label {
flex: 1;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.toggle-cb {
accent-color: hsl(var(--color-primary));
}
/* Datetime fields */
.datetime-fields {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.dt-group {
display: flex;
align-items: center;
gap: 0.375rem;
}
.dt-label {
font-size: 0.6875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
width: 2.75rem;
flex-shrink: 0;
}
.dt-input {
flex: 1;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-background));
padding: 0.25rem 0.375rem;
font-size: 0.75rem;
color: hsl(var(--color-foreground));
outline: none;
}
.dt-input:focus {
border-color: hsl(var(--color-primary));
}
.dt-input.time {
max-width: 5rem;
}
/* Select & input fields */
.field-select,
.field-input {
flex: 1;
border: none;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
background: none;
@ -222,41 +470,33 @@
padding: 0.25rem 0;
}
.location-input::placeholder {
color: hsl(var(--color-muted-foreground) / 0.4);
}
.location-input:focus {
.field-select:focus,
.field-input:focus {
border-bottom-color: hsl(var(--color-primary));
}
.action-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding-top: 0.25rem;
}
.action-right {
display: flex;
align-items: center;
gap: 0.375rem;
margin-left: auto;
}
.expand-btn {
font-size: 0.75rem;
color: hsl(var(--color-primary));
background: none;
border: none;
.field-select {
cursor: pointer;
padding: 0.25rem 0;
font-weight: 500;
}
.expand-btn:hover {
text-decoration: underline;
.field-textarea {
resize: none;
font-family: inherit;
}
.field-input::placeholder,
.field-textarea::placeholder {
color: hsl(var(--color-muted-foreground) / 0.4);
}
/* Actions */
.popover-actions {
display: flex;
justify-content: flex-end;
gap: 0.375rem;
padding: 0.625rem 0.875rem;
border-top: 1px solid hsl(var(--color-border) / 0.5);
flex-shrink: 0;
}
.cancel-btn {
@ -302,6 +542,7 @@
right: 0;
bottom: 0;
width: 100%;
max-height: 85vh;
border-radius: 1rem 1rem 0 0;
animation: slide-up 200ms ease-out;
}

View file

@ -192,7 +192,11 @@
}
// Unified sync manager — one sync engine for all apps
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
const SYNC_SERVER_URL =
(typeof window !== 'undefined' &&
(window as Record<string, unknown>).__PUBLIC_SYNC_SERVER_URL__) ||
import.meta.env.PUBLIC_SYNC_SERVER_URL ||
'http://localhost:3050';
let unifiedSync: ReturnType<typeof createUnifiedSync> | null = null;
async function handleSignOut() {

View file

@ -101,28 +101,22 @@
endTime: string;
isAllDay: boolean;
location: string | null;
description: string | null;
recurrenceRule: string | null;
}) {
eventsStore.createEvent({
calendarId: data.calendarId,
title: data.title,
description: null,
description: data.description,
startTime: data.startTime,
endTime: data.endTime,
isAllDay: data.isAllDay,
location: data.location,
recurrenceRule: null,
recurrenceRule: data.recurrenceRule,
});
showQuickCreate = false;
}
function expandQuickCreate() {
// Transfer quick create data to full modal
createStartTime = quickCreateStart;
createEndTime = quickCreateEnd;
showQuickCreate = false;
showCreateForm = true;
}
async function handleCreateSave(data: Record<string, unknown>) {
const defaultCal = getDefaultCalendar(calendarsCtx.value);
await eventsStore.createEvent({
@ -184,7 +178,6 @@
position={quickCreatePosition}
onSave={handleQuickSave}
onClose={() => (showQuickCreate = false)}
onExpand={expandQuickCreate}
/>
{/if}

View file

@ -34,7 +34,9 @@
}
},
"scripts": {
"lint": "eslint ."
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest"
},
"peerDependencies": {
"svelte": "^5.0.0"
@ -51,9 +53,14 @@
"date-fns": "^4.1.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@types/d3-force": "^3.0.10",
"@types/d3-selection": "^3.0.11",
"@types/d3-transition": "^3.0.9",
"@types/d3-zoom": "^3.0.8"
"@types/d3-zoom": "^3.0.8",
"jsdom": "^29.0.1",
"vitest": "^4.1.2"
}
}

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);

View file

@ -14,6 +14,6 @@
"noEmit": true,
"types": ["svelte"]
},
"include": ["src/**/*"],
"include": ["src/**/*", "vitest.config.ts"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
clearMocks: true,
mockReset: true,
restoreMocks: true,
},
resolve: {
conditions: ['browser'],
},
});

2168
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff