fix: accessibility & quality audit pass 4+5

CSS:
- Add :active/:focus-visible to .lt-modal-close, .lt-drawer-right-close,
  .lt-notif-panel-clear, .lt-file-item-remove
- Add :focus-visible to .lt-accordion-header, .lt-tag-remove,
  .lt-combobox-tag-remove
- Add .lt-cmd-input-wrap:focus-within focus indicator (outline:none compensation)
- Add will-change: stroke-dashoffset to .lt-gauge-fill
- Add range slider :focus-visible thumb ring
- Fix .tok-cmt hardcoded #5c8c6a → var(--color-tok-cmt) w/ light-mode override
- Add .lt-skip-link component (visible on focus)
- Fix .lt-filter-group fieldset UA border reset

JS:
- Fix infinite scroll: store throttled handler ref so removeEventListener works
- Fix right drawer: remove close-button listeners in _rdClose (were never removed)
- Fix right drawer: add Tab focus trap (matches modal behaviour)
- Fix _cmdPaletteClose: restore focus to element that opened the palette
- Fix initSortTable: set aria-sort="ascending"/"descending"/"none" on th elements
- Fix switchTab: set aria-selected="true"/"false" on .lt-tab[data-tab] buttons
- Fix copy button timeout: guard with document.contains() before DOM mutation
- Fix combobox: add role=combobox, aria-expanded, aria-controls, role=listbox;
  toggle aria-expanded on open/close

HTML:
- Add skip nav link + id="main-content" on <main>
- Primary tab nav: add role=tablist, role=tab, aria-selected, aria-controls,
  id attrs; tab panels get role=tabpanel + aria-labelledby
