fix(a11y): Phase 3 follow-up — implement ARIA radio-group keyboard pattern for energy selector
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:
@@ -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 list = tasks.filter((t) => t.done);
|
||||
if (activeBucket !== "all") {
|
||||
@@ -244,23 +294,25 @@
|
||||
from any bucket / list context. -->
|
||||
<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>
|
||||
<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"
|
||||
aria-label="My current energy">
|
||||
{#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}
|
||||
<button
|
||||
class="text-[10px] px-2 py-0.5 rounded-md
|
||||
{settings.currentEnergy === opt.value
|
||||
{checked
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-text-tertiary hover:text-text-secondary'}"
|
||||
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'}
|
||||
tabindex={checked ? 0 : -1}
|
||||
onclick={() => cycleCurrentEnergy(opt.value)}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user