1/** 2 * Mobile Navigation - Vanilla JavaScript 3 */ 4 5(function() { 6 'use strict'; 7 8 // Mobile Menu Controller 9 const MobileMenu = { 10 menuButton: null, 11 responsiveMenu: null, 12 menuOverlay: null, 13 menuItems: null, 14 lastFocusedElement: null, 15 16 init: function() { 17 this.menuButton = document.getElementById('menu-button'); 18 this.responsiveMenu = document.getElementById('responsive-menu'); 19 this.menuOverlay = document.querySelector('.menu-overlay'); 20 this.menuItems = document.querySelector('.responsive-menu-items'); 21 22 if (!this.menuButton || !this.responsiveMenu || !this.menuItems) { 23 return; // Exit if required elements don't exist 24 } 25 26 // Ensure all submenus are completely hidden on init 27 this.initializeSubmenus(); 28 29 this.bindEvents(); 30 this.setupKeyboardNavigation(); 31 }, 32 33 initializeSubmenus: function() { 34 // Force all submenus to be completely hidden on page load 35 const submenus = document.querySelectorAll('.responsive-menu-items .sub-menu'); 36 submenus.forEach(submenu => { 37 // Remove active class and let CSS handle hiding 38 submenu.classList.remove('active'); 39 submenu.setAttribute('aria-hidden', 'true'); 40 }); 41 42 // Ensure parent items are marked as closed 43 const parentItems = document.querySelectorAll('.responsive-menu-items .menu-item-has-children'); 44 parentItems.forEach(item => { 45 item.classList.remove('open'); 46 }); 47 48 // Set proper ARIA attributes on all submenu toggles 49 const parentLinks = document.querySelectorAll('.responsive-menu-items .menu-item-has-children > a'); 50 parentLinks.forEach(link => { 51 link.setAttribute('aria-expanded', 'false'); 52 }); 53 }, 54 55 bindEvents: function() { 56 const self = this; 57 58 // Menu button click (toggles open/close) 59 this.menuButton.addEventListener('click', function(e) { 60 e.preventDefault(); 61 self.toggleMenu(); 62 }); 63 64 // Overlay click to close 65 if (this.menuOverlay) { 66 this.menuOverlay.addEventListener('click', function() { 67 self.closeMenu(); 68 }); 69 } 70 71 // Menu link clicks 72 const menuLinks = this.menuItems.querySelectorAll('a'); 73 menuLinks.forEach(link => { 74 link.addEventListener('click', function(e) { 75 const parent = this.parentElement; 76 77 if (parent.classList.contains('menu-item-has-children')) { 78 e.preventDefault(); 79 self.toggleSubMenu(this); 80 } else { 81 self.closeMenu(); 82 } 83 }); 84 }); 85 86 // ESC key to close menu 87 document.addEventListener('keydown', function(e) { 88 if (e.key === 'Escape' && self.responsiveMenu.classList.contains('open')) { 89 self.closeMenu(); 90 } 91 }); 92 }, 93 94 setupKeyboardNavigation: function() { 95 const self = this; 96 97 // Arrow key navigation 98 const menuLinks = this.menuItems.querySelectorAll('a'); 99 menuLinks.forEach(link => { 100 link.addEventListener('keydown', function(e) { 101 const currentItem = this.parentElement; 102 const allLinks = Array.from(self.menuItems.querySelectorAll('a')).filter(l => { 103 return l.offsetParent !== null; // Only visible links 104 }); 105 const currentIndex = allLinks.indexOf(this); 106 107 switch(e.key) { 108 case 'ArrowDown': 109 e.preventDefault(); 110 self.focusNextItem(allLinks, currentIndex); 111 break; 112 113 case 'ArrowUp': 114 e.preventDefault(); 115 self.focusPreviousItem(allLinks, currentIndex); 116 break; 117 118 case 'ArrowRight': 119 if (currentItem.classList.contains('menu-item-has-children') && 120 !currentItem.classList.contains('open')) { 121 e.preventDefault(); 122 self.openSubMenu(this); 123 } 124 break; 125 126 case 'ArrowLeft': 127 if (currentItem.classList.contains('open')) { 128 e.preventDefault(); 129 self.closeSubMenu(this); 130 } else if (currentItem.closest('.sub-menu')) { 131 e.preventDefault(); 132 const parentItem = currentItem.closest('.menu-item-has-children'); 133 if (parentItem) { 134 const parentLink = parentItem.querySelector(':scope > a'); 135 if (parentLink) parentLink.focus(); 136 } 137 } 138 break; 139 140 case 'Enter': 141 case ' ': 142 if (currentItem.classList.contains('menu-item-has-children')) { 143 e.preventDefault(); 144 self.toggleSubMenu(this); 145 } 146 break; 147 148 case 'Home': 149 e.preventDefault(); 150 if (allLinks.length > 0) allLinks[0].focus(); 151 break; 152 153 case 'End': 154 e.preventDefault(); 155 if (allLinks.length > 0) allLinks[allLinks.length - 1].focus(); 156 break; 157 } 158 }); 159 }); 160 161 // Tab trap within open menu 162 this.responsiveMenu.addEventListener('keydown', function(e) { 163 if (e.key === 'Tab' && self.responsiveMenu.classList.contains('open')) { 164 self.handleTabKey(e); 165 } 166 }); 167 }, 168 169 toggleMenu: function() { 170 if (this.responsiveMenu.classList.contains('open')) { 171 this.closeMenu(); 172 } else { 173 this.openMenu(); 174 } 175 }, 176 177 openMenu: function() { 178 this.lastFocusedElement = document.activeElement; 179 180 this.responsiveMenu.classList.add('open'); 181 this.menuButton.classList.add('open'); 182 document.body.classList.add('responsive-menu-open'); 183 184 this.menuButton.setAttribute('aria-expanded', 'true'); 185 this.menuItems.setAttribute('aria-hidden', 'false'); 186 187 setTimeout(() => { 188 const firstLink = this.menuItems.querySelector('a'); 189 if (firstLink) firstLink.focus(); 190 }, 300); 191 192 this.announce('Menu opened'); 193}, 194 195 closeMenu: function() { 196 const openItems = this.menuItems.querySelectorAll('.menu-item-has-children.open');
197 openItems.forEach(item => { 198 const link = item.querySelector(':scope > a'); 199 if (link) this.closeSubMenu(link, false); 200 }); 201 202 this.responsiveMenu.classList.remove('open'); 203 this.menuButton.classList.remove('open'); 204 document.body.classList.remove('responsive-menu-open'); 205 206 this.menuButton.setAttribute('aria-expanded', 'false'); 207 this.menuItems.setAttribute('aria-hidden', 'true'); 208 209 if (this.lastFocusedElement) { 210 this.lastFocusedElement.focus(); 211 } else { 212 this.menuButton.focus(); 213 } 214 215 this.announce('Menu closed'); 216}, 217 218 toggleSubMenu: function(link) { 219 const parent = link.parentElement; 220 221 if (parent.classList.contains('open')) { 222 this.closeSubMenu(link); 223 } else { 224 this.openSubMenu(link); 225 } 226 }, 227 228 openSubMenu: function(link) { 229 const parent = link.parentElement; 230 const submenu = link.nextElementSibling; 231 232 if (!submenu || !submenu.classList.contains('sub-menu')) { 233 return; 234 } 235 236 // Close sibling submenus 237 const siblings = Array.from(parent.parentElement.children); 238 siblings.forEach(sibling => { 239 if (sibling !== parent && sibling.classList.contains('menu-item-has-children') && 240 sibling.classList.contains('open')) { 241 const siblingLink = sibling.querySelector(':scope > a'); 242 if (siblingLink) this.closeSubMenu(siblingLink, false); 243 } 244 }); 245 246 // Open this submenu - add active class (CSS handles animation) 247 submenu.classList.add('active'); 248 parent.classList.add('open'); 249 250 // Remove any inline max-height that might be blocking 251 submenu.style.maxHeight = ''; 252 253 // Update ARIA attributes 254 link.setAttribute('aria-expanded', 'true'); 255 submenu.setAttribute('aria-hidden', 'false'); 256 257 // Focus first submenu item after animation 258 setTimeout(() => { 259 const firstLink = submenu.querySelector('a'); 260 if (firstLink) firstLink.focus(); 261 }, 400); 262 263 // Announce to screen readers 264 const label = link.getAttribute('aria-label') || link.textContent; 265 this.announce(label + ' submenu opened'); 266 }, 267 268 closeSubMenu: function(link, shouldFocus = true) { 269 const parent = link.parentElement; 270 const submenu = link.nextElementSibling; 271 272 if (!submenu || !submenu.classList.contains('sub-menu')) { 273 return; 274 } 275 276 // Remove classes (CSS handles the collapse animation) 277 submenu.classList.remove('active'); 278 parent.classList.remove('open'); 279 280 // Update ARIA attributes 281 link.setAttribute('aria-expanded', 'false'); 282 submenu.setAttribute('aria-hidden', 'true'); 283 284 // Return focus to parent link 285 if (shouldFocus) { 286 link.focus(); 287 } 288 289 // Announce to screen readers 290 const label = link.getAttribute('aria-label') || link.textContent; 291 this.announce(label + ' submenu closed'); 292 }, 293 294 focusNextItem: function(allLinks, currentIndex) { 295 const nextIndex = (currentIndex + 1) % allLinks.length; 296 allLinks[nextIndex].focus(); 297 }, 298 299 focusPreviousItem: function(allLinks, currentIndex) { 300 const prevIndex = currentIndex > 0 ? currentIndex - 1 : allLinks.length - 1; 301 allLinks[prevIndex].focus(); 302 }, 303 304 handleTabKey: function(e) { 305 const focusableElements = Array.from( 306 this.responsiveMenu.querySelectorAll('a:not([style*="display: none"]), button:not([style*="display: none"])') 307 ).filter(el => el.offsetParent !== null); 308 309 const firstElement = focusableElements[0]; 310 const lastElement = focusableElements[focusableElements.length - 1]; 311 312 if (e.shiftKey) { 313 // Shift + Tab 314 if (document.activeElement === firstElement) { 315 e.preventDefault(); 316 lastElement.focus(); 317 } 318 } else { 319 // Tab 320 if (document.activeElement === lastElement) { 321 e.preventDefault(); 322 firstElement.focus(); 323 } 324 } 325 }, 326 327 // Simple animation function (replaces jQuery's animate) 328 animate: function(element, properties, duration, callback) { 329 const start = performance.now(); 330 const initialStyles = {}; 331 332 // Get initial values 333 for (let prop in properties) { 334 const computed = window.getComputedStyle(element); 335 if (prop === 'height') { 336 initialStyles[prop] = parseFloat(computed.height) || 0; 337 } else if (prop === 'opacity') {
338 initialStyles[prop] = parseFloat(computed.opacity) || 0; 339 } else if (prop === 'padding') { 340 initialStyles[prop] = parseFloat(computed.padding) || 0; 341 } else if (prop === 'margin') { 342 initialStyles[prop] = parseFloat(computed.margin) || 0; 343 } 344 } 345 346 const animate = (currentTime) => { 347 const elapsed = currentTime - start; 348 const progress = Math.min(elapsed / duration, 1); 349 350 // Easing function (ease-in-out) 351 const eased = progress < 0.5 352 ? 2 * progress * progress 353 : -1 + (4 - 2 * progress) * progress; 354 355 // Apply animated values 356 for (let prop in properties) { 357 const target = parseFloat(properties[prop]); 358 const initial = initialStyles[prop] || 0; 359 const current = initial + (target - initial) * eased; 360 361 if (prop === 'height') { 362 element.style.height = current + 'px'; 363 } else if (prop === 'opacity') { 364 element.style.opacity = current; 365 } else if (prop === 'padding') { 366 element.style.padding = current + 'px'; 367 } else if (prop === 'margin') { 368 element.style.margin = current + 'px'; 369 } 370 } 371 372 if (progress < 1) { 373 requestAnimationFrame(animate); 374 } else { 375 if (callback) callback(); 376 } 377 }; 378 379 requestAnimationFrame(animate); 380 }, 381 382 announce: function(message) { 383 // Create or update live region for screen reader announcements 384 let liveRegion = document.getElementById('mobile-menu-announcer'); 385 386 if (!liveRegion) { 387 liveRegion = document.createElement('div'); 388 liveRegion.id = 'mobile-menu-announcer'; 389 liveRegion.className = 'sr-only'; 390 liveRegion.setAttribute('aria-live', 'polite'); 391 liveRegion.setAttribute('aria-atomic', 'true'); 392 document.body.appendChild(liveRegion); 393 } 394 395 // Clear and announce 396 liveRegion.textContent = ''; 397 setTimeout(() => { 398 liveRegion.textContent = message; 399 }, 100); 400 } 401 }; 402 403 // Initialize on DOM ready 404 if (document.readyState === 'loading') { 405 document.addEventListener('DOMContentLoaded', function() { 406 MobileMenu.init(); 407 }); 408 } else { 409 MobileMenu.init(); 410 } 411
412 // Handle window resize - close menu if switching to desktop 413 let resizeTimer; 414 window.addEventListener('resize', function() { 415 clearTimeout(resizeTimer); 416 resizeTimer = setTimeout(function() { 417 const menu = document.getElementById('responsive-menu'); 418 if (menu && window.innerWidth > 1024 && menu.classList.contains('open')) { 419 MobileMenu.closeMenu(); 420 } 421 }, 250); 422 }); 423 424})();
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.