feat(search): add InterpretationChipRow component (#739)

Renders type-prefixed chips (Absender/Zeitraum/Stichwort), a single
directional chip for 2-name queries, gates keyword chips on
keywordsApplied, and emits onRemoveChip(type, value?). Truncating name
spans keep the 44px × button visible; chip wrappers show a focus ring.
9 vitest-browser-svelte specs (red/green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-06 17:38:51 +02:00
parent 9e425c98a1
commit 8ed65f8602
2 changed files with 266 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
<script lang="ts">
import { SvelteSet } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
type ChipType = 'sender' | 'directional' | 'date' | 'keyword';
let {
interpretation,
onRemoveChip
}: {
interpretation: NlQueryInterpretation;
onRemoveChip: (type: ChipType, value?: string) => void;
} = $props();
type Chip =
| { key: string; type: 'sender'; label: string }
| { key: string; type: 'directional'; from: string; to: string }
| { key: string; type: 'date'; label: string }
| { key: string; type: 'keyword'; value: string; label: string };
// Locally removed chips. The parent remounts this component (via {#key}) on every
// new NL search, so this set never needs an explicit reset.
const removed = new SvelteSet<string>();
function yearOf(iso: string | undefined): string | undefined {
return iso?.slice(0, 4);
}
function dateRangeLabel(from: string | undefined, to: string | undefined): string {
const fromYear = yearOf(from);
const toYear = yearOf(to);
if (fromYear && toYear) return fromYear === toYear ? fromYear : `${fromYear}${toYear}`;
return fromYear ?? toYear ?? '';
}
const chips = $derived.by(() => {
const list: Chip[] = [];
const { resolvedPersons, dateFrom, dateTo, keywords, keywordsApplied } = interpretation;
if (resolvedPersons.length >= 2) {
list.push({
key: 'directional',
type: 'directional',
from: resolvedPersons[0].displayName,
to: resolvedPersons[1].displayName
});
} else if (resolvedPersons.length === 1) {
list.push({
key: 'sender:' + resolvedPersons[0].id,
type: 'sender',
label: `${m.search_chip_sender()}: ${resolvedPersons[0].displayName}`
});
}
if (dateFrom || dateTo) {
list.push({
key: 'date',
type: 'date',
label: `${m.search_chip_date()}: ${dateRangeLabel(dateFrom, dateTo)}`
});
}
if (keywordsApplied) {
for (const keyword of keywords) {
list.push({
key: 'keyword:' + keyword,
type: 'keyword',
value: keyword,
label: `${m.search_chip_keyword()}: ${keyword}`
});
}
}
return list.filter((chip) => !removed.has(chip.key));
});
const showKeywordsNotApplied = $derived(
!interpretation.keywordsApplied && interpretation.keywords.length > 0
);
function remove(chip: Chip) {
removed.add(chip.key);
onRemoveChip(chip.type, chip.type === 'keyword' ? chip.value : undefined);
}
const nameSpan = 'sm:max-w-[12rem] max-w-[8rem] truncate';
const chipWrapper =
'inline-flex items-center gap-1.5 rounded-full border border-line bg-muted px-3 text-sm text-ink-2 focus-within:ring-2 focus-within:ring-brand-navy';
const removeButton =
'flex min-h-[44px] w-6 shrink-0 items-center justify-center text-ink-3 outline-none hover:text-red-500 focus-visible:ring-2 focus-visible:ring-brand-navy';
</script>
<div class="flex flex-wrap gap-2">
{#each chips as chip (chip.key)}
{#if chip.type === 'directional'}
<span
data-chip-type="directional"
class={chipWrapper}
aria-label={m.search_chip_directional_label({ from: chip.from, to: chip.to })}
>
<span class={nameSpan}>{chip.from}</span>
<span aria-hidden="true"></span>
<span class={nameSpan}>{chip.to}</span>
<button
type="button"
class={removeButton}
aria-label={m.search_filter_remove_label({ label: `${chip.from} ${chip.to}` })}
onclick={() => remove(chip)}
>
<span aria-hidden="true">×</span>
</button>
</span>
{:else}
<span data-chip-type={chip.type} class={chipWrapper}>
<span class={nameSpan}>{chip.label}</span>
<button
type="button"
class={removeButton}
aria-label={m.search_filter_remove_label({ label: chip.label })}
onclick={() => remove(chip)}
>
<span aria-hidden="true">×</span>
</button>
</span>
{/if}
{/each}
</div>
{#if showKeywordsNotApplied}
<p class="mt-2 text-xs text-ink-3">{m.smart_search_keywords_not_applied()}</p>
{/if}

View File

@@ -0,0 +1,133 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import InterpretationChipRow from './InterpretationChipRow.svelte';
import type { components } from '$lib/generated/api';
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
type PersonHint = components['schemas']['PersonHint'];
afterEach(() => cleanup());
const makePerson = (id: string, displayName: string): PersonHint => ({ id, displayName });
const makeInterpretation = (
overrides: Partial<NlQueryInterpretation> = {}
): NlQueryInterpretation => ({
resolvedPersons: [],
ambiguousPersons: [],
keywords: [],
rawQuery: 'test',
keywordsApplied: true,
...overrides
});
describe('InterpretationChipRow', () => {
it('renders type-prefixed labels for sender, date and keyword chips', async () => {
render(InterpretationChipRow, {
interpretation: makeInterpretation({
resolvedPersons: [makePerson('p1', 'Walter Raddatz')],
dateFrom: '1914-01-01',
dateTo: '1918-12-31',
keywords: ['krieg']
}),
onRemoveChip: vi.fn()
});
await expect.element(page.getByText('Absender: Walter Raddatz')).toBeInTheDocument();
await expect.element(page.getByText('Zeitraum: 19141918')).toBeInTheDocument();
await expect.element(page.getByText('Stichwort: krieg')).toBeInTheDocument();
});
it('calls onRemoveChip with "sender" when the sender chip × is clicked', async () => {
const onRemoveChip = vi.fn();
render(InterpretationChipRow, {
interpretation: makeInterpretation({
resolvedPersons: [makePerson('p1', 'Walter Raddatz')]
}),
onRemoveChip
});
await page.getByRole('button', { name: /Absender: Walter Raddatz/ }).click();
expect(onRemoveChip).toHaveBeenCalledWith('sender', undefined);
});
it('removes a chip from the DOM but keeps the rest when one × is clicked', async () => {
const { container } = render(InterpretationChipRow, {
interpretation: makeInterpretation({
resolvedPersons: [makePerson('p1', 'Walter Raddatz')],
dateFrom: '1914-01-01',
dateTo: '1918-12-31',
keywords: ['krieg']
}),
onRemoveChip: vi.fn()
});
expect(container.querySelectorAll('[data-chip-type]')).toHaveLength(3);
await page.getByRole('button', { name: /Absender/ }).click();
await vi.waitFor(() => expect(container.querySelectorAll('[data-chip-type]')).toHaveLength(2));
});
it('renders a single directional chip with an arrow for a 2-name query', async () => {
const { container } = render(InterpretationChipRow, {
interpretation: makeInterpretation({
resolvedPersons: [makePerson('p1', 'Walter Raddatz'), makePerson('p2', 'Emma Raddatz')]
}),
onRemoveChip: vi.fn()
});
expect(container.querySelectorAll('[data-chip-type="directional"]')).toHaveLength(1);
await expect.element(page.getByText(/→/)).toBeInTheDocument();
});
it('calls onRemoveChip with "directional" when the directional chip × is clicked', async () => {
const onRemoveChip = vi.fn();
render(InterpretationChipRow, {
interpretation: makeInterpretation({
resolvedPersons: [makePerson('p1', 'Walter Raddatz'), makePerson('p2', 'Emma Raddatz')]
}),
onRemoveChip
});
await page.getByRole('button', { name: /Walter Raddatz/ }).click();
expect(onRemoveChip).toHaveBeenCalledWith('directional', undefined);
});
it('does not render keyword chips when keywordsApplied is false', async () => {
const { container } = render(InterpretationChipRow, {
interpretation: makeInterpretation({
keywordsApplied: false,
keywords: ['krieg', 'brief']
}),
onRemoveChip: vi.fn()
});
expect(container.querySelectorAll('[data-chip-type="keyword"]')).toHaveLength(0);
});
it('renders no keyword chips when keywords is empty', async () => {
const { container } = render(InterpretationChipRow, {
interpretation: makeInterpretation({ keywordsApplied: true, keywords: [] }),
onRemoveChip: vi.fn()
});
expect(container.querySelectorAll('[data-chip-type="keyword"]')).toHaveLength(0);
});
it('renders exactly one keyword chip per keyword', async () => {
const { container } = render(InterpretationChipRow, {
interpretation: makeInterpretation({
keywordsApplied: true,
keywords: ['krieg', 'brief', 'front']
}),
onRemoveChip: vi.fn()
});
expect(container.querySelectorAll('[data-chip-type="keyword"]')).toHaveLength(3);
});
it('keeps the × button in the DOM when a display name is 100 characters', async () => {
const longName = 'W'.repeat(100);
render(InterpretationChipRow, {
interpretation: makeInterpretation({
resolvedPersons: [makePerson('p1', longName)]
}),
onRemoveChip: vi.fn()
});
await expect
.element(page.getByRole('button', { name: new RegExp('Absender') }))
.toBeInTheDocument();
});
});