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:
111
src/playlist.ts
111
src/playlist.ts
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user