1/** 2 * Header Component JavaScript 3 * Smithsonian Magazine Style - Handles mobile menu, search overlay, and scroll behavior 4 */ 5 6(function() { 7 'use strict'; 8 9 // DOM Elements 10 let header, menuToggle, mainNav, navClose, searchToggle, searchOverlay, searchClose, searchInput; 11 let isMenuOpen = false; 12 13 /** 14 * Initialize header functionality 15 */ 16 function init() { 17 // Get DOM elements 18 header = document.querySelector('.site-header'); 19 menuToggle = document.getElementById('menu-toggle'); 20 mainNav = document.getElementById('main-nav'); 21 navClose = document.getElementById('nav-close'); 22 searchToggle = document.getElementById('search-toggle'); 23 searchOverlay = document.getElementById('search-overlay'); 24 searchClose = document.getElementById('search-close'); 25 searchInput = searchOverlay ? searchOverlay.querySelector('.search-overlay-input') : null; 26 27 if (!header) return; 28 29 // Initialize features 30 initMobileMenu(); 31 initMobileDropdowns(); 32 initSearchOverlay(); 33 initStickyHeader(); 34 highlightActiveNavLink(); 35 } 36 37 /** 38 * Mobile Menu Toggle 39 */ 40 function initMobileMenu() { 41 if (!menuToggle || !mainNav) return; 42 43 menuToggle.addEventListener('click', toggleMenu); 44 45 // Close button inside drawer 46 if (navClose) { 47 navClose.addEventListener('click', closeMenu); 48 } 49 50 // Close menu when clicking overlay (the ::before pseudo-element) 51 mainNav.addEventListener('click', (e) => { 52 if (e.target === mainNav) { 53 closeMenu(); 54 } 55 }); 56 57 // Close on escape key 58 document.addEventListener('keydown', (e) => { 59 if (e.key === 'Escape' && isMenuOpen) { 60 closeMenu(); 61 } 62 }); 63
64 // Close menu on resize to desktop 65 window.addEventListener('resize', debounce(() => { 66 if (window.innerWidth >= 992 && isMenuOpen) { 67 closeMenu(); 68 } 69 }, 100)); 70 } 71 72 function toggleMenu() { 73 isMenuOpen = !isMenuOpen; 74 75 menuToggle.setAttribute('aria-expanded', isMenuOpen); 76 mainNav.classList.toggle('is-open', isMenuOpen); 77 document.body.classList.toggle('menu-open', isMenuOpen); 78 } 79 80 function closeMenu() { 81 isMenuOpen = false; 82 menuToggle.setAttribute('aria-expanded', 'false'); 83 mainNav.classList.remove('is-open'); 84 document.body.classList.remove('menu-open'); 85 86 // Close all mobile dropdowns when menu closes 87 const openDropdowns = document.querySelectorAll('.nav-item.has-dropdown.is-open'); 88 openDropdowns.forEach(item => item.classList.remove('is-open')); 89 } 90 91 /** 92 * Mobile Dropdown Toggle (for touch/click on mobile) 93 * Creates separate toggle buttons so that links remain clickable for navigation 94 * while the arrow button expands/collapses the submenu. 95 */ 96 function initMobileDropdowns() { 97 // Guard: don't add toggles twice (init may run multiple times) 98 if (document.querySelector('.nav-dropdown-toggle')) return; 99 100 var chevronSvg = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>'; 101 var chevronSmallSvg = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>'; 102 103 // Top-level dropdowns 104 var dropdownItems = document.querySelectorAll('.nav-item.has-dropdown'); 105 106 dropdownItems.forEach(function(item) { 107 var link = item.querySelector(':scope > .nav-link'); 108 if (!link) return; 109 110 // Create a separate toggle button for expanding/collapsing submenu 111 var toggle = document.createElement('button'); 112 toggle.className = 'nav-dropdown-toggle'; 113 toggle.setAttribute('aria-label', 'Toggle submenu'); 114 toggle.setAttribute('type', 'button'); 115 toggle.innerHTML = chevronSvg; 116 link.after(toggle); 117 118 toggle.addEventListener('click', function(e) { 119 e.preventDefault(); 120 e.stopPropagation(); 121 122 if (window.innerWidth < 992) { 123 // Close other open dropdowns 124 dropdownItems.forEach(function(otherItem) { 125 if (otherItem !== item && otherItem.classList.contains('is-open')) { 126 otherItem.classList.remove('is-open'); 127 } 128 }); 129 item.classList.toggle('is-open'); 130 } 131 }); 132 }); 133 134 // Nested sub-dropdowns 135 var subdropdownItems = document.querySelectorAll('.has-subdropdown'); 136 137 subdropdownItems.forEach(function(item) { 138 var link = item.querySelector(':scope > .nav-dropdown__link'); 139 if (!link) return; 140 141 var toggle = document.createElement('button'); 142 toggle.className = 'nav-subdropdown-toggle'; 143 toggle.setAttribute('aria-label', 'Toggle submenu'); 144 toggle.setAttribute('type', 'button'); 145 toggle.innerHTML = chevronSmallSvg; 146 link.after(toggle); 147 148 toggle.addEventListener('click', function(e) { 149 e.preventDefault(); 150 e.stopPropagation(); 151 152 if (window.innerWidth < 992) { 153 subdropdownItems.forEach(function(otherItem) { 154 if (otherItem !== item && otherItem.classList.contains('is-open')) { 155 otherItem.classList.remove('is-open'); 156 } 157 }); 158 item.classList.toggle('is-open'); 159 } 160 }); 161 }); 162 } 163 164 /** 165 * Search Overlay Toggle 166 */ 167 function initSearchOverlay() { 168 if (!searchToggle || !searchOverlay) return; 169 170 searchToggle.addEventListener('click', (e) => { 171 e.preventDefault(); 172 openSearchOverlay(); 173 }); 174 175 if (searchClose) { 176 searchClose.addEventListener('click', closeSearchOverlay); 177 } 178 179 // Close when clicking overlay background 180 searchOverlay.addEventListener('click', (e) => { 181 if (e.target === searchOverlay) { 182 closeSearchOverlay(); 183 } 184 }); 185 186 // Close on escape key 187 document.addEventListener('keydown', (e) => { 188 if (e.key === 'Escape' && searchOverlay.classList.contains('is-open')) { 189 closeSearchOverlay(); 190 } 191 }); 192 } 193 194 function openSearchOverlay() { 195 searchOverlay.classList.add('is-open'); 196 document.body.style.overflow = 'hidden'; 197 198 // Focus on input 199 if (searchInput) { 200 setTimeout(() => searchInput.focus(), 100); 201 } 202 } 203 204 function closeSearchOverlay() { 205 searchOverlay.classList.remove('is-open'); 206 document.body.style.overflow = ''; 207 } 208 209 /** 210 * Sticky header with scroll behavior 211 */ 212 function initStickyHeader() { 213 const headerTop = document.querySelector('.header-top'); 214 if (!headerTop) return; 215 216 window.addEventListener('scroll', () => { 217 const currentScrollY = window.pageYOffset; 218 219 // Add shadow when scrolled 220 if (currentScrollY > 10) { 221 headerTop.classList.add('scrolled'); 222 } else { 223 headerTop.classList.remove('scrolled'); 224 } 225 }, { passive: true });
226 } 227 228 /** 229 * Highlight active navigation link based on centralized config 230 */ 231 function highlightActiveNavLink() { 232 var rule = window.getNavHighlightRule ? window.getNavHighlightRule() : null; 233 if (!rule) return; 234 235 // Build a set of target hrefs (lowercased, no hash) 236 var targets = {}; 237 for (var i = 0; i < rule.nav.length; i++) { 238 targets[rule.nav[i].toLowerCase().split('#')[0]] = true; 239 } 240 241 // The section parent is the first nav item (used for top-level nav highlighting). 242 // Dropdown items matching ONLY the section parent should NOT be highlighted 243 // (e.g., "About Website" shouldn't highlight when on "News" page). 244 var sectionParent = rule.nav.length > 1 ? rule.nav[0].toLowerCase().split('#')[0] : null; 245 246 // Helper: normalize a link's href to site-root-relative form 247 function normalizeHref(href) { 248 if (!href) return ''; 249 return href.replace(/\.\.\//g, '').split('#')[0].toLowerCase(); 250 } 251 252 // Scan all nav links at every level 253 var allLinks = document.querySelectorAll('.nav-link, .nav-dropdown__link, .nav-subdropdown__link'); 254 allLinks.forEach(function(link) { 255 var href = link.getAttribute('href'); 256 if (!href || href === '#') return; 257 var norm = normalizeHref(href); 258 if (!targets[norm]) return; 259 260 // Determine link type and apply the correct active class 261 if (link.classList.contains('nav-subdropdown__link')) { 262 link.classList.add('nav-subdropdown__link--active'); 263 } else if (link.classList.contains('nav-dropdown__link')) { 264 // Skip dropdown items that only match the section parent 265 // (e.g. don't highlight "About Website" when on "News" page) 266 if (sectionParent && norm === sectionParent) return; 267 link.classList.add('nav-dropdown__link--active'); 268 } else if (link.classList.contains('nav-link')) { 269 link.classList.add('nav-link--active'); 270 } 271 }); 272 } 273 274 /** 275 * Debounce utility 276 */ 277 function debounce(func, wait) { 278 let timeout; 279 return function executedFunction(...args) { 280 const later = () => { 281 clearTimeout(timeout); 282 func(...args); 283 }; 284 clearTimeout(timeout); 285 timeout = setTimeout(later, wait); 286 }; 287 } 288 289 // Initialize when DOM is ready 290 if (document.readyState === 'loading') { 291 document.addEventListener('DOMContentLoaded', init); 292 } else { 293 init(); 294 } 295 296 // Re-initialize after components are loaded 297 document.addEventListener('componentsLoaded', init); 298})();
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.