feat(timeline): add the root-tag chip component

TagChip renders a letter's primary root tag as a small rounded pill — a
decorative aria-hidden colored square (var(--c-tag-{token}), neutral when the
color is null) plus the escaped tag name, prefixed by the sr-only theme label
so color is never the only cue. Truncation is set inline so a long name
ellipsizes without forcing the card into horizontal scroll, and the full name
stays reachable via the chip title. Timeline-local by design — lib/timeline may
not import lib/tag (eslint boundary).

Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-14 15:08:51 +02:00
parent 90e2b4d6c2
commit c19d4be3fe
2 changed files with 90 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
/**
* A single root-tag chip on a timeline letter card (§3 of the Zeitstrahl spec): a
* decorative colored square marker plus the tag name, prefixed for screen readers by
* an sr-only theme label so color is never the only cue (WCAG 1.4.1, REQ-011). The
* name is curator/import-derived and rendered via default `{...}` escaping — never
* `{@html}` (REQ-010). `color` is a `--c-tag-*` token or null; a null color renders a
* neutral marker with no `var(--c-tag-)` reference (REQ-007). Truncation is set inline
* (not via a utility class) so a long name ellipsizes even before the stylesheet loads,
* keeping the card free of horizontal overflow at 320px (REQ-008a).
*/
let { name, color }: { name: string; color: string | null } = $props();
const squareStyle = $derived(color ? `background-color: var(--c-tag-${color})` : '');
</script>
<span
data-testid="tag-chip"
title={name}
style="display: inline-flex; align-items: center; gap: 4px; max-width: 100%; min-width: 0"
class="mt-1 self-start rounded-full border border-line bg-surface px-2 py-0.5"
>
<span class="sr-only">{m.timeline_tag_chip_label()}: </span>
{#if color}
<span
data-testid="tag-chip-square"
aria-hidden="true"
style={squareStyle}
class="inline-block h-2 w-2 flex-shrink-0 rounded-sm"
></span>
{:else}
<span
data-testid="tag-chip-square"
aria-hidden="true"
class="inline-block h-2 w-2 flex-shrink-0 rounded-sm bg-ink-3"
></span>
{/if}
<span
style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0"
class="truncate font-sans text-[11px] text-ink-2">{name}</span
>
</span>

View File

@@ -0,0 +1,46 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import TagChip from './TagChip.svelte';
afterEach(() => cleanup());
describe('TagChip', () => {
it('renders the tag name (REQ-008)', () => {
render(TagChip, { name: 'Familie', color: 'sage' });
expect(document.body.textContent).toContain('Familie');
});
it('prefixes the name with an sr-only theme label and a decorative square (REQ-011)', () => {
render(TagChip, { name: 'Krieg', color: 'sienna' });
const srOnly = document.querySelector('.sr-only');
expect(srOnly?.textContent).toContain(m.timeline_tag_chip_label());
const square = document.querySelector('[data-testid="tag-chip-square"]');
expect(square?.getAttribute('aria-hidden')).toBe('true');
});
it('applies the color via var(--c-tag-{token}), never raw hex (REQ-009)', () => {
render(TagChip, { name: 'Krieg', color: 'sienna' });
const square = document.querySelector('[data-testid="tag-chip-square"]') as HTMLElement;
expect(square.getAttribute('style')).toContain('var(--c-tag-sienna)');
});
it('renders a neutral chip with no --c-tag- binding when color is null (REQ-007)', () => {
render(TagChip, { name: 'Allgemein', color: null });
expect(document.body.textContent).toContain('Allgemein');
expect(document.body.innerHTML).not.toContain('var(--c-tag-');
});
it('exposes the full name as the chip title so a truncated name stays reachable (REQ-008a)', () => {
render(TagChip, { name: 'Briefe von der Front', color: 'sienna' });
const chip = document.querySelector('[data-testid="tag-chip"]') as HTMLElement;
expect(chip.getAttribute('title')).toBe('Briefe von der Front');
});
it('renders an HTML-bearing name as inert text, never markup (REQ-010)', () => {
const evil = '<img src=x onerror="alert(1)">';
render(TagChip, { name: evil, color: null });
expect(document.body.textContent).toContain(evil);
expect(document.querySelector('img')).toBeNull();
});
});