feat(topbar): responsive DocumentTopBar — issue #173 #174

Merged
marcel merged 32 commits from feat/issue-173-document-topbar into main 2026-04-02 16:13:49 +02:00
22 changed files with 859 additions and 177 deletions

View File

@@ -223,6 +223,12 @@
"admin_label_initial_password": "Passwort",
"doc_file_error_preview": "Vorschau konnte nicht geladen werden.",
"doc_download_title": "Herunterladen",
"topbar_back_label": "Zurück zur Dokumentenliste",
"topbar_more_actions": "Weitere Aktionen",
"topbar_overflow_more": "+{count} weitere",
"topbar_overflow_suffix": "weitere",
"topbar_overflow_heading": "Weitere Empfänger",
"topbar_overflow_show": "{count} weitere Empfänger anzeigen",
"doc_tag_filter_title": "Nach {name} filtern",
"doc_conversation_title": "Konversation anzeigen",
"doc_preview_iframe_title": "Dokumentvorschau",
@@ -321,6 +327,7 @@
"doc_panel_tab_history": "Verlauf",
"doc_panel_annotate": "Annotieren",
"doc_panel_annotate_stop": "Fertig",
"doc_panel_annotate_hint": "Klicken und ziehen Sie, um einen Bereich zu markieren",
"doc_panel_annotation_thread_title": "Annotation",
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
"pdf_annotations_show": "Annotierungen anzeigen",

View File

@@ -223,6 +223,12 @@
"admin_label_initial_password": "Password",
"doc_file_error_preview": "Could not load preview.",
"doc_download_title": "Download",
"topbar_back_label": "Back to document list",
"topbar_more_actions": "More actions",
"topbar_overflow_more": "+{count} more",
"topbar_overflow_suffix": "more",
"topbar_overflow_heading": "More receivers",
"topbar_overflow_show": "Show {count} more receivers",
"doc_tag_filter_title": "Filter by {name}",
"doc_conversation_title": "Show conversation",
"doc_preview_iframe_title": "Document Preview",
@@ -321,6 +327,7 @@
"doc_panel_tab_history": "History",
"doc_panel_annotate": "Annotate",
"doc_panel_annotate_stop": "Done",
"doc_panel_annotate_hint": "Click and drag to mark an area",
"doc_panel_annotation_thread_title": "Annotation",
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
"pdf_annotations_show": "Show annotations",

View File

@@ -223,6 +223,12 @@
"admin_label_initial_password": "Contraseña",
"doc_file_error_preview": "No se pudo cargar la vista previa.",
"doc_download_title": "Descargar",
"topbar_back_label": "Volver a la lista de documentos",
"topbar_more_actions": "Más acciones",
"topbar_overflow_more": "+{count} más",
"topbar_overflow_suffix": "más",
"topbar_overflow_heading": "Más destinatarios",
"topbar_overflow_show": "Mostrar {count} destinatarios más",
"doc_tag_filter_title": "Filtrar por {name}",
"doc_conversation_title": "Ver conversación",
"doc_preview_iframe_title": "Vista previa del documento",
@@ -321,6 +327,7 @@
"doc_panel_tab_history": "Historial",
"doc_panel_annotate": "Anotar",
"doc_panel_annotate_stop": "Listo",
"doc_panel_annotate_hint": "Haga clic y arrastre para marcar un área",
"doc_panel_annotation_thread_title": "Anotación",
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
"pdf_annotations_show": "Mostrar anotaciones",

View File

@@ -0,0 +1,64 @@
import { describe, it, expect, afterEach } from 'vitest';
const { clickOutside } = await import('./clickOutside');
describe('clickOutside action', () => {
const nodes: HTMLElement[] = [];
function makeNode(): HTMLElement {
const node = document.createElement('div');
document.body.appendChild(node);
nodes.push(node);
return node;
}
afterEach(() => {
nodes.forEach((n) => n.remove());
nodes.length = 0;
});
it('registers a capture-phase click listener on mount', () => {
const node = makeNode();
const original = document.addEventListener.bind(document);
let registered = false;
document.addEventListener = (type: string, _fn: unknown, opts: unknown) => {
if (type === 'click' && opts === true) registered = true;
original(type as string, _fn as EventListener, opts as boolean);
};
clickOutside(node);
expect(registered).toBe(true);
document.addEventListener = original;
});
it('dispatches clickoutside when clicking outside the node', () => {
const node = makeNode();
const outside = makeNode();
let fired = false;
node.addEventListener('clickoutside', () => (fired = true));
clickOutside(node);
outside.click();
expect(fired).toBe(true);
});
it('does not dispatch clickoutside when clicking inside the node', () => {
const node = makeNode();
const child = document.createElement('span');
node.appendChild(child);
let fired = false;
node.addEventListener('clickoutside', () => (fired = true));
clickOutside(node);
child.click();
expect(fired).toBe(false);
});
it('removes the listener on destroy', () => {
const node = makeNode();
const outside = makeNode();
let count = 0;
node.addEventListener('clickoutside', () => count++);
const { destroy } = clickOutside(node);
destroy();
outside.click();
expect(count).toBe(0);
});
});

