feat(timeline): add WorldBand for HISTORICAL context bands

Full-width muted band; RANGE renders a span pill (1914–1918) with a Zeitraum
aria-label (REQ-009); a RANGE with no end degrades to the start year, no pill,
no crash (REQ-010). World glyph is a redundant non-color cue with sr-only label
(REQ-018); text uses text-ink-2 to hold AA in both themes (REQ-019).

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-13 19:36:21 +02:00
parent b031f2736b
commit e75448ba14
2 changed files with 91 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getAccentConfig } from './eventCardConfig';
import { timelineDateLabel } from './dateLabel';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* Full-width muted band for a HISTORICAL event, laid across the axis as context
* (REQ-009). A RANGE carries a visible span pill ("19141918") with a Zeitraum
* aria-label; a RANGE with no end degrades to the start year, no pill (REQ-010).
* The glyph is a redundant non-color cue with an sr-only label (REQ-018); text
* uses text-ink-2 to stay AA in both themes (REQ-019).
*/
let { entry }: { entry: TimelineEntryDTO } = $props();
const config = $derived(getAccentConfig(entry));
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
const fromYear = $derived(entry.eventDate ? entry.eventDate.slice(0, 4) : null);
const toYear = $derived(entry.eventDateEnd ? entry.eventDateEnd.slice(0, 4) : null);
const showSpan = $derived(entry.precision === 'RANGE' && fromYear != null && toYear != null);
const dateText = $derived(showSpan ? null : entry.precision === 'RANGE' ? fromYear : dateLabel);
</script>
<div class="my-3 border-y border-line bg-canvas px-4 py-2 text-center">
<span class="font-serif text-sm text-ink-2 italic">
<span aria-hidden="true" style="color: var(--c-tag-slate)">{config.glyph}</span>
<span class="sr-only">{config.label}</span>
{entry.title}
</span>
{#if showSpan && fromYear && toYear}
<span
data-testid="world-range"
class="ml-2 inline-block rounded-full border border-line px-2 py-0.5 font-sans text-xs text-ink-2"
aria-label={m.timeline_range_aria({ from: fromYear, to: toYear })}
>
{fromYear}{toYear}
</span>
{:else if dateText}
<span class="ml-2 font-sans text-xs text-ink-3">{dateText}</span>
{/if}
</div>

View File

@@ -0,0 +1,48 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import WorldBand from './WorldBand.svelte';
import { makeEntry } from './test-factories';
afterEach(() => cleanup());
function historical(overrides = {}) {
return makeEntry({
kind: 'EVENT',
derived: false,
type: 'HISTORICAL',
title: 'Erster Weltkrieg',
senderName: '',
receiverName: '',
precision: 'RANGE',
eventDate: '1914-01-01',
eventDateEnd: '1918-12-31',
documentId: undefined,
...overrides
});
}
describe('WorldBand', () => {
it('renders the historical title with the world glyph + "Weltgeschehen" cue (REQ-018)', () => {
render(WorldBand, { entry: historical() });
expect(document.body.textContent).toContain('Erster Weltkrieg');
const hidden = document.querySelector('[aria-hidden="true"]');
expect(hidden?.textContent).toBe('◍');
const srOnly = document.querySelector('.sr-only');
expect(srOnly?.textContent).toBe('Weltgeschehen');
});
it('renders a RANGE span pill 19141918 with a Zeitraum aria-label (REQ-009)', () => {
render(WorldBand, { entry: historical() });
const pill = document.querySelector('[data-testid="world-range"]');
expect(pill).not.toBeNull();
expect(pill?.textContent).toContain('19141918');
expect(pill?.getAttribute('aria-label')).toBe('Zeitraum: 1914 bis 1918');
});
it('degrades a RANGE with no end to the start year, no span pill, no crash (REQ-010)', () => {
render(WorldBand, { entry: historical({ eventDateEnd: undefined }) });
expect(document.querySelector('[data-testid="world-range"]')).toBeNull();
expect(document.body.textContent).toContain('Erster Weltkrieg');
expect(document.body.textContent).toContain('1914');
});
});