@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
987 lines (856 loc) • 23 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 { 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 { Observer } from "../../types/observer.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}
*/
/**
* 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 mutationObserverSymbol = Symbol("mutationObserver");
/**
* @private
* @type {symbol}
*/
const switchElementSymbol = Symbol("switchElement");
/**
* @private
* @type {symbol}
*/
const layoutStateSymbol = Symbol("layoutState");
/**
* @private
* @type {string}
*/
const ATTRIBUTE_POPPER_POSITION = "data-monster-popper-position";
/**
* @private
* @type {string}
*/
const ATTRIBUTE_LAYOUT_ALIGNMENT = "data-monster-layout-alignment";
/**
* A button bar control.
*
* @fragments /fragments/components/form/button-bar/
*
* @example /examples/components/form/button-bar-simple Button bar
*
* @copyright Volker Schukai
* @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: {},
layout: {
alignment: "left",
},
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: {} });
this[layoutStateSymbol] = {
scheduled: false,
needsMeasure: true,
needsLayout: true,
needsObserve: true,
suppressSlotChange: false,
};
initControlReferences.call(this);
initEventHandler.call(this);
// setup structure
initButtonBar.call(this);
initPopperSwitch.call(this);
applyLayoutAlignment.call(this);
this.attachObserver(
new Observer(() => {
applyLayoutAlignment.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]);
}
scheduleLayout.call(this, { measure: true, layout: true, observe: true });
}
/**
* 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);
if (this[mutationObserverSymbol]) {
this[mutationObserverSymbol].disconnect();
}
}
/**
* 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
* @param {HTMLElement} element
* @return {boolean}
*/
function isElementTrulyVisible(element) {
if (!(element instanceof HTMLElement)) {
return false;
}
const computedStyle = getComputedStyle(element);
return (
computedStyle.display !== "none" &&
computedStyle.visibility !== "hidden" &&
computedStyle.opacity !== "0" &&
element.offsetWidth > 0 &&
element.offsetHeight > 0
);
}
/**
* @private
*/
function initEventHandler() {
const self = this;
const mutationCallback = (mutationList) => {
let needsRecalc = false;
for (const mutation of mutationList) {
if (mutation.type === "attributes") {
const target = mutation.target;
if (target instanceof HTMLElement) {
const ref = target.getAttribute("data-monster-reference");
if (ref && !isElementTrulyVisible(target)) {
self[dimensionsSymbol].setVia(`data.button.${ref}`, 0);
needsRecalc = true;
}
}
}
}
if (needsRecalc) {
scheduleLayout.call(self, { measure: true, layout: true });
}
};
/**
* @param {Event} event
*/
self[closeEventHandler] = (event) => {
const path = event.composedPath();
for (const element of path) {
if (element === self) return;
}
hide.call(self);
};
if (self[buttonBarSlotElementSymbol]) {
self[buttonBarSlotElementSymbol].addEventListener("slotchange", () => {
if (self[layoutStateSymbol]?.suppressSlotChange) {
return;
}
scheduleLayout.call(self, {
measure: true,
layout: true,
observe: true,
});
});
}
if (self[popperElementSymbol]) {
self[popperElementSymbol].addEventListener("slotchange", () => {
if (self[layoutStateSymbol]?.suppressSlotChange) {
return;
}
scheduleLayout.call(self, {
measure: true,
layout: true,
observe: true,
});
});
}
self[attributeObserverSymbol][ATTRIBUTE_POPPER_POSITION] = function (value) {
self.setOption("classes.button", value);
};
self[resizeObserverSymbol] = new ResizeObserver(() => {
scheduleLayout.call(self, { measure: true, layout: true });
});
self[mutationObserverSymbol] = new MutationObserver(mutationCallback);
initSlotChangedHandler.call(self);
}
function initSlotChangedHandler() {
this[buttonBarElementSymbol].addEventListener("slotchange", () => {
if (this[layoutStateSymbol]?.suppressSlotChange) {
return;
}
scheduleLayout.call(this, { observe: true });
});
}
function scheduleLayout(options = {}) {
if (!this[layoutStateSymbol]) {
return;
}
const state = this[layoutStateSymbol];
state.needsMeasure = state.needsMeasure || options.measure === true;
state.needsLayout = state.needsLayout || options.layout === true;
state.needsObserve = state.needsObserve || options.observe === true;
if (state.scheduled) {
return;
}
state.scheduled = true;
requestAnimationFrame(() => {
runLayout.call(this);
});
}
function runLayout() {
const state = this[layoutStateSymbol];
if (!state) {
return;
}
state.scheduled = false;
if (!this.isConnected) {
return;
}
if (state.needsObserve) {
updateResizeObserverObservation.call(this);
state.needsObserve = false;
}
if (state.needsMeasure) {
try {
calculateButtonBarDimensions.call(this);
} catch (error) {
addErrorAttribute(
this,
error?.message || "An error occurred while calculating dimensions",
);
}
state.needsMeasure = false;
}
if (state.needsLayout) {
try {
rearrangeButtons.call(this);
} catch (error) {
addErrorAttribute(
this,
error?.message || "An error occurred while rearranging the buttons",
);
}
state.needsLayout = false;
}
updatePopper.call(this);
}
/**
* @private
* @return {Object}
*/
function rearrangeButtons() {
const state = this[layoutStateSymbol];
let space = 0;
try {
space = this[dimensionsSymbol].getVia("data.space");
} catch {}
const buttonReferences = this[dimensionsSymbol].getVia(
"data.buttonReferences",
[],
);
const hasButtons = buttonReferences.length > 0;
const buttonEntries = [];
for (const ref of buttonReferences) {
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;
}
let buttonWidth = 0;
try {
buttonWidth = this[dimensionsSymbol].getVia(`data.button.${ref}`);
} catch (e) {
// If the path does not exist, pathfinder throws an error.
// In this case, we assume the width is 0.
// This can happen for buttons that have never been visible.
}
const style = getComputedStyle(element);
buttonEntries.push({
element,
width: buttonWidth,
hidden: style.display === "none",
});
}
const switchWidth = this[dimensionsSymbol].getVia("data.switchWidth") || 2;
const layoutButtons = (availableSpace) => {
if (availableSpace < 0) {
availableSpace = 0;
}
let sum = 0;
const visibleButtonsInMainSlot = [];
const buttonsToMoveToPopper = [];
for (const entry of buttonEntries) {
if (entry.hidden) {
if (entry.width > 0) {
buttonsToMoveToPopper.push(entry.element);
} else {
visibleButtonsInMainSlot.push(entry.element);
}
continue;
}
if (sum + entry.width > availableSpace) {
buttonsToMoveToPopper.push(entry.element);
} else {
sum += entry.width;
visibleButtonsInMainSlot.push(entry.element);
}
}
return { visibleButtonsInMainSlot, buttonsToMoveToPopper };
};
let layout = layoutButtons(space);
if (layout.buttonsToMoveToPopper.length > 0) {
layout = layoutButtons(space - switchWidth);
}
const shouldShowSwitch =
layout.buttonsToMoveToPopper.length > 0 && hasButtons;
if (state) {
state.suppressSlotChange = true;
}
for (const button of layout.buttonsToMoveToPopper) {
button.setAttribute("slot", "popper");
}
for (const button of layout.visibleButtonsInMainSlot) {
button.removeAttribute("slot");
}
if (state) {
state.suppressSlotChange = false;
}
if (shouldShowSwitch) {
this[switchElementSymbol].removeAttribute("hidden");
this[switchElementSymbol].classList.remove("hidden");
} else {
this[switchElementSymbol].setAttribute("hidden", "");
this[switchElementSymbol].classList.add("hidden");
hide.call(this);
}
}
/**
* @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() {
if (!(this.parentElement instanceof HTMLElement)) {
this[dimensionsSymbol].setVia("data.space", 0);
this[dimensionsSymbol].setVia("data.visible", false);
this[dimensionsSymbol].setVia("data.calculated", true);
this[dimensionsSymbol].setVia("data.buttonReferences", []);
return;
}
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) {
addErrorAttribute(
this,
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) {
addErrorAttribute(
this,
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 = [];
// Get all buttons, regardless of their current slot
const allButtons = Array.from(getSlottedElements.call(this, ":scope", null));
const popperButtons = Array.from(
getSlottedElements.call(this, ":scope", "popper"),
);
const combinedButtons = [...allButtons, ...popperButtons].filter(
(button, index, self) => {
// Filter out duplicates based on data-monster-reference if present, or element itself
return (
self.findIndex(
(b) =>
b.dataset.monsterReference === button.dataset.monsterReference ||
b === button,
) === index
);
},
);
for (const button of combinedButtons) {
if (!(button instanceof HTMLElement)) {
continue;
}
if (!button.hasAttribute("data-monster-reference")) {
button.setAttribute("data-monster-reference", new ID("btn").toString());
}
const ref = button.getAttribute("data-monster-reference");
if (ref === null) continue;
buttonReferences.push(ref);
// Only calculate width for visible buttons. Assume invisible ones
// (e.g. in popper) have their width calculated previously and stored.
if (isElementTrulyVisible(button)) {
this[dimensionsSymbol].setVia(
`data.button.${ref}`,
calcBoxWidth.call(this, button),
);
}
}
if (this[switchElementSymbol]) {
this[dimensionsSymbol].setVia(
"data.switchWidth",
this[switchElementSymbol].offsetWidth,
);
}
this[dimensionsSymbol].setVia("data.calculated", true);
this[dimensionsSymbol].setVia("data.buttonReferences", buttonReferences);
}
/**
* @private
*/
function updateResizeObserverObservation() {
this[resizeObserverSymbol].disconnect();
if (this[mutationObserverSymbol]) {
this[mutationObserverSymbol].disconnect();
}
const slottedNodes = getSlottedElements.call(this);
slottedNodes.forEach((node) => {
this[resizeObserverSymbol].observe(node);
if (this[mutationObserverSymbol]) {
this[mutationObserverSymbol].observe(node, {
attributes: true,
attributeFilter: ["style", "class"],
});
}
});
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
*/
function applyLayoutAlignment() {
if (!(this[buttonBarElementSymbol] instanceof HTMLElement)) {
return;
}
const alignment = this.getOption("layout.alignment", "left");
if (alignment === "right") {
this[buttonBarElementSymbol].setAttribute(
ATTRIBUTE_LAYOUT_ALIGNMENT,
"right",
);
return;
}
this[buttonBarElementSymbol].setAttribute(ATTRIBUTE_LAYOUT_ALIGNMENT, "left");
}
/**
* @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");
}
scheduleLayout.call(this, { measure: true, layout: true, observe: true });
}
/**
* @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" 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);