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

@@ -21,6 +21,9 @@ let dragFromIndex: number | null = null;
let dropTargetIndex: number | null = null;
let dropAfter = false;
/** True while a drag-reorder is in progress. Used to guard against renderList during drag. */
export function isDragging(): boolean { return dragFromIndex !== null; }
// ---- Scrollbar state ----
let scrollbarHideTimer: ReturnType<typeof setTimeout> | null = null;
let scrollbarDragging = false;
@@ -32,6 +35,8 @@ let updateListFades: () => void = () => {};
export function initPlaylist(): void {
listEl = document.getElementById('list')!;
listEl.setAttribute('role', 'listbox');
listEl.setAttribute('aria-label', 'Playlist');
emptyHint = document.getElementById('emptyHint')!;
listScrollbar = document.getElementById('listScrollbar')!;
listScrollbarThumb = document.getElementById('listScrollbarThumb')!;
@@ -81,6 +86,13 @@ export function initPlaylist(): void {
window.addEventListener('touchend', endDrag);
}
// Allow internal DnD on the list container
if (listEl) {
listEl.addEventListener('dragenter', (e) => { e.preventDefault(); });
listEl.addEventListener('dragover', (e) => { e.preventDefault(); });
listEl.addEventListener('drop', (e) => { e.preventDefault(); });
}
if (listEl) {
updateListFades = () => {
const atTop = listEl.scrollTop < 5;
@@ -98,6 +110,14 @@ export function initPlaylist(): void {
setTimeout(updateListFades, 100);
setTimeout(updateListFades, 500);
}
// Live region for reorder announcements
const liveRegion = document.createElement('div');
liveRegion.id = 'playlistLive';
liveRegion.setAttribute('aria-live', 'polite');
liveRegion.setAttribute('aria-atomic', 'true');
liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);';
document.body.appendChild(liveRegion);
}
export function updateScrollbar(): void {
@@ -151,7 +171,7 @@ async function reorderPlaylistByGap(fromIdx: number, targetIdx: number, after: b
await cb.refreshCurrentVideoMeta?.();
}
}
} catch (_) {}
} catch (err) { console.error('reorderPlaylistByGap failed:', err); }
}
export function renderTreeSvg(it: VideoItem): SVGSVGElement {
@@ -246,12 +266,56 @@ export function renderList(): void {
row.className = 'row' + (it.index === currentIndex ? ' active' : '');
row.draggable = true;
row.dataset.index = String(it.index);
row.setAttribute('role', 'option');
row.setAttribute('aria-selected', it.index === currentIndex ? 'true' : 'false');
row.tabIndex = 0;
// Computed aria-label
const durStr = it.duration ? `${fmtTime(it.watched || 0)} / ${fmtTime(it.duration)}` : `${fmtTime(it.watched || 0)} watched`;
const statusStr = it.index === currentIndex ? 'Now playing' : it.finished ? 'Done' : '';
row.setAttribute('aria-label', `${String(displayIndex + 1).padStart(padN, '0')}. ${it.title || it.name} - ${durStr}${statusStr ? ' - ' + statusStr : ''}`);
row.onclick = () => {
if (dragFromIndex !== null) return;
cb.loadIndex?.(it.index, computeResumeTime(it), true);
};
row.addEventListener('keydown', (e) => {
const key = e.key;
if (key === 'Enter' || key === ' ') {
e.preventDefault(); e.stopPropagation();
cb.loadIndex?.(it.index, computeResumeTime(it), true);
} else if (key === 'ArrowDown' && !e.altKey) {
e.preventDefault(); e.stopPropagation();
const next = row.nextElementSibling as HTMLElement | null;
if (next && next.classList.contains('row')) next.focus();
} else if (key === 'ArrowUp' && !e.altKey) {
e.preventDefault(); e.stopPropagation();
const prev = row.previousElementSibling as HTMLElement | null;
if (prev && prev.classList.contains('row')) prev.focus();
} else if (e.altKey && key === 'ArrowDown' && displayIndex < library!.items!.length - 1) {
e.preventDefault(); e.stopPropagation();
reorderPlaylistByGap(it.index, library!.items![displayIndex + 1].index, true).then(() => {
setTimeout(() => {
const moved = listEl.querySelector(`[data-index="${it.index}"]`) as HTMLElement | null;
if (moved) moved.focus();
const lr = document.getElementById('playlistLive');
if (lr) lr.textContent = `Moved to position ${displayIndex + 2}`;
}, 100);
});
} else if (e.altKey && key === 'ArrowUp' && displayIndex > 0) {
e.preventDefault(); e.stopPropagation();
reorderPlaylistByGap(it.index, library!.items![displayIndex - 1].index, false).then(() => {
setTimeout(() => {
const moved = listEl.querySelector(`[data-index="${it.index}"]`) as HTMLElement | null;
if (moved) moved.focus();
const lr = document.getElementById('playlistLive');
if (lr) lr.textContent = `Moved to position ${displayIndex}`;
}, 100);
});
}
});
row.addEventListener('dragstart', (e) => {
dragFromIndex = Number(row.dataset.index);
row.classList.add('dragging');
@@ -268,8 +332,14 @@ export function renderList(): void {
clearDropIndicators();
});
row.addEventListener('dragenter', (e) => {
e.preventDefault();
e.stopPropagation();
});
row.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer!.dropEffect = 'move';
const rect = row.getBoundingClientRect();
const y = e.clientY - rect.top;
@@ -280,7 +350,7 @@ export function renderList(): void {
row.classList.add(after ? 'drop-after' : 'drop-before');
});
row.addEventListener('drop', (e) => e.preventDefault());
row.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); });
const left = document.createElement('div');
left.className = 'left';
@@ -316,7 +386,44 @@ export function renderList(): void {
else if (it.finished) { tag.classList.add('done'); tag.textContent = 'Done'; }
else { tag.classList.add('hidden'); tag.textContent = ''; }
// Move buttons for keyboard reorder alternative
const moveWrap = document.createElement('div');
moveWrap.className = 'moveWrap';
if (displayIndex > 0) {
const moveUp = document.createElement('button');
moveUp.className = 'moveBtn';
moveUp.setAttribute('aria-label', 'Move up');
moveUp.innerHTML = '<i class="fa-solid fa-chevron-up" aria-hidden="true"></i>';
moveUp.tabIndex = -1;
moveUp.addEventListener('click', (e) => {
e.stopPropagation();
reorderPlaylistByGap(it.index, library!.items![displayIndex - 1].index, false).then(() => {
const lr = document.getElementById('playlistLive');
if (lr) lr.textContent = `Moved to position ${displayIndex}`;
});
});
moveWrap.appendChild(moveUp);
}
if (displayIndex < library!.items!.length - 1) {
const moveDown = document.createElement('button');
moveDown.className = 'moveBtn';
moveDown.setAttribute('aria-label', 'Move down');
moveDown.innerHTML = '<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>';
moveDown.tabIndex = -1;
moveDown.addEventListener('click', (e) => {
e.stopPropagation();
reorderPlaylistByGap(it.index, library!.items![displayIndex + 1].index, true).then(() => {
const lr = document.getElementById('playlistLive');
if (lr) lr.textContent = `Moved to position ${displayIndex + 2}`;
});
});
moveWrap.appendChild(moveDown);
}
row.appendChild(left);
row.appendChild(moveWrap);
row.appendChild(tag);
listEl.appendChild(row);
}