mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -14,6 +14,6 @@
|
|||
"noEmit": true,
|
||||
"types": ["svelte"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"include": ["src/**/*", "vitest.config.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
|
|||
18
packages/shared-ui/vitest.config.ts
Normal file
18
packages/shared-ui/vitest.config.ts
Normal 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
2168
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue