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 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}
|
||||||
|
|||||||
Reference in New Issue
Block a user