- Tab bar demo: same ARIA wiring + aria-controls + role/labelledby on panels
- Sidebar filters: convert div+span to fieldset+legend for proper grouping
- Table sort headers: add aria-sort="none" (JS updates on click)
- Accordion: add aria-controls on headers, IDs on bodies
- Wizard: add aria-current="step" on active step indicator
- Table th: scope="col" on all column headers
- Row checkboxes: aria-label per ticket ID
- Worker metrics table: add <tbody>
- Progress bars: role=progressbar + aria-valuenow/min/max + aria-label
- Export + keyboard shortcuts modals: role=dialog, aria-modal, aria-labelledby

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 18:22:53 -04:00
parent 8585993602
commit fdcadad23b
3 changed files with 400 additions and 138 deletions
+99 -30
View File
@@ -73,7 +73,7 @@
function showToast(message, type, duration) {
type = type || 'info';
duration = duration || 3500;
if (_toastActive) { _toastQueue.push({ message, type, duration }); return; }
if (_toastActive) { if (_toastQueue.length < 12) _toastQueue.push({ message, type, duration }); return; }
_displayToast(message, type, duration);
}
@@ -214,7 +214,7 @@
_modalTriggers.set(el, document.activeElement);
}
el.classList.add('is-open');
el.setAttribute('aria-hidden', 'false');
el.removeAttribute('aria-hidden'); /* removing is correct; setting 'false' is an anti-pattern */
_lockScroll();
// Focus first focusable element
const first = el.querySelector(_FOCUSABLE);
@@ -263,16 +263,21 @@
lt.tabs.switch('panel-id')
---------------------------------------------------------------- */
function switchTab(panelId) {
document.querySelectorAll('.lt-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.lt-tab[data-tab]').forEach(t => {
t.classList.remove('active');
t.setAttribute('aria-selected', 'false');
});
document.querySelectorAll('.lt-tab-panel').forEach(p => p.classList.remove('active'));
const btn = document.querySelector('.lt-tab[data-tab="' + panelId + '"]');
const panel = document.getElementById(panelId);
if (btn) btn.classList.add('active');
if (btn) { btn.classList.add('active'); btn.setAttribute('aria-selected', 'true'); }
if (panel) panel.classList.add('active');
try { localStorage.setItem('lt_activeTab_' + location.pathname, panelId); } catch (_) {}
}
let _tabsInitialized = false;
function initTabs() {
if (_tabsInitialized) return; _tabsInitialized = true;
try {
const saved = localStorage.getItem('lt_activeTab_' + location.pathname);
if (saved && document.getElementById(saved)) { switchTab(saved); }
@@ -392,7 +397,9 @@
----------------------------------------------------------------
lt.sidebar.init()
---------------------------------------------------------------- */
let _sidebarInitialized = false;
function initSidebar() {
if (_sidebarInitialized) return; _sidebarInitialized = true;
document.querySelectorAll('[data-sidebar-toggle]').forEach(btn => {
const sidebar = document.getElementById(btn.dataset.sidebarToggle);
if (!sidebar) return;
@@ -429,8 +436,11 @@
lt.api.put / patch / delete
---------------------------------------------------------------- */
async function apiFetch(method, url, body) {
const opts = { method, headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()) };
if (body !== undefined) opts.body = JSON.stringify(body);
const hasBody = body !== undefined;
const headers = Object.assign({}, csrfHeaders());
if (hasBody) headers['Content-Type'] = 'application/json'; // Only set on requests with a body
const opts = { method, headers };
if (hasBody) opts.body = JSON.stringify(body);
let resp;
try { resp = await fetch(url, opts); } catch (err) { throw new Error('Network error: ' + err.message); }
let data;
@@ -536,9 +546,11 @@
const ths = Array.from(table.querySelectorAll('th[data-sort-key]'));
ths.forEach((th, colIdx) => {
let dir = 'asc';
th.setAttribute('aria-sort', 'none');
th.addEventListener('click', () => {
ths.forEach(h => h.removeAttribute('data-sort'));
ths.forEach(h => { h.removeAttribute('data-sort'); h.setAttribute('aria-sort', 'none'); });
th.setAttribute('data-sort', dir);
th.setAttribute('aria-sort', dir === 'asc' ? 'ascending' : 'descending');
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
@@ -625,7 +637,9 @@
});
}
let _accordionInitialized = false;
function initAccordion() {
if (_accordionInitialized) return; _accordionInitialized = true;
// Support both data-accordion attribute (HTML) and .lt-accordion-trigger class
document.querySelectorAll('[data-accordion], .lt-accordion-trigger').forEach(trigger => {
if (trigger.getAttribute('aria-expanded') === 'true') {
@@ -680,7 +694,11 @@
case 'right': top = r.top + sy + r.height / 2 - tr.height / 2; left = r.right + sx + 8; break;
default: top = r.top + sy - tr.height - 8; left = r.left + sx + r.width / 2 - tr.width / 2;
}
tip.style.cssText = 'position:absolute;top:' + Math.max(4, top) + 'px;left:' + Math.max(4, left) + 'px;z-index:9000';
const maxLeft = (global.scrollX || 0) + global.innerWidth - tr.width - 4;
const maxTop = (global.scrollY || 0) + global.innerHeight - tr.height - 4;
left = Math.max(4 + (global.scrollX || 0), Math.min(maxLeft, left));
top = Math.max(4 + (global.scrollY || 0), Math.min(maxTop, top));
tip.style.cssText = 'position:absolute;top:' + top + 'px;left:' + left + 'px';
requestAnimationFrame(() => tip.classList.add('is-visible'));
}
@@ -688,7 +706,10 @@
if (_tooltipEl) { _tooltipEl.remove(); _tooltipEl = null; }
}
let _tooltipInitialized = false;
function initTooltips() {
if (_tooltipInitialized) return;
_tooltipInitialized = true;
document.addEventListener('mouseover', e => { const a = e.target.closest('[data-tooltip]'); if (a) _tooltipShow(a); });
document.addEventListener('mouseout', e => { if (!e.target.closest('[data-tooltip]')) return; if (!e.relatedTarget || !e.relatedTarget.closest('[data-tooltip]')) _tooltipHide(); });
document.addEventListener('focusin', e => { const a = e.target.closest('[data-tooltip]'); if (a) _tooltipShow(a); });
@@ -718,7 +739,9 @@
} catch (_) { return false; }
}
let _copyInitialized = false;
function initCopyButtons() {
if (_copyInitialized) return; _copyInitialized = true;
document.addEventListener('click', async function (e) {
const btn = e.target.closest('[data-copy]'); if (!btn) return;
const orig = btn.textContent;
@@ -726,7 +749,7 @@
if (ok) {
btn.textContent = 'COPIED ✓'; btn.disabled = true;
if (btn.hasAttribute('data-copy-toast')) toast.success('Copied to clipboard');
setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 1500);
setTimeout(() => { if (document.contains(btn)) { btn.textContent = orig; btn.disabled = false; } }, 1500);
} else { toast.error('Copy failed'); }
});
}
@@ -750,9 +773,11 @@
}));
}
let _alertsInitialized = false;
function initAlerts() {
if (_alertsInitialized) return; _alertsInitialized = true;
document.addEventListener('click', function (e) {
const btn = e.target.closest('.lt-alert-dismiss'); if (!btn) return;
const btn = e.target.closest('.lt-alert-close, .lt-alert-dismiss'); if (!btn) return;
const al = btn.closest('.lt-alert'); if (al) dismissAlert(al);
});
document.querySelectorAll('.lt-alert[data-alert-auto-dismiss]').forEach(el => {
@@ -805,12 +830,13 @@
Command: { id, label, icon?, description?, kbd?, group?, tags?, action }
---------------------------------------------------------------- */
let _cpCommands = [], _cpSelected = 0;
let _cpCommands = [], _cpSelected = 0, _cpTrigger = null;
const _cpRecentKey = 'lt_cmd_recent';
function _cmdPaletteOpen() {
const ov = document.getElementById('lt-cmd-overlay'); if (!ov) return;
if (_mnOpen) _mnSetOpen(false);
_cpTrigger = (document.activeElement && document.activeElement !== document.body) ? document.activeElement : null;
ov.classList.add('is-open');
_lockScroll();
const palette = document.getElementById('lt-cmd-palette');
@@ -823,6 +849,7 @@
const ov = document.getElementById('lt-cmd-overlay'); if (!ov) return;
ov.classList.remove('is-open');
_unlockScroll();
if (_cpTrigger) { _cpTrigger.focus(); _cpTrigger = null; }
}
function _cpHighlight(text, q) {
@@ -960,12 +987,22 @@
function _showError(el, msg) {
el.classList.add('is-invalid'); el.classList.remove('is-valid');
let err = el.parentElement && el.parentElement.querySelector('.lt-field-error');
if (!err) { err = document.createElement('span'); err.className = 'lt-field-error'; if (el.parentElement) el.parentElement.appendChild(err); }
if (!err) {
err = document.createElement('span');
err.className = 'lt-field-error';
err.id = (el.id || ('lt-field-' + Math.random().toString(36).slice(2))) + '-err';
if (el.parentElement) el.parentElement.appendChild(err);
}
err.textContent = msg;
err.setAttribute('role', 'alert');
el.setAttribute('aria-describedby', err.id);
el.setAttribute('aria-invalid', 'true');
}
function _clearError(el) {
el.classList.remove('is-invalid'); el.classList.add('is-valid');
el.removeAttribute('aria-invalid');
el.removeAttribute('aria-describedby');
const err = el.parentElement && el.parentElement.querySelector('.lt-field-error');
if (err) err.remove();
}
@@ -990,7 +1027,7 @@
e.preventDefault();
const r = _validateForm(formEl);
if (r.valid && typeof onSubmit === 'function') onSubmit(formEl, e);
else if (!r.valid) r.errors[0].el.focus();
else if (!r.valid && r.errors.length) r.errors[0].el.focus();
});
}
@@ -1550,9 +1587,11 @@
if (triggerEl) _modalTriggers.set(drawer, triggerEl);
const first = drawer.querySelector(_FOCUSABLE);
if (first) setTimeout(() => first.focus(), 50);
// ESC to close
// ESC to close + Tab trap
drawer._rdKeyHandler = e => { if (e.key === 'Escape') _rdClose(drawer); };
document.addEventListener('keydown', drawer._rdKeyHandler);
drawer._rdTrapHandler = e => { if (e.key === 'Tab') _trapFocus(drawer, e); };
drawer.addEventListener('keydown', drawer._rdTrapHandler);
// Overlay click
if (ov) ov._rdClick = () => _rdClose(drawer);
if (ov) ov.addEventListener('click', ov._rdClick);
@@ -1570,8 +1609,12 @@
drawer.classList.remove('is-open');
drawer.setAttribute('aria-hidden', 'true');
if (ov) { ov.classList.remove('is-open'); if (ov._rdClick) { ov.removeEventListener('click', ov._rdClick); delete ov._rdClick; } }
drawer.querySelectorAll('[data-drawer-close]').forEach(btn => {
if (btn._rdHandler) { btn.removeEventListener('click', btn._rdHandler); delete btn._rdHandler; }
});
_unlockScroll();
if (drawer._rdKeyHandler) { document.removeEventListener('keydown', drawer._rdKeyHandler); delete drawer._rdKeyHandler; }
if (drawer._rdKeyHandler) { document.removeEventListener('keydown', drawer._rdKeyHandler); delete drawer._rdKeyHandler; }
if (drawer._rdTrapHandler) { drawer.removeEventListener('keydown', drawer._rdTrapHandler); delete drawer._rdTrapHandler; }
const trigger = _modalTriggers.get(drawer);
if (trigger) { trigger.focus(); _modalTriggers.delete(drawer); }
}
@@ -1791,6 +1834,15 @@
let focusedIdx = -1;
let filtered = [...options];
// ARIA combobox wiring
const dropId = dropdown.id || ('lt-cb-drop-' + Math.random().toString(36).slice(2));
dropdown.id = dropId;
dropdown.setAttribute('role', 'listbox');
inputEl.setAttribute('role', 'combobox');
inputEl.setAttribute('aria-expanded', 'false');
inputEl.setAttribute('aria-controls', dropId);
inputEl.setAttribute('aria-autocomplete', 'list');
function _renderTags() {
wrap.querySelectorAll('.lt-combobox-tag').forEach(t => t.remove());
selected.forEach(v => {
@@ -1816,7 +1868,8 @@
el.className = 'lt-combobox-option' + (selected.includes(opt.value) ? ' is-selected' : '');
el.setAttribute('role', 'option');
el.setAttribute('data-value', opt.value);
const hl = q ? opt.label.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>') : escHtml(opt.label);
const safeLabel = escHtml(opt.label);
const hl = q ? safeLabel.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>') : safeLabel;
el.innerHTML = `${opt.icon ? `<span class="icon">${escHtml(opt.icon)}</span>` : ''}<span>${hl}</span>`;
el.addEventListener('mousedown', e => { e.preventDefault(); _toggle(opt.value); });
dropdown.appendChild(el);
@@ -1843,13 +1896,17 @@
items[focusedIdx].scrollIntoView({ block: 'nearest' });
}
inputEl.addEventListener('input', () => { dropdown.classList.add('is-open'); _renderDropdown(inputEl.value); });
inputEl.addEventListener('focus', () => { dropdown.classList.add('is-open'); _renderDropdown(inputEl.value); });
function _setOpen(open) {
dropdown.classList.toggle('is-open', open);
inputEl.setAttribute('aria-expanded', open ? 'true' : 'false');
}
inputEl.addEventListener('input', () => { _setOpen(true); _renderDropdown(inputEl.value); });
inputEl.addEventListener('focus', () => { _setOpen(true); _renderDropdown(inputEl.value); });
inputEl.addEventListener('keydown', e => {
if (e.key === 'ArrowDown') { e.preventDefault(); _moveFocus(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); _moveFocus(-1); }
if (e.key === 'Enter') { e.preventDefault(); if (focusedIdx >= 0 && filtered[focusedIdx]) _toggle(filtered[focusedIdx].value); }
if (e.key === 'Escape') { dropdown.classList.remove('is-open'); }
if (e.key === 'Escape') { _setOpen(false); }
if (e.key === 'Backspace' && !inputEl.value && selected.length) { _toggle(selected[selected.length - 1]); }
});
inputWrap.addEventListener('mousedown', e => {
@@ -1857,7 +1914,7 @@
if (rmBtn) { e.preventDefault(); _toggle(rmBtn.dataset.value); return; }
inputEl.focus();
});
document.addEventListener('click', e => { if (!wrap.contains(e.target)) dropdown.classList.remove('is-open'); });
document.addEventListener('click', e => { if (!wrap.contains(e.target)) _setOpen(false); });
_renderTags();
_renderDropdown('');
@@ -1899,7 +1956,8 @@
const el = document.createElement('div');
el.className = 'lt-typeahead-item';
el.setAttribute('role', 'option');
const hl = item.label.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>');
const safeItemLabel = escHtml(item.label);
const hl = safeItemLabel.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>');
el.innerHTML = `${item.icon ? `<span class="icon">${escHtml(item.icon)}</span>` : ''}<span>${hl}</span>${item.meta ? `<span style="margin-left:auto;color:var(--text-muted);font-size:0.68rem">${escHtml(item.meta)}</span>` : ''}`;
el.addEventListener('mousedown', e => { e.preventDefault(); _select(item); });
dropdown.appendChild(el);
@@ -2119,8 +2177,9 @@
const dist = el.scrollHeight - el.scrollTop - el.clientHeight;
if (dist < threshold) _load();
}
scrollRoot.addEventListener('scroll', throttle(_onScroll, 150), { passive: true });
return { reset() { _done = false; _loading = false; }, stop() { scrollRoot.removeEventListener('scroll', _onScroll); } };
const _onScrollThrottled = throttle(_onScroll, 150);
scrollRoot.addEventListener('scroll', _onScrollThrottled, { passive: true });
return { reset() { _done = false; _loading = false; }, stop() { scrollRoot.removeEventListener('scroll', _onScrollThrottled); } };
}
},
};
@@ -2175,16 +2234,23 @@
if (first) setTimeout(() => first.focus(), 60);
}
let _wizBusy = false;
async function _next() {
if (validate) {
const ok = await validate(current + 1, _getStepData(current));
if (!ok) {
container.querySelectorAll('[data-wizard-indicator]')[current]?.classList.add('is-error');
return;
if (_wizBusy) return;
_wizBusy = true;
try {
if (validate) {
const ok = await validate(current + 1, _getStepData(current));
if (!ok) {
container.querySelectorAll('[data-wizard-indicator]')[current]?.classList.add('is-error');
return;
}
}
Object.assign(formData, _getStepData(current));
if (current < total - 1) { current++; _show(current); }
} finally {
_wizBusy = false;
}
Object.assign(formData, _getStepData(current));
if (current < total - 1) { current++; _show(current); }
}
function _prev() {
@@ -2671,7 +2737,10 @@
alerts: bool, clipboard: bool, sidebar: bool, submenus: bool }
Individual modules can still be called manually.
================================================================ */
let _ltInitialized = false;
function ltInit(opts) {
if (_ltInitialized) return; // Guard: safe to call multiple times
_ltInitialized = true;
const o = Object.assign({
boot: true,
bootName: null,