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