feat(PersonMentionEditor): rewrite as Tiptap editor with AC-1 typed-text displayName

Replaces the textarea-based editor with a Tiptap v3 contenteditable.
The custom Mention node uses personId/displayName attrs (instead of
Tiptap's default id/label) so mentionSerializer round-trips cleanly.

AC-1 fix (issue #372): when the user types '@Aug' and selects
'Auguste Raddatz', the mention node stores displayName: 'Aug' (the
typed query) — not the person's DB display name. This preserves
archival fidelity of the original transcription.

The MentionDropdown is mounted imperatively on document.body via
Svelte 5's mount(). Its three pieces of dynamic state (items,
command, clientRect) are passed as a single $state proxy (model)
because Svelte 5's mount() does not return prop accessors.

Spec is fully rewritten — all old tests used document.querySelector
('textarea') which is dead after the migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-29 15:53:21 +02:00
parent 39ddf90725
commit d87ad36278
2 changed files with 344 additions and 450 deletions

View File

@@ -1,263 +1,242 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, tick } from 'svelte'; import { onMount, onDestroy, mount, unmount } from 'svelte';
import { detectPersonMention } from '$lib/utils/personMention'; import { Editor } from '@tiptap/core';
import { formatLifeDateRange } from '$lib/utils/personLifeDates'; import StarterKit from '@tiptap/starter-kit';
import { Mention } from '@tiptap/extension-mention';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
import type { PersonMention } from '$lib/types';
import { deserialize, serialize } from '$lib/utils/mentionSerializer';
import MentionDropdown from './MentionDropdown.svelte';
type Person = components['schemas']['Person']; type Person = components['schemas']['Person'];
type PersonMention = components['schemas']['PersonMention'];
type Props = { type Props = {
value: string; value: string;
mentionedPersons: PersonMention[]; mentionedPersons: PersonMention[];
placeholder?: string; placeholder?: string;
rows?: number;
disabled?: boolean; disabled?: boolean;
onfocus?: () => void; onfocus?: () => void;
onblur?: () => void; onblur?: () => void;
// Optional escape hatch: lets the parent observe the underlying textarea node onSelectionChange?: (text: string | null) => void;
// (e.g. to read selection bounds for quote-selection features). Returning a
// cleanup function from the parent is not required.
captureTextarea?: (node: HTMLTextAreaElement) => void | (() => void);
}; };
let { let {
value = $bindable(''), value = $bindable(''),
mentionedPersons = $bindable([]), mentionedPersons = $bindable([]),
placeholder = '', placeholder = '',
rows = 1,
disabled = false, disabled = false,
onfocus, onfocus,
onblur, onblur,
captureTextarea onSelectionChange
}: Props = $props(); }: Props = $props();
let query: string | null = $state(null); let editorEl: HTMLDivElement;
let results: Person[] = $state([]); let editor: Editor | null = null;
let highlightedIndex = $state(0);
let mentionStart = $state(0);
let loading = $state(false);
let textarea: HTMLTextAreaElement | null = null; // Single reactive state object shared with MentionDropdown. Mutating these
let debounceTimer: ReturnType<typeof setTimeout> | undefined; // fields propagates to the mounted dropdown via Svelte's $state proxy —
// this is required because Svelte 5's `mount()` does NOT return prop
function attachTextarea(node: HTMLTextAreaElement) { // accessors; setting `instance.items = ...` does not update the component.
textarea = node; let dropdownState = $state<{
resizeTextarea(); items: Person[];
const parentCleanup = captureTextarea?.(node); command: (item: Person) => void;
return () => { clientRect: (() => DOMRect | null) | null;
parentCleanup?.(); }>({
textarea = null; items: [],
}; command: () => {},
} clientRect: null
function resizeTextarea() {
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
}
// Autoresize on every value change — read `value` so this $effect
// re-runs whenever the bound prop is reassigned.
$effect(() => {
void value;
resizeTextarea();
}); });
function handleInput() { type DropdownExports = {
if (!textarea) return; onKeyDown: (event: KeyboardEvent) => boolean;
const cursorPos = textarea.selectionStart; };
const detected = detectPersonMention(value, cursorPos);
if (detected === null) { onMount(() => {
closePopup(); // Custom Mention node: uses personId / displayName instead of the
return; // default id / label attribute names so the mentionSerializer can
} // round-trip correctly without attribute remapping.
const CustomMention = Mention.extend({
addAttributes() {
return {
personId: {
default: null,
parseHTML: (el) => el.getAttribute('data-person-id'),
renderHTML: (attrs) => ({ 'data-person-id': attrs.personId })
},
displayName: {
default: null,
parseHTML: (el) => el.getAttribute('data-display-name'),
renderHTML: (attrs) => ({ 'data-display-name': attrs.displayName })
},
mentionSuggestionChar: {
default: '@',
parseHTML: (el) => el.getAttribute('data-mention-suggestion-char'),
renderHTML: (attrs) => ({
'data-mention-suggestion-char': attrs.mentionSuggestionChar
})
}
};
}
});
const before = value.slice(0, cursorPos); editor = new Editor({
mentionStart = before.lastIndexOf('@'); element: editorEl,
extensions: [
StarterKit.configure({
heading: false,
bold: false,
italic: false,
strike: false,
code: false,
blockquote: false,
codeBlock: false,
bulletList: false,
orderedList: false,
hardBreak: false,
horizontalRule: false
}),
CustomMention.configure({
renderHTML({ node }) {
return [
'span',
{
'data-type': 'mention',
'data-person-id': node.attrs.personId,
'data-display-name': node.attrs.displayName,
class:
'mention-token underline decoration-brand-mint underline-offset-2 text-brand-navy font-medium'
},
`@${node.attrs.displayName}`
];
},
renderText({ node }) {
return `@${node.attrs.displayName}`;
},
suggestion: {
char: '@',
items: async ({ query }: { query: string }) => {
if (!query) return [];
try {
const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`);
if (!res.ok) return [];
return ((await res.json()) as Person[]).slice(0, 5);
} catch {
return [];
}
},
// AC-1 fix: insert the typed query as displayName, not person.displayName.
command({ editor: ed, range, props }) {
const p = props as unknown as { personId: string; displayName: string };
const nodeAfter = ed.view.state.selection.$to.nodeAfter;
if (nodeAfter?.text?.startsWith(' ')) range.to += 1;
ed.chain()
.focus()
.insertContentAt(range, [
{
type: 'mention',
attrs: { personId: p.personId, displayName: p.displayName }
},
{ type: 'text', text: ' ' }
])
.run();
},
render() {
let component: object | null = null;
let exports: DropdownExports | null = null;
if (query !== detected) { // Tiptap's SuggestionProps types `command` against the default
query = detected; // MentionNodeAttrs (id/label). Our custom Mention extension uses
highlightedIndex = 0; // personId/displayName, so we cast the renderProps locally.
scheduleSearch(detected); type LooseRenderProps = {
} items: unknown;
} command: (props: { personId: string; displayName: string }) => void;
query: string;
clientRect?: (() => DOMRect | null) | null;
};
function scheduleSearch(q: string) { const updateState = (renderProps: LooseRenderProps) => {
clearTimeout(debounceTimer); dropdownState.items = renderProps.items as Person[];
if (!q.trim()) { // AC-1: pass typed query as displayName, not person.displayName
// Empty query: keep popup open with last results so the user can browse, dropdownState.command = (item: Person) =>
// but don't fire a backend call until they actually type something. renderProps.command({
results = []; personId: item.id,
loading = false; displayName: renderProps.query
return; });
} dropdownState.clientRect = renderProps.clientRect ?? null;
loading = true; };
debounceTimer = setTimeout(async () => {
try { return {
// SECURITY: relies on the SvelteKit Vite proxy injecting the auth_token onStart(renderProps) {
// cookie as the Authorization header (vite.config.ts) and on the updateState(renderProps as unknown as LooseRenderProps);
// browser's same-origin policy for the /api/* path. Mounted in const mounted = mount(MentionDropdown, {
// transcribe mode behind WRITE_ALL — never reachable to unauthenticated target: document.body,
// users. props: { model: dropdownState }
const res = await fetch(`/api/persons?q=${encodeURIComponent(q)}`); });
if (res.ok) { component = mounted as object;
const data: Person[] = await res.json(); exports = mounted as unknown as DropdownExports;
results = data.slice(0, 5); },
} else { onUpdate(renderProps) {
results = []; updateState(renderProps as unknown as LooseRenderProps);
},
onKeyDown({ event }) {
// Escape is handled by the suggestion plugin itself.
if (event.key === 'Escape') return false;
return exports?.onKeyDown(event) ?? false;
},
onExit() {
if (component) {
unmount(component);
component = null;
exports = null;
}
}
};
}
}
})
],
content: deserialize(value, mentionedPersons),
editorProps: {
attributes: {
role: 'textbox',
'aria-multiline': 'true',
'aria-label': m.transcription_editor_aria_label(),
...(placeholder ? { 'data-placeholder': placeholder } : {}),
class: [
'min-h-[120px] px-1 py-2.5',
'font-serif text-base leading-relaxed text-ink',
'focus:outline-none',
'tiptap-editor-inner'
].join(' ')
} }
} catch { },
results = []; onUpdate({ editor: ed }) {
} finally { const { text, mentionedPersons: mp } = serialize(ed.getJSON());
loading = false; value = text;
mentionedPersons = mp;
},
onFocus() {
onfocus?.();
},
onBlur() {
onblur?.();
},
onSelectionUpdate({ editor: ed }) {
const { from, to } = ed.state.selection;
onSelectionChange?.(from !== to ? ed.state.doc.textBetween(from, to) : null);
} }
}, 200); });
} });
async function selectPerson(person: Person) { onDestroy(() => {
if (!textarea) return; editor?.destroy();
});
const displayName = person.displayName ?? '';
const replacement = `@${displayName} `;
const cursorPos = textarea.selectionStart;
const before = value.slice(0, mentionStart);
const after = value.slice(cursorPos);
value = before + replacement + after;
if (!mentionedPersons.some((existing) => existing.personId === person.id)) {
mentionedPersons = [...mentionedPersons, { personId: person.id!, displayName }];
}
closePopup();
await tick();
if (!textarea) return;
const pos = mentionStart + replacement.length;
textarea.selectionStart = pos;
textarea.selectionEnd = pos;
textarea.focus();
}
function closePopup() {
query = null;
results = [];
highlightedIndex = 0;
loading = false;
clearTimeout(debounceTimer);
}
function handleBlur() {
// Small delay so an option's onmousedown can fire and select before the
// popup unmounts. Without this, clicking a result on the way out would
// race with blur and lose the selection.
setTimeout(() => closePopup(), 150);
onblur?.();
}
function handleKeydown(e: KeyboardEvent) {
if (query === null) return;
if (e.key === 'Escape') {
e.preventDefault();
closePopup();
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (results.length > 0) {
highlightedIndex = (highlightedIndex + 1) % results.length;
}
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (results.length > 0) {
highlightedIndex = (highlightedIndex - 1 + results.length) % results.length;
}
return;
}
if (e.key === 'Enter' && results.length > 0) {
e.preventDefault();
selectPerson(results[highlightedIndex]);
return;
}
}
onDestroy(() => clearTimeout(debounceTimer));
const popupOpen = $derived(query !== null);
</script> </script>
<div class="relative"> <div
<textarea class="relative rounded-sm border border-transparent focus-within:border-brand-mint focus-within:ring-2 focus-within:ring-brand-mint/40"
{@attach attachTextarea} class:opacity-50={disabled}
class="block min-h-[44px] w-full resize-none rounded-sm border border-transparent bg-transparent px-1 py-2.5 font-serif text-base leading-relaxed text-ink placeholder:text-ink-3 focus-visible:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-mint/40 focus-visible:outline-none" class:pointer-events-none={disabled}
rows={rows} bind:this={editorEl}
placeholder={placeholder} ></div>
disabled={disabled}
bind:value={value}
oninput={handleInput}
onkeydown={handleKeydown}
onfocus={onfocus}
onblur={handleBlur}
></textarea>
{#if popupOpen}
<div
class="absolute z-20 mt-1 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
role="listbox"
aria-label={m.person_mention_btn_label()}
>
{#if loading}
<p class="px-3 py-2 font-sans text-sm text-ink-3">{m.comp_typeahead_loading()}</p>
{:else if results.length === 0}
<div class="flex flex-col gap-2 px-3 py-2.5">
<p class="font-sans text-sm text-ink-3">{m.person_mention_popup_empty()}</p>
<a
href="/persons/new?name={encodeURIComponent(query ?? '')}"
target="_blank"
rel="noopener"
class="font-sans text-sm font-medium text-brand-navy underline-offset-2 hover:underline"
>
{m.person_mention_create_new()}
</a>
</div>
{:else}
{#each results as person, i (person.id)}
<div
class={[
'flex min-h-[44px] cursor-pointer flex-col gap-1 px-3 py-2.5 text-left hover:bg-canvas',
// Keyboard-highlighted row gets a stronger token than hover so
// keyboard users (and tablet stylus users sweeping over rows)
// can tell the cursor position apart from a hover (Leonie #5507 §3,
// WCAG 1.4.11 Non-Text Contrast).
i === highlightedIndex &&
'bg-brand-mint/20 ring-2 ring-brand-mint ring-inset'
]}
role="option"
aria-selected={i === highlightedIndex}
data-test-person-id={person.id}
tabindex="-1"
onmousedown={(e) => {
e.preventDefault();
selectPerson(person);
}}
>
<span class="truncate font-serif text-base text-ink">{person.displayName}</span>
{#if formatLifeDateRange(person.birthYear, person.deathYear)}
<span class="truncate font-sans text-xs text-ink-3">
{formatLifeDateRange(person.birthYear, person.deathYear)}
</span>
{/if}
</div>
{/each}
{/if}
</div>
{/if}
</div>

View File

@@ -1,26 +1,19 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; /**
* PersonMentionEditor — Tiptap-based component tests.
*
* All old tests used document.querySelector('textarea') which is dead after
* the Tiptap migration. These tests drive the contenteditable via
* userEvent.type() and inspect the serialized output from the test host.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page, userEvent } from 'vitest/browser';
import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte'; import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person']; type Person = components['schemas']['Person'];
type PersonMention = components['schemas']['PersonMention']; type PersonMention = components['schemas']['PersonMention'];
// Editor's internal search debounce is 200ms — drive it via fake timers
// so tests are deterministic and fast (Tester #5506 §1).
const DEBOUNCE_MS = 200;
async function flushDebounce() {
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
// Let the awaited fetch resolve and the resulting state assignments flush.
await vi.runAllTimersAsync();
}
async function tick() {
await vi.advanceTimersByTimeAsync(0);
}
const AUGUSTE: Person = { const AUGUSTE: Person = {
id: 'p-aug', id: 'p-aug',
firstName: 'Auguste', firstName: 'Auguste',
@@ -39,34 +32,17 @@ const ANNA: Person = {
} as unknown as Person; } as unknown as Person;
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) { function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
const fetchMock = vi vi.stubGlobal(
.fn() 'fetch',
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) }); vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) })
vi.stubGlobal('fetch', fetchMock); );
return fetchMock;
} }
function mockFetchEmpty() { function mockFetchEmpty() {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }); vi.stubGlobal(
vi.stubGlobal('fetch', fetchMock); 'fetch',
return fetchMock; vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
} );
function mockFetchRejects() {
const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
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[] }; type Snapshot = { value: string; mentionedPersons: PersonMention[] };
@@ -90,275 +66,216 @@ function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[
}; };
} }
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
vi.useRealTimers();
}); });
// ─── Rendering ──────────────────────────────────────────────────────────────── // ─── Rendering ────────────────────────────────────────────────────────────────
describe('PersonMentionEditor — rendering', () => { describe('PersonMentionEditor — rendering', () => {
it('renders the textarea with placeholder', async () => { it('renders the editor as a textbox (ARIA role from editorProps)', async () => {
render(PersonMentionEditorHost, { render(PersonMentionEditorHost, {
initialValue: '', initialValue: '',
initialMentions: [], initialMentions: [],
placeholder: 'Transkription…',
onChange: () => {} onChange: () => {}
}); });
await expect.element(page.getByPlaceholder('Transkription…')).toBeInTheDocument(); await expect.element(page.getByRole('textbox')).toBeInTheDocument();
}); });
it('reflects bound initial value', async () => { it('reflects bound initial value as visible text', async () => {
render(PersonMentionEditorHost, { render(PersonMentionEditorHost, {
initialValue: 'Hallo Welt', initialValue: 'Hallo Welt',
initialMentions: [], initialMentions: [],
onChange: () => {} onChange: () => {}
}); });
await expect.element(page.getByRole('textbox')).toHaveValue('Hallo Welt'); await expect.element(page.getByText('Hallo Welt')).toBeInTheDocument();
}); });
}); });
// ─── Typeahead opens on @ ───────────────────────────────────────────────────── // ─── Typeahead opens on @ ─────────────────────────────────────────────────────
describe('PersonMentionEditor — typeahead', () => { describe('PersonMentionEditor — typeahead', () => {
it('opens the popup when typing @ + query and shows results', async () => { it('opens the dropdown when typing @ + query and shows results', async () => {
mockFetchWithPersons(); mockFetchWithPersons();
renderHost(); renderHost();
const ta = getTextarea(); await userEvent.type(page.getByRole('textbox'), '@Aug');
ta.focus();
ta.value = '@Aug';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce(); await vi.waitFor(async () => {
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); });
}); });
it('hits /api/persons?q= with the typed query', async () => { it('hits /api/persons?q= with the typed query', async () => {
const fetchMock = mockFetchWithPersons(); const fetchMock = vi
.fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
vi.stubGlobal('fetch', fetchMock);
renderHost(); renderHost();
const ta = getTextarea(); await userEvent.type(page.getByRole('textbox'), '@Aug');
ta.focus();
ta.value = '@Aug';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce(); await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug')); });
}); });
it('shows life dates next to the name in the dropdown', async () => { it('shows life dates next to the name in the dropdown', async () => {
mockFetchWithPersons(); mockFetchWithPersons();
renderHost(); renderHost();
const ta = getTextarea(); await userEvent.type(page.getByRole('textbox'), '@Aug');
ta.focus();
ta.value = '@Aug';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
await expect.element(page.getByText('* 1882 † 1944')).toBeInTheDocument(); await vi.waitFor(async () => {
await expect.element(page.getByText('* 1882 † 1944')).toBeInTheDocument();
});
}); });
it('shows empty state when no persons match', async () => { it('shows empty state when no persons match', async () => {
mockFetchEmpty(); mockFetchEmpty();
renderHost(); renderHost();
const ta = getTextarea(); await userEvent.type(page.getByRole('textbox'), '@xyz');
ta.focus();
ta.value = '@xyz';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument(); await vi.waitFor(async () => {
}); await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
});
it('falls back to the empty state when the typeahead fetch rejects (network error)', async () => {
mockFetchRejects();
renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@Aug';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
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 flushDebounce();
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
}); });
}); });
// ─── Selection writes text + sidecar ───────────────────────────────────────── // ─── AC-1: typed text becomes displayName, not DB name ───────────────────────
describe('PersonMentionEditor — selecting a person', () => { describe('PersonMentionEditor — AC-1: typed text as displayName', () => {
it('inserts @DisplayName followed by a trailing space into the textarea', async () => { it('stores the typed query as displayName, not the person DB name', async () => {
mockFetchWithPersons(); mockFetchWithPersons();
const host = renderHost(); const host = renderHost();
const ta = getTextarea(); // User types "@Aug" (not the full "Auguste Raddatz") and selects Auguste Raddatz
ta.focus(); await userEvent.type(page.getByRole('textbox'), '@Aug');
ta.value = '@Aug'; await vi.waitFor(async () => {
ta.selectionStart = 4; await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
ta.selectionEnd = 4; });
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
clickOption('p-aug'); await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
await tick();
expect(host.snapshot.value).toBe('@Auguste Raddatz '); await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons).toHaveLength(1);
expect(host.snapshot.mentionedPersons[0]).toEqual({
personId: 'p-aug',
displayName: 'Aug' // typed text, not "Auguste Raddatz"
});
});
}); });
it('pushes {personId, displayName} into the bound mentionedPersons array', async () => { it('regression: text value contains the typed query, not the full DB name', async () => {
mockFetchWithPersons(); mockFetchWithPersons();
const host = renderHost(); const host = renderHost();
const ta = getTextarea(); await userEvent.type(page.getByRole('textbox'), '@Aug');
ta.focus(); await vi.waitFor(async () => {
ta.value = '@Aug'; await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
ta.selectionStart = 4; });
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
clickOption('p-aug'); await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
await tick();
expect(host.snapshot.mentionedPersons).toEqual([ await vi.waitFor(() => {
{ personId: 'p-aug', displayName: 'Auguste Raddatz' } // Text should contain "@Aug " (typed text + space), not "@Auguste Raddatz "
]); expect(host.snapshot.value).toContain('@Aug');
expect(host.snapshot.value).not.toContain('@Auguste Raddatz');
});
});
it('pushes {personId, displayName} into mentionedPersons sidecar', async () => {
mockFetchWithPersons();
const host = renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
});
}); });
it('does not duplicate the sidecar entry when the same person is selected twice', async () => { it('does not duplicate the sidecar entry when the same person is selected twice', async () => {
mockFetchWithPersons(); mockFetchWithPersons();
const host = renderHost({ const host = renderHost({
value: '@Auguste Raddatz ', value: '@Aug ',
mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }] mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }]
}); });
const ta = getTextarea(); await userEvent.type(page.getByRole('textbox'), '@Aug');
ta.focus(); await vi.waitFor(async () => {
ta.value = '@Auguste Raddatz @Aug'; await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
ta.selectionStart = ta.value.length; });
ta.selectionEnd = ta.value.length;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
clickOption('p-aug'); await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
await tick();
expect(host.snapshot.mentionedPersons).toHaveLength(1); await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons).toHaveLength(1);
});
}); });
}); });
// ─── Keyboard navigation (B11b) ────────────────────────────────────────────── // ─── Keyboard navigation ──────────────────────────────────────────────────────
describe('PersonMentionEditor — keyboard navigation (B11b)', () => { describe('PersonMentionEditor — keyboard navigation', () => {
it('ArrowDown / ArrowUp cycle the highlighted result', async () => { it('Enter selects the highlighted result', async () => {
mockFetchWithPersons();
const host = renderHost();
await userEvent.type(page.getByRole('textbox'), '@A');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
await userEvent.keyboard('{Enter}');
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons).toHaveLength(1);
});
});
it('ArrowDown moves the highlight to the next result', async () => {
mockFetchWithPersons(); mockFetchWithPersons();
renderHost(); renderHost();
const ta = getTextarea(); await userEvent.type(page.getByRole('textbox'), '@A');
ta.focus();
ta.value = '@A';
ta.selectionStart = 2;
ta.selectionEnd = 2;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
const optAuguste = document.querySelector( await vi.waitFor(async () => {
'[role="option"][data-test-person-id="p-aug"]' await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
) as HTMLElement; });
const optAnna = document.querySelector(
'[role="option"][data-test-person-id="p-anna"]'
) as HTMLElement;
expect(optAuguste.getAttribute('aria-selected')).toBe('true'); await userEvent.keyboard('{ArrowDown}');
expect(optAnna.getAttribute('aria-selected')).toBe('false');
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await vi.waitFor(async () => {
await tick(); const annaOption = page.getByRole('option', { name: /Anna Schmidt/ });
await expect.element(annaOption).toHaveAttribute('aria-selected', 'true');
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 () => { it('Escape closes the dropdown without inserting', async () => {
mockFetchWithPersons(); mockFetchWithPersons();
const host = renderHost(); const host = renderHost();
const ta = getTextarea(); await userEvent.type(page.getByRole('textbox'), '@Aug');
ta.focus();
ta.value = '@A';
ta.selectionStart = 2;
ta.selectionEnd = 2;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await vi.waitFor(async () => {
await tick(); await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); });
await tick();
expect(host.snapshot.mentionedPersons).toEqual([ await userEvent.keyboard('{Escape}');
{ personId: 'p-anna', displayName: 'Anna Schmidt' }
]);
});
it('Escape closes the popup without inserting anything', async () => { await vi.waitFor(async () => {
mockFetchWithPersons(); await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
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 flushDebounce();
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([]); expect(host.snapshot.mentionedPersons).toEqual([]);
}); });
}); });
@@ -370,13 +287,11 @@ describe('PersonMentionEditor — touch target', () => {
mockFetchWithPersons(); mockFetchWithPersons();
renderHost(); renderHost();
const ta = getTextarea(); await userEvent.type(page.getByRole('textbox'), '@Aug');
ta.focus();
ta.value = '@Aug'; await vi.waitFor(async () => {
ta.selectionStart = 4; await expect.element(page.getByRole('option').first()).toBeVisible();
ta.selectionEnd = 4; });
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
const option = document.querySelector('[role="option"]') as HTMLElement; const option = document.querySelector('[role="option"]') as HTMLElement;
expect(option).not.toBeNull(); expect(option).not.toBeNull();