diff --git a/static/js/citations.js b/static/js/citations.js deleted file mode 100644 index 70c93d2..0000000 --- a/static/js/citations.js +++ /dev/null @@ -1,86 +0,0 @@ -/* citations.js — hover tooltip for inline citation markers. - On hover of a .cite-marker, reads the matching bibliography entry from - the DOM and shows it in a floating tooltip. On click, follows the href - to jump to the bibliography section. Phase 3 popups.js can supersede this. */ - -(function () { - 'use strict'; - - let activeTooltip = null; - let hideTimer = null; - - function makeTooltip(html) { - const el = document.createElement('div'); - el.className = 'cite-tooltip'; - el.innerHTML = html; - el.addEventListener('mouseenter', () => clearTimeout(hideTimer)); - el.addEventListener('mouseleave', scheduleHide); - return el; - } - - function positionTooltip(tooltip, anchor) { - document.body.appendChild(tooltip); - const aRect = anchor.getBoundingClientRect(); - const tRect = tooltip.getBoundingClientRect(); - - let left = aRect.left + window.scrollX; - let top = aRect.top + window.scrollY - tRect.height - 10; - - // Keep horizontally within viewport with margin - const maxLeft = window.innerWidth - tRect.width - 12; - left = Math.max(8, Math.min(left, maxLeft)); - - // Flip below anchor if not enough room above - if (top < window.scrollY + 8) { - top = aRect.bottom + window.scrollY + 10; - } - - tooltip.style.left = left + 'px'; - tooltip.style.top = top + 'px'; - } - - function scheduleHide() { - hideTimer = setTimeout(() => { - if (activeTooltip) { - activeTooltip.remove(); - activeTooltip = null; - } - }, 180); - } - - function getRefHtml(refEl) { - // Strip the [N] number span, return the remaining innerHTML - const clone = refEl.cloneNode(true); - const num = clone.querySelector('.ref-num'); - if (num) num.remove(); - return clone.innerHTML.trim(); - } - - function init() { - document.querySelectorAll('.cite-marker').forEach(marker => { - const link = marker.querySelector('a.cite-link'); - if (!link) return; - - const href = link.getAttribute('href'); - if (!href || !href.startsWith('#')) return; - - const refEl = document.getElementById(href.slice(1)); - if (!refEl) return; - - marker.addEventListener('mouseenter', () => { - clearTimeout(hideTimer); - if (activeTooltip) { activeTooltip.remove(); } - activeTooltip = makeTooltip(getRefHtml(refEl)); - positionTooltip(activeTooltip, marker); - }); - - marker.addEventListener('mouseleave', scheduleHide); - }); - } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } -})(); diff --git a/static/js/lightbox.js b/static/js/lightbox.js index 4889831..ae5a503 100644 --- a/static/js/lightbox.js +++ b/static/js/lightbox.js @@ -165,7 +165,12 @@ var images = document.querySelectorAll('img[data-lightbox]'); images.forEach(function (el) { - el.addEventListener('click', function () { + // Keyboard activation: the trigger acts as a button, and the + // tabindex also lets close() return focus to it. + el.setAttribute('tabindex', '0'); + el.setAttribute('role', 'button'); + + function activate() { // Look for a sibling figcaption in the parent figure var figcaptionText = ''; var parent = el.parentElement; @@ -176,6 +181,14 @@ } } open(el.src, el.alt, figcaptionText, el); + } + + el.addEventListener('click', activate); + el.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + activate(); + } }); }); @@ -199,11 +212,42 @@ setInfoVisible(!overlay.classList.contains('is-info-visible')); }); - // Escape closes; "i" toggles info panel (darkroom only). + /* Focus trap for the overlay: cycle Tab/Shift+Tab through the + focusable controls inside the lightbox so keyboard users + cannot tab out into the obscured page background. Same + approach as gallery.js's trapTab; the [hidden] exclusion + covers infoBtn, which is hidden outside darkroom mode. */ + function trapTab(e) { + var focusable = Array.from(overlay.querySelectorAll( + 'button:not([disabled]):not([hidden]), [tabindex]:not([tabindex="-1"])' + )); + if (focusable.length === 0) { + e.preventDefault(); + return; + } + var first = focusable[0]; + var last = focusable[focusable.length - 1]; + var active = document.activeElement; + if (e.shiftKey) { + if (active === first || !overlay.contains(active)) { + e.preventDefault(); + last.focus(); + } + } else { + if (active === last || !overlay.contains(active)) { + e.preventDefault(); + first.focus(); + } + } + } + + // Escape closes; Tab is trapped; "i" toggles info panel (darkroom only). document.addEventListener('keydown', function (e) { if (!overlay.classList.contains('is-open')) return; if (e.key === 'Escape') { close(); + } else if (e.key === 'Tab') { + trapTab(e); } else if ((e.key === 'i' || e.key === 'I') && overlay.classList.contains('darkroom') && !infoBtn.hidden) { diff --git a/static/js/nav.js b/static/js/nav.js index da35627..4834134 100644 --- a/static/js/nav.js +++ b/static/js/nav.js @@ -17,17 +17,23 @@ const toggle = document.querySelector('.nav-portal-toggle'); if (!portals || !toggle) return; + // safeStorage (utils.js, loaded synchronously before us) so a + // storage-blocked context can't throw before the click listener + // below binds; guarded like theme.js in case utils.js itself + // failed to load. + const store = window.lnUtils && window.lnUtils.safeStorage; + function setOpen(open) { portals.classList.toggle('is-open', open); toggle.setAttribute('aria-expanded', String(open)); // Rotate arrow indicator if present. const arrow = toggle.querySelector('.nav-portal-arrow'); if (arrow) arrow.textContent = open ? '▲' : '▼'; - localStorage.setItem(STORAGE_KEY, open ? '1' : '0'); + if (store) store.set(STORAGE_KEY, open ? '1' : '0'); } // Restore persisted state; default is collapsed. - const stored = localStorage.getItem(STORAGE_KEY); + const stored = store ? store.get(STORAGE_KEY) : null; setOpen(stored === '1'); toggle.addEventListener('click', function () { diff --git a/static/js/popups.js b/static/js/popups.js index a5b863c..d5489ce 100644 --- a/static/js/popups.js +++ b/static/js/popups.js @@ -472,7 +472,12 @@ if (!match) return Promise.resolve(null); var ctx = { match: match, href: href }; - var url = p.url(ctx); + /* p.url runs synchronously (before the .catch below attaches) and + can throw — e.g. decodeURIComponent on a malformed percent + sequence in the link path. Treat a throw as "no popup". */ + var url; + try { url = p.url(ctx); } + catch (e) { return Promise.resolve(null); } var fetcher = p.fetchType === 'xml' ? fetchXml : fetchJson; return fetcher(url, p.fetchInit).then(function (data) { @@ -951,10 +956,10 @@ var agoDays = daysBetween(start, today); /* "~" prefix when we've rounded to a unit larger than days. */ var span = humanDuration(spanDays, true); - var ago = humanAgo(agoDays); + var ago = humanAgo(agoDays); /* '' when start is in the future */ lines.push( '