1/** 2 * Header Navigation Scripts 3 * 4 * Handles mobile menu, search modal, and dropdown navigation. 5 * 6 * @package StudioSwift 7 */ 8 9(function() { 10 'use strict'; 11 12 // Wait for DOM to be ready. 13 document.addEventListener('DOMContentLoaded', function() { 14 initMobileMenu(); 15 initDropdowns(); 16 initSearchModal(); 17 initKeyboardNavigation(); 18 initFocusTrap(); 19 stickyHeader(); 20 }); 21 22 /** 23 * Mobile menu toggle 24 */ 25 function initMobileMenu() { 26 const menuOpen = document.querySelector('.js-menu-open'); 27 const menuClose = document.querySelector('.js-menu-close'); 28 const header = document.querySelector('.js-header'); 29 const nav = document.querySelector('.header__nav'); 30 const body = document.body; 31 32 if (!menuOpen || !menuClose || !header || !nav) return; 33 34 // Open menu 35 menuOpen.addEventListener('click', function() { 36 header.classList.add('is-open'); 37 body.classList.add('menu-open'); 38 menuOpen.setAttribute('aria-expanded', 'true'); 39 40 // Set focus to close button 41 menuClose.focus(); 42 43 // Prevent body scroll 44 body.style.overflow = 'hidden'; 45 }); 46 47 // Close menu 48 function closeMenu() { 49 header.classList.remove('is-open'); 50 body.classList.remove('menu-open'); 51 menuOpen.setAttribute('aria-expanded', 'false'); 52 body.style.overflow = ''; 53 54 // Return focus to open button 55 menuOpen.focus(); 56 } 57 58 menuClose.addEventListener('click', closeMenu); 59 60 // Close on escape key 61 document.addEventListener('keydown', function(e) { 62 if (e.key === 'Escape' && header.classList.contains('is-open')) { 63 closeMenu(); 64 } 65 }); 66 67 // Close when clicking outside 68 document.addEventListener('click', function(e) { 69 if (header.classList.contains('is-open') && 70 !nav.contains(e.target) && 71 !menuOpen.contains(e.target)) { 72 closeMenu(); 73 } 74 }); 75 } 76 77 /** 78 * Dropdown menu functionality 79 */ 80 function initDropdowns() { 81 const dropdownToggles = document.querySelectorAll('.dropdown-toggle'); 82 83 dropdownToggles.forEach(function(toggle) { 84 toggle.addEventListener('click', function(e) { 85 e.preventDefault(); 86 e.stopPropagation(); 87 88 const parent = toggle.closest('.has-dropdown'); 89 const submenu = parent.querySelector('.sub-menu'); 90 const isExpanded = toggle.getAttribute('aria-expanded') === 'true'; 91 92 // Close all other dropdowns 93 closeAllDropdowns(); 94 95 if (!isExpanded) { 96 // Open this dropdown 97 toggle.setAttribute('aria-expanded', 'true'); 98 submenu.setAttribute('aria-hidden', 'false'); 99 parent.classList.add('is-open'); 100 } 101 }); 102 }); 103 104 // Close dropdowns when clicking outside 105 document.addEventListener('click', function(e) { 106 if (!e.target.closest('.has-dropdown')) { 107 closeAllDropdowns(); 108 } 109 }); 110 111 // Close on escape key 112 document.addEventListener('keydown', function(e) { 113 if (e.key === 'Escape') { 114 closeAllDropdowns(); 115 } 116 }); 117 } 118 119 /** 120 * Close all dropdown menus 121 */ 122 function closeAllDropdowns() { 123 const dropdowns = document.querySelectorAll('.has-dropdown'); 124 125 dropdowns.forEach(function(dropdown) { 126 const toggle = dropdown.querySelector('.dropdown-toggle'); 127 const submenu = dropdown.querySelector('.sub-menu'); 128 129 if (toggle && submenu) { 130 toggle.setAttribute('aria-expanded', 'false'); 131 submenu.setAttribute('aria-hidden', 'true'); 132 dropdown.classList.remove('is-open'); 133 } 134 }); 135 } 136 137 /** 138 * Search modal functionality 139 */ 140 function initSearchModal() { 141 const searchBtns = document.querySelectorAll('.js-search-btn'); 142 const searchCloses = document.querySelectorAll('.js-search-close'); 143 const searchModal = document.getElementById('site-search'); 144 const searchOverlay = document.querySelector('.js-search-overlay'); 145 146 if (!searchModal) return; 147 148 // Open search 149 searchBtns.forEach(function(btn) { 150 btn.addEventListener('click', function() { 151 searchModal.classList.add('is-open'); 152 searchModal.previousElementSibling.classList.add('is-open'); 153 searchModal.setAttribute('aria-hidden', 'false'); 154 searchModal.removeAttribute('inert'); 155 btn.setAttribute('aria-expanded', 'true'); 156 document.body.style.overflow = 'hidden'; 157 158 // Clear search input and results 159 const searchInput = searchModal.querySelector('input[type="search"]'); 160 const searchResults = searchModal.querySelector('.site-search__results ul'); 161 const searchFooter = searchModal.querySelector('.site-search__footer'); 162 163 if (searchInput) { 164 searchInput.value = ''; 165 } 166 167 if (searchResults) { 168 searchResults.innerHTML = ''; 169 } 170 171 if (searchFooter) { 172 searchFooter.style.display = 'none'; 173 } 174 175 // Focus search input 176 if (searchInput) { 177 setTimeout(function() { 178 searchInput.focus(); 179 }, 100); 180 } 181 }); 182 }); 183 184 // Close search 185 function closeSearch() { 186 searchModal.classList.remove('is-open'); 187 searchOverlay.classList.remove('is-open'); 188 searchModal.setAttribute('aria-hidden', 'true'); 189 searchModal.setAttribute('inert', '');
190 searchBtns.forEach(function(btn) { 191 btn.setAttribute('aria-expanded', 'false'); 192 }); 193 document.body.style.overflow = ''; 194 195 // Return focus to search button 196 if (searchBtns[0]) { 197 searchBtns[0].focus(); 198 } 199 } 200 201 searchCloses.forEach(function(btn) { 202 btn.addEventListener('click', closeSearch); 203 }); 204 205 // Close on escape key 206 document.addEventListener('keydown', function(e) { 207 if (e.key === 'Escape' && searchModal.classList.contains('is-open')) { 208 closeSearch(); 209 } 210 }); 211 } 212 213 /** 214 * Keyboard navigation support 215 */ 216 function initKeyboardNavigation() { 217 const nav = document.querySelector('.header__nav'); 218 if (!nav) return; 219 220 // Handle arrow keys in dropdown menus 221 nav.addEventListener('keydown', function(e) { 222 const activeElement = document.activeElement; 223 const parent = activeElement.closest('.has-dropdown'); 224 225 if (!parent) return; 226 227 const submenu = parent.querySelector('.sub-menu'); 228 const toggle = parent.querySelector('.dropdown-toggle'); 229 230 // Arrow down: Open dropdown or move to first item 231 if (e.key === 'ArrowDown') { 232 e.preventDefault(); 233 234 if (activeElement === toggle || activeElement.closest('.dropdown-link')) { 235 const isExpanded = toggle.getAttribute('aria-expanded') === 'true'; 236 237 if (!isExpanded) { 238 toggle.click(); 239 } 240 241 if (submenu) { 242 const firstLink = submenu.querySelector('a'); 243 if (firstLink) firstLink.focus(); 244 } 245 } else if (submenu.contains(activeElement)) { 246 focusNextMenuItem(submenu, activeElement); 247 } 248 } 249 250 // Arrow up: Move to previous item or close 251 if (e.key === 'ArrowUp') { 252 e.preventDefault(); 253 254 if (submenu && submenu.contains(activeElement)) { 255 focusPreviousMenuItem(submenu, activeElement); 256 } 257 } 258 }); 259 } 260 261 /** 262 * Focus next menu item 263 */ 264 function focusNextMenuItem(submenu, currentElement) { 265 const items = Array.from(submenu.querySelectorAll('a')); 266 const currentIndex = items.indexOf(currentElement); 267 268 if (currentIndex < items.length - 1) { 269 items[currentIndex + 1].focus(); 270 } 271 } 272 273 /** 274 * Focus previous menu item 275 */ 276 function focusPreviousMenuItem(submenu, currentElement) { 277 const items = Array.from(submenu.querySelectorAll('a')); 278 const currentIndex = items.indexOf(currentElement); 279 280 if (currentIndex > 0) { 281 items[currentIndex - 1].focus(); 282 } else { 283 // Focus back on toggle 284 const parent = submenu.closest('.has-dropdown'); 285 const toggle = parent.querySelector('.dropdown-toggle'); 286 if (toggle) toggle.focus(); 287 } 288 } 289 290 /** 291 * Focus trap for modals (search, mobile menu) 292 */ 293 function initFocusTrap() { 294 // This ensures focus stays within modal when tabbing 295 document.addEventListener('keydown', function(e) { 296 if (e.key !== 'Tab') return; 297 298 const searchModal = document.getElementById('site-search'); 299 const header = document.querySelector('.js-header'); 300 const nav = document.querySelector('.header__nav'); 301 302 // Focus trap in search modal 303 if (searchModal && searchModal.classList.contains('is-open')) { 304 trapFocus(e, searchModal); 305 } 306 307 // Focus trap in mobile menu 308 if (header && header.classList.contains('is-open') && nav) { 309 trapFocus(e, nav); 310 } 311 }); 312 } 313 314 /** 315 * Trap focus within an element 316 */ 317 function trapFocus(e, element) { 318 const focusableElements = element.querySelectorAll( 319 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' 320 ); 321 322 const firstFocusable = focusableElements[0]; 323 const lastFocusable = focusableElements[focusableElements.length - 1]; 324 325 if (e.shiftKey) { 326 // Shift + Tab 327 if (document.activeElement === firstFocusable) { 328 e.preventDefault(); 329 lastFocusable.focus(); 330 } 331 } else { 332 // Tab 333 if (document.activeElement === lastFocusable) { 334 e.preventDefault(); 335 firstFocusable.focus(); 336 } 337 } 338 } 339 340 function stickyHeader() { 341 const header = document.querySelector('.js-header'); 342 let lastScrollTop = 0; 343 const headerHeight = header.offsetHeight; 344 345 window.addEventListener('scroll', () => { 346 const currentScroll = window.pageYOffset || document.documentElement.scrollTop; 347 348 // Only start hiding after scrolling past header height 349 if (currentScroll > headerHeight) { 350 if (currentScroll > lastScrollTop) { 351 // scrolling down 352 header.classList.add('is-hidden'); 353 } else { 354 // scrolling up 355 header.classList.remove('is-hidden'); 356 } 357 } else { 358 // always show header if within header height 359 header.classList.remove('hide-header'); 360 } 361 362 lastScrollTop = currentScroll <= 0 ? 0 : currentScroll; 363 }); 364 365 } 366 367})(); 368
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.