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');
+ });
+});