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:
2026-03-26 20:16:12 -04:00
parent e8c1197613
commit 8b54efef61
3 changed files with 38 additions and 21 deletions
+20 -6
View File
@@ -1616,7 +1616,7 @@
const ov = document.getElementById(ovId);
if (_mnOpen) _mnSetOpen(false);
drawer.classList.add('is-open');
drawer.setAttribute('aria-hidden', 'false');
drawer.removeAttribute('aria-hidden');
if (ov) ov.classList.add('is-open');
_lockScroll();
if (triggerEl) _modalTriggers.set(drawer, triggerEl);
@@ -2175,12 +2175,19 @@
if (!label) return;
label.setAttribute('tabindex', '0');
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('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _toggle(); } });
// Open group if it contains the active link
if (group.querySelector('.lt-sidebar-sub-link.active, .lt-sidebar-sub-link[aria-current="page"]')) {
group.classList.add('is-open');
label.setAttribute('aria-expanded', 'true');
}
});
}
@@ -2581,6 +2588,7 @@
if (!_overlay) return;
_overlay.classList.remove('is-open');
_unlockScroll();
if (_lbKeyBound) { document.removeEventListener('keydown', _lbKeyBound); _lbKeyBound = null; }
if (_lbTrigger && document.contains(_lbTrigger)) { _lbTrigger.focus(); }
_lbTrigger = null;
},
@@ -2713,10 +2721,16 @@
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/__(.+?)__/g, '<strong>$1</strong>')
.replace(/_(.+?)_/g, '<em>$1</em>')
// Links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
// Images
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%">')
// Links — block javascript: and data: URIs
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => {
const safeUrl = /^(https?:\/\/|\/|#|\.\.?\/)/i.test(url) ? url : '#';
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
.replace(/^&gt;\s(.+)$/gm, '<blockquote>$1</blockquote>')
// Horizontal rule