diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte b/frontend/src/lib/components/PersonMentionEditor.svelte new file mode 100644 index 00000000..45029bf9 --- /dev/null +++ b/frontend/src/lib/components/PersonMentionEditor.svelte @@ -0,0 +1,206 @@ + + +
+ + + {#if popupOpen} +
+ {#if results.length === 0} +

{m.person_mention_popup_empty()}

+ {:else} + {#each results as person, i (person.id)} +
{ + e.preventDefault(); + selectPerson(person); + }} + > + {person.displayName} + {#if formatLifeDateRange(person.birthYear, person.deathYear)} + + {formatLifeDateRange(person.birthYear, person.deathYear)} + + {/if} +
+ {/each} + {/if} +
+ {/if} +
diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts new file mode 100644 index 00000000..25362537 --- /dev/null +++ b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts @@ -0,0 +1,348 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte'; +import type { components } from '$lib/generated/api'; + +type Person = components['schemas']['Person']; +type PersonMention = components['schemas']['PersonMention']; + +const waitForDebounce = () => new Promise((r) => setTimeout(r, 250)); +const tick = () => new Promise((r) => setTimeout(r, 0)); + +const AUGUSTE: Person = { + id: 'p-aug', + firstName: 'Auguste', + lastName: 'Raddatz', + displayName: 'Auguste Raddatz', + birthYear: 1882, + deathYear: 1944 +} as unknown as Person; + +const ANNA: Person = { + id: 'p-anna', + firstName: 'Anna', + lastName: 'Schmidt', + displayName: 'Anna Schmidt', + birthYear: 1860 +} as unknown as Person; + +function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +function mockFetchEmpty() { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +function getTextarea(): HTMLTextAreaElement { + return document.querySelector('textarea')!; +} + +function clickOption(personId: string) { + const opt = document.querySelector( + `[role="option"][data-test-person-id="${personId}"]` + ) as HTMLElement; + opt.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); +} + +type Snapshot = { value: string; mentionedPersons: PersonMention[] }; + +function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[] } = {}) { + let snapshot: Snapshot = { + value: initial.value ?? '', + mentionedPersons: initial.mentionedPersons ?? [] + }; + render(PersonMentionEditorHost, { + initialValue: initial.value ?? '', + initialMentions: initial.mentionedPersons ?? [], + onChange: (snap: Snapshot) => { + snapshot = snap; + } + }); + return { + get snapshot() { + return snapshot; + } + }; +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +// ─── Rendering ──────────────────────────────────────────────────────────────── + +describe('PersonMentionEditor — rendering', () => { + it('renders the textarea with placeholder', async () => { + render(PersonMentionEditorHost, { + initialValue: '', + initialMentions: [], + placeholder: 'Transkription…', + onChange: () => {} + }); + await expect.element(page.getByPlaceholder('Transkription…')).toBeInTheDocument(); + }); + + it('reflects bound initial value', async () => { + render(PersonMentionEditorHost, { + initialValue: 'Hallo Welt', + initialMentions: [], + onChange: () => {} + }); + await expect.element(page.getByRole('textbox')).toHaveValue('Hallo Welt'); + }); +}); + +// ─── Typeahead opens on @ ───────────────────────────────────────────────────── + +describe('PersonMentionEditor — typeahead', () => { + it('opens the popup when typing @ + query and shows results', async () => { + mockFetchWithPersons(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + + await waitForDebounce(); + + await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); + }); + + it('hits /api/persons?q= with the typed query', async () => { + const fetchMock = mockFetchWithPersons(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + + await waitForDebounce(); + + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug')); + }); + + it('shows life dates next to the name in the dropdown', async () => { + mockFetchWithPersons(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument(); + }); + + it('shows empty state when no persons match', async () => { + mockFetchEmpty(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@xyz'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument(); + }); + + it('keeps the popup open when the query has a trailing space (multi-word names)', async () => { + mockFetchWithPersons(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Auguste '; + ta.selectionStart = 9; + ta.selectionEnd = 9; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); + }); +}); + +// ─── Selection writes text + sidecar ───────────────────────────────────────── + +describe('PersonMentionEditor — selecting a person', () => { + it('inserts @DisplayName followed by a trailing space into the textarea', async () => { + mockFetchWithPersons(); + const host = renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + clickOption('p-aug'); + await tick(); + + expect(host.snapshot.value).toBe('@Auguste Raddatz '); + }); + + it('pushes {personId, displayName} into the bound mentionedPersons array', async () => { + mockFetchWithPersons(); + const host = renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + clickOption('p-aug'); + await tick(); + + expect(host.snapshot.mentionedPersons).toEqual([ + { personId: 'p-aug', displayName: 'Auguste Raddatz' } + ]); + }); + + it('does not duplicate the sidecar entry when the same person is selected twice', async () => { + mockFetchWithPersons(); + const host = renderHost({ + value: '@Auguste Raddatz ', + mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }] + }); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Auguste Raddatz @Aug'; + ta.selectionStart = ta.value.length; + ta.selectionEnd = ta.value.length; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + clickOption('p-aug'); + await tick(); + + expect(host.snapshot.mentionedPersons).toHaveLength(1); + }); +}); + +// ─── Keyboard navigation (B11b) ────────────────────────────────────────────── + +describe('PersonMentionEditor — keyboard navigation (B11b)', () => { + it('ArrowDown / ArrowUp cycle the highlighted result', async () => { + mockFetchWithPersons(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@A'; + ta.selectionStart = 2; + ta.selectionEnd = 2; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + const optAuguste = document.querySelector( + '[role="option"][data-test-person-id="p-aug"]' + ) as HTMLElement; + const optAnna = document.querySelector( + '[role="option"][data-test-person-id="p-anna"]' + ) as HTMLElement; + + expect(optAuguste.getAttribute('aria-selected')).toBe('true'); + expect(optAnna.getAttribute('aria-selected')).toBe('false'); + + ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await tick(); + + expect(optAuguste.getAttribute('aria-selected')).toBe('false'); + expect(optAnna.getAttribute('aria-selected')).toBe('true'); + + ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + await tick(); + + expect(optAuguste.getAttribute('aria-selected')).toBe('true'); + expect(optAnna.getAttribute('aria-selected')).toBe('false'); + }); + + it('Enter selects the currently highlighted result', async () => { + mockFetchWithPersons(); + const host = renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@A'; + ta.selectionStart = 2; + ta.selectionEnd = 2; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await tick(); + ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await tick(); + + expect(host.snapshot.mentionedPersons).toEqual([ + { personId: 'p-anna', displayName: 'Anna Schmidt' } + ]); + }); + + it('Escape closes the popup without inserting anything', async () => { + mockFetchWithPersons(); + const host = renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await tick(); + + await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument(); + expect(host.snapshot.value).toBe('@Aug'); + expect(host.snapshot.mentionedPersons).toEqual([]); + }); +}); + +// ─── Touch target (WCAG 2.2 AA) ────────────────────────────────────────────── + +describe('PersonMentionEditor — touch target', () => { + it('each result row has min-h-[44px] (WCAG 2.2 AA)', async () => { + mockFetchWithPersons(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + const option = document.querySelector('[role="option"]') as HTMLElement; + expect(option).not.toBeNull(); + expect(option.className).toContain('min-h-[44px]'); + }); +}); diff --git a/frontend/src/lib/components/PersonMentionEditor.test-host.svelte b/frontend/src/lib/components/PersonMentionEditor.test-host.svelte new file mode 100644 index 00000000..e608d694 --- /dev/null +++ b/frontend/src/lib/components/PersonMentionEditor.test-host.svelte @@ -0,0 +1,31 @@ + + +