@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
905 lines (796 loc) • 21 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 {
addAttributeToken,
containsAttributeToken,
removeAttributeToken,
} from "../../dom/attributes.mjs";
import { ATTRIBUTE_ROLE } from "../../dom/constants.mjs";
import {
assembleMethodSymbol,
attributeObserverSymbol,
CustomElement,
registerCustomElement,
} from "../../dom/customelement.mjs";
import { addErrorAttribute } from "../../dom/error.mjs";
import { fireCustomEvent } from "../../dom/events.mjs";
import {
findElementWithSelectorUpwards,
getDocument,
} from "../../dom/util.mjs";
import { DeadMansSwitch } from "../../util/deadmansswitch.mjs";
import {
closePositionedPopper,
isPositionedPopperOpen,
openPositionedPopper,
positionPopper,
} from "../form/util/floating-ui.mjs";
import { PopperStyleSheet } from "./stylesheet/popper.mjs";
import { isArray } from "../../types/is.mjs";
export { Popper };
/**
* Symbol for timer callback reference
* @private
* @type {symbol}
*/
const timerCallbackSymbol = Symbol("timerCallback");
/**
* Symbol for resize observer reference
* @private
* @type {symbol}
*/
const resizeObserverSymbol = Symbol("resizeObserver");
/**
* 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");
/**
* Symbol for popper element reference
* @private
* @type {symbol}
*/
const popperElementSymbol = Symbol("popperElement");
/**
* Symbol for content element reference
* @private
* @type {symbol}
*/
const contentElementSymbol = Symbol("contentElement");
/**
* Symbol for arrow element reference
* @private
* @type {symbol}
*/
const arrowElementSymbol = Symbol("arrowElement");
/**
* @private
* @type {symbol}
*/
const hostElementSymbol = Symbol("hostElement");
/**
* @private
* @type {symbol}
*/
const dismissRecordSymbol = Symbol("dismissRecord");
/**
* @private
* @type {symbol}
*/
const usesHostDismissSymbol = Symbol("usesHostDismiss");
const actionQueueSymbol = Symbol("actionQueue");
const pendingActionSymbol = Symbol("pendingAction");
/**
* 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/
*
* @example /examples/components/layout/popper-simple
* @example /examples/components/layout/popper-click
*
* @since 1.65.0
* @copyright Volker Schukai
* @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 {
/**
* 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 {string} popper.contentOverflow - Content clipping mode: both|horizontal|visible|smart
* @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",
engine: "floating",
middleware: ["flip", "shift", "offset:15", "arrow"],
contentOverflow: "both",
},
features: {
preventOpenEventSent: false,
},
});
}
/**
* Initialize the component
* Called on first connection to DOM
* @private
*/
[assembleMethodSymbol]() {
super[assembleMethodSymbol]();
initControlReferences.call(this);
initOverflowObserver.call(this);
applyContentOverflowMode.call(this);
initEventHandler.call(this);
}
/**
* @inheritdoc
*/
setOption(path, value) {
super.setOption(path, value);
if (path === "popper.contentOverflow") {
applyContentOverflowMode.call(this);
} else if (path === "content") {
queueMicrotask(() => {
applyContentOverflowMode.call(this);
});
}
return 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();
this[hostElementSymbol] = findElementWithSelectorUpwards(
this,
"monster-host",
);
this[usesHostDismissSymbol] =
this[hostElementSymbol] &&
typeof this[hostElementSymbol].registerDismissable === "function";
if (!this[usesHostDismissSymbol]) {
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();
if (!this[usesHostDismissSymbol]) {
const document = getDocument();
for (const [, type] of Object.entries(["click", "touch"])) {
document.removeEventListener(type, this[closeEventHandler]);
}
}
unregisterFromHost.call(this);
disconnectResizeObserver.call(this);
}
/**
* Shows the popper element
* @return {Popper} The popper instance
*/
showDialog() {
queuePopperAction.call(this, "show");
return this;
}
/**
* Recalculates the size and position of an already open popper.
*
* @return {Popper}
*/
recalcPopper() {
queuePopperAction.call(this, "update");
return this;
}
/**
* Resolves the effective popper options for the current render pass.
* Subclasses can override this to adapt positioning without mutating
* the persisted component options.
*
* @return {object}
*/
resolvePopperOptions() {
return Object.assign({}, this.getOption("popper", {}));
}
/**
* Resolves the effective content overflow mode for the rendered wrapper.
* Subclasses can override this when the configured option is only an intermediate mode.
* `smart` keeps regular content measurable inside the popper while nested overlays
* are allowed to escape horizontally without forcing the parent wrapper to size to them.
*
* @return {string}
*/
resolveContentOverflowMode() {
const configuredMode = this.getOption("popper.contentOverflow", "both");
if (configuredMode !== "smart") {
return configuredMode;
}
if (containsNestedOverlayContent(this.getOption("content"))) {
return "horizontal";
}
return "both";
}
/**
* Hides the popper element
* @return {Popper} The popper instance
*/
hideDialog() {
queuePopperAction.call(this, "hide");
return this;
}
/**
* Toggles popper visibility
* @return {Popper} The popper instance
*/
toggleDialog() {
queuePopperAction.call(this, "toggle");
return this;
}
}
/**
* Initializes event handlers for popper interactivity
* @private
* @return {Popper} The popper instance
*/
function initEventHandler() {
this[closeEventHandler] = (event) => {
if (
isEventInsidePopperOwner(
this,
event,
this[controlElementSymbol],
this[buttonElementSymbol],
this[popperElementSymbol],
)
) {
return;
}
if (
!isPositionedPopperOpen(this[popperElementSymbol]) &&
!containsAttributeToken(this[controlElementSymbol], "class", "open")
) {
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;
}
/**
* Serializes popper state changes so open/close/update events do not interleave.
* The latest requested action wins while a queue is already running.
*
* @private
* @param {"show"|"hide"|"toggle"|"update"} action
* @return {Promise<void>}
*/
function queuePopperAction(action) {
this[pendingActionSymbol] = action;
if (this[actionQueueSymbol] instanceof Promise) {
return this[actionQueueSymbol];
}
this[actionQueueSymbol] = (async () => {
while (this[pendingActionSymbol]) {
const nextAction = this[pendingActionSymbol];
delete this[pendingActionSymbol];
await Promise.resolve(runPopperAction.call(this, nextAction));
}
})()
.catch((e) => {
addErrorAttribute(this, e);
})
.finally(() => {
delete this[actionQueueSymbol];
if (this[pendingActionSymbol]) {
void queuePopperAction.call(this, this[pendingActionSymbol]);
}
});
return this[actionQueueSymbol];
}
function runPopperAction(action) {
switch (action) {
case "toggle":
if (isPositionedPopperOpen(this[popperElementSymbol])) {
return performHide.call(this);
}
return performShow.call(this);
case "hide":
return performHide.call(this);
case "show":
return performShow.call(this);
case "update":
return performUpdate.call(this);
default:
return undefined;
}
}
function isEventInsidePopperOwner(
owner,
event,
controlElement,
buttonElement,
popperElement,
) {
const path = event.composedPath?.() || [];
for (const element of path) {
if (
element === owner ||
element === controlElement ||
element === buttonElement ||
element === popperElement
) {
return true;
}
}
const target = path[0] || event.target;
if (!(target instanceof Node)) {
return false;
}
if (owner instanceof HTMLElement && owner.contains(target)) {
return true;
}
if (
owner?.shadowRoot instanceof ShadowRoot &&
owner.shadowRoot.contains(target)
) {
return true;
}
return false;
}
/**
* Sets up event handlers for specific interaction mode
* @private
* @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}`);
}
}
/**
* Sets up resize observer for popper repositioning
* @private
*/
function attachResizeObserver() {
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();
}
}
/**
* Hides the popper element
* @private
*/
function hide() {
void queuePopperAction.call(this, "hide");
}
/**
* Hides the popper element
* @private
*/
function performHide() {
const self = this;
const popperElement = self[popperElementSymbol];
const controlElement = self[controlElementSymbol];
if (!self.isConnected) {
unregisterFromHost.call(self);
return;
}
fireCustomEvent(self, "monster-popper-hide", {
self,
});
if (popperElement instanceof HTMLElement) {
closePositionedPopper(popperElement);
}
if (controlElement instanceof HTMLElement) {
removeAttributeToken(controlElement, "class", "open");
}
unregisterFromHost.call(self);
setTimeout(() => {
fireCustomEvent(self, "monster-popper-hidden", {
self,
});
}, 0);
}
/**
* Shows the popper element
* @private
*/
function show() {
void queuePopperAction.call(this, "show");
}
/**
* Shows the popper element
* @private
*/
function performShow() {
const self = this;
const popperElement = self[popperElementSymbol];
const controlElement = self[controlElementSymbol];
if (self.getOption("disabled", false) === true) {
return;
}
if (
!self.isConnected ||
!(popperElement instanceof HTMLElement) ||
!(controlElement instanceof HTMLElement)
) {
return;
}
if (isPositionedPopperOpen(popperElement)) {
return;
}
fireCustomEvent(self, "monster-popper-open", {
self,
});
applyContentOverflowMode.call(self);
popperElement.style.visibility = "hidden";
openPositionedPopper(
self[controlElementSymbol],
popperElement,
self.resolvePopperOptions(),
);
addAttributeToken(controlElement, "class", "open");
registerWithHost.call(self);
return performUpdate.call(self).then(() => {
setTimeout(() => {
fireCustomEvent(self, "monster-popper-opened", {
self,
});
}, 0);
});
}
/**
* Updates popper positioning
* @private
*/
function updatePopper() {
void queuePopperAction.call(this, "update");
}
/**
* Updates popper positioning
* @private
*/
function performUpdate() {
if (
!this.isConnected ||
!(this[controlElementSymbol] instanceof HTMLElement) ||
!(this[popperElementSymbol] instanceof HTMLElement)
) {
return;
}
if (!isPositionedPopperOpen(this[popperElementSymbol])) {
return;
}
if (this.getOption("disabled", false) === true) {
return;
}
applyContentOverflowMode.call(this);
return positionPopper.call(
this,
this[controlElementSymbol],
this[popperElementSymbol],
this.resolvePopperOptions(),
);
}
/**
* @private
*/
function registerWithHost() {
if (!this[usesHostDismissSymbol]) {
return;
}
if (!(this[hostElementSymbol] instanceof HTMLElement)) {
return;
}
const record = this[hostElementSymbol].registerDismissable?.({
element: this,
owner: this,
close: () => {
this.hideDialog();
},
priority: 10,
options: {
dismissOnOutside: true,
},
});
if (record) {
this[dismissRecordSymbol] = record;
}
}
/**
* @private
*/
function unregisterFromHost() {
if (!this[usesHostDismissSymbol]) {
return;
}
if (!(this[hostElementSymbol] instanceof HTMLElement)) {
return;
}
if (this[dismissRecordSymbol]) {
this[hostElementSymbol].unregisterDismissable?.(this[dismissRecordSymbol]);
this[dismissRecordSymbol] = null;
return;
}
this[hostElementSymbol].unregisterDismissable?.(this);
}
/**
* Initializes references to DOM elements
* @private
* @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]`,
);
this[contentElementSymbol] =
this.shadowRoot.querySelector(`[part="content"]`);
return this;
}
/**
* Keeps the rendered content wrapper in sync with the configured overflow mode
* @private
* @return {void}
*/
function initOverflowObserver() {
this[attributeObserverSymbol]["data-monster-option-popper-content-overflow"] =
() => {
applyContentOverflowMode.call(this);
};
this[attributeObserverSymbol]["data-monster-option-content"] = () => {
applyContentOverflowMode.call(this);
};
}
/**
* Applies the current content overflow mode to the rendered wrapper element
* @private
* @return {void}
*/
function applyContentOverflowMode() {
const contentElement = this[contentElementSymbol];
if (!(contentElement instanceof HTMLElement)) {
return;
}
const overflowMode = this.resolveContentOverflowMode();
contentElement.setAttribute("data-monster-overflow-mode", overflowMode);
switch (overflowMode) {
case "horizontal":
contentElement.style.overflow = "visible";
contentElement.style.removeProperty("max-height");
contentElement.style.removeProperty("max-width");
break;
case "both":
case "visible":
contentElement.style.overflow = "visible";
contentElement.style.maxHeight = "none";
contentElement.style.maxWidth = "none";
break;
default:
contentElement.style.removeProperty("overflow");
contentElement.style.removeProperty("max-height");
contentElement.style.removeProperty("max-width");
break;
}
}
/**
* Gets the main template HTML
* @private
* @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>
`;
}
function containsNestedOverlayContent(content) {
const selector = [
"monster-details",
"monster-message-state-button",
"monster-popper",
"monster-popper-button",
"monster-select",
"details",
].join(",");
if (typeof content === "string") {
const container = document.createElement("div");
container.innerHTML = content;
return container.querySelector(selector) instanceof HTMLElement;
}
if (!(content instanceof HTMLElement)) {
return false;
}
if (content.matches(selector)) {
return true;
}
return content.querySelector(selector) instanceof HTMLElement;
}
registerCustomElement(Popper);