From 8ed65f860221523df1850eb2671b51914bf18632 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 17:38:51 +0200 Subject: [PATCH] feat(search): add InterpretationChipRow component (#739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../search/InterpretationChipRow.svelte | 133 ++++++++++++++++++ .../InterpretationChipRow.svelte.spec.ts | 133 ++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 frontend/src/routes/search/InterpretationChipRow.svelte create mode 100644 frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts diff --git a/frontend/src/routes/search/InterpretationChipRow.svelte b/frontend/src/routes/search/InterpretationChipRow.svelte new file mode 100644 index 00000000..8e9cdbc0 --- /dev/null +++ b/frontend/src/routes/search/InterpretationChipRow.svelte @@ -0,0 +1,133 @@ + + +
+ {#each chips as chip (chip.key)} + {#if chip.type === 'directional'} + + {chip.from} + + {chip.to} + + + {:else} + + {chip.label} + + + {/if} + {/each} +
+ +{#if showKeywordsNotApplied} +

{m.smart_search_keywords_not_applied()}

+{/if} diff --git a/frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts b/frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts new file mode 100644 index 00000000..f3164389 --- /dev/null +++ b/frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts @@ -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 => ({ + 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: 1914–1918')).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(); + }); +});