audit pass 9: XSS fix, focus management, ARIA labels, and :focus-visible gaps
JS: - Lightbox: remove keydown listener on close (memory leak fix) - Lightbox: restore focus to trigger on close - Right drawer: fix aria-hidden="false" anti-pattern to removeAttribute - Markdown renderer: block javascript:/data: protocol URIs in link and image replacements to prevent XSS - Sidebar submenus: add aria-expanded tracking on toggle; hide decorative chevron from screen readers; initialize aria-expanded on mount CSS: - Add :focus-visible to .lt-sidebar-toggle (interactive button) - Add :focus-visible to .lt-dropzone (focusable container) - Fix .lt-stat-card:focus-visible outline-offset to -2px (clip-path clips it) - Add light theme override for .lt-nav-drawer-link:focus-visible - Adjust .lt-split-divider:focus-visible outline-offset to 3px HTML: - Range input: update aria-valuenow dynamically on input event - Combobox label: add for="demo-combobox-input" association - Typeahead label: add for="demo-typeahead-input" association - Dropzone file input: add aria-label - Notification items: add descriptive aria-label to all 4 items; add aria-hidden="true" to decorative dot spans - Mark all read button: add type="button" to prevent accidental form submit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1425,6 +1425,7 @@ select option:checked {
|
|||||||
transition: var(--transition-fast);
|
transition: var(--transition-fast);
|
||||||
}
|
}
|
||||||
.lt-sidebar-toggle:hover { color: var(--accent-cyan); text-shadow: var(--glow-cyan); }
|
.lt-sidebar-toggle:hover { color: var(--accent-cyan); text-shadow: var(--glow-cyan); }
|
||||||
|
.lt-sidebar-toggle:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; }
|
||||||
|
|
||||||
.lt-sidebar-body { padding: var(--space-md); }
|
.lt-sidebar-body { padding: var(--space-md); }
|
||||||
|
|
||||||
@@ -1502,7 +1503,7 @@ select option:checked {
|
|||||||
border-color: var(--accent-cyan-border);
|
border-color: var(--accent-cyan-border);
|
||||||
box-shadow: var(--box-glow-cyan);
|
box-shadow: var(--box-glow-cyan);
|
||||||
}
|
}
|
||||||
.lt-stat-card:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; }
|
.lt-stat-card:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: -2px; }
|
||||||
.lt-stat-card:hover::before,
|
.lt-stat-card:hover::before,
|
||||||
.lt-stat-card.active::before { height: 100%; }
|
.lt-stat-card.active::before { height: 100%; }
|
||||||
|
|
||||||
@@ -2774,6 +2775,7 @@ input[type="range"].lt-range::-moz-range-thumb {
|
|||||||
position: relative;
|
position: relative;
|
||||||
clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 16px, 100% 100%, 16px 100%, 0 calc(100% - 16px));
|
clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 16px, 100% 100%, 16px 100%, 0 calc(100% - 16px));
|
||||||
}
|
}
|
||||||
|
.lt-dropzone:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: -2px; }
|
||||||
.lt-dropzone:hover,
|
.lt-dropzone:hover,
|
||||||
.lt-dropzone.drag-over {
|
.lt-dropzone.drag-over {
|
||||||
border-color: var(--accent-cyan);
|
border-color: var(--accent-cyan);
|
||||||
@@ -3619,6 +3621,7 @@ html[data-theme="light"] .lt-nav-link.active { color: var(--accent-orange
|
|||||||
html[data-theme="light"] .lt-nav-drawer-link { color: var(--text-secondary); }
|
html[data-theme="light"] .lt-nav-drawer-link { color: var(--text-secondary); }
|
||||||
html[data-theme="light"] .lt-nav-drawer-link:hover { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
|
html[data-theme="light"] .lt-nav-drawer-link:hover { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
|
||||||
html[data-theme="light"] .lt-nav-drawer-link.active { color: var(--accent-orange); background: var(--accent-orange-dim); }
|
html[data-theme="light"] .lt-nav-drawer-link.active { color: var(--accent-orange); background: var(--accent-orange-dim); }
|
||||||
|
html[data-theme="light"] .lt-nav-drawer-link:focus-visible { outline: none; color: var(--accent-cyan); background: var(--accent-cyan-dim); box-shadow: inset 3px 0 0 var(--accent-cyan); }
|
||||||
html[data-theme="light"] .lt-sidebar-nav-link { color: var(--text-secondary); }
|
html[data-theme="light"] .lt-sidebar-nav-link { color: var(--text-secondary); }
|
||||||
html[data-theme="light"] .lt-sidebar-nav-link:hover { background: var(--accent-cyan-dim); }
|
html[data-theme="light"] .lt-sidebar-nav-link:hover { background: var(--accent-cyan-dim); }
|
||||||
html[data-theme="light"] .lt-sidebar-nav-link.active { background: var(--accent-orange-dim); color: var(--accent-orange); }
|
html[data-theme="light"] .lt-sidebar-nav-link.active { background: var(--accent-orange-dim); color: var(--accent-orange); }
|
||||||
@@ -4522,7 +4525,7 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
|
|||||||
}
|
}
|
||||||
.lt-split-divider:hover,
|
.lt-split-divider:hover,
|
||||||
.lt-split-divider.is-dragging { background: var(--accent-cyan); }
|
.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-divider:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 3px; }
|
||||||
.lt-split--vertical .lt-split-divider::before { top: -6px; bottom: -6px; left: 0; right: 0; cursor: row-resize; }
|
.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; }
|
.lt-split--vertical .lt-split-divider { cursor: row-resize; }
|
||||||
/* On mobile, stack vertically and hide divider */
|
/* On mobile, stack vertically and hide divider */
|
||||||
|
|||||||
@@ -126,32 +126,32 @@
|
|||||||
<div class="lt-notif-panel" id="lt-notif-panel" role="region" aria-label="Notifications" aria-hidden="true">
|
<div class="lt-notif-panel" id="lt-notif-panel" role="region" aria-label="Notifications" aria-hidden="true">
|
||||||
<div class="lt-notif-panel-header">
|
<div class="lt-notif-panel-header">
|
||||||
<span>Notifications</span>
|
<span>Notifications</span>
|
||||||
<button class="lt-notif-panel-clear" id="lt-notif-clear-all">Mark all read</button>
|
<button type="button" class="lt-notif-panel-clear" id="lt-notif-clear-all">Mark all read</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-notif-panel-list">
|
<div class="lt-notif-panel-list">
|
||||||
<div class="lt-notif-item lt-notif-item--unread" role="button" tabindex="0">
|
<div class="lt-notif-item lt-notif-item--unread" role="button" tabindex="0" aria-label="Unread: P1 alert: storage link-down, 5 min ago. Press Enter to dismiss.">
|
||||||
<span class="lt-notif-dot"></span>
|
<span class="lt-notif-dot" aria-hidden="true"></span>
|
||||||
<div class="lt-notif-item-body">
|
<div class="lt-notif-item-body">
|
||||||
<div class="lt-notif-item-title">P1 alert: storage link-down</div>
|
<div class="lt-notif-item-title">P1 alert: storage link-down</div>
|
||||||
<div class="lt-notif-item-time">5 min ago</div>
|
<div class="lt-notif-item-time">5 min ago</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-notif-item lt-notif-item--unread" role="button" tabindex="0">
|
<div class="lt-notif-item lt-notif-item--unread" role="button" tabindex="0" aria-label="Unread: Worker node-03 reconnected, 12 min ago. Press Enter to dismiss.">
|
||||||
<span class="lt-notif-dot"></span>
|
<span class="lt-notif-dot" aria-hidden="true"></span>
|
||||||
<div class="lt-notif-item-body">
|
<div class="lt-notif-item-body">
|
||||||
<div class="lt-notif-item-title">Worker node-03 reconnected</div>
|
<div class="lt-notif-item-title">Worker node-03 reconnected</div>
|
||||||
<div class="lt-notif-item-time">12 min ago</div>
|
<div class="lt-notif-item-time">12 min ago</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-notif-item lt-notif-item--unread" role="button" tabindex="0">
|
<div class="lt-notif-item lt-notif-item--unread" role="button" tabindex="0" aria-label="Unread: Export CSV complete — 42 rows, 1 hr ago. Press Enter to dismiss.">
|
||||||
<span class="lt-notif-dot"></span>
|
<span class="lt-notif-dot" aria-hidden="true"></span>
|
||||||
<div class="lt-notif-item-body">
|
<div class="lt-notif-item-body">
|
||||||
<div class="lt-notif-item-title">Export CSV complete — 42 rows</div>
|
<div class="lt-notif-item-title">Export CSV complete — 42 rows</div>
|
||||||
<div class="lt-notif-item-time">1 hr ago</div>
|
<div class="lt-notif-item-time">1 hr ago</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-notif-item" role="button" tabindex="0">
|
<div class="lt-notif-item" role="button" tabindex="0" aria-label="Scheduled maintenance completed, 3 hr ago. Press Enter to dismiss.">
|
||||||
<span class="lt-notif-dot lt-notif-dot--read"></span>
|
<span class="lt-notif-dot lt-notif-dot--read" aria-hidden="true"></span>
|
||||||
<div class="lt-notif-item-body">
|
<div class="lt-notif-item-body">
|
||||||
<div class="lt-notif-item-title">Scheduled maintenance completed</div>
|
<div class="lt-notif-item-title">Scheduled maintenance completed</div>
|
||||||
<div class="lt-notif-item-time">3 hr ago</div>
|
<div class="lt-notif-item-time">3 hr ago</div>
|
||||||
@@ -1098,7 +1098,7 @@
|
|||||||
<div class="lt-section-header">File Dropzone</div>
|
<div class="lt-section-header">File Dropzone</div>
|
||||||
<div class="lt-section-body">
|
<div class="lt-section-body">
|
||||||
<div class="lt-dropzone" data-dropzone data-accept=".json,.csv,.log" data-max-size="10485760">
|
<div class="lt-dropzone" data-dropzone data-accept=".json,.csv,.log" data-max-size="10485760">
|
||||||
<input type="file" accept=".json,.csv,.log" multiple>
|
<input type="file" accept=".json,.csv,.log" multiple aria-label="Upload JSON, CSV, or log files (max 10 MB each)">
|
||||||
<div class="lt-dropzone-icon">⤓</div>
|
<div class="lt-dropzone-icon">⤓</div>
|
||||||
<div class="lt-dropzone-text">Drop files here or <strong>click to browse</strong></div>
|
<div class="lt-dropzone-text">Drop files here or <strong>click to browse</strong></div>
|
||||||
<div class="lt-dropzone-hint">Accepts .json, .csv, .log — max 10 MB each</div>
|
<div class="lt-dropzone-hint">Accepts .json, .csv, .log — max 10 MB each</div>
|
||||||
@@ -1284,7 +1284,7 @@
|
|||||||
<div class="lt-section-body">
|
<div class="lt-section-body">
|
||||||
<div class="lt-grid lt-grid-2" style="gap:1.5rem">
|
<div class="lt-grid lt-grid-2" style="gap:1.5rem">
|
||||||
<div>
|
<div>
|
||||||
<label class="lt-label">Assign Workers (multi-select)</label>
|
<label class="lt-label" for="demo-combobox-input">Assign Workers (multi-select)</label>
|
||||||
<div class="lt-combobox" id="demo-combobox">
|
<div class="lt-combobox" id="demo-combobox">
|
||||||
<div class="lt-combobox-input-wrap">
|
<div class="lt-combobox-input-wrap">
|
||||||
<input type="text" class="lt-combobox-input" id="demo-combobox-input" placeholder="Search workers…" autocomplete="off">
|
<input type="text" class="lt-combobox-input" id="demo-combobox-input" placeholder="Search workers…" autocomplete="off">
|
||||||
@@ -1293,7 +1293,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="lt-label">Search Tickets (typeahead)</label>
|
<label class="lt-label" for="demo-typeahead-input">Search Tickets (typeahead)</label>
|
||||||
<div class="lt-typeahead" id="demo-typeahead">
|
<div class="lt-typeahead" id="demo-typeahead">
|
||||||
<input type="text" class="lt-input lt-w-full" id="demo-typeahead-input" placeholder="Type to search…" autocomplete="off">
|
<input type="text" class="lt-input lt-w-full" id="demo-typeahead-input" placeholder="Type to search…" autocomplete="off">
|
||||||
<div class="lt-typeahead-dropdown"></div>
|
<div class="lt-typeahead-dropdown"></div>
|
||||||
@@ -1750,7 +1750,7 @@ Storage array link-down on `compute-storage-01`.
|
|||||||
const label = wrap && wrap.querySelector('.lt-range-value');
|
const label = wrap && wrap.querySelector('.lt-range-value');
|
||||||
if (label) {
|
if (label) {
|
||||||
label.textContent = r.value;
|
label.textContent = r.value;
|
||||||
r.addEventListener('input', () => { label.textContent = r.value; });
|
r.addEventListener('input', () => { label.textContent = r.value; r.setAttribute('aria-valuenow', r.value); });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1616,7 +1616,7 @@
|
|||||||
const ov = document.getElementById(ovId);
|
const ov = document.getElementById(ovId);
|
||||||
if (_mnOpen) _mnSetOpen(false);
|
if (_mnOpen) _mnSetOpen(false);
|
||||||
drawer.classList.add('is-open');
|
drawer.classList.add('is-open');
|
||||||
drawer.setAttribute('aria-hidden', 'false');
|
drawer.removeAttribute('aria-hidden');
|
||||||
if (ov) ov.classList.add('is-open');
|
if (ov) ov.classList.add('is-open');
|
||||||
_lockScroll();
|
_lockScroll();
|
||||||
if (triggerEl) _modalTriggers.set(drawer, triggerEl);
|
if (triggerEl) _modalTriggers.set(drawer, triggerEl);
|
||||||
@@ -2175,12 +2175,19 @@
|
|||||||
if (!label) return;
|
if (!label) return;
|
||||||
label.setAttribute('tabindex', '0');
|
label.setAttribute('tabindex', '0');
|
||||||
label.setAttribute('role', 'button');
|
label.setAttribute('role', 'button');
|
||||||
const _toggle = () => group.classList.toggle('is-open');
|
const chevron = label.querySelector('.chevron, .lt-sidebar-chevron');
|
||||||
|
if (chevron) chevron.setAttribute('aria-hidden', 'true');
|
||||||
|
const _toggle = () => {
|
||||||
|
group.classList.toggle('is-open');
|
||||||
|
label.setAttribute('aria-expanded', group.classList.contains('is-open') ? 'true' : 'false');
|
||||||
|
};
|
||||||
|
label.setAttribute('aria-expanded', group.classList.contains('is-open') ? 'true' : 'false');
|
||||||
label.addEventListener('click', _toggle);
|
label.addEventListener('click', _toggle);
|
||||||
label.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _toggle(); } });
|
label.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _toggle(); } });
|
||||||
// Open group if it contains the active link
|
// Open group if it contains the active link
|
||||||
if (group.querySelector('.lt-sidebar-sub-link.active, .lt-sidebar-sub-link[aria-current="page"]')) {
|
if (group.querySelector('.lt-sidebar-sub-link.active, .lt-sidebar-sub-link[aria-current="page"]')) {
|
||||||
group.classList.add('is-open');
|
group.classList.add('is-open');
|
||||||
|
label.setAttribute('aria-expanded', 'true');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2581,6 +2588,7 @@
|
|||||||
if (!_overlay) return;
|
if (!_overlay) return;
|
||||||
_overlay.classList.remove('is-open');
|
_overlay.classList.remove('is-open');
|
||||||
_unlockScroll();
|
_unlockScroll();
|
||||||
|
if (_lbKeyBound) { document.removeEventListener('keydown', _lbKeyBound); _lbKeyBound = null; }
|
||||||
if (_lbTrigger && document.contains(_lbTrigger)) { _lbTrigger.focus(); }
|
if (_lbTrigger && document.contains(_lbTrigger)) { _lbTrigger.focus(); }
|
||||||
_lbTrigger = null;
|
_lbTrigger = null;
|
||||||
},
|
},
|
||||||
@@ -2713,10 +2721,16 @@
|
|||||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||||
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
||||||
.replace(/_(.+?)_/g, '<em>$1</em>')
|
.replace(/_(.+?)_/g, '<em>$1</em>')
|
||||||
// Links
|
// Links — block javascript: and data: URIs
|
||||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => {
|
||||||
// Images
|
const safeUrl = /^(https?:\/\/|\/|#|\.\.?\/)/i.test(url) ? url : '#';
|
||||||
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%">')
|
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${text}</a>`;
|
||||||
|
})
|
||||||
|
// Images — block javascript: and data: URIs
|
||||||
|
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => {
|
||||||
|
const safeSrc = /^(https?:\/\/|\/|\.\.?\/)/i.test(src) ? src : '';
|
||||||
|
return `<img src="${safeSrc}" alt="${alt}" style="max-width:100%">`;
|
||||||
|
})
|
||||||
// Blockquote
|
// Blockquote
|
||||||
.replace(/^>\s(.+)$/gm, '<blockquote>$1</blockquote>')
|
.replace(/^>\s(.+)$/gm, '<blockquote>$1</blockquote>')
|
||||||
// Horizontal rule
|
// Horizontal rule
|
||||||
|
|||||||
Reference in New Issue
Block a user