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