UNPKG

@schukai/monster

Version:

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

905 lines (796 loc) 21 kB
/** * Copyright © Volker Schukai 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 Volker Schukai. * * SPDX-License-Identifier: AGPL-3.0 */ import { instanceSymbol } from "../../constants.mjs"; import { addAttributeToken, containsAttributeToken, removeAttributeToken, } from "../../dom/attributes.mjs"; import { ATTRIBUTE_ROLE } from "../../dom/constants.mjs"; import { assembleMethodSymbol, attributeObserverSymbol, CustomElement, registerCustomElement, } from "../../dom/customelement.mjs"; import { addErrorAttribute } from "../../dom/error.mjs"; import { fireCustomEvent } from "../../dom/events.mjs"; import { findElementWithSelectorUpwards, getDocument, } from "../../dom/util.mjs"; import { DeadMansSwitch } from "../../util/deadmansswitch.mjs"; import { closePositionedPopper, isPositionedPopperOpen, openPositionedPopper, 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 content element reference * @private * @type {symbol} */ const contentElementSymbol = Symbol("contentElement"); /** * Symbol for arrow element reference * @private * @type {symbol} */ const arrowElementSymbol = Symbol("arrowElement"); /** * @private * @type {symbol} */ const hostElementSymbol = Symbol("hostElement"); /** * @private * @type {symbol} */ const dismissRecordSymbol = Symbol("dismissRecord"); /** * @private * @type {symbol} */ const usesHostDismissSymbol = Symbol("usesHostDismiss"); const actionQueueSymbol = Symbol("actionQueue"); const pendingActionSymbol = Symbol("pendingAction"); /** * 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 Volker Schukai * @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 {string} popper.contentOverflow - Content clipping mode: both|horizontal|visible|smart * @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", engine: "floating", middleware: ["flip", "shift", "offset:15", "arrow"], contentOverflow: "both", }, features: { preventOpenEventSent: false, }, }); } /** * Initialize the component * Called on first connection to DOM * @private */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); initOverflowObserver.call(this); applyContentOverflowMode.call(this); initEventHandler.call(this); } /** * @inheritdoc */ setOption(path, value) { super.setOption(path, value); if (path === "popper.contentOverflow") { applyContentOverflowMode.call(this); } else if (path === "content") { queueMicrotask(() => { applyContentOverflowMode.call(this); }); } return 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(); this[hostElementSymbol] = findElementWithSelectorUpwards( this, "monster-host", ); this[usesHostDismissSymbol] = this[hostElementSymbol] && typeof this[hostElementSymbol].registerDismissable === "function"; if (!this[usesHostDismissSymbol]) { 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(); if (!this[usesHostDismissSymbol]) { const document = getDocument(); for (const [, type] of Object.entries(["click", "touch"])) { document.removeEventListener(type, this[closeEventHandler]); } } unregisterFromHost.call(this); disconnectResizeObserver.call(this); } /** * Shows the popper element * @return {Popper} The popper instance */ showDialog() { queuePopperAction.call(this, "show"); return this; } /** * Recalculates the size and position of an already open popper. * * @return {Popper} */ recalcPopper() { queuePopperAction.call(this, "update"); return this; } /** * Resolves the effective popper options for the current render pass. * Subclasses can override this to adapt positioning without mutating * the persisted component options. * * @return {object} */ resolvePopperOptions() { return Object.assign({}, this.getOption("popper", {})); } /** * Resolves the effective content overflow mode for the rendered wrapper. * Subclasses can override this when the configured option is only an intermediate mode. * `smart` keeps regular content measurable inside the popper while nested overlays * are allowed to escape horizontally without forcing the parent wrapper to size to them. * * @return {string} */ resolveContentOverflowMode() { const configuredMode = this.getOption("popper.contentOverflow", "both"); if (configuredMode !== "smart") { return configuredMode; } if (containsNestedOverlayContent(this.getOption("content"))) { return "horizontal"; } return "both"; } /** * Hides the popper element * @return {Popper} The popper instance */ hideDialog() { queuePopperAction.call(this, "hide"); return this; } /** * Toggles popper visibility * @return {Popper} The popper instance */ toggleDialog() { queuePopperAction.call(this, "toggle"); return this; } } /** * Initializes event handlers for popper interactivity * @private * @return {Popper} The popper instance */ function initEventHandler() { this[closeEventHandler] = (event) => { if ( isEventInsidePopperOwner( this, event, this[controlElementSymbol], this[buttonElementSymbol], this[popperElementSymbol], ) ) { return; } if ( !isPositionedPopperOpen(this[popperElementSymbol]) && !containsAttributeToken(this[controlElementSymbol], "class", "open") ) { 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; } /** * Serializes popper state changes so open/close/update events do not interleave. * The latest requested action wins while a queue is already running. * * @private * @param {"show"|"hide"|"toggle"|"update"} action * @return {Promise<void>} */ function queuePopperAction(action) { this[pendingActionSymbol] = action; if (this[actionQueueSymbol] instanceof Promise) { return this[actionQueueSymbol]; } this[actionQueueSymbol] = (async () => { while (this[pendingActionSymbol]) { const nextAction = this[pendingActionSymbol]; delete this[pendingActionSymbol]; await Promise.resolve(runPopperAction.call(this, nextAction)); } })() .catch((e) => { addErrorAttribute(this, e); }) .finally(() => { delete this[actionQueueSymbol]; if (this[pendingActionSymbol]) { void queuePopperAction.call(this, this[pendingActionSymbol]); } }); return this[actionQueueSymbol]; } function runPopperAction(action) { switch (action) { case "toggle": if (isPositionedPopperOpen(this[popperElementSymbol])) { return performHide.call(this); } return performShow.call(this); case "hide": return performHide.call(this); case "show": return performShow.call(this); case "update": return performUpdate.call(this); default: return undefined; } } function isEventInsidePopperOwner( owner, event, controlElement, buttonElement, popperElement, ) { const path = event.composedPath?.() || []; for (const element of path) { if ( element === owner || element === controlElement || element === buttonElement || element === popperElement ) { return true; } } const target = path[0] || event.target; if (!(target instanceof Node)) { return false; } if (owner instanceof HTMLElement && owner.contains(target)) { return true; } if ( owner?.shadowRoot instanceof ShadowRoot && owner.shadowRoot.contains(target) ) { return true; } return false; } /** * 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() { void queuePopperAction.call(this, "hide"); } /** * Hides the popper element * @private */ function performHide() { const self = this; const popperElement = self[popperElementSymbol]; const controlElement = self[controlElementSymbol]; if (!self.isConnected) { unregisterFromHost.call(self); return; } fireCustomEvent(self, "monster-popper-hide", { self, }); if (popperElement instanceof HTMLElement) { closePositionedPopper(popperElement); } if (controlElement instanceof HTMLElement) { removeAttributeToken(controlElement, "class", "open"); } unregisterFromHost.call(self); setTimeout(() => { fireCustomEvent(self, "monster-popper-hidden", { self, }); }, 0); } /** * Shows the popper element * @private */ function show() { void queuePopperAction.call(this, "show"); } /** * Shows the popper element * @private */ function performShow() { const self = this; const popperElement = self[popperElementSymbol]; const controlElement = self[controlElementSymbol]; if (self.getOption("disabled", false) === true) { return; } if ( !self.isConnected || !(popperElement instanceof HTMLElement) || !(controlElement instanceof HTMLElement) ) { return; } if (isPositionedPopperOpen(popperElement)) { return; } fireCustomEvent(self, "monster-popper-open", { self, }); applyContentOverflowMode.call(self); popperElement.style.visibility = "hidden"; openPositionedPopper( self[controlElementSymbol], popperElement, self.resolvePopperOptions(), ); addAttributeToken(controlElement, "class", "open"); registerWithHost.call(self); return performUpdate.call(self).then(() => { setTimeout(() => { fireCustomEvent(self, "monster-popper-opened", { self, }); }, 0); }); } /** * Updates popper positioning * @private */ function updatePopper() { void queuePopperAction.call(this, "update"); } /** * Updates popper positioning * @private */ function performUpdate() { if ( !this.isConnected || !(this[controlElementSymbol] instanceof HTMLElement) || !(this[popperElementSymbol] instanceof HTMLElement) ) { return; } if (!isPositionedPopperOpen(this[popperElementSymbol])) { return; } if (this.getOption("disabled", false) === true) { return; } applyContentOverflowMode.call(this); return positionPopper.call( this, this[controlElementSymbol], this[popperElementSymbol], this.resolvePopperOptions(), ); } /** * @private */ function registerWithHost() { if (!this[usesHostDismissSymbol]) { return; } if (!(this[hostElementSymbol] instanceof HTMLElement)) { return; } const record = this[hostElementSymbol].registerDismissable?.({ element: this, owner: this, close: () => { this.hideDialog(); }, priority: 10, options: { dismissOnOutside: true, }, }); if (record) { this[dismissRecordSymbol] = record; } } /** * @private */ function unregisterFromHost() { if (!this[usesHostDismissSymbol]) { return; } if (!(this[hostElementSymbol] instanceof HTMLElement)) { return; } if (this[dismissRecordSymbol]) { this[hostElementSymbol].unregisterDismissable?.(this[dismissRecordSymbol]); this[dismissRecordSymbol] = null; return; } this[hostElementSymbol].unregisterDismissable?.(this); } /** * 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]`, ); this[contentElementSymbol] = this.shadowRoot.querySelector(`[part="content"]`); return this; } /** * Keeps the rendered content wrapper in sync with the configured overflow mode * @private * @return {void} */ function initOverflowObserver() { this[attributeObserverSymbol]["data-monster-option-popper-content-overflow"] = () => { applyContentOverflowMode.call(this); }; this[attributeObserverSymbol]["data-monster-option-content"] = () => { applyContentOverflowMode.call(this); }; } /** * Applies the current content overflow mode to the rendered wrapper element * @private * @return {void} */ function applyContentOverflowMode() { const contentElement = this[contentElementSymbol]; if (!(contentElement instanceof HTMLElement)) { return; } const overflowMode = this.resolveContentOverflowMode(); contentElement.setAttribute("data-monster-overflow-mode", overflowMode); switch (overflowMode) { case "horizontal": contentElement.style.overflow = "visible"; contentElement.style.removeProperty("max-height"); contentElement.style.removeProperty("max-width"); break; case "both": case "visible": contentElement.style.overflow = "visible"; contentElement.style.maxHeight = "none"; contentElement.style.maxWidth = "none"; break; default: contentElement.style.removeProperty("overflow"); contentElement.style.removeProperty("max-height"); contentElement.style.removeProperty("max-width"); break; } } /** * 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> `; } function containsNestedOverlayContent(content) { const selector = [ "monster-details", "monster-message-state-button", "monster-popper", "monster-popper-button", "monster-select", "details", ].join(","); if (typeof content === "string") { const container = document.createElement("div"); container.innerHTML = content; return container.querySelector(selector) instanceof HTMLElement; } if (!(content instanceof HTMLElement)) { return false; } if (content.matches(selector)) { return true; } return content.querySelector(selector) instanceof HTMLElement; } registerCustomElement(Popper);