a11y: bring UI to WCAG 2.2 AAA compliance

Semantic HTML: lang attr, landmarks (header/main/region/complementary),
heading hierarchy (h1-h3), dl/dt/dd for info panel.

ARIA: labels on all icon buttons, aria-hidden on decorative icons,
progressbar role with dynamic aria-valuenow, aria-haspopup/expanded
on all menu triggers, role=listbox/option on playlist, aria-selected,
computed aria-labels on playlist rows.

Contrast: raised --textMuted/--textDim/--icon to AAA 7:1 ratios.

Focus: global :focus-visible outline, slider thumb glow, menu item
highlight, switch focus-within, row focus styles.

Target sizes: 44x44 hit areas on zoom/window/remove buttons via
::before pseudo-elements.

Keyboard: playlist arrow nav + Enter/Space activate + Alt+Arrow
reorder with live region announcements + move buttons. Speed menu,
subtitles menu, and recent menu all keyboard-navigable with
Arrow/Enter/Space/Escape. Dividers resizable via Arrow keys.

Dynamic document.title updates on video/folder load.
This commit is contained in:
Your Name
2026-02-19 16:35:19 +02:00
parent 600188eb1a
commit cd362a29b1
11 changed files with 956 additions and 662 deletions
+101 -4
View File
@@ -9,6 +9,7 @@ import {
clamp, fmtTime, fmtBytes, fmtDate, fmtBitrate,
currentItem, cb,
} from './store';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
// ---- DOM refs ----
let contentGrid: HTMLElement;
@@ -34,6 +35,9 @@ let overallPct: HTMLElement;
let toast: HTMLElement;
let toastMsg: HTMLElement;
let infoGridEl: HTMLElement;
let winMinBtn: HTMLElement;
let winMaxBtn: HTMLElement;
let winCloseBtn: HTMLElement;
// Info panel elements
let infoFolder: HTMLElement;
@@ -80,6 +84,8 @@ export function initUI(): void {
autoplayChk = document.getElementById('autoplayChk') as HTMLInputElement;
chooseBtn = document.getElementById('chooseBtn')!;
chooseDropBtn = document.getElementById('chooseDropBtn')!;
chooseDropBtn.setAttribute('aria-haspopup', 'true');
chooseDropBtn.setAttribute('aria-expanded', 'false');
recentMenu = document.getElementById('recentMenu')!;
refreshBtn = document.getElementById('refreshBtn')!;
resetProgBtn = document.getElementById('resetProgBtn')!;
@@ -94,6 +100,29 @@ export function initUI(): void {
infoGridEl = document.getElementById('infoGrid')!;
player = document.getElementById('player') as HTMLVideoElement;
// --- Window controls ---
winMinBtn = document.getElementById('winMinBtn')!;
winMaxBtn = document.getElementById('winMaxBtn')!;
winCloseBtn = document.getElementById('winCloseBtn')!;
const appWindow = getCurrentWebviewWindow();
winMinBtn.onclick = () => appWindow.minimize();
winMaxBtn.onclick = () => appWindow.toggleMaximize();
winCloseBtn.onclick = () => appWindow.close();
// Update maximize icon based on window state
const updateMaxIcon = async () => {
try {
const maximized = await appWindow.isMaximized();
const icon = winMaxBtn.querySelector('i')!;
icon.className = maximized ? 'fa-solid fa-clone' : 'fa-solid fa-square';
} catch (_) {}
};
appWindow.onResized(() => { updateMaxIcon(); });
updateMaxIcon();
infoFolder = document.getElementById('infoFolder')!;
infoNext = document.getElementById('infoNext')!;
infoStruct = document.getElementById('infoStruct')!;
@@ -126,16 +155,42 @@ export function initUI(): void {
}
// --- Divider drag ---
divider.tabIndex = 0;
divider.setAttribute('role', 'separator');
divider.setAttribute('aria-orientation', 'vertical');
divider.setAttribute('aria-label', 'Resize panels');
divider.addEventListener('mousedown', (e) => {
draggingDivider = true;
document.body.style.userSelect = 'none';
e.preventDefault();
});
divider.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault(); e.stopPropagation();
const delta = e.key === 'ArrowLeft' ? -0.02 : 0.02;
const current = prefs?.split_ratio || 0.62;
prefs!.split_ratio = applySplit(current + delta);
savePrefsPatch({ split_ratio: prefs!.split_ratio });
}
});
dockDivider.tabIndex = 0;
dockDivider.setAttribute('role', 'separator');
dockDivider.setAttribute('aria-orientation', 'vertical');
dockDivider.setAttribute('aria-label', 'Resize dock panes');
dockDivider.addEventListener('mousedown', (e) => {
draggingDockDivider = true;
document.body.style.userSelect = 'none';
e.preventDefault();
});
dockDivider.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault(); e.stopPropagation();
const delta = e.key === 'ArrowLeft' ? -0.02 : 0.02;
const current = prefs?.dock_ratio || 0.62;
prefs!.dock_ratio = applyDockSplit(current + delta);
savePrefsPatch({ dock_ratio: prefs!.dock_ratio });
}
});
window.addEventListener('mouseup', async () => {
if (draggingDivider) {
@@ -255,6 +310,13 @@ export function initUI(): void {
else await openRecentMenu();
};
chooseDropBtn.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && recentOpen) {
closeRecentMenu();
chooseDropBtn.focus();
}
});
window.addEventListener('resize', () => { if (recentOpen) positionRecentMenu(); });
window.addEventListener('scroll', () => { if (recentOpen) positionRecentMenu(); }, true);
window.addEventListener('click', () => { if (recentOpen) closeRecentMenu(); });
@@ -324,13 +386,23 @@ export function updateNowHeader(it: VideoItem | null): void {
}
export function updateOverall(): void {
if (!library) { overallBar.style.width = '0%'; overallPct.textContent = '-'; return; }
if (!library) {
overallBar.style.width = '0%'; overallPct.textContent = '-';
const pb = overallBar.parentElement;
if (pb) pb.setAttribute('aria-valuenow', '0');
return;
}
if (library.overall_progress === null || library.overall_progress === undefined) {
overallBar.style.width = '0%'; overallPct.textContent = '-'; return;
overallBar.style.width = '0%'; overallPct.textContent = '-';
const pb = overallBar.parentElement;
if (pb) pb.setAttribute('aria-valuenow', '0');
return;
}
const p = clamp(library.overall_progress, 0, 100);
overallBar.style.width = `${p.toFixed(1)}%`;
overallPct.textContent = `${p.toFixed(1)}%`;
const progressBarEl = overallBar.parentElement;
if (progressBarEl) progressBarEl.setAttribute('aria-valuenow', String(Math.round(p)));
}
export function updateInfoPanel(): void {
@@ -470,6 +542,7 @@ function positionRecentMenu(): void {
export function closeRecentMenu(): void {
recentOpen = false;
recentMenu.style.display = 'none';
chooseDropBtn?.setAttribute('aria-expanded', 'false');
}
export async function openRecentMenu(): Promise<void> {
@@ -486,6 +559,8 @@ export async function openRecentMenu(): Promise<void> {
for (const it of res.items) {
const row = document.createElement('div');
row.className = 'dropItem';
row.setAttribute('role', 'menuitem');
row.tabIndex = -1;
row.dataset.tooltip = it.name;
row.dataset.tooltipDesc = it.path;
@@ -497,9 +572,10 @@ export async function openRecentMenu(): Promise<void> {
name.className = 'dropName';
name.textContent = it.name;
const removeBtn = document.createElement('div');
const removeBtn = document.createElement('button');
removeBtn.className = 'dropRemove';
removeBtn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
removeBtn.setAttribute('aria-label', `Remove ${it.name}`);
removeBtn.innerHTML = '<i class="fa-solid fa-xmark" aria-hidden="true"></i>';
removeBtn.onclick = async (e) => {
e.stopPropagation();
try {
@@ -522,12 +598,33 @@ export async function openRecentMenu(): Promise<void> {
if (!info || !info.ok) { notify('Folder not available.'); return; }
await cb.onLibraryLoaded?.(info, true);
};
row.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault(); e.stopPropagation();
const next = row.nextElementSibling as HTMLElement | null;
if (next && next.classList.contains('dropItem')) next.focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault(); e.stopPropagation();
const prev = row.previousElementSibling as HTMLElement | null;
if (prev && prev.classList.contains('dropItem')) prev.focus();
} else if (e.key === 'Escape' || e.key === 'Tab') {
e.preventDefault(); e.stopPropagation();
closeRecentMenu();
chooseDropBtn.focus();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.stopPropagation();
row.click();
}
});
recentMenu.appendChild(row);
}
}
positionRecentMenu();
recentMenu.style.display = 'block';
recentOpen = true;
chooseDropBtn.setAttribute('aria-expanded', 'true');
const first = recentMenu.querySelector('.dropItem') as HTMLElement | null;
if (first) first.focus();
} catch (_) { closeRecentMenu(); }
}