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:
@@ -1,263 +1,242 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { detectPersonMention } from '$lib/utils/personMention';
|
||||
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
||||
import { onMount, onDestroy, mount, unmount } from 'svelte';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { Mention } from '@tiptap/extension-mention';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
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 PersonMention = components['schemas']['PersonMention'];
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
mentionedPersons: PersonMention[];
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
disabled?: boolean;
|
||||
onfocus?: () => void;
|
||||
onblur?: () => void;
|
||||
// Optional escape hatch: lets the parent observe the underlying textarea node
|
||||
// (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);
|
||||
onSelectionChange?: (text: string | null) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
mentionedPersons = $bindable([]),
|
||||
placeholder = '',
|
||||
rows = 1,
|
||||
disabled = false,
|
||||
onfocus,
|
||||
onblur,
|
||||
captureTextarea
|
||||
onSelectionChange
|
||||
}: Props = $props();
|
||||
|
||||
let query: string | null = $state(null);
|
||||
let results: Person[] = $state([]);
|
||||
let highlightedIndex = $state(0);
|
||||
let mentionStart = $state(0);
|
||||
let loading = $state(false);
|
||||
let editorEl: HTMLDivElement;
|
||||
let editor: Editor | null = null;
|
||||
|
||||
let textarea: HTMLTextAreaElement | null = null;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function attachTextarea(node: HTMLTextAreaElement) {
|
||||
textarea = node;
|
||||
resizeTextarea();
|
||||
const parentCleanup = captureTextarea?.(node);
|
||||
return () => {
|
||||
parentCleanup?.();
|
||||
textarea = 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();
|
||||
// Single reactive state object shared with MentionDropdown. Mutating these
|
||||
// fields propagates to the mounted dropdown via Svelte's $state proxy —
|
||||
// this is required because Svelte 5's `mount()` does NOT return prop
|
||||
// accessors; setting `instance.items = ...` does not update the component.
|
||||
let dropdownState = $state<{
|
||||
items: Person[];
|
||||
command: (item: Person) => void;
|
||||
clientRect: (() => DOMRect | null) | null;
|
||||
}>({
|
||||
items: [],
|
||||
command: () => {},
|
||||
clientRect: null
|
||||
});
|
||||
|
||||
function handleInput() {
|
||||
if (!textarea) return;
|
||||
const cursorPos = textarea.selectionStart;
|
||||
const detected = detectPersonMention(value, cursorPos);
|
||||
type DropdownExports = {
|
||||
onKeyDown: (event: KeyboardEvent) => boolean;
|
||||
};
|
||||
|
||||
if (detected === null) {
|
||||
closePopup();
|
||||
return;
|
||||
}
|
||||
onMount(() => {
|
||||
// Custom Mention node: uses personId / displayName instead of the
|
||||
// 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);
|
||||
mentionStart = before.lastIndexOf('@');
|
||||
editor = new Editor({
|
||||
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) {
|
||||
query = detected;
|
||||
highlightedIndex = 0;
|
||||
scheduleSearch(detected);
|
||||
}
|
||||
}
|
||||
// Tiptap's SuggestionProps types `command` against the default
|
||||
// MentionNodeAttrs (id/label). Our custom Mention extension uses
|
||||
// personId/displayName, so we cast the renderProps locally.
|
||||
type LooseRenderProps = {
|
||||
items: unknown;
|
||||
command: (props: { personId: string; displayName: string }) => void;
|
||||
query: string;
|
||||
clientRect?: (() => DOMRect | null) | null;
|
||||
};
|
||||
|
||||
function scheduleSearch(q: string) {
|
||||
clearTimeout(debounceTimer);
|
||||
if (!q.trim()) {
|
||||
// Empty query: keep popup open with last results so the user can browse,
|
||||
// but don't fire a backend call until they actually type something.
|
||||
results = [];
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
// SECURITY: relies on the SvelteKit Vite proxy injecting the auth_token
|
||||
// cookie as the Authorization header (vite.config.ts) and on the
|
||||
// browser's same-origin policy for the /api/* path. Mounted in
|
||||
// transcribe mode behind WRITE_ALL — never reachable to unauthenticated
|
||||
// users.
|
||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(q)}`);
|
||||
if (res.ok) {
|
||||
const data: Person[] = await res.json();
|
||||
results = data.slice(0, 5);
|
||||
} else {
|
||||
results = [];
|
||||
const updateState = (renderProps: LooseRenderProps) => {
|
||||
dropdownState.items = renderProps.items as Person[];
|
||||
// AC-1: pass typed query as displayName, not person.displayName
|
||||
dropdownState.command = (item: Person) =>
|
||||
renderProps.command({
|
||||
personId: item.id,
|
||||
displayName: renderProps.query
|
||||
});
|
||||
dropdownState.clientRect = renderProps.clientRect ?? null;
|
||||
};
|
||||
|
||||
return {
|
||||
onStart(renderProps) {
|
||||
updateState(renderProps as unknown as LooseRenderProps);
|
||||
const mounted = mount(MentionDropdown, {
|
||||
target: document.body,
|
||||
props: { model: dropdownState }
|
||||
});
|
||||
component = mounted as object;
|
||||
exports = mounted as unknown as DropdownExports;
|
||||
},
|
||||
onUpdate(renderProps) {
|
||||
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 = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
},
|
||||
onUpdate({ editor: ed }) {
|
||||
const { text, mentionedPersons: mp } = serialize(ed.getJSON());
|
||||
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) {
|
||||
if (!textarea) return;
|
||||
|
||||
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);
|
||||
onDestroy(() => {
|
||||
editor?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<textarea
|
||||
{@attach attachTextarea}
|
||||
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"
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
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>
|
||||
<div
|
||||
class="relative rounded-sm border border-transparent focus-within:border-brand-mint focus-within:ring-2 focus-within:ring-brand-mint/40"
|
||||
class:opacity-50={disabled}
|
||||
class:pointer-events-none={disabled}
|
||||
bind:this={editorEl}
|
||||
></div>
|
||||
|
||||
@@ -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 { page } from 'vitest/browser';
|
||||
import { page, userEvent } 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'];
|
||||
|
||||
// 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 = {
|
||||
id: 'p-aug',
|
||||
firstName: 'Auguste',
|
||||
@@ -39,34 +32,17 @@ const ANNA: Person = {
|
||||
} 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;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) })
|
||||
);
|
||||
}
|
||||
|
||||
function mockFetchEmpty() {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
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 }));
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
|
||||
);
|
||||
}
|
||||
|
||||
type Snapshot = { value: string; mentionedPersons: PersonMention[] };
|
||||
@@ -90,275 +66,216 @@ function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ─── 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, {
|
||||
initialValue: '',
|
||||
initialMentions: [],
|
||||
placeholder: 'Transkription…',
|
||||
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, {
|
||||
initialValue: 'Hallo Welt',
|
||||
initialMentions: [],
|
||||
onChange: () => {}
|
||||
});
|
||||
await expect.element(page.getByRole('textbox')).toHaveValue('Hallo Welt');
|
||||
await expect.element(page.getByText('Hallo Welt')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Typeahead opens on @ ─────────────────────────────────────────────────────
|
||||
|
||||
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();
|
||||
renderHost();
|
||||
|
||||
const ta = getTextarea();
|
||||
ta.focus();
|
||||
ta.value = '@Aug';
|
||||
ta.selectionStart = 4;
|
||||
ta.selectionEnd = 4;
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||
|
||||
await flushDebounce();
|
||||
|
||||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
const ta = getTextarea();
|
||||
ta.focus();
|
||||
ta.value = '@Aug';
|
||||
ta.selectionStart = 4;
|
||||
ta.selectionEnd = 4;
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||
|
||||
await flushDebounce();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
|
||||
await vi.waitFor(() => {
|
||||
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 flushDebounce();
|
||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||
|
||||
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 () => {
|
||||
mockFetchEmpty();
|
||||
renderHost();
|
||||
|
||||
const ta = getTextarea();
|
||||
ta.focus();
|
||||
ta.value = '@xyz';
|
||||
ta.selectionStart = 4;
|
||||
ta.selectionEnd = 4;
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await flushDebounce();
|
||||
await userEvent.type(page.getByRole('textbox'), '@xyz');
|
||||
|
||||
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();
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Selection writes text + sidecar ─────────────────────────────────────────
|
||||
// ─── AC-1: typed text becomes displayName, not DB name ───────────────────────
|
||||
|
||||
describe('PersonMentionEditor — selecting a person', () => {
|
||||
it('inserts @DisplayName followed by a trailing space into the textarea', async () => {
|
||||
describe('PersonMentionEditor — AC-1: typed text as displayName', () => {
|
||||
it('stores the typed query as displayName, not the person DB name', 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 flushDebounce();
|
||||
// User types "@Aug" (not the full "Auguste Raddatz") and selects Auguste Raddatz
|
||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||
});
|
||||
|
||||
clickOption('p-aug');
|
||||
await tick();
|
||||
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
|
||||
|
||||
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();
|
||||
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();
|
||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||
});
|
||||
|
||||
clickOption('p-aug');
|
||||
await tick();
|
||||
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
|
||||
|
||||
expect(host.snapshot.mentionedPersons).toEqual([
|
||||
{ personId: 'p-aug', displayName: 'Auguste Raddatz' }
|
||||
]);
|
||||
await vi.waitFor(() => {
|
||||
// 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 () => {
|
||||
mockFetchWithPersons();
|
||||
const host = renderHost({
|
||||
value: '@Auguste Raddatz ',
|
||||
mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }]
|
||||
value: '@Aug ',
|
||||
mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }]
|
||||
});
|
||||
|
||||
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 flushDebounce();
|
||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||
});
|
||||
|
||||
clickOption('p-aug');
|
||||
await tick();
|
||||
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
|
||||
|
||||
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)', () => {
|
||||
it('ArrowDown / ArrowUp cycle the highlighted result', async () => {
|
||||
describe('PersonMentionEditor — keyboard navigation', () => {
|
||||
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();
|
||||
renderHost();
|
||||
|
||||
const ta = getTextarea();
|
||||
ta.focus();
|
||||
ta.value = '@A';
|
||||
ta.selectionStart = 2;
|
||||
ta.selectionEnd = 2;
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await flushDebounce();
|
||||
await userEvent.type(page.getByRole('textbox'), '@A');
|
||||
|
||||
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;
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||
});
|
||||
|
||||
expect(optAuguste.getAttribute('aria-selected')).toBe('true');
|
||||
expect(optAnna.getAttribute('aria-selected')).toBe('false');
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
|
||||
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');
|
||||
await vi.waitFor(async () => {
|
||||
const annaOption = page.getByRole('option', { name: /Anna Schmidt/ });
|
||||
await expect.element(annaOption).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
it('Enter selects the currently highlighted result', async () => {
|
||||
it('Escape closes the dropdown without inserting', 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 flushDebounce();
|
||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||
|
||||
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||
await tick();
|
||||
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
await tick();
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(host.snapshot.mentionedPersons).toEqual([
|
||||
{ personId: 'p-anna', displayName: 'Anna Schmidt' }
|
||||
]);
|
||||
});
|
||||
await userEvent.keyboard('{Escape}');
|
||||
|
||||
it('Escape closes the popup without inserting anything', async () => {
|
||||
mockFetchWithPersons();
|
||||
const host = renderHost();
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -370,13 +287,11 @@ describe('PersonMentionEditor — touch target', () => {
|
||||
mockFetchWithPersons();
|
||||
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 userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('option').first()).toBeVisible();
|
||||
});
|
||||
|
||||
const option = document.querySelector('[role="option"]') as HTMLElement;
|
||||
expect(option).not.toBeNull();
|
||||
|
||||
Reference in New Issue
Block a user