/* eslint-disable no-case-declarations */ class Header { classes = { header: 'js-header', popover: 'js-popover', metanavigation: 'js-metanavigation', mainnavigation: 'js-mainnavigation', popoverTrigger: 'js-popover-trigger', popoverAnchor: 'js-popover-anchor', fixed: 'is--fixed' }; nodes = { header: null, popover: null, popoverTrigger: null, metanavigation: null, mainnavigation: null }; data = { metanavHeight: 0, sticky: false }; constructor() { // Cache DOM nodes this.nodes.header = document.querySelector(`.${this.classes.header}`); this.nodes.popover = this.nodes.header.querySelectorAll(`.${this.classes.popover}`); this.nodes.popoverTrigger = this.nodes.header.querySelectorAll(`.${this.classes.popoverTrigger}`); this.nodes.metanavigation = this.nodes.header.querySelector(`.${this.classes.metanavigation}`); this.nodes.mainnavigation = this.nodes.header.querySelector(`.${this.classes.mainnavigation}`); this.data.metanavHeight = this.nodes.metanavigation.offsetHeight; this.addEventListeners(); } /** * Add all relevant event listeners to header/nav elements * @returns void */ addEventListeners() { if (this.nodes.popover) { this.nodes.popover.forEach(popover => { popover.addEventListener('beforetoggle', this.beforeToggleHandler.bind(this)); popover.addEventListener('toggle', this.toggleHandler.bind(this)); }); } if (this.nodes.metanavigation) { window.addEventListener('scroll', this.stickyHeader.bind(this), {passive: true}); this.stickyHeader(); } } stickyHeader() { if (!this.data.sticky && window.scrollY >= this.data.metanavHeight) { this.nodes.header.classList.add(this.classes.fixed); this.data.sticky = true; } else if (this.data.sticky && window.scrollY < this.data.metanavHeight) { this.nodes.header.classList.remove(this.classes.fixed); this.data.sticky = false; } } /** * Position the popover relative to the popover trigger * @param {HTMLElement} popover */ positionPopover(popover) { const trigger = document.querySelector(`[popovertarget="${popover.id}"]`); const rect = trigger.getBoundingClientRect(); if (window.innerWidth >= window.breakpoints.wide) { // Set the popover's position based on the trigger's position popover.style.left = `${rect.left + rect.width - 376}px`; } } beforeToggleHandler(event) { if (event.newState === 'open' && event.target.classList.contains(this.classes.popoverAnchor)) { this.positionPopover(event.target) } } toggleHandler(event) { const trigger = document.querySelector(`[popovertarget="${event.target.id}"]`); if (event.newState === 'open') { trigger.ariaExpanded = 'true'; } else { trigger.ariaExpanded = 'false'; } } } class Navigation { #classes = { header: 'js-header', mainnavigation: 'js-mainnavigation', menuToggle: 'js-menu-toggle', view: 'js-mainnavigation-view', item: 'js-mainnavigation-item', link: 'js-mainnavigation-link', back: 'js-mainnavigation-back', children: 'has--children', open: 'is--open', opened: 'is--opened', level1: 'is--level-1', level2: 'is--level-2', level3: 'is--level-3' }; #nodes = { header: null, mainnavigation: null, menuToggle: null, links: null, views: null }; constructor() { // Cache DOM nodes this.#nodes.header = document.querySelector(`.${this.#classes.header}`); this.#nodes.mainnavigation = this.#nodes.header.querySelector(`.${this.#classes.mainnavigation}`); this.#nodes.menuToggle = this.#nodes.header.querySelector(`.${this.#classes.menuToggle}`); this.#nodes.links = [...this.#nodes.header.querySelectorAll(`.${this.#classes.link}`)]; this.#nodes.views = [...this.#nodes.header.querySelectorAll(`.${this.#classes.view}`)]; if (this.#nodes.header) { this.#addEventListeners(); } } /** * Add all relevant event listeners to header/nav elements * @returns void */ #addEventListeners() { // Toggle menu on button click if (this.#nodes.menuToggle) { this.#nodes.menuToggle.addEventListener('click', () => { this.toggleMenu(); }); } // React on navigation link clicks this.#nodes.header.addEventListener('click', event => this.#navigationItemClickHandler(event)); this.#nodes.header.addEventListener('mouseover', async event => { await new Promise(resolve => setTimeout(resolve, 200)) this.#navigationItemHoverHandler(event); }); // Close menu e.g. on backdrop click this.#nodes.header.addEventListener('click', event => this.#menuCloseHandler(event)); this.#nodes.header.addEventListener('mouseover', event => this.#menuCloseHandler(event)); // React on back clicks this.#nodes.header.addEventListener('click', event => { const back = event.target.closest(`.${this.#classes.back}`); if (back) { const submenu = back.closest(`.${this.#classes.view}`); if (submenu) { this.#closeSubmenu(submenu); } event.preventDefault(); } }); // Keyboard navigation this.#nodes.header.addEventListener('keydown', event => this.#keydownHandler(event)); } /** * Toggle the main navigation * @param {boolean|undefined} open Force open or force close? * @returns void */ toggleMenu(open = undefined) { if (open === undefined) { open = !this.#nodes.header.classList.contains(this.#classes.open); } if (open) { this.#nodes.header.classList.add(this.#classes.open); // Remove scrollbar, prevent body scroll if (window.innerWidth < window.breakpoints.wide) { Object.assign(document.body.style, { position: 'fixed', top: `-${window.scrollY}px` }); } } else { this.#nodes.header.classList.remove(this.#classes.open); // Close all submenus and set tabindex of all its links to -1 this.#nodes.views .filter(view => view.classList.contains(this.#classes.open)) .forEach(view => { view.classList.remove(this.#classes.open); this.#setTabindexOfLinks(view, -1); }); // Set all links aria-expanded to false this.#nodes.links .filter(link => link.classList.contains(this.#classes.children)) .forEach(link => link.ariaExpanded = "false"); // Reset body scrolling behavior and position if (window.innerWidth < window.breakpoints.wide) { const scrollY = document.body.style.top; Object.assign(document.body.style, { position: '', top: '' }); window.scrollTo({ top: parseInt(scrollY || '0') * -1, behavior: 'instant' }); } } Promise.all( this.#nodes.mainnavigation.getAnimations({ subtree: true }).map((animation) => animation.finished), ).then(() => { if (open) { this.#nodes.mainnavigation.classList.add(this.#classes.opened); } else { this.#nodes.mainnavigation.classList.remove(this.#classes.opened); } }); } /** * Open next navigation view * @param {HTMLElement} view * @param {HTMLAnchorElement} link * @returns void */ #openSubmenu(view, link = null) { // Close other open submenus of the same level let level = null; let levelClass = null; if (view.classList.contains(this.#classes.level2)) { levelClass = this.#classes.level2; level = 2; } else if (view.classList.contains(this.#classes.level3)) { levelClass = this.#classes.level3; level = 3; } if (level) { // Close all other views of the same level this.#nodes.views.forEach(view => { if (view.classList.contains(this.#classes.open) && view.classList.contains(levelClass)) { view.classList.remove(this.#classes.open); const link = view.parentNode.querySelector(`.${this.#classes.link}`); link.ariaExpanded = "false"; } }); // Allow tab navigation on all links within this view this.#setTabindexOfLinks(view, 0, level); } // respect scroll top position in mobile if (window.innerWidth < window.breakpoints.wide && level === 3) { const parentView = view.parentNode?.closest(`.${this.#classes.view}`); const parentScroll = parentView?.scrollTop || 0; view.style.top = `${parentScroll}px`; } view.classList.add(this.#classes.open); if (!link) { link = view.parentNode.querySelector(`.${this.#classes.link}`); } link.ariaExpanded = "true"; } /** * Close this navigation view * @param {HTMLElement} view * @param {HTMLAnchorElement} link * @returns void */ #closeSubmenu(view, link = null) { view.classList.remove(this.#classes.open); // Disallow tab navigation on all links within this view this.#setTabindexOfLinks(view, -1); if (!link) { link = view.parentNode.querySelector(`.${this.#classes.link}`); } link.ariaExpanded = "false"; } /** * Set tabindex to all links of a level * @param {HTMLElement} view * @param {number} tabindex * @param {number|undefined} level */ #setTabindexOfLinks(view, tabindex = 0, level= undefined) { let linkClass; if (window.innerWidth < window.breakpoints.wide && level) { linkClass = `.${this.#classes.link}[data-level="${level}"]`; } else { linkClass = `.${this.#classes.link}`; } const links = view.querySelectorAll(linkClass); links.forEach(link => link.tabIndex = tabindex); } /** * Handler for item click on links * @param {Event} event * @returns void */ #navigationItemClickHandler(event) { if (!event.target) { return; } const link = event.target.classList.contains(this.#classes.link) ? event.target : event.target.closest(`.${this.#classes.link}`); if (link) { const submenu = link.parentNode.querySelector(`.${this.#classes.view}`); this.#toggleSubmenu(submenu, link, event, true); } } /** * Open/close navigation view * @param {HTMLElement} view * @param {HTMLAnchorElement} link * @param {Event} event * @param {boolean|undefined} open * @returns void */ #toggleSubmenu(view, link, event = null, open = undefined) { if (open === undefined) { open = !this.#nodes.header.classList.contains(this.#classes.open); } if (view) { if (open) { this.#openSubmenu(view, link); } else { this.#closeSubmenu(view, link); } } // Open the menu if ( window.innerWidth >= window.breakpoints.wide && view && view.classList.contains(this.#classes.level2) ) { this.toggleMenu(open); } // prevent link opening on mobile menu items with submenus if ( event && view && window.innerWidth < window.breakpoints.wide ) { event.preventDefault(); } } /** * Handler for item hover on links * @param {Event} event * @returns void */ #navigationItemHoverHandler(event) { if (!event.target || window.innerWidth < window.breakpoints.wide) { return; } const link = event.target.classList.contains(this.#classes.link) ? event.target : event.target.closest(`.${this.#classes.link}`); if (link) { const submenu = link.parentNode.querySelector(`.${this.#classes.view}`); // Open the menu if ( window.innerWidth >= window.breakpoints.wide && submenu && submenu.classList.contains(this.#classes.level2) ) { this.toggleMenu(true); this.#openSubmenu(submenu, link); event.preventDefault(); event.stopPropagation(); } } } /** * Handler for keyboard events * @param {Event} event * @returns void * @see https://www.w3.org/WAI/ARIA/apg/patterns/menubar/examples/menubar-navigation/#kbd_label */ #keydownHandler(event) { const { target } = event; if ( window.innerWidth >= window.breakpoints.wide && target.classList.contains(this.#classes.link) ) { if (target.dataset.level === "1") { this.#keydownHandlerFirstLevel(target, event); } else { this.#keydownHandlerOtherLevel(target, event); } } } /** * Handle keyboard events for fist level links * @param {HTMLAnchorElement} link * @param {Event} event * @returns void */ #keydownHandlerFirstLevel(link, event) { const { key } = event; const submenu = link.parentNode.querySelector(`.${this.#classes.view}`); let navigationItem; let handled = false; switch (key) { case ' ': if (submenu) { this.#toggleSubmenu(submenu, link, null, true); handled = true; } break; case 'Enter': // open link! window.location = link.href; handled = true; break; case 'ArrowUp': if (submenu) { this.#toggleSubmenu(submenu, link, null, true); // moves focus to last item in the submenu [...submenu.querySelectorAll(`.${this.#classes.link}`)].pop().focus(); handled = true; } break; case 'ArrowDown': if (submenu) { this.#toggleSubmenu(submenu, link, null, true); // moves focus to first item in the submenu submenu.querySelector(`.${this.#classes.link}`).focus(); handled = true; } break; case 'ArrowRight': navigationItem = link.closest(`.${this.#classes.item}`); const nextLink = navigationItem?.nextElementSibling?.querySelector(`.${this.#classes.link}`); if (nextLink) { nextLink.focus(); handled = true; } break; case 'ArrowLeft': navigationItem = link.closest(`.${this.#classes.item}`); const previousLink = navigationItem?.previousElementSibling?.querySelector(`.${this.#classes.link}`); if (previousLink) { previousLink.focus(); handled = true; } break; case 'Escape': this.toggleMenu(false); link.focus(); handled = true; break; } if (handled) { event.preventDefault(); event.stopPropagation(); } } /** * Handle keyboard events for second/third level links * @param {HTMLAnchorElement} link * @param {Event} event * @returns void */ #keydownHandlerOtherLevel(link, event) { const { key } = event; const submenu = link.parentNode.querySelector(`.${this.#classes.view}`); let handled = false; let navigationItem; switch (key) { case ' ': if (submenu) { this.#toggleSubmenu(submenu, link, null, true); submenu.querySelector(`.${this.#classes.link}`).focus(); handled = true; } break; case 'Enter': // open link! window.location = link.href; handled = true; break; case 'Escape': this.toggleMenu(false); // Focus first level link const level2View = link.closest(`.${this.#classes.view}.${this.#classes.level2}`); const level1Link = level2View?.parentNode.querySelector(`.${this.#classes.link}[data-level="1"]`); if (level1Link) { level1Link.focus(); } handled = true; break; case 'ArrowUp': navigationItem = link.closest(`.${this.#classes.item}`); if (navigationItem) { const previousLink = navigationItem.previousElementSibling.querySelector(`.${this.#classes.link}`); if (previousLink) { previousLink.focus(); handled = true; } else { // go to parent const view = navigationItem.closest(`.${this.#classes.view}`); const parentLink = view?.parentNode.querySelector(`.${this.#classes.link}`); if (parentLink) { parentLink.focus(); handled = true; } } } break; case 'ArrowDown': if (submenu) { this.#toggleSubmenu(submenu, link, null, true); // moves focus to first item in the submenu submenu.querySelector(`.${this.#classes.link}`).focus(); handled = true; } else { // go to next link of the same level navigationItem = link.closest(`.${this.#classes.item}`); const nextLink = navigationItem?.nextElementSibling?.querySelector(`.${this.#classes.link}`); if (nextLink) { nextLink.focus(); handled = true; } else { // go to next parent level view const parentItem = navigationItem.parentNode.closest(`.${this.#classes.item}`); const nextParentLink = parentItem?.nextElementSibling?.querySelector(`.${this.#classes.link}`); if (nextParentLink) { nextParentLink.focus(); handled = true; } else { const firstLevelItem = parentItem?.parentNode?.closest(`.${this.#classes.item}`); const firstLevelLink = firstLevelItem?.querySelector(`.${this.#classes.link}`); if (firstLevelLink) { firstLevelLink.focus(); handled = true; } } } } break; case 'ArrowLeft': navigationItem = link.closest(`.${this.#classes.item}`); if (navigationItem) { const previousLink = navigationItem.previousElementSibling?.querySelector(`.${this.#classes.link}`); if (previousLink) { previousLink.focus(); handled = true; } } break; case 'ArrowRight': navigationItem = link.closest(`.${this.#classes.item}`); const nextLink = navigationItem?.nextElementSibling?.querySelector(`.${this.#classes.link}`); if (nextLink) { nextLink.focus(); handled = true; } break; } if (handled) { event.preventDefault(); event.stopPropagation(); } } /** * Close menu e.g. on backdrop click * @param {Event} event * @returns void */ #menuCloseHandler(event) { if (window.innerWidth >= window.breakpoints.wide && event.target === this.#nodes.header) { this.toggleMenu(false); } } } window.addEventListener("load", () => { new Header(); new Navigation(); });