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 @@
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();
});
});