@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
754 lines (651 loc) • 17.8 kB
JavaScript
/**
* 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 { Pathfinder } from "../../data/pathfinder.mjs";
import {
addAttributeToken,
removeAttributeToken,
} from "../../dom/attributes.mjs";
import {
ATTRIBUTE_ERRORMESSAGE,
ATTRIBUTE_ROLE,
} from "../../dom/constants.mjs";
import {
assembleMethodSymbol,
getSlottedElements,
registerCustomElement,
} from "../../dom/customelement.mjs";
import {
CustomElement,
attributeObserverSymbol,
} from "../../dom/customelement.mjs";
import { findTargetElementFromEvent } from "../../dom/events.mjs";
import { getDocument } from "../../dom/util.mjs";
import { getGlobal } from "../../types/global.mjs";
import { ID } from "../../types/id.mjs";
import { DeadMansSwitch } from "../../util/deadmansswitch.mjs";
import { Processing } from "../../util/processing.mjs";
import { STYLE_DISPLAY_MODE_BLOCK } from "./constants.mjs";
import { ButtonBarStyleSheet } from "./stylesheet/button-bar.mjs";
import { positionPopper } from "./util/floating-ui.mjs";
import { convertToPixels } from "../../dom/dimension.mjs";
import { addErrorAttribute } from "../../dom/error.mjs";
export { ButtonBar };
/**
* @private
* @type {symbol}
*/
const timerCallbackSymbol = Symbol("timerCallback");
/**
* local symbol
* @private
* @type {symbol}
*/
const resizeObserverSymbol = Symbol("windowResizeObserver");
/**
* @private
* @type {symbol}
*/
const dimensionsSymbol = Symbol("dimensions");
/**
* @private
* @type {symbol}
*/
const controlElementSymbol = Symbol("controlElement");
/**
* @private
* @type {symbol}
*/
const buttonBarSlotElementSymbol = Symbol("buttonBarSlotElement");
/**
* @private
* @type {symbol}
*/
const popperSlotElementSymbol = Symbol("popperSlotElement");
/**
* @private
* @type {symbol}
*/
const buttonBarElementSymbol = Symbol("buttonBarElement");
/**
* @private
* @type {symbol}
*/
const popperElementSymbol = Symbol("popperElement");
/**
* local symbol
* @private
* @type {symbol}
*/
const closeEventHandler = Symbol("closeEventHandler");
/**
* @private
* @type {symbol}
*/
const popperSwitchEventHandler = Symbol("popperSwitchEventHandler");
/**
* @private
* @type {symbol}
*/
const popperNavElementSymbol = Symbol("popperNavElement");
/**
* @private
* @type {symbol}
*/
const switchElementSymbol = Symbol("switchElement");
/**
* @private
* @type {string}
*/
const ATTRIBUTE_POPPER_POSITION = "data-monster-popper-position";
/**
* A button bar control.
*
* @fragments /fragments/components/form/button-bar/
*
* @example /examples/components/form/button-bar-simple Button bar
*
* @copyright schukai GmbH
* @summary This is a button bar control that can be used to display a set of buttons.
* @fires monster-fetched
*/
class ButtonBar extends CustomElement {
/**
* This method is called by the `instanceof` operator.
* @return {symbol}
*/
static get [instanceSymbol]() {
return Symbol.for("@schukai/monster/components/form/button-bar@@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 Template definitions
* @property {string} templates.main Main template
* @property {Object} labels
* @property {Object} popper FloatingUI popper configuration
* @property {string} popper.placement=top Placement of the popper
* @property {Array<string>} popper.middleware Middleware for the popper
*/
get defaults() {
const obj = Object.assign({}, super.defaults, {
templates: {
main: getTemplate(),
},
labels: {},
popper: {
placement: "left",
middleware: ["autoPlacement", "shift", "offset:5"],
},
});
initDefaultsFromAttributes.call(this, obj);
return obj;
}
/**
* This method is called internal and should not be called directly.
*/
[assembleMethodSymbol]() {
super[assembleMethodSymbol]();
this[dimensionsSymbol] = new Pathfinder({ data: {} });
initControlReferences.call(this);
initEventHandler.call(this);
// setup structure
initButtonBar.call(this).then(() => {
initPopperSwitch.call(this);
});
}
/**
* This method is called internal and should not be called directly.
*
* @return {CSSStyleSheet[]}
*/
static getCSSStyleSheet() {
return [ButtonBarStyleSheet];
}
/**
* This method is called internal and should not be called directly.
*
* @return {string}
*/
static getTag() {
return "monster-button-bar";
}
/**
* This method is called by the dom and should not be called directly.
*
* @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]);
}
setTimeout(() => {
updatePopper.call(this);
updateResizeObserverObservation.call(this);
}, 0);
}
/**
* This method determines which attributes are to be monitored by `attributeChangedCallback()`.
*
* @return {string[]}
*/
static get observedAttributes() {
const attributes = super.observedAttributes;
attributes.push(ATTRIBUTE_POPPER_POSITION);
return attributes;
}
/**
* This method is called by the dom and should not be called directly.
*
* @return {void}
*/
disconnectedCallback() {
super.disconnectedCallback();
const document = getDocument();
// close on outside ui-events
for (const [, type] of Object.entries(["click", "touch"])) {
document.removeEventListener(type, this[closeEventHandler]);
}
disconnectResizeObserver.call(this);
}
/**
* Close the slotted dialog.
* @return {ButtonBar}
*/
hideDialog() {
hide.call(this);
return this;
}
/**
* Open the slotted dialog.
* @return {ButtonBar}
*/
showDialog() {
show.call(this);
return this;
}
/**
* Toggle the slotted dialog.
* @return {ButtonBar}
*/
toggleDialog() {
toggle.call(this);
return this;
}
}
/**
* @private
* @param obj
* @return {*}
*/
function initDefaultsFromAttributes(obj) {
if (this.hasAttribute(ATTRIBUTE_POPPER_POSITION)) {
obj.popper.placement = this.getAttribute(ATTRIBUTE_POPPER_POSITION);
}
return obj;
}
/**
* @private
*/
function initEventHandler() {
const self = this;
/**
* @param {Event} event
*/
self[closeEventHandler] = (event) => {
const path = event.composedPath();
for (const [, element] of Object.entries(path)) {
if (element === self) {
return;
}
}
hide.call(self);
};
if (self[buttonBarSlotElementSymbol]) {
self[buttonBarSlotElementSymbol].addEventListener("slotchange", (event) => {
checkAndRearrangeButtons.call(self);
});
}
if (self[popperElementSymbol]) {
self[popperElementSymbol].addEventListener("slotchange", (event) => {
checkAndRearrangeButtons.call(self);
});
}
// data-monster-options
self[attributeObserverSymbol][ATTRIBUTE_POPPER_POSITION] = function (value) {
self.setOption("classes.button", value);
};
self[resizeObserverSymbol] = new ResizeObserver((entries) => {
if (self[timerCallbackSymbol] instanceof DeadMansSwitch) {
try {
self[timerCallbackSymbol].touch();
return;
} catch (e) {
// catch Error("has already run");
if (e.message !== "has already run") {
throw e;
}
delete self[timerCallbackSymbol];
}
}
self[timerCallbackSymbol] = new DeadMansSwitch(200, () => {
requestAnimationFrame(() => {
updatePopper.call(self);
self[dimensionsSymbol].setVia("data.calculated", false);
try {
checkAndRearrangeButtons.call(self);
} catch (error) {
addErrorAttribute(
this,
error?.message || "An error occurred while rearranging the buttons",
);
}
});
});
});
initSlotChangedHandler.call(self);
}
function initSlotChangedHandler() {
this[buttonBarElementSymbol].addEventListener("slotchange", () => {
updateResizeObserverObservation.call(this);
});
}
function checkAndRearrangeButtons() {
if (this[dimensionsSymbol].getVia("data.calculated", false) !== true) {
calculateButtonBarDimensions.call(this);
}
rearrangeButtons.call(this);
}
/**
* @private
* @return {Object}
*/
function rearrangeButtons() {
let sum = this[switchElementSymbol].offsetWidth;
const space = this[dimensionsSymbol].getVia("data.space");
const buttonReferences = this[dimensionsSymbol].getVia(
"data.buttonReferences",
);
for (const ref of buttonReferences) {
sum += this[dimensionsSymbol].getVia(`data.button.${ref}`);
let elements = getSlottedElements.call(
this,
'[data-monster-reference="' + ref + '"]',
null,
); // null ↦ o
if (elements.size === 0) {
elements = getSlottedElements.call(
this,
'[data-monster-reference="' + ref + '"]',
"popper",
); // null ↦ o
}
const nextValue = elements.values().next();
if (!nextValue) {
continue;
}
const element = nextValue?.value;
if (!(element instanceof HTMLElement)) {
continue;
}
if (sum > space) {
element.setAttribute("slot", "popper");
} else {
element.removeAttribute("slot");
}
}
const inVisibleButtons = getSlottedElements.call(this, ":scope", "popper"); // null ↦ o
if (inVisibleButtons.size > 0) {
this[switchElementSymbol].classList.remove("hidden");
} else {
this[switchElementSymbol].classList.add("hidden");
setTimeout(() => {
hide.call(this);
}, 1);
}
}
/**
* @private
* @param {HTMLElement} node
* @return {number}
*/
function calcBoxWidth(node) {
const dim = getGlobal()?.getComputedStyle(node);
if (dim === null) {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, "no computed style");
throw new Error("no computed style");
}
const bounding = node.getBoundingClientRect();
return (
convertToPixels(dim["border-left-width"]) +
convertToPixels(dim["padding-left"]) +
convertToPixels(dim["margin-left"]) +
bounding["width"] +
convertToPixels(dim["border-right-width"]) +
convertToPixels(dim["margin-right"]) +
convertToPixels(dim["padding-left"])
);
}
/**
* @private
* @return {Object}
*/
function calculateButtonBarDimensions() {
const computedStyle = getComputedStyle(this.parentElement);
if (computedStyle === null) {
throw new Error("no computed style");
}
let width = this.parentElement.clientWidth;
if (computedStyle.getPropertyValue("box-sizing") !== "border-box") {
width = computedStyle.getPropertyValue("width");
let pixel = 0;
try {
pixel = convertToPixels(width);
} catch (e) {
addAttributeToken(
this,
ATTRIBUTE_ERRORMESSAGE,
e?.message || "An error occurred while calculating the dimensions",
);
}
this[dimensionsSymbol].setVia("data.space", pixel);
} else {
let borderWidth = getComputedStyle(this).getPropertyValue(
"--monster-border-width",
);
if (borderWidth === null || borderWidth === "") {
borderWidth = "0px";
}
let borderWidthWithoutUnit = 0;
try {
borderWidthWithoutUnit = convertToPixels(borderWidth);
} catch (e) {
addAttributeToken(
this,
ATTRIBUTE_ERRORMESSAGE,
e?.message || "An error occurred while calculating the dimensions",
);
}
// space to be allocated
this[dimensionsSymbol].setVia(
"data.space",
width - 2 * borderWidthWithoutUnit,
);
}
this[dimensionsSymbol].setVia("data.visible", !(width === 0));
const buttonReferences = [];
const visibleButtons = getSlottedElements.call(this, ":scope", null); // null ↦ o
for (const [, button] of visibleButtons.entries()) {
if (!button.hasAttribute("data-monster-reference")) {
button.setAttribute("data-monster-reference", new ID("vb").toString());
}
const ref = button.getAttribute("data-monster-reference");
if (ref === null) continue;
buttonReferences.push(ref);
this[dimensionsSymbol].setVia(
`data.button.${ref}`,
calcBoxWidth.call(this, button),
);
}
const invisibleButtons = getSlottedElements.call(this, ":scope", "popper"); // null ↦ o
for (const [, button] of invisibleButtons.entries()) {
if (!button.hasAttribute("data-monster-reference")) {
button.setAttribute("data-monster-reference", new ID("ib").toString());
}
const ref = button.getAttribute("data-monster-reference");
if (ref === null) continue;
if (ref.indexOf("ib") !== 0) {
buttonReferences.push(ref);
}
}
this[dimensionsSymbol].setVia("data.calculated", true);
this[dimensionsSymbol].setVia("data.buttonReferences", buttonReferences);
}
/**
* @private
*/
function updateResizeObserverObservation() {
this[resizeObserverSymbol].disconnect();
const slottedNodes = getSlottedElements.call(this);
slottedNodes.forEach((node) => {
this[resizeObserverSymbol].observe(node);
});
requestAnimationFrame(() => {
let parent = this.parentNode;
while (!(parent instanceof HTMLElement) && parent !== null) {
parent = parent.parentNode;
}
if (parent instanceof HTMLElement) {
this[resizeObserverSymbol].observe(parent);
}
});
}
/**
* @private
*/
function disconnectResizeObserver() {
if (this[resizeObserverSymbol] instanceof ResizeObserver) {
this[resizeObserverSymbol].disconnect();
}
}
/**
* @private
*/
function toggle() {
if (this[popperElementSymbol].style.display === STYLE_DISPLAY_MODE_BLOCK) {
hide.call(this);
} else {
show.call(this);
}
}
/**
* @private
*/
function hide() {
this[popperElementSymbol].style.display = "none";
removeAttributeToken(this[controlElementSymbol], "class", "open");
}
/**
* @private
* @this PopperButton
*/
function show() {
if (this.getOption("disabled", false) === true) {
return;
}
if (this[popperElementSymbol].style.display === STYLE_DISPLAY_MODE_BLOCK) {
return;
}
this[popperElementSymbol].style.visibility = "hidden";
this[popperElementSymbol].style.display = STYLE_DISPLAY_MODE_BLOCK;
addAttributeToken(this[controlElementSymbol], "class", "open");
updatePopper.call(this);
}
/**
* @private
*/
function updatePopper() {
if (this[popperElementSymbol].style.display !== STYLE_DISPLAY_MODE_BLOCK) {
return;
}
if (this.getOption("disabled", false) === true) {
return;
}
positionPopper.call(
this,
this[switchElementSymbol],
this[popperElementSymbol],
this.getOption("popper", {}),
);
}
/**
* @private
* @return {Select}
* @throws {Error} no shadow-root is defined
*/
function initControlReferences() {
if (!this.shadowRoot) {
throw new Error("no shadow-root is defined");
}
this[controlElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}=control]`,
);
this[buttonBarElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}=button-bar]`,
);
this[popperElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}=popper]`,
);
this[popperNavElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}=popper-nav]`,
);
this[switchElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}=switch]`,
);
this[buttonBarSlotElementSymbol] = null;
if (this[buttonBarElementSymbol])
this[buttonBarSlotElementSymbol] =
this[buttonBarElementSymbol].querySelector(`slot`);
this[popperSlotElementSymbol] = null;
if (this[popperElementSymbol])
this[popperSlotElementSymbol] =
this[popperElementSymbol].querySelector(`slot`);
}
/**
* @private
* @return {Promise<unknown>}
* @throws {Error} no shadow-root is defined
*
*/
function initButtonBar() {
if (!this.shadowRoot) {
throw new Error("no shadow-root is defined");
}
return new Processing(() => {
checkAndRearrangeButtons.call(this);
}).run();
}
/**
* @private
*/
function initPopperSwitch() {
/**
* @param {Event} event
*/
this[popperSwitchEventHandler] = (event) => {
const element = findTargetElementFromEvent(event, ATTRIBUTE_ROLE, "switch");
if (element instanceof HTMLButtonElement) {
toggle.call(this);
}
};
for (const type of ["click", "touch"]) {
this[switchElementSymbol].addEventListener(
type,
this[popperSwitchEventHandler],
);
}
}
/**
* @private
* @return {string}
*/
function getTemplate() {
// language=HTML
return `
<div data-monster-role="control" part="control">
<div data-monster-role="button-bar">
<slot></slot>
<div part="popper-nav" data-monster-role="popper-nav"
tabindex="-1">
<button part="popper-switch" data-monster-role="switch"
class="monster-button-outline-tertiary hidden">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
viewBox="0 0 16 16">
<path d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>
</svg>
</button>
</div>
</div>
<div data-monster-role="popper">
<slot name="popper"></slot>
</div>
</div>`;
}
registerCustomElement(ButtonBar);