1// Navigation behaviour for the statically baked nav 2// (scripts/content/sync_nav_static.py bakes the markup at build time). 3 4// Runs immediately after the baked navbar, before main is parsed. This keeps 5// enhanced header geometry stable from the first content paint; a failed or 6// disabled script leaves the visible native-link fallback intact. 7function initNavFeatures() { 8 document.querySelector('.navbar')?.classList.add('is-enhanced'); 9 // Set active nav link based on current page 10 const currentPath = window.location.pathname; 11 document.querySelectorAll('.nav-link').forEach(link => { 12 const linkPath = new URL(link.href, window.location.origin).pathname; 13 if (linkPath === currentPath || (currentPath !== '/' && linkPath !== '/' && currentPath.startsWith(linkPath))) { 14 link.setAttribute('aria-current', 'page'); 15 } 16 }); 17 18 // Mobile nav toggle (accessible) 19 const toggle = document.querySelector('.nav-toggle'); 20 const links = document.getElementById('nav-links'); 21 if (toggle && links) { 22 const setOpen = (open) => { 23 links.setAttribute('data-open', String(open)); 24 toggle.setAttribute('aria-expanded', String(open)); 25 document.body.classList.toggle('nav-open', open); 26 }; 27 toggle.addEventListener('click', () => { 28 const open = links.getAttribute('data-open') === 'true'; 29 setOpen(!open); 30 }); 31 links.querySelectorAll('a').forEach(link => { 32 link.addEventListener('click', () => setOpen(false)); 33 }); 34 document.addEventListener('keydown', (e) => { 35 if (e.key === 'Escape' && links.getAttribute('data-open') === 'true') { 36 setOpen(false); 37 toggle.focus(); 38 } 39 }); 40 } 41 42} 43 44// The page's contents and anchors become available after parsing. Disclosures 45// start closed in HTML; opening a wide sidebar does not shift the reading column. 46function initPageNavigation() { 47 const wideLayout = window.matchMedia('(min-width: 1100px)'); 48 document.querySelectorAll('.toc-disclosure').forEach(details => { 49 details.open = wideLayout.matches; 50 wideLayout.addEventListener('change', event => { details.open = event.matches; }); 51 }); 52 53 // Guarded smooth scroll (hash links only) - respects scroll-padding-top 54 document.querySelectorAll('a[href^="#"]:not(.skip-to-content)').forEach(a => { 55 const h = a.getAttribute('href'); 56 if (!h || h === '#') return; 57 a.addEventListener('click', e => { 58 const t = document.getElementById(decodeURIComponent(h.slice(1))); 59 if (t) { 60 e.preventDefault(); 61 const disclosure = a.closest('.toc-disclosure'); 62 if (disclosure && !wideLayout.matches) disclosure.open = false; 63 const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 64 t.scrollIntoView({ behavior: reduceMotion ? 'instant' : 'smooth', block: 'start' }); 65 if (!t.hasAttribute('tabindex')) t.setAttribute('tabindex', '-1'); 66 t.focus({ preventScroll: true }); 67 // Update URL hash and trigger active state update 68 history.pushState(null, '', h); 69 // Update TOC active state immediately after click 70 updateTocActiveState(h); 71 // Pause scroll spy to prevent it from overriding the click-based update 72 pauseScrollSpy(800); 73 } 74 }); 75 }); 76} 77 78// Flag to temporarily pause scroll spy after click 79let scrollSpyPaused = false; 80let scrollSpyPauseTimeout = null; 81 82// Update TOC active state for a specific hash 83function updateTocActiveState(hash) { 84 const toc = document.querySelector('.toc'); 85 if (!toc) return; 86 87 const links = toc.querySelectorAll('.toc-link[href^="#"]'); 88 links.forEach(link => { 89 link.removeAttribute('aria-current'); 90 if (link.getAttribute('href') === hash) { 91 link.setAttribute('aria-current', 'location'); 92 } 93 }); 94} 95 96// Pause scroll spy temporarily (used after click) 97function pauseScrollSpy(duration) { 98 scrollSpyPaused = true; 99 if (scrollSpyPauseTimeout) clearTimeout(scrollSpyPauseTimeout); 100 scrollSpyPauseTimeout = setTimeout(() => { 101 scrollSpyPaused = false; 102 }, duration); 103} 104 105// Scroll-spy for the TOC: highlights active section as user scrolls 106function initScrollSpy() { 107 const subnav = document.querySelector('.toc'); 108 if (!subnav) return; 109 110 const links = subnav.querySelectorAll('.toc-link[href^="#"]'); 111 if (!links.length) return; 112 113 // Get all target sections 114 const sections = []; 115 links.forEach(link => { 116 const targetId = link.getAttribute('href').substring(1); 117 const section = document.getElementById(targetId); 118 if (section) { 119 sections.push({ id: targetId, el: section, link: link }); 120 } 121 }); 122 123 if (!sections.length) return; 124 125 // Throttled scroll handler 126 let ticking = false;
127 function onScroll() { 128 if (ticking) return; 129 ticking = true; 130 requestAnimationFrame(() => { 131 updateActiveSection(); 132 ticking = false; 133 }); 134 } 135 136 function updateActiveSection() { 137 // Skip if scroll spy is paused (e.g., after a click) 138 if (scrollSpyPaused) return; 139 140 const scrollPadding = parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 0; 141 const scrollPos = window.scrollY + scrollPadding + 17; 142 let activeSection = sections[0]; 143 144 // Check if we're at the bottom of the page 145 const atBottom = (window.innerHeight + window.scrollY) >= (document.body.scrollHeight - 50); 146 147 if (atBottom && sections.length > 0) { 148 // If at bottom, highlight the last section 149 activeSection = sections[sections.length - 1]; 150 } else { 151 // Find the section closest to or above the viewport 152 for (const section of sections) { 153 if (section.el.offsetTop <= scrollPos) { 154 activeSection = section; 155 } 156 } 157 } 158 159 // Update aria-current 160 links.forEach(link => link.removeAttribute('aria-current')); 161 if (activeSection) { 162 activeSection.link.setAttribute('aria-current', 'location'); 163 } 164 } 165 166 window.addEventListener('scroll', onScroll, { passive: true }); 167 updateActiveSection(); // Initial state 168} 169 170initNavFeatures(); 171function initPageFeatures() { 172 initPageNavigation(); 173 initScrollSpy(); 174} 175if (document.readyState === 'loading') { 176 document.addEventListener('DOMContentLoaded', initPageFeatures, { once: true }); 177} else { 178 initPageFeatures(); 179}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.