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