a11y: sidebar nav-items keyboard-accessible + aria-current

All 7 sidebar nav-items (Twitch VODs / Clips / Cutter / Merge /
Statistik / Archiv / Einstellungen) were plain `<div class="nav-item">`
elements with only an onclick. Same a11y story as the previous two
iterations: no role, no tabindex, no semantic active-state marker,
no keyboard activation.

Added on each nav item:
- role="button" and tabindex="0" so they enter the tab order and
  read as activatable buttons to assistive tech
- aria-current="page" applied to the active item, removed from the
  others — both managed in showTab() since that's the single
  switch point for active-state transitions
- A delegated keydown handler on the .nav container (one listener,
  not seven) that fires showTab on Enter / Space for whatever
  nav-item descendant is currently focused. Bound once with a
  data-keynav-bound guard so init() re-running doesn't double-bind

CSS adds a 2px purple focus-visible ring matching the rest of the
keyboard-focus family added in 4.6.50 and 4.6.51.

WCAG 2.1 success criterion 2.1.1 (Keyboard) — every interactive
element activated by keyboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-11 03:27:53 +02:00
parent a82a8f97f7
commit 5fda4e2103
3 changed files with 37 additions and 8 deletions

View File

@ -186,31 +186,31 @@
</div> </div>
<nav class="nav"> <nav class="nav">
<div class="nav-item active" data-tab="vods" onclick="showTab('vods')"> <div class="nav-item active" role="button" tabindex="0" aria-current="page" data-tab="vods" onclick="showTab('vods')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
<span id="navVodsText">Twitch VODs</span> <span id="navVodsText">Twitch VODs</span>
</div> </div>
<div class="nav-item" data-tab="clips" onclick="showTab('clips')"> <div class="nav-item" role="button" tabindex="0" data-tab="clips" onclick="showTab('clips')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
<span id="navClipsText">Twitch Clips</span> <span id="navClipsText">Twitch Clips</span>
</div> </div>
<div class="nav-item" data-tab="cutter" onclick="showTab('cutter')"> <div class="nav-item" role="button" tabindex="0" data-tab="cutter" onclick="showTab('cutter')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.64 7.64c.23-.5.36-1.05.36-1.64 0-2.21-1.79-4-4-4S2 3.79 2 6s1.79 4 4 4c.59 0 1.14-.13 1.64-.36L10 12l-2.36 2.36C7.14 14.13 6.59 14 6 14c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4c0-.59-.13-1.14-.36-1.64L12 14l7 7h3v-1L9.64 7.64zM6 8c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm0 12c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm6-7.5c-.28 0-.5-.22-.5-.5s.22-.5.5-.5.5.22.5.5-.22.5-.5.5zM19 3l-6 6 2 2 7-7V3h-3z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.64 7.64c.23-.5.36-1.05.36-1.64 0-2.21-1.79-4-4-4S2 3.79 2 6s1.79 4 4 4c.59 0 1.14-.13 1.64-.36L10 12l-2.36 2.36C7.14 14.13 6.59 14 6 14c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4c0-.59-.13-1.14-.36-1.64L12 14l7 7h3v-1L9.64 7.64zM6 8c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm0 12c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm6-7.5c-.28 0-.5-.22-.5-.5s.22-.5.5-.5.5.22.5.5-.22.5-.5.5zM19 3l-6 6 2 2 7-7V3h-3z"/></svg>
<span id="navCutterText">Video schneiden</span> <span id="navCutterText">Video schneiden</span>
</div> </div>
<div class="nav-item" data-tab="merge" onclick="showTab('merge')"> <div class="nav-item" role="button" tabindex="0" data-tab="merge" onclick="showTab('merge')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg>
<span id="navMergeText">Videos zusammenfugen</span> <span id="navMergeText">Videos zusammenfugen</span>
</div> </div>
<div class="nav-item" data-tab="stats" onclick="showTab('stats')"> <div class="nav-item" role="button" tabindex="0" data-tab="stats" onclick="showTab('stats')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 13h2v8H3zm4-7h2v15H7zm4 4h2v11h-2zm4 4h2v7h-2zm4-8h2v15h-2z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 13h2v8H3zm4-7h2v15H7zm4 4h2v11h-2zm4 4h2v7h-2zm4-8h2v15h-2z"/></svg>
<span id="navStatsText">Statistik</span> <span id="navStatsText">Statistik</span>
</div> </div>
<div class="nav-item" data-tab="archive" onclick="showTab('archive')"> <div class="nav-item" role="button" tabindex="0" data-tab="archive" onclick="showTab('archive')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<span id="navArchiveText">Archiv</span> <span id="navArchiveText">Archiv</span>
</div> </div>
<div class="nav-item" data-tab="settings" onclick="showTab('settings')"> <div class="nav-item" role="button" tabindex="0" data-tab="settings" onclick="showTab('settings')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
<span id="navSettingsText">Einstellungen</span> <span id="navSettingsText">Einstellungen</span>
</div> </div>

