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