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