UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

780 lines (703 loc) 22.7 kB
/** * Copyright © Volker Schukai and all contributing authors, 2025. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact Volker Schukai. * * SPDX-License-Identifier: AGPL-3.0 */ import { CustomElement, getSlottedElements, registerCustomElement, assembleMethodSymbol, } from "../../dom/customelement.mjs"; import { DeadMansSwitch } from "../../util/deadmansswitch.mjs"; import { SiteNavigationStyleSheet } from "./stylesheet/site-navigation.mjs"; import { computePosition, autoUpdate, flip, shift, offset, size, } from "@floating-ui/dom"; import { fireCustomEvent } from "../../dom/events.mjs"; export { SiteNavigation }; const resizeObserverSymbol = Symbol("resizeObserver"); const timerCallbackSymbol = Symbol("timerCallback"); const navElementSymbol = Symbol("navElement"); const visibleElementsSymbol = Symbol("visibleElements"); const hiddenElementsSymbol = Symbol("hiddenElements"); const hamburgerButtonSymbol = Symbol("hamburgerButton"); const hamburgerNavSymbol = Symbol("hamburgerNav"); const instanceSymbol = Symbol("instanceSymbol"); const activeSubmenuHiderSymbol = Symbol("activeSubmenuHider"); const hideHamburgerMenuSymbol = Symbol("hideHamburgerMenu"); const hamburgerCloseButtonSymbol = Symbol("hamburgerCloseButton"); /** * A responsive site navigation that automatically moves menu items into a hamburger menu * when there isn't enough available space. * * @summary An adaptive navigation component that supports hover and click interactions for submenus. * @fragments /fragments/components/navigation/site-navigation/ * * @example /examples/components/navigation/site-navigation-simple Simple Navigation * @example /examples/components/navigation/site-navigation-with-submenus Navigation with Submenus * @example /examples/components/navigation/site-navigation-with-mega-menu Navigation with Mega Menu * * @issue https://localhost.alvine.dev:8440/development/issues/closed/336.html * * @fires monster-layout-change - Fired when the layout of menu items changes. The event detail contains `{visibleItems, hiddenItems}`. * @fires monster-hamburger-show - Fired when the hamburger menu is shown. The event detail contains `{button, menu}`. * @fires monster-hamburger-hide - Fired when the hamburger menu is hidden. The event detail contains `{button, menu}`. * @fires monster-submenu-show - Fired when a submenu is shown. The event detail contains `{context, trigger, submenu, level}`. * @fires monster-submenu-hide - Fired when a submenu is hidden. The event detail contains `{context, trigger, submenu, level}`. */ class SiteNavigation extends CustomElement { static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/navigation/site@@instance"); } /** * Configuration options for the SiteNavigation component. * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control} * * To set these options via an HTML tag, use the `data-monster-options` attribute. * The individual configuration values are detailed in the table below. * * @property {Object} templates - Template definitions. * @property {string} templates.main - The main HTML template for the component. * @property {string} interactionModel="auto" - Defines the interaction with submenus. Possible values: `auto`, `click`, `hover`. With `auto`, `hover` is used on desktop and `click` is used in the hamburger menu. * @property {Object} features - Container for additional feature flags. * @property {boolean} features.resetOnClose=true - If `true`, all open submenus within the hamburger menu will be reset when it is closed. */ get defaults() { return Object.assign({}, super.defaults, { templates: { main: getTemplate() }, interactionModel: "auto", // 'auto', 'click', 'hover' features: { resetOnClose: true, }, }); } [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); initEventHandler.call(this); } static getCSSStyleSheet() { return [SiteNavigationStyleSheet]; } static getTag() { return "monster-site-navigation"; } connectedCallback() { super.connectedCallback(); attachResizeObserver.call(this); requestAnimationFrame(() => { populateTabs.call(this); }); } disconnectedCallback() { super.disconnectedCallback(); detachResizeObserver.call(this); } } /** * Queries the shadow DOM for essential elements and stores their references. * @private * @this {SiteNavigation} */ function initControlReferences() { if (!this.shadowRoot) throw new Error("Component requires a shadowRoot."); this[navElementSymbol] = this.shadowRoot.querySelector( '[data-monster-role="navigation"]', ); this[visibleElementsSymbol] = this.shadowRoot.querySelector("#visible-elements"); this[hiddenElementsSymbol] = this.shadowRoot.querySelector("#hidden-elements"); this[hamburgerButtonSymbol] = this.shadowRoot.querySelector("#hamburger-button"); this[hamburgerNavSymbol] = this.shadowRoot.querySelector( '[data-monster-role="hamburger-nav"]', ); this[hamburgerCloseButtonSymbol] = this.shadowRoot.querySelector( '[part="hamburger-close-button"]', ); } /** * Initializes event handlers for the hamburger menu button and its functionality. * @private * @this {SiteNavigation} */ function initEventHandler() { if (!this.shadowRoot) throw new Error("Component requires a shadowRoot."); const hamburgerButton = this[hamburgerButtonSymbol]; const hamburgerNav = this[hamburgerNavSymbol]; const hamburgerCloseButton = this[hamburgerCloseButtonSymbol]; let cleanup; if (!hamburgerButton || !hamburgerNav || !hamburgerCloseButton) return; const getBestPositionStrategy = (element) => { let parent = element.parentElement; while (parent) { const parentPosition = window.getComputedStyle(parent).position; if (["fixed", "sticky"].includes(parentPosition)) { return "fixed"; } parent = parent.parentElement; } return "absolute"; }; const handleOutsideClick = (event) => { if ( !hamburgerButton.contains(event.target) && !hamburgerNav.contains(event.target) ) { hideMenu(); } }; const hideMenu = () => { hamburgerNav.style.display = "none"; document.body.classList.remove("monster-navigation-open"); fireCustomEvent(this, "monster-hamburger-hide", { button: hamburgerButton, menu: hamburgerNav, }); if (this.getOption("features.resetOnClose") === true) { this[hiddenElementsSymbol] .querySelectorAll(".is-open") .forEach((submenu) => submenu.classList.remove("is-open")); } if (cleanup) { cleanup(); cleanup = undefined; } document.removeEventListener("click", handleOutsideClick); }; this[hideHamburgerMenuSymbol] = hideMenu; const showMenu = () => { this[activeSubmenuHiderSymbol]?.(); hamburgerNav.style.display = "block"; document.body.classList.add("monster-navigation-open"); fireCustomEvent(this, "monster-hamburger-show", { button: hamburgerButton, menu: hamburgerNav, }); hamburgerNav.scrollIntoView({ block: "start", behavior: "smooth" }); cleanup = autoUpdate(hamburgerButton, hamburgerNav, () => { if (window.innerWidth > 768) { const strategy = getBestPositionStrategy(this); computePosition(hamburgerButton, hamburgerNav, { placement: "bottom-end", strategy: strategy, middleware: [ offset(8), flip(), shift({ padding: 8 }), size({ apply: ({ availableHeight, elements }) => { Object.assign(elements.floating.style, { maxHeight: `${availableHeight}px`, overflowY: "auto", }); }, padding: 8, }), ], }).then(({ x, y, strategy }) => { Object.assign(hamburgerNav.style, { position: strategy, left: `${x}px`, top: `${y}px`, }); }); } else { // Mobile view (fullscreen overlay), position is handled by CSS Object.assign(hamburgerNav.style, { position: "", left: "", top: "" }); } }); setTimeout(() => document.addEventListener("click", handleOutsideClick), 0); }; hamburgerButton.addEventListener("click", (event) => { event.stopPropagation(); const isVisible = hamburgerNav.style.display === "block"; if (isVisible) { hideMenu(); } else { showMenu(); } }); hamburgerCloseButton.addEventListener("click", (event) => { event.stopPropagation(); hideMenu(); }); } /** * Attaches a ResizeObserver to the main navigation element to recalculate * tab distribution on size changes. A DeadMansSwitch is used for debouncing. * @private * @this {SiteNavigation} */ function attachResizeObserver() { this[resizeObserverSymbol] = new ResizeObserver(() => { if (this[timerCallbackSymbol] instanceof DeadMansSwitch) { try { this[timerCallbackSymbol].touch(); return; } catch (e) { delete this[timerCallbackSymbol]; } } this[timerCallbackSymbol] = new DeadMansSwitch(200, () => { requestAnimationFrame(() => { populateTabs.call(this); }); }); }); this[resizeObserverSymbol].observe(this); } /** * Disconnects and cleans up the ResizeObserver instance. * @private * @this {SiteNavigation} */ function detachResizeObserver() { if (this[resizeObserverSymbol] instanceof ResizeObserver) { this[resizeObserverSymbol].disconnect(); delete this[resizeObserverSymbol]; } } /** * Sets up interaction logic (hover, click, or touch) for a submenu. * This function is called recursively for nested submenus. * @private * @this {SiteNavigation} * @param {HTMLLIElement} parentLi The list item containing the submenu. * @param {'visible'|'hidden'} context The context (main nav or hamburger). * @param {number} level The nesting level of the submenu (starts at 1). */ function setupSubmenu(parentLi, context = "visible", level = 1) { const submenu = parentLi.querySelector( ":scope > ul, :scope > div[part='mega-menu']", ); if (!submenu) return; if (submenu.tagName === "UL") { submenu.setAttribute("part", "submenu"); } const interaction = this.getOption("interactionModel", "auto"); const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0; const effectiveInteraction = interaction === "auto" ? context === "visible" ? "hover" : "click" : interaction; const component = this; let cleanup; const immediateHide = () => { submenu.style.display = "none"; Object.assign(submenu.style, { maxHeight: "", overflowY: "", }); submenu .querySelectorAll( "ul[style*='display: block'], div[part='mega-menu'][style*='display: block']", ) .forEach((sub) => { sub.style.display = "none"; }); fireCustomEvent(this, "monster-submenu-hide", { context, trigger: parentLi, submenu, level, }); if (cleanup) { cleanup(); cleanup = null; } if (level === 1 && component[activeSubmenuHiderSymbol] === immediateHide) { component[activeSubmenuHiderSymbol] = null; } }; const show = () => { component[hideHamburgerMenuSymbol]?.(); if (level === 1) { if ( component[activeSubmenuHiderSymbol] && component[activeSubmenuHiderSymbol] !== immediateHide ) { component[activeSubmenuHiderSymbol](); } component[activeSubmenuHiderSymbol] = immediateHide; } else { [...parentLi.parentElement.children] .filter((li) => li !== parentLi) .forEach((sibling) => { const siblingSubmenu = sibling.querySelector( ":scope > ul, :scope > div[part='mega-menu']", ); if (siblingSubmenu) { siblingSubmenu.style.display = "none"; } }); } submenu.style.display = "block"; fireCustomEvent(this, "monster-submenu-show", { context, trigger: parentLi, submenu, level, }); if (!cleanup) { cleanup = autoUpdate(parentLi, submenu, () => { const middleware = [offset(8), flip(), shift({ padding: 8 })]; const containsSubmenus = submenu.querySelector( "ul, div[part='mega-menu']", ); if (!containsSubmenus) { middleware.push( size({ apply: ({ availableHeight, elements }) => { Object.assign(elements.floating.style, { maxHeight: `${availableHeight}px`, overflowY: "auto", }); }, padding: 8, }), ); } computePosition(parentLi, submenu, { placement: level === 1 ? "bottom-start" : "right-start", middleware: middleware, }).then(({ x, y, strategy }) => { Object.assign(submenu.style, { position: strategy, left: `${x}px`, top: `${y}px`, }); }); }); } }; if (effectiveInteraction === "hover" && isTouchDevice) { let lastTap = 0; const DOUBLE_TAP_DELAY = 300; const anchor = parentLi.querySelector(":scope > a"); if (!anchor) return; const handleOutsideClickForSubmenu = (event) => { if ( submenu.style.display === "block" && !parentLi.contains(event.target) ) { immediateHide(); document.removeEventListener( "click", handleOutsideClickForSubmenu, true, ); } }; anchor.addEventListener("click", (event) => { const now = Date.now(); const timeSinceLastTap = now - lastTap; lastTap = now; if (timeSinceLastTap < DOUBLE_TAP_DELAY && timeSinceLastTap > 0) { lastTap = 0; document.removeEventListener( "click", handleOutsideClickForSubmenu, true, ); immediateHide(); } else { event.preventDefault(); event.stopPropagation(); const isMenuOpen = submenu.style.display === "block"; if (isMenuOpen) { document.removeEventListener( "click", handleOutsideClickForSubmenu, true, ); immediateHide(); } else { show(); setTimeout(() => { document.addEventListener( "click", handleOutsideClickForSubmenu, true, ); }, 0); } } }); } else if (effectiveInteraction === "hover" && !isTouchDevice) { let hideTimeout; let isHovering = false; const handleMouseEnter = () => { isHovering = true; clearTimeout(hideTimeout); if (submenu.style.display !== "block") { show(); } }; const handleMouseLeave = () => { isHovering = false; hideTimeout = setTimeout(() => { if (!isHovering) { immediateHide(); } }, 250); }; parentLi.addEventListener("mouseenter", handleMouseEnter); parentLi.addEventListener("mouseleave", handleMouseLeave); submenu.addEventListener("mouseenter", handleMouseEnter); submenu.addEventListener("mouseleave", handleMouseLeave); } else { const anchor = parentLi.querySelector(":scope > a"); if (anchor) { anchor.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); if (!submenu.classList.contains("is-open")) { [...parentLi.parentElement.children] .filter((li) => li !== parentLi) .forEach((sibling) => { const siblingSubmenu = sibling.querySelector( ":scope > ul, :scope > div[part='mega-menu']", ); if (siblingSubmenu) { siblingSubmenu.classList.remove("is-open"); } }); } const isOpen = submenu.classList.toggle("is-open"); const eventName = isOpen ? "monster-submenu-show" : "monster-submenu-hide"; fireCustomEvent(this, eventName, { context, trigger: parentLi, submenu, level, }); }); } } if (submenu.tagName === "UL") { submenu .querySelectorAll(":scope > li") .forEach((li) => setupSubmenu.call(this, li, context, level + 1)); } } /** * Creates a clone of a navigation list item, setting appropriate part attributes * for styling and handling active states. * @private * @param {HTMLLIElement} item The original list item to clone. * @returns {HTMLLIElement} The cloned and configured list item. */ function cloneNavItem(item) { const liClone = item.cloneNode(true); const aClone = liClone.querySelector("a"); let navItemPart = "nav-item"; let navLinkPart = "nav-link"; if (item.classList.contains("active")) { navItemPart += " nav-item-active"; if (aClone) navLinkPart += " nav-link-active"; } liClone.setAttribute("part", navItemPart); if (aClone) aClone.setAttribute("part", navLinkPart); return liClone; } /** * Measures available space and distributes slotted navigation items between * the visible list and the hidden hamburger menu list. * @private * @this {SiteNavigation} */ /** * Misst den verfügbaren Platz und verteilt die Navigationselemente auf die sichtbare Liste * und das Hamburger-Menü. Die Methode fügt Elemente nacheinander hinzu und prüft auf * Überlauf (Overflow), um zu bestimmen, welche Elemente verschoben werden müssen. * @private * @this {SiteNavigation} */ function populateTabs() { const visibleList = this[visibleElementsSymbol]; const hiddenList = this[hiddenElementsSymbol]; const hamburgerButton = this[hamburgerButtonSymbol]; const navEl = this[navElementSymbol]; const topLevelUl = [...getSlottedElements.call(this, "ul")].find( (ul) => ul.parentElement === this, ); visibleList.innerHTML = ""; hiddenList.innerHTML = ""; hamburgerButton.style.display = "none"; this.style.visibility = "hidden"; if (!topLevelUl) { this.style.visibility = "visible"; return; // Nichts zu tun } const sourceItems = Array.from(topLevelUl.children).filter( (n) => n.tagName === "LI", ); if (sourceItems.length === 0) { this.style.visibility = "visible"; return; } const navWidth = navEl.clientWidth; const originalDisplay = hamburgerButton.style.display; hamburgerButton.style.visibility = "hidden"; hamburgerButton.style.display = "flex"; const hamburgerWidth = Math.ceil(hamburgerButton.getBoundingClientRect().width) || 0; hamburgerButton.style.display = originalDisplay; hamburgerButton.style.visibility = "visible"; navEl.style.overflow = "hidden"; visibleList.style.flexWrap = "nowrap"; visibleList.style.visibility = "hidden"; // Inhalt der Liste während Manipulation ausblenden const fit = []; const rest = []; let hasOverflow = false; for (let i = 0; i < sourceItems.length; i++) { const item = sourceItems[i]; if (hasOverflow) { rest.push(item); continue; } const liClone = cloneNavItem(item); visibleList.appendChild(liClone); const requiredWidth = liClone.offsetLeft + liClone.offsetWidth; const availableWidth = navWidth - hamburgerWidth; const SAFETY_MARGIN = 1; // 1px Sicherheitsmarge für Subpixel-Rendering if (requiredWidth > availableWidth + SAFETY_MARGIN) { hasOverflow = true; rest.push(item); } else { fit.push(item); } } if (fit.length > 0 && rest.length > 0) { const lastVisibleItem = visibleList.children[fit.length - 1]; const visibleItemsWidth = lastVisibleItem.offsetLeft + lastVisibleItem.offsetWidth; const firstHiddenItemClone = cloneNavItem(rest[0]); const submenu = firstHiddenItemClone.querySelector( "ul, div[part='mega-menu']", ); if (submenu) submenu.style.display = "none"; visibleList.appendChild(firstHiddenItemClone); const firstHiddenItemWidth = firstHiddenItemClone.getBoundingClientRect().width; visibleList.removeChild(firstHiddenItemClone); const gap = parseFloat(getComputedStyle(visibleList).gap || "0") || 0; if (visibleItemsWidth + gap + firstHiddenItemWidth <= navWidth) { fit.push(rest.shift()); } } navEl.style.overflow = ""; visibleList.style.flexWrap = ""; visibleList.innerHTML = ""; if (fit.length) { const clonedVisible = fit.map(cloneNavItem); visibleList.append(...clonedVisible); visibleList .querySelectorAll(":scope > li") .forEach((li) => setupSubmenu.call(this, li, "visible", 1)); } if (rest.length) { const clonedHidden = rest.map(cloneNavItem); hiddenList.append(...clonedHidden); hamburgerButton.style.display = "flex"; hiddenList .querySelectorAll(":scope > li") .forEach((li) => setupSubmenu.call(this, li, "hidden", 1)); } visibleList.style.visibility = "visible"; this.style.visibility = "visible"; fireCustomEvent(this, "monster-layout-change", { visibleItems: fit, hiddenItems: rest, }); } /** * A simple template literal tag function for clarity. * @private * @param {TemplateStringsArray} strings * @returns {string} The combined string. */ function html(strings) { return strings.join(""); } /** * Returns the HTML template for the component's shadow DOM. * @private * @returns {string} The HTML template string. */ function getTemplate() { return html`<div data-monster-role="control" part="control"> <nav data-monster-role="navigation" role="navigation" part="nav"> <ul id="visible-elements" part="visible-list"></ul> </nav> <div data-monster-role="hamburger-container" part="hamburger-container"> <button id="hamburger-button" part="hamburger-button" aria-label="More navigation items" > <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" > <path fill-rule="evenodd" d="M2.5 12a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5" /> </svg> </button> <nav data-monster-role="hamburger-nav" role="navigation" part="hamburger-nav" style="display: none;" > <div part="hamburger-header"> <button part="hamburger-close-button" aria-label="Close navigation"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <line x1="18" y1="6" x2="6" y2="18"></line> <line x1="6" y1="6" x2="18" y2="18"></line> </svg> </button> </div> <ul id="hidden-elements" part="hidden-list"></ul> </nav> </div> <slot class="hidden-slot" style="display: none;"></slot> </div>`; } registerCustomElement(SiteNavigation);