@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
780 lines (703 loc) • 22.7 kB
JavaScript
/**
* 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);