feat(transcription): add PersonMentionEditor with typeahead + keyboard nav

Mirrors MentionEditor for users but searches /api/persons?q=, allows
multi-word queries (delegated to detectPersonMention), displays life
dates next to each result, and uses min-h-[44px] rows for WCAG 2.2 AA
touch targets. Selection writes both the @DisplayName text and a
{personId, displayName} sidecar entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-29 00:22:30 +02:00
parent bf8fb00dd2
commit c4ee2c666b
3 changed files with 585 additions and 0 deletions

View File

@@ -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]');
});
});