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:
133
frontend/src/routes/search/InterpretationChipRow.svelte
Normal file
133
frontend/src/routes/search/InterpretationChipRow.svelte
Normal 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}
|
||||||
133
frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts
Normal file
133
frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts
Normal 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: 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user