From bc22b2d4c95ff7a692555109c6a6d3893b5ce7b5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 10:48:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(timeline):=20add=20the=20Datum=C2=B7Ereign?= =?UTF-8?q?is=C2=B7Thema=20grouping=20control?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ARIA radiogroup with roving tabindex (#827, REQ-010/011/018): three arrow-key-navigable ≥44px segments with text labels, full-word aria-labels and ≤360px abbreviations, semantic-token colours that hold contrast in dark mode, defaulting to Datum. When disabled it stays in place, retains its selection, and announces a screen-reader reason — deliberately distinct from #780's aria-pressed layer toggles. Refs #827 Co-Authored-By: Claude Opus 4.8 --- .../src/lib/timeline/GroupingControl.svelte | 114 ++++++++++++++++++ .../timeline/GroupingControl.svelte.spec.ts | 106 ++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 frontend/src/lib/timeline/GroupingControl.svelte create mode 100644 frontend/src/lib/timeline/GroupingControl.svelte.spec.ts diff --git a/frontend/src/lib/timeline/GroupingControl.svelte b/frontend/src/lib/timeline/GroupingControl.svelte new file mode 100644 index 00000000..681c289d --- /dev/null +++ b/frontend/src/lib/timeline/GroupingControl.svelte @@ -0,0 +1,114 @@ + + +
+ {#each segments as segment (segment.value)} + + {/each} +
+{#if disabled} + {m.timeline_grouping_disabled_reason()} +{/if} + + diff --git a/frontend/src/lib/timeline/GroupingControl.svelte.spec.ts b/frontend/src/lib/timeline/GroupingControl.svelte.spec.ts new file mode 100644 index 00000000..6516881d --- /dev/null +++ b/frontend/src/lib/timeline/GroupingControl.svelte.spec.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { tick } from 'svelte'; +import * as m from '$lib/paraglide/messages.js'; +import GroupingControl from './GroupingControl.svelte'; + +afterEach(() => cleanup()); + +const radios = () => Array.from(document.querySelectorAll('[role="radio"]')) as HTMLElement[]; +const group = () => document.querySelector('[role="radiogroup"]') as HTMLElement; +const checkedValue = () => + radios() + .find((r) => r.getAttribute('aria-checked') === 'true') + ?.getAttribute('data-value'); + +describe('GroupingControl (REQ-010)', () => { + it('renders three radios inside a radiogroup, each with aria-checked (a)', () => { + render(GroupingControl, {}); + expect(group()).not.toBeNull(); + const r = radios(); + expect(r).toHaveLength(3); + r.forEach((radio) => expect(radio.hasAttribute('aria-checked')).toBe(true)); + }); + + it('defaults to Datum (f)', () => { + render(GroupingControl, {}); + expect(radios().filter((r) => r.getAttribute('aria-checked') === 'true')).toHaveLength(1); + expect(checkedValue()).toBe('date'); + }); + + it('exposes a text label on every segment, not colour alone (d)', () => { + render(GroupingControl, {}); + radios().forEach((r) => expect((r.textContent ?? '').trim().length).toBeGreaterThan(0)); + }); + + it('gives the radiogroup an accessible name (e)', () => { + render(GroupingControl, {}); + expect(group().getAttribute('aria-label')).toBe(m.timeline_grouping_aria_label()); + }); + + it('each segment has a tap target of at least 44×44px (c)', () => { + render(GroupingControl, {}); + radios().forEach((r) => { + const rect = r.getBoundingClientRect(); + expect(rect.width).toBeGreaterThanOrEqual(44); + expect(rect.height).toBeGreaterThanOrEqual(44); + }); + }); + + it('exposes each segment full word as an aria-label (REQ-011)', () => { + render(GroupingControl, {}); + const labels = radios().map((r) => r.getAttribute('aria-label')); + expect(labels).toEqual([ + m.timeline_grouping_segment_date(), + m.timeline_grouping_segment_event(), + m.timeline_grouping_segment_thema() + ]); + }); + + it('moves the selection forward with the right arrow key (b)', async () => { + render(GroupingControl, { mode: 'date' }); + group().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + await tick(); + expect(checkedValue()).toBe('event'); + }); + + it('wraps to the last segment with the left arrow from Datum (b)', async () => { + render(GroupingControl, { mode: 'date' }); + group().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + await tick(); + expect(checkedValue()).toBe('thema'); + }); + + it('selects a segment on click', async () => { + render(GroupingControl, { mode: 'date' }); + const thema = radios().find((r) => r.getAttribute('data-value') === 'thema')!; + thema.click(); + await tick(); + expect(thema.getAttribute('aria-checked')).toBe('true'); + }); +}); + +describe('GroupingControl — disabled (REQ-018)', () => { + it('marks the radiogroup aria-disabled and keeps all radios in the DOM', () => { + render(GroupingControl, { mode: 'event', disabled: true }); + expect(group().getAttribute('aria-disabled')).toBe('true'); + expect(radios()).toHaveLength(3); + }); + + it('announces a screen-reader reason that letters are hidden', () => { + render(GroupingControl, { disabled: true }); + const reason = document.querySelector('[data-testid="grouping-disabled-reason"]'); + expect(reason?.textContent).toContain(m.timeline_grouping_disabled_reason()); + }); + + it('retains the active mode while disabled (no reset to Datum)', () => { + render(GroupingControl, { mode: 'thema', disabled: true }); + expect(checkedValue()).toBe('thema'); + }); + + it('ignores arrow keys while disabled', () => { + render(GroupingControl, { mode: 'event', disabled: true }); + group().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + expect(checkedValue()).toBe('event'); + }); +});