View File

@@ -0,0 +1,15 @@
export function clickOutside(node: HTMLElement): { destroy: () => void } {
function handleClick(event: MouseEvent) {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
node.dispatchEvent(new CustomEvent('clickoutside'));
}
}
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
}

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
type Props = {
annotateMode: boolean;
};
let { annotateMode }: Props = $props();
</script>
{#if annotateMode}
<div
data-testid="annotate-hint-strip"
class="hidden h-[29px] items-center gap-2 border-t border-dashed px-3.5 md:flex"
style="background: rgba(1,40,81,0.05); border-color: rgba(1,40,81,0.20)"
>
<span class="text-[16px] font-bold tracking-wide text-primary uppercase"
>{m.doc_panel_annotate()}</span
>
<span class="text-[16px] text-ink-2">{m.doc_panel_annotate_hint()}</span>
</div>
{/if}

View File

@@ -0,0 +1,27 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AnnotateHintStrip from './AnnotateHintStrip.svelte';
afterEach(cleanup);
describe('AnnotateHintStrip', () => {
it('is absent from the DOM when annotateMode is false', async () => {
render(AnnotateHintStrip, { annotateMode: false });
const strip = page.getByTestId('annotate-hint-strip');
await expect.element(strip).not.toBeInTheDocument();
});
it('is present in the DOM when annotateMode is true', async () => {
render(AnnotateHintStrip, { annotateMode: true });
const strip = page.getByTestId('annotate-hint-strip');
await expect.element(strip).toBeInTheDocument();
});
it('has hidden md:flex class to hide below 768px', async () => {
render(AnnotateHintStrip, { annotateMode: true });
const strip = page.getByTestId('annotate-hint-strip');
await expect.element(strip).toHaveClass('hidden');
await expect.element(strip).toHaveClass('md:flex');
});
});

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { statusDotClass, statusLabel } from '$lib/utils/personFormat';
type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED';
type Props = {
status: DocumentStatus;
};
let { status }: Props = $props();
const dotClass = $derived(statusDotClass(status));
const label = $derived(statusLabel(status));
</script>
<span
class="hidden shrink-0 md:block {dotClass} h-4 w-4 rounded-full"
title={label}
aria-label={label}
></span>

View File

@@ -1,5 +1,10 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/personFormat';
import { clickOutside } from '$lib/actions/clickOutside';
import PersonChipRow from './PersonChipRow.svelte';
import AnnotateHintStrip from './AnnotateHintStrip.svelte';
import OverflowPillButton from './OverflowPillButton.svelte';
type Person = { id: string; firstName: string; lastName: string };
@@ -25,128 +30,207 @@ type Props = {
let { doc, canWrite, canAnnotate, fileUrl, annotateMode = $bindable() }: Props = $props();
const isPdf = $derived(!!doc.filePath && doc.contentType?.startsWith('application/pdf'));
const receivers = $derived(doc.receivers ?? []);
const extraCount = $derived(Math.max(0, receivers.length - 2));
const overflowPersons = $derived(receivers.slice(2));
const receiverDisplay = $derived.by(() => {
const receivers = doc.receivers ?? [];
if (receivers.length === 0) return null;
const shown = receivers.slice(0, 2);
const extra = receivers.length - shown.length;
const names = shown.map((r) => `${r.firstName} ${r.lastName}`).join(', ');
return extra > 0 ? `${names} +${extra}` : names;
});
const shortDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'short') : null);
const longDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'long') : null);
const compactMeta = $derived.by(() => {
const parts: string[] = [];
if (doc.documentDate) {
parts.push(
new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'numeric',
year: 'numeric'
}).format(new Date(doc.documentDate + 'T12:00:00'))
);
}
if (doc.sender) {
const senderName = `${doc.sender.firstName} ${doc.sender.lastName}`;
const receiver = receiverDisplay;
parts.push(receiver ? `${senderName}${receiver}` : senderName);
} else if (receiverDisplay) {
parts.push(`→ ${receiverDisplay}`);
}
return parts.join(' · ');
});
let mobileMenuOpen = $state(false);
</script>
<div
class="z-20 flex shrink-0 items-center justify-between border-b border-line bg-surface px-3 py-3 shadow-sm sm:px-6"
data-topbar
>
<!-- Left: back + title -->
<div class="flex min-w-0 items-center gap-4 overflow-hidden">
{#snippet annotateBtn(mobile: boolean)}
<button
onclick={() => {
annotateMode = true;
if (mobile) mobileMenuOpen = false;
}}
aria-label={m.doc_panel_annotate()}
aria-pressed={false}
class={mobile
? 'flex w-full items-center gap-2 rounded px-3 py-2 text-left text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary'
: 'hidden items-center gap-1.5 rounded border border-primary px-3 py-1.5 font-sans text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary md:flex'}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 shrink-0"
/>
{m.doc_panel_annotate()}
</button>
{/snippet}
{#snippet annotateStopBtn(mobile: boolean)}
<button
onclick={() => {
annotateMode = false;
if (mobile) mobileMenuOpen = false;
}}
aria-label={m.doc_panel_annotate_stop()}
aria-pressed={true}
class={mobile
? 'flex w-full items-center gap-2 rounded bg-primary px-3 py-2 text-left text-[16px] text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary'
: 'flex items-center gap-1.5 rounded bg-primary px-3 py-1.5 font-sans text-[16px] font-medium text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary'}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 shrink-0 invert"
/>
{m.doc_panel_annotate_stop()}
</button>
{/snippet}
{#snippet downloadLink(mobile: boolean)}
<a
href={fileUrl}
download={doc.originalFilename}
onclick={() => {
if (mobile) mobileMenuOpen = false;
}}
class={mobile
? 'flex items-center gap-2 rounded px-3 py-2 text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary'
: 'hidden rounded border border-transparent bg-muted p-1.5 text-ink transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary md:block'}
title={m.doc_download_title()}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 shrink-0"
/>
{#if mobile}{m.doc_download_title()}{/if}
</a>
{/snippet}
<div data-topbar class="relative z-10 border-b border-line bg-surface shadow-sm">
<!-- Main row -->
<div class="flex h-[75px] shrink-0 items-center xs:h-[88px]">
<!-- Accent bar -->
<div class="h-full w-[3px] shrink-0 bg-primary"></div>
<!-- Back link — 44×44px touch target -->
<a
href="/"
class="group flex shrink-0 items-center gap-2 font-sans text-sm font-medium text-ink-2 transition-colors hover:text-ink"
aria-label={m.topbar_back_label()}
class="group -ml-0.5 flex h-11 w-11 shrink-0 items-center justify-center rounded-full transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary"
>
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-canvas transition-colors group-hover:bg-accent"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
</div>
<span class="hidden sm:inline">{m.btn_back()}</span>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</a>
<div class="min-w-0 border-l border-line pl-4">
<!-- Divider -->
<div class="mx-2 h-6 w-px shrink-0 bg-line"></div>
<!-- Title + meta -->
<div class="min-w-0 flex-1 overflow-hidden">
<h1
class="truncate font-serif text-base leading-tight text-ink"
class="truncate font-serif text-[18px] leading-tight text-ink lg:text-[20px]"
title={doc.title ?? doc.originalFilename ?? ''}
>
{doc.title || doc.originalFilename}
</h1>
{#if compactMeta}
<p class="truncate font-sans text-xs text-ink-2" title={compactMeta}>
{compactMeta}
{#if shortDate}
<p class="font-sans text-[16px] text-ink-2">
<span class="lg:hidden">{shortDate}</span>
<span class="hidden lg:inline">{longDate}</span>
</p>
{/if}
</div>
<!-- Chip row — desktop only, hidden on small screens to make room for buttons -->
<div class="mx-3 hidden min-w-0 shrink-0 md:block">
<PersonChipRow sender={doc.sender} receivers={receivers} abbreviated={true} extraCount={0} />
</div>
<!-- Overflow pill button (desktop) + status dot -->
{#if extraCount > 0}
<OverflowPillButton extraCount={extraCount} persons={overflowPersons} />
{/if}
<!-- Divider between metadata and actions -->
<div class="mx-3 hidden h-6 w-px shrink-0 bg-line md:block"></div>
<!-- Action buttons -->
<div class="flex shrink-0 items-center gap-1.5 pr-3 font-sans">
{#if canAnnotate && isPdf && !annotateMode}
{@render annotateBtn(false)}
{/if}
{#if canAnnotate && isPdf && annotateMode}
{@render annotateStopBtn(false)}
{/if}
{#if canWrite && !annotateMode}
<a
href="/documents/{doc.id}/edit"
aria-label={m.btn_edit()}
class="flex items-center gap-1.5 rounded border border-primary bg-transparent px-3 py-1.5 text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
<span class="hidden sm:inline">{m.btn_edit()}</span>
</a>
{/if}
{#if doc.filePath && !annotateMode}
{@render downloadLink(false)}
{/if}
<!-- Kebab menu — mobile only, contains actions hidden below md -->
{#if (canAnnotate && isPdf) || doc.filePath}
<div
role="group"
class="relative md:hidden"
use:clickOutside
onclickoutside={() => (mobileMenuOpen = false)}
>
<button
type="button"
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
aria-label={m.topbar_more_actions()}
aria-haspopup="true"
aria-expanded={mobileMenuOpen}
class="flex h-9 w-9 items-center justify-center rounded border border-line bg-muted transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/View-More-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</button>
{#if mobileMenuOpen}
<div
role="menu"
class="absolute top-full right-0 z-50 mt-1 min-w-[200px] rounded-md border border-line bg-surface p-2 shadow-lg"
>
{#if canAnnotate && isPdf && !annotateMode}
{@render annotateBtn(true)}
{/if}
{#if doc.filePath}
{@render downloadLink(true)}
{/if}
</div>
{/if}
</div>
{/if}
</div>
</div>
<!-- Right: actions -->
<div class="ml-4 flex shrink-0 items-center gap-2 font-sans">
{#if canAnnotate && isPdf}
<button
onclick={() => (annotateMode = !annotateMode)}
aria-label={annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
class="flex items-center gap-1.5 rounded px-3 py-1.5 font-sans text-xs font-medium transition {annotateMode
? 'bg-primary text-primary-fg'
: 'border border-primary text-ink hover:bg-primary hover:text-primary-fg'}"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 {annotateMode ? 'invert' : ''}"
/>
<span class="hidden sm:inline"
>{annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}</span
>
</button>
{/if}
{#if canWrite}
<a
href="/documents/{doc.id}/edit"
aria-label={m.btn_edit()}
class="flex items-center gap-2 rounded border border-primary bg-transparent px-3 py-1.5 text-xs font-medium text-ink transition hover:bg-primary hover:text-primary-fg"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
<span class="hidden sm:inline">{m.btn_edit()}</span>
</a>
{/if}
{#if doc.filePath}
<a
href={fileUrl}
download={doc.originalFilename}
class="rounded border border-transparent bg-muted p-1.5 text-ink transition hover:bg-accent"
title={m.doc_download_title()}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</a>
{/if}
</div>
<!-- Hint strip — only when annotateMode, only at ≥768px -->
<AnnotateHintStrip annotateMode={annotateMode} />
</div>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import { abbreviateName, personAvatarColor } from '$lib/utils/personFormat';
type Person = { id: string; firstName: string; lastName: string };
type Props = {
person: Person;
abbreviated: boolean;
};
let { person, abbreviated }: Props = $props();
const displayName = $derived(
abbreviated ? abbreviateName(person) : `${person.firstName} ${person.lastName}`
);
const avatarColor = $derived(personAvatarColor(person.id));
const initials = $derived(
`${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase()
);
</script>
<a
href="/persons/{person.id}"
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-muted px-2 py-0.5 hover:border-primary hover:bg-surface focus-visible:ring-2 focus-visible:ring-primary"
>
<span
class="flex h-[25px] w-[25px] shrink-0 items-center justify-center rounded-full text-[13px] font-bold text-white"
style="background-color: {avatarColor}"
aria-hidden="true"
>
{initials}
</span>
<span class="text-[14px] font-semibold text-ink">{displayName}</span>
</a>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import PersonChip from './PersonChip.svelte';
import OverflowPillDisplay from './OverflowPillDisplay.svelte';
type Person = { id: string; firstName: string; lastName: string };
type Props = {
sender: Person | null | undefined;
receivers: Person[];
abbreviated: boolean;
extraCount: number;
};
let { sender, receivers, abbreviated, extraCount }: Props = $props();
const visibleReceivers = $derived(receivers.slice(0, 2));
</script>
<div class="hidden min-w-0 items-center gap-1.5 overflow-hidden xs:flex">
{#if sender}
<PersonChip person={sender} abbreviated={abbreviated} />
{/if}
{#if sender && receivers.length > 0}
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg"
alt=""
aria-hidden="true"
class="h-6 w-6 shrink-0 opacity-40"
/>
{/if}
{#each visibleReceivers as receiver, i (receiver.id)}
<span class={i === 1 ? 'hidden md:contents' : ''}>
<PersonChip person={receiver} abbreviated={abbreviated} />
</span>
{/each}
{#if extraCount > 0}
<OverflowPillDisplay extraCount={extraCount} />
{/if}
</div>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
type Person = components['schemas']['Person'];
interface Props {
@@ -56,20 +57,6 @@ function selectPerson(person: Person) {
function removePerson(id: string | undefined) {
selectedPersons = selectedPersons.filter((p) => p.id !== id);
}
function clickOutside(node: HTMLElement) {
const handleClick = (e: MouseEvent) => {
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
showDropdown = false;
}
};
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
}
</script>
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
@@ -78,7 +65,7 @@ function clickOutside(node: HTMLElement) {
<input type="hidden" name="receiverIds" value={person.id} />
{/each}
<div class="relative" use:clickOutside>
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
<div
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
>

View File

@@ -2,6 +2,7 @@
import { untrack } from 'svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
type Person = components['schemas']['Person'];
interface Props {
@@ -118,23 +119,9 @@ function selectPerson(person: Person) {
showDropdown = false;
onchange?.(person.id!);
}
function clickOutside(node: HTMLElement) {
const handleClick = (event: MouseEvent) => {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
showDropdown = false;
}
};
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
}
</script>
<div class="relative" use:clickOutside>
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
<label
for={name}
class={compact

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
interface Props {
tags?: string[];
@@ -66,23 +67,9 @@ function handleKeydown(e: KeyboardEvent) {
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
}
}
function clickOutside(node: HTMLElement) {
const handleClick = (e: MouseEvent) => {
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
showSuggestions = false;
}
};
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
}
</script>
<div class="w-full" use:clickOutside>
<div class="w-full" use:clickOutside onclickoutside={() => (showSuggestions = false)}>
<!-- Tag Container -->
<div
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"

View File

@@ -0,0 +1,171 @@
import { describe, it, expect } from 'vitest';
import {
abbreviateName,
formatXsMeta,
personAvatarColor,
formatDate,
statusDotClass,
statusLabel
} from './personFormat';
// ─── abbreviateName ──────────────────────────────────────────────────────────
describe('abbreviateName', () => {
it('abbreviates first name to initial + last name', () => {
expect(abbreviateName({ firstName: 'Karl', lastName: 'Raddatz' })).toBe('K. Raddatz');
});
it('returns single name as-is when no last name', () => {
expect(abbreviateName({ firstName: 'Elfriede', lastName: '' })).toBe('Elfriede');
});
it('preserves hyphenated last name', () => {
expect(abbreviateName({ firstName: 'Karl', lastName: 'Müller-Schmidt' })).toBe(
'K. Müller-Schmidt'
);
});
it('handles leading/trailing whitespace in names', () => {
expect(abbreviateName({ firstName: ' Karl ', lastName: ' Raddatz ' })).toBe('K. Raddatz');
});
});
// ─── formatXsMeta ────────────────────────────────────────────────────────────
type Doc = {
sender?: { firstName: string; lastName: string } | null;
receivers?: { firstName: string; lastName: string }[];
documentDate?: string | null;
};
describe('formatXsMeta', () => {
const sender = { firstName: 'Karl', lastName: 'Raddatz' };
const receiver1 = { firstName: 'Elfriede', lastName: 'Raddatz' };
const receiver2 = { firstName: 'Anna', lastName: 'Müller' };
const receiver3 = { firstName: 'Hans', lastName: 'Schmidt' };
it('formats sender with no receivers and date', () => {
const doc: Doc = { sender, receivers: [], documentDate: '1943-12-24' };
expect(formatXsMeta(doc)).toBe('K.Raddatz · 24.12.1943');
});
it('formats sender with one receiver and date', () => {
const doc: Doc = { sender, receivers: [receiver1], documentDate: '1943-12-24' };
expect(formatXsMeta(doc)).toBe('K.Raddatz → E.Raddatz · 24.12.1943');
});
it('formats sender with three receivers showing +2', () => {
const doc: Doc = {
sender,
receivers: [receiver1, receiver2, receiver3],
documentDate: '1943-12-24'
};
expect(formatXsMeta(doc)).toBe('K.Raddatz → E.Raddatz +2 · 24.12.1943');
});
it('formats without sender', () => {
const doc: Doc = { sender: null, receivers: [receiver1], documentDate: '1943-12-24' };
expect(formatXsMeta(doc)).toBe('E.Raddatz · 24.12.1943');
});
it('formats without date', () => {
const doc: Doc = { sender, receivers: [], documentDate: null };
expect(formatXsMeta(doc)).toBe('K.Raddatz');
});
it('formats with no sender and no date', () => {
const doc: Doc = { sender: null, receivers: [receiver1], documentDate: null };
expect(formatXsMeta(doc)).toBe('E.Raddatz');
});
});
// ─── personAvatarColor ───────────────────────────────────────────────────────
const PALETTE = ['#012851', '#5A3080', '#007596', '#2A6040', '#803020'];
describe('personAvatarColor', () => {
it('returns a value from the palette', () => {
expect(PALETTE).toContain(personAvatarColor('abc'));
});
it('is deterministic — same id always returns same color', () => {
const id = '550e8400-e29b-41d4-a716-446655440000';
expect(personAvatarColor(id)).toBe(personAvatarColor(id));
});
it('all 5 palette entries are reachable across 1000 random UUIDs', () => {
const seen = new Set<string>();
for (let i = 0; i < 1000; i++) {
seen.add(personAvatarColor(crypto.randomUUID()));
}
expect(seen.size).toBe(5);
});
});
// ─── formatDate ──────────────────────────────────────────────────────────────
describe('formatDate', () => {
it('formats short date as dd.mm.yyyy', () => {
expect(formatDate('1943-12-24', 'short')).toBe('24.12.1943');
});
it('formats long date with German month name', () => {
expect(formatDate('1943-12-24', 'long')).toBe('24. Dezember 1943');
});
it('does not shift Dec 31 to Jan 1 (UTC off-by-one guard)', () => {
expect(formatDate('1943-12-31', 'short')).toBe('31.12.1943');
});
it('does not shift Jan 1 to Dec 31 (UTC off-by-one guard)', () => {
expect(formatDate('1944-01-01', 'short')).toBe('01.01.1944');
});
});
// ─── statusDotClass ──────────────────────────────────────────────────────────
describe('statusDotClass', () => {
it('PLACEHOLDER → bg-gray-400', () => {
expect(statusDotClass('PLACEHOLDER')).toBe('bg-gray-400');
});
it('UPLOADED → bg-emerald-500', () => {
expect(statusDotClass('UPLOADED')).toBe('bg-emerald-500');
});
it('TRANSCRIBED → bg-blue-400', () => {
expect(statusDotClass('TRANSCRIBED')).toBe('bg-blue-400');
});
it('REVIEWED → bg-amber-400', () => {
expect(statusDotClass('REVIEWED')).toBe('bg-amber-400');
});
it('ARCHIVED → bg-emerald-600', () => {
expect(statusDotClass('ARCHIVED')).toBe('bg-emerald-600');
});
});
// ─── statusLabel ─────────────────────────────────────────────────────────────
describe('statusLabel', () => {
it('PLACEHOLDER → "Platzhalter"', () => {
expect(statusLabel('PLACEHOLDER')).toBe('Platzhalter');
});
it('UPLOADED → "Hochgeladen"', () => {
expect(statusLabel('UPLOADED')).toBe('Hochgeladen');
});
it('TRANSCRIBED → "Transkribiert"', () => {
expect(statusLabel('TRANSCRIBED')).toBe('Transkribiert');
});
it('REVIEWED → "Geprüft"', () => {
expect(statusLabel('REVIEWED')).toBe('Geprüft');
});
it('ARCHIVED → "Archiviert"', () => {
expect(statusLabel('ARCHIVED')).toBe('Archiviert');
});
});

View File

@@ -0,0 +1,102 @@
import { formatDocumentStatus } from './documentStatusLabel';
type Person = { firstName: string; lastName: string };
type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED';
type DocForMeta = {
sender?: Person | null;
receivers?: Person[];
documentDate?: string | null;
};
const AVATAR_PALETTE = ['#012851', '#5A3080', '#007596', '#2A6040', '#803020'] as const;
function djb2(str: string): number {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = (hash * 33) ^ str.charCodeAt(i);
}
return Math.abs(hash);
}
export function abbreviateName(person: Person): string {
const first = person.firstName.trim();
const last = person.lastName.trim();
if (!last) return first;
return `${first.charAt(0)}. ${last}`;
}
function abbreviateCompact(person: Person): string {
const first = person.firstName.trim();
const last = person.lastName.trim();
if (!last) return first;
return `${first.charAt(0)}.${last}`;
}
export function formatXsMeta(doc: DocForMeta): string {
const parts: string[] = [];
const receivers = doc.receivers ?? [];
if (doc.sender) {
const senderAbbr = abbreviateCompact(doc.sender);
if (receivers.length === 0) {
parts.push(senderAbbr);
} else {
const extra = receivers.length - 1;
const firstReceiver = abbreviateCompact(receivers[0]);
parts.push(
extra > 0
? `${senderAbbr}${firstReceiver} +${extra}`
: `${senderAbbr}${firstReceiver}`
);
}
} else if (receivers.length > 0) {
const extra = receivers.length - 1;
const firstReceiver = abbreviateCompact(receivers[0]);
parts.push(extra > 0 ? `${firstReceiver} +${extra}` : firstReceiver);
}
if (doc.documentDate) {
parts.push(formatDate(doc.documentDate, 'short'));
}
return parts.join(' · ');
}
export function personAvatarColor(personId: string): string {
return AVATAR_PALETTE[djb2(personId) % AVATAR_PALETTE.length];
}
export function formatDate(isoDate: string, format: 'short' | 'long'): string {
const date = new Date(isoDate + 'T12:00:00');
if (format === 'short') {
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
}).format(date);
}
return new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(date);
}
export function statusDotClass(status: DocumentStatus): string {
switch (status) {
case 'PLACEHOLDER':
return 'bg-gray-400';
case 'UPLOADED':
return 'bg-emerald-500';
case 'TRANSCRIBED':
return 'bg-blue-400';
case 'REVIEWED':
return 'bg-amber-400';
case 'ARCHIVED':
return 'bg-emerald-600';
}
}
export function statusLabel(status: string): string {
return formatDocumentStatus(status);
}

View File

@@ -1,27 +1,17 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
let { userInitials }: { userInitials: string | null } = $props();
let userMenuOpen = $state(false);
function clickOutside(node: HTMLElement) {
const handleClick = (event: MouseEvent) => {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
userMenuOpen = false;
}
};
document.addEventListener('click', handleClick, true);
return () => {
document.removeEventListener('click', handleClick, true);
};
}
</script>
<div
class="relative"
{@attach clickOutside}
use:clickOutside
onclickoutside={() => (userMenuOpen = false)}
onkeydown={(e) => {
if (e.key === 'Escape') userMenuOpen = false;
}}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
interface Correspondent {
id: string;
@@ -17,20 +18,6 @@ interface Props {
let { correspondents, loading, senderName, onselect, onclose }: Props = $props();
function clickOutside(node: HTMLElement) {
const handleClick = (event: MouseEvent) => {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
onclose();
}
};
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
}
function getOptionElements(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>('[role="option"]'));
}
@@ -60,6 +47,7 @@ function getInitials(person: Correspondent): string {
<div
use:clickOutside
onclickoutside={onclose}
role="listbox"
tabindex="-1"
aria-label={m.conv_suggestions_heading()}

View File

@@ -5,6 +5,9 @@
/* ─── 2. Raw palette — never used directly in components ──────────────────── */
@theme {
/* Breakpoints */
--breakpoint-xs: 375px;
/* Brand palette constants */
--palette-navy: #012851;
--palette-mint: #a1dcd8;