From 607112afc23f43c9d7b4c49f35678f4d2b663222 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 19:26:11 +0200 Subject: [PATCH] feat(shared): add Sparkline primitive for fixed-series density bars A minimal presentational bar series (one bar per value, heights scaled to the max, faint floor for empty buckets). Lives in shared so both the timeline density strip and the document chart can use it. REQ-012 (supports). Refs #779 Co-Authored-By: Claude Opus 4.8 --- .../lib/shared/primitives/Sparkline.svelte | 38 +++++++++++++++++++ .../primitives/Sparkline.svelte.spec.ts | 28 ++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 frontend/src/lib/shared/primitives/Sparkline.svelte create mode 100644 frontend/src/lib/shared/primitives/Sparkline.svelte.spec.ts diff --git a/frontend/src/lib/shared/primitives/Sparkline.svelte b/frontend/src/lib/shared/primitives/Sparkline.svelte new file mode 100644 index 00000000..2876d3e1 --- /dev/null +++ b/frontend/src/lib/shared/primitives/Sparkline.svelte @@ -0,0 +1,38 @@ + + + diff --git a/frontend/src/lib/shared/primitives/Sparkline.svelte.spec.ts b/frontend/src/lib/shared/primitives/Sparkline.svelte.spec.ts new file mode 100644 index 00000000..228302b0 --- /dev/null +++ b/frontend/src/lib/shared/primitives/Sparkline.svelte.spec.ts @@ -0,0 +1,28 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import Sparkline from './Sparkline.svelte'; + +afterEach(() => cleanup()); + +describe('Sparkline', () => { + it('renders one bar per value', () => { + render(Sparkline, { values: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] }); + const bars = document.querySelectorAll('[data-testid="sparkline-bar"]'); + expect(bars).toHaveLength(12); + }); + + it('scales bar heights relative to the largest value', () => { + render(Sparkline, { values: [5, 10, 0] }); + const bars = document.querySelectorAll('[data-testid="sparkline-bar"]'); + const h = (i: number) => parseFloat(bars[i].style.height); + // 10 is the max → tallest; 5 is half of the max's height; 0 is the shortest. + expect(h(1)).toBeGreaterThan(h(0)); + expect(h(0)).toBeGreaterThan(h(2)); + }); + + it('exposes an accessible label when provided', () => { + render(Sparkline, { values: [1, 2, 3], label: 'Monatsdichte' }); + const img = document.querySelector('[role="img"]'); + expect(img?.getAttribute('aria-label')).toBe('Monatsdichte'); + }); +});