refactor: move shared components to lib/shared/ sub-packages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-05 14:40:14 +02:00
parent d6db7a07bd
commit efcc347c00
84 changed files with 43 additions and 43 deletions

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import type { DashboardPulseDTO } from '$lib/generated/api';
interface Props {
pulse: DashboardPulseDTO | null;
}
const { pulse }: Props = $props();
</script>
{#if pulse !== null}
<section class="rounded-sm border border-line bg-surface p-5">
<p class="font-sans text-[11px] font-bold tracking-[.12em] text-ink-3 uppercase">
{m.pulse_eyebrow()}
</p>
{#if pulse.pages > 0}
<h2 class="mt-1 font-serif text-[1.375rem] leading-snug text-ink">
{m.pulse_headline({ pages: pulse.pages })}
</h2>
{/if}
{#if pulse.yourPages > 0}
<p class="font-serif text-sm text-ink-2">
{m.pulse_you({ pages: pulse.yourPages })}
</p>
{/if}
{#if pulse.contributors.length > 0}
<div class="mt-3 flex items-center gap-1">
<p class="mr-1 font-sans text-[11px] text-ink-3">{m.pulse_contributors()}</p>
{#each pulse.contributors as c (c.initials + c.color)}
<span
class="-ml-2 inline-flex h-7 w-7 items-center justify-center rounded-full font-sans text-[11px] font-bold text-white ring-2 ring-white first:ml-0"
style="background:{c.color}"
title={c.name ?? ''}>{c.initials}</span
>
{/each}
</div>
{/if}
<div class="mt-4 grid grid-cols-3 gap-2">
<div class="flex flex-col gap-0.5">
<span class="font-serif text-[1.875rem] leading-none font-bold text-ink"
>{pulse.annotated}</span
>
<span class="flex items-center gap-1 font-sans text-[11px] text-ink-3">
<span class="text-[8px]" style="color:#00c7b1"></span>{m.pulse_transcribed()}
</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="font-serif text-[1.875rem] leading-none font-bold text-ink"
>{pulse.transcribed}</span
>
<span class="flex items-center gap-1 font-sans text-[11px] text-ink-3">
<span class="text-[8px]" style="color:#5a8a6a"></span>{m.pulse_reviewed()}
</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="font-serif text-[1.875rem] leading-none font-bold text-ink"
>{pulse.uploaded}</span
>
<span class="flex items-center gap-1 font-sans text-[11px] text-ink-3">
<span class="text-[8px]" style="color:#3060b0"></span>{m.pulse_uploaded()}
</span>
</div>
</div>
</section>
{/if}

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
import type { components } from '$lib/generated/api';
type Document = {
id: string;
title: string;
updatedAt?: string;
sender?: { id: string; firstName?: string | null; lastName: string; displayName: string };
};
type StatsDTO = components['schemas']['StatsDTO'];
interface Props {
recentDocs: Document[];
stats?: StatsDTO | null;
}
let { recentDocs, stats = null }: Props = $props();
function formatDate(dateStr: string): string {
// updatedAt is a full ISO datetime — no T12:00:00 noon-anchor needed here
return new Intl.DateTimeFormat(getLocale(), {
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(new Date(dateStr));
}
</script>
{#if recentDocs.length > 0}
<div data-testid="dashboard-recent-docs" class="rounded-sm border border-line bg-surface p-6">
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.dashboard_recent_heading()}
</h2>
{#each recentDocs as doc (doc.id)}
<div
data-testid="doc-row-{doc.id}"
class="flex min-h-[44px] items-center justify-between border-b border-line py-2 last:border-0"
>
<a
href="/documents/{doc.id}"
class="font-serif text-lg text-ink hover:text-ink-2 hover:underline"
>
{doc.title}
</a>
{#if doc.updatedAt}
<span
data-testid="doc-date-{doc.id}"
class="ml-2 shrink-0 font-sans text-xs text-gray-400"
>
{formatDate(doc.updatedAt)}
</span>
{/if}
</div>
{/each}
{#if stats?.totalDocuments != null}
<p data-testid="dashboard-stats-footnote" class="mt-4 font-sans text-sm text-ink-3">
{stats.totalDocuments}
{m.dashboard_stats_documents()} · {stats.totalPersons}
{m.dashboard_stats_persons()}
</p>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,94 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DashboardRecentDocuments from './DashboardRecentDocuments.svelte';
afterEach(cleanup);
type Document = {
id: string;
title: string;
updatedAt?: string;
sender?: { id: string; firstName: string; lastName: string };
};
function makeDoc(id: string, title: string, updatedAt?: string): Document {
return { id, title, updatedAt };
}
describe('DashboardRecentDocuments', () => {
it('renders nothing when recentDocs is empty', async () => {
render(DashboardRecentDocuments, { recentDocs: [] });
const widget = page.getByTestId('dashboard-recent-docs');
await expect.element(widget).not.toBeInTheDocument();
});
it('shows the widget when recentDocs are present', async () => {
render(DashboardRecentDocuments, { recentDocs: [makeDoc('d1', 'Taufschein')] });
const widget = page.getByTestId('dashboard-recent-docs');
await expect.element(widget).toBeInTheDocument();
});
it('renders a link to /documents/{id} for each document', async () => {
const docs = [makeDoc('d1', 'Taufschein'), makeDoc('d2', 'Heiratsurkunde')];
render(DashboardRecentDocuments, { recentDocs: docs });
const links = page.getByRole('link');
await expect.element(links.nth(0)).toHaveAttribute('href', '/documents/d1');
await expect.element(links.nth(1)).toHaveAttribute('href', '/documents/d2');
});
it('shows the document title in each row', async () => {
render(DashboardRecentDocuments, {
recentDocs: [makeDoc('d1', 'Sterbeurkunde 1930', '1930-05-12')]
});
await expect.element(page.getByText('Sterbeurkunde 1930')).toBeInTheDocument();
});
it('formats and displays the document date when present', async () => {
render(DashboardRecentDocuments, { recentDocs: [makeDoc('d1', 'Dok', '1945-04-20')] });
// The date should be visible in some formatted form
const widget = page.getByTestId('dashboard-recent-docs');
await expect.element(widget).toBeInTheDocument();
// Just verify the date element exists (not exact format due to locale)
const dateEl = page.getByTestId('doc-date-d1');
await expect.element(dateEl).toBeInTheDocument();
});
});
describe('DashboardRecentDocuments — stats footnote', () => {
it('renders stats footnote when stats.totalDocuments is provided', async () => {
render(DashboardRecentDocuments, {
recentDocs: [makeDoc('d1', 'Taufschein')],
stats: { totalDocuments: 248, totalPersons: 34 }
});
const footnote = page.getByTestId('dashboard-stats-footnote');
await expect.element(footnote).toBeInTheDocument();
});
it('omits stats footnote when stats is null', async () => {
render(DashboardRecentDocuments, {
recentDocs: [makeDoc('d1', 'Taufschein')],
stats: null
});
const footnote = page.getByTestId('dashboard-stats-footnote');
await expect.element(footnote).not.toBeInTheDocument();
});
it('shows "0 Documents" when totalDocuments is 0', async () => {
render(DashboardRecentDocuments, {
recentDocs: [makeDoc('d1', 'Taufschein')],
stats: { totalDocuments: 0, totalPersons: 0 }
});
const footnote = page.getByTestId('dashboard-stats-footnote');
await expect.element(footnote).toBeInTheDocument();
});
});
describe('DashboardRecentDocuments — touch targets', () => {
it('each doc row has min-h-[44px] class for WCAG touch target', async () => {
render(DashboardRecentDocuments, { recentDocs: [makeDoc('d1', 'Taufschein')] });
const row = page.getByTestId('doc-row-d1');
await expect.element(row).toHaveClass('min-h-[44px]');
});
});

View File

@@ -0,0 +1,133 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import type { DashboardResumeDTO } from '$lib/generated/api';
interface Props {
resumeDoc: DashboardResumeDTO | null;
}
const { resumeDoc }: Props = $props();
function safeColor(color: string): string {
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#8c9aa3';
}
</script>
{#if resumeDoc === null}
<div
data-testid="resume-strip-empty"
class="rounded-sm border border-line bg-surface p-8 text-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mx-auto text-ink-3"
>
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
<h2 class="mt-3 font-serif text-xl text-ink">{m.dashboard_empty_title()}</h2>
<p class="mt-1 font-sans text-sm text-ink-2">{m.dashboard_empty_body()}</p>
<a
href="/documents"
class="mt-4 inline-block font-sans text-sm font-bold text-accent hover:underline"
>
{m.dashboard_empty_cta()}
</a>
</div>
{:else}
<div data-testid="resume-strip" class="flex gap-4 rounded-sm border border-line bg-surface p-5">
<div
class="relative h-[252px] w-[180px] flex-shrink-0 overflow-hidden rounded-sm border border-line bg-white"
>
{#if resumeDoc.thumbnailUrl}
<img
data-testid="resume-thumbnail-img"
src={resumeDoc.thumbnailUrl}
alt=""
class="h-full w-full object-cover object-top dark:mix-blend-multiply"
loading="lazy"
decoding="async"
/>
{:else}
<div
data-testid="resume-thumbnail-fallback"
class="flex h-full w-full items-center justify-center text-ink-3"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-24 w-24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z M9 12.75h6M9 15.75h6M9 18.75h3"
/>
</svg>
</div>
{/if}
</div>
<div class="flex flex-1 flex-col gap-2">
<p class="flex items-center gap-1.5 font-sans text-xs text-ink-3">
<span class="text-[#A6DAD8]"></span>
{m.dashboard_resume_label()}
·
{m.dashboard_blocks({ count: resumeDoc.totalBlocks })}
</p>
<h2 class="font-serif text-[1.75rem] leading-tight text-ink">{resumeDoc.title}</h2>
<p class="font-sans text-sm text-ink-2 italic">{resumeDoc.caption}</p>
<blockquote
class="border-l-[3px] border-accent pl-3 font-serif text-[1.0625rem] leading-relaxed text-ink-2"
>
{resumeDoc.excerpt}
</blockquote>
<div class="mt-auto flex items-center gap-3 pt-2">
<span class="font-sans text-xs font-bold text-ink">{resumeDoc.pct}%</span>
<div
role="progressbar"
aria-valuenow={resumeDoc.pct}
aria-valuemin={0}
aria-valuemax={100}
class="h-1.5 flex-1 overflow-hidden rounded-full bg-line"
>
<div class="h-full rounded-full bg-accent" style="width:{resumeDoc.pct}%"></div>
</div>
{#each resumeDoc.collaborators.slice(0, 3) as collab (collab.initials + collab.color)}
<span
class="-ml-1 inline-flex h-6 w-6 items-center justify-center rounded-full font-sans text-[10px] font-bold text-white ring-2 ring-white"
style="background:{safeColor(collab.color)}">{collab.initials}</span
>
{/each}
</div>
<div class="mt-1 flex items-center gap-4">
<a
href="/documents/{resumeDoc.documentId}"
class="inline-block rounded-sm bg-accent px-4 py-1.5 font-sans text-sm font-bold text-white transition-opacity hover:opacity-90"
>
{m.dashboard_resume_cta()}
</a>
<a href="/documents" class="font-sans text-xs text-ink-3 transition-colors hover:text-ink">
{m.dashboard_resume_other()}
</a>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DashboardResumeStrip from './DashboardResumeStrip.svelte';
import type { components } from '$lib/generated/api';
type DashboardResumeDTO = components['schemas']['DashboardResumeDTO'];
afterEach(() => {
cleanup();
});
const mockResume: DashboardResumeDTO = {
documentId: 'doc-123',
title: 'Geburtsurkunde 1920',
caption: 'Max Mustermann · 1920-01-01',
excerpt: 'Hiermit wird beurkundet…',
totalBlocks: 4,
pct: 75,
collaborators: []
};
const mockResumeWithThumbnail: DashboardResumeDTO = {
...mockResume,
thumbnailUrl: '/api/documents/doc-123/thumbnail?v=2026-04-23T09%3A00'
};
describe('DashboardResumeStrip', () => {
it('renders empty state heading when resumeDoc is null', async () => {
render(DashboardResumeStrip, { resumeDoc: null });
const heading = page.getByRole('heading', { name: /Noch kein Dokument begonnen/i });
await expect.element(heading).toBeInTheDocument();
});
it('renders progressbar with correct aria-valuenow when resumeDoc is provided', async () => {
render(DashboardResumeStrip, { resumeDoc: mockResume });
const bar = page.getByRole('progressbar');
await expect.element(bar).toBeInTheDocument();
await expect.element(bar).toHaveAttribute('aria-valuenow', '75');
});
it('shows document title when resumeDoc is provided', async () => {
render(DashboardResumeStrip, { resumeDoc: mockResume });
const title = page.getByRole('heading', { name: /Geburtsurkunde 1920/i });
await expect.element(title).toBeInTheDocument();
});
it('links to the document for the CTA', async () => {
render(DashboardResumeStrip, { resumeDoc: mockResume });
const link = page.getByRole('link', { name: /Weitertranskribieren/i });
await expect.element(link).toHaveAttribute('href', '/documents/doc-123');
});
it('shows block count label', async () => {
render(DashboardResumeStrip, { resumeDoc: mockResume });
const label = page.getByText(/4 Abschnitte/i);
await expect.element(label).toBeInTheDocument();
});
it('renders thumbnail img with expected attrs when thumbnailUrl is set', async () => {
render(DashboardResumeStrip, { resumeDoc: mockResumeWithThumbnail });
const img = page.getByTestId('resume-thumbnail-img');
await expect.element(img).toBeInTheDocument();
await expect
.element(img)
.toHaveAttribute('src', '/api/documents/doc-123/thumbnail?v=2026-04-23T09%3A00');
await expect.element(img).toHaveAttribute('alt', '');
await expect.element(img).toHaveAttribute('loading', 'lazy');
await expect.element(img).toHaveAttribute('decoding', 'async');
});
it('renders fallback icon when thumbnailUrl is null', async () => {
render(DashboardResumeStrip, { resumeDoc: mockResume });
const fallback = page.getByTestId('resume-thumbnail-fallback');
await expect.element(fallback).toBeInTheDocument();
await expect.element(page.getByTestId('resume-thumbnail-img')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import SegmentationColumn from '$lib/document/SegmentationColumn.svelte';
import TranscriptionColumn from '$lib/document/transcription/TranscriptionColumn.svelte';
import ReadyColumn from '$lib/document/ReadyColumn.svelte';
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
type TranscriptionWeeklyStatsDTO = components['schemas']['TranscriptionWeeklyStatsDTO'];
interface Props {
segmentationDocs: TranscriptionQueueItemDTO[];
transcriptionDocs: TranscriptionQueueItemDTO[];
readyDocs: TranscriptionQueueItemDTO[];
weeklyStats: TranscriptionWeeklyStatsDTO | null;
}
let { segmentationDocs, transcriptionDocs, readyDocs, weeklyStats }: Props = $props();
</script>
<section class="mt-4 rounded-sm border border-line bg-surface p-6">
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.mission_control_heading()}
</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<SegmentationColumn docs={segmentationDocs} weeklyCount={weeklyStats?.segmentationCount ?? 0} />
<TranscriptionColumn
docs={transcriptionDocs}
weeklyCount={weeklyStats?.transcriptionCount ?? 0}
/>
<ReadyColumn docs={readyDocs} />
</div>
</section>

View File

@@ -0,0 +1,107 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import MissionControlStrip from './MissionControlStrip.svelte';
import type { components } from '$lib/generated/api';
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
type TranscriptionWeeklyStatsDTO = components['schemas']['TranscriptionWeeklyStatsDTO'];
afterEach(cleanup);
function makeDoc(
id: string,
title: string,
overrides: Partial<TranscriptionQueueItemDTO> = {}
): TranscriptionQueueItemDTO {
return {
id,
title,
annotationCount: 0,
textedBlockCount: 0,
reviewedBlockCount: 0,
contributors: [],
hasMoreContributors: false,
...overrides
};
}
const emptyStats: TranscriptionWeeklyStatsDTO = {
segmentationCount: 0,
transcriptionCount: 0,
readyCount: 0
};
describe('MissionControlStrip', () => {
it('renders section heading always', async () => {
render(MissionControlStrip, {
props: {
segmentationDocs: [],
transcriptionDocs: [],
readyDocs: [],
weeklyStats: null
}
});
await expect.element(page.getByText('Was braucht Aufmerksamkeit?')).toBeInTheDocument();
});
it('renders all three column headings', async () => {
render(MissionControlStrip, {
props: {
segmentationDocs: [makeDoc('s1', 'Seg Dok')],
transcriptionDocs: [makeDoc('t1', 'Trans Dok')],
readyDocs: [makeDoc('r1', 'Ready Dok')],
weeklyStats: emptyStats
}
});
await expect.element(page.getByText('Text markieren')).toBeInTheDocument();
await expect.element(page.getByText('Text transkribieren')).toBeInTheDocument();
await expect.element(page.getByText(/Lesefertig/)).toBeInTheDocument();
});
it('renders document titles in correct columns', async () => {
const segDoc = makeDoc('seg-1', 'Segmentierungs Brief');
const transDoc = makeDoc('trans-1', 'Transkriptions Postkarte');
const readyDoc = makeDoc('ready-1', 'Fertiger Tagebucheintrag');
render(MissionControlStrip, {
props: {
segmentationDocs: [segDoc],
transcriptionDocs: [transDoc],
readyDocs: [readyDoc],
weeklyStats: emptyStats
}
});
await expect.element(page.getByText('Segmentierungs Brief')).toBeInTheDocument();
await expect.element(page.getByText('Transkriptions Postkarte')).toBeInTheDocument();
await expect.element(page.getByText('Fertiger Tagebucheintrag')).toBeInTheDocument();
});
it('renders section heading even when all arrays are empty and weeklyStats is null', async () => {
render(MissionControlStrip, {
props: {
segmentationDocs: [],
transcriptionDocs: [],
readyDocs: [],
weeklyStats: null
}
});
// Heading always visible
await expect.element(page.getByText('Was braucht Aufmerksamkeit?')).toBeInTheDocument();
// All three empty states should also be visible
await expect
.element(page.getByText('Alle Dokumente haben bereits Segmentierungsblöcke.'))
.toBeInTheDocument();
await expect
.element(page.getByText('Keine Dokumente warten auf Transkription.'))
.toBeInTheDocument();
await expect
.element(page.getByText('Noch keine Dokumente vollständig transkribiert.'))
.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,116 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import type { FlatMessage } from '$lib/types';
import { extractQuote } from '$lib/shared/discussion/comment';
import { getInitials } from '$lib/person/personFormat';
import { relativeTime } from '$lib/shared/utils/time';
import { renderBody } from '$lib/shared/discussion/mention';
type Props = {
message: FlatMessage;
isOwn: boolean;
isEditing: boolean;
editText: string;
onEdit: () => void;
onDelete: () => void;
onEditTextChange: (text: string) => void;
onEditKeydown: (e: KeyboardEvent) => void;
};
let {
message,
isOwn,
isEditing,
editText,
onEdit,
onDelete,
onEditTextChange,
onEditKeydown
}: Props = $props();
const wasEdited = $derived(message.updatedAt > message.createdAt);
const parsed = $derived(extractQuote(message.content));
</script>
<div
id="comment-{message.id}"
role="article"
tabindex="-1"
class="flex gap-2 rounded outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2"
>
<!-- Avatar circle with initials -->
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
>
{getInitials(message.authorName)}
</div>
<!-- Content -->
<div class="min-w-0 flex-1">
<!-- Author + timestamp -->
<div class="flex items-center gap-1.5">
<span class="font-sans text-sm font-semibold text-ink">{message.authorName}</span>
{#if wasEdited}
<span class="font-sans text-xs text-ink-3"
>{relativeTime(message.updatedAt)} {m.comment_edited_label()}</span
>
{:else}
<span class="font-sans text-xs text-ink-3">{relativeTime(message.createdAt)}</span>
{/if}
</div>
<!-- Quote block (if present) -->
{#if parsed.quote}
<div class="my-1 border-l-2 border-line pl-2 font-serif text-base text-ink-3 italic">
&ldquo;{parsed.quote}&rdquo;
</div>
{/if}
<!-- Edit mode vs view mode -->
{#if isEditing}
<textarea
class="mt-1 w-full resize-none rounded border border-line bg-surface px-2 py-1 font-serif text-sm leading-relaxed text-ink outline-none focus:border-primary"
rows={2}
value={editText}
oninput={(e) => onEditTextChange((e.currentTarget as HTMLTextAreaElement).value)}
onkeydown={onEditKeydown}
></textarea>
<div class="mt-1 font-sans text-xs text-ink-3">{m.comment_edit_hint()}</div>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="relative" onclick={() => { if (isOwn) onEdit(); }}>
<p
class="font-serif text-base leading-relaxed text-ink-2 {isOwn
? '-mx-1 cursor-text rounded px-1 transition-colors hover:bg-surface'
: ''}"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
{@html renderBody(parsed.body, message.mentionDTOs ?? [])}
</p>
{#if isOwn}
<button
type="button"
class="hover:text-error absolute -right-1 -bottom-1 cursor-pointer rounded p-2 text-ink-3 transition-colors"
aria-label="{m.btn_delete()} {message.authorName}"
onclick={(e) => { e.stopPropagation(); onDelete(); }}
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,106 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import CommentMessage from './CommentMessage.svelte';
import type { FlatMessage } from '$lib/types';
afterEach(cleanup);
const baseMsg: FlatMessage = {
id: 'msg-1',
authorId: 'user-1',
authorName: 'Anna Müller',
content: 'Hello world',
createdAt: new Date(Date.now() - 5 * 60_000).toISOString(),
updatedAt: new Date(Date.now() - 5 * 60_000).toISOString()
};
function defaultProps(overrides: Partial<Parameters<typeof render>[1]> = {}) {
return {
message: baseMsg,
isOwn: false,
isEditing: false,
editText: '',
onEdit: vi.fn(),
onDelete: vi.fn(),
onEditTextChange: vi.fn(),
onEditKeydown: vi.fn(),
...overrides
};
}
describe('CommentMessage', () => {
it('renders author name', async () => {
render(CommentMessage, defaultProps());
await expect.element(page.getByText('Anna Müller')).toBeInTheDocument();
});
it('renders initials in avatar', async () => {
render(CommentMessage, defaultProps());
await expect.element(page.getByText('AM')).toBeInTheDocument();
});
it('renders message body', async () => {
render(CommentMessage, defaultProps());
await expect.element(page.getByText('Hello world')).toBeInTheDocument();
});
it('renders quoted section when content contains a quote', async () => {
render(
CommentMessage,
defaultProps({
message: { ...baseMsg, content: '> "Interesting passage"\n\nMy reply' }
})
);
await expect.element(page.getByText(/Interesting passage/)).toBeInTheDocument();
await expect.element(page.getByText('My reply')).toBeInTheDocument();
});
it('does not show delete button for messages not owned by current user', async () => {
render(CommentMessage, defaultProps({ isOwn: false }));
await expect.element(page.getByRole('button')).not.toBeInTheDocument();
});
it('shows delete button for own messages', async () => {
render(CommentMessage, defaultProps({ isOwn: true }));
await expect.element(page.getByRole('button')).toBeInTheDocument();
});
it('calls onDelete when delete button is clicked', async () => {
const onDelete = vi.fn();
render(CommentMessage, defaultProps({ isOwn: true, onDelete }));
await userEvent.click(page.getByRole('button'));
expect(onDelete).toHaveBeenCalled();
});
it('shows edit textarea when isEditing is true', async () => {
render(
CommentMessage,
defaultProps({ isOwn: true, isEditing: true, editText: 'current edit text' })
);
const textarea = page.getByRole('textbox');
await expect.element(textarea).toBeInTheDocument();
await expect.element(textarea).toHaveValue('current edit text');
});
it('exposes id="comment-{message.id}" on the article wrapper for deep-link scroll', async () => {
render(CommentMessage, defaultProps());
const article = page.getByRole('article').element();
expect(article.getAttribute('id')).toBe('comment-msg-1');
});
it('is focusable but not in tab order (tabindex="-1")', async () => {
render(CommentMessage, defaultProps());
const article = page.getByRole('article').element();
expect(article.getAttribute('tabindex')).toBe('-1');
});
it('shows a focus-visible ring when focused via keyboard', async () => {
render(CommentMessage, defaultProps());
const article = page.getByRole('article').element();
const classes = article.className;
expect(classes).toMatch(/focus-visible:ring-2/);
expect(classes).toMatch(/focus-visible:ring-brand-navy/);
expect(classes).toMatch(/outline-none/);
});
});

View File

@@ -0,0 +1,212 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import type { Comment, FlatMessage, MentionDTO } from '$lib/types';
import MentionEditor from '$lib/shared/discussion/MentionEditor.svelte';
import CommentMessage from '$lib/shared/discussion/CommentMessage.svelte';
import { extractContent } from '$lib/shared/discussion/mention';
type Props = {
documentId: string;
annotationId?: string | null;
blockId?: string | null;
initialComments?: Comment[];
loadOnMount?: boolean;
canComment: boolean;
currentUserId: string | null;
quotedText?: string | null;
showCompose?: boolean;
onCountChange?: (count: number) => void;
};
let {
documentId,
annotationId = null,
blockId = null,
initialComments = [],
loadOnMount = false,
canComment,
currentUserId = null,
quotedText = null,
showCompose = true,
onCountChange
}: Props = $props();
let comments: Comment[] = $state(untrack(() => [...initialComments]));
let newText: string = $state('');
let posting: boolean = $state(false);
let newMentionCandidates: MentionDTO[] = $state([]);
let editingId: string | null = $state(null);
let editText: string = $state('');
const commentsBase = $derived(
blockId
? `/api/documents/${documentId}/transcription-blocks/${blockId}/comments`
: annotationId
? `/api/documents/${documentId}/annotations/${annotationId}/comments`
: `/api/documents/${documentId}/comments`
);
const flatMessages = $derived(
comments.flatMap((thread) => [thread as FlatMessage, ...(thread.replies as FlatMessage[])])
);
$effect(() => {
if (quotedText && quotedText.trim()) {
newText = `> "${quotedText}"\n\n`;
}
});
function isOwn(c: { authorId: string | null }): boolean {
return currentUserId !== null && c.authorId === currentUserId;
}
async function reload() {
try {
const res = await fetch(commentsBase);
if (res.ok) {
comments = await res.json();
const total = comments.reduce((s, c) => s + 1 + c.replies.length, 0);
onCountChange?.(total);
}
} catch {
/* ignore */
}
}
async function postComment() {
const text = newText.trim();
if (!text || posting) return;
posting = true;
try {
const { content, mentionedUserIds } = extractContent(text, newMentionCandidates);
const res = await fetch(commentsBase, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, mentionedUserIds })
});
if (res.ok) {
newText = '';
newMentionCandidates = [];
await reload();
}
} finally {
posting = false;
}
}
function startEdit(msg: FlatMessage) {
editingId = msg.id;
editText = msg.content;
}
async function saveEdit(commentId: string) {
const text = editText.trim();
if (!text || posting) return;
posting = true;
try {
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text })
});
if (res.ok) {
editingId = null;
editText = '';
await reload();
}
} finally {
posting = false;
}
}
function cancelEdit() {
editingId = null;
editText = '';
}
function handleEditKeydown(e: KeyboardEvent, commentId: string) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
saveEdit(commentId);
} else if (e.key === 'Escape') {
e.stopPropagation();
cancelEdit();
}
}
async function deleteComment(commentId: string) {
if (posting) return;
posting = true;
try {
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
method: 'DELETE'
});
if (res.ok) {
await reload();
}
} finally {
posting = false;
}
}
onMount(() => {
if (loadOnMount) {
reload();
} else {
const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0);
onCountChange?.(total);
}
});
</script>
{#if flatMessages.length === 0}
<p class="text-sm text-ink-3 italic">{m.comment_empty_hint()}</p>
{:else}
<div class="rounded border-l-2 border-accent bg-muted p-2">
<div class="mb-2 flex items-center gap-1.5 font-sans text-sm font-semibold text-ink-2">
<svg
class="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
/>
</svg>
{flatMessages.length}
{flatMessages.length === 1 ? 'Kommentar' : 'Kommentare'}
</div>
<div role="log" class="space-y-2">
{#each flatMessages as msg (msg.id)}
<CommentMessage
message={msg}
isOwn={isOwn(msg)}
isEditing={editingId === msg.id}
editText={editText}
onEdit={() => startEdit(msg)}
onDelete={() => deleteComment(msg.id)}
onEditTextChange={(text) => { editText = text; }}
onEditKeydown={(e) => handleEditKeydown(e, msg.id)}
/>
{/each}
</div>
</div>
{/if}
{#if canComment && (showCompose || flatMessages.length > 0)}
<div class="mt-2">
<MentionEditor
bind:value={newText}
bind:mentionCandidates={newMentionCandidates}
rows={1}
placeholder={m.comment_placeholder()}
disabled={posting}
onsubmit={postComment}
/>
</div>
{/if}

View File

@@ -0,0 +1,70 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import CommentThread from './CommentThread.svelte';
import type { Comment } from '$lib/types';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function makeComment(id: string, content = 'Hello'): Comment {
return {
id,
authorId: 'user-1',
authorName: 'Alice',
content,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
replies: []
};
}
const baseProps = {
documentId: 'doc-1',
canComment: true,
currentUserId: 'user-1',
canAdmin: false
};
describe('CommentThread empty state', () => {
it('shows empty state hint when there are no comments', async () => {
render(CommentThread, { ...baseProps, initialComments: [] });
await expect
.element(page.getByText('Noch keine Kommentare starte die Diskussion!'))
.toBeInTheDocument();
});
it('does not show empty state hint when comments exist', async () => {
render(CommentThread, { ...baseProps, initialComments: [makeComment('c-1')] });
await expect
.element(page.getByText('Noch keine Kommentare starte die Diskussion!'))
.not.toBeInTheDocument();
});
});
describe('CommentThread onCountChange', () => {
it('calls onCountChange with initial SSR count on mount', async () => {
const onCountChange = vi.fn();
render(CommentThread, {
...baseProps,
initialComments: [makeComment('c-1'), makeComment('c-2')],
onCountChange
});
expect(onCountChange).toHaveBeenCalledWith(2);
});
it('calls onCountChange with 0 when no initial comments', async () => {
const onCountChange = vi.fn();
render(CommentThread, { ...baseProps, initialComments: [], onCountChange });
expect(onCountChange).toHaveBeenCalledWith(0);
});
it('counts replies in the total', async () => {
const onCountChange = vi.fn();
const comment = { ...makeComment('c-1'), replies: [makeComment('r-1') as never] };
render(CommentThread, { ...baseProps, initialComments: [comment], onCountChange });
expect(onCountChange).toHaveBeenCalledWith(2);
});
});

View File

@@ -0,0 +1,170 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import { formatLifeDateRange } from '$lib/person/personLifeDates';
import { m } from '$lib/paraglide/messages.js';
type Person = components['schemas']['Person'];
// The dropdown receives a single reactive state object. PersonMentionEditor
// mutates fields on this object (model.items = ..., etc.) and Svelte's $state
// proxy reactivity propagates the change here. This is the supported way to
// update an imperatively-mounted Svelte 5 component — `mount` does not return
// settable prop accessors.
type DropdownState = {
items: Person[];
command: (item: Person) => void;
clientRect: (() => DOMRect | null) | null;
};
let { model }: { model: DropdownState } = $props();
// highlightedIndex must be both writable (keyboard handler mutates it) and
// reset when `items` changes (so it never points past the end of a new list).
// A pure $derived is read-only and cannot serve both needs, so $state + $effect
// is the correct pattern here. The autofixer suggestion to use $derived does not
// apply: the mutation in onKeyDown is not a derivation.
let highlightedIndex = $state(0);
$effect(() => {
// Read model.items to subscribe; reset index whenever the list is replaced.
void model.items;
highlightedIndex = 0;
});
// ---------------------------------------------------------------------------
// Positioning — flip strategy: open upward when there is not enough room
// below the cursor to show the dropdown without clipping the viewport.
// ---------------------------------------------------------------------------
type Position = {
top: string | null;
bottom: string | null;
left: string;
};
const DROPDOWN_CLEARANCE_PX = 200;
const position = $derived.by<Position>(() => {
const cr = model.clientRect;
if (!cr) return { top: '0px', bottom: null, left: '0px' };
const rect = cr();
if (!rect) return { top: '0px', bottom: null, left: '0px' };
// Some editors report a caret DOMRect with zero width; fall back to rect.x.
const left = `${rect.width === 0 ? rect.x : rect.left}px`;
if (window.innerHeight - rect.bottom < DROPDOWN_CLEARANCE_PX) {
// Not enough space below — anchor bottom of dropdown to top of caret.
return {
top: null,
bottom: `${window.innerHeight - rect.top}px`,
left
};
}
return { top: `${rect.bottom}px`, bottom: null, left };
});
// ---------------------------------------------------------------------------
// Keyboard handler — exported so Tiptap's render() can forward events.
// Returns true when the event is consumed (prevents the editor's default).
// ---------------------------------------------------------------------------
export function onKeyDown(event: KeyboardEvent): boolean {
const len = model.items.length;
if (event.key === 'ArrowDown') {
highlightedIndex = (highlightedIndex + 1) % Math.max(len, 1);
return true;
}
if (event.key === 'ArrowUp') {
highlightedIndex = (highlightedIndex - 1 + Math.max(len, 1)) % Math.max(len, 1);
return true;
}
if (event.key === 'Enter') {
const selected = model.items[highlightedIndex];
if (selected) {
model.command(selected);
}
return true;
}
// Escape: let the suggestion plugin handle it (return false = not consumed).
return false;
}
function selectItem(item: Person) {
model.command(item);
}
</script>
<!--
Mounted imperatively to document.body by the Tiptap suggestion plugin.
Positioned absolutely relative to the viewport using inline styles derived
from the Tiptap clientRect() callback.
SECURITY: This component receives pre-filtered Person[] items from the
parent — it does NOT fetch. The parent's fetch relies on the SvelteKit Vite
proxy injecting the auth_token cookie as the Authorization header.
Mounted in transcribe mode behind WRITE_ALL — never reachable to
unauthenticated users.
-->
<div
class="fixed z-50 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
role="listbox"
aria-label={m.person_mention_btn_label()}
style:top={position.top}
style:bottom={position.bottom}
style:left={position.left}
>
{#if model.items.length === 0}
<p class="px-3 py-2.5 font-sans text-sm text-ink-3">
{m.person_mention_popup_empty()}
</p>
<!--
Empty-state escape hatch — without it the transcriber has to close
the dropdown, navigate to /persons/new, come back, and re-type the
query. target=_blank keeps the document and editor state intact;
rel=noopener prevents reverse-tabnabbing on the new tab. Leonie #5621.
-->
<a
href="/persons/new"
target="_blank"
rel="noopener"
class="flex min-h-[44px] items-center gap-2 border-t border-line px-3 py-2.5 font-sans text-sm font-medium text-brand-navy hover:bg-canvas focus:bg-canvas focus:outline-none"
onmousedown={(e) => e.preventDefault()}
>
{m.person_mention_create_new()}
<span aria-hidden="true"></span>
</a>
{:else}
{#each model.items as person, i (person.id)}
<div
class={[
'flex min-h-[44px] cursor-pointer flex-col gap-1 px-3 py-2.5 text-left hover:bg-canvas',
// brand-mint ring (≈2.5:1 on white) fails WCAG 1.4.11 Non-Text
// Contrast for a meaningful keyboard-highlight indicator. brand-navy
// gives ~14.5:1 against the bg-brand-mint/20 row. Leonie #5621.
i === highlightedIndex && 'bg-brand-mint/20 ring-2 ring-brand-navy ring-inset'
]}
role="option"
aria-selected={i === highlightedIndex}
data-test-person-id={person.id}
tabindex="-1"
onmousedown={(e) => {
// Prevent blur on the editor before the selection fires.
e.preventDefault();
selectItem(person);
}}
>
<span class="truncate font-serif text-base text-ink">{person.displayName}</span>
{#if formatLifeDateRange(person.birthYear, person.deathYear)}
<span class="truncate font-sans text-xs text-ink-3">
{formatLifeDateRange(person.birthYear, person.deathYear)}
</span>
{/if}
</div>
{/each}
{/if}
</div>

View File

@@ -0,0 +1,201 @@
<script lang="ts">
import { onDestroy, tick } from 'svelte';
import { detectMention } from '$lib/shared/discussion/mention';
import type { MentionDTO } from '$lib/types';
import { m } from '$lib/paraglide/messages.js';
type Props = {
value: string;
mentionCandidates: MentionDTO[];
placeholder?: string;
rows?: number;
disabled?: boolean;
onsubmit?: () => void;
};
let {
value = $bindable(''),
mentionCandidates = $bindable([]),
placeholder = '',
rows = 3,
disabled = false,
onsubmit
}: Props = $props();
let query: string | null = $state(null);
let results: MentionDTO[] = $state([]);
let highlightedIndex = $state(0);
let mentionStart = $state(0);
let textarea: HTMLTextAreaElement | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
function attachTextarea(node: HTMLTextAreaElement) {
textarea = node;
return () => {
textarea = null;
};
}
function handleInput() {
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const detected = detectMention(value, cursorPos);
if (detected === null) {
closePopup();
return;
}
// Calculate where the @ starts
const before = value.slice(0, cursorPos);
const atIndex = before.lastIndexOf('@');
mentionStart = atIndex;
if (query !== detected) {
query = detected;
highlightedIndex = 0;
scheduleSearch(detected);
}
}
function scheduleSearch(q: string) {
clearTimeout(debounceTimer);
if (!q) {
results = [];
return;
}
debounceTimer = setTimeout(async () => {
try {
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`);
if (res.ok) {
const data: MentionDTO[] = await res.json();
results = data.slice(0, 5);
} else {
results = [];
}
} catch {
results = [];
}
}, 200);
}
async function selectUser(user: MentionDTO) {
if (!textarea) return;
const displayName = `${user.firstName} ${user.lastName}`;
// Replace @partialQuery with @FirstName LastName (plus trailing space)
const replacement = `@${displayName} `;
const cursorPos = textarea.selectionStart;
const before = value.slice(0, mentionStart);
const after = value.slice(cursorPos);
value = before + replacement + after;
// Deduplicate and add to candidates
if (!mentionCandidates.some((c) => c.id === user.id)) {
mentionCandidates = [...mentionCandidates, user];
}
closePopup();
// Reposition cursor after the inserted mention
await tick();
if (!textarea) return;
const pos = mentionStart + replacement.length;
textarea.selectionStart = pos;
textarea.selectionEnd = pos;
textarea.focus();
}
function closePopup() {
query = null;
results = [];
highlightedIndex = 0;
clearTimeout(debounceTimer);
}
function handleKeydown(e: KeyboardEvent) {
// Enter sends, Shift+Enter adds newline
if (e.key === 'Enter' && !e.shiftKey && query === null) {
e.preventDefault();
onsubmit?.();
return;
}
if (query === null) return;
if (e.key === 'Escape') {
e.preventDefault();
closePopup();
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (results.length > 0) {
highlightedIndex = (highlightedIndex + 1) % results.length;
}
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (results.length > 0) {
highlightedIndex = (highlightedIndex - 1 + results.length) % results.length;
}
return;
}
if (e.key === 'Enter' && results.length > 0) {
e.preventDefault();
selectUser(results[highlightedIndex]);
return;
}
}
onDestroy(() => clearTimeout(debounceTimer));
const popupOpen = $derived(query !== null);
</script>
<div class="relative">
<textarea
{@attach attachTextarea}
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
rows={rows}
placeholder={placeholder}
disabled={disabled}
bind:value={value}
oninput={handleInput}
onkeydown={handleKeydown}
></textarea>
{#if popupOpen}
<div
class="absolute z-20 mt-1 w-64 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
role="listbox"
aria-label={m.mention_btn_label()}
>
{#if results.length === 0}
<p class="px-3 py-2 font-sans text-sm text-ink-3">{m.mention_popup_empty()}</p>
{:else}
{#each results as user, i (user.id)}
<div
class="w-full px-3 py-2 text-left font-sans text-sm text-ink hover:bg-canvas {i === highlightedIndex ? 'bg-canvas' : ''}"
role="option"
aria-selected={i === highlightedIndex}
tabindex="-1"
onmousedown={(e) => {
// Use mousedown to fire before textarea blur
e.preventDefault();
selectUser(user);
}}
>
{user.firstName}
{user.lastName}
</div>
{/each}
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,295 @@
<script lang="ts">
import { onMount, onDestroy, mount, unmount } from 'svelte';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { Mention } from '@tiptap/extension-mention';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import type { PersonMention } from '$lib/types';
import { deserialize, serialize } from '$lib/shared/discussion/mentionSerializer';
import MentionDropdown from './MentionDropdown.svelte';
type Person = components['schemas']['Person'];
type Props = {
value: string;
mentionedPersons: PersonMention[];
placeholder?: string;
disabled?: boolean;
onfocus?: () => void;
onblur?: () => void;
onSelectionChange?: (text: string | null) => void;
};
let {
value = $bindable(''),
mentionedPersons = $bindable([]),
placeholder = '',
disabled = false,
onfocus,
onblur,
onSelectionChange
}: Props = $props();
let editorEl: HTMLDivElement;
let editor: Editor | null = null;
// Single reactive state object shared with MentionDropdown. Mutating these
// fields propagates to the mounted dropdown via Svelte's $state proxy —
// this is required because Svelte 5's `mount()` does NOT return prop
// accessors; setting `instance.items = ...` does not update the component.
let dropdownState = $state<{
items: Person[];
command: (item: Person) => void;
clientRect: (() => DOMRect | null) | null;
}>({
items: [],
command: () => {},
clientRect: null
});
type DropdownExports = {
onKeyDown: (event: KeyboardEvent) => boolean;
};
onMount(() => {
// Custom Mention node: uses personId / displayName instead of the
// default id / label attribute names so the mentionSerializer can
// round-trip correctly without attribute remapping.
const CustomMention = Mention.extend({
addAttributes() {
return {
personId: {
default: null,
parseHTML: (el) => el.getAttribute('data-person-id'),
renderHTML: (attrs) => ({ 'data-person-id': attrs.personId })
},
displayName: {
default: null,
parseHTML: (el) => el.getAttribute('data-display-name'),
renderHTML: (attrs) => ({ 'data-display-name': attrs.displayName })
},
mentionSuggestionChar: {
default: '@',
parseHTML: (el) => el.getAttribute('data-mention-suggestion-char'),
renderHTML: (attrs) => ({
'data-mention-suggestion-char': attrs.mentionSuggestionChar
})
}
};
}
});
editor = new Editor({
element: editorEl,
// Initial editable state honors the `disabled` prop. The reactive
// $effect below keeps it in sync if the prop flips after mount —
// without this, a keyboard user can tab into the contenteditable
// even when the wrapper has pointer-events-none (WCAG 2.1.1).
editable: !disabled,
extensions: [
StarterKit.configure({
heading: false,
bold: false,
italic: false,
strike: false,
code: false,
blockquote: false,
codeBlock: false,
bulletList: false,
orderedList: false,
hardBreak: false,
horizontalRule: false
}),
CustomMention.configure({
renderHTML({ node }) {
// Underline color matches the read-mode .person-mention rule
// (ink at ~50% alpha) — brand-mint on white fails WCAG 1.4.11
// Non-Text Contrast (≈1.7:1, needs 3:1). Leonie #5621.
return [
'span',
{
'data-type': 'mention',
'data-person-id': node.attrs.personId,
'data-display-name': node.attrs.displayName,
class:
'mention-token underline decoration-ink/50 underline-offset-2 text-brand-navy font-medium'
},
`@${node.attrs.displayName}`
];
},
renderText({ node }) {
return `@${node.attrs.displayName}`;
},
suggestion: {
char: '@',
allowSpaces: true,
// ─────────────────────────────────────────────────────────────
// EXCEPTION to frontend/CLAUDE.md "no client-side API fetch":
// Tiptap's suggestion plugin lives entirely on the client and
// fires on every keystroke after `@`. Routing each query through
// a SvelteKit form action would round-trip through SSR for a
// dropdown that needs to feel instantaneous, and a +server.ts
// endpoint would only proxy the same call. Auth flows through
// the Vite proxy in dev and Caddy in prod (cookie-based), so the
// network surface is identical to a server-driven call.
// Markus #5616: an ADR will formalise this. Open follow-up:
// "ADR: client-side fetch exception for editor suggestion plugins."
// Nora #5618 #3 — separate issue tracks the GET /api/persons
// response-shape audit (PersonSummaryDTO leaks `notes`).
// ─────────────────────────────────────────────────────────────
items: async ({ query }: { query: string }) => {
if (!query) return [];
try {
const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`);
if (!res.ok) return [];
return ((await res.json()) as Person[]).slice(0, 5);
} catch {
return [];
}
},
// AC-1 fix: insert the typed query as displayName, not person.displayName.
command({ editor: ed, range, props }) {
const p = props as unknown as { personId: string; displayName: string };
const nodeAfter = ed.view.state.selection.$to.nodeAfter;
if (nodeAfter?.text?.startsWith(' ')) range.to += 1;
ed.chain()
.focus()
.insertContentAt(range, [
{
type: 'mention',
attrs: { personId: p.personId, displayName: p.displayName }
},
{ type: 'text', text: ' ' }
])
.run();
},
render() {
let component: object | null = null;
let exports: DropdownExports | null = null;
// Tiptap's SuggestionProps types `command` against the default
// MentionNodeAttrs (id/label). Our custom Mention extension uses
// personId/displayName, so we cast the renderProps locally.
type LooseRenderProps = {
items: unknown;
command: (props: { personId: string; displayName: string }) => void;
query: string;
clientRect?: (() => DOMRect | null) | null;
};
const updateState = (renderProps: LooseRenderProps) => {
dropdownState.items = renderProps.items as Person[];
// AC-1: pass typed query as displayName, not person.displayName
dropdownState.command = (item: Person) =>
renderProps.command({
personId: item.id,
displayName: renderProps.query
});
dropdownState.clientRect = renderProps.clientRect ?? null;
};
return {
onStart(renderProps) {
updateState(renderProps as unknown as LooseRenderProps);
const mounted = mount(MentionDropdown, {
target: document.body,
props: { model: dropdownState }
});
component = mounted as object;
exports = mounted as unknown as DropdownExports;
},
onUpdate(renderProps) {
updateState(renderProps as unknown as LooseRenderProps);
},
onKeyDown({ event }) {
// Escape is handled by the suggestion plugin itself.
if (event.key === 'Escape') return false;
return exports?.onKeyDown(event) ?? false;
},
onExit() {
if (component) {
unmount(component);
component = null;
exports = null;
}
}
};
}
}
})
],
content: deserialize(value, mentionedPersons),
editorProps: {
attributes: {
role: 'textbox',
'aria-multiline': 'true',
'aria-label': m.transcription_editor_aria_label(),
'data-editor-inner': '',
class: [
'min-h-[120px] px-1 py-2.5',
'font-serif text-base leading-relaxed text-ink',
'focus:outline-none',
'tiptap-editor-inner'
].join(' ')
}
},
onUpdate({ editor: ed }) {
const { text, mentionedPersons: mp } = serialize(ed.getJSON());
value = text;
mentionedPersons = mp;
},
onFocus() {
onfocus?.();
},
onBlur() {
onblur?.();
},
onSelectionUpdate({ editor: ed }) {
const { from, to } = ed.state.selection;
onSelectionChange?.(from !== to ? ed.state.doc.textBetween(from, to) : null);
}
});
});
onDestroy(() => {
editor?.destroy();
});
// Keep the data-placeholder attribute in sync with actual emptiness so the
// placeholder CSS only fires when there is no content (not just on blur).
$effect(() => {
if (!editor || !placeholder) return;
void value; // Tiptap's onUpdate always fires on content change, but $effect needs a
// reactive read to re-run — void value registers value as a dependency without using it.
const inner = editorEl?.querySelector('[data-editor-inner]') as HTMLElement | null;
if (!inner) return;
if (editor.isEmpty) {
inner.setAttribute('data-placeholder', placeholder);
} else {
inner.removeAttribute('data-placeholder');
}
});
// Keep editor in sync with the reactive `disabled` prop. Tiptap's setEditable
// flips contenteditable on the inner DOM and stops accepting input — matches
// the textarea's old `disabled` semantics for keyboard users (WCAG 2.1.1).
//
// Guard: setEditable triggers a ProseMirror transaction which fires onUpdate;
// onUpdate writes through bind:value / bind:mentionedPersons. Without this
// idempotence check, the effect would loop on every prop pass-through.
$effect(() => {
const shouldBeEditable = !disabled;
if (editor && editor.isEditable !== shouldBeEditable) {
editor.setEditable(shouldBeEditable);
}
});
</script>
<div
class="relative rounded-sm border border-transparent focus-within:border-brand-mint focus-within:ring-2 focus-within:ring-brand-mint/40"
class:opacity-50={disabled}
class:pointer-events-none={disabled}
aria-disabled={disabled ? 'true' : undefined}
bind:this={editorEl}
></div>

View File

@@ -0,0 +1,424 @@
/**
* PersonMentionEditor — Tiptap-based component tests.
*
* All old tests used document.querySelector('textarea') which is dead after
* the Tiptap migration. These tests drive the contenteditable via
* userEvent.type() and inspect the serialized output from the test host.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
type Person = components['schemas']['Person'];
type PersonMention = components['schemas']['PersonMention'];
const AUGUSTE: Person = {
id: 'p-aug',
firstName: 'Auguste',
lastName: 'Raddatz',
displayName: 'Auguste Raddatz',
birthYear: 1882,
deathYear: 1944
} as unknown as Person;
const ANNA: Person = {
id: 'p-anna',
firstName: 'Anna',
lastName: 'Schmidt',
displayName: 'Anna Schmidt',
birthYear: 1860
} as unknown as Person;
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) })
);
}
function mockFetchEmpty() {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
);
}
type Snapshot = { value: string; mentionedPersons: PersonMention[] };
function renderHost(
initial: { value?: string; mentionedPersons?: PersonMention[]; disabled?: boolean } = {}
) {
let snapshot: Snapshot = {
value: initial.value ?? '',
mentionedPersons: initial.mentionedPersons ?? []
};
render(PersonMentionEditorHost, {
initialValue: initial.value ?? '',
initialMentions: initial.mentionedPersons ?? [],
disabled: initial.disabled ?? false,
onChange: (snap: Snapshot) => {
snapshot = snap;
}
});
return {
get snapshot() {
return snapshot;
}
};
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('PersonMentionEditor — rendering', () => {
it('renders the editor as a textbox (ARIA role from editorProps)', async () => {
render(PersonMentionEditorHost, {
initialValue: '',
initialMentions: [],
onChange: () => {}
});
await expect.element(page.getByRole('textbox')).toBeInTheDocument();
});
it('reflects bound initial value as visible text', async () => {
render(PersonMentionEditorHost, {
initialValue: 'Hallo Welt',
initialMentions: [],
onChange: () => {}
});
await expect.element(page.getByText('Hallo Welt')).toBeInTheDocument();
});
});
// ─── Typeahead opens on @ ─────────────────────────────────────────────────────
describe('PersonMentionEditor — typeahead', () => {
it('opens the dropdown when typing @ + query and shows results', async () => {
mockFetchWithPersons();
renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
});
});
it('hits /api/persons?q= with the typed query', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
vi.stubGlobal('fetch', fetchMock);
renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
});
});
it('shows life dates next to the name in the dropdown', async () => {
mockFetchWithPersons();
renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByText('* 1882 † 1944')).toBeInTheDocument();
});
});
it('shows empty state when no persons match', async () => {
mockFetchEmpty();
renderHost();
await userEvent.type(page.getByRole('textbox'), '@xyz');
await vi.waitFor(async () => {
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
});
});
it('offers a "create new person" link in the empty state', async () => {
mockFetchEmpty();
renderHost();
await userEvent.type(page.getByRole('textbox'), '@xyz');
await vi.waitFor(async () => {
const link = page.getByRole('link', { name: /Neue Person anlegen/ });
await expect.element(link).toBeVisible();
await expect.element(link).toHaveAttribute('href', '/persons/new');
});
});
});
// ─── AC-1: typed text becomes displayName, not DB name ───────────────────────
describe('PersonMentionEditor — AC-1: typed text as displayName', () => {
it('stores the typed query as displayName, not the person DB name', async () => {
mockFetchWithPersons();
const host = renderHost();
// User types "@Aug" (not the full "Auguste Raddatz") and selects Auguste Raddatz
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons).toHaveLength(1);
expect(host.snapshot.mentionedPersons[0]).toEqual({
personId: 'p-aug',
displayName: 'Aug' // typed text, not "Auguste Raddatz"
});
});
});
it('regression: text value contains the typed query, not the full DB name', async () => {
mockFetchWithPersons();
const host = renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
await vi.waitFor(() => {
// Text should contain "@Aug " (typed text + space), not "@Auguste Raddatz "
expect(host.snapshot.value).toContain('@Aug');
expect(host.snapshot.value).not.toContain('@Auguste Raddatz');
});
});
it('pushes {personId, displayName} into mentionedPersons sidecar', async () => {
mockFetchWithPersons();
const host = renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
});
});
it('does not duplicate the sidecar entry when the same person is selected twice', async () => {
mockFetchWithPersons();
const host = renderHost({
value: '@Aug ',
mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }]
});
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons).toHaveLength(1);
});
});
});
// ─── Keyboard navigation ──────────────────────────────────────────────────────
describe('PersonMentionEditor — keyboard navigation', () => {
it('Enter selects the highlighted result', async () => {
mockFetchWithPersons();
const host = renderHost();
await userEvent.type(page.getByRole('textbox'), '@A');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
await userEvent.keyboard('{Enter}');
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons).toHaveLength(1);
});
});
it('ArrowDown moves the highlight to the next result', async () => {
mockFetchWithPersons();
renderHost();
await userEvent.type(page.getByRole('textbox'), '@A');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
await userEvent.keyboard('{ArrowDown}');
await vi.waitFor(async () => {
const annaOption = page.getByRole('option', { name: /Anna Schmidt/ });
await expect.element(annaOption).toHaveAttribute('aria-selected', 'true');
});
});
it('Escape closes the dropdown without inserting', async () => {
mockFetchWithPersons();
const host = renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
});
await userEvent.keyboard('{Escape}');
await vi.waitFor(async () => {
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
});
expect(host.snapshot.mentionedPersons).toEqual([]);
});
});
// ─── Disabled state (WCAG 2.1.1 — keyboard users) ────────────────────────────
describe('PersonMentionEditor — disabled state', () => {
it('sets contenteditable=false on the editor when disabled', async () => {
renderHost({ value: 'Bestehender Text', disabled: true });
await vi.waitFor(() => {
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
expect(textbox).not.toBeNull();
expect(textbox!.getAttribute('contenteditable')).toBe('false');
});
});
it('exposes aria-disabled=true on the editor wrapper when disabled', async () => {
renderHost({ disabled: true });
await vi.waitFor(() => {
const wrapper = document.querySelector('[aria-disabled="true"]');
expect(wrapper).not.toBeNull();
});
});
it('keeps the editor editable (contenteditable=true) when not disabled', async () => {
renderHost({ disabled: false });
await vi.waitFor(() => {
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
expect(textbox).not.toBeNull();
expect(textbox!.getAttribute('contenteditable')).toBe('true');
});
});
});
// ─── Security — XSS in displayName (CWE-79) ──────────────────────────────────
describe('PersonMentionEditor — XSS resistance', () => {
it('renders a malicious displayName as text, not as HTML elements', async () => {
// A historical sidecar entry whose displayName contains an HTML payload
// that would execute if interpolated as raw HTML. Tiptap's renderHTML
// returns the @-prefixed string as the third tuple entry, which
// ProseMirror's DOMSerializer treats as a Text node — escaping it.
const maliciousMention: PersonMention = {
personId: '00000000-0000-0000-0000-000000000001',
displayName: '<img src=x onerror=alert(1)>'
};
renderHost({
value: '@<img src=x onerror=alert(1)>',
mentionedPersons: [maliciousMention]
});
await vi.waitFor(() => {
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
expect(textbox).not.toBeNull();
// No element from the malicious payload should have appeared as a real
// DOM node. (Tiptap inserts its own ProseMirror-separator <img> in empty
// paragraphs — that is internal markup and never carries user attrs;
// guard against the injection by checking the user-controlled attrs.)
expect(textbox!.querySelector('img[onerror]')).toBeNull();
expect(textbox!.querySelector('img[src="x"]')).toBeNull();
expect(textbox!.querySelector('script')).toBeNull();
// The payload should appear as visible text content instead.
expect(textbox!.textContent ?? '').toContain('<img src=x onerror=alert(1)>');
});
});
});
// ─── Placeholder behavior ─────────────────────────────────────────────────────
describe('PersonMentionEditor — placeholder behavior', () => {
it('sets data-placeholder on the inner element when editor is empty', async () => {
render(PersonMentionEditorHost, {
initialValue: '',
initialMentions: [],
placeholder: 'Gib Text ein...',
onChange: () => {}
});
await vi.waitFor(() => {
const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null;
expect(inner).not.toBeNull();
expect(inner!.getAttribute('data-placeholder')).toBe('Gib Text ein...');
});
});
it('omits data-placeholder on the inner element when editor has content', async () => {
render(PersonMentionEditorHost, {
initialValue: 'Bestehender Text',
initialMentions: [],
placeholder: 'Gib Text ein...',
onChange: () => {}
});
await vi.waitFor(() => {
const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null;
expect(inner).not.toBeNull();
expect(inner!.hasAttribute('data-placeholder')).toBe(false);
});
});
});
// ─── i18n message content ─────────────────────────────────────────────────────
describe('PersonMentionEditor — i18n message content', () => {
it('transcription_block_placeholder contains @ mention trigger for discoverability', () => {
expect(m.transcription_block_placeholder()).toContain('@');
});
});
// ─── Touch target (WCAG 2.2 AA) ──────────────────────────────────────────────
describe('PersonMentionEditor — touch target', () => {
it('each result row has min-h-[44px] (WCAG 2.2 AA)', async () => {
mockFetchWithPersons();
renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option').first()).toBeVisible();
});
const option = document.querySelector('[role="option"]') as HTMLElement;
expect(option).not.toBeNull();
expect(option.className).toContain('min-h-[44px]');
});
});

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import { untrack } from 'svelte';
import PersonMentionEditor from './PersonMentionEditor.svelte';
import type { components } from '$lib/generated/api';
type PersonMention = components['schemas']['PersonMention'];
type Props = {
initialValue?: string;
initialMentions?: PersonMention[];
placeholder?: string;
disabled?: boolean;
onChange: (snapshot: { value: string; mentionedPersons: PersonMention[] }) => void;
};
let {
initialValue = '',
initialMentions = [],
placeholder,
disabled = false,
onChange
}: Props = $props();
// initial* props seed mount-time state; reading them inside untrack signals
// the intentional one-shot capture and silences state_referenced_locally.
let value = $state(untrack(() => initialValue));
let mentionedPersons = $state<PersonMention[]>(untrack(() => [...initialMentions]));
$effect(() => {
onChange({ value, mentionedPersons: [...mentionedPersons] });
});
</script>
<PersonMentionEditor
bind:value={value}
bind:mentionedPersons={mentionedPersons}
placeholder={placeholder}
disabled={disabled}
/>

View File

@@ -0,0 +1,101 @@
<script module>
// Module-level counter produces stable, predictable IDs across SSR + hydration.
// Math.random() would generate different values server-side vs client-side,
// causing a hydration mismatch on first render.
let _counter = 0;
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
type Placement = 'bottom' | 'top' | 'left' | 'right';
type Props = {
label: string;
placement?: Placement;
children?: Snippet;
};
let { label, placement = 'bottom', children }: Props = $props();
const popoverId = `help-popover-${_counter++}`;
let open = $state(false);
let triggerEl: HTMLButtonElement | null = $state(null);
function toggle() {
open = !open;
}
function close() {
open = false;
triggerEl?.focus();
}
$effect(() => {
if (!open) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
close();
}
}
function onPointerDown(e: PointerEvent) {
if (triggerEl && (e.target === triggerEl || triggerEl.contains(e.target as Node))) return;
const popoverEl = document.getElementById(popoverId);
if (popoverEl && popoverEl.contains(e.target as Node)) return;
open = false;
}
document.addEventListener('keydown', onKeyDown);
document.addEventListener('pointerdown', onPointerDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('pointerdown', onPointerDown);
};
});
const placementClass: Record<Placement, string> = {
bottom: 'top-full mt-1.5 left-1/2 -translate-x-1/2',
top: 'bottom-full mb-1.5 left-1/2 -translate-x-1/2',
left: 'right-full mr-1.5 top-1/2 -translate-y-1/2',
right: 'left-full ml-1.5 top-1/2 -translate-y-1/2'
};
</script>
<div class="relative inline-block">
<!--
Outer button is 44×44px for WCAG 2.5.8 touch-target compliance (our transcriber
audience is 60+). The inner <span> carries the visual 20×20px circle.
-->
<button
bind:this={triggerEl}
type="button"
aria-label={label}
aria-expanded={open}
aria-controls={popoverId}
onclick={toggle}
class="group flex h-[44px] w-[44px] items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-brand-navy"
>
<span
class="flex h-5 w-5 items-center justify-center rounded-full border border-line bg-muted font-sans text-[10px] font-bold text-ink-3 transition-colors group-hover:border-brand-navy group-hover:text-brand-navy"
>
?
</span>
</button>
{#if open}
<div
id={popoverId}
role="region"
aria-label={label}
class="absolute z-50 w-64 rounded-sm border border-line bg-white p-3 font-sans text-sm text-ink shadow-md {placementClass[placement]}"
>
{#if children}
{@render children()}
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import HelpPopover from './HelpPopover.svelte';
afterEach(cleanup);
function renderPopover(label = 'Help') {
return render(HelpPopover, { props: { label } });
}
describe('HelpPopover — initial state', () => {
it('renders a trigger button with the given label', async () => {
renderPopover();
const btn = page.getByRole('button', { name: /Help/ });
await expect.element(btn).toBeInTheDocument();
});
it('starts closed: aria-expanded is false, popover not in DOM', async () => {
renderPopover();
const btn = page.getByRole('button', { name: /Help/ });
await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
expect(document.querySelector('[role="region"]')).toBeNull();
});
});
describe('HelpPopover — open / close interactions', () => {
it('opens on click: aria-expanded true, popover in DOM', async () => {
renderPopover();
await page.getByRole('button', { name: /Help/ }).click();
const btn = page.getByRole('button', { name: /Help/ });
await expect.element(btn).toHaveAttribute('aria-expanded', 'true');
expect(document.querySelector('[role="region"]')).not.toBeNull();
});
it('closes on Esc key', async () => {
renderPopover();
await page.getByRole('button', { name: /Help/ }).click();
expect(document.querySelector('[role="region"]')).not.toBeNull();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
});
it('closes on outside click', async () => {
renderPopover();
await page.getByRole('button', { name: /Help/ }).click();
expect(document.querySelector('[role="region"]')).not.toBeNull();
document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
});
it('opens on Enter key', async () => {
renderPopover();
(document.querySelector('button[aria-expanded]') as HTMLButtonElement).focus();
await userEvent.keyboard('{Enter}');
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
});
it('opens on Space key', async () => {
renderPopover();
(document.querySelector('button[aria-expanded]') as HTMLButtonElement).focus();
await userEvent.keyboard('{Space}');
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
});
});
describe('HelpPopover — hover-target', () => {
it('hover styles propagate from 44px button group to inner span, not from span itself', () => {
const { container } = renderPopover();
const btn = container.querySelector('button[aria-expanded]')!;
const span = btn.querySelector('span')!;
const btnClasses = btn.className.split(/\s+/);
const spanClasses = span.className.split(/\s+/);
expect(btnClasses).toContain('group');
expect(spanClasses).not.toContain('hover:border-brand-navy');
expect(spanClasses).toContain('group-hover:border-brand-navy');
expect(spanClasses).not.toContain('hover:text-brand-navy');
expect(spanClasses).toContain('group-hover:text-brand-navy');
});
it('outer button has focus-visible ring for keyboard users', () => {
const { container } = renderPopover();
const btn = container.querySelector('button[aria-expanded]')!;
expect(btn.className).toContain('focus-visible:ring-2');
expect(btn.className).toContain('focus-visible:ring-brand-navy');
});
});
describe('HelpPopover — aria wiring', () => {
it('trigger aria-controls matches popover element id', async () => {
renderPopover();
await page.getByRole('button', { name: /Help/ }).click();
const btn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
const controls = btn.getAttribute('aria-controls');
expect(controls).toBeTruthy();
const popover = document.getElementById(controls!);
expect(popover).not.toBeNull();
});
it('two renders produce different, predictable IDs (no Math.random — SSR safe)', async () => {
const { container: c1 } = render(HelpPopover, { props: { label: 'A' } });
const { container: c2 } = render(HelpPopover, { props: { label: 'B' } });
const id1 = c1.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
const id2 = c2.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
expect(id1).toBeTruthy();
expect(id2).toBeTruthy();
expect(id1).not.toBe(id2);
// IDs must be deterministic (counter-based), not random hex
expect(id1).toMatch(/^help-popover-\d+$/);
expect(id2).toMatch(/^help-popover-\d+$/);
});
});

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import TranscribeDragDemo from './TranscribeDragDemo.svelte';
</script>
<div class="border-brand-sand rounded-sm border bg-white p-7 shadow-sm">
<h2 class="mb-3 font-serif text-[22px] font-bold text-ink">
{m.transcribe_coach_title()}
</h2>
<p class="mb-6 font-serif text-[15px] leading-relaxed text-ink-2">
{m.transcribe_coach_preamble()}
</p>
<ol class="m-0 flex list-none flex-col gap-[18px] p-0">
<!-- Step 1 -->
<li aria-label="Schritt 1 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
<span
aria-hidden="true"
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
>1</span
>
<div class="pt-0.5 font-serif text-base leading-snug text-ink">
<strong>{m.transcribe_coach_step_1_title()}</strong>
{m.transcribe_coach_step_1_body()}
<TranscribeDragDemo />
</div>
</li>
<!-- Step 2 -->
<li aria-label="Schritt 2 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
<span
aria-hidden="true"
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
>2</span
>
<div class="pt-0.5 font-serif text-base leading-snug text-ink">
<strong>{m.transcribe_coach_step_2_title()}</strong>
{m.transcribe_coach_step_2_body()}
</div>
</li>
<!-- Step 3 -->
<li aria-label="Schritt 3 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
<span
aria-hidden="true"
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
>3</span
>
<div class="pt-0.5 font-serif text-base leading-snug text-ink">
<strong>{m.transcribe_coach_step_3_title()}</strong>
</div>
</li>
</ol>
<div class="border-brand-sand mt-6 flex flex-wrap gap-4 border-t pt-3.5 font-sans text-[13px]">
<a
href="https://de.wikipedia.org/wiki/Kurrent"
target="_blank"
rel="noopener noreferrer"
referrerpolicy="no-referrer"
class="text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]"
>
{m.transcribe_coach_footer_kurrent()}
<span class="ml-1 text-[11px] text-ink-3">{m.common_opens_new_tab()}</span>
</a>
<a
href="/hilfe/transkription"
target="_blank"
rel="noopener noreferrer"
class="text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]"
>
{m.transcribe_coach_footer_richtlinien()}
<span class="ml-1 text-[11px] text-ink-3">{m.common_opens_new_tab()}</span>
</a>
</div>
</div>

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte';
vi.mock('$lib/paraglide/messages.js', () => ({
m: {
transcribe_coach_title: () => 'Erste Transkription?',
transcribe_coach_preamble: () => 'Unser Kurrent-Erkenner lernt noch.',
transcribe_coach_step_1_title: () => 'Rahmen ziehen.',
transcribe_coach_step_1_body: () => 'Klicken und ziehen Sie mit der Maus einen Rahmen.',
transcribe_coach_step_2_title: () => 'Text eingeben.',
transcribe_coach_step_2_body: () => 'Geben Sie den Text ein.',
transcribe_coach_step_3_title: () => 'Speichert automatisch.',
transcribe_coach_footer_kurrent: () => 'Hilfe zu Kurrent ↗',
transcribe_coach_footer_richtlinien: () => 'Transkriptions-Richtlinien ↗',
common_opens_new_tab: () => '(öffnet in neuem Tab)'
}
}));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe('TranscribeCoachEmptyState', () => {
it('renders the title and preamble', async () => {
render(TranscribeCoachEmptyState);
await expect
.element(page.getByRole('heading', { level: 2 }))
.toHaveTextContent('Erste Transkription?');
await expect.element(page.getByText('Unser Kurrent-Erkenner lernt noch.')).toBeInTheDocument();
});
it('renders three numbered steps', async () => {
render(TranscribeCoachEmptyState);
await expect.element(page.getByText('Rahmen ziehen.')).toBeInTheDocument();
await expect
.element(page.getByText('Klicken und ziehen Sie mit der Maus einen Rahmen.'))
.toBeInTheDocument();
await expect.element(page.getByText('Text eingeben.')).toBeInTheDocument();
await expect.element(page.getByText('Geben Sie den Text ein.')).toBeInTheDocument();
await expect.element(page.getByText('Speichert automatisch.')).toBeInTheDocument();
});
it('renders footer links to Wikipedia Kurrent and Richtlinien page', async () => {
render(TranscribeCoachEmptyState);
const kurrentLink = page.getByRole('link', { name: /Hilfe zu Kurrent/ });
await expect.element(kurrentLink).toBeInTheDocument();
await expect.element(kurrentLink).toHaveAttribute('target', '_blank');
await expect.element(kurrentLink).toHaveAttribute('rel', 'noopener noreferrer');
await expect.element(kurrentLink).toHaveAttribute('referrerpolicy', 'no-referrer');
const richtlinienLink = page.getByRole('link', { name: /Transkriptions-Richtlinien/ });
await expect.element(richtlinienLink).toBeInTheDocument();
await expect.element(richtlinienLink).toHaveAttribute('target', '_blank');
await expect.element(richtlinienLink).toHaveAttribute('rel', 'noopener noreferrer');
});
it('renders visible "(öffnet in neuem Tab)" annotation on each footer link', async () => {
render(TranscribeCoachEmptyState);
const annotations = page.getByText('(öffnet in neuem Tab)');
await expect.element(annotations.first()).toBeInTheDocument();
});
it('renders the drag demo animation region inside step 1', async () => {
render(TranscribeCoachEmptyState);
const demo = page.getByRole('img', { name: /Rahmen ziehen|Animation/i });
await expect.element(demo).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,217 @@
<script lang="ts">
// $derived from .matches is a one-shot snapshot — it doesn't react when the
// user toggles the OS setting at runtime. Use $state + addEventListener instead.
let prefersReducedMotion = $state(
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
$effect(() => {
if (typeof window === 'undefined') return;
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
const handler = (e: MediaQueryListEvent) => {
prefersReducedMotion = e.matches;
};
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
});
</script>
{#if prefersReducedMotion}
<!-- Static final frame for reduced-motion users -->
<svg
role="img"
aria-label="Eine gestrichelte Umrandung markiert eine Zeile Kurrentschrift auf dem Dokument."
viewBox="0 0 600 180"
class="border-brand-sand block w-full rounded-sm border bg-parchment"
>
<g
stroke="#2a2a2a"
stroke-width="1.6"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M 70 100 q 4 -25 10 -8 q 6 22 14 -2 q 4 -20 10 -3 q 6 22 12 -6 q 4 -18 10 2" />
<path d="M 145 100 q 5 -28 14 -2 q 5 -20 12 -4 q 6 22 14 -6 q 4 -15 10 4" />
<path d="M 230 100 q 6 -26 14 -2 q 5 -22 12 1 q 5 25 14 -5 q 4 -18 10 3 q 5 -20 10 1" />
<path d="M 340 100 q 5 -30 12 -4 q 6 -18 14 5 q 5 -24 12 0 q 6 24 14 -10" />
<path d="M 440 100 q 6 -28 14 0 q 5 -22 12 -4 q 6 24 14 -1 q 4 -18 10 2" />
</g>
<line
x1="60"
y1="120"
x2="540"
y2="120"
stroke="#D4D1C4"
stroke-width="0.8"
stroke-dasharray="2 3"
/>
<rect
x="55"
y="68"
width="470"
height="57"
fill="rgba(166, 218, 216, 0.12)"
stroke="#002850"
stroke-width="2.2"
/>
<g transform="translate(515, 58)">
<circle cx="0" cy="0" r="9" fill="#002850" />
<path
d="M -4 0 L -1 3 L 4 -3"
stroke="white"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
</svg>
{:else}
<!-- Animated 5-second drawing loop -->
<svg
role="img"
aria-label="Animation: Ein Cursor zieht einen gestrichelten Rahmen um eine Zeile Kurrentschrift. Beim Loslassen wird der Rahmen durchgehend und ein Häkchen erscheint."
viewBox="0 0 600 180"
class="border-brand-sand block w-full rounded-sm border bg-parchment"
>
<!-- Kurrent writing (static) -->
<g
stroke="#2a2a2a"
stroke-width="1.6"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M 70 100 q 4 -25 10 -8 q 6 22 14 -2 q 4 -20 10 -3 q 6 22 12 -6 q 4 -18 10 2" />
<path d="M 145 100 q 5 -28 14 -2 q 5 -20 12 -4 q 6 22 14 -6 q 4 -15 10 4" />
<path d="M 230 100 q 6 -26 14 -2 q 5 -22 12 1 q 5 25 14 -5 q 4 -18 10 3 q 5 -20 10 1" />
<path d="M 340 100 q 5 -30 12 -4 q 6 -18 14 5 q 5 -24 12 0 q 6 24 14 -10" />
<path d="M 440 100 q 6 -28 14 0 q 5 -22 12 -4 q 6 24 14 -1 q 4 -18 10 2" />
</g>
<line
x1="60"
y1="120"
x2="540"
y2="120"
stroke="#D4D1C4"
stroke-width="0.8"
stroke-dasharray="2 3"
/>
<!-- Click ripple -->
<circle cx="55" cy="68" r="0" fill="none" stroke="#A6DAD8" stroke-width="2.5" opacity="0">
<animate
attributeName="r"
values="0;0;4;18;0;0"
keyTimes="0;0.17;0.19;0.24;0.26;1"
dur="5s"
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="0;0;1;0;0;0"
keyTimes="0;0.17;0.19;0.24;0.26;1"
dur="5s"
repeatCount="indefinite"
/>
</circle>
<!-- Growing selection rectangle -->
<rect
x="55"
y="68"
width="0"
height="0"
fill="rgba(166, 218, 216, 0.12)"
stroke="#002850"
stroke-width="2"
stroke-dasharray="5 4"
opacity="0"
>
<animate
attributeName="opacity"
values="0;0;1;1;1;1;0;0"
keyTimes="0;0.18;0.20;0.88;0.92;0.94;0.98;1"
dur="5s"
repeatCount="indefinite"
/>
<animate
attributeName="width"
values="0;0;470;470;470;470;0"
keyTimes="0;0.20;0.62;0.88;0.94;0.98;1"
dur="5s"
repeatCount="indefinite"
/>
<animate
attributeName="height"
values="0;0;57;57;57;57;0"
keyTimes="0;0.20;0.62;0.88;0.94;0.98;1"
dur="5s"
repeatCount="indefinite"
/>
<animate
attributeName="stroke-dasharray"
values="5 4;5 4;5 4;1 0;1 0;5 4"
keyTimes="0;0.60;0.64;0.68;0.94;1"
dur="5s"
repeatCount="indefinite"
/>
<animate
attributeName="stroke-width"
values="2;2;2;3.2;2.2;2;2"
keyTimes="0;0.64;0.66;0.68;0.72;0.90;1"
dur="5s"
repeatCount="indefinite"
/>
</rect>
<!-- Confirmation checkmark badge -->
<g opacity="0" transform="translate(515, 58)">
<circle cx="0" cy="0" r="9" fill="#002850" />
<path
d="M -4 0 L -1 3 L 4 -3"
stroke="white"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
/>
<animate
attributeName="opacity"
values="0;0;1;1;0;0"
keyTimes="0;0.66;0.70;0.86;0.92;1"
dur="5s"
repeatCount="indefinite"
/>
</g>
<!-- Cursor arrow -->
<g>
<animateTransform
attributeName="transform"
type="translate"
values="15,20; 55,68; 55,68; 525,125; 525,125; 15,20"
keyTimes="0; 0.15; 0.20; 0.62; 0.92; 1"
calcMode="spline"
keySplines="0.4 0 0.2 1; 0 0 1 1; 0.4 0 0.2 1; 0 0 1 1; 0.4 0 0.2 1"
dur="5s"
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="1;1;1;0;0;1"
keyTimes="0;0.92;0.94;0.96;0.99;1"
dur="5s"
repeatCount="indefinite"
/>
<path
d="M 0 0 L 0 16 L 4.5 12 L 7.5 18 L 10.5 16.6 L 7.8 10.6 L 13 9 Z"
fill="#002850"
stroke="white"
stroke-width="0.8"
stroke-linejoin="round"
/>
</g>
</svg>
{/if}

View File

@@ -0,0 +1,23 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TranscribeDragDemo from './TranscribeDragDemo.svelte';
afterEach(() => {
cleanup();
});
describe('TranscribeDragDemo', () => {
it('renders an SVG with an aria-label describing the animation', async () => {
render(TranscribeDragDemo);
const svg = page.getByRole('img');
await expect.element(svg).toBeInTheDocument();
await expect.element(svg).toHaveAttribute('aria-label');
});
it('contains a dashed-border rectangle animation element', async () => {
const { container } = render(TranscribeDragDemo);
const rect = container.querySelector('rect');
expect(rect).not.toBeNull();
});
});

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const { createTypeahead } = await import('../useTypeahead.svelte');
const { createTypeahead } = await import('./useTypeahead.svelte');
describe('createTypeahead', () => {
beforeEach(() => {

View File

@@ -14,7 +14,7 @@ vi.mock('$app/navigation', () => ({
goto: mockGoto
}));
const { createUnsavedWarning } = await import('../useUnsavedWarning.svelte');
const { createUnsavedWarning } = await import('./useUnsavedWarning.svelte');
function simulateNavigate(href: string | null = '/somewhere') {
const cancel = vi.fn();

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let { class: cls = 'mb-4', showLabel = true }: { class?: string; showLabel?: boolean } = $props();
</script>
<button
type="button"
onclick={() => history.back()}
aria-label={!showLabel ? m.btn_back() : undefined}
class="group {cls} inline-flex min-h-[44px] items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors outline-none hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<svg
class="{showLabel ? 'mr-2' : ''} h-4 w-4 transform transition-transform group-hover:-translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
{#if showLabel}{m.btn_back()}{/if}
</button>

View File

@@ -0,0 +1,43 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import BackButton from './BackButton.svelte';
afterEach(cleanup);
describe('BackButton', () => {
it('renders a button with "Zurück" text', async () => {
render(BackButton);
await expect.element(page.getByRole('button', { name: /zurück/i })).toBeInTheDocument();
});
it('calls history.back() when clicked', async () => {
const backSpy = vi.spyOn(history, 'back').mockImplementation(() => {});
render(BackButton);
await page.getByRole('button', { name: /zurück/i }).click();
expect(backSpy).toHaveBeenCalledOnce();
backSpy.mockRestore();
});
it('applies mb-4 by default', async () => {
render(BackButton);
const btn = document.querySelector('button');
expect(btn?.className).toContain('mb-4');
});
it('applies custom class prop instead of default', async () => {
render(BackButton, { props: { class: 'mr-3 md:hidden' } });
const btn = document.querySelector('button');
expect(btn?.className).toContain('mr-3');
expect(btn?.className).not.toContain('mb-4');
});
it('hides label text and sets aria-label when showLabel is false', async () => {
render(BackButton, { props: { showLabel: false } });
const btn = document.querySelector('button');
expect(btn?.textContent?.trim()).toBe('');
expect(btn?.getAttribute('aria-label')).toMatch(/zurück/i);
});
});

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import { getConfirmService } from '$lib/shared/services/confirm.svelte.js';
import { m } from '$lib/paraglide/messages.js';
// Context must already be set by the parent layout via provideConfirmService().
const service = getConfirmService();
let dialogEl: HTMLDialogElement;
$effect(() => {
if (service.options) {
dialogEl.showModal();
} else {
dialogEl.close();
}
});
</script>
<dialog
bind:this={dialogEl}
class="m-auto w-full max-w-sm rounded-sm border border-line bg-surface p-6 shadow-lg backdrop:bg-black/50"
aria-labelledby="confirm-title"
oncancel={(e) => {
e.preventDefault();
service.settle(false);
}}
onclick={(e) => {
const opts = service.options;
if (!opts) return;
const closeOnBackdrop = opts.closeOnBackdrop ?? !opts.destructive;
if (closeOnBackdrop && e.target === dialogEl) {
service.settle(false);
}
}}
>
{#if service.options}
{@const opts = service.options}
<h2 id="confirm-title" class="mb-2 font-serif text-lg text-ink">{opts.title}</h2>
{#if opts.body !== undefined}
<p class="mb-6 text-sm text-ink-2">{opts.body}</p>
{/if}
<div class="flex items-center justify-end gap-3">
<button
type="button"
class="min-h-[44px] cursor-pointer rounded-sm border border-line px-4 py-2 text-sm font-medium text-ink-2 transition-colors hover:bg-muted"
onclick={() => service.settle(false)}
>
{opts.cancelLabel ?? m.btn_cancel()}
</button>
<button
type="button"
class="min-h-[44px] cursor-pointer rounded-sm px-4 py-2 text-sm font-medium transition-colors {opts.destructive
? 'bg-danger text-danger-fg hover:bg-danger/80'
: 'bg-primary text-primary-fg hover:bg-primary/80'}"
onclick={() => service.settle(true)}
>
{opts.confirmLabel ?? m.btn_confirm()}
</button>
</div>
{/if}
</dialog>

View File

@@ -0,0 +1,130 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ConfirmDialog from './ConfirmDialog.svelte';
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
afterEach(cleanup);
function renderDialog() {
const service = createConfirmService();
const result = render(ConfirmDialog, {
context: new Map([[CONFIRM_KEY, service]])
});
return { ...result, service };
}
describe('ConfirmDialog', () => {
it('renders the title when options are set', async () => {
const { service } = renderDialog();
service.confirm({ title: 'Delete this item?' });
await expect.element(page.getByText('Delete this item?')).toBeInTheDocument();
service.settle(false);
});
it('renders the body when provided', async () => {
const { service } = renderDialog();
service.confirm({ title: 'Delete?', body: 'This cannot be undone.' });
await expect.element(page.getByText('This cannot be undone.')).toBeInTheDocument();
service.settle(false);
});
it('does not render body element when body is omitted', async () => {
const { service } = renderDialog();
service.confirm({ title: 'Delete?' });
await expect.element(page.getByText('Delete?')).toBeInTheDocument();
const body = document.querySelector('p');
expect(body).toBeNull();
service.settle(false);
});
it('applies bg-danger class on confirm button when destructive', async () => {
const { service } = renderDialog();
service.confirm({ title: 'Delete?', destructive: true });
await expect.element(page.getByText('Delete?')).toBeInTheDocument();
const confirmBtn = document.querySelector('button[class*="bg-danger"]');
expect(confirmBtn).not.toBeNull();
service.settle(false);
});
it('applies bg-primary class on confirm button when not destructive', async () => {
const { service } = renderDialog();
service.confirm({ title: 'Confirm action?' });
await expect.element(page.getByText('Confirm action?')).toBeInTheDocument();
const confirmBtn = document.querySelector('button[class*="bg-primary"]');
expect(confirmBtn).not.toBeNull();
service.settle(false);
});
it('renders custom confirmLabel when provided', async () => {
const { service } = renderDialog();
service.confirm({ title: 'Remove?', confirmLabel: 'Yes, remove it' });
await expect.element(page.getByRole('button', { name: 'Yes, remove it' })).toBeInTheDocument();
service.settle(false);
});
it('renders custom cancelLabel when provided', async () => {
const { service } = renderDialog();
service.confirm({ title: 'Remove?', cancelLabel: 'No, keep it' });
await expect.element(page.getByRole('button', { name: 'No, keep it' })).toBeInTheDocument();
service.settle(false);
});
it('settles true when confirm button is clicked', async () => {
const { service } = renderDialog();
const resultPromise = service.confirm({ title: 'Do it?' });
await expect.element(page.getByText('Do it?')).toBeInTheDocument();
const confirmBtn = document.querySelectorAll<HTMLButtonElement>('button[type="button"]')[1];
confirmBtn.click();
expect(await resultPromise).toBe(true);
});
it('settles false when cancel button is clicked', async () => {
const { service } = renderDialog();
const resultPromise = service.confirm({ title: 'Do it?' });
await expect.element(page.getByText('Do it?')).toBeInTheDocument();
const cancelBtn = document.querySelectorAll<HTMLButtonElement>('button[type="button"]')[0];
cancelBtn.click();
expect(await resultPromise).toBe(false);
});
it('hides content when no options are set', () => {
renderDialog();
const heading = document.querySelector('#confirm-title');
expect(heading).toBeNull();
});
it('has aria-labelledby pointing to the title element', async () => {
const { service } = renderDialog();
service.confirm({ title: 'Accessible title' });
await expect.element(page.getByText('Accessible title')).toBeInTheDocument();
const dialog = document.querySelector('dialog');
expect(dialog?.getAttribute('aria-labelledby')).toBe('confirm-title');
const title = document.getElementById('confirm-title');
expect(title).not.toBeNull();
service.settle(false);
});
it('does not show content after settling', async () => {
const { service } = renderDialog();
service.confirm({ title: 'Gone soon?' });
await expect.element(page.getByText('Gone soon?')).toBeInTheDocument();
service.settle(false);
await vi.waitFor(() => {
expect(document.querySelector('#confirm-title')).toBeNull();
});
});
});

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
type ActivityActorDTO = components['schemas']['ActivityActorDTO'];
interface Props {
contributors: ActivityActorDTO[];
hasMore: boolean;
}
let { contributors, hasMore }: Props = $props();
const safeContributors = $derived(contributors ?? []);
function safeColor(color: string): string {
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#8c9aa3';
}
</script>
{#if safeContributors.length === 0}
<span
role="img"
aria-label="Noch niemand angefangen"
class="inline-block h-[22px] w-[22px] flex-shrink-0 rounded-full border-[1.5px] border-dashed border-[#cdcbbf]"
></span>
{:else}
<span class="inline-flex items-center">
{#each safeContributors as actor, i (actor.initials + '-' + actor.color)}
<span
role="img"
aria-label={actor.name ?? actor.initials}
class="inline-flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-full font-sans text-[10px] font-bold text-white ring-2 ring-white {i > 0 ? '-ml-1.5' : ''}"
style="background-color: {safeColor(actor.color)};"
title={actor.name ?? actor.initials}
>
{actor.initials}
</span>
{/each}
{#if hasMore}
<span
role="img"
aria-label="Weitere Mitwirkende"
class="-ml-1.5 inline-flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-full bg-[#e4e2d7] font-sans text-[10px] font-bold text-ink-3 ring-2 ring-white"
>
</span>
{/if}
</span>
{/if}

View File

@@ -0,0 +1,52 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ContributorStack from './ContributorStack.svelte';
import type { components } from '$lib/generated/api';
type ActivityActorDTO = components['schemas']['ActivityActorDTO'];
afterEach(() => cleanup());
const makeActor = (overrides: Partial<ActivityActorDTO> = {}): ActivityActorDTO => ({
initials: 'MR',
color: '#7a4f9a',
name: 'Max Raddatz',
...overrides
});
describe('ContributorStack', () => {
it('contributor avatar is announced by screen readers with actor name', async () => {
const actor = makeActor({ name: 'Anna Meier', initials: 'AM' });
render(ContributorStack, { contributors: [actor], hasMore: false });
await expect.element(page.getByRole('img', { name: 'Anna Meier' })).toBeInTheDocument();
});
it('falls back to initials as accessible name when actor name is null', async () => {
const actor = makeActor({ name: undefined, initials: 'AM' });
render(ContributorStack, { contributors: [actor], hasMore: false });
await expect.element(page.getByRole('img', { name: 'AM' })).toBeInTheDocument();
});
it('renders two avatars without crashing when actors have identical initials', async () => {
const actors = [
makeActor({ name: undefined, initials: 'AM', color: '#aa0000' }),
makeActor({ name: undefined, initials: 'AM', color: '#0000bb' })
];
render(ContributorStack, { contributors: actors, hasMore: false });
await expect.element(page.getByText('AM').first()).toBeInTheDocument();
});
it('renders overflow indicator when hasMore is true', async () => {
render(ContributorStack, { contributors: [makeActor()], hasMore: true });
await expect.element(page.getByText('…')).toBeInTheDocument();
});
it('renders empty placeholder when no contributors', async () => {
render(ContributorStack, { contributors: [], hasMore: false });
await expect
.element(page.getByRole('img', { name: 'Noch niemand angefangen' }))
.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import { isoToGerman, handleGermanDateInput, germanToIso } from '$lib/shared/utils/date';
import { m } from '$lib/paraglide/messages.js';
interface Props {
value?: string;
errorMessage?: string | null;
name?: string;
id?: string;
placeholder?: string;
class?: string;
onchange?: () => void;
}
let {
value = $bindable(''),
errorMessage = $bindable<string | null>(null),
name,
id,
placeholder,
class: className = '',
onchange
}: Props = $props();
let display = $state(isoToGerman(value ?? ''));
// ─── Validation helper ────────────────────────────────────────────────────
function isCalendarValid(iso: string): boolean {
if (!iso) return false;
const [, mm, dd] = iso.match(/^\d{4}-(\d{2})-(\d{2})$/) ?? [];
const month = parseInt(mm, 10);
const day = parseInt(dd, 10);
return month >= 1 && month <= 12 && day >= 1 && day <= 31;
}
// ─── Input handler ────────────────────────────────────────────────────────
function handleInput(e: Event) {
const result = handleGermanDateInput(e);
display = result.display;
if (result.display === '') {
value = '';
errorMessage = null;
onchange?.();
return;
}
if (result.display.length < 10) {
value = '';
errorMessage = m.form_date_error();
return;
}
const iso = germanToIso(result.display);
if (!iso || !isCalendarValid(iso)) {
value = '';
errorMessage = m.form_date_error();
return;
}
value = iso;
errorMessage = null;
onchange?.();
}
</script>
<input
type="text"
inputmode="numeric"
maxlength="10"
id={id}
value={display}
placeholder={placeholder ?? m.form_placeholder_date()}
oninput={handleInput}
class={className}
/>
{#if name}
<input type="hidden" name={name} value={value} />
{/if}

View File

@@ -0,0 +1,210 @@
import { describe, expect, it, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DateInput from './DateInput.svelte';
afterEach(() => cleanup());
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('DateInput rendering', () => {
it('renders a text input with inputmode=numeric and maxlength=10', async () => {
render(DateInput, {});
const input = page.getByRole('textbox');
await expect.element(input).toBeInTheDocument();
await expect.element(input).toHaveAttribute('inputmode', 'numeric');
await expect.element(input).toHaveAttribute('maxlength', '10');
});
it('has default placeholder from paraglide', async () => {
render(DateInput, {});
const input = page.getByRole('textbox');
await expect.element(input).toHaveAttribute('placeholder', 'TT.MM.JJJJ');
});
it('accepts a custom placeholder', async () => {
render(DateInput, { placeholder: 'Geburtsdatum' });
const input = page.getByRole('textbox');
await expect.element(input).toHaveAttribute('placeholder', 'Geburtsdatum');
});
it('passes id prop to the input', async () => {
render(DateInput, { id: 'my-date' });
const input = page.getByRole('textbox');
await expect.element(input).toHaveAttribute('id', 'my-date');
});
});
// ─── Init from value ──────────────────────────────────────────────────────────
describe('DateInput init from value', () => {
it('displays ISO value in German format on mount', async () => {
render(DateInput, { value: '2024-12-20' });
const input = page.getByRole('textbox');
await expect.element(input).toHaveValue('20.12.2024');
});
it('starts empty and error-free when no value is given', async () => {
let errorMessage: string | null = null;
render(DateInput, {
get errorMessage() {
return errorMessage;
},
set errorMessage(v) {
errorMessage = v;
}
});
const input = page.getByRole('textbox');
await expect.element(input).toHaveValue('');
expect(errorMessage).toBeNull();
});
});
// ─── Typing valid date ────────────────────────────────────────────────────────
describe('DateInput typing a valid date', () => {
it('auto-formats to DD.MM.YYYY and updates value to ISO', async () => {
let value = '';
let errorMessage: string | null = null;
render(DateInput, {
get value() {
return value;
},
set value(v) {
value = v;
},
get errorMessage() {
return errorMessage;
},
set errorMessage(v) {
errorMessage = v;
}
});
const input = page.getByRole('textbox');
await input.fill('20122024');
await expect.element(input).toHaveValue('20.12.2024');
expect(value).toBe('2024-12-20');
expect(errorMessage).toBeNull();
});
});
// ─── Typing invalid month ─────────────────────────────────────────────────────
describe('DateInput typing a date with invalid month', () => {
it('sets errorMessage and clears value when month > 12', async () => {
let value = '';
let errorMessage: string | null = null;
render(DateInput, {
get value() {
return value;
},
set value(v) {
value = v;
},
get errorMessage() {
return errorMessage;
},
set errorMessage(v) {
errorMessage = v;
}
});
const input = page.getByRole('textbox');
await input.fill('22222222');
await expect.element(input).toHaveValue('22.22.2222');
expect(value).toBe('');
expect(errorMessage).not.toBeNull();
});
});
// ─── Typing partial date ──────────────────────────────────────────────────────
describe('DateInput typing a partial date', () => {
it('sets errorMessage and clears value when date is incomplete', async () => {
let value = '';
let errorMessage: string | null = null;
render(DateInput, {
get value() {
return value;
},
set value(v) {
value = v;
},
get errorMessage() {
return errorMessage;
},
set errorMessage(v) {
errorMessage = v;
}
});
const input = page.getByRole('textbox');
await input.fill('2212');
await expect.element(input).toHaveValue('22.12');
expect(value).toBe('');
expect(errorMessage).not.toBeNull();
});
});
// ─── Clearing date ────────────────────────────────────────────────────────────
describe('DateInput clearing the date', () => {
it('resets value and errorMessage to null when cleared', async () => {
let value = '';
let errorMessage: string | null = null;
render(DateInput, {
get value() {
return value;
},
set value(v) {
value = v;
},
get errorMessage() {
return errorMessage;
},
set errorMessage(v) {
errorMessage = v;
}
});
const input = page.getByRole('textbox');
// Type a valid date first
await input.fill('20122024');
expect(value).toBe('2024-12-20');
// Now clear
await input.fill('');
expect(value).toBe('');
expect(errorMessage).toBeNull();
});
it('fires onchange when the field is cleared', async () => {
let called = 0;
render(DateInput, { value: '2024-12-20', onchange: () => called++ });
const input = page.getByRole('textbox');
await input.fill('');
expect(called).toBeGreaterThan(0);
});
});
// ─── Hidden input ─────────────────────────────────────────────────────────────
describe('DateInput hidden input for form submission', () => {
it('renders a hidden input with the given name when name prop is set', async () => {
render(DateInput, { name: 'documentDate' });
const hidden = document.querySelector('input[type="hidden"][name="documentDate"]');
expect(hidden).not.toBeNull();
});
it('does not render a hidden input when name prop is absent', async () => {
render(DateInput, {});
const hidden = document.querySelector('input[type="hidden"]');
expect(hidden).toBeNull();
});
it('hidden input value reflects the ISO value', async () => {
render(DateInput, { name: 'documentDate', value: '' });
const input = page.getByRole('textbox');
await input.fill('20122024');
const hidden = document.querySelector<HTMLInputElement>(
'input[type="hidden"][name="documentDate"]'
);
await expect.poll(() => hidden?.value).toBe('2024-12-20');
});
});

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
interface Props {
outCount: number;
inCount: number;
senderName: string;
receiverName: string;
}
let { outCount, inCount, senderName, receiverName }: Props = $props();
const total = $derived(outCount + inCount);
const outPct = $derived(total > 0 ? (outCount / total) * 100 : 0);
const shortSenderName = $derived(senderName.split(' ')[0] ?? senderName);
const shortReceiverName = $derived(receiverName.split(' ')[0] ?? receiverName);
const ariaLabel = $derived(m.dist_bar_aria({ outCount, senderName, inCount, receiverName }));
const outSegmentText = $derived(m.dist_bar_segment({ count: outCount, name: shortSenderName }));
const inSegmentText = $derived(m.dist_bar_segment({ count: inCount, name: shortReceiverName }));
</script>
<div
class="flex flex-col gap-1 border-b border-line bg-muted px-[18px] py-2"
role="img"
aria-label={ariaLabel}
>
<div class="flex justify-between text-sm font-bold">
<span class="inline-flex items-center gap-1 text-primary"
>{outSegmentText}
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg"
alt=""
aria-hidden="true"
class="inline h-3.5 w-3.5 opacity-60"
/></span
>
<span class="inline-flex items-center gap-1 text-accent"
>{inSegmentText}
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Left-MD.svg"
alt=""
aria-hidden="true"
class="inline h-3.5 w-3.5 opacity-60"
/></span
>
</div>
<div class="flex h-[5px] overflow-hidden rounded-full bg-line">
<div
data-testid="dist-bar-segment"
class="h-full bg-primary transition-all"
style="width: {outPct}%"
></div>
<div
data-testid="dist-bar-segment"
class="h-full bg-accent transition-all"
style="width: {100 - outPct}%"
></div>
</div>
</div>

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import DistributionBar from './DistributionBar.svelte';
afterEach(() => {
cleanup();
});
describe('DistributionBar', () => {
it('renders the Paraglide aria-label and visible segments', async () => {
render(DistributionBar, {
outCount: 3,
inCount: 7,
senderName: 'Hans Müller',
receiverName: 'Anna Schmidt'
});
const container = document.querySelector('[role="img"]') as HTMLElement;
expect(container).toBeTruthy();
// The aria-label must come from Paraglide, not a hardcoded German string,
// so the EN / ES users aren't served "Briefverteilung in diesem Zeitraum".
const expectedAria = m.dist_bar_aria({
outCount: 3,
senderName: 'Hans Müller',
inCount: 7,
receiverName: 'Anna Schmidt'
});
expect(container.getAttribute('aria-label')).toBe(expectedAria);
// The visible "{count} from/von {name}" spans must also come from Paraglide.
const outText = m.dist_bar_segment({ count: 3, name: 'Hans' });
const inText = m.dist_bar_segment({ count: 7, name: 'Anna' });
expect(container.textContent).toContain(outText);
expect(container.textContent).toContain(inText);
// 3/10 → 30% / 70% split on the two segments
const segments = container.querySelectorAll('[data-testid="dist-bar-segment"]');
expect(segments).toHaveLength(2);
expect((segments[0] as HTMLElement).style.width).toBe('30%');
expect((segments[1] as HTMLElement).style.width).toBe('70%');
});
it('falls back to the full name when it has no space to split', async () => {
render(DistributionBar, {
outCount: 1,
inCount: 0,
senderName: 'SingleWord',
receiverName: 'Another'
});
const container = document.querySelector('[role="img"]') as HTMLElement;
const expected = m.dist_bar_segment({ count: 1, name: 'SingleWord' });
expect(container.textContent).toContain(expected);
});
it('renders a zero-percent left segment when outCount is zero', async () => {
render(DistributionBar, {
outCount: 0,
inCount: 4,
senderName: 'Hans',
receiverName: 'Anna'
});
const segments = document.querySelectorAll('[data-testid="dist-bar-segment"]');
expect((segments[0] as HTMLElement).style.width).toBe('0%');
expect((segments[1] as HTMLElement).style.width).toBe('100%');
});
});

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let { text, maxLines = 10 }: { text: string; maxLines?: number } = $props();
let expanded = $state(false);
let el = $state<HTMLElement | undefined>(undefined);
let isClamped = $state(false);
$effect(() => {
if (el && !expanded) {
isClamped = el.scrollHeight > el.clientHeight;
}
});
</script>
<div>
<div
bind:this={el}
style={!expanded ? `overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: ${maxLines}` : ''}
class="rounded border border-line bg-muted p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-ink"
>
{text}
</div>
{#if isClamped || expanded}
<button
onclick={() => (expanded = !expanded)}
class="mt-2 font-sans text-xs text-ink-3 transition hover:text-ink"
>
{expanded ? m.comp_expandable_show_less() : m.comp_expandable_show_more()}
</button>
{/if}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
let { label }: { label: string } = $props();
</script>
<div
data-testid="group-divider"
role="separator"
aria-label={label}
class="relative flex items-center py-2 text-center"
>
<div class="flex-grow border-t border-line"></div>
<span class="mx-4 font-sans text-sm font-bold tracking-widest text-ink/60 uppercase">{label}</span
>
<div class="flex-grow border-t border-line"></div>
</div>

View File

@@ -0,0 +1,23 @@
import { describe, expect, it, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import GroupDivider from './GroupDivider.svelte';
afterEach(() => cleanup());
describe('GroupDivider', () => {
it('renders the label text', async () => {
render(GroupDivider, { label: '1938' });
await expect.element(page.getByText('1938')).toBeInTheDocument();
});
it('has data-testid="group-divider" on the root element', async () => {
render(GroupDivider, { label: 'Test' });
await expect.element(page.getByTestId('group-divider')).toBeInTheDocument();
});
it('renders a person name label', async () => {
render(GroupDivider, { label: 'Anna Müller' });
await expect.element(page.getByText('Anna Müller')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { setLocale, getLocale } from '$lib/paraglide/runtime';
let { inverted = false }: { inverted?: boolean } = $props();
const locales = ['DE', 'EN', 'ES'] as const;
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
const activeLocale = $derived(getLocale().toUpperCase());
</script>
{#each locales as locale (locale)}
<button
type="button"
onclick={() => setLocale(localeMap[locale])}
class="rounded px-1 font-sans tracking-widest transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring
{activeLocale === locale
? inverted
? 'font-bold text-white'
: 'font-bold text-ink'
: inverted
? 'font-normal text-white/70 hover:text-white'
: 'font-normal text-ink-3 hover:text-ink'}"
>
{locale}
</button>
{/each}

View File

@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import LanguageSwitcher from './LanguageSwitcher.svelte';
const mockSetLocale = vi.hoisted(() => vi.fn());
vi.mock('$lib/paraglide/runtime', () => ({
getLocale: vi.fn(() => 'de'),
setLocale: mockSetLocale
}));
beforeEach(() => mockSetLocale.mockClear());
afterEach(cleanup);
// ─── inverted=true (dark background) ──────────────────────────────────────
describe('LanguageSwitcher inverted=true', () => {
it('active locale button has text-white and font-bold', async () => {
render(LanguageSwitcher, { inverted: true });
const el = await page.getByRole('button', { name: 'DE' }).element();
expect(el.className).toMatch(/\btext-white\b/);
expect(el.className).toMatch(/\bfont-bold\b/);
});
it('inactive locale buttons have text-white/70', async () => {
render(LanguageSwitcher, { inverted: true });
const el = await page.getByRole('button', { name: 'EN' }).element();
expect(el.className).toMatch(/text-white\/70/);
});
it('inactive locale buttons do not have font-bold', async () => {
render(LanguageSwitcher, { inverted: true });
const el = await page.getByRole('button', { name: 'EN' }).element();
expect(el.className).not.toMatch(/\bfont-bold\b/);
});
});
// ─── inverted=false (light background) ─────────────────────────────────────
describe('LanguageSwitcher inverted=false', () => {
it('active locale button has text-ink and font-bold', async () => {
render(LanguageSwitcher, { inverted: false });
const el = await page.getByRole('button', { name: 'DE' }).element();
expect(el.className).toMatch(/\btext-ink\b/);
expect(el.className).toMatch(/\bfont-bold\b/);
});
it('inactive locale buttons have text-ink-3', async () => {
render(LanguageSwitcher, { inverted: false });
const el = await page.getByRole('button', { name: 'EN' }).element();
expect(el.className).toMatch(/\btext-ink-3\b/);
});
it('inactive locale buttons do not have text-white', async () => {
render(LanguageSwitcher, { inverted: false });
const el = await page.getByRole('button', { name: 'EN' }).element();
expect(el.className).not.toMatch(/\btext-white\b/);
});
});
// ─── locale switching ──────────────────────────────────────────────────────
describe('LanguageSwitcher locale switching', () => {
it('calls setLocale with en when EN button is clicked', async () => {
render(LanguageSwitcher, { inverted: false });
const el = await page.getByRole('button', { name: 'EN' }).element();
el.click();
expect(mockSetLocale).toHaveBeenCalledWith('en');
});
it('calls setLocale with es when ES button is clicked', async () => {
render(LanguageSwitcher, { inverted: false });
const el = await page.getByRole('button', { name: 'ES' }).element();
el.click();
expect(mockSetLocale).toHaveBeenCalledWith('es');
});
});

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import { tick } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside';
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Props = {
extraCount: number;
persons: Person[];
};
let { extraCount, persons }: Props = $props();
let open = $state(false);
let buttonEl: HTMLButtonElement | undefined = $state();
function toggle() {
open = !open;
}
async function close() {
open = false;
await tick();
buttonEl?.focus();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.stopPropagation();
close();
}
}
</script>
<div
role="group"
class="relative hidden md:block"
use:clickOutside
onclickoutside={() => (open = false)}
onkeydown={handleKeydown}
>
<button
bind:this={buttonEl}
type="button"
aria-haspopup="true"
aria-expanded={open}
aria-label={m.topbar_overflow_show({ count: extraCount })}
onclick={toggle}
onkeydown={handleKeydown}
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted px-2 py-0.5 text-[14px] font-bold text-ink-2 hover:bg-surface focus-visible:ring-2 focus-visible:ring-primary"
>
+{extraCount}<span class="hidden lg:inline">&nbsp;{m.topbar_overflow_suffix()}</span>
</button>
{#if open}
<div
role="list"
class="absolute top-full left-0 z-50 mt-1 min-w-[160px] rounded-md border border-line bg-surface p-3 shadow-lg"
>
<p class="mb-2 text-[14px] font-bold tracking-wide text-ink-2 uppercase">
{m.topbar_overflow_heading()}
</p>
{#each persons as person (person.id)}
<div role="listitem">
<a
href="/persons/{person.id}"
class="block py-0.5 text-[18px] text-ink hover:text-primary focus-visible:ring-2 focus-visible:ring-primary"
>
{person.displayName}
</a>
</div>
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,47 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import OverflowPillButton from './OverflowPillButton.svelte';
afterEach(cleanup);
const persons = [
{ id: 'p1', firstName: 'Anna', lastName: 'Müller' },
{ id: 'p2', firstName: 'Hans', lastName: 'Schmidt' }
];
describe('OverflowPillButton', () => {
it('renders button with correct aria-haspopup and collapsed aria-expanded', async () => {
render(OverflowPillButton, { extraCount: 2, persons });
const btn = page.getByRole('button');
await expect.element(btn).toHaveAttribute('aria-haspopup', 'true');
await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
});
it('shows tooltip on click and sets aria-expanded true', async () => {
render(OverflowPillButton, { extraCount: 2, persons });
const btn = page.getByRole('button');
await userEvent.click(btn);
const tooltip = page.getByRole('list');
await expect.element(tooltip).toBeInTheDocument();
await expect.element(btn).toHaveAttribute('aria-expanded', 'true');
});
it('closes tooltip on Escape and returns focus to button', async () => {
render(OverflowPillButton, { extraCount: 2, persons });
const btn = page.getByRole('button');
await userEvent.click(btn);
await expect.element(page.getByRole('list')).toBeInTheDocument();
await userEvent.keyboard('{Escape}');
await expect.element(page.getByRole('list')).not.toBeInTheDocument();
await expect.element(btn).toHaveFocus();
});
it('renders person links inside tooltip', async () => {
render(OverflowPillButton, { extraCount: 2, persons });
await userEvent.click(page.getByRole('button'));
const links = page.getByRole('link');
await expect.element(links.nth(0)).toHaveAttribute('href', '/persons/p1');
await expect.element(links.nth(1)).toHaveAttribute('href', '/persons/p2');
});
});

View File

@@ -0,0 +1,14 @@
<script lang="ts">
type Props = {
extraCount: number;
};
let { extraCount }: Props = $props();
</script>
<span
aria-hidden="true"
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted px-2 py-0.5 text-[14px] font-bold text-ink-2"
>
+{extraCount}
</span>

View File

@@ -0,0 +1,169 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
interface Props {
/** 0-indexed current page. */
page: number;
/** Total number of pages. `0` or `1` hides the control as trivially there's nothing to navigate. */
totalPages: number;
/** Given a 0-indexed page number, returns the href the link should point at. */
makeHref: (page: number) => string;
/** Optional override for the outer `<nav>`'s aria-label. */
ariaLabel?: string;
}
const { page, totalPages, makeHref, ariaLabel }: Props = $props();
const hasPrev = $derived(page > 0);
const hasNext = $derived(page < totalPages - 1);
const controlBase =
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-1.5 rounded-sm border border-line bg-white px-4 py-2 font-sans text-sm font-bold text-ink';
const linkBase = `${controlBase} transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none`;
const disabledBase = `${controlBase} cursor-not-allowed opacity-40`;
const activePageBase =
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-sm border border-brand-navy bg-brand-navy px-4 py-2 font-sans text-sm font-bold text-white';
/**
* Builds the sliding window of 1-indexed page numbers to render as buttons.
* Always shows: first, last, current, one neighbor each side.
* null entries represent ellipsis gaps.
*/
const pageWindow = $derived.by(() => {
const first = 1;
const last = totalPages;
const current = page + 1; // convert to 1-indexed
const windowStart = Math.max(first, current - 1);
const windowEnd = Math.min(last, current + 1);
const result: (number | null)[] = [];
result.push(first);
if (windowStart > first + 2) {
result.push(null); // left ellipsis
} else if (windowStart === first + 2) {
result.push(first + 1); // bridge: one page gap, show directly instead of ellipsis
}
for (let p = Math.max(windowStart, first + 1); p <= Math.min(windowEnd, last - 1); p++) {
result.push(p);
}
if (windowEnd < last - 2) {
result.push(null); // right ellipsis
} else if (windowEnd === last - 2) {
result.push(last - 1); // bridge: one page gap, show directly instead of ellipsis
}
if (last > first) {
result.push(last);
}
return result;
});
</script>
{#if totalPages > 1}
<nav
aria-label={ariaLabel ?? m.pagination_nav_label()}
class="mt-6 flex flex-col items-center gap-3 sm:flex-row sm:justify-between"
>
<!--
At the bounds we render a <span aria-hidden="true"> instead of an
<a aria-disabled>. aria-disabled on a link is the documented pattern
but screen readers still announce "Previous, link, disabled" — which
is confusing on a pagination control where the disabled state is
purely visual. Hiding the element from the AT tree entirely is the
cleaner semantic.
-->
{#if hasPrev}
<a
data-testid="pagination-prev"
aria-label={m.pagination_prev()}
href={makeHref(page - 1)}
class={linkBase}
>
<span aria-hidden="true">«</span>
{m.pagination_prev()}
</a>
{:else}
<span data-testid="pagination-prev" aria-hidden="true" class={disabledBase}>
<span aria-hidden="true">«</span>
{m.pagination_prev()}
</span>
{/if}
<!-- Mobile: "Seite X von Y" label (hidden on sm: and above) -->
<!-- aria-hidden: decorative visual label; AT uses the sr-only span below for aria-current -->
<span
data-testid="pagination-page-label"
aria-hidden="true"
class="font-sans text-sm text-ink-2 sm:hidden"
>
{m.pagination_page_of({ page: page + 1, total: totalPages })}
</span>
<!-- Always in the AT tree: announces current page regardless of breakpoint.
On mobile, the desktop button container is display:none so this is the only AT anchor.
On desktop, the active page button also carries aria-current — both announce the same info. -->
<span data-testid="pagination-current-page-sr" aria-current="page" class="sr-only">
{m.pagination_page_of({ page: page + 1, total: totalPages })}
</span>
<!-- Desktop: numbered page buttons (hidden below sm:) -->
<div data-testid="pagination-pages" class="hidden items-center gap-1 sm:flex">
{#each pageWindow as entry, i (entry === null ? 'ellipsis-' + i : entry)}
{#if entry === null}
{#if i === 1}
<span
data-testid="pagination-ellipsis-left"
aria-hidden="true"
class="px-2 text-sm text-ink-2"></span
>
{:else}
<span
data-testid="pagination-ellipsis-right"
aria-hidden="true"
class="px-2 text-sm text-ink-2"></span
>
{/if}
{:else if entry === page + 1}
<span
data-testid="pagination-page-{entry}"
aria-current="page"
aria-label={m.pagination_page_button({ page: entry })}
class={activePageBase}
>
{entry}
</span>
{:else}
<a
data-testid="pagination-page-{entry}"
aria-label={m.pagination_page_button({ page: entry })}
href={makeHref(entry - 1)}
class={linkBase}
>
{entry}
</a>
{/if}
{/each}
</div>
{#if hasNext}
<a
data-testid="pagination-next"
aria-label={m.pagination_next()}
href={makeHref(page + 1)}
class={linkBase}
>
{m.pagination_next()}
<span aria-hidden="true">»</span>
</a>
{:else}
<span data-testid="pagination-next" aria-hidden="true" class={disabledBase}>
{m.pagination_next()}
<span aria-hidden="true">»</span>
</span>
{/if}
</nav>
{/if}

View File

@@ -0,0 +1,220 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Pagination from './Pagination.svelte';
afterEach(() => {
cleanup();
});
const makeHref = (p: number) => `/documents?page=${p}`;
describe('Pagination', () => {
it('renders the page-of-total label for the current page', async () => {
render(Pagination, { page: 2, totalPages: 10, makeHref });
const label = page.getByTestId('pagination-page-label');
await expect.element(label).toHaveTextContent(/3/); // page is 0-indexed, label is 1-indexed
await expect.element(label).toHaveTextContent(/10/);
});
it('mobile page label is aria-hidden (desktop buttons carry the aria-current role)', async () => {
render(Pagination, { page: 0, totalPages: 3, makeHref });
const label = page.getByTestId('pagination-page-label');
await expect.element(label).toHaveAttribute('aria-hidden', 'true');
});
describe('page number buttons', () => {
it('renders page number buttons when totalPages > 1', async () => {
render(Pagination, { page: 4, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
// active page button — the current page (5, 1-indexed)
const activeBtn = nav.getByTestId('pagination-page-5');
await expect.element(activeBtn).toBeInTheDocument();
});
it('does not render page number buttons when totalPages <= 1', async () => {
render(Pagination, { page: 0, totalPages: 1, makeHref });
// entire nav is hidden
const nav = page.getByRole('navigation');
await expect.element(nav).not.toBeInTheDocument();
});
it('marks the active page button with aria-current="page"', async () => {
render(Pagination, { page: 4, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const activeBtn = nav.getByTestId('pagination-page-5');
await expect.element(activeBtn).toHaveAttribute('aria-current', 'page');
});
it('active page button has brand-navy background', async () => {
render(Pagination, { page: 4, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const activeBtn = nav.getByTestId('pagination-page-5');
await expect.element(activeBtn).toHaveClass(/bg-brand-navy/);
});
it('active page button has 44px touch target', async () => {
render(Pagination, { page: 4, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const activeBtn = nav.getByTestId('pagination-page-5');
await expect.element(activeBtn).toHaveClass(/min-h-\[44px\]/);
await expect.element(activeBtn).toHaveClass(/min-w-\[44px\]/);
});
it('inactive page buttons link to their target page via makeHref', async () => {
const spy = vi.fn(makeHref);
render(Pagination, { page: 4, totalPages: 12, makeHref: spy });
const nav = page.getByRole('navigation');
// page button for page 1 (0-indexed: 0) should link to /documents?page=0
const firstPageBtn = nav.getByTestId('pagination-page-1');
await expect.element(firstPageBtn).toHaveAttribute('href', '/documents?page=0');
});
it('renders first and last page buttons always visible', async () => {
render(Pagination, { page: 5, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
await expect.element(nav.getByTestId('pagination-page-1')).toBeInTheDocument();
await expect.element(nav.getByTestId('pagination-page-12')).toBeInTheDocument();
});
it('renders ellipsis span between first page and window when gap exists', async () => {
// page 6 (0-indexed: 5) — window is 5,6,7 — gap between 1 and 5
render(Pagination, { page: 5, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const ellipses = nav.getByTestId('pagination-ellipsis-left');
await expect.element(ellipses).toBeInTheDocument();
});
it('renders ellipsis span between window and last page when gap exists', async () => {
// page 1 (0-indexed: 0) — window is 1,2 — gap between 2 and 12
render(Pagination, { page: 0, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const ellipsis = nav.getByTestId('pagination-ellipsis-right');
await expect.element(ellipsis).toBeInTheDocument();
});
it('does not render left ellipsis when window is adjacent to first page', async () => {
// page 1 (0-indexed: 0) — window starts at 1, adjacent to first page
render(Pagination, { page: 0, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const leftEllipsis = nav.getByTestId('pagination-ellipsis-left');
await expect.element(leftEllipsis).not.toBeInTheDocument();
});
it('does not render right ellipsis when window is adjacent to last page', async () => {
// last page (0-indexed: 11) — window ends at 12, adjacent to last page
render(Pagination, { page: 11, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const rightEllipsis = nav.getByTestId('pagination-ellipsis-right');
await expect.element(rightEllipsis).not.toBeInTheDocument();
});
it('page buttons container has hidden class on mobile (sm: prefix)', async () => {
// The page buttons container must be hidden below sm: breakpoint
render(Pagination, { page: 4, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const pageButtons = nav.getByTestId('pagination-pages');
await expect.element(pageButtons).toHaveClass(/hidden/);
await expect.element(pageButtons).toHaveClass(/sm:flex/);
});
it('renders both pages without ellipsis when totalPages is 2', async () => {
render(Pagination, { page: 0, totalPages: 2, makeHref });
const nav = page.getByRole('navigation');
await expect.element(nav.getByTestId('pagination-page-1')).toBeInTheDocument();
await expect.element(nav.getByTestId('pagination-page-2')).toBeInTheDocument();
await expect.element(nav.getByTestId('pagination-ellipsis-left')).not.toBeInTheDocument();
await expect.element(nav.getByTestId('pagination-ellipsis-right')).not.toBeInTheDocument();
});
});
it('mobile page label is aria-hidden so screen readers skip it on wide screens', async () => {
render(Pagination, { page: 2, totalPages: 10, makeHref });
const label = page.getByTestId('pagination-page-label');
await expect.element(label).toHaveAttribute('aria-hidden', 'true');
});
it('sr-only span always provides aria-current="page" for screen readers at all breakpoints', async () => {
render(Pagination, { page: 2, totalPages: 10, makeHref });
const nav = page.getByRole('navigation');
const srLabel = nav.getByTestId('pagination-current-page-sr');
await expect.element(srLabel).toBeInTheDocument();
await expect.element(srLabel).toHaveAttribute('aria-current', 'page');
});
it('renders prev as a link pointing at page - 1 when not on first page', async () => {
render(Pagination, { page: 4, totalPages: 10, makeHref });
const prev = page.getByTestId('pagination-prev');
await expect.element(prev).toHaveAttribute('href', '/documents?page=3');
});
it('renders disabled prev as an aria-hidden non-link so screen readers skip it', async () => {
render(Pagination, { page: 0, totalPages: 3, makeHref });
const prev = page.getByTestId('pagination-prev');
// Not a link — no href, no role=link
await expect.element(prev).not.toHaveAttribute('href');
// Hidden from assistive tech — AT shouldn't read "Previous, link, disabled"
await expect.element(prev).toHaveAttribute('aria-hidden', 'true');
});
it('renders next as a link pointing at page + 1 when not on last page', async () => {
render(Pagination, { page: 0, totalPages: 3, makeHref });
const next = page.getByTestId('pagination-next');
await expect.element(next).toHaveAttribute('href', '/documents?page=1');
});
it('renders disabled next as an aria-hidden non-link on the last page', async () => {
render(Pagination, { page: 2, totalPages: 3, makeHref });
const next = page.getByTestId('pagination-next');
await expect.element(next).not.toHaveAttribute('href');
await expect.element(next).toHaveAttribute('aria-hidden', 'true');
});
it('calls makeHref with p-1 and p+1', async () => {
const spy = vi.fn(makeHref);
render(Pagination, { page: 3, totalPages: 10, makeHref: spy });
const calls = spy.mock.calls.map((c) => c[0]).sort((a, b) => a - b);
expect(calls).toContain(2);
expect(calls).toContain(4);
});
it('renders decorative chevron inside aria-hidden span so screen readers skip it', async () => {
render(Pagination, { page: 1, totalPages: 3, makeHref });
const prev = page.getByTestId('pagination-prev');
await expect
.element(prev.getByText('«', { exact: true }))
.toHaveAttribute('aria-hidden', 'true');
});
it('prev and next have min 44px touch targets', async () => {
render(Pagination, { page: 1, totalPages: 3, makeHref });
const prev = page.getByTestId('pagination-prev');
await expect.element(prev).toHaveClass(/min-h-\[44px\]/);
await expect.element(prev).toHaveClass(/min-w-\[44px\]/);
});
});

View File

@@ -0,0 +1,26 @@
<script lang="ts">
let { percentage }: { percentage: number } = $props();
</script>
<div class="flex flex-col items-center">
<svg width="36" height="36" viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="7" fill="none" stroke="var(--c-line)" stroke-width="2" />
<circle
class="fill-arc"
cx="10"
cy="10"
r="7"
fill="none"
stroke="var(--c-accent)"
stroke-width="2"
stroke-linecap="round"
transform="rotate(-90 10 10)"
stroke-dasharray="{(percentage / 100) * 43.98} 43.98"
/>
</svg>
<span
class="block text-center font-sans text-xs font-bold {percentage > 0 ? 'text-primary' : 'text-gray-400'}"
>
{percentage}%
</span>
</div>

View File

@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ProgressRing from './ProgressRing.svelte';
afterEach(cleanup);
describe('ProgressRing', () => {
it('renders the correct stroke-dasharray for 75%', async () => {
render(ProgressRing, { percentage: 75 });
const arc = document.querySelector('circle.fill-arc') as SVGCircleElement | null;
expect(arc).not.toBeNull();
// circumference = 2 * π * 7 ≈ 43.98; 75% of that ≈ 32.99
const dasharray = arc!.getAttribute('stroke-dasharray') ?? '';
const filled = parseFloat(dasharray.split(' ')[0]);
expect(filled).toBeCloseTo(32.99, 1);
});
it('renders a gray label when percentage is 0', async () => {
render(ProgressRing, { percentage: 0 });
const label = page.getByText('0%');
await expect.element(label).toBeInTheDocument();
// Label should carry the gray class, not the mint class
const el = (await label.element()) as HTMLElement;
expect(el.className).toContain('text-gray-400');
});
it('renders a primary-colored label when percentage is > 0', async () => {
render(ProgressRing, { percentage: 75 });
const label = page.getByText('75%');
await expect.element(label).toBeInTheDocument();
const el = (await label.element()) as HTMLElement;
expect(el.className).toContain('text-primary');
});
it('renders a fully filled arc for 100%', async () => {
render(ProgressRing, { percentage: 100 });
const arc = document.querySelector('circle.fill-arc') as SVGCircleElement | null;
expect(arc).not.toBeNull();
const dasharray = arc!.getAttribute('stroke-dasharray') ?? '';
const filled = parseFloat(dasharray.split(' ')[0]);
expect(filled).toBeCloseTo(43.98, 1);
});
});

View File

@@ -0,0 +1,46 @@
<script lang="ts">
type Props = {
icon: string;
title: string;
body: string;
beispielInput?: string;
beispielInputStrike?: boolean;
beispielOutput?: string;
beispielLabel?: string;
};
let {
icon,
title,
body,
beispielInput,
beispielInputStrike = false,
beispielOutput,
beispielLabel = 'Beispiel'
}: Props = $props();
</script>
<div class="border-brand-sand break-inside-avoid rounded-sm border bg-white p-5 shadow-sm">
<div class="mb-3 flex items-center gap-2">
<span aria-hidden="true" class="text-xl">{icon}</span>
<h3 class="font-serif text-base font-bold text-ink">{title}</h3>
</div>
<p class="font-serif text-sm leading-relaxed text-ink-2">{body}</p>
{#if beispielOutput !== undefined}
<div class="border-brand-sand mt-4 rounded-sm border bg-parchment px-4 py-3">
<p class="font-sans text-xs font-semibold tracking-wider text-ink-3 uppercase">
{beispielLabel}
</p>
<p class="mt-1 font-sans text-sm text-ink">
{#if beispielInput !== undefined}
<code
class={['font-mono', beispielInputStrike && 'line-through'].filter(Boolean).join(' ')}
>{beispielInput}</code
>
{/if}
<code class="font-mono">{beispielOutput}</code>
</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,49 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import RichtlinienRuleCard from './RichtlinienRuleCard.svelte';
afterEach(cleanup);
const defaultProps = {
icon: '✍',
title: 'Unleserliche Wörter',
body: 'Schreiben Sie [unleserlich].',
beispielOutput: '[unleserlich]'
};
describe('RichtlinienRuleCard', () => {
it('renders an h3 with the title', async () => {
render(RichtlinienRuleCard, { props: defaultProps });
await expect
.element(page.getByRole('heading', { level: 3 }))
.toHaveTextContent('Unleserliche Wörter');
});
it('renders the body text', async () => {
render(RichtlinienRuleCard, { props: defaultProps });
await expect.element(page.getByText('Schreiben Sie [unleserlich].')).toBeInTheDocument();
});
it('renders icon in a span with aria-hidden="true"', async () => {
render(RichtlinienRuleCard, { props: defaultProps });
const iconSpan = document.querySelector('span[aria-hidden="true"]');
expect(iconSpan).not.toBeNull();
expect(iconSpan!.textContent).toContain('✍');
});
it('renders beispielOutput in monospace with → arrow', async () => {
render(RichtlinienRuleCard, { props: defaultProps });
const mono = document.querySelector('code, [class*="font-mono"]');
expect(mono).not.toBeNull();
expect(mono!.textContent).toContain('[unleserlich]');
await expect.element(page.getByText(/→/)).toBeInTheDocument();
});
it('does not render beispiel section when beispielOutput is absent', async () => {
render(RichtlinienRuleCard, {
props: { icon: '✍', title: 'Test', body: 'Body' }
});
expect(document.querySelector('code, [class*="font-mono"]')).toBeNull();
});
});

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
interface Props {
sort: string;
dir: string;
}
let { sort = $bindable(), dir = $bindable() }: Props = $props();
function toggleDir() {
dir = dir === 'asc' ? 'desc' : 'asc';
}
</script>
<div class="inline-flex items-stretch">
<label for="sort-field" class="sr-only">{m.docs_sort_label()}</label>
<div class="relative">
<select
id="sort-field"
bind:value={sort}
class="appearance-none border border-line bg-muted py-2.5 pr-9 pl-4 text-sm font-bold text-ink-2 transition hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<option value="DATE">{m.docs_sort_date()}</option>
<option value="TITLE">{m.docs_sort_title()}</option>
<option value="SENDER">{m.docs_sort_sender()}</option>
<option value="RECEIVER">{m.docs_sort_receiver()}</option>
<option value="UPLOAD_DATE">{m.docs_sort_upload()}</option>
</select>
<svg
class="pointer-events-none absolute top-1/2 right-2.5 h-4 w-4 -translate-y-1/2 text-ink-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</div>
<button
type="button"
onclick={toggleDir}
class="-ml-px flex items-center justify-center border border-line bg-muted px-3 py-2.5 text-sm font-bold text-ink-2 transition hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
aria-label={dir === 'asc' ? m.sort_dir_asc() : m.sort_dir_desc()}
>
{#if dir === 'asc'}
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Up-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 opacity-60"
/>
{:else}
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Down-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 opacity-60"
/>
{/if}
</button>
</div>

View File

@@ -0,0 +1,40 @@
import { render } from 'vitest-browser-svelte';
import { describe, expect, it } from 'vitest';
import { page } from '@vitest/browser/context';
import SortDropdown from './SortDropdown.svelte';
describe('SortDropdown', () => {
it('renders a select with all sort options', async () => {
render(SortDropdown, { sort: 'DATE', dir: 'desc' });
const select = page.getByRole('combobox');
await expect.element(select).toBeInTheDocument();
});
it('shows the current sort value as selected', async () => {
render(SortDropdown, { sort: 'TITLE', dir: 'asc' });
const select = page.getByRole('combobox');
await expect.element(select).toHaveValue('TITLE');
});
it('renders direction toggle button', async () => {
render(SortDropdown, { sort: 'DATE', dir: 'asc' });
const btn = page.getByRole('button');
await expect.element(btn).toBeInTheDocument();
});
it('direction button shows up arrow when dir is asc', async () => {
render(SortDropdown, { sort: 'DATE', dir: 'asc' });
await expect.element(page.getByRole('button')).toBeInTheDocument();
const img = document.querySelector('button img') as HTMLImageElement;
expect(img).not.toBeNull();
expect(img.src).toContain('Long-Arrow-Up');
});
it('direction button shows down arrow when dir is desc', async () => {
render(SortDropdown, { sort: 'DATE', dir: 'desc' });
await expect.element(page.getByRole('button')).toBeInTheDocument();
const img = document.querySelector('button img') as HTMLImageElement;
expect(img).not.toBeNull();
expect(img.src).toContain('Long-Arrow-Down');
});
});

View File

@@ -0,0 +1,74 @@
<script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
type Theme = 'light' | 'dark';
function systemPrefersDark(): boolean {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function resolveInitialTheme(): Theme {
const saved = localStorage.getItem('theme');
if (saved === 'light' || saved === 'dark') return saved;
return systemPrefersDark() ? 'dark' : 'light';
}
let theme = $state<Theme>('light');
onMount(() => {
theme = resolveInitialTheme();
});
const themeLabel = $derived(
theme === 'dark' ? m.theme_toggle_to_light() : m.theme_toggle_to_dark()
);
function toggle() {
theme = theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', theme);
document.documentElement.setAttribute('data-theme', theme);
}
</script>
<button
type="button"
onclick={toggle}
aria-label={themeLabel}
title={themeLabel}
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{#if theme === 'dark'}
<!-- Sun icon — click to go light -->
<svg
class="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path
stroke-linecap="round"
d="M12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"
/>
</svg>
{:else}
<!-- Moon icon — click to go dark -->
<svg
class="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"
/>
</svg>
{/if}
</button>

View File

@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ThemeToggle from './ThemeToggle.svelte';
afterEach(() => {
cleanup();
localStorage.removeItem('theme');
});
describe('ThemeToggle — label derivation (light mode)', () => {
beforeEach(() => {
localStorage.setItem('theme', 'light');
});
it('aria-label invites switching to dark mode when theme is light', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('aria-label')).toBe('Zu dunklem Design wechseln');
});
it('title equals aria-label in light mode', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
});
});
describe('ThemeToggle — label derivation (dark mode)', () => {
beforeEach(() => {
localStorage.setItem('theme', 'dark');
});
it('aria-label invites switching to light mode when theme is dark', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('aria-label')).toBe('Zu hellem Design wechseln');
});
it('title equals aria-label in dark mode', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
});
});

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
interface Props {
onDiscard: () => void;
}
let { onDiscard }: Props = $props();
</script>
<div
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
>
<span>{m.admin_unsaved_warning()}</span>
<button
type="button"
onclick={onDiscard}
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
>
{m.person_discard_changes()}
</button>
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { provideConfirmService, type ConfirmService } from './confirm.svelte.js';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import ConfirmDialog from '$lib/shared/primitives/ConfirmDialog.svelte';
let { onReady }: { onReady: (service: ConfirmService) => void } = $props();