fix(persons): keyboard navigation now updates PersonTypeSelector reactive state

radioGroupNav now accepts an onChange callback; PersonTypeSelector passes
select() as the callback so ArrowLeft/Right navigation updates the hidden
input value. aria-live region starts empty and announces only on user
interaction (fixes initial page-load announcement).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-26 10:17:47 +02:00
parent 0c47c22185
commit 842ab28f59
3 changed files with 32 additions and 5 deletions

View File

@@ -1,4 +1,9 @@
export function radioGroupNav(node: HTMLElement): { destroy: () => void } {
export function radioGroupNav(
node: HTMLElement,
onChange?: (value: string) => void
): { destroy: () => void; update: (onChange?: (value: string) => void) => void } {
let onChangeFn = onChange;
function getRadios(): HTMLElement[] {
return Array.from(node.querySelectorAll<HTMLElement>('[role="radio"]'));
}
@@ -16,11 +21,15 @@ export function radioGroupNav(node: HTMLElement): { destroy: () => void } {
radios[current].setAttribute('aria-checked', 'false');
radios[next].setAttribute('aria-checked', 'true');
radios[next].focus();
onChangeFn?.(radios[next].getAttribute('value') ?? '');
}
node.addEventListener('keydown', handleKeydown);
return {
update(newOnChange) {
onChangeFn = newOnChange;
},
destroy() {
node.removeEventListener('keydown', handleKeydown);
}

View File

@@ -16,6 +16,8 @@ let selected = $state<PersonType>(
untrack(() => (TYPES.includes(value as PersonType) ? (value as PersonType) : 'PERSON'))
);
let announcement = $state('');
const labels: Record<PersonType, () => string> = {
PERSON: m.person_type_PERSON,
INSTITUTION: m.person_type_INSTITUTION,
@@ -25,15 +27,21 @@ const labels: Record<PersonType, () => string> = {
function select(type: PersonType) {
selected = type;
announcement = m.a11y_type_changed({ type: labels[type]() });
onchange?.(type);
}
</script>
<div role="radiogroup" class="grid grid-cols-2 gap-2 sm:grid-cols-4" use:radioGroupNav>
<div
role="radiogroup"
class="grid grid-cols-2 gap-2 sm:grid-cols-4"
use:radioGroupNav={(v) => { if (TYPES.includes(v as PersonType)) select(v as PersonType); }}
>
{#each TYPES as type (type)}
<button
type="button"
role="radio"
value={type}
aria-checked={selected === type}
tabindex={selected === type ? 0 : -1}
onclick={() => select(type)}
@@ -48,6 +56,4 @@ function select(type: PersonType) {
<input type="hidden" name={name} value={selected} />
<div class="sr-only" aria-live="polite">
{m.a11y_type_changed({ type: labels[selected]() })}
</div>
<div class="sr-only" aria-live="polite" aria-atomic="true">{announcement}</div>

View File

@@ -1,11 +1,23 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { userEvent } from 'vitest/browser';
import PersonTypeSelector from './PersonTypeSelector.svelte';
afterEach(() => cleanup());
describe('PersonTypeSelector', () => {
it('hidden input value updates when user navigates with ArrowRight', async () => {
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement;
expect(hiddenInput.value).toBe('PERSON');
const personButton = container.querySelector('[aria-checked="true"]') as HTMLElement;
personButton.focus();
await userEvent.keyboard('{ArrowRight}');
expect(hiddenInput.value).toBe('INSTITUTION');
});
it('selected button uses semantic bg-primary and text-primary-fg classes', () => {
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
const buttons = container.querySelectorAll('[role="radio"]');