audit pass 10-11: type=button, XSS escaping, focus/ARIA fixes

HTML:
- Add type="button" to all buttons outside forms (22 instances)
- Add aria-label="Add comment" to unlabelled textarea#td-comment

JS:
- Escape alt text and link text in markdown renderer with escHtml()
  to prevent XSS in image alt/link content
- Fix nested modal focus: only restore trigger focus when no other
  modal is still open; add document.contains guard

CSS:
- Add .lt-nav-link:focus-visible focus ring (was missing entirely)
- Fix .lt-typeahead-option (dead selector) → .lt-typeahead-item with
  :hover, .is-focused, and :focus-visible for light theme

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 20:46:31 -04:00
parent 8b54efef61
commit ca2d6d225e
3 changed files with 58 additions and 45 deletions
+24 -14
View File
@@ -232,9 +232,14 @@
_unlockScroll();
// Remove trap handler
if (el._ltTrapHandler) { el.removeEventListener('keydown', el._ltTrapHandler); delete el._ltTrapHandler; }
// Return focus to trigger
// Return focus to trigger (only if no other modal remains open)
const trigger = _modalTriggers.get(el);
if (trigger) { trigger.focus(); _modalTriggers.delete(el); }
if (trigger) {
_modalTriggers.delete(el);
if (!document.querySelector('.lt-modal-overlay.is-open') && document.contains(trigger)) {
trigger.focus();
}
}
}
function closeAllModals() {
@@ -1672,9 +1677,10 @@
lt.contextMenu.register(selector, items)
items = [{ label, icon, kbd, danger, divider, action }]
================================================================ */
let _ctxMenu = null;
let _ctxMenu = null, _ctxTrigger = null;
const _ctxItems = {};
function _ctxShow(x, y, items) {
function _ctxShow(x, y, items, trigger) {
_ctxTrigger = trigger || (document.activeElement !== document.body ? document.activeElement : null);
if (!_ctxMenu) {
_ctxMenu = document.createElement('div');
_ctxMenu.className = 'lt-context-menu';
@@ -1706,6 +1712,8 @@
}
function _ctxHide() {
if (_ctxMenu) _ctxMenu.classList.remove('is-open');
if (_ctxTrigger && document.contains(_ctxTrigger)) { _ctxTrigger.focus(); }
_ctxTrigger = null;
}
document.addEventListener('click', () => _ctxHide());
document.addEventListener('contextmenu', e => {
@@ -1714,7 +1722,7 @@
e.preventDefault();
const menuId = target.dataset.contextMenu;
const items = _ctxItems[menuId];
if (items) _ctxShow(e.clientX, e.clientY, items);
if (items) _ctxShow(e.clientX, e.clientY, items, target);
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') _ctxHide(); });
const contextMenu = {
@@ -2724,12 +2732,12 @@
// 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>`;
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${escHtml(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%">`;
return `<img src="${safeSrc}" alt="${escHtml(alt)}" style="max-width:100%">`;
})
// Blockquote
.replace(/^&gt;\s(.+)$/gm, '<blockquote>$1</blockquote>')
@@ -2775,7 +2783,7 @@
const pages = _pages();
let html = '';
// Prev
html += `<button class="lt-page-btn" ${page <= 1 ? 'disabled' : ''} data-page="${page - 1}">&laquo;</button>`;
html += `<button class="lt-page-btn" ${page <= 1 ? 'disabled' : ''} data-page="${page - 1}" aria-label="Previous page">&laquo;</button>`;
// Page buttons with ellipsis
const half = Math.floor((maxBtns - 2) / 2);
let start = Math.max(2, page - half);
@@ -2784,15 +2792,17 @@
if (start === 2) end = Math.min(pages - 1, start + maxBtns - 3);
else start = Math.max(2, end - maxBtns + 3);
}
html += `<button class="lt-page-btn${page === 1 ? ' active' : ''}" data-page="1">1</button>`;
if (start > 2) html += `<button class="lt-page-btn" disabled>…</button>`;
html += `<button class="lt-page-btn${page === 1 ? ' active' : ''}" data-page="1"${page === 1 ? ' aria-current="page"' : ''} aria-label="Page 1">1</button>`;
if (start > 2) html += `<button class="lt-page-btn" disabled aria-hidden="true">…</button>`;
for (let i = start; i <= end; i++) {
html += `<button class="lt-page-btn${page === i ? ' active' : ''}" data-page="${i}">${i}</button>`;
html += `<button class="lt-page-btn${page === i ? ' active' : ''}" data-page="${i}"${page === i ? ' aria-current="page"' : ''} aria-label="Page ${i}">${i}</button>`;
}
if (end < pages - 1) html += `<button class="lt-page-btn" disabled>…</button>`;
if (pages > 1) html += `<button class="lt-page-btn${page === pages ? ' active' : ''}" data-page="${pages}">${pages}</button>`;
if (end < pages - 1) html += `<button class="lt-page-btn" disabled aria-hidden="true">…</button>`;
if (pages > 1) html += `<button class="lt-page-btn${page === pages ? ' active' : ''}" data-page="${pages}"${page === pages ? ' aria-current="page"' : ''} aria-label="Page ${pages}">${pages}</button>`;
// Next
html += `<button class="lt-page-btn" ${page >= pages ? 'disabled' : ''} data-page="${page + 1}">&raquo;</button>`;
html += `<button class="lt-page-btn" ${page >= pages ? 'disabled' : ''} data-page="${page + 1}" aria-label="Next page">&raquo;</button>`;
if (!nav.getAttribute('role')) nav.setAttribute('role', 'navigation');
if (!nav.getAttribute('aria-label')) nav.setAttribute('aria-label', 'Pagination');
nav.innerHTML = html;
}