class Navigation { constructor() { this.counter = 0; this.initElements(); if (this.navs.size === 0) { return; } this.openNav = null; this.openSubmenu = null; this.releaseFocusTrap = null; this.clicked = new Set(); this.initListeners(); } initElements() { this.navs = new Map(); document.querySelectorAll('nav:has(.submenu), nav:has(.toggle)').forEach(nav => { let navID = nav.id; if (navID === '' || this.navs.has(navID)) { navID = `nav-${this.counter}`; nav.id = navID; this.counter++; } if (nav.querySelector('.submenu')) { nav.addEventListener('mouseenter', this.handleHoverOn.bind(this)); nav.addEventListener('mouseleave', this.handleHoverOff.bind(this)); } let [ toggles, submenus, submenuToggles ] = [ nav.querySelectorAll('nav .toggle'), nav.querySelectorAll('.has-submenu'), nav.querySelectorAll('.toggle:not(.main)') ]; let elements = { nav: nav, toggles: toggles, submenus: submenus, submenuToggles: submenuToggles } this.counter++; submenus.forEach(menu => { menu.id = 'submenu-'+this.counter; menu.addEventListener('mouseenter', this.handleHoverOn.bind(this)); menu.addEventListener('mouseleave', this.handleHoverOff.bind(this)); this.counter++; }); this.navs.set(navID, elements); }); } initListeners() { this.clickListener = this.handleClick.bind(this); this.keysListener = this.handleKeys.bind(this); this.hoverOnListener = this.handleHoverOn.bind(this); this.hoverOffListener = this.handleHoverOff.bind(this); document.addEventListener('click', this.clickListener); } handleClick(e) { if (this.navs.size === 0) { return; } let toggle = e.target.closest('.toggle.main'); if (toggle) { let nav = toggle.closest('nav'); let isOpening = !this.clicked.has(nav); let shouldToggle = nav.classList.contains('open') !== isOpening; if (shouldToggle) { this.toggleNav(isOpening, nav.id); } if (isOpening) { this.clicked.add(nav); } else { this.clicked.delete(nav); } return; } let submenuToggle = e.target.closest('[data-action="toggle-submenu"], .has-submenu .a') if (submenuToggle) { let li = submenuToggle.closest('li'); let isOpening = !this.clicked.has(li); let shouldToggle = li.classList.contains('open') !== isOpening; if (isOpening) { this.clicked.add(li); } else { this.clicked.delete(li); } if (shouldToggle) { this.toggleSubmenu(isOpening, li); } return; } if (!this.openNav) { return; } let close = true; for (let [navID, elements] of this.navs) { if (e.target.closest('#'+navID)) { close = false; break; } } if (close) { this.toggleNav(false, this.openNav); } } handleHoverOn(e) { let target = e.currentTarget; if (this.clicked.has(target) || target.closest('nav.sidebar')) { return; } if (target.classList.contains('has-submenu')) { this.toggleSubmenu(true, target); } else if (target.tagName === 'NAV') { if (!target.classList.contains('mobile')) { this.toggleNav(true, target.id); } } } handleHoverOff(e) { let target = e.currentTarget; if (this.clicked.has(target) || target.closest('nav.sidebar')) { return; } if (target.classList.contains('has-submenu')) { this.toggleSubmenu(false, target); } else if (target.tagName === 'NAV') { if (target.classList.contains('mobile')) { return; } let nav = this.navs.get(target.id); let shouldToggle = true; for (let submenu of nav.submenus) { if (this.clicked.has(submenu)) { shouldToggle = false; break; } } if (shouldToggle) { this.toggleNav(false, target.id); } } } handleKeys(e) { if (!this.openNav) return; switch (e.key) { case 'Escape': this.closeAll(); break; case 'ArrowDown': this.focusNextItem(); e.preventDefault(); break; case 'ArrowUp': this.focusPrevItem(); e.preventDefault(); break; } } closeAll() { let nav = this.navs.get(this.openNav); if (nav && this.clicked.has(nav.nav)) { this.clicked.delete(nav.nav); } this.toggleNav(false, this.openNav); } focusNextItem() { const items = this.getFocusableItems(); const i = items.indexOf(document.activeElement); const next = items[i + 1] || items[0]; next.focus(); } focusPrevItem() { const items = this.getFocusableItems(); const i = items.indexOf(document.activeElement); const prev = items[i - 1] || items[items.length - 1]; prev.focus(); } getFocusableItems() { return Array.from(document.querySelectorAll( 'nav.open a, nav.open button' )).filter(el => !el.disabled && !el.closest('[hidden]') && !el.closest('[inert]')); } toggleNav(on, id) { let nav = this.navs.get(id); if (!nav) { return; } if (on && id !== this.openNav) { this.toggleNav(false, this.openNav); } if (on) { this.openNav = id; document.addEventListener('keydown', this.keysListener); if (nav.nav.classList.contains('mobile')) { this.releaseFocusTrap = window.jvbA11y.trapFocus(nav.nav); } } else { if (this.releaseFocusTrap) { this.releaseFocusTrap(); this.releaseFocusTrap = null; } if (this.openNav === id) { this.openNav = null; } document.removeEventListener('keydown', this.keysListener); if (!nav.nav.classList.contains('sidebar')) { Array.from(nav.submenus).forEach(submenu => { if(submenu.classList.contains('open')) { this.toggleSubmenu(false, submenu); } }); } Array.from(nav.submenus).forEach(submenu => { if (this.clicked.has(submenu)) { this.clicked.delete(submenu); } }); } nav.nav.classList.toggle('open', on); if (nav.nav.classList.contains('mobile')) { const content = nav.nav.querySelector(':scope > ul'); if (content) content.inert = !on; } nav.nav.setAttribute('aria-expanded', on); if (on) { nav.nav.querySelector('a:not(.skip-to-content)')?.focus(); } } toggleSubmenu(on, submenu) { if (on && this.openSubmenu && this.openSubmenu !== submenu && !this.openSubmenu.contains(submenu)) { this.toggleSubmenu(false, this.openSubmenu); } if (on) { this.openSubmenu = submenu; } else if (this.openSubmenu === submenu) { this.openSubmenu = null; this.clicked.delete(submenu); } let [ toggle, firstLink ] = [ submenu.querySelector('.toggle'), submenu.querySelector('a') ]; if (!on) { toggle.focus(); } const content = submenu.querySelector(':scope > ul'); if (content) content.inert = !on; let label = toggle.getAttribute('aria-label'); window.jvbA11y.announce(on ? `${label} expanded` : `${label} collapsed`); submenu.classList.toggle('open', on); toggle.setAttribute('aria-expanded', on); if (on && firstLink) { firstLink.focus(); } } } document.addEventListener('DOMContentLoaded', function() { window.jvbNav = new Navigation(); });