audit pass 7: ARIA, focus management, and label fixes

CSS:
- Add :focus-visible to sortable th, breadcrumb links, list links, cmd-item,
  combobox-option, typeahead-item, sidebar-sub-link, split-divider, stat-card
- Fix .lt-skip-link:focus to also include :focus-visible for spec compliance

JS:
- Mobile nav: add focus trap (_trapFocus), save/restore trigger focus, fix
  aria-hidden="false" to removeAttribute pattern, add document.contains guard
- Combobox: add aria-activedescendant on _moveFocus; add unique IDs to options;
  clear aria-activedescendant on close; wrap querySelectorAll in Array.from
- Typeahead: add aria-activedescendant on _moveFocus; add unique IDs to items;
  add aria-busy during async search; clear aria-activedescendant on select;
  wrap querySelectorAll in Array.from
- Command palette: add unique IDs to items; set/clear aria-activedescendant
  on move and mouseenter; clear on close
- Lightbox: add document.contains guard on focus setTimeout
- Stats filter: add Enter/Space keyboard handler for role="button" cards

HTML:
- Stat cards: add role="button" tabindex="0" aria-label (interactive divs)
- Advanced filter selects: add id/for associations to all 3 label+select pairs
- Accordion SVG icons: add aria-hidden="true" (decorative)
- Range input: add aria-label, aria-valuemin/max/now
- Wizard form controls: add id/for to all 4 label+input/select pairs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 20:02:15 -04:00
parent d08007cdd7
commit b84d71dd7a
3 changed files with 76 additions and 38 deletions
+39 -11
View File
@@ -586,14 +586,16 @@
---------------------------------------------------------------- */
function initStatsFilter() {
document.querySelectorAll('.lt-stat-card[data-filter-key]').forEach(card => {
card.addEventListener('click', () => {
const _activate = () => {
const key = card.dataset.filterKey, val = card.dataset.filterVal;
const wasActive = card.classList.contains('active');
document.querySelectorAll('.lt-stat-card').forEach(c => c.classList.remove('active'));
if (!wasActive) card.classList.add('active');
if (typeof global.lt_onStatFilter === 'function')
global.lt_onStatFilter(wasActive ? null : key, wasActive ? null : val);
});
};
card.addEventListener('click', _activate);
card.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _activate(); } });
});
}
@@ -860,6 +862,8 @@
const ov = document.getElementById('lt-cmd-overlay'); if (!ov) return;
ov.classList.remove('is-open');
_unlockScroll();
const inp = document.querySelector('#lt-cmd-palette .lt-cmd-input');
if (inp) inp.removeAttribute('aria-activedescendant');
if (_cpTrigger) { if (document.contains(_cpTrigger)) _cpTrigger.focus(); _cpTrigger = null; }
}
@@ -903,7 +907,7 @@
if (!groups[g] || !groups[g].length) return;
html += '<div class="lt-cmd-section-label">' + escHtml(g) + '</div>';
groups[g].forEach(cmd => {
html += '<div class="lt-cmd-item' + (idx === 0 ? ' is-selected' : '') + '" data-cmd-id="' + escHtml(cmd.id) + '">' +
html += '<div class="lt-cmd-item' + (idx === 0 ? ' is-selected' : '') + '" id="lt-cmd-item-' + idx + '" data-cmd-id="' + escHtml(cmd.id) + '">' +
'<span class="lt-cmd-item-icon">' + escHtml(cmd.icon || '◦') + '</span>' +
'<span class="lt-cmd-item-label">' + _cpHighlight(cmd.label, query) + '</span>' +
(cmd.kbd ? '<span class="lt-cmd-item-kbd">' + escHtml(cmd.kbd) + '</span>' : '') +
@@ -913,10 +917,15 @@
});
results.innerHTML = html;
results.querySelectorAll('.lt-cmd-item').forEach((item, i) => {
const pal = document.getElementById('lt-cmd-palette');
const inp = pal && pal.querySelector('.lt-cmd-input');
const allItems = Array.from(results.querySelectorAll('.lt-cmd-item'));
if (inp && allItems[0]) inp.setAttribute('aria-activedescendant', allItems[0].id);
allItems.forEach((item, i) => {
item.addEventListener('mouseenter', () => {
results.querySelectorAll('.lt-cmd-item').forEach(x => x.classList.remove('is-selected'));
allItems.forEach(x => x.classList.remove('is-selected'));
item.classList.add('is-selected'); _cpSelected = i;
if (inp) inp.setAttribute('aria-activedescendant', item.id);
});
item.addEventListener('click', () => _cpExec(item.dataset.cmdId));
});
@@ -941,6 +950,8 @@
_cpSelected = (_cpSelected + dir + items.length) % items.length;
items[_cpSelected] && items[_cpSelected].classList.add('is-selected');
items[_cpSelected] && items[_cpSelected].scrollIntoView({ block: 'nearest' });
const inp = ov.querySelector('.lt-cmd-input');
if (inp && items[_cpSelected]) inp.setAttribute('aria-activedescendant', items[_cpSelected].id);
}
function initCmdPalette(commands) {
@@ -1392,7 +1403,7 @@
Auto-inits on [id="lt-menu-btn"] + [id="lt-nav-drawer"]
Swipe right from left edge (≤ 20px) opens; swipe left closes.
================================================================ */
let _mnOpen = false;
let _mnOpen = false, _mnTrigger = null;
function _mnSetOpen(open) {
_mnOpen = open;
@@ -1402,20 +1413,28 @@
if (!drawer) return;
if (open) {
_mnTrigger = (document.activeElement && document.activeElement !== document.body) ? document.activeElement : null;
drawer.classList.add('open');
drawer.setAttribute('aria-hidden', 'false');
drawer.removeAttribute('aria-hidden');
if (overlay) overlay.classList.add('open');
if (btn) { btn.classList.add('open'); btn.setAttribute('aria-expanded', 'true'); }
document.body.style.overflow = 'hidden';
// Trap focus inside drawer
if (!drawer._mnTrapHandler) {
drawer._mnTrapHandler = e => { if (e.key === 'Tab') _trapFocus(drawer, e); };
drawer.addEventListener('keydown', drawer._mnTrapHandler);
}
const first = drawer.querySelector('button, a, [tabindex]');
if (first) setTimeout(() => first.focus(), 50);
if (first) setTimeout(() => { if (document.contains(first)) first.focus(); }, 50);
} else {
drawer.classList.remove('open');
drawer.setAttribute('aria-hidden', 'true');
if (overlay) overlay.classList.remove('open');
if (btn) { btn.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); }
document.body.style.overflow = '';
if (drawer._mnTrapHandler) { drawer.removeEventListener('keydown', drawer._mnTrapHandler); delete drawer._mnTrapHandler; }
if (_mnTrigger && document.contains(_mnTrigger)) { _mnTrigger.focus(); }
_mnTrigger = null;
}
bus.emit('mobileNav:' + (open ? 'open' : 'close'));
}
@@ -1877,6 +1896,7 @@
}
filtered.forEach((opt, i) => {
const el = document.createElement('div');
el.id = dropId + '-opt-' + i;
el.className = 'lt-combobox-option' + (selected.includes(opt.value) ? ' is-selected' : '');
el.setAttribute('role', 'option');
el.setAttribute('data-value', opt.value);
@@ -1901,16 +1921,18 @@
}
function _moveFocus(dir) {
const items = dropdown.querySelectorAll('.lt-combobox-option');
const items = Array.from(dropdown.querySelectorAll('.lt-combobox-option'));
if (!items.length) return;
focusedIdx = Math.max(0, Math.min(focusedIdx + dir, items.length - 1));
items.forEach((el, i) => el.classList.toggle('is-focused', i === focusedIdx));
items[focusedIdx].scrollIntoView({ block: 'nearest' });
inputEl.setAttribute('aria-activedescendant', items[focusedIdx].id);
}
function _setOpen(open) {
dropdown.classList.toggle('is-open', open);
inputEl.setAttribute('aria-expanded', open ? 'true' : 'false');
if (!open) { inputEl.removeAttribute('aria-activedescendant'); focusedIdx = -1; }
}
inputEl.addEventListener('input', () => { _setOpen(true); _renderDropdown(inputEl.value); });
inputEl.addEventListener('focus', () => { _setOpen(true); _renderDropdown(inputEl.value); });
@@ -1966,6 +1988,7 @@
const q = query.toLowerCase();
_items.forEach((item, i) => {
const el = document.createElement('div');
el.id = (inputEl.id || 'lt-ta') + '-item-' + i;
el.className = 'lt-typeahead-item';
el.setAttribute('role', 'option');
const safeItemLabel = escHtml(item.label);
@@ -1980,27 +2003,32 @@
async function _search(query) {
dropdown.innerHTML = '<div class="lt-typeahead-loading">Searching…</div>';
dropdown.classList.add('is-open');
inputEl.setAttribute('aria-busy', 'true');
try {
const results = typeof source === 'function' ? await source(query) : source.filter(i => i.label.toLowerCase().includes(query.toLowerCase()));
_render(results, query);
} catch(e) {
dropdown.innerHTML = '<div class="lt-typeahead-empty">Error loading results</div>';
} finally {
inputEl.setAttribute('aria-busy', 'false');
}
}
function _select(item) {
inputEl.value = item.label;
inputEl.removeAttribute('aria-activedescendant');
dropdown.classList.remove('is-open');
if (onSelect) onSelect(item);
bus.emit('typeahead:select', { item });
}
function _moveFocus(dir) {
const els = dropdown.querySelectorAll('.lt-typeahead-item');
const els = Array.from(dropdown.querySelectorAll('.lt-typeahead-item'));
if (!els.length) return;
_focusedIdx = Math.max(0, Math.min(_focusedIdx + dir, els.length - 1));
els.forEach((el, i) => el.classList.toggle('is-focused', i === _focusedIdx));
els[_focusedIdx].scrollIntoView({ block: 'nearest' });
inputEl.setAttribute('aria-activedescendant', els[_focusedIdx].id);
}
inputEl.addEventListener('input', () => {
@@ -2502,7 +2530,7 @@
_overlay.querySelector('.lt-lightbox-next').style.display = (loop || idx < _images.length - 1) && _images.length > 1 ? '' : 'none';
_overlay.classList.add('is-open');
_lockScroll();
setTimeout(() => el.focus?.(), 50);
setTimeout(() => { if (document.contains(el)) el.focus?.(); }, 50);
}
function _collect() {