feat: decouple person-mention display text from person name (#372) #373
@@ -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: [
|
||||||
if (query !== detected) {
|
StarterKit.configure({
|
||||||
query = detected;
|
heading: false,
|
||||||
highlightedIndex = 0;
|
bold: false,
|
||||||
scheduleSearch(detected);
|
italic: false,
|
||||||
}
|
strike: false,
|
||||||
}
|
code: false,
|
||||||
|
blockquote: false,
|
||||||
function scheduleSearch(q: string) {
|
codeBlock: false,
|
||||||
clearTimeout(debounceTimer);
|
bulletList: false,
|
||||||
if (!q.trim()) {
|
orderedList: false,
|
||||||
// Empty query: keep popup open with last results so the user can browse,
|
hardBreak: false,
|
||||||
// but don't fire a backend call until they actually type something.
|
horizontalRule: false
|
||||||
results = [];
|
}),
|
||||||
loading = false;
|
CustomMention.configure({
|
||||||
return;
|
renderHTML({ node }) {
|
||||||
}
|
return [
|
||||||
loading = true;
|
'span',
|
||||||
debounceTimer = setTimeout(async () => {
|
{
|
||||||
|
'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 {
|
try {
|
||||||
// SECURITY: relies on the SvelteKit Vite proxy injecting the auth_token
|
const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`);
|
||||||
// cookie as the Authorization header (vite.config.ts) and on the
|
if (!res.ok) return [];
|
||||||
// browser's same-origin policy for the /api/* path. Mounted in
|
return ((await res.json()) as Person[]).slice(0, 5);
|
||||||
// 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 = [];
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
results = [];
|
return [];
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
}
|
||||||
}, 200);
|
},
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
content: deserialize(value, mentionedPersons),
|
||||||
// popup unmounts. Without this, clicking a result on the way out would
|
editorProps: {
|
||||||
// race with blur and lose the selection.
|
attributes: {
|
||||||
setTimeout(() => closePopup(), 150);
|
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(' ')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUpdate({ editor: ed }) {
|
||||||
|
const { text, mentionedPersons: mp } = serialize(ed.getJSON());
|
||||||
|
value = text;
|
||||||
|
mentionedPersons = mp;
|
||||||
|
},
|
||||||
|
onFocus() {
|
||||||
|
onfocus?.();
|
||||||
|
},
|
||||||
|
onBlur() {
|
||||||
onblur?.();
|
onblur?.();
|
||||||
|
},
|
||||||
|
onSelectionUpdate({ editor: ed }) {
|
||||||
|
const { from, to } = ed.state.selection;
|
||||||
|
onSelectionChange?.(from !== to ? ed.state.doc.textBetween(from, to) : null);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
onDestroy(() => {
|
||||||
if (query === null) return;
|
editor?.destroy();
|
||||||
|
});
|
||||||
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">
|
|
||||||
<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
|
<div
|
||||||
class="absolute z-20 mt-1 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
class="relative rounded-sm border border-transparent focus-within:border-brand-mint focus-within:ring-2 focus-within:ring-brand-mint/40"
|
||||||
role="listbox"
|
class:opacity-50={disabled}
|
||||||
aria-label={m.person_mention_btn_label()}
|
class:pointer-events-none={disabled}
|
||||||
>
|
bind:this={editorEl}
|
||||||
{#if loading}
|
></div>
|
||||||
<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>
|
|
||||||
|
|||||||
@@ -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 vi.waitFor(async () => {
|
||||||
await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument();
|
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 vi.waitFor(async () => {
|
||||||
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
|
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 tick();
|
|
||||||
|
|
||||||
expect(host.snapshot.value).toBe('@Auguste Raddatz ');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('pushes {personId, displayName} into the bound mentionedPersons array', async () => {
|
await userEvent.click(page.getByRole('option', { name: /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('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();
|
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(host.snapshot.mentionedPersons).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Keyboard navigation ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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);
|
expect(host.snapshot.mentionedPersons).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Keyboard navigation (B11b) ──────────────────────────────────────────────
|
it('ArrowDown moves the highlight to the next result', async () => {
|
||||||
|
|
||||||
describe('PersonMentionEditor — keyboard navigation (B11b)', () => {
|
|
||||||
it('ArrowDown / ArrowUp cycle the highlighted 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');
|
|
||||||
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 () => {
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
const annaOption = page.getByRole('option', { name: /Anna Schmidt/ });
|
||||||
|
await expect.element(annaOption).toHaveAttribute('aria-selected', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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([
|
|
||||||
{ personId: 'p-anna', displayName: 'Anna Schmidt' }
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Escape closes the popup without inserting anything', async () => {
|
await userEvent.keyboard('{Escape}');
|
||||||
mockFetchWithPersons();
|
|
||||||
const host = renderHost();
|
|
||||||
|
|
||||||
const ta = getTextarea();
|
await vi.waitFor(async () => {
|
||||||
ta.focus();
|
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
||||||
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();
|
||||||
|
|||||||
Reference in New Issue
Block a user