1/** 2 * VDI Main Nav - All Main Navigation Functionality 3 * 4 * Please review documentation upon first use, and, as-needed: 5 * https://docs.vincentdevelopment.ca/docs/starter-v3-enhancements/navigation/ 6 */ 7 8/** 9 * Navigation 10 * Handles navigation logic 11 * 12 * @param {string} mobileMenuButtonId 13 * @param {string} dropDownClass 14 * @param {string} subMenuLinkClass 15 */ 16class Navigation { 17 /** 18 * The main toggle element 19 * 20 * @type {HTMLElement} 21 * @link {https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties} 22 */ 23 #mobileMenuButton; 24 25 /** 26 * List of sub dropdown buttons 27 * 28 * @type {NodeList} 29 * @link {https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties} 30 */ 31 #dropdownButtons; 32 33 /** 34 * Class name to identify sub menu items (ul>li>a) 35 * @type {string} 36 */ 37 #subMenuElementIdentifier; 38 39 /** 40 * Current navigation level in sliding viewport 41 * @type {number} 42 */ 43 #currentLevel = 0; 44 45 /** 46 * Navigation stack for breadcrumb functionality 47 * @type {Array} 48 */ 49 #navigationStack = []; 50 51 /** 52 * Whether sliding viewport mode is enabled 53 * @type {boolean} 54 */ 55 #slidingViewportEnabled = false; 56 57 58 59 constructor(mobileMenuButtonId, dropDownClass, subMenuLinkClass = "sub-menu-item") { 60 this.#mobileMenuButton = document.getElementById(mobileMenuButtonId); 61 this.#dropdownButtons = document.querySelectorAll(dropDownClass); // Do not change this to getElementsByClassName, logic is iterating over non-live node list 62 this.#subMenuElementIdentifier = subMenuLinkClass; 63 64 this.syncMobileMenuScrollLock(); 65 66 this.handleEscapeKey(); 67 68 // Initialize sliding viewport immediately if styles are detected 69 this.initializeSlidingViewport(); 70 } 71 72 /** 73 * Handles main mobile toggling 74 * Adds an event listener to mobile menu dropdown 75 * toggle button 76 */ 77 mobileMenuToggle() { 78 this.#mobileMenuButton.addEventListener("click", (event) => { 79 this.toggleMobileMenu(event, this.#mobileMenuButton); // toggle submenu on mobile 80 }); 81 82 this.closeOnBlur(this.#mobileMenuButton, { "navType": "desktop" }); // close submenu when user clicks outside 83 this.closeOnBlur(this.#mobileMenuButton, { "navType": "mobile" }); 84 } 85 86 /** 87 * Handles dropdowns on desktop navigation 88 * Loops over list of navigation dropdown buttons 89 * and adds eventlisteners to toggle view 90 */ 91 desktopMenuDropdowns() { 92 this.#dropdownButtons.forEach((button) => { 93 button.addEventListener("click", (event) => { 94 this.toggleDropdown(event, button); 95 }); 96 97 this.closeOnBlur(button); // close menu when user clicks outside 98 this.handleFocus(button); // close dropdown when user finishes tabbing submenu elements 99 }); 100 } 101 102 /** 103 * Toggles desktop dropdowns 104 * 105 * @param {EventTarget} event 106 * @param {HTMLButtonElement} button 107 */ 108 toggleDropdown(event, button) { 109 this.toggleAriaExpanded(event, button); 110 111 const isExpanded = button.getAttribute("aria-expanded") === "true"; 112 if (isExpanded) { 113 this.centerExpandedMenuItem(button); 114 } 115 } 116 117 /** 118 * Toggles mobile menu 119 * 120 * @param {EventTarget} event 121 * @param {HTMLButtonElement} button 122 */ 123 toggleMobileMenu(event, button) { 124 this.toggleAriaExpanded(event, button); 125 this.syncMobileMenuScrollLock(); 126 127 const isExpanded = button.getAttribute("aria-expanded") === "true"; 128 129 if (!isExpanded) { 130 // Reset sliding navigation when menu is closed 131 if (this.#slidingViewportEnabled) { 132 this.resetSlidingNavigation(); 133 } 134 } 135 } 136 137 /** 138 * Toggles aria-expanded attribute 139 * 140 * @param {EventTarget} event 141 * @param {HTMLButtonElement} button 142 */ 143 toggleAriaExpanded(event, button) { 144 let isExpanded = event.currentTarget.getAttribute("aria-expanded") === "true"; // true is returned as string 145 146 // ... but open the targeted secondary nav 147 if (!isExpanded) { 148 // close all dropdowns... 149 this.closeAllDropDowns(); 150 // then toggle targeted dropdown... 151 button.setAttribute("aria-expanded", true); 152 } else { 153 button.setAttribute("aria-expanded", false); 154 } 155 } 156 157 /** 158 * Close dropdown when user clicks anywhere else on the screen 159 * 160 * @param {HTMLButtonElement} button 161 * @link {https://developer.mozilla.org/en-US/docs/Web/API/Element/blur_event} 162 * @link {https://developer.mozilla.org/en-US/docs/Web/API/FocusEvent/relatedTarget} 163 */ 164 closeOnBlur(button, args) { 165 if (args === undefined || args?.navType === "desktop") { 166 button.addEventListener("blur", (event) => { 167 if (event.relatedTarget == null) { 168 event.currentTarget.setAttribute("aria-expanded", false); 169 170 if (event.currentTarget === this.#mobileMenuButton) { 171 this.syncMobileMenuScrollLock(); 172 } 173 } 174 }, { passive: true }); 175 } 176 177 //FIXME: Remove hardcoded literal 178 if (args?.navType === "mobile") { 179 const menuContainer = document.getElementById("menu-container"); 180 181 if (!menuContainer) { 182 return; 183 } 184
185 // Close only when focus moves outside the whole mobile menu container. 186 menuContainer.addEventListener("focusout", (event) => { 187 const nextFocusElement = event.relatedTarget; 188 const focusStayedInsideMenu = nextFocusElement instanceof Node && menuContainer.contains(nextFocusElement); 189 190 if (!focusStayedInsideMenu) { 191 button.setAttribute("aria-expanded", false); 192 this.syncMobileMenuScrollLock(); 193 } 194 }, true) 195 } 196 } 197 198 /** 199 * Handles escape behaviour 200 * Closes all dropdown when user hits 201 * escape key 202 * 203 * @link {https://developer.mozilla.org/en-US/docs/Web/API/Element/keyup_event} 204 */ 205 handleEscapeKey() { 206 window.addEventListener("keyup", (event) => { 207 if (event.key === "Escape") { 208 this.#mobileMenuButton.setAttribute("aria-expanded", false); 209 this.syncMobileMenuScrollLock(); 210 this.closeAllDropDowns(); 211 212 // Reset sliding navigation on escape 213 if (this.#slidingViewportEnabled) { 214 this.resetSlidingNavigation(); 215 } 216 } 217 }, { passive: true }); 218 } 219 220 /** 221 * Toggle document scroll lock class while mobile menu is open. 222 */ 223 syncMobileMenuScrollLock() { 224 if (!this.#mobileMenuButton) { 225 return; 226 } 227 228 const isExpanded = this.#mobileMenuButton.getAttribute("aria-expanded") === "true"; 229 230 document.documentElement.classList.toggle("nav-mobile-open", isExpanded); 231 document.body.classList.toggle("nav-mobile-open", isExpanded); 232 } 233 234 /** 235 * When opening a parent item on mobile accordion nav, align it to the 236 * top of the visible menu viewport for easier scanning. 237 * 238 * @param {HTMLButtonElement} button 239 */ 240 centerExpandedMenuItem(button) { 241 if (window.innerWidth > 1200) { 242 return; 243 } 244 245 const menuContainer = document.getElementById("menu-container"); 246 if (!menuContainer || menuContainer.classList.contains("menu-vdi--sliding")) { 247 return; 248 } 249 250 const menuItem = button.closest(".menu-vdi__item"); 251 if (!menuItem) { 252 return; 253 } 254 255 requestAnimationFrame(() => { 256 const containerRect = menuContainer.getBoundingClientRect(); 257 const itemRect = menuItem.getBoundingClientRect(); 258 259 const itemTopOffset = itemRect.top - containerRect.top + menuContainer.scrollTop; 260 const targetScrollTop = Math.max(0, itemTopOffset); 261 const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; 262 263 menuContainer.scrollTo({ 264 top: targetScrollTop, 265 behavior: prefersReducedMotion ? "auto" : "smooth", 266 }); 267 }); 268 } 269 270 /** 271 * Close all dropdown menus 272 * Sets aria expanded property by looping 273 * over the list of dropdown button elements 274 * 275 * @link {https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-expanded} 276 */ 277 closeAllDropDowns() { 278 this.#dropdownButtons.forEach((button) => { 279 button.setAttribute("aria-expanded", false); 280 }); 281 } 282 283 /** 284 * Handle focus 285 * Watches blur event on submenu list container, if the next 286 * focus element is not a submenu list item, closes the 287 * dropdown. 288 * 289 * Implemented for WCAG 2.2 compliance 290 * 291 * @param {htmlButtonElement} button 292 */ 293 handleFocus(button) { 294 const subMenuListElement = button.closest("ul"); 295 296 /** 297 * Ducking JavaScript, I am not sure why the blur event on submenu(ul) 298 * would bubble up to the button since the button and sub menu list element 299 * are siblings and not nested. This is a mystery to me. 300 * 301 * Here we are stopping any bubbling event that may be propagated to the 302 * top level buttons. This includes the bubbling event from sub menu list element. 303 * 304 * If anyone finds a better solution to this or can explain bubbling of focus event 305 * to next sibling, you time and effort would be much appreciated. 306 */ 307 button.addEventListener("focusout", (event) => { 308 event.stopImmediatePropagation(); 309 }) 310 311 subMenuListElement.addEventListener("focusout", (event) => { 312 // blur event triggers when user clicks outside 313 const nextFocusElement = event.relatedTarget; 314 315 let isSubMenuElement; 316 317 if (nextFocusElement !== null) { 318 isSubMenuElement = nextFocusElement.classList.contains(this.#subMenuElementIdentifier); 319 } else { 320 isSubMenuElement = false; // close when user clicks outside 321 } 322 323 if (!isSubMenuElement) { 324 this.closeAllDropDowns(); 325 } 326 }); 327 } 328 329 /** 330 * Initialize sliding viewport navigation 331 * Detects if sliding viewport should be enabled and sets up the structure 332 */ 333 initializeSlidingViewport() { 334 // Check if we should enable sliding viewport (could be based on screen size, user preference, etc.) 335 this.#slidingViewportEnabled = this.shouldEnableSlidingViewport(); 336 337 if (this.#slidingViewportEnabled) { 338 console.log('Sliding viewport enabled, setting up structure'); 339 this.setupSlidingViewportStructure(); 340 } else { 341 console.log('Sliding viewport not enabled'); 342 } 343 } 344 345 /** 346 * Determine if sliding viewport should be enabled 347 * @returns {boolean} 348 */ 349 shouldEnableSlidingViewport() { 350 // Check if sliding viewport styles are loaded by testing if the CSS rule exists 351 // This allows CSS-based switching between navigation styles 352 const isMobile = window.innerWidth <= 1000; // 75rem converted to px 353 354 if (!isMobile) return false;
355 356 // Check if sliding viewport CSS is loaded by testing a specific rule 357 // We need to test the element within the proper context (.nav-main) 358 try { 359 const navMain = document.querySelector('.nav-main'); 360 if (!navMain) return false; 361 362 const testElement = document.createElement('div'); 363 testElement.className = 'menu-vdi--sliding'; 364 testElement.style.position = 'absolute'; 365 testElement.style.visibility = 'hidden'; 366 testElement.style.height = '1px'; 367 testElement.style.width = '1px'; 368 369 navMain.appendChild(testElement); 370 371 // Get computed styles to check if sliding viewport CSS is active 372 const computedStyle = window.getComputedStyle(testElement); 373 const hasOverflowHidden = computedStyle.overflow === 'hidden'; 374 375 navMain.removeChild(testElement); 376 377 return hasOverflowHidden; 378 } catch (error) { 379 console.warn('Error detecting sliding viewport styles:', error); 380 return false; 381 } 382 } 383 384 /** 385 * Setup the HTML structure for sliding viewport navigation 386 */ 387 setupSlidingViewportStructure() { 388 const menuContainer = document.getElementById('menu-container'); 389 if (!menuContainer) { 390 console.warn('Menu container not found'); 391 return; 392 } 393 394 // Don't set up if already configured 395 if (menuContainer.classList.contains('menu-vdi--sliding')) { 396 console.log('Sliding viewport already configured'); 397 return; 398 } 399 400 console.log('Setting up sliding viewport structure'); 401 402 // Add sliding class to enable sliding styles 403 menuContainer.classList.add('menu-vdi--sliding'); 404 405 // Create viewport container 406 const viewport = document.createElement('div'); 407 viewport.className = 'menu-vdi__viewport'; 408 viewport.setAttribute('data-current-level', '0'); 409 410 // Create main level 411 const mainLevel = document.createElement('div'); 412 mainLevel.className = 'menu-vdi__level menu-vdi__level--main'; 413 mainLevel.setAttribute('data-level', '0'); 414 415 // Move existing menu items to main level 416 const existingItems = Array.from(menuContainer.children); 417 console.log('Moving', existingItems.length, 'existing items to main level'); 418 419 existingItems.forEach(item => { 420 mainLevel.appendChild(item); 421 }); 422 423 // Setup click handlers for parent items in sliding mode 424 this.setupSlidingClickHandlers(mainLevel); 425 426 viewport.appendChild(mainLevel); 427 menuContainer.appendChild(viewport); 428 429 console.log('Sliding viewport structure complete'); 430 } 431 432 /** 433 * Setup click handlers for parent menu items in sliding mode 434 * @param {HTMLElement} level 435 */ 436 setupSlidingClickHandlers(level) { 437 const parentButtons = level.querySelectorAll('.menu-vdi__toggle'); 438 439 parentButtons.forEach(button => { 440 button.addEventListener('click', (event) => { 441 if (this.#slidingViewportEnabled) { 442 event.preventDefault(); 443 event.stopPropagation(); 444 445 const parentItem = button.closest('.menu-vdi__item--parent'); 446 const submenu = parentItem.querySelector('.menu-vdi__submenu'); 447 448 if (submenu) { 449 this.navigateToLevel(button.textContent.trim(), submenu); 450 } 451 } 452 }, true); // Use capture phase to intercept before other handlers 453 }); 454 } 455 456 /** 457 * Navigate to a specific navigation level 458 * @param {string} levelTitle 459 * @param {HTMLElement} submenuElement 460 */ 461 navigateToLevel(levelTitle, submenuElement) { 462 const viewport = document.querySelector('.menu-vdi__viewport'); 463 if (!viewport) return; 464 465 this.#currentLevel++; 466 this.#navigationStack.push({ title: levelTitle, level: this.#currentLevel }); 467 468 // Create new level 469 const newLevel = document.createElement('div'); 470 newLevel.className = 'menu-vdi__level'; 471 newLevel.setAttribute('data-level', this.#currentLevel); 472 473 // Add back button 474 const backButton = this.createBackButton(); 475 newLevel.appendChild(backButton); 476 477 // Add level title 478 const levelTitleEl = document.createElement('div'); 479 levelTitleEl.className = 'menu-vdi__level-title'; 480 levelTitleEl.textContent = levelTitle; 481 newLevel.appendChild(levelTitleEl); 482 483 // Clone and add submenu items 484 const submenuItems = submenuElement.cloneNode(true); 485 submenuItems.className = 'menu-vdi__level-items'; 486 487 // Convert submenu items to level items and ensure proper visibility 488 const items = submenuItems.querySelectorAll('.menu-vdi__item');
489 items.forEach(item => { 490 // Remove any nested classes that don't apply to sliding mode 491 item.classList.remove('menu-vdi__item--child', 'menu-vdi__item--grandchild'); 492 493 // Handle nested items if they exist 494 const nestedToggle = item.querySelector('.menu-vdi__toggle'); 495 if (nestedToggle) { 496 nestedToggle.addEventListener('click', (event) => { 497 event.preventDefault(); 498 // Could implement deeper nesting here if needed 499 }); 500 } 501 }); 502 503 // Remove any hidden submenu classes from nested elements to ensure visibility 504 const nestedSubmenus = submenuItems.querySelectorAll('.menu-vdi__submenu'); 505 nestedSubmenus.forEach(nestedSubmenu => { 506 nestedSubmenu.classList.remove('menu-vdi__submenu'); 507 nestedSubmenu.classList.add('menu-vdi__nested-items'); 508 }); 509 510 // Ensure all list items are visible in sliding mode 511 const allListItems = submenuItems.querySelectorAll('li'); 512 allListItems.forEach(li => { 513 if (!li.classList.contains('menu-vdi__item')) { 514 li.classList.add('menu-vdi__item'); 515 } 516 }); 517 518 newLevel.appendChild(submenuItems); 519 viewport.appendChild(newLevel); 520 521 // Animate to new level 522 this.animateToLevel(this.#currentLevel); 523 } 524 525 /** 526 * Create a back button for navigation levels 527 * @returns {HTMLElement} 528 */ 529 createBackButton() { 530 const backButton = document.createElement('button'); 531 backButton.className = 'menu-vdi__back'; 532 backButton.innerHTML = ` 533 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> 534 <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" /> 535 </svg> 536 Back 537 `; 538 539 backButton.addEventListener('click', (event) => { 540 event.preventDefault(); 541 event.stopPropagation(); 542 this.navigateBack(); 543 }); 544 545 return backButton; 546 } 547 548 /** 549 * Navigate back to previous level 550 */ 551 navigateBack() { 552 if (this.#currentLevel > 0) { 553 this.#currentLevel--; 554 this.#navigationStack.pop(); 555 556 // Remove the current level element 557 const viewport = document.querySelector('.menu-vdi__viewport'); 558 const currentLevelEl = viewport.querySelector(`[data-level="${this.#currentLevel + 1}"]`); 559 if (currentLevelEl) { 560 currentLevelEl.remove(); 561 } 562 563 // Animate back to previous level 564 this.animateToLevel(this.#currentLevel); 565 566 // Ensure menu stays open after back navigation 567 setTimeout(() => { 568 if (this.#mobileMenuButton.getAttribute("aria-expanded") === "false") { 569 this.#mobileMenuButton.setAttribute("aria-expanded", "true"); 570 } 571 }, 50); 572 } 573 } 574 575 /** 576 * Animate viewport to specific level 577 * @param {number} level 578 */ 579 animateToLevel(level) { 580 const viewport = document.querySelector('.menu-vdi__viewport'); 581 if (!viewport) return; 582 583 const translateX = -level * 100; 584 viewport.style.transform = `translateX(${translateX}%)`; 585 viewport.setAttribute('data-current-level', level); 586 } 587 588 /** 589 * Reset sliding navigation to main level 590 */ 591 resetSlidingNavigation() { 592 const menuContainer = document.getElementById('menu-container'); 593 if (!menuContainer) return; 594 595 if (this.#slidingViewportEnabled) { 596 this.#currentLevel = 0; 597 this.#navigationStack = []; 598 599 const viewport = document.querySelector('.menu-vdi__viewport'); 600 if (viewport) { 601 // Remove all levels except main 602 const levels = viewport.querySelectorAll('.menu-vdi__level:not(.menu-vdi__level--main)'); 603 levels.forEach(level => level.remove()); 604 605 // Reset position 606 this.animateToLevel(0); 607 } 608 } else { 609 // Clean up sliding structure and restore normal menu 610 this.cleanupSlidingStructure(); 611 } 612 } 613 614 /** 615 * Clean up sliding viewport structure and restore normal menu 616 */ 617 cleanupSlidingStructure() { 618 const menuContainer = document.getElementById('menu-container'); 619 if (!menuContainer) return; 620 621 // Remove sliding class 622 menuContainer.classList.remove('menu-vdi--sliding'); 623 624 // Find viewport and move items back to container 625 const viewport = menuContainer.querySelector('.menu-vdi__viewport'); 626 if (viewport) { 627 const mainLevel = viewport.querySelector('.menu-vdi__level--main'); 628 if (mainLevel) { 629 // Move all items back to menu container 630 const items = Array.from(mainLevel.children);
631 items.forEach(item => { 632 menuContainer.appendChild(item); 633 }); 634 } 635 636 // Remove viewport structure 637 viewport.remove(); 638 } 639 640 console.log('Sliding structure cleaned up'); 641 } 642} 643 644export default Navigation;
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.