a11y: remove-X spans become keyboard-accessible with aria-label

The two `<span class="remove">x</span>` glyphs (one per queue
item, one per streamer-list item) had no semantic role, no
aria-label, no tabindex — entirely mouse-only and screen-readers
just announced them as the bare "x" character.

Made both fully keyboard-accessible:
- role="button" + tabindex="0" so they enter the tab order and
  read as buttons to AT
- aria-label="Remove" / "Entfernen" via new
  streamers.removeAria locale key (DE + EN)
- Keydown handler on Enter + Space synthesizes the same removal
  callback (mirroring native button behaviour for synthetic
  buttons — Enter on real buttons fires click, Space does too)
- Focus-visible state: 2px red glow ring + force opacity:1 on
  the streamer-list .remove (which is normally opacity:0 until
  the streamer-item is hovered) so keyboard navigators can see
  the focused X

Both call sites preserved e.stopPropagation in the keydown handler
so Enter on a focused X doesn't bubble up to the row's click
handler (which would trigger streamer-select).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-11 03:18:18 +02:00
parent 5f7ce36845
commit 35769959f4
5 changed files with 24 additions and 4 deletions

View File

@ -356,7 +356,8 @@ const UI_TEXT_DE = {
autoRecordScanEmpty: 'Manueller Scan: kein Streamer ist gerade live.', autoRecordScanEmpty: 'Manueller Scan: kein Streamer ist gerade live.',
liveNowTooltip: 'Aktuell live auf Twitch', liveNowTooltip: 'Aktuell live auf Twitch',
modalCloseAria: 'Dialog schliessen', modalCloseAria: 'Dialog schliessen',
sidebarEmpty: 'Noch keine Streamer. Fuege oben rechts einen hinzu.' sidebarEmpty: 'Noch keine Streamer. Fuege oben rechts einen hinzu.',
removeAria: 'Entfernen'
}, },
vods: { vods: {
noneTitle: 'Keine VODs', noneTitle: 'Keine VODs',

View File

@ -356,7 +356,8 @@ const UI_TEXT_EN = {
autoRecordScanEmpty: 'Manual scan: no streamers currently live.', autoRecordScanEmpty: 'Manual scan: no streamers currently live.',
liveNowTooltip: 'Currently live on Twitch', liveNowTooltip: 'Currently live on Twitch',
modalCloseAria: 'Close dialog', modalCloseAria: 'Close dialog',
sidebarEmpty: 'No streamers yet. Add one via the input at the top right.' sidebarEmpty: 'No streamers yet. Add one via the input at the top right.',
removeAria: 'Remove'
}, },
vods: { vods: {
noneTitle: 'No VODs', noneTitle: 'No VODs',

View File

@ -572,7 +572,7 @@ function renderQueue(): void {
</div> </div>
</div> </div>
${item.status === 'error' ? `<button class="queue-retry-btn" title="${escapeHtml(UI_TEXT.queue.retryItem)}" onclick="retryQueueItem('${item.id}')">&#x21bb;</button>` : ''} ${item.status === 'error' ? `<button class="queue-retry-btn" title="${escapeHtml(UI_TEXT.queue.retryItem)}" onclick="retryQueueItem('${item.id}')">&#x21bb;</button>` : ''}
<span class="remove" onclick="removeFromQueue('${item.id}')">x</span> <span class="remove" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.streamers.removeAria)}" onclick="removeFromQueue('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();removeFromQueue('${item.id}');}">x</span>
</div> </div>
`; `;
}).join(''); }).join('');

View File

@ -525,10 +525,20 @@ function renderStreamers(): void {
const removeSpan = document.createElement('span'); const removeSpan = document.createElement('span');
removeSpan.className = 'remove'; removeSpan.className = 'remove';
removeSpan.textContent = 'x'; removeSpan.textContent = 'x';
removeSpan.setAttribute('role', 'button');
removeSpan.setAttribute('tabindex', '0');
removeSpan.setAttribute('aria-label', UI_TEXT.streamers.removeAria);
removeSpan.addEventListener('click', (e) => { removeSpan.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
void removeStreamer(streamer); void removeStreamer(streamer);
}); });
removeSpan.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
void removeStreamer(streamer);
}
});
item.append(nameSpan, autoBtn, vodBtn, recBtn, removeSpan); item.append(nameSpan, autoBtn, vodBtn, recBtn, removeSpan);
item.addEventListener('click', () => { item.addEventListener('click', () => {

View File

@ -443,10 +443,18 @@ body {
cursor: pointer; cursor: pointer;
} }
.streamer-item:hover .remove { .streamer-item:hover .remove,
.streamer-item .remove:focus-visible {
opacity: 1; opacity: 1;
} }
.streamer-item .remove:focus-visible,
.queue-item .remove:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(255, 70, 70, 0.5);
border-radius: 3px;
}
/* Live-dot red pulsing indicator shown next to a streamer's name in /* Live-dot red pulsing indicator shown next to a streamer's name in
the sidebar when they are currently broadcasting on Twitch. */ the sidebar when they are currently broadcasting on Twitch. */
.streamer-live-dot { .streamer-live-dot {