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