a11y: AUTO / VOD / REC streamer chips keyboard-accessible

The three streamer-row action chips (AUTO toggle, VOD toggle, REC
one-shot) were spans wired only with click listeners. Same a11y
gap as the .remove X chips in 4.6.50: no role, no tabindex, no
keyboard activation, no semantic state for toggles. Screen readers
read them as raw text "AUTO", "VOD", "REC" with no clue they were
interactive controls.

Factored a wireChipButton helper inside renderStreamers and ran all
three chips through it. The helper stamps:
- role="button" + tabindex="0"
- aria-label (locale-driven, picked up the existing
  autoRecordTitle / autoVodTitle / recordLiveTitle locale keys
  that were previously only used for the visual title-tooltip)
- aria-pressed="true"/"false" for the AUTO and VOD toggles so
  AT announces the on/off state
- A keydown handler that synthesises the same click handler on
  Enter / Space and stops propagation so the row's click handler
  (streamer-select) does not also fire

CSS adds three focus-visible rings (green for AUTO, blue for VOD,
red for REC) matching each chips active-state colour palette.
Keyboard navigators tabbing through a streamer item now see the
ring on the focused chip clearly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-11 03:23:29 +02:00
parent 6086cd51c1
commit 1d4b6718b9
2 changed files with 62 additions and 17 deletions

View File

@ -482,45 +482,72 @@ function renderStreamers(): void {
nameSpan.className = 'streamer-name' + (isLive ? ' is-live' : '');
nameSpan.textContent = streamer;
// Three streamer-row action chips (AUTO toggle / VOD toggle / REC
// one-shot). All share the same accessibility wiring:
// role="button", tabindex="0", aria-pressed for the toggles +
// aria-label for screen readers, plus Enter/Space keydown
// activation. wireChipButton centralises that so each chip only
// declares its own visual class + label + handler.
const wireChipButton = (el: HTMLElement, opts: {
handler: () => void;
ariaLabel: string;
pressed?: boolean;
}): void => {
el.setAttribute('role', 'button');
el.setAttribute('tabindex', '0');
el.setAttribute('aria-label', opts.ariaLabel);
if (opts.pressed !== undefined) el.setAttribute('aria-pressed', String(opts.pressed));
el.addEventListener('click', (e) => {
e.stopPropagation();
opts.handler();
});
el.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
opts.handler();
}
});
};
// AUTO toggle — when enabled, the main-process auto-record poller
// watches this channel for offline->live transitions and queues a
// live recording automatically. Off by default, click to toggle.
// live recording automatically.
const autoList = (config.auto_record_streamers as string[] | undefined) || [];
const isAutoOn = autoList.includes(streamer);
const autoBtn = document.createElement('span');
autoBtn.className = 'streamer-auto' + (isAutoOn ? ' active' : '');
autoBtn.textContent = 'AUTO';
autoBtn.title = UI_TEXT.streamers?.autoRecordTitle || 'Auto-record when this streamer goes live';
autoBtn.addEventListener('click', (e) => {
e.stopPropagation();
void toggleAutoRecord(streamer);
wireChipButton(autoBtn, {
handler: () => { void toggleAutoRecord(streamer); },
ariaLabel: UI_TEXT.streamers?.autoRecordTitle || 'Auto-record',
pressed: isAutoOn
});
// VOD-auto-download toggle — when enabled, the main-process auto-VOD
// poller scans this streamer's VOD list periodically and queues new
// VODs published in the rolling window automatically. Complements
// AUTO (live capture): VOD covers downtime + transcoded archive,
// AUTO covers a stream as it happens. Useful for both.
// VOD-auto-download toggle — periodic scan of this streamer's
// VOD list, auto-queues anything new within the age window.
const vodList = (config.auto_vod_download_streamers as string[] | undefined) || [];
const isVodOn = vodList.includes(streamer);
const vodBtn = document.createElement('span');
vodBtn.className = 'streamer-vod' + (isVodOn ? ' active' : '');
vodBtn.textContent = 'VOD';
vodBtn.title = UI_TEXT.streamers?.autoVodTitle || 'Auto-download new VODs';
vodBtn.addEventListener('click', (e) => {
e.stopPropagation();
void toggleAutoVodDownload(streamer);
wireChipButton(vodBtn, {
handler: () => { void toggleAutoVodDownload(streamer); },
ariaLabel: UI_TEXT.streamers?.autoVodTitle || 'Auto-download new VODs',
pressed: isVodOn
});
// Live-record button — small red dot, only triggers a live capture
// when the streamer is currently online (server checks via Helix).
// Live-record one-shot — triggers a recording immediately (server
// verifies the streamer is online before honoring the request).
const recBtn = document.createElement('span');
recBtn.className = 'streamer-rec';
recBtn.textContent = 'REC';
recBtn.title = UI_TEXT.streamers?.recordLiveTitle || 'Record live now';
recBtn.addEventListener('click', (e) => {
e.stopPropagation();
void triggerLiveRecording(streamer);
wireChipButton(recBtn, {
handler: () => { void triggerLiveRecording(streamer); },
ariaLabel: UI_TEXT.streamers?.recordLiveTitle || 'Record live now'
});
const removeSpan = document.createElement('span');
removeSpan.className = 'remove';

View File

@ -455,6 +455,24 @@ body {
border-radius: 3px;
}
/* Keyboard focus rings for the AUTO / VOD / REC chip buttons.
Each picks up its own accent: AUTO + VOD use semantic green/blue
tints matching their active state, REC the red of the live dot. */
.streamer-auto:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(0, 200, 83, 0.55);
}
.streamer-vod:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.55);
}
.streamer-rec:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(255, 68, 68, 0.55);
}
/* Live-dot red pulsing indicator shown next to a streamer's name in
the sidebar when they are currently broadcasting on Twitch. */
.streamer-live-dot {