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:
@@ -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(); }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user