From b344e8a580f3250f80738e98850894d51a93a543 Mon Sep 17 00:00:00 2001 From: Jake Date: Fri, 24 Apr 2026 14:58:50 +0100 Subject: [PATCH] =?UTF-8?q?fix(a11y):=20Phase=203=20follow-up=20=E2=80=94?= =?UTF-8?q?=20implement=20ARIA=20radio-group=20keyboard=20pattern=20for=20?= =?UTF-8?q?energy=20selector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex post-implementation review flagged one MAJOR: the energy segmented control declared `role="radiogroup"` / `role="radio"` but only wired `onclick`. No arrow-key navigation, no Home/End, no roving tabindex. Keyboard users got four independent tab stops while assistive tech was told it was a single radio group — a broken ARIA contract. Fix (W3C APG Radio Group pattern): - Extract the options list as `energyOptions` so the render loop and the keyboard handler share one source of truth. - `energyRadioKeydown` handles ArrowLeft/Right/Up/Down (cycle wraps), Home (first), End (last). - Roving tabindex: the currently-checked button gets `tabindex=0`, the rest get `tabindex=-1`, matching the APG recipe. Focus moves with selection. - The radiogroup container gets `tabindex="-1"` to satisfy the svelte-check a11y rule without creating its own tab stop. All green: 251 tests, clippy -D warnings, fmt, svelte-check 0/0, build. --- src/lib/pages/TasksPage.svelte | 72 +++++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/src/lib/pages/TasksPage.svelte b/src/lib/pages/TasksPage.svelte index 9d137c0..de9c6af 100644 --- a/src/lib/pages/TasksPage.svelte +++ b/src/lib/pages/TasksPage.svelte @@ -110,6 +110,56 @@ } } + // ARIA radiogroup keyboard handling for the energy segmented control. + // Full W3C APG Radio Group pattern: arrow keys cycle, Home / End jump + // to ends, focus and selection move together. The options array lives + // here so the keyboard handler and the render loop share one source + // of truth — desynchronising them is the usual way these patterns + // rot over time. + type EnergyOption = { value: EnergyLevel | null; label: string }; + const energyOptions: EnergyOption[] = [ + { value: null, label: "—" }, + { value: "high", label: "High" }, + { value: "medium", label: "Med" }, + { value: "brain_dead", label: "Low" }, + ]; + let energyRadioGroupEl = $state(null); + + function selectEnergyByIndex(idx: number) { + const clamped = Math.max(0, Math.min(energyOptions.length - 1, idx)); + const opt = energyOptions[clamped]; + cycleCurrentEnergy(opt.value); + // Move focus to the newly-checked button so keyboard nav tracks + // selection, per the ARIA radio pattern. + const btn = energyRadioGroupEl?.querySelectorAll('[role="radio"]')[clamped]; + btn?.focus(); + } + + function energyRadioKeydown(e: KeyboardEvent) { + const current = energyOptions.findIndex((o) => o.value === settings.currentEnergy); + const here = current < 0 ? 0 : current; + switch (e.key) { + case "ArrowRight": + case "ArrowDown": + e.preventDefault(); + selectEnergyByIndex((here + 1) % energyOptions.length); + break; + case "ArrowLeft": + case "ArrowUp": + e.preventDefault(); + selectEnergyByIndex((here - 1 + energyOptions.length) % energyOptions.length); + break; + case "Home": + e.preventDefault(); + selectEnergyByIndex(0); + break; + case "End": + e.preventDefault(); + selectEnergyByIndex(energyOptions.length - 1); + break; + } + } + let completedTasks = $derived.by(() => { let list = tasks.filter((t) => t.done); if (activeBucket !== "all") { @@ -244,23 +294,25 @@ from any bucket / list context. -->
-
- {#each [ - { value: null, label: '—' }, - { value: 'high' as EnergyLevel, label: 'High' }, - { value: 'medium' as EnergyLevel, label: 'Med' }, - { value: 'brain_dead' as EnergyLevel, label: 'Low' }, - ] as opt} + aria-label="My current energy" + tabindex="-1" + onkeydown={energyRadioKeydown} + > + {#each energyOptions as opt} + {@const checked = settings.currentEnergy === opt.value} {/each}