feat(timeline): add the EventCluster card + show-more/less i18n
A curated event with linked letters renders as one contained card: the
event is the header (accent glyph, title, date · provenance, count, and a
curator edit link), its letters sit inside as compact .lcard.ev cards. The
first CLUSTER_PREVIEW (5) show, then a keyboard-operable show-more/less
toggle reveals the rest. A cross-year card (no event prop) gets a plain
'✉ title' text header with no edit link. Titles render through default
{...} escaping. Adds timeline_bucket_show_more/less keys to de/en/es.
Refs #850
This commit is contained in:
@@ -1052,6 +1052,8 @@
|
||||
"timeline_grouping_date": "Gruppierung: Datum",
|
||||
"timeline_provenance_derived": "abgeleitet",
|
||||
"timeline_provenance_curated": "kuratiert",
|
||||
"timeline_bucket_show_more": "+ {count} weitere Briefe anzeigen",
|
||||
"timeline_bucket_show_less": "Weniger anzeigen",
|
||||
"timeline_letter_glyph_label": "Brief",
|
||||
"timeline_tag_chip_label": "Thema",
|
||||
"timeline_layer_historical_suffix": "historisch",
|
||||
|
||||
@@ -1052,6 +1052,8 @@
|
||||
"timeline_grouping_date": "Grouping: Date",
|
||||
"timeline_provenance_derived": "derived",
|
||||
"timeline_provenance_curated": "curated",
|
||||
"timeline_bucket_show_more": "+ {count} more letters",
|
||||
"timeline_bucket_show_less": "Show fewer",
|
||||
"timeline_letter_glyph_label": "Letter",
|
||||
"timeline_tag_chip_label": "Topic",
|
||||
"timeline_layer_historical_suffix": "historical",
|
||||
|
||||
@@ -1052,6 +1052,8 @@
|
||||
"timeline_grouping_date": "Agrupación: Fecha",
|
||||
"timeline_provenance_derived": "derivado",
|
||||
"timeline_provenance_curated": "curado",
|
||||
"timeline_bucket_show_more": "+ {count} cartas más",
|
||||
"timeline_bucket_show_less": "Mostrar menos",
|
||||
"timeline_letter_glyph_label": "Carta",
|
||||
"timeline_tag_chip_label": "Tema",
|
||||
"timeline_layer_historical_suffix": "histórico",
|
||||
|
||||
140
frontend/src/lib/timeline/EventCluster.svelte
Normal file
140
frontend/src/lib/timeline/EventCluster.svelte
Normal file
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import LetterCard from './LetterCard.svelte';
|
||||
import { entryKey } from './entryKey';
|
||||
import { getAccentConfig } from './eventCardConfig';
|
||||
import { timelineDateLabel } from './dateLabel';
|
||||
import { CLUSTER_PREVIEW } from './eventClustering';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
|
||||
/**
|
||||
* A curated event with linked letters, rendered as one contained card: the event IS the card's
|
||||
* header (so its title reads once — never also as a floating pill, #850 REQ-002), and its letters
|
||||
* sit inside as compact `.lcard.ev` cards.
|
||||
*
|
||||
* - Same-year event (`event` given): the header carries the accent glyph + sr-only label, the
|
||||
* title, a `{date} · {kuratiert|abgeleitet}` subtitle, the letter count, and — for a curator on
|
||||
* a curated event — an edit link to `/zeitstrahl/events/{eventId}/edit` (REQ-002).
|
||||
* - Cross-year (`title` given, no `event`): a plain `✉ {title}` text header, no edit link, no pill
|
||||
* chrome — it holds that other year's linked letters (REQ-004).
|
||||
*
|
||||
* A card shows its first {@link CLUSTER_PREVIEW} letters, then a keyboard-operable show-more/less
|
||||
* toggle reveals/collapses the rest instead of flooding the timeline (REQ-003).
|
||||
*/
|
||||
let {
|
||||
letters,
|
||||
event = undefined,
|
||||
title = '',
|
||||
canWrite = false
|
||||
}: {
|
||||
letters: TimelineEntryDTO[];
|
||||
/** The same-year curated event whose letters this card holds — renders as the header. */
|
||||
event?: TimelineEntryDTO;
|
||||
/** Header label for a cross-year card (no `event`). */
|
||||
title?: string;
|
||||
canWrite?: boolean;
|
||||
} = $props();
|
||||
|
||||
const count = $derived(letters.length);
|
||||
|
||||
// Event-as-header: a same-year curated event renders as this card's header, mirroring EventPill —
|
||||
// glyph + title + date · provenance + an edit pencil for a curator. The title is never repeated
|
||||
// as a separate floating pill (REQ-002).
|
||||
const accent = $derived(event ? getAccentConfig(event) : null);
|
||||
const eventDateLabel = $derived(
|
||||
event ? timelineDateLabel(event.eventDate, event.precision, event.eventDateEnd) : null
|
||||
);
|
||||
const provenance = $derived(
|
||||
event?.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated()
|
||||
);
|
||||
const eventSubtitle = $derived(eventDateLabel ? `${eventDateLabel} · ${provenance}` : provenance);
|
||||
const canEdit = $derived(canWrite && !!event && !event.derived && event.eventId != null);
|
||||
|
||||
// First-5 preview + show-more (REQ-003): a large cluster stays readable instead of dumping every
|
||||
// card into the timeline.
|
||||
let expanded = $state(false);
|
||||
const visible = $derived(expanded ? letters : letters.slice(0, CLUSTER_PREVIEW));
|
||||
const hiddenCount = $derived(letters.length - CLUSTER_PREVIEW);
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="my-3 overflow-hidden rounded-md border border-l-2 border-line border-l-brand-mint bg-surface shadow-sm"
|
||||
data-testid="event-card"
|
||||
>
|
||||
{#if event && accent}
|
||||
<!-- A same-year curated event IS the card header — its title reads once here, never also
|
||||
as a floating pill (REQ-002). Glyph is aria-hidden with an sr-only label sibling; the
|
||||
edit pencil mirrors EventPill's gate (REQ-010). -->
|
||||
<header
|
||||
data-testid="event-header"
|
||||
class="flex items-center gap-2 border-b border-line bg-canvas px-3 py-2"
|
||||
>
|
||||
<span
|
||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full {accent.accent ===
|
||||
'curated'
|
||||
? 'bg-brand-mint text-brand-navy'
|
||||
: 'bg-brand-navy text-brand-mint'}"
|
||||
>
|
||||
<span aria-hidden="true">{accent.glyph}</span>
|
||||
<span class="sr-only">{accent.label}</span>
|
||||
</span>
|
||||
<span class="min-w-0 text-left">
|
||||
<span class="block font-serif text-sm font-bold whitespace-pre-line text-brand-navy"
|
||||
>{event.title}</span
|
||||
>
|
||||
<span class="block font-sans text-xs text-ink-3">
|
||||
{eventSubtitle} <span data-testid="event-count">· {count}</span>
|
||||
</span>
|
||||
</span>
|
||||
{#if canEdit}
|
||||
<a
|
||||
data-testid="event-edit"
|
||||
href="/zeitstrahl/events/{event.eventId}/edit"
|
||||
class="ml-auto rounded-sm px-1 font-sans text-xs text-ink-3 hover:text-brand-navy focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
<span aria-hidden="true">✎</span>
|
||||
<span class="sr-only">{m.btn_edit()}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</header>
|
||||
{:else}
|
||||
<!-- Cross-year card (REQ-004): the same event's letters in another year band get a plain
|
||||
✉ text header — no pill chrome, no edit link. -->
|
||||
<header
|
||||
data-testid="event-header"
|
||||
class="flex items-center gap-2 border-b border-line px-3 py-2"
|
||||
>
|
||||
<span class="font-serif text-sm font-bold whitespace-pre-line text-ink">
|
||||
<span aria-hidden="true">✉</span>
|
||||
{title}
|
||||
</span>
|
||||
<span data-testid="event-count" class="font-sans text-xs text-ink-3">· {count}</span>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<div class="px-3 py-2">
|
||||
<ul class="space-y-1.5">
|
||||
{#each visible as letter (entryKey(letter))}
|
||||
<li>
|
||||
<LetterCard entry={letter} variant="event" compact={true} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if hiddenCount > 0}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="bucket-show-more"
|
||||
aria-expanded={expanded}
|
||||
onclick={() => (expanded = !expanded)}
|
||||
style="display: inline-flex; align-items: center; min-height: 44px"
|
||||
class="mt-1 px-1 font-sans text-xs font-semibold text-brand-navy hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
{expanded
|
||||
? m.timeline_bucket_show_less()
|
||||
: m.timeline_bucket_show_more({ count: hiddenCount })}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
109
frontend/src/lib/timeline/EventCluster.svelte.spec.ts
Normal file
109
frontend/src/lib/timeline/EventCluster.svelte.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import EventCluster from './EventCluster.svelte';
|
||||
import { makeEntry } from './test-factories';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const EV_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||
|
||||
const makeEvent = (overrides: Partial<TimelineEntryDTO> = {}): TimelineEntryDTO =>
|
||||
makeEntry({
|
||||
kind: 'EVENT',
|
||||
type: 'PERSONAL',
|
||||
documentId: undefined,
|
||||
eventId: EV_ID,
|
||||
eventDate: '1916-07-06',
|
||||
precision: 'DAY',
|
||||
title: 'Ein gewaltiger Stadtbrand',
|
||||
...overrides
|
||||
});
|
||||
|
||||
const letters = (n: number): TimelineEntryDTO[] =>
|
||||
Array.from({ length: n }, (_, i) =>
|
||||
makeEntry({ kind: 'LETTER', documentId: `doc-${i}`, title: `Brief ${i}`, linkedEventId: EV_ID })
|
||||
);
|
||||
|
||||
describe('EventCluster — contained event card (#850)', () => {
|
||||
it('renders a data-testid event-card with the event title once (REQ-002)', () => {
|
||||
render(EventCluster, { letters: letters(2), event: makeEvent() });
|
||||
expect(document.querySelector('[data-testid="event-card"]')).not.toBeNull();
|
||||
const occurrences = (document.body.textContent?.match(/Ein gewaltiger Stadtbrand/g) ?? [])
|
||||
.length;
|
||||
expect(occurrences).toBe(1);
|
||||
});
|
||||
|
||||
it('shows the event-edit link for a curator on a curated event (REQ-002)', () => {
|
||||
render(EventCluster, { letters: letters(2), event: makeEvent(), canWrite: true });
|
||||
const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement;
|
||||
expect(edit).not.toBeNull();
|
||||
expect(edit.getAttribute('href')).toBe(`/zeitstrahl/events/${EV_ID}/edit`);
|
||||
});
|
||||
|
||||
it('hides the event-edit link when canWrite is false', () => {
|
||||
render(EventCluster, { letters: letters(2), event: makeEvent(), canWrite: false });
|
||||
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides the event-edit link for a derived event even with canWrite', () => {
|
||||
render(EventCluster, {
|
||||
letters: letters(2),
|
||||
event: makeEvent({ derived: true, eventId: undefined, derivedType: 'BIRTH' }),
|
||||
canWrite: true
|
||||
});
|
||||
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders its letters as compact a.lcard.ev cards (REQ-002)', () => {
|
||||
render(EventCluster, { letters: letters(2), event: makeEvent() });
|
||||
expect(document.querySelectorAll('a.lcard.ev').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows the first 5 of 8 letters + a show-more toggle that expands to 8 then back to 5 (REQ-003)', async () => {
|
||||
render(EventCluster, { letters: letters(8), event: makeEvent() });
|
||||
expect(document.querySelectorAll('a.lcard').length).toBe(5);
|
||||
const toggle = document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement;
|
||||
expect(toggle).not.toBeNull();
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(toggle.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
|
||||
|
||||
toggle.click();
|
||||
await tick();
|
||||
expect(document.querySelectorAll('a.lcard').length).toBe(8);
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true');
|
||||
|
||||
toggle.click();
|
||||
await tick();
|
||||
expect(document.querySelectorAll('a.lcard').length).toBe(5);
|
||||
});
|
||||
|
||||
it('renders no show-more toggle when the cluster holds 5 or fewer letters', () => {
|
||||
render(EventCluster, { letters: letters(5), event: makeEvent() });
|
||||
expect(document.querySelector('[data-testid="bucket-show-more"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a cross-year text header (✉ title, no event-edit, no pill) when no event is given (REQ-004)', () => {
|
||||
render(EventCluster, {
|
||||
letters: letters(2),
|
||||
title: 'Briefe von der Front',
|
||||
canWrite: true
|
||||
});
|
||||
expect(document.querySelector('[data-testid="event-card"]')).not.toBeNull();
|
||||
expect(document.body.textContent).toContain('✉');
|
||||
expect(document.body.textContent).toContain('Briefe von der Front');
|
||||
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders an HTML-bearing event title verbatim as text, never as markup (REQ-010)', () => {
|
||||
render(EventCluster, {
|
||||
letters: letters(1),
|
||||
event: makeEvent({ title: '<img src=x onerror=alert(1)>' })
|
||||
});
|
||||
expect(document.querySelector('[data-testid="event-card"] img')).toBeNull();
|
||||
expect(document.body.textContent).toContain('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user