diff --git a/source/components/form/message-state-button.mjs b/source/components/form/message-state-button.mjs index bbca2798e32ac419c44b6941084dc36d93f1de1d..60f87ca19878b1812c83784a5044c26494e530e0 100644 --- a/source/components/form/message-state-button.mjs +++ b/source/components/form/message-state-button.mjs @@ -35,19 +35,20 @@ export { MessageStateButton }; const buttonElementSymbol = Symbol("buttonElement"); /** - * A select control that can be used to select one or more options from a list. + * A specialized button component that combines state management with message display capabilities. + * It extends the Popper component to show messages in a popup overlay and can be used for form submissions + * or manual actions. * * @fragments /fragments/components/form/message-state-button/ - * * @example /examples/components/form/message-state-button-simple * * @since 2.11.0 * @copyright schukai GmbH - * @summary A beautiful select control that can make your life easier and also looks good. - * @fires monster-options-set - * @fires monster-selected - * @fires monster-change - * @fires monster-changed + * @summary Button component with integrated message display and state management + * @fires monster-state-changed - Fired when button state changes + * @fires monster-message-shown - Fired when message is displayed + * @fires monster-message-hidden - Fired when message is hidden + * @fires monster-click - Fired when button is clicked */ class MessageStateButton extends Popper { /** @@ -61,12 +62,12 @@ class MessageStateButton extends Popper { } /** + * Sets the state of the button which affects its visual appearance * - * @param {string} state - * @param {number} timeout - * @return {MessageStateButton} - * @throws {TypeError} value is not a string - * @throws {TypeError} value is not an instance + * @param {string} state - The state to set (e.g. 'success', 'error', 'loading') + * @param {number} timeout - Optional timeout in milliseconds after which state is removed + * @return {MessageStateButton} Returns the button instance for chaining + * @throws {TypeError} When state is not a string or timeout is not a number */ setState(state, timeout) { return this[buttonElementSymbol].setState(state, timeout); @@ -164,12 +165,13 @@ class MessageStateButton extends Popper { } /** - * Sets the message + * Sets the message content to be displayed in the popup overlay * - * @param {string|HTMLElement}message - * @param {string} title - * @param {string} icon - * @return {MessageStateButton} + * @param {string|HTMLElement} message - The message content as string or HTML element + * @param {string} title - Optional title to show above the message + * @param {string} icon - Optional icon HTML to display next to the title + * @return {MessageStateButton} Returns the button instance for chaining + * @throws {TypeError} When message is empty or invalid type */ setMessage(message, title, icon) { if (isString(message)) { @@ -230,10 +232,10 @@ class MessageStateButton extends Popper { } /** - * With this method you can show the popper with timeout feature. + * Shows the message popup overlay with optional auto-hide timeout * - * @param {number} timeout - * @return {MessageStateButton} + * @param {number} timeout - Optional time in milliseconds after which the message will auto-hide + * @return {MessageStateButton} Returns the button instance for chaining */ showMessage(timeout) { this.showDialog.call(this); @@ -248,7 +250,7 @@ class MessageStateButton extends Popper { } /** - * With this method you can show the popper. + * With this method, you can show the popper. * * @return {MessageStateButton} */ @@ -306,10 +308,12 @@ class MessageStateButton extends Popper { } /** - * The Button.click() method simulates a click on the internal button element. + * Programmatically triggers a click event on the button + * Will not trigger if button is disabled * * @since 3.27.0 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/click} + * @fires monster-click */ click() { if (this.getOption("disabled") === true) { diff --git a/source/components/layout/popper.mjs b/source/components/layout/popper.mjs index 4a33687321de9eac8720920895b2b4ccfd5b59a2..b7fcc30fc50049d8b36731cd587fd320499adf43 100644 --- a/source/components/layout/popper.mjs +++ b/source/components/layout/popper.mjs @@ -12,75 +12,82 @@ * SPDX-License-Identifier: AGPL-3.0 */ -import { instanceSymbol } from "../../constants.mjs"; +import {instanceSymbol} from "../../constants.mjs"; import { - addAttributeToken, - removeAttributeToken, + addAttributeToken, + removeAttributeToken, } from "../../dom/attributes.mjs"; -import { ATTRIBUTE_ROLE } from "../../dom/constants.mjs"; +import {ATTRIBUTE_ROLE} from "../../dom/constants.mjs"; import { - assembleMethodSymbol, - CustomElement, - registerCustomElement, + 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"; +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 }; +export {Popper}; /** + * Symbol for timer callback reference * @private * @type {symbol} */ const timerCallbackSymbol = Symbol("timerCallback"); /** - * local symbol + * Symbol for resize observer reference * @private * @type {symbol} */ const resizeObserverSymbol = Symbol("resizeObserver"); /** - * local symbol + * 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"); /** - * local symbol + * Symbol for popper element reference * @private * @type {symbol} */ const popperElementSymbol = Symbol("popperElement"); /** - * local symbol + * Symbol for arrow element reference * @private * @type {symbol} */ const arrowElementSymbol = Symbol("arrowElement"); /** - * A Popper is a floating UI element that can be shown or hidden. + * 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/ * @@ -89,400 +96,400 @@ const arrowElementSymbol = Symbol("arrowElement"); * * @since 1.65.0 * @copyright schukai GmbH - * @summary A beautiful popper that can make your life easier and also looks good. - * @fires monster-popper-hide fired when the popper is hide. - * @fires monster-popper-hidden fired when the popper is hidden. - * @fires monster-popper-open fired when the popper is open. - * @fires monster-popper-opened fired when the popper is opened. + * @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 { - /** - * This method is called by the `instanceof` operator. - * @return {symbol} - */ - static get [instanceSymbol]() { - return Symbol.for("@schukai/monster/components/layout/popper@@instance"); - } - - /** - * To set the options via the HTML tag, the attribute `data-monster-options` must be used. - * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control} - * - * The individual configuration values can be found in the table. - * - * @property {Object} templates The templates for the control. - * @property {string} templates.main The main template. - * @property {string} mode The mode of the popper. Possible values are `click`, `enter`, `manual`, `focus`, "auto" or a combination of them. - * @property {string} content The content of the popper. - * @property {object} popper The popper options. - * @property {string} popper.placement The placement of the popper. Possible values are `top`, `bottom`, `left` and `right`. - * @property {function[]} popper.middleware The middleware functions of the popper. - * @property {Object} features The features of the popper. - * @property {boolean} features.preventOpenEventSent Prevents the open event from being sent. - */ - 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, - }, - }); - } - - /** - * This method is called by the `connectedCallback` method on the first call. - * - * @return {void} - */ - [assembleMethodSymbol]() { - super[assembleMethodSymbol](); - initControlReferences.call(this); - initEventHandler.call(this); - } - - /** - * This method returns the tag name of the element. - * - * @return {string} - */ - static getTag() { - return "monster-popper"; - } - - /** - * This method returns the css styles of the element. - * - * @return {CSSStyleSheet[]} - */ - static getCSSStyleSheet() { - return [PopperStyleSheet]; - } - - /** - * This method is called when the element is connected to the dom. - * - * @return {void} - */ - connectedCallback() { - super.connectedCallback(); - - const document = getDocument(); - - for (const [, type] of Object.entries(["click", "touch"])) { - // close on outside ui-events - document.addEventListener(type, this[closeEventHandler]); - } - - updatePopper.call(this); - attachResizeObserver.call(this); - } - - /** - * This method is called when the element is disconnected from the dom. - * - * @return {void} - */ - disconnectedCallback() { - super.disconnectedCallback(); - - // close on outside ui-events - for (const [, type] of Object.entries(["click", "touch"])) { - document.removeEventListener(type, this[closeEventHandler]); - } - - disconnectResizeObserver.call(this); - } - - /** - * With this method, you can show the popper. - * - * @return {Popper} - */ - showDialog() { - show.call(this); - return this; - } - - /** - * With this method you can hide the popper. - * - * @return {Popper} - */ - hideDialog() { - hide.call(this); - return this; - } - - /** - * With this method you can toggle the popper. - * - * @return {Popper} - */ - toggleDialog() { - if (this[popperElementSymbol].style.display === STYLE_DISPLAY_MODE_BLOCK) { - this.hideDialog(); - } else { - this.showDialog(); - } - return this; - } + /** + * 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} + * @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; + 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 mode - * @return {Popper} - * @throws Error + * @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}`); - } + 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() { - // against flickering - 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); - } - }); + 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(); - } + if (this[resizeObserverSymbol] instanceof ResizeObserver) { + this[resizeObserverSymbol].disconnect(); + } } /** + * Hides the popper element * @private */ function hide() { - const self = this; + const self = this; - fireCustomEvent(self, "monster-popper-hide", { - self, - }); + fireCustomEvent(self, "monster-popper-hide", { + self, + }); - self[popperElementSymbol].style.display = "none"; - removeAttributeToken(self[controlElementSymbol], "class", "open"); + self[popperElementSymbol].style.display = "none"; + removeAttributeToken(self[controlElementSymbol], "class", "open"); - setTimeout(() => { - fireCustomEvent(self, "monster-popper-hidden", { - self, - }); - }, 0); + setTimeout(() => { + fireCustomEvent(self, "monster-popper-hidden", { + self, + }); + }, 0); } /** + * Shows the popper element * @private */ function show() { - const self = this; + const self = this; - if (self.getOption("disabled", false) === true) { - return; - } + if (self.getOption("disabled", false) === true) { + return; + } - if (self[popperElementSymbol].style.display === STYLE_DISPLAY_MODE_BLOCK) { - return; - } + if (self[popperElementSymbol].style.display === STYLE_DISPLAY_MODE_BLOCK) { + return; + } - fireCustomEvent(self, "monster-popper-open", { - self, - }); + fireCustomEvent(self, "monster-popper-open", { + self, + }); - self[popperElementSymbol].style.visibility = "hidden"; - self[popperElementSymbol].style.display = STYLE_DISPLAY_MODE_BLOCK; + self[popperElementSymbol].style.visibility = "hidden"; + self[popperElementSymbol].style.display = STYLE_DISPLAY_MODE_BLOCK; - addAttributeToken(self[controlElementSymbol], "class", "open"); - updatePopper.call(self); + addAttributeToken(self[controlElementSymbol], "class", "open"); + updatePopper.call(self); - setTimeout(() => { - fireCustomEvent(self, "monster-popper-opened", { - self, - }); - }, 0); + 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", {}), - ); + 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} + * @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; + 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} + * @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> - `; + // 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); +registerCustomElement(Popper); \ No newline at end of file