a11y: streamer-item row itself keyboard-accessible + aria-current

The previous a11y pass made all four chip-buttons inside a streamer
row (AUTO / VOD / REC / remove) keyboard-accessible, but the row
itself — the parent .streamer-item div whose click selects the
streamer — was still mouse-only. A keyboard user could focus the
chips but never the row, so they could never select a streamer
without a mouse.

Made the row a focusable role="button":
- role + tabindex on the .streamer-item div
- aria-label set to the streamer's name (so AT announces "xrohat
  button" rather than reading every chip child)
- aria-current="true" on the currently selected row (mirroring
  the visual .active state) so AT understands which row is the
  current selection
- A keydown handler on the row that fires selectStreamer on
  Enter / Space, but ONLY when the row itself (not a chip child)
  is the event target. The chips already preventDefault +
  stopPropagation on their own keydowns so they never reach this
  handler — and even if they did, the e.target check guards.

Focus-visible adds an inset 2px purple ring (inset to match the
row's left-border-marker styling for the active state). Tab order
through the sidebar is now: nav-items → streamer row → AUTO →
VOD → REC → remove-X → next streamer row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-11 03:32:03 +02:00
parent 78eeb8f3dc
commit 26b03da765
2 changed files with 23 additions and 0 deletions

View File

@ -465,6 +465,14 @@ function renderStreamers(): void {
item.className = 'streamer-item' + (currentStreamer === streamer ? ' active' : ''); item.className = 'streamer-item' + (currentStreamer === streamer ? ' active' : '');
item.setAttribute('draggable', 'true'); item.setAttribute('draggable', 'true');
item.dataset.streamerName = streamer; item.dataset.streamerName = streamer;
// Keyboard a11y for the row itself — click selects the streamer.
// Each chip inside still gets its own focus + Enter/Space wiring
// and stops propagation, so tabbing through a row lands on row
// first, then AUTO / VOD / REC / remove in order.
item.setAttribute('role', 'button');
item.setAttribute('tabindex', '0');
item.setAttribute('aria-label', streamer);
if (currentStreamer === streamer) item.setAttribute('aria-current', 'true');
// Live-dot — red pulsing dot when this streamer is currently // Live-dot — red pulsing dot when this streamer is currently
// broadcasting on Twitch. Populated from the live-status batch // broadcasting on Twitch. Populated from the live-status batch
@ -573,6 +581,16 @@ function renderStreamers(): void {
if (draggedStreamerName === streamer) return; if (draggedStreamerName === streamer) return;
void selectStreamer(streamer); void selectStreamer(streamer);
}); });
item.addEventListener('keydown', (e) => {
// Activate row on Enter / Space when the row itself (not a
// chip child) is focused. The chips already preventDefault
// + stopPropagation on their own keydowns so they won't reach
// this handler.
if (e.key !== 'Enter' && e.key !== ' ') return;
if (e.target !== item) return;
e.preventDefault();
void selectStreamer(streamer);
});
list.appendChild(item); list.appendChild(item);
}); });

View File

@ -130,6 +130,11 @@ body {
color: var(--text); color: var(--text);
} }
.streamer-item:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px rgba(145, 70, 255, 0.55);
}
.streamer-item.active { .streamer-item.active {
background: linear-gradient(90deg, rgba(145, 71, 255, 0.28) 0%, rgba(145, 71, 255, 0.08) 100%); background: linear-gradient(90deg, rgba(145, 71, 255, 0.28) 0%, rgba(145, 71, 255, 0.08) 100%);
color: var(--text); color: var(--text);