Skip to content

Commit 5538fd8

Browse files
authored
Polish preview accessibility and UX (#97)
1 parent a543c31 commit 5538fd8

8 files changed

Lines changed: 228 additions & 43 deletions

File tree

src/features/blpPreview.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -583,7 +583,7 @@ class BlpPreviewProvider implements vscode.CustomReadonlyEditorProvider<BlpDocum
583583
<canvas id="canvas3d" class="stage-canvas" width="1" height="1" style="display:none;"></canvas>
584584
</div>
585585
<canvas id="gizmo" class="gizmo" width="80" height="80"></canvas>
586-
<div id="loadingOverlay" class="wv-loading-overlay visible">
586+
<div id="loadingOverlay" class="wv-loading-overlay visible" role="status" aria-live="polite" aria-busy="true" aria-hidden="false">
587587
<div class="wv-spinner"></div>
588588
<div id="loadingText" class="wv-loading-text">Loading...</div>
589589
</div>
@@ -644,10 +644,14 @@ class BlpPreviewProvider implements vscode.CustomReadonlyEditorProvider<BlpDocum
644644
if (text) loadingText.textContent = text;
645645
if (isLoading) {
646646
overlay.classList.add('visible');
647+
overlay.setAttribute('aria-busy', 'true');
648+
overlay.setAttribute('aria-hidden', 'false');
647649
stage.classList.add('loading-stage');
648650
debug('loading on: ' + (text || ''));
649651
} else {
650652
overlay.classList.remove('visible');
653+
overlay.setAttribute('aria-busy', 'false');
654+
overlay.setAttribute('aria-hidden', 'true');
651655
stage.classList.remove('loading-stage');
652656
debug('loading off');
653657
}

src/features/mpqViewer.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,13 @@ function buildHtml(webview: vscode.Webview, archiveName: string, scriptUri: vsco
321321
position: relative;
322322
}
323323
.row:hover { background: var(--hover); }
324+
.row:focus-visible { outline: 1px solid var(--vscode-focusBorder, #007fd4); outline-offset: -1px; }
325+
.row-select {
326+
flex: 1; min-width: 0; display: flex; align-items: center; gap: 5px;
327+
padding: 0; border: 0; background: transparent; color: inherit; font: inherit;
328+
text-align: left; cursor: pointer; user-select: none;
329+
}
330+
.row-select:focus-visible { outline: 1px solid var(--vscode-focusBorder, #007fd4); outline-offset: -1px; }
324331
.row.selected { background: var(--active); color: var(--active-fg); }
325332
.row.selected .size, .row.selected .folder-meta { color: var(--active-fg); opacity: 0.75; }
326333
.row.hidden,
@@ -403,7 +410,8 @@ function buildHtml(webview: vscode.Webview, archiveName: string, scriptUri: vsco
403410
.row-action svg { width: 13px; height: 13px; fill: currentColor; }
404411
.row-action span { line-height: 1; }
405412
.row:hover .row-action,
406-
.row.selected .row-action {
413+
.row.selected .row-action,
414+
.row:focus-within .row-action {
407415
opacity: 0.92;
408416
pointer-events: auto;
409417
}
@@ -470,11 +478,11 @@ body.extracting .tree-wrap { cursor: progress; }
470478
<svg class="search-icon" width="14" height="14" viewBox="0 0 16 16" fill="var(--icon-fg)">
471479
<path d="M6.5 1a5.5 5.5 0 0 1 4.38 8.82l3.15 3.15-.71.71-3.15-3.15A5.5 5.5 0 1 1 6.5 1zm0 1a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9z"/>
472480
</svg>
473-
<input class="search-input" id="searchInput" type="text" placeholder="Filter files\u2026" autocomplete="off" spellcheck="false">
481+
<input class="search-input" id="searchInput" type="search" placeholder="Filter files\u2026" aria-label="Filter archive files" autocomplete="off" spellcheck="false">
474482
<span class="match-count" id="matchCount"></span>
475483
</div>
476484
477-
<div class="tree-wrap" id="treeWrap">
485+
<div class="tree-wrap" id="treeWrap" role="group" aria-label="Archive contents">
478486
<div class="wv-state"><span>Loading archive\u2026</span></div>
479487
</div>
480488

src/features/objModPreview.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1596,7 +1596,7 @@ function buildObjLoadingHtml(fileName: string): string {
15961596
return buildPage({
15971597
csp: "default-src 'none'; style-src 'unsafe-inline';",
15981598
title: escapeHtml(fileName),
1599-
body: `<div class="wv-state"><div class="wv-spinner"></div><div class="wv-loading-text">Loading ${escapeHtml(fileName)}…</div></div>`,
1599+
body: `<div class="wv-state" role="status" aria-live="polite" aria-busy="true"><div class="wv-spinner"></div><div class="wv-loading-text">Loading ${escapeHtml(fileName)}…</div></div>`,
16001600
});
16011601
}
16021602

@@ -1641,7 +1641,7 @@ async function buildHtml(
16411641
const metaLine = `WC3 ${typeLabel} object data - v${parsed.version} - ${summary} - ${metadataSource}` +
16421642
`${parsed.extended ? ' - extended (level/dataPt)' : ''}${combinedMeta}`;
16431643
const errorBanner = parsed.error
1644-
? `<div class="error">Parse error: ${escapeHtml(parsed.error)}</div>`
1644+
? `<div class="error" role="alert">Parse error: ${escapeHtml(parsed.error)}</div>`
16451645
: '';
16461646
const warningBanner = wtsWarning
16471647
? `<div class="warning">${escapeHtml(wtsWarning)}</div>`
@@ -2228,6 +2228,10 @@ textarea.edit-raw { min-height: 48px; line-height: 1.4; padding: 4px 6px; resize
22282228
background: rgba(0,0,0,0.5);
22292229
}
22302230
.ab-overlay[hidden] { display: none; }
2231+
.ab-sr-only {
2232+
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
2233+
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
2234+
}
22312235
.ab-modal {
22322236
display: flex;
22332237
flex-direction: column;
@@ -2315,8 +2319,13 @@ textarea.edit-raw { min-height: 48px; line-height: 1.4; padding: 4px 6px; resize
23152319
border-radius: 4px;
23162320
cursor: pointer;
23172321
text-align: center;
2322+
width: 100%;
2323+
font: inherit;
2324+
color: var(--fg);
2325+
background: transparent;
23182326
}
23192327
.ab-card:hover { border-color: var(--vscode-focusBorder, #007fd4); background: var(--hover); }
2328+
.ab-card:focus-visible { outline: 1px solid var(--vscode-focusBorder, #007fd4); outline-offset: -1px; }
23202329
.ab-card .object-icon { width: 48px; height: 48px; }
23212330
.model-thumb,
23222331
.object-icon.model-thumb { background: var(--model-bg); }
@@ -2546,6 +2555,7 @@ tr.hidden { display: none; }
25462555
}
25472556
.splitter:hover,
25482557
.splitter.dragging { opacity: 1; background: var(--vscode-textLink-foreground, var(--fg)); }
2558+
.splitter:focus-visible { outline: 1px solid var(--vscode-focusBorder, #007fd4); outline-offset: -1px; opacity: 1; }
25492559
.object-list {
25502560
min-width: 0;
25512561
min-height: 0;
@@ -2963,16 +2973,16 @@ ${gameDataBanner}
29632973
</div>
29642974
<div id="tree" class="tree"></div>
29652975
</aside>
2966-
<div class="splitter" id="splitter" title="Drag to resize"></div>
2976+
<div class="splitter" id="splitter" role="separator" aria-orientation="vertical" aria-label="Resize object list" tabindex="0" title="Drag to resize; use arrow keys to adjust"></div>
29672977
<main id="details" class="details"></main>
29682978
</div>
2969-
<div id="mpv-box" class="mpv-box" hidden>
2979+
<div id="mpv-box" class="mpv-box" role="dialog" aria-labelledby="mpv-name" hidden>
29702980
<div class="mpv-head" id="mpv-head">
29712981
<span id="mpv-name" class="mpv-name">Model</span>
29722982
<select id="mpv-anim" class="mpv-anim" title="Animation" hidden></select>
29732983
<button id="mpv-play" class="mpv-ctl" type="button" title="Pause" aria-label="Play/pause">⏸</button>
29742984
<button id="mpv-restart" class="mpv-ctl" type="button" title="Restart animation" aria-label="Restart">⟲</button>
2975-
<button id="mpv-help" class="mpv-ctl" type="button" tabindex="-1" aria-label="Controls help" title="Drag header to move · drag model to orbit · scroll to zoom · dropdown switches animation · ⟲ replays from start">?</button>
2985+
<button id="mpv-help" class="mpv-ctl" type="button" aria-label="Controls help" title="Drag header to move · drag model to orbit · scroll to zoom · dropdown switches animation · ⟲ replays from start">?</button>
29762986
<button id="mpv-close" class="mpv-close" type="button" title="Close preview" aria-label="Close preview">✕</button>
29772987
</div>
29782988
<div id="mpv-viewport" class="mpv-viewport">
@@ -2984,21 +2994,22 @@ ${gameDataBanner}
29842994
<canvas id="model-thumb-canvas" class="thumb-render-canvas" width="96" height="96" aria-hidden="true"></canvas>
29852995
<div id="model-thumb-viewport" class="thumb-render-canvas" aria-hidden="true"></div>
29862996
<div id="ab-overlay" class="ab-overlay" hidden>
2987-
<div class="ab-modal" role="dialog" aria-label="Asset browser">
2997+
<div class="ab-modal" role="dialog" aria-modal="true" aria-labelledby="ab-title">
2998+
<h2 id="ab-title" class="ab-sr-only">Asset browser</h2>
29882999
<div class="ab-head">
2989-
<div class="ab-tabs" id="ab-tabs">
2990-
<button class="ab-tab" type="button" data-tab="model">Models</button>
2991-
<button class="ab-tab" type="button" data-tab="icon">Icons</button>
2992-
<button class="ab-tab" type="button" data-tab="sound">Sounds</button>
2993-
<button class="ab-tab" type="button" data-tab="pathing">Pathing</button>
3000+
<div class="ab-tabs" id="ab-tabs" role="tablist" aria-label="Asset type">
3001+
<button class="ab-tab" type="button" role="tab" data-tab="model" aria-controls="ab-grid" aria-selected="false">Models</button>
3002+
<button class="ab-tab" type="button" role="tab" data-tab="icon" aria-controls="ab-grid" aria-selected="false">Icons</button>
3003+
<button class="ab-tab" type="button" role="tab" data-tab="sound" aria-controls="ab-grid" aria-selected="false">Sounds</button>
3004+
<button class="ab-tab" type="button" role="tab" data-tab="pathing" aria-controls="ab-grid" aria-selected="false">Pathing</button>
29943005
</div>
2995-
<input id="ab-search" class="ab-search" placeholder="Search game assets…" aria-label="Search assets">
3006+
<input id="ab-search" class="ab-search" type="search" placeholder="Search game assets…" aria-label="Search assets">
29963007
<select id="ab-source" class="ab-source" title="Filter by source" aria-label="Filter by source">
29973008
<option value="all">All</option>
29983009
<option value="wc3">WC3</option>
29993010
<option value="import">Imports</option>
30003011
</select>
3001-
<span id="ab-count" class="ab-count"></span>
3012+
<span id="ab-count" class="ab-count" role="status" aria-live="polite"></span>
30023013
<button id="ab-close" class="ab-close" type="button" title="Close (Esc)" aria-label="Close">✕</button>
30033014
</div>
30043015
<div id="ab-grid" class="ab-grid"></div>

src/features/preview/framework.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ main {
8484
opacity: 1;
8585
}`,
8686
body: `<main>
87-
<div class="wv-loading-overlay visible">
87+
<div class="wv-loading-overlay visible" role="status" aria-live="polite" aria-busy="true">
8888
<div>
8989
<div class="wv-spinner"></div>
9090
<div class="wv-loading-text">Loading ${escapeHtml(fileName)}...</div>
@@ -98,7 +98,7 @@ function buildErrorHtml(fileName: string, message: string): string {
9898
return buildPage({
9999
csp: "default-src 'none'; style-src 'unsafe-inline';",
100100
title: escapeHtml(fileName),
101-
body: `<div class="wv-state">
101+
body: `<div class="wv-state" role="alert">
102102
<span>Failed to load ${escapeHtml(fileName)}</span>
103103
<span class="err">${escapeHtml(message)}</span>
104104
</div>`,

src/features/webviewShared.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ export function sep(): string {
321321
*/
322322
export function spinnerOverlay(textId: string, initiallyVisible = true): string {
323323
const cls = initiallyVisible ? 'wv-loading-overlay visible' : 'wv-loading-overlay';
324-
return `<div class="${cls}">
324+
return `<div class="${cls}" role="status" aria-live="polite" aria-busy="${initiallyVisible}">
325325
<div class="wv-spinner"></div>
326326
<div id="${textId}" class="wv-loading-text">Loading...</div>
327327
</div>`;

src/webview/mpqViewerWebview.ts

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ const ICON_FOLDER = '<svg viewBox="0 0 16 16"><path d="M1 4.5A1.5 1.5 0 0 1 2.5
163163
const ICON_OPEN = '<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8.5 1a.5.5 0 0 0-1 0v6.793L5.354 5.646a.5.5 0 1 0-.708.708l3 3a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 7.793V1zM3 10.5a.5.5 0 0 0-1 0v3a.5.5 0 0 0 .5.5h11a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-1 0V13H3v-2.5z"/></svg>';
164164

165165
let selectedRow: HTMLElement | null = null;
166+
let filteredFolderState: Map<string, boolean> | null = null;
166167

167168
function renderNode(node: TreeNode, indent: number, container: HTMLElement): void {
168169
if (node.type === 'folder') renderFolder(node, indent, container);
@@ -173,9 +174,14 @@ function renderFolder(node: TreeNode, indent: number, container: HTMLElement): v
173174
const wrapper = document.createElement('div');
174175
wrapper.dataset['type'] = 'folder';
175176
wrapper.dataset['name'] = node.name.toLowerCase();
177+
wrapper.dataset['path'] = node.fullPath;
176178

177179
const row = document.createElement('div');
178180
row.className = 'row';
181+
row.setAttribute('role', 'button');
182+
row.tabIndex = 0;
183+
row.setAttribute('aria-expanded', 'false');
184+
row.setAttribute('aria-label', 'Expand folder ' + node.name);
179185
row.style.paddingLeft = (indent * 16 + 6) + 'px';
180186
row.innerHTML =
181187
'<span class="chevron">' + ICON_CHEVRON + '</span>' +
@@ -188,9 +194,17 @@ function renderFolder(node: TreeNode, indent: number, container: HTMLElement): v
188194

189195
for (const child of node.children) renderNode(child, indent + 1, children);
190196

191-
row.addEventListener('click', () => {
197+
const toggleFolder = () => {
192198
const collapsed = wrapper.classList.toggle('collapsed');
193199
children.classList.toggle('collapsed', collapsed);
200+
row.setAttribute('aria-expanded', String(!collapsed));
201+
row.setAttribute('aria-label', (collapsed ? 'Expand' : 'Collapse') + ' folder ' + node.name);
202+
};
203+
row.addEventListener('click', toggleFolder);
204+
row.addEventListener('keydown', e => {
205+
if (e.key !== 'Enter' && e.key !== ' ') return;
206+
e.preventDefault();
207+
toggleFolder();
194208
});
195209

196210
wrapper.appendChild(row);
@@ -239,6 +253,12 @@ function renderFile(node: TreeNode, indent: number, container: HTMLElement): voi
239253
size.className = 'size';
240254
size.textContent = fmtSize(node.entry!.normalSize);
241255

256+
const selectBtn = document.createElement('button');
257+
selectBtn.className = 'row-select';
258+
selectBtn.type = 'button';
259+
selectBtn.setAttribute('aria-label', description ? `${node.fullPath} - ${description}` : node.fullPath);
260+
selectBtn.setAttribute('aria-pressed', 'false');
261+
242262
// Action button — only visible on hover/selection
243263
const openBtn = document.createElement('button');
244264
openBtn.className = 'row-action';
@@ -250,22 +270,36 @@ function renderFile(node: TreeNode, indent: number, container: HTMLElement): voi
250270
vscode.postMessage({ type: 'openFile', name: node.fullPath });
251271
});
252272

253-
row.appendChild(badge);
254-
row.appendChild(fileMain);
273+
selectBtn.appendChild(badge);
274+
selectBtn.appendChild(fileMain);
275+
selectBtn.appendChild(size);
276+
row.appendChild(selectBtn);
255277
row.appendChild(openBtn);
256-
row.appendChild(size);
257278

258279
// Click selects the row (does NOT immediately open)
259-
row.addEventListener('click', () => {
280+
const selectRow = () => {
260281
if (selectedRow) selectedRow.classList.remove('selected');
282+
if (selectedRow) selectedRow.querySelector<HTMLButtonElement>('.row-select')?.setAttribute('aria-pressed', 'false');
261283
row.classList.add('selected');
284+
selectBtn.setAttribute('aria-pressed', 'true');
262285
selectedRow = row;
263-
});
286+
};
287+
selectBtn.addEventListener('click', selectRow);
264288

265289
// Double-click opens
266-
row.addEventListener('dblclick', () => {
290+
const openFile = () => {
267291
if (busy) return;
268292
vscode.postMessage({ type: 'openFile', name: node.fullPath });
293+
};
294+
selectBtn.addEventListener('dblclick', openFile);
295+
selectBtn.addEventListener('keydown', e => {
296+
if (e.key === 'Enter') {
297+
e.preventDefault();
298+
openFile();
299+
} else if (e.key === ' ') {
300+
e.preventDefault();
301+
selectRow();
302+
}
269303
});
270304

271305
container.appendChild(row);
@@ -278,11 +312,28 @@ function applyFilter(query: string): void {
278312
const allFiles = treeWrap.querySelectorAll<HTMLElement>('[data-type="file"]');
279313
let shown = 0;
280314

315+
if (q && !filteredFolderState) {
316+
filteredFolderState = new Map<string, boolean>();
317+
treeWrap.querySelectorAll<HTMLElement>('[data-type="folder"]').forEach(folder => {
318+
filteredFolderState!.set(folder.dataset['path'] ?? '', !folder.classList.contains('collapsed'));
319+
});
320+
}
321+
281322
if (!q) {
282323
allFiles.forEach(r => r.classList.remove('hidden'));
283324
treeWrap.querySelectorAll<HTMLElement>('[data-type="folder"]').forEach(f => f.classList.remove('hidden'));
284-
treeWrap.querySelectorAll<HTMLElement>('.children').forEach(c => c.classList.add('collapsed'));
285-
treeWrap.querySelectorAll<HTMLElement>('[data-type="folder"]').forEach(f => f.classList.add('collapsed'));
325+
treeWrap.querySelectorAll<HTMLElement>('[data-type="folder"]').forEach(f => {
326+
const expanded = filteredFolderState?.get(f.dataset['path'] ?? '') ?? false;
327+
f.classList.toggle('collapsed', !expanded);
328+
const children = f.querySelector<HTMLElement>(':scope > .children');
329+
if (children) children.classList.toggle('collapsed', !expanded);
330+
const row = f.querySelector<HTMLElement>(':scope > .row');
331+
if (row) {
332+
row.setAttribute('aria-expanded', String(expanded));
333+
row.setAttribute('aria-label', (expanded ? 'Collapse' : 'Expand') + ' folder ' + (f.dataset['name'] ?? ''));
334+
}
335+
});
336+
filteredFolderState = null;
286337
matchCount.textContent = '';
287338
return;
288339
}
@@ -301,6 +352,11 @@ function applyFilter(query: string): void {
301352
el.classList.remove('collapsed');
302353
const childrenEl = el.querySelector('.children');
303354
if (childrenEl) childrenEl.classList.remove('collapsed');
355+
const row = el.querySelector<HTMLElement>(':scope > .row');
356+
if (row) {
357+
row.setAttribute('aria-expanded', 'true');
358+
row.setAttribute('aria-label', 'Collapse folder ' + (el.dataset['name'] ?? ''));
359+
}
304360
}
305361
}
306362

@@ -355,7 +411,7 @@ window.addEventListener('message', (e: MessageEvent) => {
355411
}
356412

357413
if (msg.type === 'error') {
358-
treeWrap.innerHTML = '<div class="state"><span>Failed to read archive</span><span class="err">' + esc(msg.message ?? '') + '</span></div>';
414+
treeWrap.innerHTML = '<div class="state" role="alert"><span>Failed to read archive</span><span class="err">' + esc(msg.message ?? '') + '</span></div>';
359415
btnExtractAll.setAttribute('disabled', '');
360416
btnExportFolder.setAttribute('disabled', '');
361417
return;

0 commit comments

Comments
 (0)