From d43d73f231f3666c9b5239f9666274907d6e03ac Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 22:13:58 +0200 Subject: [PATCH] feat(documents): add TimelineDensityFilter component (#385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 + {/each} + + + {#if hasSelection} + + {/if} + +{/if} + + diff --git a/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts b/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts new file mode 100644 index 00000000..5d86ae09 --- /dev/null +++ b/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts @@ -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 = {}) { + 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; + 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; + 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