PageSourceSearch

https://fnfmb.com/wp-content/themes/fnfmb/static/js/modules/Navigation.js

js fnfmb.com collected 2026-09-28 08:42:14 UTC 19,896 bytes, 644 lines download raw bytes

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.