@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
496 lines (437 loc) • 12 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 {
addAttributeToken,
removeAttributeToken,
} from "../../dom/attributes.mjs";
import { ATTRIBUTE_ROLE } from "../../dom/constants.mjs";
import {
assembleMethodSymbol,
CustomElement,
registerCustomElement,
} from "../../dom/customelement.mjs";
import { fireCustomEvent } from "../../dom/events.mjs";
import { getDocument } from "../../dom/util.mjs";
import { DeadMansSwitch } from "../../util/deadmansswitch.mjs";
import { STYLE_DISPLAY_MODE_BLOCK } from "../form/constants.mjs";
import { 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 arrow element reference
* @private
* @type {symbol}
*/
const arrowElementSymbol = Symbol("arrowElement");
/**
* 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 schukai GmbH
* @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 {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",
middleware: ["autoPlacement", "shift", "offset:15", "arrow"],
},
features: {
preventOpenEventSent: false,
},
});
}
/**
* Initialize the component
* Called on first connection to DOM
* @private
*/
[assembleMethodSymbol]() {
super[assembleMethodSymbol]();
initControlReferences.call(this);
initEventHandler.call(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();
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();
for (const [, type] of Object.entries(["click", "touch"])) {
document.removeEventListener(type, this[closeEventHandler]);
}
disconnectResizeObserver.call(this);
}
/**
* Shows the popper element
* @return {Popper} The popper instance
*/
showDialog() {
show.call(this);
return this;
}
/**
* Hides the popper element
* @return {Popper} The popper instance
*/
hideDialog() {
hide.call(this);
return this;
}
/**
* Toggles popper visibility
* @return {Popper} The popper instance
*/
toggleDialog() {
if (this[popperElementSymbol].style.display === STYLE_DISPLAY_MODE_BLOCK) {
this.hideDialog();
} else {
this.showDialog();
}
return this;
}
}
/**
* Initializes event handlers for popper interactivity
* @private
* @return {Popper} The popper instance
*/
function initEventHandler() {
this[closeEventHandler] = (event) => {
const path = event.composedPath();
for (const [, element] of Object.entries(path)) {
if (element === this) {
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;
}
/**
* 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() {
const self = this;
fireCustomEvent(self, "monster-popper-hide", {
self,
});
self[popperElementSymbol].style.display = "none";
removeAttributeToken(self[controlElementSymbol], "class", "open");
setTimeout(() => {
fireCustomEvent(self, "monster-popper-hidden", {
self,
});
}, 0);
}
/**
* Shows the popper element
* @private
*/
function show() {
const self = this;
if (self.getOption("disabled", false) === true) {
return;
}
if (self[popperElementSymbol].style.display === STYLE_DISPLAY_MODE_BLOCK) {
return;
}
fireCustomEvent(self, "monster-popper-open", {
self,
});
self[popperElementSymbol].style.visibility = "hidden";
self[popperElementSymbol].style.display = STYLE_DISPLAY_MODE_BLOCK;
addAttributeToken(self[controlElementSymbol], "class", "open");
updatePopper.call(self);
setTimeout(() => {
fireCustomEvent(self, "monster-popper-opened", {
self,
});
}, 0);
}
/**
* Updates popper positioning
* @private
*/
function updatePopper() {
if (this[popperElementSymbol].style.display !== STYLE_DISPLAY_MODE_BLOCK) {
return;
}
if (this.getOption("disabled", false) === true) {
return;
}
positionPopper.call(
this,
this[controlElementSymbol],
this[popperElementSymbol],
this.getOption("popper", {}),
);
}
/**
* 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]`,
);
return this;
}
/**
* 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>
`;
}
registerCustomElement(Popper);