feat(search): render title highlights and transcription snippets in DocumentList

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-15 18:34:14 +02:00
parent 9673cefe44
commit 93c78433cf
2 changed files with 100 additions and 3 deletions

View File

@@ -4,6 +4,8 @@ import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import { groupDocuments } from '$lib/utils/groupDocuments';
import GroupDivider from '$lib/components/GroupDivider.svelte';
import { applyOffsets } from '$lib/search';
import type { components } from '$lib/generated/api';
let {
documents,
@@ -11,7 +13,8 @@ let {
error,
total = 0,
q = '',
sort
sort,
matchData = {}
}: {
documents: {
id: string;
@@ -28,6 +31,7 @@ let {
total?: number;
q?: string;
sort?: string;
matchData?: Record<string, components['schemas']['SearchMatchData']>;
} = $props();
const fallbackLabel = $derived(
@@ -75,6 +79,10 @@ const showDividers = $derived(groupedDocuments.length >= 2);
{/if}
<ul class="divide-y divide-line-2">
{#each group.documents as doc (doc.id)}
{@const titleText = doc.title || doc.originalFilename}
{@const titleOffsets = matchData?.[doc.id]?.titleOffsets ?? []}
{@const titleSegments = applyOffsets(titleText, titleOffsets)}
{@const snippet = matchData?.[doc.id]?.transcriptionSnippet}
<li class="group transition-colors duration-200 hover:bg-muted/50">
<a href="/documents/{doc.id}" class="block p-6">
<div class="flex flex-col gap-6 sm:flex-row">
@@ -82,7 +90,11 @@ const showDividers = $derived(groupedDocuments.length >= 2);
<div class="flex-1">
<div class="mb-2 flex items-baseline justify-between">
<h3 class="font-serif text-xl font-medium text-ink group-hover:underline">
{doc.title || doc.originalFilename}
{#each titleSegments as seg, i (i)}
{#if seg.highlight}<mark class="bg-accent/20 text-inherit not-italic"
>{seg.text}</mark
>{:else}{seg.text}{/if}
{/each}
</h3>
</div>
@@ -110,6 +122,15 @@ const showDividers = $derived(groupedDocuments.length >= 2);
{/if}
</div>
{#if snippet}
<p
data-testid="search-snippet"
class="mb-4 line-clamp-2 font-sans text-sm text-ink-2"
>
<span class="sr-only">Fundstelle: </span>{snippet}
</p>
{/if}
<!-- Sender/Receiver Info -->
<div class="grid grid-cols-1 gap-4 font-serif text-sm sm:grid-cols-2">
<div class="flex items-baseline">

View File

@@ -12,11 +12,16 @@ const baseProps = {
canWrite: false,
error: null,
total: 0,
q: ''
q: '',
matchData: {} as Record<
string,
import('$lib/generated/api').components['schemas']['SearchMatchData']
>
};
type DocOverrides = {
id?: string;
title?: string;
documentDate?: string | null;
sender?: { firstName?: string | null; lastName: string; displayName: string } | null;
receivers?: { firstName?: string | null; lastName: string; displayName: string }[];
@@ -115,3 +120,74 @@ describe('DocumentList group headers', () => {
await expect.element(links.nth(1)).toBeInTheDocument();
});
});
// ─── Match data: snippet and title highlighting ───────────────────────────────
describe('DocumentList match snippets and highlights', () => {
it('shows transcription snippet when matchData has one for the document', async () => {
const doc = makeDoc({ id: 'doc1' });
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
matchData: {
doc1: {
transcriptionSnippet: 'Er schrieb einen langen Brief',
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: []
}
}
});
await expect.element(page.getByText('Er schrieb einen langen Brief')).toBeInTheDocument();
});
it('does not show snippet section when matchData has no entry for the document', async () => {
const doc = makeDoc({ id: 'doc1' });
render(DocumentList, { ...baseProps, documents: [doc], total: 1, matchData: {} });
await expect.element(page.getByTestId('search-snippet')).not.toBeInTheDocument();
});
it('renders a <mark> element when titleOffsets are present', async () => {
const doc = makeDoc({ id: 'doc1', title: 'Brief an Anna' });
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
matchData: {
doc1: {
transcriptionSnippet: undefined,
titleOffsets: [{ start: 0, length: 5 }], // "Brief"
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: []
}
}
});
// The word "Brief" should be inside a <mark> element
const mark = page.getByRole('mark');
await expect.element(mark).toBeInTheDocument();
await expect.element(mark).toHaveTextContent('Brief');
});
it('renders title as plain text when titleOffsets is empty', async () => {
const doc = makeDoc({ id: 'doc1', title: 'Brief an Anna' });
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
matchData: {
doc1: {
transcriptionSnippet: undefined,
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: []
}
}
});
await expect.element(page.getByRole('mark')).not.toBeInTheDocument();
await expect.element(page.getByText('Brief an Anna')).toBeInTheDocument();
});
});