| | |
| | | } |
| | | |
| | | this.openNav = null; |
| | | this.openSubmenu = null; |
| | | this.releaseFocusTrap = null; |
| | | this.clicked = new Set(); |
| | | this.initListeners(); |
| | | } |
| | | |
| | |
| | | this.counter++; |
| | | } |
| | | if (nav.querySelector('.submenu')) { |
| | | nav.addEventListener('mouseenter', this.hoverOnListener); |
| | | nav.addEventListener('mouseleave', this.hoverOffListener); |
| | | nav.addEventListener('mouseenter', this.handleHoverOn.bind(this)); |
| | | nav.addEventListener('mouseleave', this.handleHoverOff.bind(this)); |
| | | } |
| | | |
| | | let [ |
| | |
| | | submenus: submenus, |
| | | submenuToggles: submenuToggles |
| | | } |
| | | this.navs.set(navID, elements); |
| | | 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); |
| | | }); |
| | | } |
| | | |
| | | navIDs() { |
| | | return Array.from(this.navs.keys()).map((nav) => `#${nav}`); |
| | | } |
| | | |
| | | initListeners() { |
| | | this.clickListener = this.handleClick.bind(this); |
| | | this.escapeListener = this.handleEscape.bind(this); |
| | | this.keysListener = this.handleKeys.bind(this); |
| | | this.hoverOnListener = this.handleHoverOn.bind(this); |
| | | this.hoverOffListener = this.handleHoverOff.bind(this); |
| | | |
| | |
| | | if (this.navs.size === 0) { |
| | | return; |
| | | } |
| | | if (this.openNav && !e.target.closest(this.openNav)) { |
| | | this.toggleNav(false); |
| | | } |
| | | if (!e.target.closest(... this.navIDs())) { |
| | | return; |
| | | } |
| | | |
| | | let toggle = e.target.closest('.toggle.main'); |
| | | if (toggle) { |
| | | let nav = toggle.closest('nav'); |
| | | this.toggleNav(!nav.classList.contains('open'), nav.id); |
| | | 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"]') |
| | | let submenuToggle = e.target.closest('[data-action="toggle-submenu"], .has-submenu .a') |
| | | if (submenuToggle) { |
| | | let li = submenuToggle.closest('li'); |
| | | this.toggleSubmenu(!li.classList.contains('open'), 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) { |
| | | console.log(e.target); |
| | | let nav = e.target.closest('nav'); |
| | | if (nav) { |
| | | this.toggleNav(true, nav.id); |
| | | let target = e.currentTarget; |
| | | if (this.clicked.has(target) || target.closest('nav.sidebar')) { |
| | | return; |
| | | } |
| | | let submenu = e.target.closest('.has-submenu'); |
| | | if (submenu) { |
| | | this.toggleSubmenu(true, submenu); |
| | | 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) { |
| | | console.log(e.target); |
| | | let nav = e.target.closest('nav'); |
| | | if (nav) { |
| | | this.toggleNav(false, nav.id); |
| | | let target = e.currentTarget; |
| | | if (this.clicked.has(target) || target.closest('nav.sidebar')) { |
| | | return; |
| | | } |
| | | // let submenu = e.target.closest('.has-submenu'); |
| | | // if (submenu) { |
| | | // this.toggleSubmenu(false, submenu); |
| | | // } |
| | | |
| | | 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); |
| | | } |
| | | |
| | | } |
| | | } |
| | | |
| | | handleEscape(e) { |
| | | if (this.openNav && e.key === 'Escape') { |
| | | this.toggleNav(false, this.openNav); |
| | | 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) { |
| | |
| | | } |
| | | if (on) { |
| | | this.openNav = id; |
| | | document.addEventListener('keydown', this.escapeListener); |
| | | 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.escapeListener); |
| | | 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(submenu.classList.contains('open')) { |
| | | this.toggleSubmenu(false, submenu); |
| | | if (this.clicked.has(submenu)) { |
| | | this.clicked.delete(submenu); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | nav.nav.ariaExpanded = on; |
| | | nav.nav.classList.toggle('open', on); |
| | | nav.ariaHidden = !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('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); |
| | | submenu.ariaHidden = !on; |
| | | toggle.ariaExpanded = on; |
| | | toggle.setAttribute('aria-expanded', on); |
| | | if (on && firstLink) { |
| | | firstLink.focus(); |
| | | } |
| | | } |
| | | |
| | | |
| | | } |
| | | |
| | | document.addEventListener('DOMContentLoaded', function() { |
| | | |
| | | window.jvbNav = new Navigation(); |
| | | }); |