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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"]');
|
||||
|
||||
Reference in New Issue
Block a user