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
+15 -5
View File
@@ -1058,6 +1058,7 @@ select option:checked {
color: var(--accent-orange);
text-shadow: var(--glow-orange);
}
.lt-table th[data-sort-key]:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: -2px; }
.lt-table td {
padding: 0.55rem 0.85rem;
@@ -1495,11 +1496,13 @@ select option:checked {
}
.lt-stat-card:hover,
.lt-stat-card.active {
.lt-stat-card.active,
.lt-stat-card:focus-visible {
background: var(--bg-tertiary);
border-color: var(--accent-cyan-border);
box-shadow: var(--box-glow-cyan);
}
.lt-stat-card:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; }
.lt-stat-card:hover::before,
.lt-stat-card.active::before { height: 100%; }
@@ -2306,7 +2309,7 @@ select option:checked {
z-index: var(--z-toast);
transition: top 0.1s;
}
.lt-skip-link:focus { top: var(--space-sm); }
.lt-skip-link:focus, .lt-skip-link:focus-visible { top: var(--space-sm); }
.lt-sr-only {
position: absolute;
@@ -2519,6 +2522,7 @@ select option:checked {
transition: color 0.15s;
}
.lt-breadcrumb-item a:hover { color: var(--accent-cyan); }
.lt-breadcrumb-item a:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; }
.lt-breadcrumb-item.active { color: var(--accent-orange); }
.lt-breadcrumb-sep { color: var(--border-dim); }
.lt-breadcrumb-sep::before { content: '/'; }
@@ -2867,7 +2871,8 @@ input[type="range"].lt-range::-moz-range-thumb {
transition: background 0.1s;
}
.lt-cmd-item:hover,
.lt-cmd-item.is-selected {
.lt-cmd-item.is-selected,
.lt-cmd-item:focus-visible {
background: rgba(0,212,255,0.08);
color: var(--accent-cyan);
}
@@ -3115,6 +3120,7 @@ input[type="range"].lt-range::-moz-range-thumb {
flex: 1;
}
.lt-list-item a:hover { color: var(--accent-cyan); }
.lt-list-item a:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; }
.lt-list-item-meta { color: var(--text-dim); font-size: 0.7rem; margin-left: auto; }
.lt-list-item--active { background: rgba(255,107,0,0.06); border-left: 2px solid var(--accent-orange); }
@@ -4258,7 +4264,8 @@ html[data-theme="light"] ::-webkit-scrollbar-thumb:hover { background: var(--acc
transition: background 0.1s;
}
.lt-combobox-option:hover,
.lt-combobox-option.is-focused { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
.lt-combobox-option.is-focused,
.lt-combobox-option:focus-visible { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
.lt-combobox-option.is-selected::before {
content: '✓';
color: var(--accent-green);
@@ -4515,6 +4522,7 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
}
.lt-split-divider:hover,
.lt-split-divider.is-dragging { background: var(--accent-cyan); }
.lt-split-divider:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; }
.lt-split--vertical .lt-split-divider::before { top: -6px; bottom: -6px; left: 0; right: 0; cursor: row-resize; }
.lt-split--vertical .lt-split-divider { cursor: row-resize; }
/* On mobile, stack vertically and hide divider */
@@ -4659,7 +4667,8 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
transition: background 0.1s;
}
.lt-typeahead-item:hover,
.lt-typeahead-item.is-focused { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
.lt-typeahead-item.is-focused,
.lt-typeahead-item:focus-visible { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
.lt-typeahead-item mark {
background: none;
color: var(--accent-orange);
@@ -5010,6 +5019,7 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
white-space: nowrap;
}
.lt-sidebar-sub-link:hover { color: var(--accent-cyan); background: var(--accent-cyan-dim); }
.lt-sidebar-sub-link:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 1px; border-radius: 2px; }
.lt-sidebar-sub-link.active,
.lt-sidebar-sub-link[aria-current="page"] {
color: var(--accent-orange);
+22 -22
View File
@@ -276,29 +276,29 @@
data-filter-key / data-filter-val → wired by lt.statsFilter
========================================================== -->
<div class="lt-stats-grid">
<div class="lt-stat-card active" data-filter-key="status" data-filter-val="Open">
<span class="lt-stat-icon">📋</span>
<div class="lt-stat-card active" role="button" tabindex="0" data-filter-key="status" data-filter-val="Open" aria-label="Open tickets: 42">
<span class="lt-stat-icon" aria-hidden="true">📋</span>
<div class="lt-stat-info">
<span class="lt-stat-value">42</span>
<span class="lt-stat-label">Open</span>
</div>
</div>
<div class="lt-stat-card" data-filter-key="priority" data-filter-val="1">
<span class="lt-stat-icon">🔴</span>
<div class="lt-stat-card" role="button" tabindex="0" data-filter-key="priority" data-filter-val="1" aria-label="Critical tickets: 3">
<span class="lt-stat-icon" aria-hidden="true">🔴</span>
<div class="lt-stat-info">
<span class="lt-stat-value">3</span>
<span class="lt-stat-label">Critical</span>
</div>
</div>
<div class="lt-stat-card" data-filter-key="assigned_to" data-filter-val="0">
<span class="lt-stat-icon">👤</span>
<div class="lt-stat-card" role="button" tabindex="0" data-filter-key="assigned_to" data-filter-val="0" aria-label="Unassigned tickets: 11">
<span class="lt-stat-icon" aria-hidden="true">👤</span>
<div class="lt-stat-info">
<span class="lt-stat-value">11</span>
<span class="lt-stat-label">Unassigned</span>
</div>
</div>
<div class="lt-stat-card" data-filter-key="created" data-filter-val="today">
<span class="lt-stat-icon">📅</span>
<div class="lt-stat-card" role="button" tabindex="0" data-filter-key="created" data-filter-val="today" aria-label="Today's tickets: 7">
<span class="lt-stat-icon" aria-hidden="true">📅</span>
<div class="lt-stat-info">
<span class="lt-stat-value">7</span>
<span class="lt-stat-label">Today</span>
@@ -392,8 +392,8 @@
<div class="lt-dropdown-panel" id="adv-filter-panel" aria-hidden="true">
<div style="padding:0.75rem;display:grid;gap:0.5rem;width:clamp(200px,60vw,260px)">
<div class="lt-form-group" style="margin:0">
<label class="lt-label" style="font-size:0.75rem">Status</label>
<select class="lt-select" style="font-size:0.8rem">
<label class="lt-label" style="font-size:0.75rem" for="adv-filter-status">Status</label>
<select id="adv-filter-status" class="lt-select" style="font-size:0.8rem">
<option value="">All</option>
<option>Open</option>
<option>In Progress</option>
@@ -402,8 +402,8 @@
</select>
</div>
<div class="lt-form-group" style="margin:0">
<label class="lt-label" style="font-size:0.75rem">Priority</label>
<select class="lt-select" style="font-size:0.8rem">
<label class="lt-label" style="font-size:0.75rem" for="adv-filter-priority">Priority</label>
<select id="adv-filter-priority" class="lt-select" style="font-size:0.8rem">
<option value="">All</option>
<option>P1 Critical</option>
<option>P2 High</option>
@@ -412,8 +412,8 @@
</select>
</div>
<div class="lt-form-group" style="margin:0">
<label class="lt-label" style="font-size:0.75rem">Assignee</label>
<select class="lt-select" style="font-size:0.8rem">
<label class="lt-label" style="font-size:0.75rem" for="adv-filter-assignee">Assignee</label>
<select id="adv-filter-assignee" class="lt-select" style="font-size:0.8rem">
<option value="">Anyone</option>
<option>Unassigned</option>
<option>jdoe</option>
@@ -878,21 +878,21 @@
<div class="lt-accordion">
<button class="lt-accordion-header" aria-expanded="false" aria-controls="acc-body-1" data-accordion>
SYSTEM OVERVIEW
<svg class="lt-accordion-icon" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 1l4 4 4-4"/></svg>
<svg class="lt-accordion-icon" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M1 1l4 4 4-4"/></svg>
</button>
<div class="lt-accordion-body" id="acc-body-1"><div class="lt-accordion-content">Node running at 72% CPU. 12 active processes. Last restart: 3d ago.</div></div>
</div>
<div class="lt-accordion">
<button class="lt-accordion-header" aria-expanded="false" aria-controls="acc-body-2" data-accordion>
NETWORK CONFIG
<svg class="lt-accordion-icon" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 1l4 4 4-4"/></svg>
<svg class="lt-accordion-icon" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M1 1l4 4 4-4"/></svg>
</button>
<div class="lt-accordion-body" id="acc-body-2"><div class="lt-accordion-content">eth0: 10.0.0.7 — MTU 1500 — RX 4.2 GB — TX 1.1 GB</div></div>
</div>
<div class="lt-accordion">
<button class="lt-accordion-header" aria-expanded="false" aria-controls="acc-body-3" data-accordion>
FIREWALL RULES
<svg class="lt-accordion-icon" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 1l4 4 4-4"/></svg>
<svg class="lt-accordion-icon" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M1 1l4 4 4-4"/></svg>
</button>
<div class="lt-accordion-body" id="acc-body-3"><div class="lt-accordion-content">22/tcp ALLOW — 80/tcp ALLOW — 443/tcp ALLOW — */* DENY</div></div>
</div>
@@ -949,7 +949,7 @@
<span class="lt-range-label">Refresh Interval (s)</span>
<span class="lt-range-value">30</span>
</div>
<input type="range" class="lt-range" min="5" max="60" value="30">
<input type="range" class="lt-range" min="5" max="60" value="30" aria-label="Refresh Interval in seconds" aria-valuemin="5" aria-valuemax="60" aria-valuenow="30">
</div>
<div class="lt-tags">
<span class="lt-tag lt-tag--orange">CRITICAL</span>
@@ -1420,13 +1420,13 @@
<!-- Steps -->
<div data-wizard-step="1" class="is-active">
<div class="lt-grid lt-grid-2" style="gap:1rem;margin-top:1rem">
<div class="lt-form-group"><label class="lt-label">Ticket Title</label><input type="text" name="title" class="lt-input lt-w-full" placeholder="Brief description…"></div>
<div class="lt-form-group"><label class="lt-label">Priority</label><select name="priority" class="lt-select lt-w-full"><option value="p1">P1 Critical</option><option value="p2">P2 High</option><option value="p3" selected>P3 Medium</option><option value="p4">P4 Low</option></select></div>
<div class="lt-form-group"><label class="lt-label" for="wizard-title">Ticket Title</label><input id="wizard-title" type="text" name="title" class="lt-input lt-w-full" placeholder="Brief description…"></div>
<div class="lt-form-group"><label class="lt-label" for="wizard-priority">Priority</label><select id="wizard-priority" name="priority" class="lt-select lt-w-full"><option value="p1">P1 Critical</option><option value="p2">P2 High</option><option value="p3" selected>P3 Medium</option><option value="p4">P4 Low</option></select></div>
</div>
</div>
<div data-wizard-step="2" aria-hidden="true">
<div class="lt-form-group" style="margin-top:1rem"><label class="lt-label">Assign To</label><input type="text" name="assignee" class="lt-input lt-w-full" placeholder="Username…"></div>
<div class="lt-form-group"><label class="lt-label">Due Date</label><input type="date" name="due" class="lt-input"></div>
<div class="lt-form-group" style="margin-top:1rem"><label class="lt-label" for="wizard-assignee">Assign To</label><input id="wizard-assignee" type="text" name="assignee" class="lt-input lt-w-full" placeholder="Username…"></div>
<div class="lt-form-group"><label class="lt-label" for="wizard-due">Due Date</label><input id="wizard-due" type="date" name="due" class="lt-input"></div>
</div>
<div data-wizard-step="3" aria-hidden="true">
<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon"></div><div class="lt-empty-state-title">Review &amp; Submit</div><div class="lt-empty-state-body">Check the details above and click Submit when ready.</div></div>
+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() {