feat(timeline): add the Datum·Ereignis·Thema grouping control
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 <noreply@anthropic.com>
This commit is contained in:
114
frontend/src/lib/timeline/GroupingControl.svelte
Normal file
114
frontend/src/lib/timeline/GroupingControl.svelte
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import type { GroupingMode } from './timelineGrouping';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Datum·Ereignis·Thema segmented control (#827, REQ-010/011/018). An ARIA radiogroup with
|
||||||
|
* roving tabindex — single selection, arrow-key navigable — deliberately distinct from #780's
|
||||||
|
* `aria-pressed` layer-filter toggles. Defaults to Datum. Each segment is ≥44×44px, carries a
|
||||||
|
* text label (full word as `aria-label`, an abbreviated label shown ≤360px so the control never
|
||||||
|
* overflows at 320px), and uses semantic tokens so the selected/unselected contrast holds in dark
|
||||||
|
* mode. When `disabled` (the Letters layer is off, nothing to regroup) the control stays in place
|
||||||
|
* — no reflow — keeps its `aria-checked` selection so re-enabling restores the mode, and announces
|
||||||
|
* a screen-reader reason.
|
||||||
|
*/
|
||||||
|
let {
|
||||||
|
mode = $bindable('date'),
|
||||||
|
disabled = false,
|
||||||
|
ariaLabel = m.timeline_grouping_aria_label()
|
||||||
|
}: { mode?: GroupingMode; disabled?: boolean; ariaLabel?: string } = $props();
|
||||||
|
|
||||||
|
interface Segment {
|
||||||
|
value: GroupingMode;
|
||||||
|
full: string;
|
||||||
|
short: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments: Segment[] = [
|
||||||
|
{
|
||||||
|
value: 'date',
|
||||||
|
full: m.timeline_grouping_segment_date(),
|
||||||
|
short: m.timeline_grouping_segment_date_short()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'event',
|
||||||
|
full: m.timeline_grouping_segment_event(),
|
||||||
|
short: m.timeline_grouping_segment_event_short()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'thema',
|
||||||
|
full: m.timeline_grouping_segment_thema(),
|
||||||
|
short: m.timeline_grouping_segment_thema_short()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function select(value: GroupingMode) {
|
||||||
|
if (disabled) return;
|
||||||
|
mode = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (disabled) return;
|
||||||
|
const forward = event.key === 'ArrowRight' || event.key === 'ArrowDown';
|
||||||
|
const backward = event.key === 'ArrowLeft' || event.key === 'ArrowUp';
|
||||||
|
if (!forward && !backward) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const index = segments.findIndex((s) => s.value === mode);
|
||||||
|
const delta = forward ? 1 : -1;
|
||||||
|
const next = segments[(index + delta + segments.length) % segments.length];
|
||||||
|
mode = next.value;
|
||||||
|
const groupEl = event.currentTarget as HTMLElement;
|
||||||
|
groupEl.querySelector<HTMLElement>(`[data-value="${next.value}"]`)?.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
data-testid="grouping-control"
|
||||||
|
class="inline-flex overflow-hidden rounded-md border border-line"
|
||||||
|
onkeydown={onKeydown}
|
||||||
|
>
|
||||||
|
{#each segments as segment (segment.value)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
data-value={segment.value}
|
||||||
|
aria-label={segment.full}
|
||||||
|
aria-checked={mode === segment.value}
|
||||||
|
tabindex={mode === segment.value ? 0 : -1}
|
||||||
|
disabled={disabled}
|
||||||
|
onclick={() => select(segment.value)}
|
||||||
|
style="display: inline-flex; align-items: center; justify-content: center; min-height: 44px; min-width: 44px"
|
||||||
|
class="px-3 py-2 font-sans text-xs font-semibold focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset"
|
||||||
|
class:bg-brand-navy={mode === segment.value && !disabled}
|
||||||
|
class:text-white={mode === segment.value && !disabled}
|
||||||
|
class:bg-surface={mode !== segment.value || disabled}
|
||||||
|
class:text-ink-3={mode !== segment.value || disabled}
|
||||||
|
>
|
||||||
|
<span class="seg-full">{segment.full}</span>
|
||||||
|
<span class="seg-short">{segment.short}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if disabled}
|
||||||
|
<span class="sr-only" role="status" data-testid="grouping-disabled-reason"
|
||||||
|
>{m.timeline_grouping_disabled_reason()}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.seg-short {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 360px) {
|
||||||
|
.seg-full {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.seg-short {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
106
frontend/src/lib/timeline/GroupingControl.svelte.spec.ts
Normal file
106
frontend/src/lib/timeline/GroupingControl.svelte.spec.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user