UNPKG

@schukai/monster

Version:

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

723 lines (641 loc) 19 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 { ATTRIBUTE_DISABLED, ATTRIBUTE_ROLE } from "../../dom/constants.mjs"; import { assembleMethodSymbol, attributeObserverSymbol, registerCustomElement, } from "../../dom/customelement.mjs"; import { isArray, isString } from "../../types/is.mjs"; import { validateString } from "../../types/validate.mjs"; import { Popper } from "../layout/popper.mjs"; import { MessageStateButtonStyleSheet } from "./stylesheet/message-state-button.mjs"; import { StateButtonStyleSheet } from "./stylesheet/state-button.mjs"; import "./state-button.mjs"; import { isFunction } from "../../types/is.mjs"; import { Observer } from "../../types/observer.mjs"; export { MessageStateButton }; /** * @private * @type {symbol} */ const buttonElementSymbol = Symbol("buttonElement"); const innerDisabledObserverSymbol = Symbol("innerDisabledObserver"); const popperElementSymbol = Symbol("popperElement"); const messageElementSymbol = Symbol("messageElement"); const measurementPopperSymbol = Symbol("measurementPopper"); const autoHideTimerSymbol = Symbol("autoHideTimer"); /** * 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 Volker Schukai * @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 { /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for( "@schukai/monster/components/form/message-state-button@@instance", ); } /** * Sets the state of the button which affects its visual appearance * * @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); } /** * * @return {MessageStateButton} */ removeState() { return this[buttonElementSymbol].removeState(); } /** * @return {MessageStateButton|undefined} */ getState() { return this[buttonElementSymbol].getState(); } /** * 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 Template definitions * @property {string} templates.main Main template * @property {Object} message Message definition * @property {string|HTMLElement} message.content The message content * @property {string} message.title The message title * @property {string} message.icon The message icon * @property {Object} message.width Width options for the message popper * @property {string|number} message.width.min Minimum width (px, rem, em, vw) * @property {string|number} message.width.max Maximum width (px, rem, em, vw) * @property {number} message.width.viewportRatio Max width as ratio of viewport width (0-1) * @property {string} mode The mode of the button, can be `manual` or `submit` * @property {string} labels.button Button label * @property {Object} classes Classes for internal elements * @property {string} classes.button Button class * @property {Object} actions Action callbacks * @property {function} actions.click Action triggered on click * @property {Object} aria Aria attributes * @property {string} aria.role Aria role, only if the button is not a button * @property {string} aria.label Aria label for the button */ get defaults() { return Object.assign({}, super.defaults, { message: { title: undefined, content: undefined, icon: undefined, width: { min: "12rem", max: "32rem", viewportRatio: 0.7, }, }, templates: { main: getTemplate(), }, mode: "manual", labels: { button: "<slot></slot>", }, classes: { button: "monster-button-outline-primary", }, actions: { click: (e) => {}, }, aria: { role: null, label: null, }, }); } /** * @return {void} */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); initDisabledSync.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); } } /** * Sets the message content to be displayed in the popup overlay * * @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)) { if (message === "") { throw new TypeError("message must not be empty"); } const containerDiv = document.createElement("div"); const messageDiv = document.createElement("div"); const titleDiv = document.createElement("div"); titleDiv.setAttribute(ATTRIBUTE_ROLE, "message-title-box"); let titleElement, iconElement; if (title !== undefined) { title = validateString(title); titleElement = document.createElement("div"); titleElement.setAttribute("class", ""); titleElement.innerHTML = title; titleElement.setAttribute(ATTRIBUTE_ROLE, "message-title"); titleDiv.appendChild(titleElement); } if (icon !== undefined) { icon = validateString(icon); iconElement = document.createElement("div"); iconElement.setAttribute("class", ""); iconElement.innerHTML = icon; iconElement.setAttribute(ATTRIBUTE_ROLE, "message-icon"); titleDiv.appendChild(iconElement); } messageDiv.innerHTML = message; containerDiv.appendChild(titleDiv); containerDiv.appendChild(messageDiv); this.setOption("message.content", containerDiv); } else if (message instanceof HTMLElement) { this.setOption("message.content", message); } else { throw new TypeError( "message must be a string or an instance of HTMLElement", ); } return this; } /** * clears the Message * * @return {MessageStateButton} */ clearMessage() { this.setOption("message.title", undefined); this.setOption("message.content", undefined); this.setOption("message.icon", undefined); clearAutoHideTimer.call(this); return this; } /** * Shows the message popup overlay with optional auto-hide timeout * * @param {number} timeout - Optional time in milliseconds after which the message will auto-hide * @return {MessageStateButton} Returns the button instance for chaining */ showMessage(timeout) { clearAutoHideTimer.call(this); applyMeasuredMessageWidth.call(this); this.showDialog.call(this); if (timeout !== undefined) { this[autoHideTimerSymbol] = setTimeout(() => { this[autoHideTimerSymbol] = undefined; if (!this.isConnected) { return; } this.hideMessage(); }, timeout); } return this; } /** * With this method, you can show the popper. * * @return {MessageStateButton} */ showDialog() { if (this.getOption("message.content") === undefined) { return; } super.showDialog(); return this; } /** * * @return {MessageStateButton} */ hideMessage() { clearAutoHideTimer.call(this); super.hideDialog(); return this; } disconnectedCallback() { clearAutoHideTimer.call(this); super.disconnectedCallback(); } /** * * @return {MessageStateButton} */ toggleMessage() { super.toggleDialog(); return this; } /** * * @return {Object} */ getMessage() { return this.getOption("message"); } /** * * @return {string} */ static getTag() { return "monster-message-state-button"; } /** * * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { const styles = Popper.getCSSStyleSheet(); styles.push(StateButtonStyleSheet); styles.push(MessageStateButtonStyleSheet); return styles; } /** * Programmatically triggers a click event on the button * Will not trigger if the 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) { return; } if ( this[buttonElementSymbol] && isFunction(this[buttonElementSymbol].click) ) { this[buttonElementSymbol].click(); } } /** * The Button.focus() method sets focus on the internal button element. * * @since 3.27.0 * @param {Object} options * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus} */ focus(options) { if (this.getOption("disabled") === true) { return; } if ( this[buttonElementSymbol] && isFunction(this[buttonElementSymbol].focus) ) { this[buttonElementSymbol].focus(options); } } /** * The Button.blur() method removes focus from the internal button element. */ blur() { if ( this[buttonElementSymbol] && isFunction(this[buttonElementSymbol].blur) ) { this[buttonElementSymbol].blur(); } } } /** * @private * @param mode */ function initEventHandlerByMode(mode) { switch (mode) { case "manual": this[buttonElementSymbol].setOption("actions.click", (e) => { const callback = this.getOption("actions.click"); if (isFunction(callback)) { callback(e); } }); break; case "submit": this[buttonElementSymbol].setOption("actions.click", (e) => { const form = this.form; if (form instanceof HTMLFormElement) { form.requestSubmit(); } }); break; } } function clearAutoHideTimer() { if (this[autoHideTimerSymbol] !== undefined) { clearTimeout(this[autoHideTimerSymbol]); this[autoHideTimerSymbol] = undefined; } } /** * @private * @return {Select} */ function initControlReferences() { this[buttonElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=button]`, ); this[popperElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=popper]`, ); this[messageElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=message]`, ); } /** * @private */ function initDisabledSync() { const self = this; const attachInnerObserver = (button) => { if (!button || !isFunction(button.attachObserver)) { return; } const existing = self[innerDisabledObserverSymbol]; if (existing?.button === button) { return; } if ( existing?.button && isFunction(existing.button.detachObserver) && existing.observer ) { existing.button.detachObserver(existing.observer); } const observer = new Observer(syncDisabled); button.attachObserver(observer); self[innerDisabledObserverSymbol] = { button, observer }; }; const syncDisabled = () => { const disabled = self.getOption("disabled", false); const button = self.shadowRoot?.querySelector(`[${ATTRIBUTE_ROLE}=button]`) ?? self[buttonElementSymbol]; if (!button) { return; } self[buttonElementSymbol] = button; if (disabled) { button.setAttribute(ATTRIBUTE_DISABLED, ""); } else { button.removeAttribute(ATTRIBUTE_DISABLED); } if (isFunction(button.setOption)) { button.setOption("disabled", disabled); } attachInnerObserver(button); }; syncDisabled(); const existingObserver = self[attributeObserverSymbol]?.[ATTRIBUTE_DISABLED]; if (existingObserver) { self[attributeObserverSymbol][ATTRIBUTE_DISABLED] = () => { existingObserver.call(self); syncDisabled(); }; } self.attachObserver(new Observer(syncDisabled)); if (typeof customElements?.whenDefined === "function") { customElements.whenDefined("monster-state-button").then(() => { syncDisabled(); }); } } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <div data-monster-role="control" part="control"> <monster-state-button exportparts="button:button-button,control:button-control" data-monster-attributes=" data-monster-option-classes-button path:classes.button, data-monster-option-aria-role path:aria.role, data-monster-option-aria-label path:aria.label, disabled path:disabled | if:true" part="button" name="button" data-monster-role="button"> <span data-monster-replace="path:labels.button"></span> </monster-state-button> <div data-monster-role="popper" part="popper" tabindex="-1" class="monster-color-primary-1"> <div data-monster-role="arrow"></div> <div data-monster-role="message" part="message" class="flex" data-monster-patch="path:message.content"></div> </div> </div> </div> `; } /** * @private * @param {string|HTMLElement} content * @return {HTMLElement|string|null} */ function getMeasurementContent(content) { if (isString(content)) { return content; } if (content instanceof HTMLElement) { return content.cloneNode(true); } return null; } /** * @private * @return {{popper: HTMLElement, message: HTMLElement}|null} */ function ensureMeasurementPopper() { if (this[measurementPopperSymbol]) { return this[measurementPopperSymbol]; } if (!this.shadowRoot) { return null; } const popper = document.createElement("div"); popper.setAttribute(ATTRIBUTE_ROLE, "popper"); popper.setAttribute("data-measurement", "true"); popper.setAttribute("aria-hidden", "true"); popper.style.position = "absolute"; popper.style.left = "-10000px"; popper.style.top = "-10000px"; popper.style.visibility = "hidden"; popper.style.display = "block"; popper.style.pointerEvents = "none"; popper.style.maxWidth = "none"; popper.style.width = "max-content"; if (this[popperElementSymbol]?.className) { popper.className = this[popperElementSymbol].className; } const message = document.createElement("div"); message.setAttribute(ATTRIBUTE_ROLE, "message"); message.className = "flex"; popper.appendChild(message); this.shadowRoot.appendChild(popper); this[measurementPopperSymbol] = { popper, message }; return this[measurementPopperSymbol]; } /** * @private */ function applyMeasuredMessageWidth() { const popper = this[popperElementSymbol]; if (!popper) { return; } const content = this.getOption("message.content"); const measureContent = getMeasurementContent(content); if (!measureContent) { return; } const measurement = ensureMeasurementPopper.call(this); if (!measurement?.message) { return; } if (popper.className && measurement.popper.className !== popper.className) { measurement.popper.className = popper.className; } measurement.message.innerHTML = ""; if (isString(measureContent)) { measurement.message.innerHTML = measureContent; } else { measurement.message.appendChild(measureContent); } const measuredWidth = Math.ceil( measurement.popper.getBoundingClientRect().width, ); const fontSize = parseFloat(getComputedStyle(popper).fontSize) || 16; const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; const widthOptions = this.getOption("message.width", {}); const minWidthOption = resolveLength( widthOptions?.min, fontSize, rootFontSize, window.innerWidth, ); const maxWidthOption = resolveLength( widthOptions?.max, fontSize, rootFontSize, window.innerWidth, ); const viewportRatio = typeof widthOptions?.viewportRatio === "number" && widthOptions.viewportRatio > 0 && widthOptions.viewportRatio <= 1 ? widthOptions.viewportRatio : 0.7; const minWidth = Math.max(0, minWidthOption ?? Math.round(fontSize * 12)); const maxViewportWidth = Math.max( minWidth, window.innerWidth * viewportRatio, ); const maxWidth = Math.max( minWidth, Math.min(maxWidthOption ?? fontSize * 32, maxViewportWidth), ); const targetWidth = Math.max( minWidth, Math.min(measuredWidth || minWidth, maxWidth), ); popper.style.width = `${targetWidth}px`; popper.style.minWidth = `${minWidth}px`; popper.style.maxWidth = `${maxWidth}px`; popper.style.whiteSpace = "normal"; popper.style.overflowWrap = "anywhere"; } /** * @private * @param {unknown} value * @param {number} fontSize * @param {number} rootFontSize * @param {number} viewportWidth * @return {number|null} */ function resolveLength(value, fontSize, rootFontSize, viewportWidth) { if (typeof value === "number" && Number.isFinite(value)) { return value; } if (!isString(value)) { return null; } const trimmed = value.trim(); const number = parseFloat(trimmed); if (!Number.isFinite(number)) { return null; } if (trimmed.endsWith("rem")) { return number * rootFontSize; } if (trimmed.endsWith("em")) { return number * fontSize; } if (trimmed.endsWith("vw")) { return (number / 100) * viewportWidth; } if (trimmed.endsWith("px")) { return number; } return number; } registerCustomElement(MessageStateButton);