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:
parent
5f7ce36845
commit
35769959f4
@ -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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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}')">↻</button>` : ''}
|
${item.status === 'error' ? `<button class="queue-retry-btn" title="${escapeHtml(UI_TEXT.queue.retryItem)}" onclick="retryQueueItem('${item.id}')">↻</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('');
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user