refactor: move shared components to lib/shared/ sub-packages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
27
frontend/src/lib/shared/primitives/BackButton.svelte
Normal file
27
frontend/src/lib/shared/primitives/BackButton.svelte
Normal 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>
|
||||
43
frontend/src/lib/shared/primitives/BackButton.svelte.spec.ts
Normal file
43
frontend/src/lib/shared/primitives/BackButton.svelte.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
61
frontend/src/lib/shared/primitives/ConfirmDialog.svelte
Normal file
61
frontend/src/lib/shared/primitives/ConfirmDialog.svelte
Normal 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>
|
||||
130
frontend/src/lib/shared/primitives/ConfirmDialog.svelte.spec.ts
Normal file
130
frontend/src/lib/shared/primitives/ConfirmDialog.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
49
frontend/src/lib/shared/primitives/ContributorStack.svelte
Normal file
49
frontend/src/lib/shared/primitives/ContributorStack.svelte
Normal 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}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
79
frontend/src/lib/shared/primitives/DateInput.svelte
Normal file
79
frontend/src/lib/shared/primitives/DateInput.svelte
Normal 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}
|
||||
210
frontend/src/lib/shared/primitives/DateInput.svelte.spec.ts
Normal file
210
frontend/src/lib/shared/primitives/DateInput.svelte.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
60
frontend/src/lib/shared/primitives/DistributionBar.svelte
Normal file
60
frontend/src/lib/shared/primitives/DistributionBar.svelte
Normal 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>
|
||||
@@ -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%');
|
||||
});
|
||||
});
|
||||
33
frontend/src/lib/shared/primitives/ExpandableText.svelte
Normal file
33
frontend/src/lib/shared/primitives/ExpandableText.svelte
Normal 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>
|
||||
15
frontend/src/lib/shared/primitives/GroupDivider.svelte
Normal file
15
frontend/src/lib/shared/primitives/GroupDivider.svelte
Normal 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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
26
frontend/src/lib/shared/primitives/LanguageSwitcher.svelte
Normal file
26
frontend/src/lib/shared/primitives/LanguageSwitcher.svelte
Normal 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}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
76
frontend/src/lib/shared/primitives/OverflowPillButton.svelte
Normal file
76
frontend/src/lib/shared/primitives/OverflowPillButton.svelte
Normal 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"> {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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
169
frontend/src/lib/shared/primitives/Pagination.svelte
Normal file
169
frontend/src/lib/shared/primitives/Pagination.svelte
Normal 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}
|
||||
220
frontend/src/lib/shared/primitives/Pagination.svelte.spec.ts
Normal file
220
frontend/src/lib/shared/primitives/Pagination.svelte.spec.ts
Normal 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\]/);
|
||||
});
|
||||
});
|
||||
26
frontend/src/lib/shared/primitives/ProgressRing.svelte
Normal file
26
frontend/src/lib/shared/primitives/ProgressRing.svelte
Normal 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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
66
frontend/src/lib/shared/primitives/SortDropdown.svelte
Normal file
66
frontend/src/lib/shared/primitives/SortDropdown.svelte
Normal 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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
74
frontend/src/lib/shared/primitives/ThemeToggle.svelte
Normal file
74
frontend/src/lib/shared/primitives/ThemeToggle.svelte
Normal 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>
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user