View File

@ -43,6 +43,26 @@ async function init(): Promise<void> {
renderStreamers(); renderStreamers();
renderQueue(); renderQueue();
// Keyboard activation for nav-items (Enter / Space). The items are
// div[role="button"][tabindex="0"], so browsers won't synthesise a
// click on Enter/Space natively — we wire it here once via event
// delegation so the listener doesn't need re-binding per tab switch.
const nav = document.querySelector('.nav');
if (nav && !nav.hasAttribute('data-keynav-bound')) {
nav.setAttribute('data-keynav-bound', '1');
nav.addEventListener('keydown', (event) => {
const ev = event as KeyboardEvent;
if (ev.key !== 'Enter' && ev.key !== ' ') return;
const target = ev.target as HTMLElement | null;
const item = target?.closest('.nav-item') as HTMLElement | null;
if (!item) return;
const tab = item.dataset.tab;
if (!tab) return;
ev.preventDefault();
showTab(tab);
});
}
// Kick off live-status subscription so the sidebar dots populate. // Kick off live-status subscription so the sidebar dots populate.
const liveStatusInit = (window as unknown as { initLiveStatusSubscription?: () => Promise<void> }).initLiveStatusSubscription; const liveStatusInit = (window as unknown as { initLiveStatusSubscription?: () => Promise<void> }).initLiveStatusSubscription;
if (typeof liveStatusInit === 'function') void liveStatusInit(); if (typeof liveStatusInit === 'function') void liveStatusInit();
@ -777,7 +797,10 @@ function persistActiveTab(tab: string): void {
} }
function showTab(tab: string): void { function showTab(tab: string): void {
queryAll('.nav-item').forEach((i) => i.classList.remove('active')); queryAll('.nav-item').forEach((i) => {
i.classList.remove('active');
i.removeAttribute('aria-current');
});
queryAll('.tab-content').forEach((c) => c.classList.remove('active')); queryAll('.tab-content').forEach((c) => c.classList.remove('active'));
const navItem = query(`.nav-item[data-tab="${tab}"]`); const navItem = query(`.nav-item[data-tab="${tab}"]`);
@ -787,6 +810,7 @@ function showTab(tab: string): void {
return; return;
} }
navItem.classList.add('active'); navItem.classList.add('active');
navItem.setAttribute('aria-current', 'page');
byId(tab + 'Tab').classList.add('active'); byId(tab + 'Tab').classList.add('active');
const titles: Record<string, string> = UI_TEXT.tabs; const titles: Record<string, string> = UI_TEXT.tabs;

View File

@ -89,6 +89,11 @@ body {
color: white; color: white;
} }
.nav-item:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.55);
}
.nav-item svg { .nav-item svg {
width: 20px; width: 20px;
height: 20px; height: 20px;