feat(search): render removable theme chips in InterpretationChipRow

When tagsApplied is true, each resolvedTag renders as a 'Thema: Name'
chip with optional inline color style from the tag's resolved color.
Clicking × calls onRemoveChip('theme', tag.name).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-06 23:33:53 +02:00
parent 847874abb3
commit 5387bc9247
2 changed files with 126 additions and 3 deletions

View File

@@ -6,6 +6,7 @@ import type { components } from '$lib/generated/api';
import type { ChipType } from './chip-types.js';
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
type TagHint = components['schemas']['TagHint'];
let {
interpretation,
@@ -19,7 +20,8 @@ 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 };
| { key: string; type: 'keyword'; value: string; label: string }
| { key: string; type: 'theme'; tag: TagHint; 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.
@@ -36,9 +38,22 @@ function dateRangeLabel(from: string | undefined, to: string | undefined): strin
return fromYear ?? toYear ?? '';
}
function tagColorStyle(color: string | undefined): string | undefined {
if (!color) return undefined;
return `background-color: var(--c-tag-${color}); border-left-color: var(--c-tag-${color})`;
}
const chips = $derived.by(() => {
const list: Chip[] = [];
const { resolvedPersons, dateFrom, dateTo, keywords, keywordsApplied } = interpretation;
const {
resolvedPersons,
dateFrom,
dateTo,
keywords,
keywordsApplied,
resolvedTags,
tagsApplied
} = interpretation;
if (resolvedPersons.length >= 2) {
list.push({
@@ -74,6 +89,17 @@ const chips = $derived.by(() => {
}
}
if (tagsApplied) {
for (const tag of resolvedTags) {
list.push({
key: 'theme:' + tag.id,
type: 'theme',
tag,
label: `${m.search_chip_theme_prefix()}: ${tag.name}`
});
}
}
return list.filter((chip) => !removed.has(chip.key));
});
@@ -83,7 +109,13 @@ const showKeywordsNotApplied = $derived(
function remove(chip: Chip) {
removed.add(chip.key);
onRemoveChip(chip.type, chip.type === 'keyword' ? chip.value : undefined);
if (chip.type === 'keyword') {
onRemoveChip(chip.type, chip.value);
} else if (chip.type === 'theme') {
onRemoveChip(chip.type, chip.tag.name);
} else {
onRemoveChip(chip.type, undefined);
}
}
const nameSpan = 'sm:max-w-[12rem] max-w-[8rem] truncate';
@@ -113,6 +145,21 @@ const removeButton =
<span aria-hidden="true">×</span>
</button>
</span>
{:else if chip.type === 'theme'}
<span data-chip-type="theme" class={chipWrapper} style={tagColorStyle(chip.tag.color)}>
<span>{m.search_chip_theme_prefix()}:</span>
<span class={nameSpan}>{chip.tag.name}</span>
<button
type="button"
class={removeButton}
aria-label={m.search_filter_remove_label({
label: `${m.search_chip_theme_prefix()}: ${chip.tag.name}`
})}
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>

View File

@@ -6,6 +6,7 @@ import type { components } from '$lib/generated/api';
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
type PersonHint = components['schemas']['PersonHint'];
type TagHint = components['schemas']['TagHint'];
afterEach(() => cleanup());
@@ -132,4 +133,79 @@ describe('InterpretationChipRow', () => {
.element(page.getByRole('button', { name: new RegExp('Absender') }))
.toBeInTheDocument();
});
// ── theme chips ─────────────────────────────────────────────────────────────
const makeTag = (id: string, name: string, color?: string): TagHint => ({ id, name, color });
it('renders theme chips when tagsApplied is true', async () => {
const { container } = render(InterpretationChipRow, {
interpretation: makeInterpretation({
resolvedTags: [makeTag('t1', 'Hochzeit')],
tagsApplied: true
}),
onRemoveChip: vi.fn()
});
expect(container.querySelectorAll('[data-chip-type="theme"]')).toHaveLength(1);
await expect.element(page.getByText(/Thema: Hochzeit/)).toBeInTheDocument();
});
it('renders no theme chips when tagsApplied is false', async () => {
const { container } = render(InterpretationChipRow, {
interpretation: makeInterpretation({
resolvedTags: [makeTag('t1', 'Hochzeit')],
tagsApplied: false
}),
onRemoveChip: vi.fn()
});
expect(container.querySelectorAll('[data-chip-type="theme"]')).toHaveLength(0);
});
it('renders exactly N theme chips for N resolved tags', async () => {
const { container } = render(InterpretationChipRow, {
interpretation: makeInterpretation({
resolvedTags: [makeTag('t1', 'Krieg'), makeTag('t2', 'Hochzeit'), makeTag('t3', 'Familie')],
tagsApplied: true
}),
onRemoveChip: vi.fn()
});
expect(container.querySelectorAll('[data-chip-type="theme"]')).toHaveLength(3);
});
it('calls onRemoveChip with "theme" and tag name when × is clicked', async () => {
const onRemoveChip = vi.fn();
render(InterpretationChipRow, {
interpretation: makeInterpretation({
resolvedTags: [makeTag('t1', 'Hochzeit')],
tagsApplied: true
}),
onRemoveChip
});
await page.getByRole('button', { name: /Thema: Hochzeit/ }).click();
expect(onRemoveChip).toHaveBeenCalledWith('theme', 'Hochzeit');
});
it('applies inline color style for a tag with a color', async () => {
const { container } = render(InterpretationChipRow, {
interpretation: makeInterpretation({
resolvedTags: [makeTag('t1', 'Hochzeit', 'sage')],
tagsApplied: true
}),
onRemoveChip: vi.fn()
});
const chip = container.querySelector('[data-chip-type="theme"]') as HTMLElement;
expect(chip.style.backgroundColor).toBeTruthy();
});
it('omits color style for a tag with no color', async () => {
const { container } = render(InterpretationChipRow, {
interpretation: makeInterpretation({
resolvedTags: [makeTag('t1', 'Hochzeit')],
tagsApplied: true
}),
onRemoveChip: vi.fn()
});
const chip = container.querySelector('[data-chip-type="theme"]') as HTMLElement;
expect(chip.getAttribute('style')).toBeFalsy();
});
});