From b8dcb2d3f44458697eab1caac502fa8b46065e30 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 21:17:06 +0200 Subject: [PATCH] feat(persons): add radioGroupNav action for keyboard navigation in type selector Co-Authored-By: Claude Sonnet 4.6 --- .../lib/actions/radioGroupNav.svelte.spec.ts | 87 +++++++++++++++++++ frontend/src/lib/actions/radioGroupNav.ts | 28 ++++++ 2 files changed, 115 insertions(+) create mode 100644 frontend/src/lib/actions/radioGroupNav.svelte.spec.ts create mode 100644 frontend/src/lib/actions/radioGroupNav.ts diff --git a/frontend/src/lib/actions/radioGroupNav.svelte.spec.ts b/frontend/src/lib/actions/radioGroupNav.svelte.spec.ts new file mode 100644 index 00000000..f624b38f --- /dev/null +++ b/frontend/src/lib/actions/radioGroupNav.svelte.spec.ts @@ -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]); + }); +}); diff --git a/frontend/src/lib/actions/radioGroupNav.ts b/frontend/src/lib/actions/radioGroupNav.ts new file mode 100644 index 00000000..65164327 --- /dev/null +++ b/frontend/src/lib/actions/radioGroupNav.ts @@ -0,0 +1,28 @@ +export function radioGroupNav(node: HTMLElement): { destroy: () => void } { + function getRadios(): HTMLElement[] { + return Array.from(node.querySelectorAll('[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); + } + }; +}