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:
44
frontend/src/lib/timeline/TagChip.svelte
Normal file
44
frontend/src/lib/timeline/TagChip.svelte
Normal 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>
|
||||
46
frontend/src/lib/timeline/TagChip.svelte.spec.ts
Normal file
46
frontend/src/lib/timeline/TagChip.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user