feat(timeline): cap grouped clusters at 5 letters with a show-more toggle
Replaces the in-bucket month-density sparkline with a first-5 preview + show-more / show-less toggle, the agreed grouped-view pattern. Datum mode keeps the >12 YearLetterStrip. Refs #827
This commit is contained in:
@@ -1063,6 +1063,8 @@
|
|||||||
"timeline_grouping_multitag_hint": "Brief mit mehreren Tags erscheint unter seinem primären Tag.",
|
"timeline_grouping_multitag_hint": "Brief mit mehreren Tags erscheint unter seinem primären Tag.",
|
||||||
"timeline_bucket_other_letters": "Weitere Briefe",
|
"timeline_bucket_other_letters": "Weitere Briefe",
|
||||||
"timeline_bucket_no_topic": "Ohne Thema",
|
"timeline_bucket_no_topic": "Ohne Thema",
|
||||||
|
"timeline_bucket_show_more": "+ {count} weitere Briefe anzeigen",
|
||||||
|
"timeline_bucket_show_less": "Weniger anzeigen",
|
||||||
"timeline_provenance_derived": "abgeleitet",
|
"timeline_provenance_derived": "abgeleitet",
|
||||||
"timeline_provenance_curated": "kuratiert",
|
"timeline_provenance_curated": "kuratiert",
|
||||||
"timeline_letter_glyph_label": "Brief",
|
"timeline_letter_glyph_label": "Brief",
|
||||||
|
|||||||
@@ -1063,6 +1063,8 @@
|
|||||||
"timeline_grouping_multitag_hint": "A letter with several tags appears under its primary tag.",
|
"timeline_grouping_multitag_hint": "A letter with several tags appears under its primary tag.",
|
||||||
"timeline_bucket_other_letters": "More letters",
|
"timeline_bucket_other_letters": "More letters",
|
||||||
"timeline_bucket_no_topic": "No topic",
|
"timeline_bucket_no_topic": "No topic",
|
||||||
|
"timeline_bucket_show_more": "+ {count} more letters",
|
||||||
|
"timeline_bucket_show_less": "Show fewer",
|
||||||
"timeline_provenance_derived": "derived",
|
"timeline_provenance_derived": "derived",
|
||||||
"timeline_provenance_curated": "curated",
|
"timeline_provenance_curated": "curated",
|
||||||
"timeline_letter_glyph_label": "Letter",
|
"timeline_letter_glyph_label": "Letter",
|
||||||
|
|||||||
@@ -1063,6 +1063,8 @@
|
|||||||
"timeline_grouping_multitag_hint": "Una carta con varias etiquetas aparece bajo su etiqueta principal.",
|
"timeline_grouping_multitag_hint": "Una carta con varias etiquetas aparece bajo su etiqueta principal.",
|
||||||
"timeline_bucket_other_letters": "Más cartas",
|
"timeline_bucket_other_letters": "Más cartas",
|
||||||
"timeline_bucket_no_topic": "Sin tema",
|
"timeline_bucket_no_topic": "Sin tema",
|
||||||
|
"timeline_bucket_show_more": "+ {count} cartas más",
|
||||||
|
"timeline_bucket_show_less": "Mostrar menos",
|
||||||
"timeline_provenance_derived": "derivado",
|
"timeline_provenance_derived": "derivado",
|
||||||
"timeline_provenance_curated": "curado",
|
"timeline_provenance_curated": "curado",
|
||||||
"timeline_letter_glyph_label": "Carta",
|
"timeline_letter_glyph_label": "Carta",
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import LetterCard from './LetterCard.svelte';
|
import LetterCard from './LetterCard.svelte';
|
||||||
import BucketHeaderChip from './BucketHeaderChip.svelte';
|
import BucketHeaderChip from './BucketHeaderChip.svelte';
|
||||||
import YearLetterStrip from './YearLetterStrip.svelte';
|
|
||||||
import { entryKey } from './entryKey';
|
import { entryKey } from './entryKey';
|
||||||
import { isBucketDense, tagColorVar, type LetterBucket } from './timelineGrouping';
|
import { CLUSTER_PREVIEW, tagColorVar, type LetterBucket } from './timelineGrouping';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One cluster of loose letters, bound together by a coloured left rail so the group reads as a
|
* One cluster of loose letters, bound together by a coloured left rail so the group reads as a
|
||||||
@@ -17,18 +16,21 @@ import { isBucketDense, tagColorVar, type LetterBucket } from './timelineGroupin
|
|||||||
* header), a mint rail, `.lcard.ev` cards (REQ-003/014). The standalone "Weitere Briefe" /
|
* header), a mint rail, `.lcard.ev` cards (REQ-003/014). The standalone "Weitere Briefe" /
|
||||||
* "Ohne Thema" fallback keeps its label and a neutral rail (REQ-006/007).
|
* "Ohne Thema" fallback keeps its label and a neutral rail (REQ-006/007).
|
||||||
*
|
*
|
||||||
* A bucket larger than the density threshold collapses to the month-density `YearLetterStrip`
|
* A cluster shows its first `CLUSTER_PREVIEW` letters, then a show-more toggle reveals the rest
|
||||||
* instead of flooding the timeline with every card (#827) — the catch-all buckets are the biggest.
|
* instead of flooding the timeline with every card (#827 redesign).
|
||||||
*/
|
*/
|
||||||
let {
|
let {
|
||||||
bucket,
|
bucket,
|
||||||
mode,
|
mode,
|
||||||
|
// `year` is the band's year — accepted for the cross-year label card seam (#827) but no
|
||||||
|
// longer consumed here now the in-bucket month-density strip is gone (the year frames the
|
||||||
|
// time from the band heading). Kept in the prop contract for callers/tests.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
year = 0,
|
year = 0,
|
||||||
nested = false
|
nested = false
|
||||||
}: { bucket: LetterBucket; mode: 'event' | 'thema'; year?: number; nested?: boolean } = $props();
|
}: { bucket: LetterBucket; mode: 'event' | 'thema'; year?: number; nested?: boolean } = $props();
|
||||||
|
|
||||||
const count = $derived(bucket.letters.length);
|
const count = $derived(bucket.letters.length);
|
||||||
const dense = $derived(isBucketDense(count));
|
|
||||||
const fallbackLabel = $derived(
|
const fallbackLabel = $derived(
|
||||||
mode === 'event' ? m.timeline_bucket_other_letters() : m.timeline_bucket_no_topic()
|
mode === 'event' ? m.timeline_bucket_other_letters() : m.timeline_bucket_no_topic()
|
||||||
);
|
);
|
||||||
@@ -38,6 +40,12 @@ const railColor = $derived(bucket.kind === 'tag' ? tagColorVar(bucket.color) : n
|
|||||||
const railStyle = $derived(railColor ? `border-left-color: ${railColor}` : '');
|
const railStyle = $derived(railColor ? `border-left-color: ${railColor}` : '');
|
||||||
const isEventCluster = $derived(nested || bucket.kind === 'event');
|
const isEventCluster = $derived(nested || bucket.kind === 'event');
|
||||||
const cardVariant = $derived(mode === 'event' && isEventCluster ? 'event' : 'plain');
|
const cardVariant = $derived(mode === 'event' && isEventCluster ? 'event' : 'plain');
|
||||||
|
|
||||||
|
// First-5 preview + show-more (#827 redesign): a large cluster stays readable instead of
|
||||||
|
// dumping every card into the timeline.
|
||||||
|
let expanded = $state(false);
|
||||||
|
const visible = $derived(expanded ? bucket.letters : bucket.letters.slice(0, CLUSTER_PREVIEW));
|
||||||
|
const hiddenCount = $derived(bucket.letters.length - CLUSTER_PREVIEW);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -64,21 +72,28 @@ const cardVariant = $derived(mode === 'event' && isEventCluster ? 'event' : 'pla
|
|||||||
</header>
|
</header>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if dense}
|
<ul class="space-y-1.5">
|
||||||
<!-- Oversized bucket → the month-density strip (count + sparkline + expand), not a flood. -->
|
{#each visible as letter (entryKey(letter))}
|
||||||
<YearLetterStrip letters={bucket.letters} year={year} />
|
<li>
|
||||||
{:else}
|
<LetterCard
|
||||||
<ul class="space-y-1.5">
|
entry={letter}
|
||||||
{#each bucket.letters as letter (entryKey(letter))}
|
variant={cardVariant}
|
||||||
<li>
|
suppressTagChip={mode === 'thema'}
|
||||||
<LetterCard
|
compact={true}
|
||||||
entry={letter}
|
/>
|
||||||
variant={cardVariant}
|
</li>
|
||||||
suppressTagChip={mode === 'thema'}
|
{/each}
|
||||||
compact={true}
|
</ul>
|
||||||
/>
|
{#if hiddenCount > 0}
|
||||||
</li>
|
<button
|
||||||
{/each}
|
type="button"
|
||||||
</ul>
|
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}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, afterEach } from 'vitest';
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import LetterBucket from './LetterBucket.svelte';
|
import LetterBucket from './LetterBucket.svelte';
|
||||||
import { makeEntry } from './test-factories';
|
import { makeEntry } from './test-factories';
|
||||||
@@ -78,47 +79,49 @@ const manyLetters = (n: number) =>
|
|||||||
makeEntry({ documentId: `d${i}`, eventDate: `1916-0${(i % 9) + 1}-01` })
|
makeEntry({ documentId: `d${i}`, eventDate: `1916-0${(i % 9) + 1}-01` })
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('LetterBucket — density + containment (#827)', () => {
|
describe('LetterBucket — preview cap + show-more (#827 redesign)', () => {
|
||||||
it('collapses an oversized bucket to the density strip instead of flooding cards', () => {
|
it('shows only the first 5 letters with a show-more toggle when the cluster is larger', () => {
|
||||||
const bucket: Bucket = {
|
const bucket: Bucket = {
|
||||||
key: 'tag:t1',
|
key: 'tag:t1',
|
||||||
kind: 'tag',
|
kind: 'tag',
|
||||||
title: 'Sonstiges',
|
title: 'Krieg',
|
||||||
color: null,
|
color: 'sienna',
|
||||||
letters: manyLetters(10)
|
letters: manyLetters(8)
|
||||||
};
|
};
|
||||||
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||||
expect(document.querySelector('[data-testid="strip-expand"]')).not.toBeNull();
|
expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
|
||||||
// not ten individual cards dumped into the timeline
|
expect(document.querySelector('[data-testid="bucket-show-more"]')).not.toBeNull();
|
||||||
expect(document.querySelectorAll('a.lcard')).toHaveLength(0);
|
expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull(); // sparkline gone
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders compact cards for a small bucket (no strip)', () => {
|
it('expands to all letters and collapses back on toggle', async () => {
|
||||||
|
const bucket: Bucket = {
|
||||||
|
key: 'tag:t1',
|
||||||
|
kind: 'tag',
|
||||||
|
title: 'Krieg',
|
||||||
|
color: 'sienna',
|
||||||
|
letters: manyLetters(8)
|
||||||
|
};
|
||||||
|
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||||
|
(document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement).click();
|
||||||
|
await tick();
|
||||||
|
expect(document.querySelectorAll('a.lcard')).toHaveLength(8);
|
||||||
|
(document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement).click();
|
||||||
|
await tick();
|
||||||
|
expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows all letters and no toggle for a small cluster (<= 5)', () => {
|
||||||
const bucket: Bucket = {
|
const bucket: Bucket = {
|
||||||
key: 'tag:t1',
|
key: 'tag:t1',
|
||||||
kind: 'tag',
|
kind: 'tag',
|
||||||
title: 'Tod',
|
title: 'Tod',
|
||||||
color: null,
|
color: null,
|
||||||
letters: manyLetters(2)
|
letters: manyLetters(3)
|
||||||
};
|
};
|
||||||
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||||
expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull();
|
expect(document.querySelectorAll('a.lcard')).toHaveLength(3);
|
||||||
expect(document.querySelectorAll('a.lcard.compact')).toHaveLength(2);
|
expect(document.querySelector('[data-testid="bucket-show-more"]')).toBeNull();
|
||||||
});
|
|
||||||
|
|
||||||
it('omits the header when nested — the event pill above is the header', () => {
|
|
||||||
const bucket: Bucket = {
|
|
||||||
key: 'event:e1',
|
|
||||||
kind: 'event',
|
|
||||||
title: 'Ein gewaltiger Stadtbrand',
|
|
||||||
color: null,
|
|
||||||
letters: manyLetters(1)
|
|
||||||
};
|
|
||||||
render(LetterBucket, { bucket, mode: 'event', nested: true, year: 1916 });
|
|
||||||
expect(document.querySelector('[data-testid="bucket-count"]')).toBeNull();
|
|
||||||
expect(document.body.textContent).not.toContain('Ein gewaltiger Stadtbrand');
|
|
||||||
// still the event-letter variant, just headerless under its pill
|
|
||||||
expect(document.querySelector('a.lcard.ev')).not.toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('binds a tag bucket together with a coloured left rail from its token', () => {
|
it('binds a tag bucket together with a coloured left rail from its token', () => {
|
||||||
|
|||||||
@@ -14,17 +14,8 @@ export type GroupingMode = 'date' | 'event' | 'thema';
|
|||||||
/** The default mode — chronological, as #779 shipped. */
|
/** The default mode — chronological, as #779 shipped. */
|
||||||
export const DEFAULT_GROUPING: GroupingMode = 'date';
|
export const DEFAULT_GROUPING: GroupingMode = 'date';
|
||||||
|
|
||||||
/**
|
/** Letters shown before a cluster needs a "show more" toggle (#827 redesign). */
|
||||||
* A bucket larger than this collapses to a month-density strip instead of flooding the
|
export const CLUSTER_PREVIEW = 5;
|
||||||
* timeline with individual cards (#827) — the catch-all "Weitere Briefe"/"Ohne Thema"
|
|
||||||
* buckets are always the biggest, so without this they swamp the grouped view. Lower than
|
|
||||||
* Datum mode's `DENSE_THRESHOLD` (12) because a bucket is a narrower context than a year.
|
|
||||||
*/
|
|
||||||
export const BUCKET_DENSE_THRESHOLD = 6;
|
|
||||||
|
|
||||||
export function isBucketDense(letterCount: number): boolean {
|
|
||||||
return letterCount > BUCKET_DENSE_THRESHOLD;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The `--c-tag-*` colour-name tokens (sage/sienna/…); shared by the chip and the rail. */
|
/** The `--c-tag-*` colour-name tokens (sage/sienna/…); shared by the chip and the rail. */
|
||||||
const TAG_COLOR_TOKENS = new Set([
|
const TAG_COLOR_TOKENS = new Set([
|
||||||
|
|||||||
Reference in New Issue
Block a user