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

View File

@@ -17,6 +17,9 @@ export function initSubtitles(): void {
subsBtn = document.getElementById('subsBtn')!;
subsMenu = document.getElementById('subsMenu')!;
subsBtn.setAttribute('aria-haspopup', 'true');
subsBtn.setAttribute('aria-expanded', 'false');
subsBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!library) return;
@@ -24,6 +27,13 @@ export function initSubtitles(): void {
else await openSubsMenu();
});
subsBtn.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && subsMenuOpen) {
closeSubsMenu();
subsBtn.focus();
}
});
window.addEventListener('click', () => { if (subsMenuOpen) closeSubsMenu(); });
subsMenu.addEventListener('click', (e) => e.stopPropagation());
}
@@ -92,6 +102,29 @@ export function clearSubtitles(): void {
export function closeSubsMenu(): void {
subsMenuOpen = false;
subsMenu?.classList.remove('show');
subsBtn?.setAttribute('aria-expanded', 'false');
}
function menuItemKeyHandler(e: KeyboardEvent): void {
const item = e.currentTarget as HTMLElement;
if (e.key === 'ArrowDown') {
e.preventDefault(); e.stopPropagation();
let next = item.nextElementSibling as HTMLElement | null;
while (next && !next.classList.contains('subsMenuItem')) next = next.nextElementSibling as HTMLElement | null;
if (next) next.focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault(); e.stopPropagation();
let prev = item.previousElementSibling as HTMLElement | null;
while (prev && !prev.classList.contains('subsMenuItem')) prev = prev.previousElementSibling as HTMLElement | null;
if (prev) prev.focus();
} else if (e.key === 'Escape' || e.key === 'Tab') {
e.preventDefault(); e.stopPropagation();
closeSubsMenu();
subsBtn.focus();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.stopPropagation();
item.click();
}
}
export async function openSubsMenu(): Promise<void> {
@@ -111,7 +144,10 @@ export async function openSubsMenu(): Promise<void> {
for (const sub of available.sidecar) {
const item = document.createElement('div');
item.className = 'subsMenuItem';
item.innerHTML = `<i class="fa-solid fa-file-lines"></i> ${sub.label} <span style="opacity:.5;font-size:10px;margin-left:auto;">${sub.format}</span>`;
item.setAttribute('role', 'menuitem');
item.tabIndex = -1;
item.addEventListener('keydown', menuItemKeyHandler);
item.innerHTML = `<i class="fa-solid fa-file-lines" aria-hidden="true"></i> ${sub.label} <span style="opacity:.5;font-size:10px;margin-left:auto;">${sub.format}</span>`;
item.onclick = async () => {
closeSubsMenu();
const res = await api.loadSidecarSubtitle(sub.path);
@@ -142,7 +178,10 @@ export async function openSubsMenu(): Promise<void> {
for (const track of available.embedded) {
const item = document.createElement('div');
item.className = 'subsMenuItem embedded';
item.innerHTML = `<i class="fa-solid fa-film"></i> ${track.label} <span style="opacity:.5;font-size:10px;margin-left:auto;">${track.codec}</span>`;
item.setAttribute('role', 'menuitem');
item.tabIndex = -1;
item.addEventListener('keydown', menuItemKeyHandler);
item.innerHTML = `<i class="fa-solid fa-film" aria-hidden="true"></i> ${track.label} <span style="opacity:.5;font-size:10px;margin-left:auto;">${track.codec}</span>`;
item.onclick = async () => {
closeSubsMenu();
const res = await api.extractEmbeddedSubtitle(track.index);
@@ -169,7 +208,10 @@ export async function openSubsMenu(): Promise<void> {
// "Load from file" option
const loadItem = document.createElement('div');
loadItem.className = 'subsMenuItem';
loadItem.innerHTML = '<i class="fa-solid fa-file-import"></i> Load from file...';
loadItem.setAttribute('role', 'menuitem');
loadItem.tabIndex = -1;
loadItem.addEventListener('keydown', menuItemKeyHandler);
loadItem.innerHTML = '<i class="fa-solid fa-file-import" aria-hidden="true"></i> Load from file...';
loadItem.onclick = async () => {
closeSubsMenu();
try {
@@ -187,7 +229,10 @@ export async function openSubsMenu(): Promise<void> {
// "Disable" option
const disableItem = document.createElement('div');
disableItem.className = 'subsMenuItem';
disableItem.innerHTML = '<i class="fa-solid fa-xmark"></i> Disable subtitles';
disableItem.setAttribute('role', 'menuitem');
disableItem.tabIndex = -1;
disableItem.addEventListener('keydown', menuItemKeyHandler);
disableItem.innerHTML = '<i class="fa-solid fa-xmark" aria-hidden="true"></i> Disable subtitles';
disableItem.onclick = () => {
closeSubsMenu();
try {
@@ -203,4 +248,7 @@ export async function openSubsMenu(): Promise<void> {
subsMenu.classList.add('show');
subsMenuOpen = true;
subsBtn.setAttribute('aria-expanded', 'true');
const first = subsMenu.querySelector('.subsMenuItem') as HTMLElement | null;
if (first) first.focus();
}