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:
@@ -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(/^>\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}">«</button>`;
|
||||
html += `<button class="lt-page-btn" ${page <= 1 ? 'disabled' : ''} data-page="${page - 1}" aria-label="Previous page">«</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}">»</button>`;
|
||||
html += `<button class="lt-page-btn" ${page >= pages ? 'disabled' : ''} data-page="${page + 1}" aria-label="Next page">»</button>`;
|
||||
if (!nav.getAttribute('role')) nav.setAttribute('role', 'navigation');
|
||||
if (!nav.getAttribute('aria-label')) nav.setAttribute('aria-label', 'Pagination');
|
||||
nav.innerHTML = html;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user