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[] { function getRadios(): HTMLElement[] {
return Array.from(node.querySelectorAll<HTMLElement>('[role="radio"]')); 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[current].setAttribute('aria-checked', 'false');
radios[next].setAttribute('aria-checked', 'true'); radios[next].setAttribute('aria-checked', 'true');
radios[next].focus(); radios[next].focus();
onChangeFn?.(radios[next].getAttribute('value') ?? '');
} }
node.addEventListener('keydown', handleKeydown); node.addEventListener('keydown', handleKeydown);
return { return {
update(newOnChange) {
onChangeFn = newOnChange;
},
destroy() { destroy() {
node.removeEventListener('keydown', handleKeydown); node.removeEventListener('keydown', handleKeydown);
} }

View File

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

View File

@@ -1,11 +1,23 @@
import { describe, it, expect, afterEach } from 'vitest'; import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { userEvent } from 'vitest/browser';
import PersonTypeSelector from './PersonTypeSelector.svelte'; import PersonTypeSelector from './PersonTypeSelector.svelte';
afterEach(() => cleanup()); afterEach(() => cleanup());
describe('PersonTypeSelector', () => { 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', () => { it('selected button uses semantic bg-primary and text-primary-fg classes', () => {
const { container } = render(PersonTypeSelector, { value: 'PERSON' }); const { container } = render(PersonTypeSelector, { value: 'PERSON' });
const buttons = container.querySelectorAll('[role="radio"]'); const buttons = container.querySelectorAll('[role="radio"]');