@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
368 lines (321 loc) • 7.54 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 {
arrow,
autoPlacement,
autoUpdate,
computePosition,
flip,
hide,
offset,
shift,
size,
} from "@floating-ui/dom";
import { extend } from "../../../data/extend.mjs";
import {
isArray,
isElement,
isFunction,
isNumber,
isObject,
isString,
} from "../../../types/is.mjs";
import { validateBoolean } from "../../../types/validate.mjs";
export {
createFloatingPopper,
setEventListenersModifiers,
popperInstanceSymbol,
};
/**
* @private
* @type {symbol}
*/
const popperInstanceSymbol = Symbol("popperInstance");
/**
* @private
* @type {symbol}
*/
const optionsSymbol = Symbol("options");
/**
* @private
* @type {symbol}
*/
const referenceElementSymbol = Symbol("referenceElement");
/**
* @private
* @type {symbol}
*/
const popperElementSymbol = Symbol("popperElement");
/**
* @private
* @type {symbol}
*/
const autoUpdateCleanupSymbol = Symbol("autoUpdateCleanup");
/**
* @private
* @param {HTMLElement} referenceElement
* @param {HTMLElement} popperElement
* @param {object} options
* @return {{destroy: Function, setOptions: Function, update: Function}}
*/
function createFloatingPopper(referenceElement, popperElement, options = {}) {
const instance = {
[referenceElementSymbol]: referenceElement,
[popperElementSymbol]: popperElement,
[optionsSymbol]: normalizeOptions(options),
[autoUpdateCleanupSymbol]: null,
destroy() {
stopAutoUpdate(instance);
},
setOptions(nextOptions) {
const normalizedOptions = isFunction(nextOptions)
? nextOptions(instance[optionsSymbol])
: nextOptions;
instance[optionsSymbol] = normalizeOptions(normalizedOptions);
syncAutoUpdate(instance);
return instance.update();
},
update() {
return updatePosition(instance);
},
};
syncAutoUpdate(instance);
return instance;
}
/**
* @private
* @this {CustomElement}
* @param {Boolean} mode
*/
function setEventListenersModifiers(mode) {
const options = extend({}, this.getOption("popper"));
const modifiers = options?.modifiers;
if (!isArray(modifiers)) {
options.modifiers = [];
}
const existingModifier = options.modifiers.find((entry) => {
return entry?.name === "eventListeners";
});
if (existingModifier) {
existingModifier.enabled = validateBoolean(mode);
} else {
options.modifiers.push({
name: "eventListeners",
enabled: validateBoolean(mode),
});
}
return this[popperInstanceSymbol].setOptions(options);
}
/**
* @private
* @param {object} instance
* @return {Promise<void>}
*/
function updatePosition(instance) {
const referenceElement = instance[referenceElementSymbol];
const popperElement = instance[popperElementSymbol];
const options = instance[optionsSymbol];
if (!isElement(referenceElement) || !isElement(popperElement)) {
return Promise.resolve();
}
return computePosition(referenceElement, popperElement, {
placement: options.placement,
strategy: options.strategy,
middleware: options.middleware,
}).then(({ x, y, strategy }) => {
Object.assign(popperElement.style, {
position: strategy,
left: `${roundByDPR(x)}px`,
top: `${roundByDPR(y)}px`,
transform: "",
});
});
}
/**
* @private
* @param {object} instance
* @return {void}
*/
function syncAutoUpdate(instance) {
stopAutoUpdate(instance);
if (instance[optionsSymbol].eventListeners !== true) {
return;
}
instance[autoUpdateCleanupSymbol] = autoUpdate(
instance[referenceElementSymbol],
instance[popperElementSymbol],
() => {
void instance.update();
},
{
elementResize: typeof ResizeObserver === "function",
layoutShift: false,
},
);
}
/**
* @private
* @param {object} instance
* @return {void}
*/
function stopAutoUpdate(instance) {
const cleanup = instance[autoUpdateCleanupSymbol];
if (typeof cleanup === "function") {
cleanup();
}
instance[autoUpdateCleanupSymbol] = null;
}
/**
* @private
* @param {object} options
* @return {object}
*/
function normalizeOptions(options) {
const config = extend(
{
placement: "bottom",
strategy: "absolute",
modifiers: [],
},
options || {},
);
config.eventListeners = normalizeEventListeners(config.modifiers);
config.middleware = normalizeMiddleware(config);
if (config.placement === "auto") {
config.placement = "bottom";
config.middleware.unshift(
autoPlacement({
crossAxis: true,
autoAlignment: true,
}),
);
}
return config;
}
/**
* @private
* @param {object[]} modifiers
* @return {boolean}
*/
function normalizeEventListeners(modifiers) {
if (!isArray(modifiers)) {
return true;
}
let result = true;
for (const entry of modifiers) {
if (entry?.name === "eventListeners") {
result = validateBoolean(entry.enabled);
}
}
return result;
}
/**
* @private
* @param {object} config
* @return {Array}
*/
function normalizeMiddleware(config) {
const result = [];
const middleware = [];
if (isArray(config?.middleware)) {
middleware.push(...config.middleware);
}
if (isArray(config?.modifiers)) {
middleware.push(...config.modifiers);
}
for (const entry of middleware) {
if (isFunction(entry)) {
result.push(entry);
continue;
}
if (isObject(entry) && isFunction(entry?.fn)) {
result.push(entry);
continue;
}
if (isObject(entry) && !isString(entry?.name)) {
result.push(entry);
continue;
}
if (!isObject(entry) || !isString(entry?.name)) {
continue;
}
const normalizedEntry = normalizeModifier(entry);
if (normalizedEntry) {
result.push(normalizedEntry);
}
}
return result;
}
/**
* @private
* @param {{name: string, options?: object, enabled?: boolean}} modifier
* @return {object|null}
*/
function normalizeModifier(modifier) {
if (modifier.enabled === false) {
return null;
}
switch (modifier.name) {
case "arrow":
if (!isElement(modifier?.options?.element)) {
return null;
}
return arrow(modifier.options);
case "autoPlacement":
return autoPlacement(modifier.options);
case "eventListeners":
return null;
case "flip":
return flip(modifier.options);
case "hide":
return hide(modifier.options);
case "offset":
return offset(normalizeOffset(modifier.options?.offset));
case "preventOverflow":
return shift(modifier.options);
case "shift":
return shift(modifier.options);
case "size":
return size(modifier.options);
default:
return null;
}
}
/**
* @private
* @param {number|Array<number>} rawOffset
* @return {number|object}
*/
function normalizeOffset(rawOffset) {
if (isArray(rawOffset)) {
const [skidding = 0, distance = 0] = rawOffset;
return {
mainAxis: distance,
crossAxis: skidding,
};
}
if (isNumber(rawOffset)) {
return rawOffset;
}
return 0;
}
/**
* @private
* @param {number} value
* @return {number}
*/
function roundByDPR(value) {
const dpr = window.devicePixelRatio || 1;
return Math.round(value * dpr) / dpr;
}