refactor: move shared components to lib/shared/ sub-packages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-05 14:40:14 +02:00
parent d6db7a07bd
commit efcc347c00
84 changed files with 43 additions and 43 deletions

View File

@@ -0,0 +1,116 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import type { FlatMessage } from '$lib/types';
import { extractQuote } from '$lib/shared/discussion/comment';
import { getInitials } from '$lib/person/personFormat';
import { relativeTime } from '$lib/shared/utils/time';
import { renderBody } from '$lib/shared/discussion/mention';
type Props = {
message: FlatMessage;
isOwn: boolean;
isEditing: boolean;
editText: string;
onEdit: () => void;
onDelete: () => void;
onEditTextChange: (text: string) => void;
onEditKeydown: (e: KeyboardEvent) => void;
};
let {
message,
isOwn,
isEditing,
editText,
onEdit,
onDelete,
onEditTextChange,
onEditKeydown
}: Props = $props();
const wasEdited = $derived(message.updatedAt > message.createdAt);
const parsed = $derived(extractQuote(message.content));
</script>
<div
id="comment-{message.id}"
role="article"
tabindex="-1"
class="flex gap-2 rounded outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2"
>
<!-- Avatar circle with initials -->
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
>
{getInitials(message.authorName)}
</div>
<!-- Content -->
<div class="min-w-0 flex-1">
<!-- Author + timestamp -->
<div class="flex items-center gap-1.5">
<span class="font-sans text-sm font-semibold text-ink">{message.authorName}</span>
{#if wasEdited}
<span class="font-sans text-xs text-ink-3"
>{relativeTime(message.updatedAt)} {m.comment_edited_label()}</span
>
{:else}
<span class="font-sans text-xs text-ink-3">{relativeTime(message.createdAt)}</span>
{/if}
</div>
<!-- Quote block (if present) -->
{#if parsed.quote}
<div class="my-1 border-l-2 border-line pl-2 font-serif text-base text-ink-3 italic">
&ldquo;{parsed.quote}&rdquo;
</div>
{/if}
<!-- Edit mode vs view mode -->
{#if isEditing}
<textarea
class="mt-1 w-full resize-none rounded border border-line bg-surface px-2 py-1 font-serif text-sm leading-relaxed text-ink outline-none focus:border-primary"
rows={2}
value={editText}
oninput={(e) => onEditTextChange((e.currentTarget as HTMLTextAreaElement).value)}
onkeydown={onEditKeydown}
></textarea>
<div class="mt-1 font-sans text-xs text-ink-3">{m.comment_edit_hint()}</div>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="relative" onclick={() => { if (isOwn) onEdit(); }}>
<p
class="font-serif text-base leading-relaxed text-ink-2 {isOwn
? '-mx-1 cursor-text rounded px-1 transition-colors hover:bg-surface'
: ''}"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
{@html renderBody(parsed.body, message.mentionDTOs ?? [])}
</p>
{#if isOwn}
<button
type="button"
class="hover:text-error absolute -right-1 -bottom-1 cursor-pointer rounded p-2 text-ink-3 transition-colors"
aria-label="{m.btn_delete()} {message.authorName}"
onclick={(e) => { e.stopPropagation(); onDelete(); }}
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,106 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import CommentMessage from './CommentMessage.svelte';
import type { FlatMessage } from '$lib/types';
afterEach(cleanup);
const baseMsg: FlatMessage = {
id: 'msg-1',
authorId: 'user-1',
authorName: 'Anna Müller',
content: 'Hello world',
createdAt: new Date(Date.now() - 5 * 60_000).toISOString(),
updatedAt: new Date(Date.now() - 5 * 60_000).toISOString()
};
function defaultProps(overrides: Partial<Parameters<typeof render>[1]> = {}) {
return {
message: baseMsg,
isOwn: false,
isEditing: false,
editText: '',
onEdit: vi.fn(),
onDelete: vi.fn(),
onEditTextChange: vi.fn(),
onEditKeydown: vi.fn(),
...overrides
};
}
describe('CommentMessage', () => {
it('renders author name', async () => {
render(CommentMessage, defaultProps());
await expect.element(page.getByText('Anna Müller')).toBeInTheDocument();
});
it('renders initials in avatar', async () => {
render(CommentMessage, defaultProps());
await expect.element(page.getByText('AM')).toBeInTheDocument();
});
it('renders message body', async () => {
render(CommentMessage, defaultProps());
await expect.element(page.getByText('Hello world')).toBeInTheDocument();
});
it('renders quoted section when content contains a quote', async () => {
render(
CommentMessage,
defaultProps({
message: { ...baseMsg, content: '> "Interesting passage"\n\nMy reply' }
})
);
await expect.element(page.getByText(/Interesting passage/)).toBeInTheDocument();
await expect.element(page.getByText('My reply')).toBeInTheDocument();
});
it('does not show delete button for messages not owned by current user', async () => {
render(CommentMessage, defaultProps({ isOwn: false }));
await expect.element(page.getByRole('button')).not.toBeInTheDocument();
});
it('shows delete button for own messages', async () => {
render(CommentMessage, defaultProps({ isOwn: true }));
await expect.element(page.getByRole('button')).toBeInTheDocument();
});
it('calls onDelete when delete button is clicked', async () => {
const onDelete = vi.fn();
render(CommentMessage, defaultProps({ isOwn: true, onDelete }));
await userEvent.click(page.getByRole('button'));
expect(onDelete).toHaveBeenCalled();
});
it('shows edit textarea when isEditing is true', async () => {
render(
CommentMessage,
defaultProps({ isOwn: true, isEditing: true, editText: 'current edit text' })
);
const textarea = page.getByRole('textbox');
await expect.element(textarea).toBeInTheDocument();
await expect.element(textarea).toHaveValue('current edit text');
});
it('exposes id="comment-{message.id}" on the article wrapper for deep-link scroll', async () => {
render(CommentMessage, defaultProps());
const article = page.getByRole('article').element();
expect(article.getAttribute('id')).toBe('comment-msg-1');
});
it('is focusable but not in tab order (tabindex="-1")', async () => {
render(CommentMessage, defaultProps());
const article = page.getByRole('article').element();
expect(article.getAttribute('tabindex')).toBe('-1');
});
it('shows a focus-visible ring when focused via keyboard', async () => {
render(CommentMessage, defaultProps());
const article = page.getByRole('article').element();
const classes = article.className;
expect(classes).toMatch(/focus-visible:ring-2/);
expect(classes).toMatch(/focus-visible:ring-brand-navy/);
expect(classes).toMatch(/outline-none/);
});
});

View File

@@ -0,0 +1,212 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import type { Comment, FlatMessage, MentionDTO } from '$lib/types';
import MentionEditor from '$lib/shared/discussion/MentionEditor.svelte';
import CommentMessage from '$lib/shared/discussion/CommentMessage.svelte';
import { extractContent } from '$lib/shared/discussion/mention';
type Props = {
documentId: string;
annotationId?: string | null;
blockId?: string | null;
initialComments?: Comment[];
loadOnMount?: boolean;
canComment: boolean;
currentUserId: string | null;
quotedText?: string | null;
showCompose?: boolean;
onCountChange?: (count: number) => void;
};
let {
documentId,
annotationId = null,
blockId = null,
initialComments = [],
loadOnMount = false,
canComment,
currentUserId = null,
quotedText = null,
showCompose = true,
onCountChange
}: Props = $props();
let comments: Comment[] = $state(untrack(() => [...initialComments]));
let newText: string = $state('');
let posting: boolean = $state(false);
let newMentionCandidates: MentionDTO[] = $state([]);
let editingId: string | null = $state(null);
let editText: string = $state('');
const commentsBase = $derived(
blockId
? `/api/documents/${documentId}/transcription-blocks/${blockId}/comments`
: annotationId
? `/api/documents/${documentId}/annotations/${annotationId}/comments`
: `/api/documents/${documentId}/comments`
);
const flatMessages = $derived(
comments.flatMap((thread) => [thread as FlatMessage, ...(thread.replies as FlatMessage[])])
);
$effect(() => {
if (quotedText && quotedText.trim()) {
newText = `> "${quotedText}"\n\n`;
}
});
function isOwn(c: { authorId: string | null }): boolean {
return currentUserId !== null && c.authorId === currentUserId;
}
async function reload() {
try {
const res = await fetch(commentsBase);
if (res.ok) {
comments = await res.json();
const total = comments.reduce((s, c) => s + 1 + c.replies.length, 0);
onCountChange?.(total);
}
} catch {
/* ignore */
}
}
async function postComment() {
const text = newText.trim();
if (!text || posting) return;
posting = true;
try {
const { content, mentionedUserIds } = extractContent(text, newMentionCandidates);
const res = await fetch(commentsBase, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, mentionedUserIds })
});
if (res.ok) {
newText = '';
newMentionCandidates = [];
await reload();
}
} finally {
posting = false;
}
}
function startEdit(msg: FlatMessage) {
editingId = msg.id;
editText = msg.content;
}
async function saveEdit(commentId: string) {
const text = editText.trim();
if (!text || posting) return;
posting = true;
try {
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text })
});
if (res.ok) {
editingId = null;
editText = '';
await reload();
}
} finally {
posting = false;
}
}
function cancelEdit() {
editingId = null;
editText = '';
}
function handleEditKeydown(e: KeyboardEvent, commentId: string) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
saveEdit(commentId);
} else if (e.key === 'Escape') {
e.stopPropagation();
cancelEdit();
}
}
async function deleteComment(commentId: string) {
if (posting) return;
posting = true;
try {
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
method: 'DELETE'
});
if (res.ok) {
await reload();
}
} finally {
posting = false;
}
}
onMount(() => {
if (loadOnMount) {
reload();
} else {
const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0);
onCountChange?.(total);
}
});
</script>
{#if flatMessages.length === 0}
<p class="text-sm text-ink-3 italic">{m.comment_empty_hint()}</p>
{:else}
<div class="rounded border-l-2 border-accent bg-muted p-2">
<div class="mb-2 flex items-center gap-1.5 font-sans text-sm font-semibold text-ink-2">
<svg
class="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
/>
</svg>
{flatMessages.length}
{flatMessages.length === 1 ? 'Kommentar' : 'Kommentare'}
</div>
<div role="log" class="space-y-2">
{#each flatMessages as msg (msg.id)}
<CommentMessage
message={msg}
isOwn={isOwn(msg)}
isEditing={editingId === msg.id}
editText={editText}
onEdit={() => startEdit(msg)}
onDelete={() => deleteComment(msg.id)}
onEditTextChange={(text) => { editText = text; }}
onEditKeydown={(e) => handleEditKeydown(e, msg.id)}
/>
{/each}
</div>
</div>
{/if}
{#if canComment && (showCompose || flatMessages.length > 0)}
<div class="mt-2">
<MentionEditor
bind:value={newText}
bind:mentionCandidates={newMentionCandidates}
rows={1}
placeholder={m.comment_placeholder()}
disabled={posting}
onsubmit={postComment}
/>
</div>
{/if}

View File

@@ -0,0 +1,70 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import CommentThread from './CommentThread.svelte';
import type { Comment } from '$lib/types';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function makeComment(id: string, content = 'Hello'): Comment {
return {
id,
authorId: 'user-1',
authorName: 'Alice',
content,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
replies: []
};
}
const baseProps = {
documentId: 'doc-1',
canComment: true,
currentUserId: 'user-1',
canAdmin: false
};
describe('CommentThread empty state', () => {
it('shows empty state hint when there are no comments', async () => {
render(CommentThread, { ...baseProps, initialComments: [] });
await expect
.element(page.getByText('Noch keine Kommentare starte die Diskussion!'))
.toBeInTheDocument();
});
it('does not show empty state hint when comments exist', async () => {
render(CommentThread, { ...baseProps, initialComments: [makeComment('c-1')] });
await expect
.element(page.getByText('Noch keine Kommentare starte die Diskussion!'))
.not.toBeInTheDocument();
});
});
describe('CommentThread onCountChange', () => {
it('calls onCountChange with initial SSR count on mount', async () => {
const onCountChange = vi.fn();
render(CommentThread, {
...baseProps,
initialComments: [makeComment('c-1'), makeComment('c-2')],
onCountChange
});
expect(onCountChange).toHaveBeenCalledWith(2);
});
it('calls onCountChange with 0 when no initial comments', async () => {
const onCountChange = vi.fn();
render(CommentThread, { ...baseProps, initialComments: [], onCountChange });
expect(onCountChange).toHaveBeenCalledWith(0);
});
it('counts replies in the total', async () => {
const onCountChange = vi.fn();
const comment = { ...makeComment('c-1'), replies: [makeComment('r-1') as never] };
render(CommentThread, { ...baseProps, initialComments: [comment], onCountChange });
expect(onCountChange).toHaveBeenCalledWith(2);
});
});

View File

@@ -0,0 +1,170 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import { formatLifeDateRange } from '$lib/person/personLifeDates';
import { m } from '$lib/paraglide/messages.js';
type Person = components['schemas']['Person'];
// The dropdown receives a single reactive state object. PersonMentionEditor
// mutates fields on this object (model.items = ..., etc.) and Svelte's $state
// proxy reactivity propagates the change here. This is the supported way to
// update an imperatively-mounted Svelte 5 component — `mount` does not return
// settable prop accessors.
type DropdownState = {
items: Person[];
command: (item: Person) => void;
clientRect: (() => DOMRect | null) | null;
};
let { model }: { model: DropdownState } = $props();
// highlightedIndex must be both writable (keyboard handler mutates it) and
// reset when `items` changes (so it never points past the end of a new list).
// A pure $derived is read-only and cannot serve both needs, so $state + $effect
// is the correct pattern here. The autofixer suggestion to use $derived does not
// apply: the mutation in onKeyDown is not a derivation.
let highlightedIndex = $state(0);
$effect(() => {
// Read model.items to subscribe; reset index whenever the list is replaced.
void model.items;
highlightedIndex = 0;
});
// ---------------------------------------------------------------------------
// Positioning — flip strategy: open upward when there is not enough room
// below the cursor to show the dropdown without clipping the viewport.
// ---------------------------------------------------------------------------
type Position = {
top: string | null;
bottom: string | null;
left: string;
};
const DROPDOWN_CLEARANCE_PX = 200;
const position = $derived.by<Position>(() => {
const cr = model.clientRect;
if (!cr) return { top: '0px', bottom: null, left: '0px' };
const rect = cr();
if (!rect) return { top: '0px', bottom: null, left: '0px' };
// Some editors report a caret DOMRect with zero width; fall back to rect.x.
const left = `${rect.width === 0 ? rect.x : rect.left}px`;
if (window.innerHeight - rect.bottom < DROPDOWN_CLEARANCE_PX) {
// Not enough space below — anchor bottom of dropdown to top of caret.
return {
top: null,
bottom: `${window.innerHeight - rect.top}px`,
left
};
}
return { top: `${rect.bottom}px`, bottom: null, left };
});
// ---------------------------------------------------------------------------
// Keyboard handler — exported so Tiptap's render() can forward events.
// Returns true when the event is consumed (prevents the editor's default).
// ---------------------------------------------------------------------------
export function onKeyDown(event: KeyboardEvent): boolean {
const len = model.items.length;
if (event.key === 'ArrowDown') {
highlightedIndex = (highlightedIndex + 1) % Math.max(len, 1);
return true;
}
if (event.key === 'ArrowUp') {
highlightedIndex = (highlightedIndex - 1 + Math.max(len, 1)) % Math.max(len, 1);
return true;
}
if (event.key === 'Enter') {
const selected = model.items[highlightedIndex];
if (selected) {
model.command(selected);
}
return true;
}
// Escape: let the suggestion plugin handle it (return false = not consumed).
return false;
}
function selectItem(item: Person) {
model.command(item);
}
</script>
<!--
Mounted imperatively to document.body by the Tiptap suggestion plugin.
Positioned absolutely relative to the viewport using inline styles derived
from the Tiptap clientRect() callback.
SECURITY: This component receives pre-filtered Person[] items from the
parent — it does NOT fetch. The parent's fetch relies on the SvelteKit Vite
proxy injecting the auth_token cookie as the Authorization header.
Mounted in transcribe mode behind WRITE_ALL — never reachable to
unauthenticated users.
-->
<div
class="fixed z-50 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
role="listbox"
aria-label={m.person_mention_btn_label()}
style:top={position.top}
style:bottom={position.bottom}
style:left={position.left}
>
{#if model.items.length === 0}
<p class="px-3 py-2.5 font-sans text-sm text-ink-3">
{m.person_mention_popup_empty()}
</p>
<!--
Empty-state escape hatch — without it the transcriber has to close
the dropdown, navigate to /persons/new, come back, and re-type the
query. target=_blank keeps the document and editor state intact;
rel=noopener prevents reverse-tabnabbing on the new tab. Leonie #5621.
-->
<a
href="/persons/new"
target="_blank"
rel="noopener"
class="flex min-h-[44px] items-center gap-2 border-t border-line px-3 py-2.5 font-sans text-sm font-medium text-brand-navy hover:bg-canvas focus:bg-canvas focus:outline-none"
onmousedown={(e) => e.preventDefault()}
>
{m.person_mention_create_new()}
<span aria-hidden="true"></span>
</a>
{:else}
{#each model.items 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',
// brand-mint ring (≈2.5:1 on white) fails WCAG 1.4.11 Non-Text
// Contrast for a meaningful keyboard-highlight indicator. brand-navy
// gives ~14.5:1 against the bg-brand-mint/20 row. Leonie #5621.
i === highlightedIndex && 'bg-brand-mint/20 ring-2 ring-brand-navy ring-inset'
]}
role="option"
aria-selected={i === highlightedIndex}
data-test-person-id={person.id}
tabindex="-1"
onmousedown={(e) => {
// Prevent blur on the editor before the selection fires.
e.preventDefault();
selectItem(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>

View File

@@ -0,0 +1,201 @@
<script lang="ts">
import { onDestroy, tick } from 'svelte';
import { detectMention } from '$lib/shared/discussion/mention';
import type { MentionDTO } from '$lib/types';
import { m } from '$lib/paraglide/messages.js';
type Props = {
value: string;
mentionCandidates: MentionDTO[];
placeholder?: string;
rows?: number;
disabled?: boolean;
onsubmit?: () => void;
};
let {
value = $bindable(''),
mentionCandidates = $bindable([]),
placeholder = '',
rows = 3,
disabled = false,
onsubmit
}: Props = $props();
let query: string | null = $state(null);
let results: MentionDTO[] = $state([]);
let highlightedIndex = $state(0);
let mentionStart = $state(0);
let textarea: HTMLTextAreaElement | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
function attachTextarea(node: HTMLTextAreaElement) {
textarea = node;
return () => {
textarea = null;
};
}
function handleInput() {
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const detected = detectMention(value, cursorPos);
if (detected === null) {
closePopup();
return;
}
// Calculate where the @ starts
const before = value.slice(0, cursorPos);
const atIndex = before.lastIndexOf('@');
mentionStart = atIndex;
if (query !== detected) {
query = detected;
highlightedIndex = 0;
scheduleSearch(detected);
}
}
function scheduleSearch(q: string) {
clearTimeout(debounceTimer);
if (!q) {
results = [];
return;
}
debounceTimer = setTimeout(async () => {
try {
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`);
if (res.ok) {
const data: MentionDTO[] = await res.json();
results = data.slice(0, 5);
} else {
results = [];
}
} catch {
results = [];
}
}, 200);
}
async function selectUser(user: MentionDTO) {
if (!textarea) return;
const displayName = `${user.firstName} ${user.lastName}`;
// Replace @partialQuery with @FirstName LastName (plus trailing space)
const replacement = `@${displayName} `;
const cursorPos = textarea.selectionStart;
const before = value.slice(0, mentionStart);
const after = value.slice(cursorPos);
value = before + replacement + after;
// Deduplicate and add to candidates
if (!mentionCandidates.some((c) => c.id === user.id)) {
mentionCandidates = [...mentionCandidates, user];
}
closePopup();
// Reposition cursor after the inserted mention
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;
clearTimeout(debounceTimer);
}
function handleKeydown(e: KeyboardEvent) {
// Enter sends, Shift+Enter adds newline
if (e.key === 'Enter' && !e.shiftKey && query === null) {
e.preventDefault();
onsubmit?.();
return;
}
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();
selectUser(results[highlightedIndex]);
return;
}
}
onDestroy(() => clearTimeout(debounceTimer));
const popupOpen = $derived(query !== null);
</script>
<div class="relative">
<textarea
{@attach attachTextarea}
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
rows={rows}
placeholder={placeholder}
disabled={disabled}
bind:value={value}
oninput={handleInput}
onkeydown={handleKeydown}
></textarea>
{#if popupOpen}
<div
class="absolute z-20 mt-1 w-64 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
role="listbox"
aria-label={m.mention_btn_label()}
>
{#if results.length === 0}
<p class="px-3 py-2 font-sans text-sm text-ink-3">{m.mention_popup_empty()}</p>
{:else}
{#each results as user, i (user.id)}
<div
class="w-full px-3 py-2 text-left font-sans text-sm text-ink hover:bg-canvas {i === highlightedIndex ? 'bg-canvas' : ''}"
role="option"
aria-selected={i === highlightedIndex}
tabindex="-1"
onmousedown={(e) => {
// Use mousedown to fire before textarea blur
e.preventDefault();
selectUser(user);
}}
>
{user.firstName}
{user.lastName}
</div>
{/each}
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,295 @@
<script lang="ts">
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/shared/discussion/mentionSerializer';
import MentionDropdown from './MentionDropdown.svelte';
type Person = components['schemas']['Person'];
type Props = {
value: string;
mentionedPersons: PersonMention[];
placeholder?: string;
disabled?: boolean;
onfocus?: () => void;
onblur?: () => void;
onSelectionChange?: (text: string | null) => void;
};
let {
value = $bindable(''),
mentionedPersons = $bindable([]),
placeholder = '',
disabled = false,
onfocus,
onblur,
onSelectionChange
}: Props = $props();
let editorEl: HTMLDivElement;
let editor: Editor | null = null;
// 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
});
type DropdownExports = {
onKeyDown: (event: KeyboardEvent) => boolean;
};
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
})
}
};
}
});
editor = new Editor({
element: editorEl,
// Initial editable state honors the `disabled` prop. The reactive
// $effect below keeps it in sync if the prop flips after mount —
// without this, a keyboard user can tab into the contenteditable
// even when the wrapper has pointer-events-none (WCAG 2.1.1).
editable: !disabled,
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 }) {
// Underline color matches the read-mode .person-mention rule
// (ink at ~50% alpha) — brand-mint on white fails WCAG 1.4.11
// Non-Text Contrast (≈1.7:1, needs 3:1). Leonie #5621.
return [
'span',
{
'data-type': 'mention',
'data-person-id': node.attrs.personId,
'data-display-name': node.attrs.displayName,
class:
'mention-token underline decoration-ink/50 underline-offset-2 text-brand-navy font-medium'
},
`@${node.attrs.displayName}`
];
},
renderText({ node }) {
return `@${node.attrs.displayName}`;
},
suggestion: {
char: '@',
allowSpaces: true,
// ─────────────────────────────────────────────────────────────
// EXCEPTION to frontend/CLAUDE.md "no client-side API fetch":
// Tiptap's suggestion plugin lives entirely on the client and
// fires on every keystroke after `@`. Routing each query through
// a SvelteKit form action would round-trip through SSR for a
// dropdown that needs to feel instantaneous, and a +server.ts
// endpoint would only proxy the same call. Auth flows through
// the Vite proxy in dev and Caddy in prod (cookie-based), so the
// network surface is identical to a server-driven call.
// Markus #5616: an ADR will formalise this. Open follow-up:
// "ADR: client-side fetch exception for editor suggestion plugins."
// Nora #5618 #3 — separate issue tracks the GET /api/persons
// response-shape audit (PersonSummaryDTO leaks `notes`).
// ─────────────────────────────────────────────────────────────
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;
// 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;
}
}
};
}
}
})
],
content: deserialize(value, mentionedPersons),
editorProps: {
attributes: {
role: 'textbox',
'aria-multiline': 'true',
'aria-label': m.transcription_editor_aria_label(),
'data-editor-inner': '',
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?.();
},
onSelectionUpdate({ editor: ed }) {
const { from, to } = ed.state.selection;
onSelectionChange?.(from !== to ? ed.state.doc.textBetween(from, to) : null);
}
});
});
onDestroy(() => {
editor?.destroy();
});
// Keep the data-placeholder attribute in sync with actual emptiness so the
// placeholder CSS only fires when there is no content (not just on blur).
$effect(() => {
if (!editor || !placeholder) return;
void value; // Tiptap's onUpdate always fires on content change, but $effect needs a
// reactive read to re-run — void value registers value as a dependency without using it.
const inner = editorEl?.querySelector('[data-editor-inner]') as HTMLElement | null;
if (!inner) return;
if (editor.isEmpty) {
inner.setAttribute('data-placeholder', placeholder);
} else {
inner.removeAttribute('data-placeholder');
}
});
// Keep editor in sync with the reactive `disabled` prop. Tiptap's setEditable
// flips contenteditable on the inner DOM and stops accepting input — matches
// the textarea's old `disabled` semantics for keyboard users (WCAG 2.1.1).
//
// Guard: setEditable triggers a ProseMirror transaction which fires onUpdate;
// onUpdate writes through bind:value / bind:mentionedPersons. Without this
// idempotence check, the effect would loop on every prop pass-through.
$effect(() => {
const shouldBeEditable = !disabled;
if (editor && editor.isEditable !== shouldBeEditable) {
editor.setEditable(shouldBeEditable);
}
});
</script>
<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}
aria-disabled={disabled ? 'true' : undefined}
bind:this={editorEl}
></div>

View File

@@ -0,0 +1,424 @@
/**
* 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, userEvent } from 'vitest/browser';
import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
type Person = components['schemas']['Person'];
type PersonMention = components['schemas']['PersonMention'];
const AUGUSTE: Person = {
id: 'p-aug',
firstName: 'Auguste',
lastName: 'Raddatz',
displayName: 'Auguste Raddatz',
birthYear: 1882,
deathYear: 1944
} as unknown as Person;
const ANNA: Person = {
id: 'p-anna',
firstName: 'Anna',
lastName: 'Schmidt',
displayName: 'Anna Schmidt',
birthYear: 1860
} as unknown as Person;
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) })
);
}
function mockFetchEmpty() {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
);
}
type Snapshot = { value: string; mentionedPersons: PersonMention[] };
function renderHost(
initial: { value?: string; mentionedPersons?: PersonMention[]; disabled?: boolean } = {}
) {
let snapshot: Snapshot = {
value: initial.value ?? '',
mentionedPersons: initial.mentionedPersons ?? []
};
render(PersonMentionEditorHost, {
initialValue: initial.value ?? '',
initialMentions: initial.mentionedPersons ?? [],
disabled: initial.disabled ?? false,
onChange: (snap: Snapshot) => {
snapshot = snap;
}
});
return {
get snapshot() {
return snapshot;
}
};
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('PersonMentionEditor — rendering', () => {
it('renders the editor as a textbox (ARIA role from editorProps)', async () => {
render(PersonMentionEditorHost, {
initialValue: '',
initialMentions: [],
onChange: () => {}
});
await expect.element(page.getByRole('textbox')).toBeInTheDocument();
});
it('reflects bound initial value as visible text', async () => {
render(PersonMentionEditorHost, {
initialValue: 'Hallo Welt',
initialMentions: [],
onChange: () => {}
});
await expect.element(page.getByText('Hallo Welt')).toBeInTheDocument();
});
});
// ─── Typeahead opens on @ ─────────────────────────────────────────────────────
describe('PersonMentionEditor — typeahead', () => {
it('opens the dropdown when typing @ + query and shows results', async () => {
mockFetchWithPersons();
renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
});
});
it('hits /api/persons?q= with the typed query', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
vi.stubGlobal('fetch', fetchMock);
renderHost();
await userEvent.type(page.getByRole('textbox'), '@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();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByText('* 1882 † 1944')).toBeInTheDocument();
});
});
it('shows empty state when no persons match', async () => {
mockFetchEmpty();
renderHost();
await userEvent.type(page.getByRole('textbox'), '@xyz');
await vi.waitFor(async () => {
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
});
});
it('offers a "create new person" link in the empty state', async () => {
mockFetchEmpty();
renderHost();
await userEvent.type(page.getByRole('textbox'), '@xyz');
await vi.waitFor(async () => {
const link = page.getByRole('link', { name: /Neue Person anlegen/ });
await expect.element(link).toBeVisible();
await expect.element(link).toHaveAttribute('href', '/persons/new');
});
});
});
// ─── AC-1: typed text becomes displayName, not DB name ───────────────────────
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();
// 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();
});
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();
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(() => {
// 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: '@Aug ',
mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }]
});
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).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);
});
});
it('ArrowDown moves the highlight to the next result', async () => {
mockFetchWithPersons();
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('{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();
const host = renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
});
await userEvent.keyboard('{Escape}');
await vi.waitFor(async () => {
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
});
expect(host.snapshot.mentionedPersons).toEqual([]);
});
});
// ─── Disabled state (WCAG 2.1.1 — keyboard users) ────────────────────────────
describe('PersonMentionEditor — disabled state', () => {
it('sets contenteditable=false on the editor when disabled', async () => {
renderHost({ value: 'Bestehender Text', disabled: true });
await vi.waitFor(() => {
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
expect(textbox).not.toBeNull();
expect(textbox!.getAttribute('contenteditable')).toBe('false');
});
});
it('exposes aria-disabled=true on the editor wrapper when disabled', async () => {
renderHost({ disabled: true });
await vi.waitFor(() => {
const wrapper = document.querySelector('[aria-disabled="true"]');
expect(wrapper).not.toBeNull();
});
});
it('keeps the editor editable (contenteditable=true) when not disabled', async () => {
renderHost({ disabled: false });
await vi.waitFor(() => {
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
expect(textbox).not.toBeNull();
expect(textbox!.getAttribute('contenteditable')).toBe('true');
});
});
});
// ─── Security — XSS in displayName (CWE-79) ──────────────────────────────────
describe('PersonMentionEditor — XSS resistance', () => {
it('renders a malicious displayName as text, not as HTML elements', async () => {
// A historical sidecar entry whose displayName contains an HTML payload
// that would execute if interpolated as raw HTML. Tiptap's renderHTML
// returns the @-prefixed string as the third tuple entry, which
// ProseMirror's DOMSerializer treats as a Text node — escaping it.
const maliciousMention: PersonMention = {
personId: '00000000-0000-0000-0000-000000000001',
displayName: '<img src=x onerror=alert(1)>'
};
renderHost({
value: '@<img src=x onerror=alert(1)>',
mentionedPersons: [maliciousMention]
});
await vi.waitFor(() => {
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
expect(textbox).not.toBeNull();
// No element from the malicious payload should have appeared as a real
// DOM node. (Tiptap inserts its own ProseMirror-separator <img> in empty
// paragraphs — that is internal markup and never carries user attrs;
// guard against the injection by checking the user-controlled attrs.)
expect(textbox!.querySelector('img[onerror]')).toBeNull();
expect(textbox!.querySelector('img[src="x"]')).toBeNull();
expect(textbox!.querySelector('script')).toBeNull();
// The payload should appear as visible text content instead.
expect(textbox!.textContent ?? '').toContain('<img src=x onerror=alert(1)>');
});
});
});
// ─── Placeholder behavior ─────────────────────────────────────────────────────
describe('PersonMentionEditor — placeholder behavior', () => {
it('sets data-placeholder on the inner element when editor is empty', async () => {
render(PersonMentionEditorHost, {
initialValue: '',
initialMentions: [],
placeholder: 'Gib Text ein...',
onChange: () => {}
});
await vi.waitFor(() => {
const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null;
expect(inner).not.toBeNull();
expect(inner!.getAttribute('data-placeholder')).toBe('Gib Text ein...');
});
});
it('omits data-placeholder on the inner element when editor has content', async () => {
render(PersonMentionEditorHost, {
initialValue: 'Bestehender Text',
initialMentions: [],
placeholder: 'Gib Text ein...',
onChange: () => {}
});
await vi.waitFor(() => {
const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null;
expect(inner).not.toBeNull();
expect(inner!.hasAttribute('data-placeholder')).toBe(false);
});
});
});
// ─── i18n message content ─────────────────────────────────────────────────────
describe('PersonMentionEditor — i18n message content', () => {
it('transcription_block_placeholder contains @ mention trigger for discoverability', () => {
expect(m.transcription_block_placeholder()).toContain('@');
});
});
// ─── Touch target (WCAG 2.2 AA) ──────────────────────────────────────────────
describe('PersonMentionEditor — touch target', () => {
it('each result row has min-h-[44px] (WCAG 2.2 AA)', async () => {
mockFetchWithPersons();
renderHost();
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();
expect(option.className).toContain('min-h-[44px]');
});
});

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import { untrack } from 'svelte';
import PersonMentionEditor from './PersonMentionEditor.svelte';
import type { components } from '$lib/generated/api';
type PersonMention = components['schemas']['PersonMention'];
type Props = {
initialValue?: string;
initialMentions?: PersonMention[];
placeholder?: string;
disabled?: boolean;
onChange: (snapshot: { value: string; mentionedPersons: PersonMention[] }) => void;
};
let {
initialValue = '',
initialMentions = [],
placeholder,
disabled = false,
onChange
}: Props = $props();
// initial* props seed mount-time state; reading them inside untrack signals
// the intentional one-shot capture and silences state_referenced_locally.
let value = $state(untrack(() => initialValue));
let mentionedPersons = $state<PersonMention[]>(untrack(() => [...initialMentions]));
$effect(() => {
onChange({ value, mentionedPersons: [...mentionedPersons] });
});
</script>
<PersonMentionEditor
bind:value={value}
bind:mentionedPersons={mentionedPersons}
placeholder={placeholder}
disabled={disabled}
/>