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,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();
});
});