diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 3024029b..53fd4114 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -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", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 109e7060..3ad3cfdb 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index c9b445c6..8f8110c2 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -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", diff --git a/frontend/src/lib/actions/clickOutside.svelte.spec.ts b/frontend/src/lib/actions/clickOutside.svelte.spec.ts new file mode 100644 index 00000000..81917cb0 --- /dev/null +++ b/frontend/src/lib/actions/clickOutside.svelte.spec.ts @@ -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); + }); +}); diff --git a/frontend/src/lib/actions/clickOutside.ts b/frontend/src/lib/actions/clickOutside.ts new file mode 100644 index 00000000..ef7fc3a2 --- /dev/null +++ b/frontend/src/lib/actions/clickOutside.ts @@ -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); + } + }; +} diff --git a/frontend/src/lib/components/AnnotateHintStrip.svelte b/frontend/src/lib/components/AnnotateHintStrip.svelte new file mode 100644 index 00000000..e3ea7ab6 --- /dev/null +++ b/frontend/src/lib/components/AnnotateHintStrip.svelte @@ -0,0 +1,22 @@ + + +{#if annotateMode} + +{/if} diff --git a/frontend/src/lib/components/AnnotateHintStrip.svelte.spec.ts b/frontend/src/lib/components/AnnotateHintStrip.svelte.spec.ts new file mode 100644 index 00000000..80f72c13 --- /dev/null +++ b/frontend/src/lib/components/AnnotateHintStrip.svelte.spec.ts @@ -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'); + }); +}); diff --git a/frontend/src/lib/components/DocumentStatusChip.svelte b/frontend/src/lib/components/DocumentStatusChip.svelte new file mode 100644 index 00000000..05857ebc --- /dev/null +++ b/frontend/src/lib/components/DocumentStatusChip.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/DocumentTopBar.svelte b/frontend/src/lib/components/DocumentTopBar.svelte index c0c7da65..9c1bd419 100644 --- a/frontend/src/lib/components/DocumentTopBar.svelte +++ b/frontend/src/lib/components/DocumentTopBar.svelte @@ -1,5 +1,10 @@ -
- -
+{#snippet annotateBtn(mobile: boolean)} + +{/snippet} + +{#snippet annotateStopBtn(mobile: boolean)} + +{/snippet} + +{#snippet downloadLink(mobile: boolean)} + { + 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()} + > + + {#if mobile}{m.doc_download_title()}{/if} + +{/snippet} + +
+ +
+ +
+ + -
- -
- +
-
+ +
+ + +

{doc.title || doc.originalFilename}

- {#if compactMeta} -

- {compactMeta} + {#if shortDate} +

+ {shortDate} +

{/if}
+ + + + + + {#if extraCount > 0} + + {/if} + + + + + +
+ {#if canAnnotate && isPdf && !annotateMode} + {@render annotateBtn(false)} + {/if} + + {#if canAnnotate && isPdf && annotateMode} + {@render annotateStopBtn(false)} + {/if} + + {#if canWrite && !annotateMode} + + + + + {/if} + + {#if doc.filePath && !annotateMode} + {@render downloadLink(false)} + {/if} + + + {#if (canAnnotate && isPdf) || doc.filePath} +
(mobileMenuOpen = false)} + > + + + {#if mobileMenuOpen} + + {/if} +
+ {/if} +
- -
- {#if canAnnotate && isPdf} - - {/if} - - {#if canWrite} - - - - - {/if} - - {#if doc.filePath} - - - - {/if} -
+ +
diff --git a/frontend/src/lib/components/OverflowPillButton.svelte b/frontend/src/lib/components/OverflowPillButton.svelte new file mode 100644 index 00000000..5541edc7 --- /dev/null +++ b/frontend/src/lib/components/OverflowPillButton.svelte @@ -0,0 +1,77 @@ + + + diff --git a/frontend/src/lib/components/OverflowPillButton.svelte.spec.ts b/frontend/src/lib/components/OverflowPillButton.svelte.spec.ts new file mode 100644 index 00000000..cc2268a2 --- /dev/null +++ b/frontend/src/lib/components/OverflowPillButton.svelte.spec.ts @@ -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'); + }); +}); diff --git a/frontend/src/lib/components/OverflowPillDisplay.svelte b/frontend/src/lib/components/OverflowPillDisplay.svelte new file mode 100644 index 00000000..a5c68868 --- /dev/null +++ b/frontend/src/lib/components/OverflowPillDisplay.svelte @@ -0,0 +1,14 @@ + + + diff --git a/frontend/src/lib/components/PersonChip.svelte b/frontend/src/lib/components/PersonChip.svelte new file mode 100644 index 00000000..d17e7365 --- /dev/null +++ b/frontend/src/lib/components/PersonChip.svelte @@ -0,0 +1,34 @@ + + + + + {displayName} + diff --git a/frontend/src/lib/components/PersonChipRow.svelte b/frontend/src/lib/components/PersonChipRow.svelte new file mode 100644 index 00000000..e50ed3e3 --- /dev/null +++ b/frontend/src/lib/components/PersonChipRow.svelte @@ -0,0 +1,42 @@ + + + diff --git a/frontend/src/lib/components/PersonMultiSelect.svelte b/frontend/src/lib/components/PersonMultiSelect.svelte index 5fc1171f..536ef97b 100644 --- a/frontend/src/lib/components/PersonMultiSelect.svelte +++ b/frontend/src/lib/components/PersonMultiSelect.svelte @@ -1,6 +1,7 @@ @@ -78,7 +65,7 @@ function clickOutside(node: HTMLElement) { {/each} -
+
(showDropdown = false)}>
diff --git a/frontend/src/lib/components/PersonTypeahead.svelte b/frontend/src/lib/components/PersonTypeahead.svelte index c17c1e81..8e9cf930 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte +++ b/frontend/src/lib/components/PersonTypeahead.svelte @@ -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); - } - }; -} -
+
(showDropdown = false)}>