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:
121
frontend/src/lib/document/TimelineDensityFilter.svelte
Normal file
121
frontend/src/lib/document/TimelineDensityFilter.svelte
Normal 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>
|
||||||
132
frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts
Normal file
132
frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user