refactor(briefwechsel): extract TagChipList from ThumbnailRow

Lifts the three-chip-plus-"+N" tag row out of ThumbnailRow into a
standalone TagChipList component so the chip cap + overflow policy
lives in one place and can be reused on other surfaces (document
detail header is a candidate). ThumbnailRow drops from 110 to ~90
lines and no longer owns tag-slicing logic — it just asks for the
list with max=3.

Behavior is byte-identical: same data-testid, same max cap, same
"+N" overflow indicator. All ThumbnailRow row-level tag tests
continue to pass against the new composition.

Refs #305
Fixes @felixbrandt suggestion 1 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-23 20:43:04 +02:00
parent 78caac8d1a
commit 3b3b551d84
3 changed files with 59 additions and 16 deletions

View File

@@ -0,0 +1,23 @@
<script lang="ts">
type Tag = { id: string; name: string };
let { tags, max }: { tags: Tag[]; max: number } = $props();
const displayedTags = $derived(tags.slice(0, max));
const hiddenTagCount = $derived(Math.max(0, tags.length - max));
</script>
{#if tags.length > 0}
<div class="flex flex-wrap items-center gap-1 pt-0.5">
{#each displayedTags as tag (tag.id)}
<span
data-testid="thumb-row-tag"
class="max-w-[140px] truncate rounded-full border border-line bg-surface px-2 py-0.5 text-xs text-ink-2"
>{tag.name}</span
>
{/each}
{#if hiddenTagCount > 0}
<span class="text-xs font-bold text-ink-3">+{hiddenTagCount}</span>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,34 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import TagChipList from './TagChipList.svelte';
afterEach(() => {
cleanup();
});
const makeTags = (n: number) =>
Array.from({ length: n }, (_, i) => ({ id: `t${i}`, name: `Tag${i}` }));
describe('TagChipList', () => {
it('renders all tags as chips when under the cap', () => {
render(TagChipList, { tags: makeTags(2), max: 3 });
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
expect(chips).toHaveLength(2);
expect(document.body.textContent).not.toMatch(/\+/);
});
it('caps visible chips at max and renders +N for the remainder', () => {
render(TagChipList, { tags: makeTags(5), max: 3 });
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
expect(chips).toHaveLength(3);
expect(document.body.textContent).toMatch(/\+2/);
});
it('renders nothing when tags is empty', () => {
render(TagChipList, { tags: [], max: 3 });
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
expect(chips).toHaveLength(0);
expect(document.body.textContent).not.toMatch(/\+/);
});
});

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import ConversationThumbnail from '$lib/components/ConversationThumbnail.svelte';
import TagChipList from '$lib/components/TagChipList.svelte';
import { formatDate } from '$lib/utils/date';
import { relativeYearsDe } from '$lib/relativeTime';
@@ -36,8 +37,6 @@ let {
} = $props();
const title = $derived(doc.title || doc.originalFilename);
const displayedTags = $derived((doc.tags ?? []).slice(0, 3));
const hiddenTagCount = $derived(Math.max(0, (doc.tags ?? []).length - 3));
const otherPartyName = $derived(
showOtherParty
? isOut
@@ -91,19 +90,6 @@ const ariaLabel = $derived(
{/if}
</div>
{#if displayedTags.length > 0}
<div class="flex flex-wrap items-center gap-1 pt-0.5">
{#each displayedTags as tag (tag.id)}
<span
data-testid="thumb-row-tag"
class="max-w-[140px] truncate rounded-full border border-line bg-surface px-2 py-0.5 text-xs text-ink-2"
>{tag.name}</span
>
{/each}
{#if hiddenTagCount > 0}
<span class="text-xs font-bold text-ink-3">+{hiddenTagCount}</span>
{/if}
</div>
{/if}
<TagChipList tags={doc.tags ?? []} max={3} />
</div>
</a>