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:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user