UNPKG

@schukai/monster

Version:

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

496 lines (437 loc) 12 kB
/** * Copyright © schukai GmbH and all contributing authors, {{copyRightYear}}. 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 schukai GmbH. * * SPDX-License-Identifier: AGPL-3.0 */ import { instanceSymbol } from "../../constants.mjs"; import { addAttributeToken, removeAttributeToken, } from "../../dom/attributes.mjs"; import { ATTRIBUTE_ROLE } from "../../dom/constants.mjs"; import { assembleMethodSymbol, CustomElement, registerCustomElement, } from "../../dom/customelement.mjs"; import { fireCustomEvent } from "../../dom/events.mjs"; import { getDocument } from "../../dom/util.mjs"; import { DeadMansSwitch } from "../../util/deadmansswitch.mjs"; import { STYLE_DISPLAY_MODE_BLOCK } from "../form/constants.mjs"; import { positionPopper } from "../form/util/floating-ui.mjs"; import { PopperStyleSheet } from "./stylesheet/popper.mjs"; import { isArray } from "../../types/is.mjs"; export { Popper }; /** * Symbol for timer callback reference * @private * @type {symbol} */ const timerCallbackSymbol = Symbol("timerCallback"); /** * Symbol for resize observer reference * @private * @type {symbol} */ const resizeObserverSymbol = Symbol("resizeObserver"); /** * Symbol for close event handler reference * @private * @type {symbol} */ const closeEventHandler = Symbol("closeEventHandler"); /** * Symbol for control element reference * @private * @type {symbol} */ const controlElementSymbol = Symbol("controlElement"); /** * Symbol for button element reference * @private * @type {symbol} */ const buttonElementSymbol = Symbol("buttonElement"); /** * Symbol for popper element reference * @private * @type {symbol} */ const popperElementSymbol = Symbol("popperElement"); /** * Symbol for arrow element reference * @private * @type {symbol} */ const arrowElementSymbol = Symbol("arrowElement"); /** * Popper component for displaying floating UI elements * * The Popper class creates a floating overlay element that can be shown/hidden * and positioned relative to a trigger element. It supports different interaction * modes like click, hover, focus etc. * * @fragments /fragments/components/layout/popper/ * * @example /examples/components/layout/popper-simple * @example /examples/components/layout/popper-click * * @since 1.65.0 * @copyright schukai GmbH * @summary Floating overlay component with flexible positioning and interaction modes * @fires monster-popper-hide - Fired when popper starts hiding * @fires monster-popper-hidden - Fired when popper is fully hidden * @fires monster-popper-open - Fired when popper starts opening * @fires monster-popper-opened - Fired when popper is fully opened */ class Popper extends CustomElement { /** * Gets the instance symbol for type checking * @return {symbol} The instance type symbol */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/layout/popper@@instance"); } /** * Default configuration options for the popper * * @property {Object} templates - Template configuration * @property {string} templates.main - Main template HTML * @property {string} mode - Interaction mode(s): click|enter|manual|focus|auto * @property {string} content - Content template * @property {Object} popper - Positioning options * @property {string} popper.placement - Placement: top|bottom|left|right * @property {Array} popper.middleware - Positioning middleware functions * @property {Object} features - Feature flags * @property {boolean} features.preventOpenEventSent - Prevent open event * @returns {Object} Default options merged with parent defaults */ get defaults() { return Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, mode: "auto focus", content: "<slot></slot>", popper: { placement: "top", middleware: ["autoPlacement", "shift", "offset:15", "arrow"], }, features: { preventOpenEventSent: false, }, }); } /** * Initialize the component * Called on first connection to DOM * @private */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); initEventHandler.call(this); } /** * Gets the custom element tag name * @return {string} The tag name */ static getTag() { return "monster-popper"; } /** * Gets component stylesheets * @return {CSSStyleSheet[]} Array of stylesheets */ static getCSSStyleSheet() { return [PopperStyleSheet]; } /** * Lifecycle callback when element connects to DOM * Sets up event listeners and initializes popper */ connectedCallback() { super.connectedCallback(); const document = getDocument(); for (const [, type] of Object.entries(["click", "touch"])) { document.addEventListener(type, this[closeEventHandler]); } updatePopper.call(this); attachResizeObserver.call(this); } /** * Lifecycle callback when element disconnects from DOM * Cleans up event listeners and observers */ disconnectedCallback() { super.disconnectedCallback(); for (const [, type] of Object.entries(["click", "touch"])) { document.removeEventListener(type, this[closeEventHandler]); } disconnectResizeObserver.call(this); } /** * Shows the popper element * @return {Popper} The popper instance */ showDialog() { show.call(this); return this; } /** * Hides the popper element * @return {Popper} The popper instance */ hideDialog() { hide.call(this); return this; } /** * Toggles popper visibility * @return {Popper} The popper instance */ toggleDialog() { if (this[popperElementSymbol].style.display === STYLE_DISPLAY_MODE_BLOCK) { this.hideDialog(); } else { this.showDialog(); } return this; } } /** * Initializes event handlers for popper interactivity * @private * @return {Popper} The popper instance */ function initEventHandler() { this[closeEventHandler] = (event) => { const path = event.composedPath(); for (const [, element] of Object.entries(path)) { if (element === this) { return; } } hide.call(this); }; let modes = null; const modeOption = this.getOption("mode"); if (typeof modeOption === "string") { modes = modeOption.split(" "); } if ( modes === null || modes === undefined || isArray(modes) === false || modes.length === 0 ) { modes = ["manual"]; } for (const [, mode] of Object.entries(modes)) { initEventHandlerByMode.call(this, mode); } return this; } /** * Sets up event handlers for specific interaction mode * @private * @param {string} mode - Interaction mode to initialize * @return {Popper} The popper instance * @throws {Error} For unknown modes */ function initEventHandlerByMode(mode) { switch (mode) { case "manual": break; case "focus": this[buttonElementSymbol].addEventListener("focus", (event) => { if (this.getOption("features.preventOpenEventSent") === true) { event.preventDefault(); } this.showDialog(); }); this[buttonElementSymbol].addEventListener("blur", (event) => { if (this.getOption("features.preventOpenEventSent") === true) { event.preventDefault(); } this.hideDialog(); }); break; case "click": this[buttonElementSymbol].addEventListener("click", (event) => { if (this.getOption("features.preventOpenEventSent") === true) { event.preventDefault(); } this.toggleDialog(); }); break; case "enter": this[buttonElementSymbol].addEventListener("mouseenter", (event) => { if (this.getOption("features.preventOpenEventSent") === true) { event.preventDefault(); } this.showDialog(); }); break; case "auto": // is hover this[buttonElementSymbol].addEventListener("mouseenter", (event) => { if (this.getOption("features.preventOpenEventSent") === true) { event.preventDefault(); } this.showDialog(); }); this[buttonElementSymbol].addEventListener("mouseleave", (event) => { if (this.getOption("features.preventOpenEventSent") === true) { event.preventDefault(); } this.hideDialog(); }); break; default: throw new Error(`Unknown mode ${mode}`); } } /** * Sets up resize observer for popper repositioning * @private */ function attachResizeObserver() { this[resizeObserverSymbol] = new ResizeObserver((entries) => { if (this[timerCallbackSymbol] instanceof DeadMansSwitch) { try { this[timerCallbackSymbol].touch(); return; } catch (e) { delete this[timerCallbackSymbol]; } } this[timerCallbackSymbol] = new DeadMansSwitch(200, () => { updatePopper.call(this); }); }); requestAnimationFrame(() => { let parent = this.parentNode; while (!(parent instanceof HTMLElement) && parent !== null) { parent = parent.parentNode; } if (parent instanceof HTMLElement) { this[resizeObserverSymbol].observe(parent); } }); } /** * Disconnects resize observer * @private */ function disconnectResizeObserver() { if (this[resizeObserverSymbol] instanceof ResizeObserver) { this[resizeObserverSymbol].disconnect(); } } /** * Hides the popper element * @private */ function hide() { const self = this; fireCustomEvent(self, "monster-popper-hide", { self, }); self[popperElementSymbol].style.display = "none"; removeAttributeToken(self[controlElementSymbol], "class", "open"); setTimeout(() => { fireCustomEvent(self, "monster-popper-hidden", { self, }); }, 0); } /** * Shows the popper element * @private */ function show() { const self = this; if (self.getOption("disabled", false) === true) { return; } if (self[popperElementSymbol].style.display === STYLE_DISPLAY_MODE_BLOCK) { return; } fireCustomEvent(self, "monster-popper-open", { self, }); self[popperElementSymbol].style.visibility = "hidden"; self[popperElementSymbol].style.display = STYLE_DISPLAY_MODE_BLOCK; addAttributeToken(self[controlElementSymbol], "class", "open"); updatePopper.call(self); setTimeout(() => { fireCustomEvent(self, "monster-popper-opened", { self, }); }, 0); } /** * Updates popper positioning * @private */ function updatePopper() { if (this[popperElementSymbol].style.display !== STYLE_DISPLAY_MODE_BLOCK) { return; } if (this.getOption("disabled", false) === true) { return; } positionPopper.call( this, this[controlElementSymbol], this[popperElementSymbol], this.getOption("popper", {}), ); } /** * Initializes references to DOM elements * @private * @return {Popper} The popper instance */ function initControlReferences() { this[controlElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=control]`, ); this[buttonElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=button]`, ); this[popperElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=popper]`, ); this[arrowElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=arrow]`, ); return this; } /** * Gets the main template HTML * @private * @return {string} Template HTML */ function getTemplate() { // language=HTML return ` <div data-monster-role="control" part="control"> <slot name="button" data-monster-role="button"></slot> <div data-monster-role="popper" part="popper" tabindex="-1" class="monster-color-primary-1"> <div data-monster-role="arrow"></div> <div part="content" class="flex" data-monster-replace="path:content"> </div> </div> </div> `; } registerCustomElement(Popper);