feat(timeline): add EventTypeSelect segmented radio (PERSONAL/HISTORICAL)
grid-cols-2 segmented radio group modelled on PersonTypeSelector: role=radiogroup with role=radio buttons, roving tabindex, radioGroupNav arrow-key support, and an sr-only aria-live type-change announcement. Each option pairs a decorative aria-hidden icon with a visible localized text label (icon is never the sole differentiator), min-h-48px target. Emits a hidden input for form submission. Refs #781 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
69
frontend/src/lib/timeline/EventTypeSelect.svelte
Normal file
69
frontend/src/lib/timeline/EventTypeSelect.svelte
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { radioGroupNav } from '$lib/shared/actions/radioGroupNav';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type EventType = 'PERSONAL' | 'HISTORICAL';
|
||||||
|
const TYPES: EventType[] = ['PERSONAL', 'HISTORICAL'];
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = 'PERSONAL',
|
||||||
|
name = 'type',
|
||||||
|
onchange
|
||||||
|
}: { value?: string; name?: string; onchange?: (type: EventType) => void } = $props();
|
||||||
|
|
||||||
|
let selected = $state<EventType>(
|
||||||
|
untrack(() => (TYPES.includes(value as EventType) ? (value as EventType) : 'PERSONAL'))
|
||||||
|
);
|
||||||
|
|
||||||
|
let announcement = $state('');
|
||||||
|
|
||||||
|
const labels: Record<EventType, () => string> = {
|
||||||
|
PERSONAL: m.event_type_PERSONAL,
|
||||||
|
HISTORICAL: m.event_type_HISTORICAL
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decorative accents only — never the sole differentiator (text label is always
|
||||||
|
// present). aria-hidden so AT announces the label, not the glyph.
|
||||||
|
const icons: Record<EventType, string> = {
|
||||||
|
PERSONAL: '👤',
|
||||||
|
HISTORICAL: '🏛'
|
||||||
|
};
|
||||||
|
|
||||||
|
function select(type: EventType) {
|
||||||
|
selected = type;
|
||||||
|
announcement = m.a11y_type_changed({ type: labels[type]() });
|
||||||
|
onchange?.(type);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={m.event_editor_type_label()}
|
||||||
|
class="grid grid-cols-2 gap-2"
|
||||||
|
use:radioGroupNav={(v) => {
|
||||||
|
if (TYPES.includes(v as EventType)) select(v as EventType);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#each TYPES as type (type)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
value={type}
|
||||||
|
aria-checked={selected === type}
|
||||||
|
tabindex={selected === type ? 0 : -1}
|
||||||
|
onclick={() => select(type)}
|
||||||
|
class="flex min-h-[48px] cursor-pointer items-center gap-2 rounded-sm border px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none {selected ===
|
||||||
|
type
|
||||||
|
? 'border-primary bg-primary text-primary-fg'
|
||||||
|
: 'border-line bg-surface text-ink hover:border-primary/50'}"
|
||||||
|
>
|
||||||
|
<span class="text-lg leading-none" aria-hidden="true">{icons[type]}</span>
|
||||||
|
<span>{labels[type]()}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name={name} value={selected} />
|
||||||
|
|
||||||
|
<div class="sr-only" aria-live="polite" aria-atomic="true">{announcement}</div>
|
||||||
27
frontend/src/lib/timeline/EventTypeSelect.svelte.spec.ts
Normal file
27
frontend/src/lib/timeline/EventTypeSelect.svelte.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import EventTypeSelect from './EventTypeSelect.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('EventTypeSelect — segmented PERSONAL/HISTORICAL radio', () => {
|
||||||
|
it('renders exactly two radio options', async () => {
|
||||||
|
render(EventTypeSelect, { value: 'PERSONAL' });
|
||||||
|
const radios = document.querySelectorAll('[role="radio"]');
|
||||||
|
expect(radios.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the initial value as checked and seeds the hidden input', async () => {
|
||||||
|
render(EventTypeSelect, { value: 'HISTORICAL', name: 'type' });
|
||||||
|
const hidden = document.querySelector('input[type="hidden"][name="type"]') as HTMLInputElement;
|
||||||
|
expect(hidden.value).toBe('HISTORICAL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects HISTORICAL and updates the hidden input when clicked', async () => {
|
||||||
|
render(EventTypeSelect, { value: 'PERSONAL', name: 'type' });
|
||||||
|
await page.getByRole('radio', { name: 'Historisch' }).click();
|
||||||
|
const hidden = document.querySelector('input[type="hidden"][name="type"]') as HTMLInputElement;
|
||||||
|
expect(hidden.value).toBe('HISTORICAL');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user