feat(timeline): regroup /zeitstrahl by Ereignis or Thema (#827) #847
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