feat(documents): add TimelineDensityFilter component (#385)

Density timeline widget: one bar per month within minDate/maxDate,
proportional heights, click-to-select-month with onchange callback,
and a clear button when a selection is active.

Notable details:
- Hidden entirely when density is null (mobile / calendar view; +page.ts
  controls the gating).
- Zero-count months render at 2 px so the time axis stays readable
  (Leonie's design intent overrides AC's literal "no bar" wording).
- Component-scoped --timeline-bar-idle CSS var for the dim idle color
  (light: mint-tinted rgba; dark: structural navy #0d3358 — meets
  WCAG 1.4.11 3:1 against surface, unlike the spec's #0E2535).
- Clear button is a real <button> with aria-label per Nora's a11y note.
- Bars are <button>s with aria-pressed selection state.
- Drag-range, tooltip, and year-tick labels are deferred for follow-ups —
  the AC-required behaviours (click filter, clear, AND-with-other-filters)
  are all in.

11 vitest-browser tests cover visibility gating, bar rendering with
gap-fill, zero-height floor, and selection/clear callback paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-07 22:13:58 +02:00
parent ad82f2e1e2
commit d43d73f231
2 changed files with 253 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { fillDensityGaps, monthBoundaryFrom, monthBoundaryTo } from '$lib/document/timeline';
import type { components } from '$lib/generated/api';
type MonthBucket = components['schemas']['MonthBucket'];
type SelectionEvent = { from: string; to: string };
const BAR_AREA_HEIGHT = 80; // px — Leonie spec h-20
const ZERO_COUNT_BAR_HEIGHT = 2; // px — minimum visible signal for empty months
let {
density,
minDate,
maxDate,
from,
to,
onchange
}: {
density: MonthBucket[] | null;
minDate: string | null;
maxDate: string | null;
from: string;
to: string;
onchange: (event: SelectionEvent) => void;
} = $props();
const filled = $derived.by(() => {
if (density === null) return [];
return fillDensityGaps(density, minDate, maxDate);
});
const maxCount = $derived(Math.max(...filled.map((b) => b.count), 1));
const hasSelection = $derived(from !== '' || to !== '');
function barHeight(count: number): number {
if (count === 0) return ZERO_COUNT_BAR_HEIGHT;
return Math.max(ZERO_COUNT_BAR_HEIGHT, (count / maxCount) * BAR_AREA_HEIGHT);
}
function selectMonth(month: string) {
onchange({ from: monthBoundaryFrom(month), to: monthBoundaryTo(month) });
}
function clearSelection() {
onchange({ from: '', to: '' });
}
function isSelected(month: string): boolean {
if (!hasSelection) return false;
const monthFrom = monthBoundaryFrom(month);
return monthFrom >= from && monthFrom <= to;
}
</script>
{#if density !== null}
<div
data-testid="timeline-density-filter"
role="group"
aria-label={m.timeline_aria_label()}
class="relative rounded-sm border border-line bg-surface px-3 pt-3 pb-2 shadow-sm"
>
<div class="flex h-20 items-end gap-px" style="height: {BAR_AREA_HEIGHT}px;">
{#each filled as bucket (bucket.month)}
<button
type="button"
data-testid="timeline-bar"
aria-label="{bucket.month} · {bucket.count}"
aria-pressed={isSelected(bucket.month)}
onclick={() => selectMonth(bucket.month)}
class="bar group flex flex-1 cursor-pointer items-end justify-center bg-transparent p-0 transition-colors"
class:selected={isSelected(bucket.month)}
>
<span
class="bar-fill block w-full rounded-t-[2px]"
style="height: {barHeight(bucket.count)}px;"
></span>
</button>
{/each}
</div>
{#if hasSelection}
<button
type="button"
data-testid="timeline-clear"
aria-label={m.timeline_clear_selection()}
onclick={clearSelection}
class="hover:text-ink-1 absolute top-2 right-2 inline-flex h-6 w-6 items-center justify-center rounded-full text-ink-3 hover:bg-canvas"
>
×
</button>
{/if}
</div>
{/if}
<style>
:root {
--timeline-bar-idle: rgba(161, 220, 216, 0.35);
--timeline-bar-outside: var(--c-line);
}
:global(.dark) {
--timeline-bar-idle: #0d3358;
--timeline-bar-outside: #1a2735;
}
.bar .bar-fill {
background-color: var(--timeline-bar-idle);
transition: background-color 100ms ease;
}
.bar.selected .bar-fill {
background-color: var(--palette-mint, #a1dcd8);
}
.bar:hover .bar-fill {
background-color: var(--palette-mint, #a1dcd8);
opacity: 0.85;
}
</style>

View File

@@ -0,0 +1,132 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TimelineDensityFilter from './TimelineDensityFilter.svelte';
import type { components } from '$lib/generated/api';
type MonthBucket = components['schemas']['MonthBucket'];
afterEach(() => cleanup());
const NOOP = () => undefined;
function makeProps(overrides: Record<string, unknown> = {}) {
return {
density: [
{ month: '1915-08', count: 5 },
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
] satisfies MonthBucket[],
minDate: '1915-08-01',
maxDate: '1915-10-31',
from: '',
to: '',
onchange: NOOP,
...overrides
};
}
describe('TimelineDensityFilter — visibility', () => {
it('renders nothing when density is null', async () => {
render(TimelineDensityFilter, makeProps({ density: null, minDate: null, maxDate: null }));
expect(document.querySelector('[data-testid="timeline-density-filter"]')).toBeNull();
});
it('renders the widget when density is populated', async () => {
render(TimelineDensityFilter, makeProps());
await expect.element(page.getByTestId('timeline-density-filter')).toBeInTheDocument();
});
it('exposes an accessible group label on the widget', async () => {
render(TimelineDensityFilter, makeProps());
const widget = document.querySelector('[data-testid="timeline-density-filter"]') as HTMLElement;
expect(widget.getAttribute('role')).toBe('group');
expect(widget.getAttribute('aria-label')).toBeTruthy();
});
});
describe('TimelineDensityFilter — bars', () => {
it('renders one bar per month within the range, including zero-count gaps', async () => {
render(
TimelineDensityFilter,
makeProps({
density: [
{ month: '1915-08', count: 5 },
{ month: '1915-10', count: 8 }
],
minDate: '1915-08-01',
maxDate: '1915-10-31'
})
);
const bars = document.querySelectorAll('[data-testid="timeline-bar"]');
expect(bars.length).toBe(3);
});
it('zero-count months get the minimum visible bar height of 2px', async () => {
render(
TimelineDensityFilter,
makeProps({
density: [{ month: '1915-08', count: 4 }],
minDate: '1915-08-01',
maxDate: '1915-09-30'
})
);
const bars = document.querySelectorAll(
'[data-testid="timeline-bar"] .bar-fill'
) as NodeListOf<HTMLElement>;
expect(bars.length).toBe(2);
expect(bars[1].style.height).toBe('2px');
});
it('renders an empty widget without crashing when density is empty array and no range', async () => {
render(TimelineDensityFilter, makeProps({ density: [], minDate: null, maxDate: null }));
await expect.element(page.getByTestId('timeline-density-filter')).toBeInTheDocument();
expect(document.querySelectorAll('[data-testid="timeline-bar"]').length).toBe(0);
});
});
describe('TimelineDensityFilter — selection', () => {
it('clicking a bar emits the boundary dates of that month via onchange', async () => {
const onchange = vi.fn();
render(TimelineDensityFilter, makeProps({ onchange }));
const bars = document.querySelectorAll(
'[data-testid="timeline-bar"]'
) as NodeListOf<HTMLElement>;
bars[0].dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onchange).toHaveBeenCalledTimes(1);
expect(onchange).toHaveBeenCalledWith({ from: '1915-08-01', to: '1915-08-31' });
});
it('shows a clear button when from/to are set', async () => {
render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30' }));
await expect.element(page.getByTestId('timeline-clear')).toBeInTheDocument();
});
it('does not show the clear button when from/to are empty', async () => {
render(TimelineDensityFilter, makeProps());
expect(document.querySelector('[data-testid="timeline-clear"]')).toBeNull();
});
it('clicking the clear button emits empty dates via onchange', async () => {
const onchange = vi.fn();
render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30', onchange }));
const clearBtn = document.querySelector('[data-testid="timeline-clear"]') as HTMLButtonElement;
clearBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onchange).toHaveBeenCalledTimes(1);
expect(onchange).toHaveBeenCalledWith({ from: '', to: '' });
});
it('clear button is a real <button> with aria-label (Nora a11y review)', async () => {
render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30' }));
const clearBtn = document.querySelector('[data-testid="timeline-clear"]') as HTMLElement;
expect(clearBtn.tagName).toBe('BUTTON');
expect(clearBtn.getAttribute('aria-label')).toBeTruthy();
});
});