refactor: move shared components to lib/shared/ sub-packages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
116
frontend/src/lib/shared/discussion/CommentMessage.svelte
Normal file
116
frontend/src/lib/shared/discussion/CommentMessage.svelte
Normal 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">
|
||||
“{parsed.quote}”
|
||||
</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>
|
||||
106
frontend/src/lib/shared/discussion/CommentMessage.svelte.spec.ts
Normal file
106
frontend/src/lib/shared/discussion/CommentMessage.svelte.spec.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
212
frontend/src/lib/shared/discussion/CommentThread.svelte
Normal file
212
frontend/src/lib/shared/discussion/CommentThread.svelte
Normal 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}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
170
frontend/src/lib/shared/discussion/MentionDropdown.svelte
Normal file
170
frontend/src/lib/shared/discussion/MentionDropdown.svelte
Normal 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>
|
||||
201
frontend/src/lib/shared/discussion/MentionEditor.svelte
Normal file
201
frontend/src/lib/shared/discussion/MentionEditor.svelte
Normal 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>
|
||||
295
frontend/src/lib/shared/discussion/PersonMentionEditor.svelte
Normal file
295
frontend/src/lib/shared/discussion/PersonMentionEditor.svelte
Normal 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>
|
||||
@@ -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]');
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
Reference in New Issue
Block a user