feat(persons): add radioGroupNav action for keyboard navigation in type selector

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-25 21:17:06 +02:00
committed by marcel
parent ecd531601a
commit b8dcb2d3f4
2 changed files with 115 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, afterEach } from 'vitest';
const { radioGroupNav } = await import('./radioGroupNav');
describe('radioGroupNav action', () => {
const nodes: HTMLElement[] = [];
function makeGroup(count: number): { container: HTMLElement; buttons: HTMLElement[] } {
const container = document.createElement('div');
container.setAttribute('role', 'radiogroup');
const buttons: HTMLElement[] = [];
for (let i = 0; i < count; i++) {
const btn = document.createElement('button');
btn.setAttribute('role', 'radio');
btn.setAttribute('aria-checked', i === 0 ? 'true' : 'false');
btn.setAttribute('tabindex', i === 0 ? '0' : '-1');
container.appendChild(btn);
buttons.push(btn);
}
document.body.appendChild(container);
nodes.push(container);
return { container, buttons };
}
afterEach(() => {
nodes.forEach((n) => n.remove());
nodes.length = 0;
});
it('ArrowRight moves focus to next button', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[0].focus();
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(document.activeElement).toBe(buttons[1]);
});
it('ArrowRight wraps from last to first', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[3].focus();
buttons[3].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(document.activeElement).toBe(buttons[0]);
});
it('ArrowLeft moves focus to previous button', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[2].focus();
buttons[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
expect(document.activeElement).toBe(buttons[1]);
});
it('ArrowLeft wraps from first to last', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[0].focus();
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
expect(document.activeElement).toBe(buttons[3]);
});
it('ArrowRight updates aria-checked on new button and removes it from old', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[0].focus();
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(buttons[1].getAttribute('aria-checked')).toBe('true');
expect(buttons[0].getAttribute('aria-checked')).toBe('false');
});
it('destroy removes keydown listener', () => {
const { container, buttons } = makeGroup(4);
const { destroy } = radioGroupNav(container);
destroy();
buttons[0].focus();
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(document.activeElement).toBe(buttons[0]);
});
it('ignores non-arrow keys', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[0].focus();
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(document.activeElement).toBe(buttons[0]);
});
});

View File

@@ -0,0 +1,28 @@
export function radioGroupNav(node: HTMLElement): { destroy: () => void } {
function getRadios(): HTMLElement[] {
return Array.from(node.querySelectorAll<HTMLElement>('[role="radio"]'));
}
function handleKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return;
const radios = getRadios();
const current = radios.indexOf(document.activeElement as HTMLElement);
if (current === -1) return;
const delta = event.key === 'ArrowRight' ? 1 : -1;
const next = (current + delta + radios.length) % radios.length;
radios[current].setAttribute('aria-checked', 'false');
radios[next].setAttribute('aria-checked', 'true');
radios[next].focus();
}
node.addEventListener('keydown', handleKeydown);
return {
destroy() {
node.removeEventListener('keydown', handleKeydown);
}
};
}