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();
|
});
|