fix(a11y): Phase 3 follow-up — implement ARIA radio-group keyboard pattern for energy selector
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled

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.
This commit is contained in:
2026-04-24 14:58:50 +01:00
parent 1d4f1070a2
commit b344e8a580

View File

@@ -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<HTMLDivElement | null>(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<HTMLButtonElement>('[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 completedTasks = $derived.by(() => {
let list = tasks.filter((t) => t.done); let list = tasks.filter((t) => t.done);
if (activeBucket !== "all") { if (activeBucket !== "all") {
@@ -244,23 +294,25 @@
from any bucket / list context. --> from any bucket / list context. -->
<div class="flex items-center gap-2 mr-2"> <div class="flex items-center gap-2 mr-2">
<span class="text-[10px] text-text-tertiary hidden sm:inline" aria-hidden="true">I feel</span> <span class="text-[10px] text-text-tertiary hidden sm:inline" aria-hidden="true">I feel</span>
<div class="flex items-center gap-0.5 bg-bg-input border border-border-subtle rounded-lg p-0.5" <div
bind:this={energyRadioGroupEl}
class="flex items-center gap-0.5 bg-bg-input border border-border-subtle rounded-lg p-0.5"
role="radiogroup" role="radiogroup"
aria-label="My current energy"> aria-label="My current energy"
{#each [ tabindex="-1"
{ value: null, label: '—' }, onkeydown={energyRadioKeydown}
{ value: 'high' as EnergyLevel, label: 'High' }, >
{ value: 'medium' as EnergyLevel, label: 'Med' }, {#each energyOptions as opt}
{ value: 'brain_dead' as EnergyLevel, label: 'Low' }, {@const checked = settings.currentEnergy === opt.value}
] as opt}
<button <button
class="text-[10px] px-2 py-0.5 rounded-md class="text-[10px] px-2 py-0.5 rounded-md
{settings.currentEnergy === opt.value {checked
? 'bg-accent/15 text-accent' ? 'bg-accent/15 text-accent'
: 'text-text-tertiary hover:text-text-secondary'}" : 'text-text-tertiary hover:text-text-secondary'}"
role="radio" role="radio"
aria-checked={settings.currentEnergy === opt.value} aria-checked={checked}
aria-label={opt.value ? `Set current energy to ${energyLabel(opt.value)}` : 'Clear current energy'} aria-label={opt.value ? `Set current energy to ${energyLabel(opt.value)}` : 'Clear current energy'}
tabindex={checked ? 0 : -1}
onclick={() => cycleCurrentEnergy(opt.value)} onclick={() => cycleCurrentEnergy(opt.value)}
>{opt.label}</button> >{opt.label}</button>
{/each} {/each}