/*! trentpower.fr · authored source */ /* trentpower.fr overlay lifecycle Role: the accessible, focus-trapped overlay shared by the homepage access modal, the verify action menu (built by verify-modal.js) and the language gate. exposed via window.TP_OVERLAY so each overlay can register its own (overlay, trigger) pair without duplicating the lifecycle. handles both shapes: • legacy .modal-overlay container (.active class) • shell-system .modal-shell-scrim container (.is-active class) the body.modal-open class is set on open so the publication-blur rule in styles.src.css (body.modal-open .site / #main → blur) applies regardless of which shape is active. Source: edited here as overlay.template.js; compiled to overlay.js by generate_site.py (minified, no substitution). Constraints: - no fetch, no inline handlers, no third-party scripts - loaded after first paint; not on the lcp critical path - enhancement only: the verify menu degrades to a /verify/ link and the project modal to in-page content when this is absent */ (function () { 'use strict'; var siteContent = document.getElementById('main'); var supportsInert = 'inert' in document.documentElement; function setInert(el, state) { if (!el) return; if (state) { if (supportsInert) el.setAttribute('inert', ''); el.setAttribute('aria-hidden', 'true'); } else { if (supportsInert) el.removeAttribute('inert'); el.removeAttribute('aria-hidden'); } } function focusableIn(el) { return el.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])'); } var activeOverlay = null; var activeTrigger = null; // history integration · whether this opener pushed a state. only set // when opts.hash is supplied. cleared whenever an overlay closes. var pushedHistoryState = false; function openOverlay(overlay, trigger, opts) { if (!overlay) return; activeOverlay = overlay; activeTrigger = trigger || null; overlay.setAttribute('aria-hidden', 'false'); // the overlay starts inert in markup (focusable children inside // an aria-hidden region would otherwise still be in the tab // order — lighthouse flags it). remove inert here so the open // modal becomes interactive again. if (supportsInert) overlay.removeAttribute('inert'); // both class names are added so the CSS for either shape matches: // legacy `.modal-overlay.active`, new `.modal-shell-scrim.is-active`. overlay.classList.add('active'); overlay.classList.add('is-active'); // publication-blur target — styles.src.css uses `body.modal-open` // to apply the blur to .site / #main behind the scrim. the // matching `body.modal-open { overflow: hidden }` declaration // (added in phase 96) carries the scroll-lock so we don't need // an inline-style write here. document.body.classList.add('modal-open'); setInert(siteContent, true); if (trigger) trigger.setAttribute('aria-expanded', 'true'); var focusable = focusableIn(overlay); if (focusable.length > 0) focusable[0].focus(); // optional history integration. caller passes opts.hash (e.g. '#cite') // and the overlay becomes back-button-closable + deep-linkable. the // opts.fromPopstate flag lets the popstate listener re-open without // pushing an extra entry. pushedHistoryState = false; if (opts && opts.hash && !opts.fromPopstate && typeof history !== 'undefined' && typeof history.pushState === 'function') { try { history.pushState({ tp_overlay: opts.hash.replace(/^#/, '') }, '', opts.hash); pushedHistoryState = true; } catch (_) { /* sandboxed iframe etc. — silently no-op */ } } } function closeOverlay(opts) { if (!activeOverlay) return; var overlay = activeOverlay; var trigger = activeTrigger; overlay.classList.remove('active'); overlay.classList.remove('is-active'); overlay.setAttribute('aria-hidden', 'true'); // restore inert so the closed modal's focusable children // (× button + mailto link in the access modal, etc.) are // removed from the tab order. if (supportsInert) overlay.setAttribute('inert', ''); // matching close to the open path; the scroll-lock comes off // when `body.modal-open` is removed (css `overflow: hidden`). document.body.classList.remove('modal-open'); setInert(siteContent, false); if (trigger) { trigger.setAttribute('aria-expanded', 'false'); trigger.focus(); } activeOverlay = null; activeTrigger = null; // if we pushed a history entry on open and this close is user- // initiated (× / esc / click-outside, not a popstate-driven close), // pop our entry so the url reverts cleanly. wrapped in try because // history.back() can fail when the page is the only entry. if (pushedHistoryState && !(opts && opts.fromPopstate) && typeof history !== 'undefined' && typeof history.back === 'function') { try { history.back(); } catch (_) { /* no prior entry — stay put */ } } pushedHistoryState = false; } // popstate · browser back/forward. if an overlay is open and the new // location no longer carries our state, close from popstate so we // don't double-pop the history. Open-on-forward isn't supported in // this version — initial-hash auto-open is handled by the overlay's // own init code (verify-modal.template.js). window.addEventListener('popstate', function () { if (activeOverlay) { var st = history.state; var stillOpen = st && st.tp_overlay; if (!stillOpen) closeOverlay({ fromPopstate: true }); } }); document.addEventListener('keydown', function (e) { if (!activeOverlay) return; if (e.key === 'Escape') { closeOverlay(); return; } if (e.key === 'Tab') { var focusable = focusableIn(activeOverlay); if (focusable.length === 0) return; var first = focusable[0]; var last = focusable[focusable.length - 1]; if (e.shiftKey) { if (document.activeElement === first) { e.preventDefault(); last.focus(); } } else { if (document.activeElement === last) { e.preventDefault(); first.focus(); } } } }); // expose for verify-modal.js (and any future overlay). window.TP_OVERLAY = { open: openOverlay, close: closeOverlay }; // project "view project" modal , wires its trigger / close button into // the shared lifecycle. pages without #modal silently skip. var modal = document.getElementById('modal'); var btn = document.getElementById('access-btn'); var closeBtn = document.getElementById('modal-close'); if (modal && btn && closeBtn) { btn.addEventListener('click', function () { openOverlay(modal, btn); }); closeBtn.addEventListener('click', closeOverlay); modal.addEventListener('click', function (e) { if (e.target === modal) closeOverlay(); }); } // [data-inert-default] · nu html checker doesn't recognise the // `inert` attribute yet, so background layers (the gate background, // closed modal scrims) ship without it and we graft it on at boot. // openOverlay/closeOverlay still toggle inert dynamically via // setInert(); this just sets the initial state so the page is // accessible-correct from first paint. if (supportsInert) { var inertNodes = document.querySelectorAll('[data-inert-default]'); for (var i = 0; i < inertNodes.length; i++) { inertNodes[i].inert = true; } } })();