mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2025-01-07 13:03:24 -05:00
5d05ec58be
- Updated some Rust dependencies - Fixed an issue with CSP header, this was not configured correctly - Prevent sending CSP and Frame headers for the MFA connector.html files. Else some clients will fail to handle these protocols. - Add `unsafe-inline` for `script-src` only to the CSP for the Admin Interface - Updated JavaScript and CSS files for the Admin interface - Changed the layout for showing overridden settings, better visible now. - Made the version check cachable to prevent hitting the Github API rate limits - Hide the `database_url` as if it is a password in the Admin Interface Else for MariaDB/MySQL or PostgreSQL this was plain text. - Fixed an issue that pressing enter on the SMTP Test would save the config. resolves #2542 - Prevent user names larger then 50 characters resolves #2419
5992 lines
175 KiB
JavaScript
Vendored
5992 lines
175 KiB
JavaScript
Vendored
/*!
|
|
* Native JavaScript for Bootstrap v4.2.0 (https://thednp.github.io/bootstrap.native/)
|
|
* Copyright 2015-2022 © dnp_theme
|
|
* Licensed under MIT (https://github.com/thednp/bootstrap.native/blob/master/LICENSE)
|
|
*/
|
|
(function (global, factory) {
|
|
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
|
|
typeof define === 'function' && define.amd ? define(factory) :
|
|
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.BSN = factory());
|
|
})(this, (function () { 'use strict';
|
|
|
|
/** @type {Record<string, any>} */
|
|
const EventRegistry = {};
|
|
|
|
/**
|
|
* The global event listener.
|
|
*
|
|
* @type {EventListener}
|
|
* @this {EventTarget}
|
|
*/
|
|
function globalListener(e) {
|
|
const that = this;
|
|
const { type } = e;
|
|
|
|
[...EventRegistry[type]].forEach((elementsMap) => {
|
|
const [element, listenersMap] = elementsMap;
|
|
/* istanbul ignore else */
|
|
if (element === that) {
|
|
[...listenersMap].forEach((listenerMap) => {
|
|
const [listener, options] = listenerMap;
|
|
listener.apply(element, [e]);
|
|
|
|
if (options && options.once) {
|
|
removeListener(element, type, listener, options);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Register a new listener with its options and attach the `globalListener`
|
|
* to the target if this is the first listener.
|
|
*
|
|
* @type {Listener.ListenerAction<EventTarget>}
|
|
*/
|
|
const addListener = (element, eventType, listener, options) => {
|
|
// get element listeners first
|
|
if (!EventRegistry[eventType]) {
|
|
EventRegistry[eventType] = new Map();
|
|
}
|
|
const oneEventMap = EventRegistry[eventType];
|
|
|
|
if (!oneEventMap.has(element)) {
|
|
oneEventMap.set(element, new Map());
|
|
}
|
|
const oneElementMap = oneEventMap.get(element);
|
|
|
|
// get listeners size
|
|
const { size } = oneElementMap;
|
|
|
|
// register listener with its options
|
|
oneElementMap.set(listener, options);
|
|
|
|
// add listener last
|
|
if (!size) {
|
|
element.addEventListener(eventType, globalListener, options);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Remove a listener from registry and detach the `globalListener`
|
|
* if no listeners are found in the registry.
|
|
*
|
|
* @type {Listener.ListenerAction<EventTarget>}
|
|
*/
|
|
const removeListener = (element, eventType, listener, options) => {
|
|
// get listener first
|
|
const oneEventMap = EventRegistry[eventType];
|
|
const oneElementMap = oneEventMap && oneEventMap.get(element);
|
|
const savedOptions = oneElementMap && oneElementMap.get(listener);
|
|
|
|
// also recover initial options
|
|
const { options: eventOptions } = savedOptions !== undefined
|
|
? savedOptions
|
|
: { options };
|
|
|
|
// unsubscribe second, remove from registry
|
|
if (oneElementMap && oneElementMap.has(listener)) oneElementMap.delete(listener);
|
|
if (oneEventMap && (!oneElementMap || !oneElementMap.size)) oneEventMap.delete(element);
|
|
if (!oneEventMap || !oneEventMap.size) delete EventRegistry[eventType];
|
|
|
|
// remove listener last
|
|
/* istanbul ignore else */
|
|
if (!oneElementMap || !oneElementMap.size) {
|
|
element.removeEventListener(eventType, globalListener, eventOptions);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Advanced event listener based on subscribe / publish pattern.
|
|
* @see https://www.patterns.dev/posts/classic-design-patterns/#observerpatternjavascript
|
|
* @see https://gist.github.com/shystruk/d16c0ee7ac7d194da9644e5d740c8338#file-subpub-js
|
|
* @see https://hackernoon.com/do-you-still-register-window-event-listeners-in-each-component-react-in-example-31a4b1f6f1c8
|
|
*/
|
|
const Listener = {
|
|
on: addListener,
|
|
off: removeListener,
|
|
globalListener,
|
|
registry: EventRegistry,
|
|
};
|
|
|
|
/**
|
|
* A global namespace for `click` event.
|
|
* @type {string}
|
|
*/
|
|
const mouseclickEvent = 'click';
|
|
|
|
/**
|
|
* A global namespace for 'transitionend' string.
|
|
* @type {string}
|
|
*/
|
|
const transitionEndEvent = 'transitionend';
|
|
|
|
/**
|
|
* A global namespace for 'transitionDelay' string.
|
|
* @type {string}
|
|
*/
|
|
const transitionDelay = 'transitionDelay';
|
|
|
|
/**
|
|
* A global namespace for `transitionProperty` string for modern browsers.
|
|
*
|
|
* @type {string}
|
|
*/
|
|
const transitionProperty = 'transitionProperty';
|
|
|
|
/**
|
|
* Shortcut for `window.getComputedStyle(element).propertyName`
|
|
* static method.
|
|
*
|
|
* * If `element` parameter is not an `HTMLElement`, `getComputedStyle`
|
|
* throws a `ReferenceError`.
|
|
*
|
|
* @param {HTMLElement} element target
|
|
* @param {string} property the css property
|
|
* @return {string} the css property value
|
|
*/
|
|
function getElementStyle(element, property) {
|
|
const computedStyle = getComputedStyle(element);
|
|
|
|
// must use camelcase strings,
|
|
// or non-camelcase strings with `getPropertyValue`
|
|
return property.includes('--')
|
|
? computedStyle.getPropertyValue(property)
|
|
: computedStyle[property];
|
|
}
|
|
|
|
/**
|
|
* Utility to get the computed `transitionDelay`
|
|
* from Element in miliseconds.
|
|
*
|
|
* @param {HTMLElement} element target
|
|
* @return {number} the value in miliseconds
|
|
*/
|
|
function getElementTransitionDelay(element) {
|
|
const propertyValue = getElementStyle(element, transitionProperty);
|
|
const delayValue = getElementStyle(element, transitionDelay);
|
|
const delayScale = delayValue.includes('ms') ? /* istanbul ignore next */1 : 1000;
|
|
const duration = propertyValue && propertyValue !== 'none'
|
|
? parseFloat(delayValue) * delayScale : 0;
|
|
|
|
return !Number.isNaN(duration) ? duration : /* istanbul ignore next */0;
|
|
}
|
|
|
|
/**
|
|
* A global namespace for 'transitionDuration' string.
|
|
* @type {string}
|
|
*/
|
|
const transitionDuration = 'transitionDuration';
|
|
|
|
/**
|
|
* Utility to get the computed `transitionDuration`
|
|
* from Element in miliseconds.
|
|
*
|
|
* @param {HTMLElement} element target
|
|
* @return {number} the value in miliseconds
|
|
*/
|
|
function getElementTransitionDuration(element) {
|
|
const propertyValue = getElementStyle(element, transitionProperty);
|
|
const durationValue = getElementStyle(element, transitionDuration);
|
|
const durationScale = durationValue.includes('ms') ? /* istanbul ignore next */1 : 1000;
|
|
const duration = propertyValue && propertyValue !== 'none'
|
|
? parseFloat(durationValue) * durationScale : 0;
|
|
|
|
return !Number.isNaN(duration) ? duration : /* istanbul ignore next */0;
|
|
}
|
|
|
|
/**
|
|
* Shortcut for the `Element.dispatchEvent(Event)` method.
|
|
*
|
|
* @param {HTMLElement} element is the target
|
|
* @param {Event} event is the `Event` object
|
|
*/
|
|
const dispatchEvent = (element, event) => element.dispatchEvent(event);
|
|
|
|
/**
|
|
* Utility to make sure callbacks are consistently
|
|
* called when transition ends.
|
|
*
|
|
* @param {HTMLElement} element target
|
|
* @param {EventListener} handler `transitionend` callback
|
|
*/
|
|
function emulateTransitionEnd(element, handler) {
|
|
let called = 0;
|
|
const endEvent = new Event(transitionEndEvent);
|
|
const duration = getElementTransitionDuration(element);
|
|
const delay = getElementTransitionDelay(element);
|
|
|
|
if (duration) {
|
|
/**
|
|
* Wrap the handler in on -> off callback
|
|
* @type {EventListener} e Event object
|
|
*/
|
|
const transitionEndWrapper = (e) => {
|
|
/* istanbul ignore else */
|
|
if (e.target === element) {
|
|
handler.apply(element, [e]);
|
|
element.removeEventListener(transitionEndEvent, transitionEndWrapper);
|
|
called = 1;
|
|
}
|
|
};
|
|
element.addEventListener(transitionEndEvent, transitionEndWrapper);
|
|
setTimeout(() => {
|
|
/* istanbul ignore next */
|
|
if (!called) dispatchEvent(element, endEvent);
|
|
}, duration + delay + 17);
|
|
} else {
|
|
handler.apply(element, [endEvent]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if an object is a `Node`.
|
|
*
|
|
* @param {any} node the target object
|
|
* @returns {boolean} the query result
|
|
*/
|
|
const isNode = (element) => (element && [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
|
|
.some((x) => +element.nodeType === x)) || false;
|
|
|
|
/**
|
|
* Check if a target object is `Window`.
|
|
* => equivalent to `object instanceof Window`
|
|
*
|
|
* @param {any} object the target object
|
|
* @returns {boolean} the query result
|
|
*/
|
|
const isWindow = (object) => (object && object.constructor.name === 'Window') || false;
|
|
|
|
/**
|
|
* Checks if an object is a `Document`.
|
|
* @see https://dom.spec.whatwg.org/#node
|
|
*
|
|
* @param {any} object the target object
|
|
* @returns {boolean} the query result
|
|
*/
|
|
const isDocument = (object) => (object && object.nodeType === 9) || false;
|
|
|
|
/**
|
|
* Returns the `document` or the `#document` element.
|
|
* @see https://github.com/floating-ui/floating-ui
|
|
* @param {(Node | Window)=} node
|
|
* @returns {Document}
|
|
*/
|
|
function getDocument(node) {
|
|
// node instanceof Document
|
|
if (isDocument(node)) return node;
|
|
// node instanceof Node
|
|
if (isNode(node)) return node.ownerDocument;
|
|
// node instanceof Window
|
|
if (isWindow(node)) return node.document;
|
|
// node is undefined | NULL
|
|
return window.document;
|
|
}
|
|
|
|
/**
|
|
* Utility to check if target is typeof `HTMLElement`, `Element`, `Node`
|
|
* or find one that matches a selector.
|
|
*
|
|
* @param {Node | string} selector the input selector or target element
|
|
* @param {ParentNode=} parent optional node to look into
|
|
* @return {HTMLElement?} the `HTMLElement` or `querySelector` result
|
|
*/
|
|
function querySelector(selector, parent) {
|
|
if (isNode(selector)) {
|
|
return selector;
|
|
}
|
|
const lookUp = isNode(parent) ? parent : getDocument();
|
|
|
|
return lookUp.querySelector(selector);
|
|
}
|
|
|
|
/**
|
|
* Shortcut for `HTMLElement.closest` method which also works
|
|
* with children of `ShadowRoot`. The order of the parameters
|
|
* is intentional since they're both required.
|
|
*
|
|
* @see https://stackoverflow.com/q/54520554/803358
|
|
*
|
|
* @param {HTMLElement} element Element to look into
|
|
* @param {string} selector the selector name
|
|
* @return {HTMLElement?} the query result
|
|
*/
|
|
function closest(element, selector) {
|
|
return element ? (element.closest(selector)
|
|
// break out of `ShadowRoot`
|
|
|| closest(element.getRootNode().host, selector)) : null;
|
|
}
|
|
|
|
/**
|
|
* Shortcut for `Object.assign()` static method.
|
|
* @param {Record<string, any>} obj a target object
|
|
* @param {Record<string, any>} source a source object
|
|
*/
|
|
const ObjectAssign = (obj, source) => Object.assign(obj, source);
|
|
|
|
/**
|
|
* Check class in `HTMLElement.classList`.
|
|
*
|
|
* @param {HTMLElement} element target
|
|
* @param {string} classNAME to check
|
|
* @returns {boolean}
|
|
*/
|
|
function hasClass(element, classNAME) {
|
|
return element.classList.contains(classNAME);
|
|
}
|
|
|
|
/**
|
|
* Remove class from `HTMLElement.classList`.
|
|
*
|
|
* @param {HTMLElement} element target
|
|
* @param {string} classNAME to remove
|
|
* @returns {void}
|
|
*/
|
|
function removeClass(element, classNAME) {
|
|
element.classList.remove(classNAME);
|
|
}
|
|
|
|
/**
|
|
* Checks if an element is an `HTMLElement`.
|
|
* @see https://dom.spec.whatwg.org/#node
|
|
*
|
|
* @param {any} element the target object
|
|
* @returns {boolean} the query result
|
|
*/
|
|
const isHTMLElement = (element) => (element && element.nodeType === 1) || false;
|
|
|
|
/** @type {Map<string, Map<HTMLElement, Record<string, any>>>} */
|
|
const componentData = new Map();
|
|
/**
|
|
* An interface for web components background data.
|
|
* @see https://github.com/thednp/bootstrap.native/blob/master/src/components/base-component.js
|
|
*/
|
|
const Data = {
|
|
/**
|
|
* Sets web components data.
|
|
* @param {HTMLElement} element target element
|
|
* @param {string} component the component's name or a unique key
|
|
* @param {Record<string, any>} instance the component instance
|
|
*/
|
|
set: (element, component, instance) => {
|
|
if (!isHTMLElement(element)) return;
|
|
|
|
/* istanbul ignore else */
|
|
if (!componentData.has(component)) {
|
|
componentData.set(component, new Map());
|
|
}
|
|
|
|
const instanceMap = componentData.get(component);
|
|
// not undefined, but defined right above
|
|
instanceMap.set(element, instance);
|
|
},
|
|
|
|
/**
|
|
* Returns all instances for specified component.
|
|
* @param {string} component the component's name or a unique key
|
|
* @returns {Map<HTMLElement, Record<string, any>>?} all the component instances
|
|
*/
|
|
getAllFor: (component) => {
|
|
const instanceMap = componentData.get(component);
|
|
|
|
return instanceMap || null;
|
|
},
|
|
|
|
/**
|
|
* Returns the instance associated with the target.
|
|
* @param {HTMLElement} element target element
|
|
* @param {string} component the component's name or a unique key
|
|
* @returns {Record<string, any>?} the instance
|
|
*/
|
|
get: (element, component) => {
|
|
if (!isHTMLElement(element) || !component) return null;
|
|
const allForC = Data.getAllFor(component);
|
|
const instance = element && allForC && allForC.get(element);
|
|
|
|
return instance || null;
|
|
},
|
|
|
|
/**
|
|
* Removes web components data.
|
|
* @param {HTMLElement} element target element
|
|
* @param {string} component the component's name or a unique key
|
|
*/
|
|
remove: (element, component) => {
|
|
const instanceMap = componentData.get(component);
|
|
if (!instanceMap || !isHTMLElement(element)) return;
|
|
|
|
instanceMap.delete(element);
|
|
|
|
/* istanbul ignore else */
|
|
if (instanceMap.size === 0) {
|
|
componentData.delete(component);
|
|
}
|
|
},
|
|
};
|
|
|
|
/**
|
|
* An alias for `Data.get()`.
|
|
* @type {SHORTY.getInstance<any>}
|
|
*/
|
|
const getInstance = (target, component) => Data.get(target, component);
|
|
|
|
/**
|
|
* Checks if an object is an `Object`.
|
|
*
|
|
* @param {any} obj the target object
|
|
* @returns {boolean} the query result
|
|
*/
|
|
const isObject = (obj) => (typeof obj === 'object') || false;
|
|
|
|
/**
|
|
* Returns a namespaced `CustomEvent` specific to each component.
|
|
* @param {string} EventType Event.type
|
|
* @param {Record<string, any>=} config Event.options | Event.properties
|
|
* @returns {SHORTY.OriginalEvent} a new namespaced event
|
|
*/
|
|
function OriginalEvent(EventType, config) {
|
|
const OriginalCustomEvent = new CustomEvent(EventType, {
|
|
cancelable: true, bubbles: true,
|
|
});
|
|
|
|
/* istanbul ignore else */
|
|
if (isObject(config)) {
|
|
ObjectAssign(OriginalCustomEvent, config);
|
|
}
|
|
return OriginalCustomEvent;
|
|
}
|
|
|
|
/**
|
|
* Global namespace for most components `fade` class.
|
|
*/
|
|
const fadeClass = 'fade';
|
|
|
|
/**
|
|
* Global namespace for most components `show` class.
|
|
*/
|
|
const showClass = 'show';
|
|
|
|
/**
|
|
* Global namespace for most components `dismiss` option.
|
|
*/
|
|
const dataBsDismiss = 'data-bs-dismiss';
|
|
|
|
/** @type {string} */
|
|
const alertString = 'alert';
|
|
|
|
/** @type {string} */
|
|
const alertComponent = 'Alert';
|
|
|
|
/**
|
|
* Shortcut for `HTMLElement.getAttribute()` method.
|
|
* @param {HTMLElement} element target element
|
|
* @param {string} attribute attribute name
|
|
* @returns {string?} attribute value
|
|
*/
|
|
const getAttribute = (element, attribute) => element.getAttribute(attribute);
|
|
|
|
/**
|
|
* The raw value or a given component option.
|
|
*
|
|
* @typedef {string | HTMLElement | Function | number | boolean | null} niceValue
|
|
*/
|
|
|
|
/**
|
|
* Utility to normalize component options
|
|
*
|
|
* @param {any} value the input value
|
|
* @return {niceValue} the normalized value
|
|
*/
|
|
function normalizeValue(value) {
|
|
if (['true', true].includes(value)) { // boolean
|
|
// if ('true' === value) { // boolean
|
|
return true;
|
|
}
|
|
|
|
if (['false', false].includes(value)) { // boolean
|
|
// if ('false' === value) { // boolean
|
|
return false;
|
|
}
|
|
|
|
if (value === '' || value === 'null') { // null
|
|
return null;
|
|
}
|
|
|
|
if (value !== '' && !Number.isNaN(+value)) { // number
|
|
return +value;
|
|
}
|
|
|
|
// string / function / HTMLElement / object
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Shortcut for `Object.keys()` static method.
|
|
* @param {Record<string, any>} obj a target object
|
|
* @returns {string[]}
|
|
*/
|
|
const ObjectKeys = (obj) => Object.keys(obj);
|
|
|
|
/**
|
|
* Shortcut for `String.toLowerCase()`.
|
|
*
|
|
* @param {string} source input string
|
|
* @returns {string} lowercase output string
|
|
*/
|
|
const toLowerCase = (source) => source.toLowerCase();
|
|
|
|
/**
|
|
* Utility to normalize component options.
|
|
*
|
|
* @param {HTMLElement} element target
|
|
* @param {Record<string, any>} defaultOps component default options
|
|
* @param {Record<string, any>} inputOps component instance options
|
|
* @param {string=} ns component namespace
|
|
* @return {Record<string, any>} normalized component options object
|
|
*/
|
|
function normalizeOptions(element, defaultOps, inputOps, ns) {
|
|
const data = { ...element.dataset };
|
|
/** @type {Record<string, any>} */
|
|
const normalOps = {};
|
|
/** @type {Record<string, any>} */
|
|
const dataOps = {};
|
|
const title = 'title';
|
|
|
|
ObjectKeys(data).forEach((k) => {
|
|
const key = ns && k.includes(ns)
|
|
? k.replace(ns, '').replace(/[A-Z]/, (match) => toLowerCase(match))
|
|
: k;
|
|
|
|
dataOps[key] = normalizeValue(data[k]);
|
|
});
|
|
|
|
ObjectKeys(inputOps).forEach((k) => {
|
|
inputOps[k] = normalizeValue(inputOps[k]);
|
|
});
|
|
|
|
ObjectKeys(defaultOps).forEach((k) => {
|
|
/* istanbul ignore else */
|
|
if (k in inputOps) {
|
|
normalOps[k] = inputOps[k];
|
|
} else if (k in dataOps) {
|
|
normalOps[k] = dataOps[k];
|
|
} else {
|
|
normalOps[k] = k === title
|
|
? getAttribute(element, title)
|
|
: defaultOps[k];
|
|
}
|
|
});
|
|
|
|
return normalOps;
|
|
}
|
|
|
|
var version = "4.2.0";
|
|
|
|
const Version = version;
|
|
|
|
/* Native JavaScript for Bootstrap 5 | Base Component
|
|
----------------------------------------------------- */
|
|
|
|
/** Returns a new `BaseComponent` instance. */
|
|
class BaseComponent {
|
|
/**
|
|
* @param {HTMLElement | string} target `Element` or selector string
|
|
* @param {BSN.ComponentOptions=} config component instance options
|
|
*/
|
|
constructor(target, config) {
|
|
const self = this;
|
|
const element = querySelector(target);
|
|
|
|
if (!element) {
|
|
throw Error(`${self.name} Error: "${target}" is not a valid selector.`);
|
|
}
|
|
|
|
/** @static @type {BSN.ComponentOptions} */
|
|
self.options = {};
|
|
|
|
const prevInstance = Data.get(element, self.name);
|
|
if (prevInstance) prevInstance.dispose();
|
|
|
|
/** @type {HTMLElement} */
|
|
self.element = element;
|
|
|
|
/* istanbul ignore else */
|
|
if (self.defaults && ObjectKeys(self.defaults).length) {
|
|
self.options = normalizeOptions(element, self.defaults, (config || {}), 'bs');
|
|
}
|
|
|
|
Data.set(element, self.name, self);
|
|
}
|
|
|
|
/* eslint-disable */
|
|
/* istanbul ignore next */
|
|
/** @static */
|
|
get version() { return Version; }
|
|
|
|
/* eslint-enable */
|
|
/* istanbul ignore next */
|
|
/** @static */
|
|
get name() { return this.constructor.name; }
|
|
|
|
/* istanbul ignore next */
|
|
/** @static */
|
|
get defaults() { return this.constructor.defaults; }
|
|
|
|
/**
|
|
* Removes component from target element;
|
|
*/
|
|
dispose() {
|
|
const self = this;
|
|
Data.remove(self.element, self.name);
|
|
ObjectKeys(self).forEach((prop) => { self[prop] = null; });
|
|
}
|
|
}
|
|
|
|
/* Native JavaScript for Bootstrap 5 | Alert
|
|
-------------------------------------------- */
|
|
|
|
// ALERT PRIVATE GC
|
|
// ================
|
|
const alertSelector = `.${alertString}`;
|
|
const alertDismissSelector = `[${dataBsDismiss}="${alertString}"]`;
|
|
|
|
/**
|
|
* Static method which returns an existing `Alert` instance associated
|
|
* to a target `Element`.
|
|
*
|
|
* @type {BSN.GetInstance<Alert>}
|
|
*/
|
|
const getAlertInstance = (element) => getInstance(element, alertComponent);
|
|
|
|
/**
|
|
* An `Alert` initialization callback.
|
|
* @type {BSN.InitCallback<Alert>}
|
|
*/
|
|
const alertInitCallback = (element) => new Alert(element);
|
|
|
|
// ALERT CUSTOM EVENTS
|
|
// ===================
|
|
const closeAlertEvent = OriginalEvent(`close.bs.${alertString}`);
|
|
const closedAlertEvent = OriginalEvent(`closed.bs.${alertString}`);
|
|
|
|
// ALERT EVENT HANDLER
|
|
// ===================
|
|
/**
|
|
* Alert `transitionend` callback.
|
|
* @param {Alert} self target Alert instance
|
|
*/
|
|
function alertTransitionEnd(self) {
|
|
const { element } = self;
|
|
toggleAlertHandler(self);
|
|
|
|
dispatchEvent(element, closedAlertEvent);
|
|
|
|
self.dispose();
|
|
element.remove();
|
|
}
|
|
|
|
// ALERT PRIVATE METHOD
|
|
// ====================
|
|
/**
|
|
* Toggle on / off the `click` event listener.
|
|
* @param {Alert} self the target alert instance
|
|
* @param {boolean=} add when `true`, event listener is added
|
|
*/
|
|
function toggleAlertHandler(self, add) {
|
|
const action = add ? addListener : removeListener;
|
|
const { dismiss } = self;
|
|
/* istanbul ignore else */
|
|
if (dismiss) action(dismiss, mouseclickEvent, self.close);
|
|
}
|
|
|
|
// ALERT DEFINITION
|
|
// ================
|
|
/** Creates a new Alert instance. */
|
|
class Alert extends BaseComponent {
|
|
/** @param {HTMLElement | string} target element or selector */
|
|
constructor(target) {
|
|
super(target);
|
|
// bind
|
|
const self = this;
|
|
|
|
// initialization element
|
|
const { element } = self;
|
|
|
|
// the dismiss button
|
|
/** @static @type {HTMLElement?} */
|
|
self.dismiss = querySelector(alertDismissSelector, element);
|
|
|
|
// add event listener
|
|
toggleAlertHandler(self, true);
|
|
}
|
|
|
|
/* eslint-disable */
|
|
/**
|
|
* Returns component name string.
|
|
*/
|
|
get name() { return alertComponent; }
|
|
/* eslint-enable */
|
|
|
|
// ALERT PUBLIC METHODS
|
|
// ====================
|
|
/**
|
|
* Public method that hides the `.alert` element from the user,
|
|
* disposes the instance once animation is complete, then
|
|
* removes the element from the DOM.
|
|
*
|
|
* @param {Event=} e most likely the `click` event
|
|
* @this {Alert} the `Alert` instance or `EventTarget`
|
|
*/
|
|
close(e) {
|
|
const self = e ? getAlertInstance(closest(this, alertSelector)) : this;
|
|
const { element } = self;
|
|
|
|
/* istanbul ignore else */
|
|
if (element && hasClass(element, showClass)) {
|
|
dispatchEvent(element, closeAlertEvent);
|
|
if (closeAlertEvent.defaultPrevented) return;
|
|
|
|
removeClass(element, showClass);
|
|
|
|
if (hasClass(element, fadeClass)) {
|
|
emulateTransitionEnd(element, () => alertTransitionEnd(self));
|
|
} else alertTransitionEnd(self);
|
|
}
|
|
}
|
|
|
|
/** Remove the component from target element. */
|
|
dispose() {
|
|
toggleAlertHandler(this);
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
ObjectAssign(Alert, {
|
|
selector: alertSelector,
|
|
init: alertInitCallback,
|
|
getInstance: getAlertInstance,
|
|
});
|
|
|
|
/**
|
|
* A global namespace for aria-pressed.
|
|
* @type {string}
|
|
*/
|
|
const ariaPressed = 'aria-pressed';
|
|
|
|
/**
|
|
* Shortcut for `HTMLElement.setAttribute()` method.
|
|
* @param {HTMLElement} element target element
|
|
* @param {string} attribute attribute name
|
|
* @param {string} value attribute value
|
|
* @returns {void}
|
|
*/
|
|
const setAttribute = (element, attribute, value) => element.setAttribute(attribute, value);
|
|
|
|
/**
|
|
* Add class to `HTMLElement.classList`.
|
|
*
|
|
* @param {HTMLElement} element target
|
|
* @param {string} classNAME to add
|
|
* @returns {void}
|
|
*/
|
|
function addClass(element, classNAME) {
|
|
element.classList.add(classNAME);
|
|
}
|
|
|
|
/**
|
|
* Global namespace for most components active class.
|
|
*/
|
|
const activeClass = 'active';
|
|
|
|
/**
|
|
* Global namespace for most components `toggle` option.
|
|
*/
|
|
const dataBsToggle = 'data-bs-toggle';
|
|
|
|
/** @type {string} */
|
|
const buttonString = 'button';
|
|
|
|
/** @type {string} */
|
|
const buttonComponent = 'Button';
|
|
|
|
/* Native JavaScript for Bootstrap 5 | Button
|
|
---------------------------------------------*/
|
|
|
|
// BUTTON PRIVATE GC
|
|
// =================
|
|
const buttonSelector = `[${dataBsToggle}="${buttonString}"]`;
|
|
|
|
/**
|
|
* Static method which returns an existing `Button` instance associated
|
|
* to a target `Element`.
|
|
*
|
|
* @type {BSN.GetInstance<Button>}
|
|
*/
|
|
const getButtonInstance = (element) => getInstance(element, buttonComponent);
|
|
|
|
/**
|
|
* A `Button` initialization callback.
|
|
* @type {BSN.InitCallback<Button>}
|
|
*/
|
|
const buttonInitCallback = (element) => new Button(element);
|
|
|
|
// BUTTON PRIVATE METHOD
|
|
// =====================
|
|
/**
|
|
* Toggles on/off the `click` event listener.
|
|
* @param {Button} self the `Button` instance
|
|
* @param {boolean=} add when `true`, event listener is added
|
|
*/
|
|
function toggleButtonHandler(self, add) {
|
|
const action = add ? addListener : removeListener;
|
|
action(self.element, mouseclickEvent, self.toggle);
|
|
}
|
|
|
|
// BUTTON DEFINITION
|
|
// =================
|
|
/** Creates a new `Button` instance. */
|
|
class Button extends BaseComponent {
|
|
/**
|
|
* @param {HTMLElement | string} target usually a `.btn` element
|
|
*/
|
|
constructor(target) {
|
|
super(target);
|
|
const self = this;
|
|
|
|
// initialization element
|
|
const { element } = self;
|
|
|
|
// set initial state
|
|
/** @type {boolean} */
|
|
self.isActive = hasClass(element, activeClass);
|
|
setAttribute(element, ariaPressed, `${!!self.isActive}`);
|
|
|
|
// add event listener
|
|
toggleButtonHandler(self, true);
|
|
}
|
|
|
|
/* eslint-disable */
|
|
/**
|
|
* Returns component name string.
|
|
*/
|
|
get name() { return buttonComponent; }
|
|
/* eslint-enable */
|
|
|
|
// BUTTON PUBLIC METHODS
|
|
// =====================
|
|
/**
|
|
* Toggles the state of the target button.
|
|
* @param {MouseEvent} e usually `click` Event object
|
|
*/
|
|
toggle(e) {
|
|
if (e) e.preventDefault();
|
|
const self = e ? getButtonInstance(this) : this;
|
|
if (!self.element) return;
|
|
const { element, isActive } = self;
|
|
|
|
if (hasClass(element, 'disabled')) return;
|
|
|
|
const action = isActive ? removeClass : addClass;
|
|
action(element, activeClass);
|
|
setAttribute(element, ariaPressed, isActive ? 'false' : 'true');
|
|
self.isActive = hasClass(element, activeClass);
|
|
}
|
|
|
|
/** Removes the `Button` component from the target element. */
|
|
dispose() {
|
|
toggleButtonHandler(this);
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
ObjectAssign(Button, {
|
|
selector: buttonSelector,
|
|
init: buttonInitCallback,
|
|
getInstance: getButtonInstance,
|
|
});
|
|
|
|
/**
|
|
* A global namespace for `mouseenter` event.
|
|
* @type {string}
|
|
*/
|
|
const mouseenterEvent = 'mouseenter';
|
|
|
|
/**
|
|
* A global namespace for `mouseleave` event.
|
|
* @type {string}
|
|
*/
|
|
const mouseleaveEvent = 'mouseleave';
|
|
|
|
/**
|
|
* A global namespace for `keydown` event.
|
|
* @type {string}
|
|
*/
|
|
const keydownEvent = 'keydown';
|
|
|
|
/**
|
|
* A global namespace for `ArrowLeft` key.
|
|
* @type {string} e.which = 37 equivalent
|
|
*/
|
|
const keyArrowLeft = 'ArrowLeft';
|
|
|
|
/**
|
|
* A global namespace for `ArrowRight` key.
|
|
* @type {string} e.which = 39 equivalent
|
|
*/
|
|
const keyArrowRight = 'ArrowRight';
|
|
|
|
/**
|
|
* A global namespace for `pointerdown` event.
|
|
* @type {string}
|
|
*/
|
|
const pointerdownEvent = 'pointerdown';
|
|
|
|
/**
|
|
* A global namespace for `pointermove` event.
|
|
* @type {string}
|
|
*/
|
|
const pointermoveEvent = 'pointermove';
|
|
|
|
/**
|
|
* A global namespace for `pointerup` event.
|
|
* @type {string}
|
|
*/
|
|
const pointerupEvent = 'pointerup';
|
|
|
|
/**
|
|
* Returns the bounding client rect of a target `HTMLElement`.
|
|
*
|
|
* @see https://github.com/floating-ui/floating-ui
|
|
*
|
|
* @param {HTMLElement} element event.target
|
|
* @param {boolean=} includeScale when *true*, the target scale is also computed
|
|
* @returns {SHORTY.BoundingClientRect} the bounding client rect object
|
|
*/
|
|
function getBoundingClientRect(element, includeScale) {
|
|
const {
|
|
width, height, top, right, bottom, left,
|
|
} = element.getBoundingClientRect();
|
|
let scaleX = 1;
|
|
let scaleY = 1;
|
|
|
|
if (includeScale && isHTMLElement(element)) {
|
|
const { offsetWidth, offsetHeight } = element;
|
|
scaleX = offsetWidth > 0 ? Math.round(width) / offsetWidth
|
|
: /* istanbul ignore next */1;
|
|
scaleY = offsetHeight > 0 ? Math.round(height) / offsetHeight
|
|
: /* istanbul ignore next */1;
|
|
}
|
|
|
|
return {
|
|
width: width / scaleX,
|
|
height: height / scaleY,
|
|
top: top / scaleY,
|
|
right: right / scaleX,
|
|
bottom: bottom / scaleY,
|
|
left: left / scaleX,
|
|
x: left / scaleX,
|
|
y: top / scaleY,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns the `document.documentElement` or the `<html>` element.
|
|
*
|
|
* @param {(Node | Window)=} node
|
|
* @returns {HTMLHtmlElement}
|
|
*/
|
|
function getDocumentElement(node) {
|
|
return getDocument(node).documentElement;
|
|
}
|
|
|
|
/**
|
|
* Utility to determine if an `HTMLElement`
|
|
* is partially visible in viewport.
|
|
*
|
|
* @param {HTMLElement} element target
|
|
* @return {boolean} the query result
|
|
*/
|
|
const isElementInScrollRange = (element) => {
|
|
if (!element || !isNode(element)) return false;
|
|
|
|
const { top, bottom } = getBoundingClientRect(element);
|
|
const { clientHeight } = getDocumentElement(element);
|
|
return top <= clientHeight && bottom >= 0;
|
|
};
|
|
|
|
/**
|
|
* Checks if a page is Right To Left.
|
|
* @param {HTMLElement=} node the target
|
|
* @returns {boolean} the query result
|
|
*/
|
|
const isRTL = (node) => getDocumentElement(node).dir === 'rtl';
|
|
|
|
/**
|
|
* A shortcut for `(document|Element).querySelectorAll`.
|
|
*
|
|
* @param {string} selector the input selector
|
|
* @param {ParentNode=} parent optional node to look into
|
|
* @return {NodeListOf<HTMLElement>} the query result
|
|
*/
|
|
function querySelectorAll(selector, parent) {
|
|
const lookUp = isNode(parent) ? parent : getDocument();
|
|
return lookUp.querySelectorAll(selector);
|
|
}
|
|
|
|
/**
|
|
* Shortcut for `HTMLElement.getElementsByClassName` method. Some `Node` elements
|
|
* like `ShadowRoot` do not support `getElementsByClassName`.
|
|
*
|
|
* @param {string} selector the class name
|
|
* @param {ParentNode=} parent optional Element to look into
|
|
* @return {HTMLCollectionOf<HTMLElement>} the 'HTMLCollection'
|
|
*/
|
|
function getElementsByClassName(selector, parent) {
|
|
const lookUp = isNode(parent) ? parent : getDocument();
|
|
return lookUp.getElementsByClassName(selector);
|
|
}
|
|
|
|
/** @type {Map<HTMLElement, any>} */
|
|
const TimeCache = new Map();
|
|
/**
|
|
* An interface for one or more `TimerHandler`s per `Element`.
|
|
* @see https://github.com/thednp/navbar.js/
|
|
*/
|
|
const Timer = {
|
|
/**
|
|
* Sets a new timeout timer for an element, or element -> key association.
|
|
* @param {HTMLElement} element target element
|
|
* @param {ReturnType<TimerHandler>} callback the callback
|
|
* @param {number} delay the execution delay
|
|
* @param {string=} key a unique key
|
|
*/
|
|
set: (element, callback, delay, key) => {
|
|
if (!isHTMLElement(element)) return;
|
|
|
|
/* istanbul ignore else */
|
|
if (key && key.length) {
|
|
/* istanbul ignore else */
|
|
if (!TimeCache.has(element)) {
|
|
TimeCache.set(element, new Map());
|
|
}
|
|
const keyTimers = TimeCache.get(element);
|
|
keyTimers.set(key, setTimeout(callback, delay));
|
|
} else {
|
|
TimeCache.set(element, setTimeout(callback, delay));
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Returns the timer associated with the target.
|
|
* @param {HTMLElement} element target element
|
|
* @param {string=} key a unique
|
|
* @returns {number?} the timer
|
|
*/
|
|
get: (element, key) => {
|
|
if (!isHTMLElement(element)) return null;
|
|
const keyTimers = TimeCache.get(element);
|
|
|
|
if (key && key.length && keyTimers && keyTimers.get) {
|
|
return keyTimers.get(key) || /* istanbul ignore next */null;
|
|
}
|
|
return keyTimers || null;
|
|
},
|
|
|
|
/**
|
|
* Clears the element's timer.
|
|
* @param {HTMLElement} element target element
|
|
* @param {string=} key a unique key
|
|
*/
|
|
clear: (element, key) => {
|
|
if (!isHTMLElement(element)) return;
|
|
|
|
if (key && key.length) {
|
|
const keyTimers = TimeCache.get(element);
|
|
/* istanbul ignore else */
|
|
if (keyTimers && keyTimers.get) {
|
|
clearTimeout(keyTimers.get(key));
|
|
keyTimers.delete(key);
|
|
/* istanbul ignore else */
|
|
if (keyTimers.size === 0) {
|
|
TimeCache.delete(element);
|
|
}
|
|
}
|
|
} else {
|
|
clearTimeout(TimeCache.get(element));
|
|
TimeCache.delete(element);
|
|
}
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Utility to force re-paint of an `HTMLElement` target.
|
|
*
|
|
* @param {HTMLElement} element is the target
|
|
* @return {number} the `Element.offsetHeight` value
|
|
*/
|
|
const reflow = (element) => element.offsetHeight;
|
|
|
|
/**
|
|
* A global namespace for most scroll event listeners.
|
|
* @type {Partial<AddEventListenerOptions>}
|
|
*/
|
|
const passiveHandler = { passive: true };
|
|
|
|
/**
|
|
* Global namespace for most components `target` option.
|
|
*/
|
|
const dataBsTarget = 'data-bs-target';
|
|
|
|
/** @type {string} */
|
|
const carouselString = 'carousel';
|
|
|
|
/** @type {string} */
|
|
const carouselComponent = 'Carousel';
|
|
|
|
/**
|
|
* Global namespace for most components `parent` option.
|
|
*/
|
|
const dataBsParent = 'data-bs-parent';
|
|
|
|
/**
|
|
* Global namespace for most components `container` option.
|
|
*/
|
|
const dataBsContainer = 'data-bs-container';
|
|
|
|
/**
|
|
* Returns the `Element` that THIS one targets
|
|
* via `data-bs-target`, `href`, `data-bs-parent` or `data-bs-container`.
|
|
*
|
|
* @param {HTMLElement} element the target element
|
|
* @returns {HTMLElement?} the query result
|
|
*/
|
|
function getTargetElement(element) {
|
|
const targetAttr = [dataBsTarget, dataBsParent, dataBsContainer, 'href'];
|
|
const doc = getDocument(element);
|
|
|
|
return targetAttr.map((att) => {
|
|
const attValue = getAttribute(element, att);
|
|
if (attValue) {
|
|
return att === dataBsParent ? closest(element, attValue) : querySelector(attValue, doc);
|
|
}
|
|
return null;
|
|
}).filter((x) => x)[0];
|
|
}
|
|
|
|
/* Native JavaScript for Bootstrap 5 | Carousel
|
|
----------------------------------------------- */
|
|
|
|
// CAROUSEL PRIVATE GC
|
|
// ===================
|
|
const carouselSelector = `[data-bs-ride="${carouselString}"]`;
|
|
const carouselItem = `${carouselString}-item`;
|
|
const dataBsSlideTo = 'data-bs-slide-to';
|
|
const dataBsSlide = 'data-bs-slide';
|
|
const pausedClass = 'paused';
|
|
|
|
const carouselDefaults = {
|
|
pause: 'hover',
|
|
keyboard: false,
|
|
touch: true,
|
|
interval: 5000,
|
|
};
|
|
|
|
/**
|
|
* Static method which returns an existing `Carousel` instance associated
|
|
* to a target `Element`.
|
|
*
|
|
* @type {BSN.GetInstance<Carousel>}
|
|
*/
|
|
const getCarouselInstance = (element) => getInstance(element, carouselComponent);
|
|
|
|
/**
|
|
* A `Carousel` initialization callback.
|
|
* @type {BSN.InitCallback<Carousel>}
|
|
*/
|
|
const carouselInitCallback = (element) => new Carousel(element);
|
|
|
|
let startX = 0;
|
|
let currentX = 0;
|
|
let endX = 0;
|
|
|
|
// CAROUSEL CUSTOM EVENTS
|
|
// ======================
|
|
const carouselSlideEvent = OriginalEvent(`slide.bs.${carouselString}`);
|
|
const carouselSlidEvent = OriginalEvent(`slid.bs.${carouselString}`);
|
|
|
|
// CAROUSEL EVENT HANDLERS
|
|
// =======================
|
|
/**
|
|
* The `transitionend` event listener of the `Carousel`.
|
|
* @param {Carousel} self the `Carousel` instance
|
|
*/
|
|
function carouselTransitionEndHandler(self) {
|
|
const {
|
|
index, direction, element, slides, options,
|
|
} = self;
|
|
|
|
// discontinue disposed instances
|
|
/* istanbul ignore else */
|
|
if (self.isAnimating && getCarouselInstance(element)) {
|
|
const activeItem = getActiveIndex(self);
|
|
const orientation = direction === 'left' ? 'next' : 'prev';
|
|
const directionClass = direction === 'left' ? 'start' : 'end';
|
|
|
|
addClass(slides[index], activeClass);
|
|
removeClass(slides[index], `${carouselItem}-${orientation}`);
|
|
removeClass(slides[index], `${carouselItem}-${directionClass}`);
|
|
|
|
removeClass(slides[activeItem], activeClass);
|
|
removeClass(slides[activeItem], `${carouselItem}-${directionClass}`);
|
|
|
|
dispatchEvent(element, carouselSlidEvent);
|
|
Timer.clear(element, dataBsSlide);
|
|
|
|
// check for element, might have been disposed
|
|
if (!getDocument(element).hidden && options.interval
|
|
&& !self.isPaused) {
|
|
self.cycle();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles the `mouseenter` events when *options.pause*
|
|
* is set to `hover`.
|
|
*
|
|
* @this {HTMLElement}
|
|
*/
|
|
function carouselPauseHandler() {
|
|
const element = this;
|
|
const self = getCarouselInstance(element);
|
|
/* istanbul ignore else */
|
|
if (self && !self.isPaused && !Timer.get(element, pausedClass)) {
|
|
addClass(element, pausedClass);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles the `mouseleave` events when *options.pause*
|
|
* is set to `hover`.
|
|
*
|
|
* @this {HTMLElement}
|
|
*/
|
|
function carouselResumeHandler() {
|
|
const element = this;
|
|
const self = getCarouselInstance(element);
|
|
/* istanbul ignore else */
|
|
if (self && self.isPaused && !Timer.get(element, pausedClass)) {
|
|
self.cycle();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles the `click` event for the `Carousel` indicators.
|
|
*
|
|
* @this {HTMLElement}
|
|
* @param {MouseEvent} e the `Event` object
|
|
*/
|
|
function carouselIndicatorHandler(e) {
|
|
e.preventDefault();
|
|
const indicator = this;
|
|
const element = closest(indicator, carouselSelector) || getTargetElement(indicator);
|
|
const self = getCarouselInstance(element);
|
|
|
|
if (!self || self.isAnimating) return;
|
|
|
|
const newIndex = +getAttribute(indicator, dataBsSlideTo);
|
|
|
|
if (indicator && !hasClass(indicator, activeClass) // event target is not active
|
|
&& !Number.isNaN(newIndex)) { // AND has the specific attribute
|
|
self.to(newIndex); // do the slide
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles the `click` event for the `Carousel` arrows.
|
|
*
|
|
* @this {HTMLElement}
|
|
* @param {MouseEvent} e the `Event` object
|
|
*/
|
|
function carouselControlsHandler(e) {
|
|
e.preventDefault();
|
|
const control = this;
|
|
const element = closest(control, carouselSelector) || getTargetElement(control);
|
|
const self = getCarouselInstance(element);
|
|
|
|
if (!self || self.isAnimating) return;
|
|
const orientation = getAttribute(control, dataBsSlide);
|
|
|
|
/* istanbul ignore else */
|
|
if (orientation === 'next') {
|
|
self.next();
|
|
} else if (orientation === 'prev') {
|
|
self.prev();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles the keyboard `keydown` event for the visible `Carousel` elements.
|
|
*
|
|
* @param {KeyboardEvent} e the `Event` object
|
|
*/
|
|
function carouselKeyHandler({ code, target }) {
|
|
const doc = getDocument(target);
|
|
const [element] = [...querySelectorAll(carouselSelector, doc)]
|
|
.filter((x) => isElementInScrollRange(x));
|
|
const self = getCarouselInstance(element);
|
|
|
|
/* istanbul ignore next */
|
|
if (!self || self.isAnimating || /textarea|input/i.test(target.tagName)) return;
|
|
const RTL = isRTL(element);
|
|
const arrowKeyNext = !RTL ? keyArrowRight : keyArrowLeft;
|
|
const arrowKeyPrev = !RTL ? keyArrowLeft : keyArrowRight;
|
|
|
|
/* istanbul ignore else */
|
|
if (code === arrowKeyPrev) self.prev();
|
|
else if (code === arrowKeyNext) self.next();
|
|
}
|
|
|
|
// CAROUSEL TOUCH HANDLERS
|
|
// =======================
|
|
/**
|
|
* Handles the `pointerdown` event for the `Carousel` element.
|
|
*
|
|
* @this {HTMLElement}
|
|
* @param {PointerEvent} e the `Event` object
|
|
*/
|
|
function carouselPointerDownHandler(e) {
|
|
const element = this;
|
|
const { target } = e;
|
|
const self = getCarouselInstance(element);
|
|
|
|
// filter pointer event on controls & indicators
|
|
const { controls, indicators } = self;
|
|
if ([...controls, ...indicators].some((el) => (el === target || el.contains(target)))) {
|
|
return;
|
|
}
|
|
|
|
if (!self || self.isAnimating || self.isTouch) { return; }
|
|
|
|
startX = e.pageX;
|
|
|
|
/* istanbul ignore else */
|
|
if (element.contains(target)) {
|
|
self.isTouch = true;
|
|
toggleCarouselTouchHandlers(self, true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles the `pointermove` event for the `Carousel` element.
|
|
*
|
|
* @this {HTMLElement}
|
|
* @param {PointerEvent} e
|
|
*/
|
|
function carouselPointerMoveHandler(e) {
|
|
// const self = getCarouselInstance(this);
|
|
|
|
// if (!self || !self.isTouch) { return; }
|
|
|
|
currentX = e.pageX;
|
|
}
|
|
|
|
/**
|
|
* Handles the `pointerup` event for the `Carousel` element.
|
|
*
|
|
* @this {HTMLElement}
|
|
|
|
* @param {PointerEvent} e
|
|
*/
|
|
function carouselPointerUpHandler(e) {
|
|
const { target } = e;
|
|
const doc = getDocument(target);
|
|
const self = [...querySelectorAll(carouselSelector, doc)]
|
|
.map((c) => getCarouselInstance(c)).find((i) => i.isTouch);
|
|
|
|
// impossible to satisfy
|
|
/* istanbul ignore next */
|
|
if (!self) { return; }
|
|
|
|
const { element, index } = self;
|
|
const RTL = isRTL(target);
|
|
|
|
self.isTouch = false;
|
|
toggleCarouselTouchHandlers(self);
|
|
|
|
if (doc.getSelection().toString().length) {
|
|
// reset pointer position
|
|
startX = 0; currentX = 0; endX = 0;
|
|
return;
|
|
}
|
|
|
|
endX = e.pageX;
|
|
|
|
// the event target is outside the carousel context
|
|
// OR swipe distance is less than 120px
|
|
/* istanbul ignore else */
|
|
if (!element.contains(target) || Math.abs(startX - endX) < 120) {
|
|
// reset pointer position
|
|
startX = 0; currentX = 0; endX = 0;
|
|
return;
|
|
}
|
|
// OR determine next index to slide to
|
|
/* istanbul ignore else */
|
|
if (currentX < startX) {
|
|
self.to(index + (RTL ? -1 : 1));
|
|
} else if (currentX > startX) {
|
|
self.to(index + (RTL ? 1 : -1));
|
|
}
|
|
// reset pointer position
|
|
startX = 0; currentX = 0; endX = 0;
|
|
}
|
|
|
|
// CAROUSEL PRIVATE METHODS
|
|
// ========================
|
|
/**
|
|
* Sets active indicator for the `Carousel` instance.
|
|
* @param {Carousel} self the `Carousel` instance
|
|
* @param {number} pageIndex the index of the new active indicator
|
|
*/
|
|
function activateCarouselIndicator(self, pageIndex) {
|
|
const { indicators } = self;
|
|
[...indicators].forEach((x) => removeClass(x, activeClass));
|
|
|
|
/* istanbul ignore else */
|
|
if (self.indicators[pageIndex]) addClass(indicators[pageIndex], activeClass);
|
|
}
|
|
|
|
/**
|
|
* Toggles the pointer event listeners for a given `Carousel` instance.
|
|
* @param {Carousel} self the `Carousel` instance
|
|
* @param {boolean=} add when `TRUE` event listeners are added
|
|
*/
|
|
function toggleCarouselTouchHandlers(self, add) {
|
|
const { element } = self;
|
|
const action = add ? addListener : removeListener;
|
|
action(getDocument(element), pointermoveEvent, carouselPointerMoveHandler, passiveHandler);
|
|
action(getDocument(element), pointerupEvent, carouselPointerUpHandler, passiveHandler);
|
|
}
|
|
|
|
/**
|
|
* Toggles all event listeners for a given `Carousel` instance.
|
|
* @param {Carousel} self the `Carousel` instance
|
|
* @param {boolean=} add when `TRUE` event listeners are added
|
|
*/
|
|
function toggleCarouselHandlers(self, add) {
|
|
const {
|
|
element, options, slides, controls, indicators,
|
|
} = self;
|
|
const {
|
|
touch, pause, interval, keyboard,
|
|
} = options;
|
|
const action = add ? addListener : removeListener;
|
|
|
|
if (pause && interval) {
|
|
action(element, mouseenterEvent, carouselPauseHandler);
|
|
action(element, mouseleaveEvent, carouselResumeHandler);
|
|
}
|
|
|
|
if (touch && slides.length > 2) {
|
|
action(element, pointerdownEvent, carouselPointerDownHandler, passiveHandler);
|
|
}
|
|
|
|
/* istanbul ignore else */
|
|
if (controls.length) {
|
|
controls.forEach((arrow) => {
|
|
/* istanbul ignore else */
|
|
if (arrow) action(arrow, mouseclickEvent, carouselControlsHandler);
|
|
});
|
|
}
|
|
|
|
/* istanbul ignore else */
|
|
if (indicators.length) {
|
|
indicators.forEach((indicator) => {
|
|
action(indicator, mouseclickEvent, carouselIndicatorHandler);
|
|
});
|
|
}
|
|
|
|
if (keyboard) action(getDocument(element), keydownEvent, carouselKeyHandler);
|
|
}
|
|
|
|
/**
|
|
* Returns the index of the current active item.
|
|
* @param {Carousel} self the `Carousel` instance
|
|
* @returns {number} the query result
|
|
*/
|
|
function getActiveIndex(self) {
|
|
const { slides, element } = self;
|
|
const activeItem = querySelector(`.${carouselItem}.${activeClass}`, element);
|
|
return [...slides].indexOf(activeItem);
|
|
}
|
|
|
|
// CAROUSEL DEFINITION
|
|
// ===================
|
|
/** Creates a new `Carousel` instance. */
|
|
class Carousel extends BaseComponent {
|
|
/**
|
|
* @param {HTMLElement | string} target mostly a `.carousel` element
|
|
* @param {BSN.Options.Carousel=} config instance options
|
|
*/
|
|
constructor(target, config) {
|
|
super(target, config);
|
|
// bind
|
|
const self = this;
|
|
// initialization element
|
|
const { element } = self;
|
|
|
|
// additional properties
|
|
/** @type {string} */
|
|
self.direction = isRTL(element) ? 'right' : 'left';
|
|
/** @type {number} */
|
|
self.index = 0;
|
|
/** @type {boolean} */
|
|
self.isTouch = false;
|
|
|
|
// carousel elements
|
|
// a LIVE collection is prefferable
|
|
self.slides = getElementsByClassName(carouselItem, element);
|
|
const { slides } = self;
|
|
|
|
// invalidate when not enough items
|
|
// no need to go further
|
|
if (slides.length < 2) { return; }
|
|
// external controls must be within same document context
|
|
const doc = getDocument(element);
|
|
|
|
self.controls = [
|
|
...querySelectorAll(`[${dataBsSlide}]`, element),
|
|
...querySelectorAll(`[${dataBsSlide}][${dataBsTarget}="#${element.id}"]`, doc),
|
|
];
|
|
|
|
/** @type {HTMLElement?} */
|
|
self.indicator = querySelector(`.${carouselString}-indicators`, element);
|
|
|
|
// a LIVE collection is prefferable
|
|
/** @type {HTMLElement[]} */
|
|
self.indicators = [
|
|
...(self.indicator ? querySelectorAll(`[${dataBsSlideTo}]`, self.indicator) : []),
|
|
...querySelectorAll(`[${dataBsSlideTo}][${dataBsTarget}="#${element.id}"]`, doc),
|
|
];
|
|
|
|
// set JavaScript and DATA API options
|
|
const { options } = self;
|
|
|
|
// don't use TRUE as interval, it's actually 0, use the default 5000ms better
|
|
self.options.interval = options.interval === true
|
|
? carouselDefaults.interval
|
|
: options.interval;
|
|
|
|
// set first slide active if none
|
|
/* istanbul ignore else */
|
|
if (getActiveIndex(self) < 0) {
|
|
addClass(slides[0], activeClass);
|
|
/* istanbul ignore else */
|
|
if (self.indicators.length) activateCarouselIndicator(self, 0);
|
|
}
|
|
|
|
// attach event handlers
|
|
toggleCarouselHandlers(self, true);
|
|
|
|
// start to cycle if interval is set
|
|
if (options.interval) self.cycle();
|
|
}
|
|
|
|
/* eslint-disable */
|
|
/**
|
|
* Returns component name string.
|
|
*/
|
|
get name() { return carouselComponent; }
|
|
/**
|
|
* Returns component default options.
|
|
*/
|
|
get defaults() { return carouselDefaults; }
|
|
/* eslint-enable */
|
|
|
|
/**
|
|
* Check if instance is paused.
|
|
* @returns {boolean}
|
|
*/
|
|
get isPaused() {
|
|
return hasClass(this.element, pausedClass);
|
|
}
|
|
|
|
/**
|
|
* Check if instance is animating.
|
|
* @returns {boolean}
|
|
*/
|
|
get isAnimating() {
|
|
return querySelector(`.${carouselItem}-next,.${carouselItem}-prev`, this.element) !== null;
|
|
}
|
|
|
|
// CAROUSEL PUBLIC METHODS
|
|
// =======================
|
|
/** Slide automatically through items. */
|
|
cycle() {
|
|
const self = this;
|
|
const {
|
|
element, options, isPaused, index,
|
|
} = self;
|
|
|
|
Timer.clear(element, carouselString);
|
|
if (isPaused) {
|
|
Timer.clear(element, pausedClass);
|
|
removeClass(element, pausedClass);
|
|
}
|
|
|
|
Timer.set(element, () => {
|
|
// it's very important to check self.element
|
|
// where instance might have been disposed
|
|
/* istanbul ignore else */
|
|
if (self.element && !self.isPaused && !self.isTouch
|
|
&& isElementInScrollRange(element)) {
|
|
self.to(index + 1);
|
|
}
|
|
}, options.interval, carouselString);
|
|
}
|
|
|
|
/** Pause the automatic cycle. */
|
|
pause() {
|
|
const self = this;
|
|
const { element, options } = self;
|
|
/* istanbul ignore else */
|
|
if (!self.isPaused && options.interval) {
|
|
addClass(element, pausedClass);
|
|
Timer.set(element, () => {}, 1, pausedClass);
|
|
}
|
|
}
|
|
|
|
/** Slide to the next item. */
|
|
next() {
|
|
const self = this;
|
|
/* istanbul ignore else */
|
|
if (!self.isAnimating) { self.to(self.index + 1); }
|
|
}
|
|
|
|
/** Slide to the previous item. */
|
|
prev() {
|
|
const self = this;
|
|
/* istanbul ignore else */
|
|
if (!self.isAnimating) { self.to(self.index - 1); }
|
|
}
|
|
|
|
/**
|
|
* Jump to the item with the `idx` index.
|
|
* @param {number} idx the index of the item to jump to
|
|
*/
|
|
to(idx) {
|
|
const self = this;
|
|
const {
|
|
element, slides, options,
|
|
} = self;
|
|
const activeItem = getActiveIndex(self);
|
|
const RTL = isRTL(element);
|
|
let next = idx;
|
|
|
|
// when controled via methods, make sure to check again
|
|
// first return if we're on the same item #227
|
|
// `to()` must be SPAM protected by Timer
|
|
if (self.isAnimating || activeItem === next || Timer.get(element, dataBsSlide)) return;
|
|
|
|
// determine transition direction
|
|
/* istanbul ignore else */
|
|
if ((activeItem < next) || (activeItem === 0 && next === slides.length - 1)) {
|
|
self.direction = RTL ? 'right' : 'left'; // next
|
|
} else if ((activeItem > next) || (activeItem === slides.length - 1 && next === 0)) {
|
|
self.direction = RTL ? 'left' : 'right'; // prev
|
|
}
|
|
const { direction } = self;
|
|
|
|
// find the right next index
|
|
if (next < 0) { next = slides.length - 1; } else if (next >= slides.length) { next = 0; }
|
|
|
|
// orientation, class name, eventProperties
|
|
const orientation = direction === 'left' ? 'next' : 'prev';
|
|
const directionClass = direction === 'left' ? 'start' : 'end';
|
|
|
|
const eventProperties = {
|
|
relatedTarget: slides[next],
|
|
from: activeItem,
|
|
to: next,
|
|
direction,
|
|
};
|
|
|
|
// update event properties
|
|
ObjectAssign(carouselSlideEvent, eventProperties);
|
|
ObjectAssign(carouselSlidEvent, eventProperties);
|
|
|
|
// discontinue when prevented
|
|
dispatchEvent(element, carouselSlideEvent);
|
|
if (carouselSlideEvent.defaultPrevented) return;
|
|
|
|
// update index
|
|
self.index = next;
|
|
activateCarouselIndicator(self, next);
|
|
|
|
if (getElementTransitionDuration(slides[next]) && hasClass(element, 'slide')) {
|
|
Timer.set(element, () => {
|
|
addClass(slides[next], `${carouselItem}-${orientation}`);
|
|
reflow(slides[next]);
|
|
addClass(slides[next], `${carouselItem}-${directionClass}`);
|
|
addClass(slides[activeItem], `${carouselItem}-${directionClass}`);
|
|
|
|
emulateTransitionEnd(slides[next], () => carouselTransitionEndHandler(self));
|
|
}, 0, dataBsSlide);
|
|
} else {
|
|
addClass(slides[next], activeClass);
|
|
removeClass(slides[activeItem], activeClass);
|
|
|
|
Timer.set(element, () => {
|
|
Timer.clear(element, dataBsSlide);
|
|
// check for element, might have been disposed
|
|
/* istanbul ignore else */
|
|
if (element && options.interval && !self.isPaused) {
|
|
self.cycle();
|
|
}
|
|
|
|
dispatchEvent(element, carouselSlidEvent);
|
|
}, 0, dataBsSlide);
|
|
}
|
|
}
|
|
|
|
/** Remove `Carousel` component from target. */
|
|
dispose() {
|
|
const self = this;
|
|
const { slides } = self;
|
|
const itemClasses = ['start', 'end', 'prev', 'next'];
|
|
|
|
[...slides].forEach((slide, idx) => {
|
|
if (hasClass(slide, activeClass)) activateCarouselIndicator(self, idx);
|
|
itemClasses.forEach((c) => removeClass(slide, `${carouselItem}-${c}`));
|
|
});
|
|
|
|
toggleCarouselHandlers(self);
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
ObjectAssign(Carousel, {
|
|
selector: carouselSelector,
|
|
init: carouselInitCallback,
|
|
getInstance: getCarouselInstance,
|
|
});
|
|
|
|
/**
|
|
* A global namespace for aria-expanded.
|
|
* @type {string}
|
|
*/
|
|
const ariaExpanded = 'aria-expanded';
|
|
|
|
/**
|
|
* Shortcut for `Object.entries()` static method.
|
|
* @param {Record<string, any>} obj a target object
|
|
* @returns {[string, any][]}
|
|
*/
|
|
const ObjectEntries = (obj) => Object.entries(obj);
|
|
|
|
/**
|
|
* Shortcut for multiple uses of `HTMLElement.style.propertyName` method.
|
|
* @param {HTMLElement} element target element
|
|
* @param {Partial<CSSStyleDeclaration>} styles attribute value
|
|
*/
|
|
const setElementStyle = (element, styles) => {
|
|
ObjectEntries(styles).forEach(([key, value]) => {
|
|
if (key.includes('--')) {
|
|
element.style.setProperty(key, value);
|
|
} else {
|
|
const propObject = {}; propObject[key] = value;
|
|
ObjectAssign(element.style, propObject);
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Global namespace for most components `collapsing` class.
|
|
* As used by `Collapse` / `Tab`.
|
|
*/
|
|
const collapsingClass = 'collapsing';
|
|
|
|
/** @type {string} */
|
|
const collapseString = 'collapse';
|
|
|
|
/** @type {string} */
|
|
const collapseComponent = 'Collapse';
|
|
|
|
/* Native JavaScript for Bootstrap 5 | Collapse
|
|
----------------------------------------------- */
|
|
|
|
// COLLAPSE GC
|
|
// ===========
|
|
const collapseSelector = `.${collapseString}`;
|
|
const collapseToggleSelector = `[${dataBsToggle}="${collapseString}"]`;
|
|
const collapseDefaults = { parent: null };
|
|
|
|
/**
|
|
* Static method which returns an existing `Collapse` instance associated
|
|
* to a target `Element`.
|
|
*
|
|
* @type {BSN.GetInstance<Collapse>}
|
|
*/
|
|
const getCollapseInstance = (element) => getInstance(element, collapseComponent);
|
|
|
|
/**
|
|
* A `Collapse` initialization callback.
|
|
* @type {BSN.InitCallback<Collapse>}
|
|
*/
|
|
const collapseInitCallback = (element) => new Collapse(element);
|
|
|
|
// COLLAPSE CUSTOM EVENTS
|
|
// ======================
|
|
const showCollapseEvent = OriginalEvent(`show.bs.${collapseString}`);
|
|
const shownCollapseEvent = OriginalEvent(`shown.bs.${collapseString}`);
|
|
const hideCollapseEvent = OriginalEvent(`hide.bs.${collapseString}`);
|
|
const hiddenCollapseEvent = OriginalEvent(`hidden.bs.${collapseString}`);
|
|
|
|
// COLLAPSE PRIVATE METHODS
|
|
// ========================
|
|
/**
|
|
* Expand the designated `Element`.
|
|
* @param {Collapse} self the `Collapse` instance
|
|
*/
|
|
function expandCollapse(self) {
|
|
const {
|
|
element, parent, triggers,
|
|
} = self;
|
|
|
|
dispatchEvent(element, showCollapseEvent);
|
|
if (showCollapseEvent.defaultPrevented) return;
|
|
|
|
Timer.set(element, () => {}, 17);
|
|
if (parent) Timer.set(parent, () => {}, 17);
|
|
|
|
addClass(element, collapsingClass);
|
|
removeClass(element, collapseString);
|
|
|
|
setElementStyle(element, { height: `${element.scrollHeight}px` });
|
|
|
|
emulateTransitionEnd(element, () => {
|
|
Timer.clear(element);
|
|
if (parent) Timer.clear(parent);
|
|
|
|
triggers.forEach((btn) => setAttribute(btn, ariaExpanded, 'true'));
|
|
|
|
removeClass(element, collapsingClass);
|
|
addClass(element, collapseString);
|
|
addClass(element, showClass);
|
|
|
|
setElementStyle(element, { height: '' });
|
|
|
|
dispatchEvent(element, shownCollapseEvent);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Collapse the designated `Element`.
|
|
* @param {Collapse} self the `Collapse` instance
|
|
*/
|
|
function collapseContent(self) {
|
|
const {
|
|
element, parent, triggers,
|
|
} = self;
|
|
|
|
dispatchEvent(element, hideCollapseEvent);
|
|
|
|
if (hideCollapseEvent.defaultPrevented) return;
|
|
|
|
Timer.set(element, () => {}, 17);
|
|
if (parent) Timer.set(parent, () => {}, 17);
|
|
|
|
setElementStyle(element, { height: `${element.scrollHeight}px` });
|
|
|
|
removeClass(element, collapseString);
|
|
removeClass(element, showClass);
|
|
addClass(element, collapsingClass);
|
|
|
|
reflow(element);
|
|
setElementStyle(element, { height: '0px' });
|
|
|
|
emulateTransitionEnd(element, () => {
|
|
Timer.clear(element);
|
|
/* istanbul ignore else */
|
|
if (parent) Timer.clear(parent);
|
|
|
|
triggers.forEach((btn) => setAttribute(btn, ariaExpanded, 'false'));
|
|
|
|
removeClass(element, collapsingClass);
|
|
addClass(element, collapseString);
|
|
|
|
setElementStyle(element, { height: '' });
|
|
|
|
dispatchEvent(element, hiddenCollapseEvent);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Toggles on/off the event listener(s) of the `Collapse` instance.
|
|
* @param {Collapse} self the `Collapse` instance
|
|
* @param {boolean=} add when `true`, the event listener is added
|
|
*/
|
|
function toggleCollapseHandler(self, add) {
|
|
const action = add ? addListener : removeListener;
|
|
const { triggers } = self;
|
|
|
|
/* istanbul ignore else */
|
|
if (triggers.length) {
|
|
triggers.forEach((btn) => action(btn, mouseclickEvent, collapseClickHandler));
|
|
}
|
|
}
|
|
|
|
// COLLAPSE EVENT HANDLER
|
|
// ======================
|
|
/**
|
|
* Handles the `click` event for the `Collapse` instance.
|
|
* @param {MouseEvent} e the `Event` object
|
|
*/
|
|
function collapseClickHandler(e) {
|
|
const { target } = e; // our target is `HTMLElement`
|
|
const trigger = target && closest(target, collapseToggleSelector);
|
|
const element = trigger && getTargetElement(trigger);
|
|
const self = element && getCollapseInstance(element);
|
|
/* istanbul ignore else */
|
|
if (self) self.toggle();
|
|
|
|
// event target is anchor link #398
|
|
if (trigger && trigger.tagName === 'A') e.preventDefault();
|
|
}
|
|
|
|
// COLLAPSE DEFINITION
|
|
// ===================
|
|
|
|
/** Returns a new `Colapse` instance. */
|
|
class Collapse extends BaseComponent {
|
|
/**
|
|
* @param {HTMLElement | string} target and `Element` that matches the selector
|
|
* @param {BSN.Options.Collapse=} config instance options
|
|
*/
|
|
constructor(target, config) {
|
|
super(target, config);
|
|
// bind
|
|
const self = this;
|
|
|
|
// initialization element
|
|
const { element, options } = self;
|
|
const doc = getDocument(element);
|
|
|
|
// set triggering elements
|
|
/** @type {HTMLElement[]} */
|
|
self.triggers = [...querySelectorAll(collapseToggleSelector, doc)]
|
|
.filter((btn) => getTargetElement(btn) === element);
|
|
|
|
// set parent accordion
|
|
/** @type {HTMLElement?} */
|
|
self.parent = querySelector(options.parent, doc)
|
|
|| getTargetElement(element) || null;
|
|
|
|
// add event listeners
|
|
toggleCollapseHandler(self, true);
|
|
}
|
|
|
|
/* eslint-disable */
|
|
/**
|
|
* Returns component name string.
|
|
*/
|
|
get name() { return collapseComponent; }
|
|
/**
|
|
* Returns component default options.
|
|
*/
|
|
get defaults() { return collapseDefaults; }
|
|
/* eslint-enable */
|
|
|
|
// COLLAPSE PUBLIC METHODS
|
|
// =======================
|
|
/** Toggles the visibility of the collapse. */
|
|
toggle() {
|
|
const self = this;
|
|
if (!hasClass(self.element, showClass)) self.show();
|
|
else self.hide();
|
|
}
|
|
|
|
/** Hides the collapse. */
|
|
hide() {
|
|
const self = this;
|
|
const { triggers, element } = self;
|
|
if (Timer.get(element)) return;
|
|
|
|
collapseContent(self);
|
|
/* istanbul ignore else */
|
|
if (triggers.length) {
|
|
triggers.forEach((btn) => addClass(btn, `${collapseString}d`));
|
|
}
|
|
}
|
|
|
|
/** Shows the collapse. */
|
|
show() {
|
|
const self = this;
|
|
const {
|
|
element, parent, triggers,
|
|
} = self;
|
|
let activeCollapse;
|
|
let activeCollapseInstance;
|
|
|
|
if (parent) {
|
|
activeCollapse = [...querySelectorAll(`.${collapseString}.${showClass}`, parent)]
|
|
.find((i) => getCollapseInstance(i));
|
|
activeCollapseInstance = activeCollapse && getCollapseInstance(activeCollapse);
|
|
}
|
|
|
|
if ((!parent || !Timer.get(parent)) && !Timer.get(element)) {
|
|
if (activeCollapseInstance && activeCollapse !== element) {
|
|
collapseContent(activeCollapseInstance);
|
|
activeCollapseInstance.triggers.forEach((btn) => {
|
|
addClass(btn, `${collapseString}d`);
|
|
});
|
|
}
|
|
|
|
expandCollapse(self);
|
|
/* istanbul ignore else */
|
|
if (triggers.length) {
|
|
triggers.forEach((btn) => removeClass(btn, `${collapseString}d`));
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Remove the `Collapse` component from the target `Element`. */
|
|
dispose() {
|
|
const self = this;
|
|
toggleCollapseHandler(self);
|
|
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
ObjectAssign(Collapse, {
|
|
selector: collapseSelector,
|
|
init: collapseInitCallback,
|
|
getInstance: getCollapseInstance,
|
|
});
|
|
|
|
/**
|
|
* A global namespace for `focus` event.
|
|
* @type {string}
|
|
*/
|
|
const focusEvent = 'focus';
|
|
|
|
/**
|
|
* A global namespace for `keyup` event.
|
|
* @type {string}
|
|
*/
|
|
const keyupEvent = 'keyup';
|
|
|
|
/**
|
|
* A global namespace for `scroll` event.
|
|
* @type {string}
|
|
*/
|
|
const scrollEvent = 'scroll';
|
|
|
|
/**
|
|
* A global namespace for `resize` event.
|
|
* @type {string}
|
|
*/
|
|
const resizeEvent = 'resize';
|
|
|
|
/**
|
|
* A global namespace for `ArrowUp` key.
|
|
* @type {string} e.which = 38 equivalent
|
|
*/
|
|
const keyArrowUp = 'ArrowUp';
|
|
|
|
/**
|
|
* A global namespace for `ArrowDown` key.
|
|
* @type {string} e.which = 40 equivalent
|
|
*/
|
|
const keyArrowDown = 'ArrowDown';
|
|
|
|
/**
|
|
* A global namespace for `Escape` key.
|
|
* @type {string} e.which = 27 equivalent
|
|
*/
|
|
const keyEscape = 'Escape';
|
|
|
|
/**
|
|
* Shortcut for `HTMLElement.hasAttribute()` method.
|
|
* @param {HTMLElement} element target element
|
|
* @param {string} attribute attribute name
|
|
* @returns {boolean} the query result
|
|
*/
|
|
const hasAttribute = (element, attribute) => element.hasAttribute(attribute);
|
|
|
|
/**
|
|
* Utility to focus an `HTMLElement` target.
|
|
*
|
|
* @param {HTMLElement} element is the target
|
|
*/
|
|
const focus = (element) => element.focus();
|
|
|
|
/**
|
|
* Returns the `Window` object of a target node.
|
|
* @see https://github.com/floating-ui/floating-ui
|
|
*
|
|
* @param {(Node | Window)=} node target node
|
|
* @returns {Window} the `Window` object
|
|
*/
|
|
function getWindow(node) {
|
|
// node is undefined | NULL
|
|
if (!node) return window;
|
|
// node instanceof Document
|
|
if (isDocument(node)) return node.defaultView;
|
|
// node instanceof Node
|
|
if (isNode(node)) return node.ownerDocument.defaultView;
|
|
// node is instanceof Window
|
|
return node;
|
|
}
|
|
|
|
/**
|
|
* Global namespace for `Dropdown` types / classes.
|
|
*/
|
|
const dropdownMenuClasses = ['dropdown', 'dropup', 'dropstart', 'dropend'];
|
|
|
|
/** @type {string} */
|
|
const dropdownComponent = 'Dropdown';
|
|
|
|
/**
|
|
* Global namespace for `.dropdown-menu`.
|
|
*/
|
|
const dropdownMenuClass = 'dropdown-menu';
|
|
|
|
/**
|
|
* Checks if an *event.target* or its parent has an `href="#"` value.
|
|
* We need to prevent jumping around onclick, don't we?
|
|
*
|
|
* @param {Node} element the target element
|
|
* @returns {boolean} the query result
|
|
*/
|
|
function isEmptyAnchor(element) {
|
|
// `EventTarget` must be `HTMLElement`
|
|
const parentAnchor = closest(element, 'A');
|
|
return isHTMLElement(element)
|
|
// anchor href starts with #
|
|
&& ((hasAttribute(element, 'href') && element.href.slice(-1) === '#')
|
|
// OR a child of an anchor with href starts with #
|
|
|| (parentAnchor && hasAttribute(parentAnchor, 'href')
|
|
&& parentAnchor.href.slice(-1) === '#'));
|
|
}
|
|
|
|
/* Native JavaScript for Bootstrap 5 | Dropdown
|
|
----------------------------------------------- */
|
|
|
|
// DROPDOWN PRIVATE GC
|
|
// ===================
|
|
const [
|
|
dropdownString,
|
|
dropupString,
|
|
dropstartString,
|
|
dropendString,
|
|
] = dropdownMenuClasses;
|
|
const dropdownSelector = `[${dataBsToggle}="${dropdownString}"]`;
|
|
|
|
/**
|
|
* Static method which returns an existing `Dropdown` instance associated
|
|
* to a target `Element`.
|
|
*
|
|
* @type {BSN.GetInstance<Dropdown>}
|
|
*/
|
|
const getDropdownInstance = (element) => getInstance(element, dropdownComponent);
|
|
|
|
/**
|
|
* A `Dropdown` initialization callback.
|
|
* @type {BSN.InitCallback<Dropdown>}
|
|
*/
|
|
const dropdownInitCallback = (element) => new Dropdown(element);
|
|
|
|
// DROPDOWN PRIVATE GC
|
|
// ===================
|
|
// const dropdownMenuStartClass = `${dropdownMenuClass}-start`;
|
|
const dropdownMenuEndClass = `${dropdownMenuClass}-end`;
|
|
const verticalClass = [dropdownString, dropupString];
|
|
const horizontalClass = [dropstartString, dropendString];
|
|
const menuFocusTags = ['A', 'BUTTON'];
|
|
|
|
const dropdownDefaults = {
|
|
offset: 5, // [number] 5(px)
|
|
display: 'dynamic', // [dynamic|static]
|
|
};
|
|
|
|
// DROPDOWN CUSTOM EVENTS
|
|
// ======================
|
|
const showDropdownEvent = OriginalEvent(`show.bs.${dropdownString}`);
|
|
const shownDropdownEvent = OriginalEvent(`shown.bs.${dropdownString}`);
|
|
const hideDropdownEvent = OriginalEvent(`hide.bs.${dropdownString}`);
|
|
const hiddenDropdownEvent = OriginalEvent(`hidden.bs.${dropdownString}`);
|
|
|
|
// DROPDOWN PRIVATE METHODS
|
|
// ========================
|
|
/**
|
|
* Apply specific style or class names to a `.dropdown-menu` to automatically
|
|
* accomodate the layout and the page scroll.
|
|
*
|
|
* @param {Dropdown} self the `Dropdown` instance
|
|
*/
|
|
function styleDropdown(self) {
|
|
const {
|
|
element, menu, parentElement, options,
|
|
} = self;
|
|
const { offset } = options;
|
|
|
|
// don't apply any style on mobile view
|
|
/* istanbul ignore next: this test requires a navbar */
|
|
if (getElementStyle(menu, 'position') === 'static') return;
|
|
|
|
const RTL = isRTL(element);
|
|
// const menuStart = hasClass(menu, dropdownMenuStartClass);
|
|
const menuEnd = hasClass(menu, dropdownMenuEndClass);
|
|
|
|
// reset menu offset and position
|
|
const resetProps = ['margin', 'top', 'bottom', 'left', 'right'];
|
|
resetProps.forEach((p) => { menu.style[p] = ''; });
|
|
|
|
// set initial position class
|
|
// take into account .btn-group parent as .dropdown
|
|
// this requires navbar/btn-group/input-group
|
|
let positionClass = dropdownMenuClasses.find((c) => hasClass(parentElement, c))
|
|
|| /* istanbul ignore next: fallback position */ dropdownString;
|
|
|
|
/** @type {Record<string, Record<string, any>>} */
|
|
let dropdownMargin = {
|
|
dropdown: [offset, 0, 0],
|
|
dropup: [0, 0, offset],
|
|
dropstart: RTL ? [-1, 0, 0, offset] : [-1, offset, 0],
|
|
dropend: RTL ? [-1, offset, 0] : [-1, 0, 0, offset],
|
|
};
|
|
|
|
/** @type {Record<string, Record<string, any>>} */
|
|
const dropdownPosition = {
|
|
dropdown: { top: '100%' },
|
|
dropup: { top: 'auto', bottom: '100%' },
|
|
dropstart: RTL ? { left: '100%', right: 'auto' } : { left: 'auto', right: '100%' },
|
|
dropend: RTL ? { left: 'auto', right: '100%' } : { left: '100%', right: 'auto' },
|
|
menuStart: RTL ? { right: 0, left: 'auto' } : { right: 'auto', left: 0 },
|
|
menuEnd: RTL ? { right: 'auto', left: 0 } : { right: 0, left: 'auto' },
|
|
};
|
|
|
|
const { offsetWidth: menuWidth, offsetHeight: menuHeight } = menu;
|
|
|
|
const { clientWidth, clientHeight } = getDocumentElement(element);
|
|
const {
|
|
left: targetLeft, top: targetTop,
|
|
width: targetWidth, height: targetHeight,
|
|
} = getBoundingClientRect(element);
|
|
|
|
// dropstart | dropend
|
|
const leftFullExceed = targetLeft - menuWidth - offset < 0;
|
|
// dropend
|
|
const rightFullExceed = targetLeft + menuWidth + targetWidth + offset >= clientWidth;
|
|
// dropstart | dropend
|
|
const bottomExceed = targetTop + menuHeight + offset >= clientHeight;
|
|
// dropdown
|
|
const bottomFullExceed = targetTop + menuHeight + targetHeight + offset >= clientHeight;
|
|
// dropup
|
|
const topExceed = targetTop - menuHeight - offset < 0;
|
|
// dropdown / dropup
|
|
const leftExceed = ((!RTL && menuEnd) || (RTL && !menuEnd))
|
|
&& targetLeft + targetWidth - menuWidth < 0;
|
|
const rightExceed = ((RTL && menuEnd) || (!RTL && !menuEnd))
|
|
&& targetLeft + menuWidth >= clientWidth;
|
|
|
|
// recompute position
|
|
// handle RTL as well
|
|
if (horizontalClass.includes(positionClass) && leftFullExceed && rightFullExceed) {
|
|
positionClass = dropdownString;
|
|
}
|
|
if (positionClass === dropstartString && (!RTL ? leftFullExceed : rightFullExceed)) {
|
|
positionClass = dropendString;
|
|
}
|
|
if (positionClass === dropendString && (RTL ? leftFullExceed : rightFullExceed)) {
|
|
positionClass = dropstartString;
|
|
}
|
|
if (positionClass === dropupString && topExceed && !bottomFullExceed) {
|
|
positionClass = dropdownString;
|
|
}
|
|
if (positionClass === dropdownString && bottomFullExceed && !topExceed) {
|
|
positionClass = dropupString;
|
|
}
|
|
|
|
// override position for horizontal classes
|
|
if (horizontalClass.includes(positionClass) && bottomExceed) {
|
|
ObjectAssign(dropdownPosition[positionClass], {
|
|
top: 'auto', bottom: 0,
|
|
});
|
|
}
|
|
|
|
// override position for vertical classes
|
|
if (verticalClass.includes(positionClass) && (leftExceed || rightExceed)) {
|
|
// don't realign when menu is wider than window
|
|
// in both RTL and non-RTL readability is KING
|
|
let posAjust;
|
|
if (!leftExceed && rightExceed && !RTL) posAjust = { left: 'auto', right: 0 };
|
|
if (leftExceed && !rightExceed && RTL) posAjust = { left: 0, right: 'auto' };
|
|
if (posAjust) ObjectAssign(dropdownPosition[positionClass], posAjust);
|
|
}
|
|
|
|
dropdownMargin = dropdownMargin[positionClass];
|
|
setElementStyle(menu, {
|
|
...dropdownPosition[positionClass],
|
|
margin: `${dropdownMargin.map((x) => (x ? `${x}px` : x)).join(' ')}`,
|
|
});
|
|
|
|
// override dropdown-menu-start | dropdown-menu-end
|
|
if (verticalClass.includes(positionClass) && menuEnd) {
|
|
/* istanbul ignore else */
|
|
if (menuEnd) {
|
|
const endAdjust = (!RTL && leftExceed) || (RTL && rightExceed)
|
|
? 'menuStart' : /* istanbul ignore next */'menuEnd';
|
|
setElementStyle(menu, dropdownPosition[endAdjust]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns an `Array` of focusable items in the given dropdown-menu.
|
|
* @param {HTMLElement} menu
|
|
* @returns {HTMLElement[]}
|
|
*/
|
|
function getMenuItems(menu) {
|
|
return [...menu.children].map((c) => {
|
|
if (c && menuFocusTags.includes(c.tagName)) return c;
|
|
const { firstElementChild } = c;
|
|
if (firstElementChild && menuFocusTags.includes(firstElementChild.tagName)) {
|
|
return firstElementChild;
|
|
}
|
|
return null;
|
|
}).filter((c) => c);
|
|
}
|
|
|
|
/**
|
|
* Toggles on/off the listeners for the events that close the dropdown
|
|
* as well as event that request a new position for the dropdown.
|
|
*
|
|
* @param {Dropdown} self the `Dropdown` instance
|
|
*/
|
|
function toggleDropdownDismiss(self) {
|
|
const { element, options } = self;
|
|
const action = self.open ? addListener : removeListener;
|
|
const doc = getDocument(element);
|
|
|
|
action(doc, mouseclickEvent, dropdownDismissHandler);
|
|
action(doc, focusEvent, dropdownDismissHandler);
|
|
action(doc, keydownEvent, dropdownPreventScroll);
|
|
action(doc, keyupEvent, dropdownKeyHandler);
|
|
|
|
/* istanbul ignore else */
|
|
if (options.display === 'dynamic') {
|
|
[scrollEvent, resizeEvent].forEach((ev) => {
|
|
action(getWindow(element), ev, dropdownLayoutHandler, passiveHandler);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggles on/off the `click` event listener of the `Dropdown`.
|
|
*
|
|
* @param {Dropdown} self the `Dropdown` instance
|
|
* @param {boolean=} add when `true`, it will add the event listener
|
|
*/
|
|
function toggleDropdownHandler(self, add) {
|
|
const action = add ? addListener : removeListener;
|
|
action(self.element, mouseclickEvent, dropdownClickHandler);
|
|
}
|
|
|
|
/**
|
|
* Returns the currently open `.dropdown` element.
|
|
*
|
|
* @param {(Node | Window)=} element target
|
|
* @returns {HTMLElement?} the query result
|
|
*/
|
|
function getCurrentOpenDropdown(element) {
|
|
const currentParent = [...dropdownMenuClasses, 'btn-group', 'input-group']
|
|
.map((c) => getElementsByClassName(`${c} ${showClass}`, getDocument(element)))
|
|
.find((x) => x.length);
|
|
|
|
if (currentParent && currentParent.length) {
|
|
return [...currentParent[0].children]
|
|
.find((x) => hasAttribute(x, dataBsToggle));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// DROPDOWN EVENT HANDLERS
|
|
// =======================
|
|
/**
|
|
* Handles the `click` event for the `Dropdown` instance.
|
|
*
|
|
* @param {MouseEvent} e event object
|
|
* @this {Document}
|
|
*/
|
|
function dropdownDismissHandler(e) {
|
|
const { target, type } = e;
|
|
|
|
/* istanbul ignore next: impossible to satisfy */
|
|
if (!target || !target.closest) return; // some weird FF bug #409
|
|
|
|
const element = getCurrentOpenDropdown(target);
|
|
const self = getDropdownInstance(element);
|
|
|
|
/* istanbul ignore next */
|
|
if (!self) return;
|
|
|
|
const { parentElement, menu } = self;
|
|
|
|
const hasData = closest(target, dropdownSelector) !== null;
|
|
const isForm = parentElement && parentElement.contains(target)
|
|
&& (target.tagName === 'form' || closest(target, 'form') !== null);
|
|
|
|
if (type === mouseclickEvent && isEmptyAnchor(target)) {
|
|
e.preventDefault();
|
|
}
|
|
if (type === focusEvent
|
|
&& (target === element || target === menu || menu.contains(target))) {
|
|
return;
|
|
}
|
|
|
|
/* istanbul ignore else */
|
|
if (isForm || hasData) ; else if (self) {
|
|
self.hide();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles `click` event listener for `Dropdown`.
|
|
* @this {HTMLElement}
|
|
* @param {MouseEvent} e event object
|
|
*/
|
|
function dropdownClickHandler(e) {
|
|
const element = this;
|
|
const { target } = e;
|
|
const self = getDropdownInstance(element);
|
|
|
|
/* istanbul ignore else */
|
|
if (self) {
|
|
self.toggle();
|
|
/* istanbul ignore else */
|
|
if (target && isEmptyAnchor(target)) e.preventDefault();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Prevents scroll when dropdown-menu is visible.
|
|
* @param {KeyboardEvent} e event object
|
|
*/
|
|
function dropdownPreventScroll(e) {
|
|
/* istanbul ignore else */
|
|
if ([keyArrowDown, keyArrowUp].includes(e.code)) e.preventDefault();
|
|
}
|
|
|
|
/**
|
|
* Handles keyboard `keydown` events for `Dropdown`.
|
|
* @param {KeyboardEvent} e keyboard key
|
|
* @this {Document}
|
|
*/
|
|
function dropdownKeyHandler(e) {
|
|
const { code } = e;
|
|
const element = getCurrentOpenDropdown(this);
|
|
const self = element && getDropdownInstance(element);
|
|
const { activeElement } = element && getDocument(element);
|
|
/* istanbul ignore next: impossible to satisfy */
|
|
if (!self || !activeElement) return;
|
|
const { menu, open } = self;
|
|
const menuItems = getMenuItems(menu);
|
|
|
|
// arrow up & down
|
|
if (menuItems && menuItems.length && [keyArrowDown, keyArrowUp].includes(code)) {
|
|
let idx = menuItems.indexOf(activeElement);
|
|
/* istanbul ignore else */
|
|
if (activeElement === element) {
|
|
idx = 0;
|
|
} else if (code === keyArrowUp) {
|
|
idx = idx > 1 ? idx - 1 : 0;
|
|
} else if (code === keyArrowDown) {
|
|
idx = idx < menuItems.length - 1 ? idx + 1 : idx;
|
|
}
|
|
/* istanbul ignore else */
|
|
if (menuItems[idx]) focus(menuItems[idx]);
|
|
}
|
|
|
|
if (keyEscape === code && open) {
|
|
self.toggle();
|
|
focus(element);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @this {globalThis}
|
|
* @returns {void}
|
|
*/
|
|
function dropdownLayoutHandler() {
|
|
const element = getCurrentOpenDropdown(this);
|
|
const self = element && getDropdownInstance(element);
|
|
|
|
/* istanbul ignore else */
|
|
if (self && self.open) styleDropdown(self);
|
|
}
|
|
|
|
// DROPDOWN DEFINITION
|
|
// ===================
|
|
/** Returns a new Dropdown instance. */
|
|
class Dropdown extends BaseComponent {
|
|
/**
|
|
* @param {HTMLElement | string} target Element or string selector
|
|
* @param {BSN.Options.Dropdown=} config the instance options
|
|
*/
|
|
constructor(target, config) {
|
|
super(target, config);
|
|
// bind
|
|
const self = this;
|
|
|
|
// initialization element
|
|
const { element } = self;
|
|
const { parentElement } = element;
|
|
|
|
// set targets
|
|
/** @type {(Element | HTMLElement)} */
|
|
self.parentElement = parentElement;
|
|
/** @type {(Element | HTMLElement)} */
|
|
self.menu = querySelector(`.${dropdownMenuClass}`, parentElement);
|
|
|
|
// set initial state to closed
|
|
/** @type {boolean} */
|
|
self.open = false;
|
|
|
|
// add event listener
|
|
toggleDropdownHandler(self, true);
|
|
}
|
|
|
|
/* eslint-disable */
|
|
/**
|
|
* Returns component name string.
|
|
*/
|
|
get name() { return dropdownComponent; }
|
|
/**
|
|
* Returns component default options.
|
|
*/
|
|
get defaults() { return dropdownDefaults; }
|
|
/* eslint-enable */
|
|
|
|
// DROPDOWN PUBLIC METHODS
|
|
// =======================
|
|
/** Shows/hides the dropdown menu to the user. */
|
|
toggle() {
|
|
const self = this;
|
|
|
|
if (self.open) self.hide();
|
|
else self.show();
|
|
}
|
|
|
|
/** Shows the dropdown menu to the user. */
|
|
show() {
|
|
const self = this;
|
|
const {
|
|
element, open, menu, parentElement,
|
|
} = self;
|
|
|
|
/* istanbul ignore next */
|
|
if (open) return;
|
|
|
|
const currentElement = getCurrentOpenDropdown(element);
|
|
const currentInstance = currentElement && getDropdownInstance(currentElement);
|
|
if (currentInstance) currentInstance.hide();
|
|
|
|
// dispatch event
|
|
[showDropdownEvent, shownDropdownEvent].forEach((e) => {
|
|
e.relatedTarget = element;
|
|
});
|
|
dispatchEvent(parentElement, showDropdownEvent);
|
|
if (showDropdownEvent.defaultPrevented) return;
|
|
|
|
addClass(menu, showClass);
|
|
addClass(parentElement, showClass);
|
|
setAttribute(element, ariaExpanded, 'true');
|
|
|
|
// change menu position
|
|
styleDropdown(self);
|
|
|
|
self.open = !open;
|
|
|
|
focus(element); // focus the element
|
|
toggleDropdownDismiss(self);
|
|
dispatchEvent(parentElement, shownDropdownEvent);
|
|
}
|
|
|
|
/** Hides the dropdown menu from the user. */
|
|
hide() {
|
|
const self = this;
|
|
const {
|
|
element, open, menu, parentElement,
|
|
} = self;
|
|
|
|
/* istanbul ignore next */
|
|
if (!open) return;
|
|
|
|
[hideDropdownEvent, hiddenDropdownEvent].forEach((e) => {
|
|
e.relatedTarget = element;
|
|
});
|
|
dispatchEvent(parentElement, hideDropdownEvent);
|
|
if (hideDropdownEvent.defaultPrevented) return;
|
|
|
|
removeClass(menu, showClass);
|
|
removeClass(parentElement, showClass);
|
|
setAttribute(element, ariaExpanded, 'false');
|
|
|
|
self.open = !open;
|
|
// only re-attach handler if the instance is not disposed
|
|
toggleDropdownDismiss(self);
|
|
dispatchEvent(parentElement, hiddenDropdownEvent);
|
|
}
|
|
|
|
/** Removes the `Dropdown` component from the target element. */
|
|
dispose() {
|
|
const self = this;
|
|
if (self.open) self.hide();
|
|
|
|
toggleDropdownHandler(self);
|
|
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
ObjectAssign(Dropdown, {
|
|
selector: dropdownSelector,
|
|
init: dropdownInitCallback,
|
|
getInstance: getDropdownInstance,
|
|
});
|
|
|
|
/**
|
|
* A global namespace for aria-hidden.
|
|
* @type {string}
|
|
*/
|
|
const ariaHidden = 'aria-hidden';
|
|
|
|
/**
|
|
* A global namespace for aria-modal.
|
|
* @type {string}
|
|
*/
|
|
const ariaModal = 'aria-modal';
|
|
|
|
/**
|
|
* Shortcut for `HTMLElement.removeAttribute()` method.
|
|
* @param {HTMLElement} element target element
|
|
* @param {string} attribute attribute name
|
|
* @returns {void}
|
|
*/
|
|
const removeAttribute = (element, attribute) => element.removeAttribute(attribute);
|
|
|
|
/**
|
|
* Returns the `document.body` or the `<body>` element.
|
|
*
|
|
* @param {(Node | Window)=} node
|
|
* @returns {HTMLBodyElement}
|
|
*/
|
|
function getDocumentBody(node) {
|
|
return getDocument(node).body;
|
|
}
|
|
|
|
/** @type {string} */
|
|
const modalString = 'modal';
|
|
|
|
/** @type {string} */
|
|
const modalComponent = 'Modal';
|
|
|
|
/**
|
|
* Check if target is a `ShadowRoot`.
|
|
*
|
|
* @param {any} element target
|
|
* @returns {boolean} the query result
|
|
*/
|
|
const isShadowRoot = (element) => (element && element.constructor.name === 'ShadowRoot')
|
|
|| false;
|
|
|
|
/**
|
|
* Returns the `parentNode` also going through `ShadowRoot`.
|
|
* @see https://github.com/floating-ui/floating-ui
|
|
*
|
|
* @param {Node} node the target node
|
|
* @returns {Node} the apropriate parent node
|
|
*/
|
|
function getParentNode(node) {
|
|
if (node.nodeName === 'HTML') {
|
|
return node;
|
|
}
|
|
|
|
// this is a quicker (but less type safe) way to save quite some bytes from the bundle
|
|
return (
|
|
node.assignedSlot // step into the shadow DOM of the parent of a slotted node
|
|
|| node.parentNode // DOM Element detected
|
|
|| (isShadowRoot(node) && node.host) // ShadowRoot detected
|
|
|| getDocumentElement(node) // fallback
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if a target element is a `<table>`, `<td>` or `<th>`.
|
|
* This specific check is important for determining
|
|
* the `offsetParent` of a given element.
|
|
*
|
|
* @param {any} element the target element
|
|
* @returns {boolean} the query result
|
|
*/
|
|
const isTableElement = (element) => (element && ['TABLE', 'TD', 'TH'].includes(element.tagName))
|
|
|| false;
|
|
|
|
/**
|
|
* Returns an `HTMLElement` to be used as default value for *options.container*
|
|
* for `Tooltip` / `Popover` components.
|
|
*
|
|
* When `getOffset` is *true*, it returns the `offsetParent` for tooltip/popover
|
|
* offsets computation similar to **floating-ui**.
|
|
* @see https://github.com/floating-ui/floating-ui
|
|
*
|
|
* @param {HTMLElement} element the target
|
|
* @param {boolean=} getOffset when *true* it will return an `offsetParent`
|
|
* @returns {ParentNode | Window} the query result
|
|
*/
|
|
function getElementContainer(element, getOffset) {
|
|
const majorBlockTags = ['HTML', 'BODY'];
|
|
|
|
if (getOffset) {
|
|
/** @type {any} */
|
|
let { offsetParent } = element;
|
|
const win = getWindow(element);
|
|
|
|
while (offsetParent && (isTableElement(offsetParent)
|
|
|| (isHTMLElement(offsetParent)
|
|
// we must count for both fixed & sticky
|
|
&& !['sticky', 'fixed'].includes(getElementStyle(offsetParent, 'position'))))) {
|
|
offsetParent = offsetParent.offsetParent;
|
|
}
|
|
|
|
if (!offsetParent || (majorBlockTags.includes(offsetParent.tagName)
|
|
|| getElementStyle(offsetParent, 'position') === 'static')) {
|
|
offsetParent = win;
|
|
}
|
|
return offsetParent;
|
|
}
|
|
|
|
/** @type {ParentNode[]} */
|
|
const containers = [];
|
|
/** @type {ParentNode} */
|
|
let { parentNode } = element;
|
|
|
|
while (parentNode && !majorBlockTags.includes(parentNode.nodeName)) {
|
|
parentNode = getParentNode(parentNode);
|
|
/* istanbul ignore else */
|
|
if (!(isShadowRoot(parentNode) || !!parentNode.shadowRoot
|
|
|| isTableElement(parentNode))) {
|
|
containers.push(parentNode);
|
|
}
|
|
}
|
|
|
|
return containers.find((c, i) => {
|
|
if (getElementStyle(c, 'position') !== 'relative'
|
|
&& containers.slice(i + 1).every((r) => getElementStyle(r, 'position') === 'static')) {
|
|
return c;
|
|
}
|
|
return null;
|
|
}) || getDocumentBody(element);
|
|
}
|
|
|
|
/**
|
|
* Global namespace for components `fixed-top` class.
|
|
*/
|
|
const fixedTopClass = 'fixed-top';
|
|
|
|
/**
|
|
* Global namespace for components `fixed-bottom` class.
|
|
*/
|
|
const fixedBottomClass = 'fixed-bottom';
|
|
|
|
/**
|
|
* Global namespace for components `sticky-top` class.
|
|
*/
|
|
const stickyTopClass = 'sticky-top';
|
|
|
|
/**
|
|
* Global namespace for components `position-sticky` class.
|
|
*/
|
|
const positionStickyClass = 'position-sticky';
|
|
|
|
/** @param {(HTMLElement | Document)=} parent */
|
|
const getFixedItems = (parent) => [
|
|
...getElementsByClassName(fixedTopClass, parent),
|
|
...getElementsByClassName(fixedBottomClass, parent),
|
|
...getElementsByClassName(stickyTopClass, parent),
|
|
...getElementsByClassName(positionStickyClass, parent),
|
|
...getElementsByClassName('is-fixed', parent),
|
|
];
|
|
|
|
/**
|
|
* Removes *padding* and *overflow* from the `<body>`
|
|
* and all spacing from fixed items.
|
|
* @param {HTMLElement=} element the target modal/offcanvas
|
|
*/
|
|
function resetScrollbar(element) {
|
|
const bd = getDocumentBody(element);
|
|
setElementStyle(bd, {
|
|
paddingRight: '',
|
|
overflow: '',
|
|
});
|
|
|
|
const fixedItems = getFixedItems(bd);
|
|
|
|
if (fixedItems.length) {
|
|
fixedItems.forEach((fixed) => {
|
|
setElementStyle(fixed, {
|
|
paddingRight: '',
|
|
marginRight: '',
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the scrollbar width if the body does overflow
|
|
* the window.
|
|
* @param {HTMLElement=} element
|
|
* @returns {number} the value
|
|
*/
|
|
function measureScrollbar(element) {
|
|
const { clientWidth } = getDocumentElement(element);
|
|
const { innerWidth } = getWindow(element);
|
|
return Math.abs(innerWidth - clientWidth);
|
|
}
|
|
|
|
/**
|
|
* Sets the `<body>` and fixed items style when modal / offcanvas
|
|
* is shown to the user.
|
|
*
|
|
* @param {HTMLElement} element the target modal/offcanvas
|
|
* @param {boolean=} overflow body does overflow or not
|
|
*/
|
|
function setScrollbar(element, overflow) {
|
|
const bd = getDocumentBody(element);
|
|
const bodyPad = parseInt(getElementStyle(bd, 'paddingRight'), 10);
|
|
const isOpen = getElementStyle(bd, 'overflow') === 'hidden';
|
|
const sbWidth = isOpen && bodyPad ? 0 : measureScrollbar(element);
|
|
const fixedItems = getFixedItems(bd);
|
|
|
|
/* istanbul ignore else */
|
|
if (overflow) {
|
|
setElementStyle(bd, {
|
|
overflow: 'hidden',
|
|
paddingRight: `${bodyPad + sbWidth}px`,
|
|
});
|
|
|
|
/* istanbul ignore else */
|
|
if (fixedItems.length) {
|
|
fixedItems.forEach((fixed) => {
|
|
const itemPadValue = getElementStyle(fixed, 'paddingRight');
|
|
fixed.style.paddingRight = `${parseInt(itemPadValue, 10) + sbWidth}px`;
|
|
/* istanbul ignore else */
|
|
if ([stickyTopClass, positionStickyClass].some((c) => hasClass(fixed, c))) {
|
|
const itemMValue = getElementStyle(fixed, 'marginRight');
|
|
fixed.style.marginRight = `${parseInt(itemMValue, 10) - sbWidth}px`;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This is a shortie for `document.createElement` method
|
|
* which allows you to create a new `HTMLElement` for a given `tagName`
|
|
* or based on an object with specific non-readonly attributes:
|
|
* `id`, `className`, `textContent`, `style`, etc.
|
|
* @see https://developer.mozilla.org/en-US/docs/Web/API/Document/createElement
|
|
*
|
|
* @param {Record<string, string> | string} param `tagName` or object
|
|
* @return {HTMLElement} a new `HTMLElement` or `Element`
|
|
*/
|
|
function createElement(param) {
|
|
if (!param) return null;
|
|
|
|
if (typeof param === 'string') {
|
|
return getDocument().createElement(param);
|
|
}
|
|
|
|
const { tagName } = param;
|
|
const attr = { ...param };
|
|
const newElement = createElement(tagName);
|
|
delete attr.tagName;
|
|
ObjectAssign(newElement, attr);
|
|
return newElement;
|
|
}
|
|
|
|
/** @type {string} */
|
|
const offcanvasString = 'offcanvas';
|
|
|
|
const backdropString = 'backdrop';
|
|
const modalBackdropClass = `${modalString}-${backdropString}`;
|
|
const offcanvasBackdropClass = `${offcanvasString}-${backdropString}`;
|
|
const modalActiveSelector = `.${modalString}.${showClass}`;
|
|
const offcanvasActiveSelector = `.${offcanvasString}.${showClass}`;
|
|
|
|
// any document would suffice
|
|
const overlay = createElement('div');
|
|
|
|
/**
|
|
* Returns the current active modal / offcancas element.
|
|
* @param {HTMLElement=} element the context element
|
|
* @returns {HTMLElement?} the requested element
|
|
*/
|
|
function getCurrentOpen(element) {
|
|
return querySelector(`${modalActiveSelector},${offcanvasActiveSelector}`, getDocument(element));
|
|
}
|
|
|
|
/**
|
|
* Toogles from a Modal overlay to an Offcanvas, or vice-versa.
|
|
* @param {boolean=} isModal
|
|
*/
|
|
function toggleOverlayType(isModal) {
|
|
const targetClass = isModal ? modalBackdropClass : offcanvasBackdropClass;
|
|
[modalBackdropClass, offcanvasBackdropClass].forEach((c) => {
|
|
removeClass(overlay, c);
|
|
});
|
|
addClass(overlay, targetClass);
|
|
}
|
|
|
|
/**
|
|
* Append the overlay to DOM.
|
|
* @param {HTMLElement} container
|
|
* @param {boolean} hasFade
|
|
* @param {boolean=} isModal
|
|
*/
|
|
function appendOverlay(container, hasFade, isModal) {
|
|
toggleOverlayType(isModal);
|
|
container.append(overlay);
|
|
if (hasFade) addClass(overlay, fadeClass);
|
|
}
|
|
|
|
/**
|
|
* Shows the overlay to the user.
|
|
*/
|
|
function showOverlay() {
|
|
if (!hasClass(overlay, showClass)) {
|
|
addClass(overlay, showClass);
|
|
reflow(overlay);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hides the overlay from the user.
|
|
*/
|
|
function hideOverlay() {
|
|
removeClass(overlay, showClass);
|
|
}
|
|
|
|
/**
|
|
* Removes the overlay from DOM.
|
|
* @param {HTMLElement=} element
|
|
*/
|
|
function removeOverlay(element) {
|
|
if (!getCurrentOpen(element)) {
|
|
removeClass(overlay, fadeClass);
|
|
overlay.remove();
|
|
resetScrollbar(element);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} element target
|
|
* @returns {boolean}
|
|
*/
|
|
function isVisible(element) {
|
|
return isHTMLElement(element)
|
|
&& getElementStyle(element, 'visibility') !== 'hidden'
|
|
&& element.offsetParent !== null;
|
|
}
|
|
|
|
/* Native JavaScript for Bootstrap 5 | Modal
|
|
-------------------------------------------- */
|
|
|
|
// MODAL PRIVATE GC
|
|
// ================
|
|
const modalSelector = `.${modalString}`;
|
|
const modalToggleSelector = `[${dataBsToggle}="${modalString}"]`;
|
|
const modalDismissSelector = `[${dataBsDismiss}="${modalString}"]`;
|
|
const modalStaticClass = `${modalString}-static`;
|
|
|
|
const modalDefaults = {
|
|
backdrop: true, // boolean|string
|
|
keyboard: true, // boolean
|
|
};
|
|
|
|
/**
|
|
* Static method which returns an existing `Modal` instance associated
|
|
* to a target `Element`.
|
|
*
|
|
* @type {BSN.GetInstance<Modal>}
|
|
*/
|
|
const getModalInstance = (element) => getInstance(element, modalComponent);
|
|
|
|
/**
|
|
* A `Modal` initialization callback.
|
|
* @type {BSN.InitCallback<Modal>}
|
|
*/
|
|
const modalInitCallback = (element) => new Modal(element);
|
|
|
|
// MODAL CUSTOM EVENTS
|
|
// ===================
|
|
const showModalEvent = OriginalEvent(`show.bs.${modalString}`);
|
|
const shownModalEvent = OriginalEvent(`shown.bs.${modalString}`);
|
|
const hideModalEvent = OriginalEvent(`hide.bs.${modalString}`);
|
|
const hiddenModalEvent = OriginalEvent(`hidden.bs.${modalString}`);
|
|
|
|
// MODAL PRIVATE METHODS
|
|
// =====================
|
|
/**
|
|
* Applies special style for the `<body>` and fixed elements
|
|
* when a modal instance is shown to the user.
|
|
*
|
|
* @param {Modal} self the `Modal` instance
|
|
*/
|
|
function setModalScrollbar(self) {
|
|
const { element } = self;
|
|
const scrollbarWidth = measureScrollbar(element);
|
|
const { clientHeight, scrollHeight } = getDocumentElement(element);
|
|
const { clientHeight: modalHeight, scrollHeight: modalScrollHeight } = element;
|
|
const modalOverflow = modalHeight !== modalScrollHeight;
|
|
|
|
/* istanbul ignore else */
|
|
if (!modalOverflow && scrollbarWidth) {
|
|
const pad = !isRTL(element) ? 'paddingRight' : /* istanbul ignore next */'paddingLeft';
|
|
const padStyle = {};
|
|
padStyle[pad] = `${scrollbarWidth}px`;
|
|
setElementStyle(element, padStyle);
|
|
}
|
|
setScrollbar(element, (modalOverflow || clientHeight !== scrollHeight));
|
|
}
|
|
|
|
/**
|
|
* Toggles on/off the listeners of events that close the modal.
|
|
*
|
|
* @param {Modal} self the `Modal` instance
|
|
* @param {boolean=} add when `true`, event listeners are added
|
|
*/
|
|
function toggleModalDismiss(self, add) {
|
|
const action = add ? addListener : removeListener;
|
|
const { element } = self;
|
|
action(element, mouseclickEvent, modalDismissHandler);
|
|
action(getWindow(element), resizeEvent, self.update, passiveHandler);
|
|
action(getDocument(element), keydownEvent, modalKeyHandler);
|
|
}
|
|
|
|
/**
|
|
* Toggles on/off the `click` event listener of the `Modal` instance.
|
|
* @param {Modal} self the `Modal` instance
|
|
* @param {boolean=} add when `true`, event listener is added
|
|
*/
|
|
function toggleModalHandler(self, add) {
|
|
const action = add ? addListener : removeListener;
|
|
const { triggers } = self;
|
|
|
|
/* istanbul ignore else */
|
|
if (triggers.length) {
|
|
triggers.forEach((btn) => action(btn, mouseclickEvent, modalClickHandler));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Executes after a modal is hidden to the user.
|
|
* @param {Modal} self the `Modal` instance
|
|
* @param {Function} callback the `Modal` instance
|
|
*/
|
|
function afterModalHide(self, callback) {
|
|
const { triggers, element, relatedTarget } = self;
|
|
removeOverlay(element);
|
|
setElementStyle(element, { paddingRight: '', display: '' });
|
|
toggleModalDismiss(self);
|
|
|
|
const focusElement = showModalEvent.relatedTarget || triggers.find(isVisible);
|
|
/* istanbul ignore else */
|
|
if (focusElement) focus(focusElement);
|
|
|
|
/* istanbul ignore else */
|
|
if (callback) callback();
|
|
|
|
hiddenModalEvent.relatedTarget = relatedTarget;
|
|
dispatchEvent(element, hiddenModalEvent);
|
|
}
|
|
|
|
/**
|
|
* Executes after a modal is shown to the user.
|
|
* @param {Modal} self the `Modal` instance
|
|
*/
|
|
function afterModalShow(self) {
|
|
const { element, relatedTarget } = self;
|
|
focus(element);
|
|
toggleModalDismiss(self, true);
|
|
|
|
shownModalEvent.relatedTarget = relatedTarget;
|
|
dispatchEvent(element, shownModalEvent);
|
|
}
|
|
|
|
/**
|
|
* Executes before a modal is shown to the user.
|
|
* @param {Modal} self the `Modal` instance
|
|
*/
|
|
function beforeModalShow(self) {
|
|
const { element, hasFade } = self;
|
|
setElementStyle(element, { display: 'block' });
|
|
|
|
setModalScrollbar(self);
|
|
/* istanbul ignore else */
|
|
if (!getCurrentOpen(element)) {
|
|
setElementStyle(getDocumentBody(element), { overflow: 'hidden' });
|
|
}
|
|
|
|
addClass(element, showClass);
|
|
removeAttribute(element, ariaHidden);
|
|
setAttribute(element, ariaModal, 'true');
|
|
|
|
if (hasFade) emulateTransitionEnd(element, () => afterModalShow(self));
|
|
else afterModalShow(self);
|
|
}
|
|
|
|
/**
|
|
* Executes before a modal is hidden to the user.
|
|
* @param {Modal} self the `Modal` instance
|
|
* @param {Function=} callback when `true` skip animation
|
|
*/
|
|
function beforeModalHide(self, callback) {
|
|
const {
|
|
element, options, hasFade,
|
|
} = self;
|
|
|
|
// callback can also be the transitionEvent object, we wanna make sure it's not
|
|
// call is not forced and overlay is visible
|
|
if (options.backdrop && !callback && hasFade && hasClass(overlay, showClass)
|
|
&& !getCurrentOpen(element)) { // AND no modal is visible
|
|
hideOverlay();
|
|
emulateTransitionEnd(overlay, () => afterModalHide(self));
|
|
} else {
|
|
afterModalHide(self, callback);
|
|
}
|
|
}
|
|
|
|
// MODAL EVENT HANDLERS
|
|
// ====================
|
|
/**
|
|
* Handles the `click` event listener for modal.
|
|
* @param {MouseEvent} e the `Event` object
|
|
*/
|
|
function modalClickHandler(e) {
|
|
const { target } = e;
|
|
|
|
const trigger = target && closest(target, modalToggleSelector);
|
|
const element = trigger && getTargetElement(trigger);
|
|
const self = element && getModalInstance(element);
|
|
|
|
/* istanbul ignore else */
|
|
if (trigger && trigger.tagName === 'A') e.preventDefault();
|
|
self.relatedTarget = trigger;
|
|
self.toggle();
|
|
}
|
|
|
|
/**
|
|
* Handles the `keydown` event listener for modal
|
|
* to hide the modal when user type the `ESC` key.
|
|
*
|
|
* @param {KeyboardEvent} e the `Event` object
|
|
*/
|
|
function modalKeyHandler({ code, target }) {
|
|
const element = querySelector(modalActiveSelector, getDocument(target));
|
|
const self = element && getModalInstance(element);
|
|
|
|
const { options } = self;
|
|
/* istanbul ignore else */
|
|
if (options.keyboard && code === keyEscape // the keyboard option is enabled and the key is 27
|
|
&& hasClass(element, showClass)) { // the modal is not visible
|
|
self.relatedTarget = null;
|
|
self.hide();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles the `click` event listeners that hide the modal.
|
|
*
|
|
* @this {HTMLElement}
|
|
* @param {MouseEvent} e the `Event` object
|
|
*/
|
|
function modalDismissHandler(e) {
|
|
const element = this;
|
|
const self = getModalInstance(element);
|
|
|
|
// this timer is needed
|
|
/* istanbul ignore next: must have a filter */
|
|
if (!self || Timer.get(element)) return;
|
|
|
|
const { options, isStatic, modalDialog } = self;
|
|
const { backdrop } = options;
|
|
const { target } = e;
|
|
|
|
const selectedText = getDocument(element).getSelection().toString().length;
|
|
const targetInsideDialog = modalDialog.contains(target);
|
|
const dismiss = target && closest(target, modalDismissSelector);
|
|
|
|
/* istanbul ignore else */
|
|
if (isStatic && !targetInsideDialog) {
|
|
Timer.set(element, () => {
|
|
addClass(element, modalStaticClass);
|
|
emulateTransitionEnd(modalDialog, () => staticTransitionEnd(self));
|
|
}, 17);
|
|
} else if (dismiss || (!selectedText && !isStatic && !targetInsideDialog && backdrop)) {
|
|
self.relatedTarget = dismiss || null;
|
|
self.hide();
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles the `transitionend` event listeners for `Modal`.
|
|
*
|
|
* @param {Modal} self the `Modal` instance
|
|
*/
|
|
function staticTransitionEnd(self) {
|
|
const { element, modalDialog } = self;
|
|
const duration = getElementTransitionDuration(modalDialog) + 17;
|
|
removeClass(element, modalStaticClass);
|
|
// user must wait for zoom out transition
|
|
Timer.set(element, () => Timer.clear(element), duration);
|
|
}
|
|
|
|
// MODAL DEFINITION
|
|
// ================
|
|
/** Returns a new `Modal` instance. */
|
|
class Modal extends BaseComponent {
|
|
/**
|
|
* @param {HTMLElement | string} target usually the `.modal` element
|
|
* @param {BSN.Options.Modal=} config instance options
|
|
*/
|
|
constructor(target, config) {
|
|
super(target, config);
|
|
|
|
// bind
|
|
const self = this;
|
|
|
|
// the modal
|
|
const { element } = self;
|
|
|
|
// the modal-dialog
|
|
/** @type {(HTMLElement)} */
|
|
self.modalDialog = querySelector(`.${modalString}-dialog`, element);
|
|
|
|
// modal can have multiple triggering elements
|
|
/** @type {HTMLElement[]} */
|
|
self.triggers = [...querySelectorAll(modalToggleSelector, getDocument(element))]
|
|
.filter((btn) => getTargetElement(btn) === element);
|
|
|
|
// additional internals
|
|
/** @type {boolean} */
|
|
self.isStatic = self.options.backdrop === 'static';
|
|
/** @type {boolean} */
|
|
self.hasFade = hasClass(element, fadeClass);
|
|
/** @type {HTMLElement?} */
|
|
self.relatedTarget = null;
|
|
/** @type {HTMLBodyElement | HTMLElement} */
|
|
self.container = getElementContainer(element);
|
|
|
|
// attach event listeners
|
|
toggleModalHandler(self, true);
|
|
|
|
// bind
|
|
self.update = self.update.bind(self);
|
|
}
|
|
|
|
/* eslint-disable */
|
|
/**
|
|
* Returns component name string.
|
|
*/
|
|
get name() { return modalComponent; }
|
|
/**
|
|
* Returns component default options.
|
|
*/
|
|
get defaults() { return modalDefaults; }
|
|
/* eslint-enable */
|
|
|
|
// MODAL PUBLIC METHODS
|
|
// ====================
|
|
/** Toggles the visibility of the modal. */
|
|
toggle() {
|
|
const self = this;
|
|
if (hasClass(self.element, showClass)) self.hide();
|
|
else self.show();
|
|
}
|
|
|
|
/** Shows the modal to the user. */
|
|
show() {
|
|
const self = this;
|
|
const {
|
|
element, options, hasFade, relatedTarget, container,
|
|
} = self;
|
|
const { backdrop } = options;
|
|
let overlayDelay = 0;
|
|
|
|
if (hasClass(element, showClass)) return;
|
|
|
|
showModalEvent.relatedTarget = relatedTarget || null;
|
|
dispatchEvent(element, showModalEvent);
|
|
if (showModalEvent.defaultPrevented) return;
|
|
|
|
// we elegantly hide any opened modal/offcanvas
|
|
const currentOpen = getCurrentOpen(element);
|
|
if (currentOpen && currentOpen !== element) {
|
|
const this1 = getModalInstance(currentOpen);
|
|
const that1 = this1
|
|
|| /* istanbul ignore next */getInstance(currentOpen, 'Offcanvas');
|
|
that1.hide();
|
|
}
|
|
|
|
if (backdrop) {
|
|
if (!container.contains(overlay)) {
|
|
appendOverlay(container, hasFade, true);
|
|
} else {
|
|
toggleOverlayType(true);
|
|
}
|
|
|
|
overlayDelay = getElementTransitionDuration(overlay);
|
|
|
|
showOverlay();
|
|
setTimeout(() => beforeModalShow(self), overlayDelay);
|
|
} else {
|
|
beforeModalShow(self);
|
|
/* istanbul ignore else */
|
|
if (currentOpen && hasClass(overlay, showClass)) {
|
|
hideOverlay();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hide the modal from the user.
|
|
* @param {Function=} callback when defined it will skip animation
|
|
*/
|
|
hide(callback) {
|
|
const self = this;
|
|
const {
|
|
element, hasFade, relatedTarget,
|
|
} = self;
|
|
|
|
if (!hasClass(element, showClass)) return;
|
|
|
|
hideModalEvent.relatedTarget = relatedTarget || null;
|
|
dispatchEvent(element, hideModalEvent);
|
|
if (hideModalEvent.defaultPrevented) return;
|
|
removeClass(element, showClass);
|
|
setAttribute(element, ariaHidden, 'true');
|
|
removeAttribute(element, ariaModal);
|
|
|
|
// if (hasFade && callback) {
|
|
/* istanbul ignore else */
|
|
if (hasFade) {
|
|
emulateTransitionEnd(element, () => beforeModalHide(self, callback));
|
|
} else {
|
|
beforeModalHide(self, callback);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the modal layout.
|
|
* @this {Modal} the modal instance
|
|
*/
|
|
update() {
|
|
const self = this;
|
|
/* istanbul ignore else */
|
|
if (hasClass(self.element, showClass)) setModalScrollbar(self);
|
|
}
|
|
|
|
/** Removes the `Modal` component from target element. */
|
|
dispose() {
|
|
const self = this;
|
|
toggleModalHandler(self);
|
|
// use callback
|
|
self.hide(() => super.dispose());
|
|
}
|
|
}
|
|
|
|
ObjectAssign(Modal, {
|
|
selector: modalSelector,
|
|
init: modalInitCallback,
|
|
getInstance: getModalInstance,
|
|
});
|
|
|
|
/** @type {string} */
|
|
const offcanvasComponent = 'Offcanvas';
|
|
|
|
/* Native JavaScript for Bootstrap 5 | OffCanvas
|
|
------------------------------------------------ */
|
|
|
|
// OFFCANVAS PRIVATE GC
|
|
// ====================
|
|
const offcanvasSelector = `.${offcanvasString}`;
|
|
const offcanvasToggleSelector = `[${dataBsToggle}="${offcanvasString}"]`;
|
|
const offcanvasDismissSelector = `[${dataBsDismiss}="${offcanvasString}"]`;
|
|
const offcanvasTogglingClass = `${offcanvasString}-toggling`;
|
|
|
|
const offcanvasDefaults = {
|
|
backdrop: true, // boolean
|
|
keyboard: true, // boolean
|
|
scroll: false, // boolean
|
|
};
|
|
|
|
/**
|
|
* Static method which returns an existing `Offcanvas` instance associated
|
|
* to a target `Element`.
|
|
*
|
|
* @type {BSN.GetInstance<Offcanvas>}
|
|
*/
|
|
const getOffcanvasInstance = (element) => getInstance(element, offcanvasComponent);
|
|
|
|
/**
|
|
* An `Offcanvas` initialization callback.
|
|
* @type {BSN.InitCallback<Offcanvas>}
|
|
*/
|
|
const offcanvasInitCallback = (element) => new Offcanvas(element);
|
|
|
|
// OFFCANVAS CUSTOM EVENTS
|
|
// =======================
|
|
const showOffcanvasEvent = OriginalEvent(`show.bs.${offcanvasString}`);
|
|
const shownOffcanvasEvent = OriginalEvent(`shown.bs.${offcanvasString}`);
|
|
const hideOffcanvasEvent = OriginalEvent(`hide.bs.${offcanvasString}`);
|
|
const hiddenOffcanvasEvent = OriginalEvent(`hidden.bs.${offcanvasString}`);
|
|
|
|
// OFFCANVAS PRIVATE METHODS
|
|
// =========================
|
|
/**
|
|
* Sets additional style for the `<body>` and other elements
|
|
* when showing an offcanvas to the user.
|
|
*
|
|
* @param {Offcanvas} self the `Offcanvas` instance
|
|
*/
|
|
function setOffCanvasScrollbar(self) {
|
|
const { element } = self;
|
|
const { clientHeight, scrollHeight } = getDocumentElement(element);
|
|
setScrollbar(element, clientHeight !== scrollHeight);
|
|
}
|
|
|
|
/**
|
|
* Toggles on/off the `click` event listeners.
|
|
*
|
|
* @param {Offcanvas} self the `Offcanvas` instance
|
|
* @param {boolean=} add when *true*, listeners are added
|
|
*/
|
|
function toggleOffcanvasEvents(self, add) {
|
|
const action = add ? addListener : removeListener;
|
|
self.triggers.forEach((btn) => action(btn, mouseclickEvent, offcanvasTriggerHandler));
|
|
}
|
|
|
|
/**
|
|
* Toggles on/off the listeners of the events that close the offcanvas.
|
|
*
|
|
* @param {Offcanvas} self the `Offcanvas` instance
|
|
* @param {boolean=} add when *true* listeners are added
|
|
*/
|
|
function toggleOffCanvasDismiss(self, add) {
|
|
const action = add ? addListener : removeListener;
|
|
const doc = getDocument(self.element);
|
|
action(doc, keydownEvent, offcanvasKeyDismissHandler);
|
|
action(doc, mouseclickEvent, offcanvasDismissHandler);
|
|
}
|
|
|
|
/**
|
|
* Executes before showing the offcanvas.
|
|
*
|
|
* @param {Offcanvas} self the `Offcanvas` instance
|
|
*/
|
|
function beforeOffcanvasShow(self) {
|
|
const { element, options } = self;
|
|
|
|
/* istanbul ignore else */
|
|
if (!options.scroll) {
|
|
setOffCanvasScrollbar(self);
|
|
setElementStyle(getDocumentBody(element), { overflow: 'hidden' });
|
|
}
|
|
|
|
addClass(element, offcanvasTogglingClass);
|
|
addClass(element, showClass);
|
|
setElementStyle(element, { visibility: 'visible' });
|
|
|
|
emulateTransitionEnd(element, () => showOffcanvasComplete(self));
|
|
}
|
|
|
|
/**
|
|
* Executes before hiding the offcanvas.
|
|
*
|
|
* @param {Offcanvas} self the `Offcanvas` instance
|
|
* @param {Function=} callback the hide callback
|
|
*/
|
|
function beforeOffcanvasHide(self, callback) {
|
|
const { element, options } = self;
|
|
const currentOpen = getCurrentOpen(element);
|
|
|
|
element.blur();
|
|
|
|
if (!currentOpen && options.backdrop && hasClass(overlay, showClass)) {
|
|
hideOverlay();
|
|
emulateTransitionEnd(overlay, () => hideOffcanvasComplete(self, callback));
|
|
} else hideOffcanvasComplete(self, callback);
|
|
}
|
|
|
|
// OFFCANVAS EVENT HANDLERS
|
|
// ========================
|
|
/**
|
|
* Handles the `click` event listeners.
|
|
*
|
|
* @this {HTMLElement}
|
|
* @param {MouseEvent} e the `Event` object
|
|
*/
|
|
function offcanvasTriggerHandler(e) {
|
|
const trigger = closest(this, offcanvasToggleSelector);
|
|
const element = trigger && getTargetElement(trigger);
|
|
const self = element && getOffcanvasInstance(element);
|
|
|
|
/* istanbul ignore else */
|
|
if (self) {
|
|
self.relatedTarget = trigger;
|
|
self.toggle();
|
|
/* istanbul ignore else */
|
|
if (trigger && trigger.tagName === 'A') {
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles the event listeners that close the offcanvas.
|
|
*
|
|
* @param {MouseEvent} e the `Event` object
|
|
*/
|
|
function offcanvasDismissHandler(e) {
|
|
const { target } = e;
|
|
const element = querySelector(offcanvasActiveSelector, getDocument(target));
|
|
const offCanvasDismiss = querySelector(offcanvasDismissSelector, element);
|
|
const self = getOffcanvasInstance(element);
|
|
|
|
/* istanbul ignore next: must have a filter */
|
|
if (!self) return;
|
|
|
|
const { options, triggers } = self;
|
|
const { backdrop } = options;
|
|
const trigger = closest(target, offcanvasToggleSelector);
|
|
const selection = getDocument(element).getSelection();
|
|
|
|
if (overlay.contains(target) && backdrop === 'static') return;
|
|
|
|
/* istanbul ignore else */
|
|
if (!(selection && selection.toString().length)
|
|
&& ((!element.contains(target) && backdrop
|
|
&& /* istanbul ignore next */(!trigger || triggers.includes(target)))
|
|
|| (offCanvasDismiss && offCanvasDismiss.contains(target)))) {
|
|
self.relatedTarget = offCanvasDismiss && offCanvasDismiss.contains(target)
|
|
? offCanvasDismiss : null;
|
|
self.hide();
|
|
}
|
|
|
|
/* istanbul ignore next */
|
|
if (trigger && trigger.tagName === 'A') e.preventDefault();
|
|
}
|
|
|
|
/**
|
|
* Handles the `keydown` event listener for offcanvas
|
|
* to hide it when user type the `ESC` key.
|
|
*
|
|
* @param {KeyboardEvent} e the `Event` object
|
|
*/
|
|
function offcanvasKeyDismissHandler({ code, target }) {
|
|
const element = querySelector(offcanvasActiveSelector, getDocument(target));
|
|
|
|
const self = getOffcanvasInstance(element);
|
|
|
|
/* istanbul ignore next: must filter */
|
|
if (!self) return;
|
|
|
|
/* istanbul ignore else */
|
|
if (self.options.keyboard && code === keyEscape) {
|
|
self.relatedTarget = null;
|
|
self.hide();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles the `transitionend` when showing the offcanvas.
|
|
*
|
|
* @param {Offcanvas} self the `Offcanvas` instance
|
|
*/
|
|
function showOffcanvasComplete(self) {
|
|
const { element } = self;
|
|
removeClass(element, offcanvasTogglingClass);
|
|
|
|
removeAttribute(element, ariaHidden);
|
|
setAttribute(element, ariaModal, 'true');
|
|
setAttribute(element, 'role', 'dialog');
|
|
|
|
dispatchEvent(element, shownOffcanvasEvent);
|
|
|
|
toggleOffCanvasDismiss(self, true);
|
|
focus(element);
|
|
}
|
|
|
|
/**
|
|
* Handles the `transitionend` when hiding the offcanvas.
|
|
*
|
|
* @param {Offcanvas} self the `Offcanvas` instance
|
|
* @param {Function} callback the hide callback
|
|
*/
|
|
function hideOffcanvasComplete(self, callback) {
|
|
const { element, triggers } = self;
|
|
|
|
setAttribute(element, ariaHidden, 'true');
|
|
removeAttribute(element, ariaModal);
|
|
removeAttribute(element, 'role');
|
|
setElementStyle(element, { visibility: '' });
|
|
|
|
const visibleTrigger = showOffcanvasEvent.relatedTarget || triggers.find((x) => isVisible(x));
|
|
/* istanbul ignore else */
|
|
if (visibleTrigger) focus(visibleTrigger);
|
|
|
|
removeOverlay(element);
|
|
|
|
dispatchEvent(element, hiddenOffcanvasEvent);
|
|
removeClass(element, offcanvasTogglingClass);
|
|
|
|
// must check for open instances
|
|
if (!getCurrentOpen(element)) {
|
|
toggleOffCanvasDismiss(self);
|
|
}
|
|
// callback
|
|
if (callback) callback();
|
|
}
|
|
|
|
// OFFCANVAS DEFINITION
|
|
// ====================
|
|
/** Returns a new `Offcanvas` instance. */
|
|
class Offcanvas extends BaseComponent {
|
|
/**
|
|
* @param {HTMLElement | string} target usually an `.offcanvas` element
|
|
* @param {BSN.Options.Offcanvas=} config instance options
|
|
*/
|
|
constructor(target, config) {
|
|
super(target, config);
|
|
const self = this;
|
|
|
|
// instance element
|
|
const { element } = self;
|
|
|
|
// all the triggering buttons
|
|
/** @type {HTMLElement[]} */
|
|
self.triggers = [...querySelectorAll(offcanvasToggleSelector, getDocument(element))]
|
|
.filter((btn) => getTargetElement(btn) === element);
|
|
|
|
// additional instance property
|
|
/** @type {HTMLBodyElement | HTMLElement} */
|
|
self.container = getElementContainer(element);
|
|
/** @type {HTMLElement?} */
|
|
self.relatedTarget = null;
|
|
|
|
// attach event listeners
|
|
toggleOffcanvasEvents(self, true);
|
|
}
|
|
|
|
/* eslint-disable */
|
|
/**
|
|
* Returns component name string.
|
|
*/
|
|
get name() { return offcanvasComponent; }
|
|
/**
|
|
* Returns component default options.
|
|
*/
|
|
get defaults() { return offcanvasDefaults; }
|
|
/* eslint-enable */
|
|
|
|
// OFFCANVAS PUBLIC METHODS
|
|
// ========================
|
|
/** Shows or hides the offcanvas from the user. */
|
|
toggle() {
|
|
const self = this;
|
|
if (hasClass(self.element, showClass)) self.hide();
|
|
else self.show();
|
|
}
|
|
|
|
/** Shows the offcanvas to the user. */
|
|
show() {
|
|
const self = this;
|
|
const {
|
|
element, options, container, relatedTarget,
|
|
} = self;
|
|
let overlayDelay = 0;
|
|
|
|
if (hasClass(element, showClass)) return;
|
|
|
|
showOffcanvasEvent.relatedTarget = relatedTarget;
|
|
shownOffcanvasEvent.relatedTarget = relatedTarget;
|
|
dispatchEvent(element, showOffcanvasEvent);
|
|
if (showOffcanvasEvent.defaultPrevented) return;
|
|
|
|
// we elegantly hide any opened modal/offcanvas
|
|
const currentOpen = getCurrentOpen(element);
|
|
if (currentOpen && currentOpen !== element) {
|
|
const this1 = getOffcanvasInstance(currentOpen);
|
|
const that1 = this1
|
|
|| /* istanbul ignore next */getInstance(currentOpen, 'Modal');
|
|
that1.hide();
|
|
}
|
|
|
|
if (options.backdrop) {
|
|
if (!container.contains(overlay)) {
|
|
appendOverlay(container, true);
|
|
} else {
|
|
toggleOverlayType();
|
|
}
|
|
|
|
overlayDelay = getElementTransitionDuration(overlay);
|
|
showOverlay();
|
|
|
|
setTimeout(() => beforeOffcanvasShow(self), overlayDelay);
|
|
} else {
|
|
beforeOffcanvasShow(self);
|
|
/* istanbul ignore else */
|
|
if (currentOpen && hasClass(overlay, showClass)) {
|
|
hideOverlay();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hides the offcanvas from the user.
|
|
* @param {Function=} callback when `true` it will skip animation
|
|
*/
|
|
hide(callback) {
|
|
const self = this;
|
|
const { element, relatedTarget } = self;
|
|
|
|
if (!hasClass(element, showClass)) return;
|
|
|
|
hideOffcanvasEvent.relatedTarget = relatedTarget;
|
|
hiddenOffcanvasEvent.relatedTarget = relatedTarget;
|
|
dispatchEvent(element, hideOffcanvasEvent);
|
|
if (hideOffcanvasEvent.defaultPrevented) return;
|
|
|
|
addClass(element, offcanvasTogglingClass);
|
|
removeClass(element, showClass);
|
|
|
|
if (!callback) {
|
|
emulateTransitionEnd(element, () => beforeOffcanvasHide(self, callback));
|
|
} else beforeOffcanvasHide(self, callback);
|
|
}
|
|
|
|
/** Removes the `Offcanvas` from the target element. */
|
|
dispose() {
|
|
const self = this;
|
|
toggleOffcanvasEvents(self);
|
|
self.hide(() => super.dispose());
|
|
}
|
|
}
|
|
|
|
ObjectAssign(Offcanvas, {
|
|
selector: offcanvasSelector,
|
|
init: offcanvasInitCallback,
|
|
getInstance: getOffcanvasInstance,
|
|
});
|
|
|
|
/** @type {string} */
|
|
const popoverString = 'popover';
|
|
|
|
/** @type {string} */
|
|
const popoverComponent = 'Popover';
|
|
|
|
/** @type {string} */
|
|
const tooltipString = 'tooltip';
|
|
|
|
/**
|
|
* Returns a template for Popover / Tooltip.
|
|
*
|
|
* @param {string} tipType the expected markup type
|
|
* @returns {string} the template markup
|
|
*/
|
|
function getTipTemplate(tipType) {
|
|
const isTooltip = tipType === tooltipString;
|
|
const bodyClass = isTooltip ? `${tipType}-inner` : `${tipType}-body`;
|
|
const header = !isTooltip ? `<h3 class="${tipType}-header"></h3>` : '';
|
|
const arrow = `<div class="${tipType}-arrow"></div>`;
|
|
const body = `<div class="${bodyClass}"></div>`;
|
|
return `<div class="${tipType}" role="${tooltipString}">${header + arrow + body}</div>`;
|
|
}
|
|
|
|
/**
|
|
* Checks if an element is an `<svg>` (or any type of SVG element),
|
|
* `<img>` or `<video>`.
|
|
*
|
|
* *Tooltip* / *Popover* works different with media elements.
|
|
* @param {any} element the target element
|
|
* @returns {boolean} the query result
|
|
*/
|
|
|
|
const isMedia = (element) => (
|
|
element
|
|
&& element.nodeType === 1
|
|
&& ['SVG', 'Image', 'Video'].some((s) => element.constructor.name.includes(s))) || false;
|
|
|
|
/**
|
|
* Returns an `{x,y}` object with the target
|
|
* `HTMLElement` / `Node` scroll position.
|
|
*
|
|
* @see https://github.com/floating-ui/floating-ui
|
|
*
|
|
* @param {HTMLElement | Window} element target node / element
|
|
* @returns {{x: number, y: number}} the scroll tuple
|
|
*/
|
|
function getNodeScroll(element) {
|
|
const isWin = 'scrollX' in element;
|
|
const x = isWin ? element.scrollX : element.scrollLeft;
|
|
const y = isWin ? element.scrollY : element.scrollTop;
|
|
|
|
return { x, y };
|
|
}
|
|
|
|
/**
|
|
* Checks if a target `HTMLElement` is affected by scale.
|
|
* @see https://github.com/floating-ui/floating-ui
|
|
*
|
|
* @param {HTMLElement} element target
|
|
* @returns {boolean} the query result
|
|
*/
|
|
function isScaledElement(element) {
|
|
if (!element || !isHTMLElement(element)) return false;
|
|
const { width, height } = getBoundingClientRect(element);
|
|
const { offsetWidth, offsetHeight } = element;
|
|
return Math.round(width) !== offsetWidth
|
|
|| Math.round(height) !== offsetHeight;
|
|
}
|
|
|
|
/**
|
|
* Returns the rect relative to an offset parent.
|
|
* @see https://github.com/floating-ui/floating-ui
|
|
*
|
|
* @param {HTMLElement} element target
|
|
* @param {ParentNode | Window} offsetParent the container / offset parent
|
|
* @param {{x: number, y: number}} scroll the offsetParent scroll position
|
|
* @returns {SHORTY.OffsetRect}
|
|
*/
|
|
function getRectRelativeToOffsetParent(element, offsetParent, scroll) {
|
|
const isParentAnElement = isHTMLElement(offsetParent);
|
|
const rect = getBoundingClientRect(element, isParentAnElement && isScaledElement(offsetParent));
|
|
const offsets = { x: 0, y: 0 };
|
|
|
|
/* istanbul ignore next */
|
|
if (isParentAnElement) {
|
|
const offsetRect = getBoundingClientRect(offsetParent, true);
|
|
offsets.x = offsetRect.x + offsetParent.clientLeft;
|
|
offsets.y = offsetRect.y + offsetParent.clientTop;
|
|
}
|
|
|
|
return {
|
|
x: rect.left + scroll.x - offsets.x,
|
|
y: rect.top + scroll.y - offsets.y,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
};
|
|
}
|
|
|
|
/** @type {Record<string, string>} */
|
|
const tipClassPositions = {
|
|
top: 'top',
|
|
bottom: 'bottom',
|
|
left: 'start',
|
|
right: 'end',
|
|
};
|
|
|
|
/**
|
|
* Style popovers and tooltips.
|
|
* @param {BSN.Tooltip | BSN.Popover} self the `Popover` / `Tooltip` instance
|
|
* @param {PointerEvent=} e event object
|
|
*/
|
|
function styleTip(self, e) {
|
|
const tipClasses = /\b(top|bottom|start|end)+/;
|
|
const {
|
|
element, tooltip, options, arrow, offsetParent,
|
|
} = self;
|
|
const tipPositions = { ...tipClassPositions };
|
|
|
|
const RTL = isRTL(element);
|
|
if (RTL) {
|
|
tipPositions.left = 'end';
|
|
tipPositions.right = 'start';
|
|
}
|
|
|
|
// reset tooltip style (top: 0, left: 0 works best)
|
|
setElementStyle(tooltip, {
|
|
// top: '0px', left: '0px', right: '', bottom: '',
|
|
top: '', left: '', right: '', bottom: '',
|
|
});
|
|
const isPopover = self.name === popoverComponent;
|
|
const {
|
|
offsetWidth: tipWidth, offsetHeight: tipHeight,
|
|
} = tooltip;
|
|
const {
|
|
clientWidth: htmlcw, clientHeight: htmlch,
|
|
} = getDocumentElement(element);
|
|
const { container } = options;
|
|
let { placement } = options;
|
|
const {
|
|
left: parentLeft, right: parentRight, top: parentTop,
|
|
} = getBoundingClientRect(container, true);
|
|
const {
|
|
clientWidth: parentCWidth, offsetWidth: parentOWidth,
|
|
} = container;
|
|
const scrollbarWidth = Math.abs(parentCWidth - parentOWidth);
|
|
// const tipAbsolute = getElementStyle(tooltip, 'position') === 'absolute';
|
|
const parentPosition = getElementStyle(container, 'position');
|
|
// const absoluteParent = parentPosition === 'absolute';
|
|
const fixedParent = parentPosition === 'fixed';
|
|
const staticParent = parentPosition === 'static';
|
|
const stickyParent = parentPosition === 'sticky';
|
|
const isSticky = stickyParent && parentTop === parseFloat(getElementStyle(container, 'top'));
|
|
// const absoluteTarget = getElementStyle(element, 'position') === 'absolute';
|
|
// const stickyFixedParent = ['sticky', 'fixed'].includes(parentPosition);
|
|
const leftBoundry = RTL && fixedParent ? scrollbarWidth : 0;
|
|
const rightBoundry = fixedParent ? parentCWidth + parentLeft + (RTL ? scrollbarWidth : 0)
|
|
: parentCWidth + parentLeft + (htmlcw - parentRight) - 1;
|
|
const {
|
|
width: elemWidth,
|
|
height: elemHeight,
|
|
left: elemRectLeft,
|
|
right: elemRectRight,
|
|
top: elemRectTop,
|
|
} = getBoundingClientRect(element, true);
|
|
|
|
const scroll = getNodeScroll(offsetParent);
|
|
const { x, y } = getRectRelativeToOffsetParent(element, offsetParent, scroll);
|
|
// reset arrow style
|
|
setElementStyle(arrow, {
|
|
top: '', left: '', right: '', bottom: '',
|
|
});
|
|
let topPosition;
|
|
let leftPosition;
|
|
let rightPosition;
|
|
let arrowTop;
|
|
let arrowLeft;
|
|
let arrowRight;
|
|
|
|
const arrowWidth = arrow.offsetWidth || 0;
|
|
const arrowHeight = arrow.offsetHeight || 0;
|
|
const arrowAdjust = arrowWidth / 2;
|
|
|
|
// check placement
|
|
let topExceed = elemRectTop - tipHeight - arrowHeight < 0;
|
|
let bottomExceed = elemRectTop + tipHeight + elemHeight
|
|
+ arrowHeight >= htmlch;
|
|
let leftExceed = elemRectLeft - tipWidth - arrowWidth < leftBoundry;
|
|
let rightExceed = elemRectLeft + tipWidth + elemWidth
|
|
+ arrowWidth >= rightBoundry;
|
|
|
|
const horizontal = ['left', 'right'];
|
|
const vertical = ['top', 'bottom'];
|
|
|
|
topExceed = horizontal.includes(placement)
|
|
? elemRectTop + elemHeight / 2 - tipHeight / 2 - arrowHeight < 0
|
|
: topExceed;
|
|
bottomExceed = horizontal.includes(placement)
|
|
? elemRectTop + tipHeight / 2 + elemHeight / 2 + arrowHeight >= htmlch
|
|
: bottomExceed;
|
|
leftExceed = vertical.includes(placement)
|
|
? elemRectLeft + elemWidth / 2 - tipWidth / 2 < leftBoundry
|
|
: leftExceed;
|
|
rightExceed = vertical.includes(placement)
|
|
? elemRectLeft + tipWidth / 2 + elemWidth / 2 >= rightBoundry
|
|
: rightExceed;
|
|
|
|
// first remove side positions if both left and right limits are exceeded
|
|
// we usually fall back to top|bottom
|
|
placement = (horizontal.includes(placement)) && leftExceed && rightExceed ? 'top' : placement;
|
|
// second, recompute placement
|
|
placement = placement === 'top' && topExceed ? 'bottom' : placement;
|
|
placement = placement === 'bottom' && bottomExceed ? 'top' : placement;
|
|
placement = placement === 'left' && leftExceed ? 'right' : placement;
|
|
placement = placement === 'right' && rightExceed ? 'left' : placement;
|
|
|
|
// update tooltip/popover class
|
|
if (!tooltip.className.includes(placement)) {
|
|
tooltip.className = tooltip.className.replace(tipClasses, tipPositions[placement]);
|
|
}
|
|
|
|
// compute tooltip / popover coordinates
|
|
/* istanbul ignore else */
|
|
if (horizontal.includes(placement)) { // secondary|side positions
|
|
if (placement === 'left') { // LEFT
|
|
leftPosition = x - tipWidth - (isPopover ? arrowWidth : 0);
|
|
} else { // RIGHT
|
|
leftPosition = x + elemWidth + (isPopover ? arrowWidth : 0);
|
|
}
|
|
|
|
// adjust top and arrow
|
|
if (topExceed) {
|
|
topPosition = y;
|
|
topPosition += (isSticky ? -parentTop - scroll.y : 0);
|
|
|
|
arrowTop = elemHeight / 2 - arrowWidth;
|
|
} else if (bottomExceed) {
|
|
topPosition = y - tipHeight + elemHeight;
|
|
topPosition += (isSticky ? -parentTop - scroll.y : 0);
|
|
|
|
arrowTop = tipHeight - elemHeight / 2 - arrowWidth;
|
|
} else {
|
|
topPosition = y - tipHeight / 2 + elemHeight / 2;
|
|
topPosition += (isSticky ? -parentTop - scroll.y : 0);
|
|
|
|
arrowTop = tipHeight / 2 - arrowHeight / 2;
|
|
}
|
|
} else if (vertical.includes(placement)) {
|
|
if (e && isMedia(element)) {
|
|
let eX = 0;
|
|
let eY = 0;
|
|
if (staticParent) {
|
|
eX = e.pageX;
|
|
eY = e.pageY;
|
|
} else { // fixedParent | stickyParent
|
|
eX = e.clientX - parentLeft + (fixedParent ? scroll.x : 0);
|
|
eY = e.clientY - parentTop + (fixedParent ? scroll.y : 0);
|
|
}
|
|
|
|
// some weird RTL bug
|
|
eX -= RTL && fixedParent && scrollbarWidth ? scrollbarWidth : 0;
|
|
|
|
if (placement === 'top') {
|
|
topPosition = eY - tipHeight - arrowWidth;
|
|
} else {
|
|
topPosition = eY + arrowWidth;
|
|
}
|
|
|
|
// adjust (left | right) and also the arrow
|
|
if (e.clientX - tipWidth / 2 < leftBoundry) {
|
|
leftPosition = 0;
|
|
arrowLeft = eX - arrowAdjust;
|
|
} else if (e.clientX + tipWidth / 2 > rightBoundry) {
|
|
leftPosition = 'auto';
|
|
rightPosition = 0;
|
|
arrowRight = rightBoundry - eX - arrowAdjust;
|
|
arrowRight -= fixedParent ? parentLeft + (RTL ? scrollbarWidth : 0) : 0;
|
|
|
|
// normal top/bottom
|
|
} else {
|
|
leftPosition = eX - tipWidth / 2;
|
|
arrowLeft = tipWidth / 2 - arrowAdjust;
|
|
}
|
|
} else {
|
|
if (placement === 'top') {
|
|
topPosition = y - tipHeight - (isPopover ? arrowHeight : 0);
|
|
} else { // BOTTOM
|
|
topPosition = y + elemHeight + (isPopover ? arrowHeight : 0);
|
|
}
|
|
|
|
// adjust left | right and also the arrow
|
|
if (leftExceed) {
|
|
leftPosition = 0;
|
|
arrowLeft = x + elemWidth / 2 - arrowAdjust;
|
|
} else if (rightExceed) {
|
|
leftPosition = 'auto';
|
|
rightPosition = 0;
|
|
arrowRight = elemWidth / 2 + rightBoundry - elemRectRight - arrowAdjust;
|
|
} else {
|
|
leftPosition = x - tipWidth / 2 + elemWidth / 2;
|
|
arrowLeft = tipWidth / 2 - arrowAdjust;
|
|
}
|
|
}
|
|
}
|
|
|
|
// apply style to tooltip/popover
|
|
setElementStyle(tooltip, {
|
|
top: `${topPosition}px`,
|
|
left: leftPosition === 'auto' ? leftPosition : `${leftPosition}px`,
|
|
right: rightPosition !== undefined ? `${rightPosition}px` : '',
|
|
});
|
|
|
|
// update arrow placement
|
|
/* istanbul ignore else */
|
|
if (isHTMLElement(arrow)) {
|
|
if (arrowTop !== undefined) {
|
|
arrow.style.top = `${arrowTop}px`;
|
|
}
|
|
if (arrowLeft !== undefined) {
|
|
arrow.style.left = `${arrowLeft}px`;
|
|
} else if (arrowRight !== undefined) {
|
|
arrow.style.right = `${arrowRight}px`;
|
|
}
|
|
}
|
|
}
|
|
|
|
const tooltipDefaults = {
|
|
/** @type {string} */
|
|
template: getTipTemplate(tooltipString),
|
|
/** @type {string?} */
|
|
title: null, // string
|
|
/** @type {string?} */
|
|
customClass: null, // string | null
|
|
/** @type {string} */
|
|
trigger: 'hover focus',
|
|
/** @type {string?} */
|
|
placement: 'top', // string
|
|
/** @type {((c:string)=>string)?} */
|
|
sanitizeFn: null, // function
|
|
/** @type {boolean} */
|
|
animation: true, // bool
|
|
/** @type {number} */
|
|
delay: 200, // number
|
|
/** @type {HTMLElement?} */
|
|
container: null,
|
|
};
|
|
|
|
/**
|
|
* A global namespace for aria-describedby.
|
|
* @type {string}
|
|
*/
|
|
const ariaDescribedBy = 'aria-describedby';
|
|
|
|
/**
|
|
* A global namespace for `mousedown` event.
|
|
* @type {string}
|
|
*/
|
|
const mousedownEvent = 'mousedown';
|
|
|
|
/**
|
|
* A global namespace for `mousemove` event.
|
|
* @type {string}
|
|
*/
|
|
const mousemoveEvent = 'mousemove';
|
|
|
|
/**
|
|
* A global namespace for `focusin` event.
|
|
* @type {string}
|
|
*/
|
|
const focusinEvent = 'focusin';
|
|
|
|
/**
|
|
* A global namespace for `focusout` event.
|
|
* @type {string}
|
|
*/
|
|
const focusoutEvent = 'focusout';
|
|
|
|
/**
|
|
* A global namespace for `hover` event.
|
|
* @type {string}
|
|
*/
|
|
const mousehoverEvent = 'hover';
|
|
|
|
/**
|
|
* A global namespace for `touchstart` event.
|
|
* @type {string}
|
|
*/
|
|
const touchstartEvent = 'touchstart';
|
|
|
|
let elementUID = 0;
|
|
let elementMapUID = 0;
|
|
const elementIDMap = new Map();
|
|
|
|
/**
|
|
* Returns a unique identifier for popover, tooltip, scrollspy.
|
|
*
|
|
* @param {HTMLElement} element target element
|
|
* @param {string=} key predefined key
|
|
* @returns {number} an existing or new unique ID
|
|
*/
|
|
function getUID(element, key) {
|
|
let result = key ? elementUID : elementMapUID;
|
|
|
|
if (key) {
|
|
const elID = getUID(element);
|
|
const elMap = elementIDMap.get(elID) || new Map();
|
|
if (!elementIDMap.has(elID)) {
|
|
elementIDMap.set(elID, elMap);
|
|
}
|
|
if (!elMap.has(key)) {
|
|
elMap.set(key, result);
|
|
elementUID += 1;
|
|
} else result = elMap.get(key);
|
|
} else {
|
|
const elkey = element.id || element;
|
|
|
|
if (!elementIDMap.has(elkey)) {
|
|
elementIDMap.set(elkey, result);
|
|
elementMapUID += 1;
|
|
} else result = elementIDMap.get(elkey);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Checks if an object is a `Function`.
|
|
*
|
|
* @param {any} fn the target object
|
|
* @returns {boolean} the query result
|
|
*/
|
|
const isFunction = (fn) => (fn && fn.constructor.name === 'Function') || false;
|
|
|
|
const { userAgentData: uaDATA } = navigator;
|
|
|
|
/**
|
|
* A global namespace for `userAgentData` object.
|
|
*/
|
|
const userAgentData = uaDATA;
|
|
|
|
const { userAgent: userAgentString } = navigator;
|
|
|
|
/**
|
|
* A global namespace for `navigator.userAgent` string.
|
|
*/
|
|
const userAgent = userAgentString;
|
|
|
|
const appleBrands = /(iPhone|iPod|iPad)/;
|
|
|
|
/**
|
|
* A global `boolean` for Apple browsers.
|
|
* @type {boolean}
|
|
*/
|
|
const isApple = userAgentData ? userAgentData.brands.some((x) => appleBrands.test(x.brand))
|
|
: /* istanbul ignore next */appleBrands.test(userAgent);
|
|
|
|
/**
|
|
* Global namespace for `data-bs-title` attribute.
|
|
*/
|
|
const dataOriginalTitle = 'data-original-title';
|
|
|
|
/** @type {string} */
|
|
const tooltipComponent = 'Tooltip';
|
|
|
|
/**
|
|
* Checks if an object is a `NodeList`.
|
|
* => equivalent to `object instanceof NodeList`
|
|
*
|
|
* @param {any} object the target object
|
|
* @returns {boolean} the query result
|
|
*/
|
|
const isNodeList = (object) => (object && object.constructor.name === 'NodeList') || false;
|
|
|
|
/**
|
|
* Shortcut for `typeof SOMETHING === "string"`.
|
|
*
|
|
* @param {any} str input value
|
|
* @returns {boolean} the query result
|
|
*/
|
|
const isString = (str) => typeof str === 'string';
|
|
|
|
/**
|
|
* Shortcut for `Array.isArray()` static method.
|
|
*
|
|
* @param {any} arr array-like iterable object
|
|
* @returns {boolean} the query result
|
|
*/
|
|
const isArray = (arr) => Array.isArray(arr);
|
|
|
|
/**
|
|
* Append an existing `Element` to Popover / Tooltip component or HTML
|
|
* markup string to be parsed & sanitized to be used as popover / tooltip content.
|
|
*
|
|
* @param {HTMLElement} element target
|
|
* @param {Node | string} content the `Element` to append / string
|
|
* @param {ReturnType<any>} sanitizeFn a function to sanitize string content
|
|
*/
|
|
function setHtml(element, content, sanitizeFn) {
|
|
/* istanbul ignore next */
|
|
if (!isHTMLElement(element) || (isString(content) && !content.length)) return;
|
|
|
|
/* istanbul ignore else */
|
|
if (isString(content)) {
|
|
let dirty = content.trim(); // fixing #233
|
|
if (isFunction(sanitizeFn)) dirty = sanitizeFn(dirty);
|
|
|
|
const win = getWindow(element);
|
|
const domParser = new win.DOMParser();
|
|
const tempDocument = domParser.parseFromString(dirty, 'text/html');
|
|
element.append(...[...tempDocument.body.childNodes]);
|
|
} else if (isHTMLElement(content)) {
|
|
element.append(content);
|
|
} else if (isNodeList(content)
|
|
|| (isArray(content) && content.every(isNode))) {
|
|
element.append(...[...content]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a new tooltip / popover.
|
|
*
|
|
* @param {BSN.Popover | BSN.Tooltip} self the `Tooltip` / `Popover` instance
|
|
*/
|
|
function createTip(self) {
|
|
const { id, element, options } = self;
|
|
const {
|
|
animation, customClass, sanitizeFn, placement, dismissible,
|
|
title, content, template, btnClose,
|
|
} = options;
|
|
const isTooltip = self.name === tooltipComponent;
|
|
const tipString = isTooltip ? tooltipString : popoverString;
|
|
const tipPositions = { ...tipClassPositions };
|
|
let titleParts = [];
|
|
let contentParts = [];
|
|
|
|
if (isRTL(element)) {
|
|
tipPositions.left = 'end';
|
|
tipPositions.right = 'start';
|
|
}
|
|
|
|
// set initial popover class
|
|
const placementClass = `bs-${tipString}-${tipPositions[placement]}`;
|
|
|
|
// load template
|
|
/** @type {HTMLElement?} */
|
|
let tooltipTemplate;
|
|
if (isHTMLElement(template)) {
|
|
tooltipTemplate = template;
|
|
} else {
|
|
const htmlMarkup = createElement('div');
|
|
setHtml(htmlMarkup, template, sanitizeFn);
|
|
tooltipTemplate = htmlMarkup.firstChild;
|
|
}
|
|
|
|
// set popover markup
|
|
self.tooltip = isHTMLElement(tooltipTemplate) && tooltipTemplate.cloneNode(true);
|
|
|
|
const { tooltip } = self;
|
|
|
|
// set id and role attributes
|
|
setAttribute(tooltip, 'id', id);
|
|
setAttribute(tooltip, 'role', tooltipString);
|
|
|
|
const bodyClass = isTooltip ? `${tooltipString}-inner` : `${popoverString}-body`;
|
|
const tooltipHeader = isTooltip ? null : querySelector(`.${popoverString}-header`, tooltip);
|
|
const tooltipBody = querySelector(`.${bodyClass}`, tooltip);
|
|
|
|
// set arrow and enable access for styleTip
|
|
self.arrow = querySelector(`.${tipString}-arrow`, tooltip);
|
|
const { arrow } = self;
|
|
|
|
if (isHTMLElement(title)) titleParts = [title.cloneNode(true)];
|
|
else {
|
|
const tempTitle = createElement('div');
|
|
setHtml(tempTitle, title, sanitizeFn);
|
|
titleParts = [...[...tempTitle.childNodes]];
|
|
}
|
|
|
|
if (isHTMLElement(content)) contentParts = [content.cloneNode(true)];
|
|
else {
|
|
const tempContent = createElement('div');
|
|
setHtml(tempContent, content, sanitizeFn);
|
|
contentParts = [...[...tempContent.childNodes]];
|
|
}
|
|
|
|
// set dismissible button
|
|
if (dismissible) {
|
|
if (title) {
|
|
if (isHTMLElement(btnClose)) titleParts = [...titleParts, btnClose.cloneNode(true)];
|
|
else {
|
|
const tempBtn = createElement('div');
|
|
setHtml(tempBtn, btnClose, sanitizeFn);
|
|
titleParts = [...titleParts, tempBtn.firstChild];
|
|
}
|
|
} else {
|
|
/* istanbul ignore else */
|
|
if (tooltipHeader) tooltipHeader.remove();
|
|
if (isHTMLElement(btnClose)) contentParts = [...contentParts, btnClose.cloneNode(true)];
|
|
else {
|
|
const tempBtn = createElement('div');
|
|
setHtml(tempBtn, btnClose, sanitizeFn);
|
|
contentParts = [...contentParts, tempBtn.firstChild];
|
|
}
|
|
}
|
|
}
|
|
|
|
// fill the template with content from options / data attributes
|
|
// also sanitize title && content
|
|
/* istanbul ignore else */
|
|
if (!isTooltip) {
|
|
/* istanbul ignore else */
|
|
if (title && tooltipHeader) setHtml(tooltipHeader, titleParts, sanitizeFn);
|
|
/* istanbul ignore else */
|
|
if (content && tooltipBody) setHtml(tooltipBody, contentParts, sanitizeFn);
|
|
// set btn
|
|
self.btn = querySelector('.btn-close', tooltip);
|
|
} else if (title && tooltipBody) setHtml(tooltipBody, title, sanitizeFn);
|
|
|
|
// Bootstrap 5.2.x
|
|
addClass(tooltip, 'position-absolute');
|
|
addClass(arrow, 'position-absolute');
|
|
|
|
// set popover animation and placement
|
|
/* istanbul ignore else */
|
|
if (!hasClass(tooltip, tipString)) addClass(tooltip, tipString);
|
|
/* istanbul ignore else */
|
|
if (animation && !hasClass(tooltip, fadeClass)) addClass(tooltip, fadeClass);
|
|
/* istanbul ignore else */
|
|
if (customClass && !hasClass(tooltip, customClass)) {
|
|
addClass(tooltip, customClass);
|
|
}
|
|
/* istanbul ignore else */
|
|
if (!hasClass(tooltip, placementClass)) addClass(tooltip, placementClass);
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} tip target
|
|
* @param {ParentNode} container parent container
|
|
* @returns {boolean}
|
|
*/
|
|
function isVisibleTip(tip, container) {
|
|
return isHTMLElement(tip) && container.contains(tip);
|
|
}
|
|
|
|
/* Native JavaScript for Bootstrap 5 | Tooltip
|
|
---------------------------------------------- */
|
|
|
|
// TOOLTIP PRIVATE GC
|
|
// ==================
|
|
const tooltipSelector = `[${dataBsToggle}="${tooltipString}"],[data-tip="${tooltipString}"]`;
|
|
const titleAttr = 'title';
|
|
|
|
/**
|
|
* Static method which returns an existing `Tooltip` instance associated
|
|
* to a target `Element`.
|
|
*
|
|
* @type {BSN.GetInstance<Tooltip>}
|
|
*/
|
|
let getTooltipInstance = (element) => getInstance(element, tooltipComponent);
|
|
|
|
/**
|
|
* A `Tooltip` initialization callback.
|
|
* @type {BSN.InitCallback<Tooltip>}
|
|
*/
|
|
const tooltipInitCallback = (element) => new Tooltip(element);
|
|
|
|
// TOOLTIP PRIVATE METHODS
|
|
// =======================
|
|
/**
|
|
* Removes the tooltip from the DOM.
|
|
*
|
|
* @param {Tooltip} self the `Tooltip` instance
|
|
*/
|
|
function removeTooltip(self) {
|
|
const { element, tooltip } = self;
|
|
removeAttribute(element, ariaDescribedBy);
|
|
tooltip.remove();
|
|
}
|
|
|
|
/**
|
|
* Executes after the instance has been disposed.
|
|
*
|
|
* @param {Tooltip} self the `Tooltip` instance
|
|
* @param {Function=} callback the parent dispose callback
|
|
*/
|
|
function disposeTooltipComplete(self, callback) {
|
|
const { element } = self;
|
|
toggleTooltipHandlers(self);
|
|
|
|
/* istanbul ignore else */
|
|
if (hasAttribute(element, dataOriginalTitle) && self.name === tooltipComponent) {
|
|
toggleTooltipTitle(self);
|
|
}
|
|
/* istanbul ignore else */
|
|
if (callback) callback();
|
|
}
|
|
|
|
/**
|
|
* Toggles on/off the special `Tooltip` event listeners.
|
|
*
|
|
* @param {Tooltip} self the `Tooltip` instance
|
|
* @param {boolean=} add when `true`, event listeners are added
|
|
*/
|
|
function toggleTooltipAction(self, add) {
|
|
const action = add ? addListener : removeListener;
|
|
const { element } = self;
|
|
|
|
action(getDocument(element), touchstartEvent, self.handleTouch, passiveHandler);
|
|
|
|
/* istanbul ignore else */
|
|
if (!isMedia(element)) {
|
|
[scrollEvent, resizeEvent].forEach((ev) => {
|
|
action(getWindow(element), ev, self.update, passiveHandler);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Executes after the tooltip was shown to the user.
|
|
*
|
|
* @param {Tooltip} self the `Tooltip` instance
|
|
*/
|
|
function tooltipShownAction(self) {
|
|
const { element } = self;
|
|
const shownTooltipEvent = OriginalEvent(`shown.bs.${toLowerCase(self.name)}`);
|
|
|
|
toggleTooltipAction(self, true);
|
|
dispatchEvent(element, shownTooltipEvent);
|
|
Timer.clear(element, 'in');
|
|
}
|
|
|
|
/**
|
|
* Executes after the tooltip was hidden to the user.
|
|
*
|
|
* @param {Tooltip} self the `Tooltip` instance
|
|
* @param {Function=} callback the dispose callback
|
|
*/
|
|
function tooltipHiddenAction(self, callback) {
|
|
const { element } = self;
|
|
const hiddenTooltipEvent = OriginalEvent(`hidden.bs.${toLowerCase(self.name)}`);
|
|
|
|
toggleTooltipAction(self);
|
|
removeTooltip(self);
|
|
dispatchEvent(element, hiddenTooltipEvent);
|
|
if (isFunction(callback)) callback();
|
|
Timer.clear(element, 'out');
|
|
}
|
|
|
|
/**
|
|
* Toggles on/off the `Tooltip` event listeners.
|
|
*
|
|
* @param {Tooltip} self the `Tooltip` instance
|
|
* @param {boolean=} add when `true`, event listeners are added
|
|
*/
|
|
function toggleTooltipHandlers(self, add) {
|
|
const action = add ? addListener : removeListener;
|
|
// btn is only for dismissible popover
|
|
const { element, options, btn } = self;
|
|
const { trigger, dismissible } = options;
|
|
|
|
if (trigger.includes('manual')) return;
|
|
|
|
self.enabled = !!add;
|
|
|
|
/** @type {string[]} */
|
|
const triggerOptions = trigger.split(' ');
|
|
const elemIsMedia = isMedia(element);
|
|
|
|
if (elemIsMedia) {
|
|
action(element, mousemoveEvent, self.update, passiveHandler);
|
|
}
|
|
|
|
triggerOptions.forEach((tr) => {
|
|
/* istanbul ignore else */
|
|
if (elemIsMedia || tr === mousehoverEvent) {
|
|
action(element, mousedownEvent, self.show);
|
|
action(element, mouseenterEvent, self.show);
|
|
|
|
/* istanbul ignore else */
|
|
if (dismissible && btn) {
|
|
action(btn, mouseclickEvent, self.hide);
|
|
} else {
|
|
action(element, mouseleaveEvent, self.hide);
|
|
action(getDocument(element), touchstartEvent, self.handleTouch, passiveHandler);
|
|
}
|
|
} else if (tr === mouseclickEvent) {
|
|
action(element, tr, (!dismissible ? self.toggle : self.show));
|
|
} else if (tr === focusEvent) {
|
|
action(element, focusinEvent, self.show);
|
|
/* istanbul ignore else */
|
|
if (!dismissible) action(element, focusoutEvent, self.hide);
|
|
/* istanbul ignore else */
|
|
if (isApple) {
|
|
action(element, mouseclickEvent, () => focus(element));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Toggles on/off the `Tooltip` event listeners that hide/update the tooltip.
|
|
*
|
|
* @param {Tooltip} self the `Tooltip` instance
|
|
* @param {boolean=} add when `true`, event listeners are added
|
|
*/
|
|
function toggleTooltipOpenHandlers(self, add) {
|
|
const action = add ? addListener : removeListener;
|
|
const { element, options, offsetParent } = self;
|
|
const { container } = options;
|
|
const { offsetHeight, scrollHeight } = container;
|
|
const parentModal = closest(element, `.${modalString}`);
|
|
const parentOffcanvas = closest(element, `.${offcanvasString}`);
|
|
|
|
/* istanbul ignore else */
|
|
if (!isMedia(element)) {
|
|
const win = getWindow(element);
|
|
const overflow = offsetHeight !== scrollHeight;
|
|
const scrollTarget = overflow || offsetParent !== win ? container : win;
|
|
action(win, resizeEvent, self.update, passiveHandler);
|
|
action(scrollTarget, scrollEvent, self.update, passiveHandler);
|
|
}
|
|
|
|
// dismiss tooltips inside modal / offcanvas
|
|
if (parentModal) action(parentModal, `hide.bs.${modalString}`, self.hide);
|
|
if (parentOffcanvas) action(parentOffcanvas, `hide.bs.${offcanvasString}`, self.hide);
|
|
}
|
|
|
|
/**
|
|
* Toggles the `title` and `data-original-title` attributes.
|
|
*
|
|
* @param {Tooltip} self the `Tooltip` instance
|
|
* @param {string=} content when `true`, event listeners are added
|
|
*/
|
|
function toggleTooltipTitle(self, content) {
|
|
// [0 - add, 1 - remove] | [0 - remove, 1 - add]
|
|
const titleAtt = [dataOriginalTitle, titleAttr];
|
|
const { element } = self;
|
|
|
|
setAttribute(element, titleAtt[content ? 0 : 1],
|
|
(content || getAttribute(element, titleAtt[0])));
|
|
removeAttribute(element, titleAtt[content ? 1 : 0]);
|
|
}
|
|
|
|
// TOOLTIP DEFINITION
|
|
// ==================
|
|
/** Creates a new `Tooltip` instance. */
|
|
class Tooltip extends BaseComponent {
|
|
/**
|
|
* @param {HTMLElement | string} target the target element
|
|
* @param {BSN.Options.Tooltip=} config the instance options
|
|
*/
|
|
constructor(target, config) {
|
|
super(target, config);
|
|
|
|
// bind
|
|
const self = this;
|
|
const { element } = self;
|
|
const isTooltip = self.name === tooltipComponent;
|
|
const tipString = isTooltip ? tooltipString : popoverString;
|
|
const tipComponent = isTooltip ? tooltipComponent : popoverComponent;
|
|
|
|
/* istanbul ignore next: this is to set Popover too */
|
|
getTooltipInstance = (elem) => getInstance(elem, tipComponent);
|
|
|
|
// additional properties
|
|
/** @type {any} */
|
|
self.tooltip = {};
|
|
if (!isTooltip) {
|
|
/** @type {any?} */
|
|
self.btn = null;
|
|
}
|
|
/** @type {any} */
|
|
self.arrow = {};
|
|
/** @type {any} */
|
|
self.offsetParent = {};
|
|
/** @type {boolean} */
|
|
self.enabled = true;
|
|
/** @type {string} Set unique ID for `aria-describedby`. */
|
|
self.id = `${tipString}-${getUID(element, tipString)}`;
|
|
|
|
// instance options
|
|
const { options } = self;
|
|
|
|
// invalidate
|
|
if ((!options.title && isTooltip) || (!isTooltip && !options.content)) {
|
|
// throw Error(`${this.name} Error: target has no content set.`);
|
|
return;
|
|
}
|
|
|
|
const container = querySelector(options.container, getDocument(element));
|
|
const idealContainer = getElementContainer(element);
|
|
|
|
// bypass container option when its position is static/relative
|
|
self.options.container = !container || (container
|
|
&& ['static', 'relative'].includes(getElementStyle(container, 'position')))
|
|
? idealContainer
|
|
: /* istanbul ignore next */container || getDocumentBody(element);
|
|
|
|
// reset default options
|
|
tooltipDefaults[titleAttr] = null;
|
|
|
|
// all functions bind
|
|
self.handleTouch = self.handleTouch.bind(self);
|
|
self.update = self.update.bind(self);
|
|
self.show = self.show.bind(self);
|
|
self.hide = self.hide.bind(self);
|
|
self.toggle = self.toggle.bind(self);
|
|
|
|
// set title attributes and add event listeners
|
|
/* istanbul ignore else */
|
|
if (hasAttribute(element, titleAttr) && isTooltip) {
|
|
toggleTooltipTitle(self, options.title);
|
|
}
|
|
|
|
// create tooltip here
|
|
createTip(self);
|
|
|
|
// attach events
|
|
toggleTooltipHandlers(self, true);
|
|
}
|
|
|
|
/* eslint-disable */
|
|
/**
|
|
* Returns component name string.
|
|
*/
|
|
get name() { return tooltipComponent; }
|
|
/**
|
|
* Returns component default options.
|
|
*/
|
|
get defaults() { return tooltipDefaults; }
|
|
/* eslint-enable */
|
|
|
|
// TOOLTIP PUBLIC METHODS
|
|
// ======================
|
|
/**
|
|
* Shows the tooltip.
|
|
*
|
|
* @param {Event=} e the `Event` object
|
|
* @this {Tooltip}
|
|
*/
|
|
show(e) {
|
|
const self = this;
|
|
const {
|
|
options, tooltip, element, id,
|
|
} = self;
|
|
const { container, animation } = options;
|
|
const outTimer = Timer.get(element, 'out');
|
|
|
|
Timer.clear(element, 'out');
|
|
|
|
if (tooltip && !outTimer && !isVisibleTip(tooltip, container)) {
|
|
Timer.set(element, () => {
|
|
const showTooltipEvent = OriginalEvent(`show.bs.${toLowerCase(self.name)}`);
|
|
dispatchEvent(element, showTooltipEvent);
|
|
if (showTooltipEvent.defaultPrevented) return;
|
|
|
|
// append to container
|
|
container.append(tooltip);
|
|
setAttribute(element, ariaDescribedBy, `#${id}`);
|
|
// set offsetParent
|
|
self.offsetParent = getElementContainer(tooltip, true);
|
|
|
|
self.update(e);
|
|
toggleTooltipOpenHandlers(self, true);
|
|
|
|
/* istanbul ignore else */
|
|
if (!hasClass(tooltip, showClass)) addClass(tooltip, showClass);
|
|
/* istanbul ignore else */
|
|
if (animation) emulateTransitionEnd(tooltip, () => tooltipShownAction(self));
|
|
else tooltipShownAction(self);
|
|
}, 17, 'in');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hides the tooltip.
|
|
*
|
|
* @this {Tooltip} the Tooltip instance
|
|
* @param {Function=} callback the dispose callback
|
|
*/
|
|
hide(callback) {
|
|
const self = this;
|
|
const { options, tooltip, element } = self;
|
|
const { container, animation, delay } = options;
|
|
|
|
Timer.clear(element, 'in');
|
|
|
|
/* istanbul ignore else */
|
|
if (tooltip && isVisibleTip(tooltip, container)) {
|
|
Timer.set(element, () => {
|
|
const hideTooltipEvent = OriginalEvent(`hide.bs.${toLowerCase(self.name)}`);
|
|
dispatchEvent(element, hideTooltipEvent);
|
|
|
|
if (hideTooltipEvent.defaultPrevented) return;
|
|
|
|
removeClass(tooltip, showClass);
|
|
toggleTooltipOpenHandlers(self);
|
|
|
|
/* istanbul ignore else */
|
|
if (animation) emulateTransitionEnd(tooltip, () => tooltipHiddenAction(self, callback));
|
|
else tooltipHiddenAction(self, callback);
|
|
}, delay + 17, 'out');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the tooltip position.
|
|
*
|
|
* @param {Event=} e the `Event` object
|
|
* @this {Tooltip} the `Tooltip` instance
|
|
*/
|
|
update(e) {
|
|
styleTip(this, e);
|
|
}
|
|
|
|
/**
|
|
* Toggles the tooltip visibility.
|
|
*
|
|
* @param {Event=} e the `Event` object
|
|
* @this {Tooltip} the instance
|
|
*/
|
|
toggle(e) {
|
|
const self = this;
|
|
const { tooltip, options } = self;
|
|
|
|
if (!isVisibleTip(tooltip, options.container)) self.show(e);
|
|
else self.hide();
|
|
}
|
|
|
|
/** Enables the tooltip. */
|
|
enable() {
|
|
const self = this;
|
|
const { enabled } = self;
|
|
/* istanbul ignore else */
|
|
if (!enabled) {
|
|
toggleTooltipHandlers(self, true);
|
|
self.enabled = !enabled;
|
|
}
|
|
}
|
|
|
|
/** Disables the tooltip. */
|
|
disable() {
|
|
const self = this;
|
|
const {
|
|
tooltip, options, enabled,
|
|
} = self;
|
|
const { animation, container } = options;
|
|
/* istanbul ignore else */
|
|
if (enabled) {
|
|
if (isVisibleTip(tooltip, container) && animation) {
|
|
self.hide(() => toggleTooltipHandlers(self));
|
|
} else {
|
|
toggleTooltipHandlers(self);
|
|
}
|
|
self.enabled = !enabled;
|
|
}
|
|
}
|
|
|
|
/** Toggles the `disabled` property. */
|
|
toggleEnabled() {
|
|
const self = this;
|
|
if (!self.enabled) self.enable();
|
|
else self.disable();
|
|
}
|
|
|
|
/**
|
|
* Handles the `touchstart` event listener for `Tooltip`
|
|
* @this {Tooltip}
|
|
* @param {TouchEvent} e the `Event` object
|
|
*/
|
|
handleTouch({ target }) {
|
|
const { tooltip, element } = this;
|
|
|
|
/* istanbul ignore next */
|
|
if (tooltip.contains(target) || target === element
|
|
|| (target && element.contains(target))) ; else {
|
|
this.hide();
|
|
}
|
|
}
|
|
|
|
/** Removes the `Tooltip` from the target element. */
|
|
dispose() {
|
|
const self = this;
|
|
const { tooltip, options } = self;
|
|
const callback = () => disposeTooltipComplete(self, () => super.dispose());
|
|
|
|
if (options.animation && isVisibleTip(tooltip, options.container)) {
|
|
self.options.delay = 0; // reset delay
|
|
self.hide(callback);
|
|
} else {
|
|
callback();
|
|
}
|
|
}
|
|
}
|
|
|
|
ObjectAssign(Tooltip, {
|
|
selector: tooltipSelector,
|
|
init: tooltipInitCallback,
|
|
getInstance: getTooltipInstance,
|
|
styleTip,
|
|
});
|
|
|
|
/* Native JavaScript for Bootstrap 5 | Popover
|
|
---------------------------------------------- */
|
|
|
|
// POPOVER PRIVATE GC
|
|
// ==================
|
|
const popoverSelector = `[${dataBsToggle}="${popoverString}"],[data-tip="${popoverString}"]`;
|
|
|
|
const popoverDefaults = {
|
|
...tooltipDefaults,
|
|
/** @type {string} */
|
|
template: getTipTemplate(popoverString),
|
|
/** @type {string} */
|
|
btnClose: '<button class="btn-close" aria-label="Close"></button>',
|
|
/** @type {boolean} */
|
|
dismissible: false,
|
|
/** @type {string?} */
|
|
content: null,
|
|
};
|
|
|
|
// POPOVER DEFINITION
|
|
// ==================
|
|
/** Returns a new `Popover` instance. */
|
|
class Popover extends Tooltip {
|
|
/* eslint-disable -- we want to specify Popover Options */
|
|
/**
|
|
* @param {HTMLElement | string} target the target element
|
|
* @param {BSN.Options.Popover=} config the instance options
|
|
*/
|
|
constructor(target, config) {
|
|
super(target, config);
|
|
}
|
|
/**
|
|
* Returns component name string.
|
|
*/
|
|
get name() { return popoverComponent; }
|
|
/**
|
|
* Returns component default options.
|
|
*/
|
|
get defaults() { return popoverDefaults; }
|
|
/* eslint-enable */
|
|
|
|
/* extend original `show()` */
|
|
show() {
|
|
super.show();
|
|
// btn only exists within dismissible popover
|
|
const { options, btn } = this;
|
|
/* istanbul ignore else */
|
|
if (options.dismissible && btn) setTimeout(() => focus(btn), 17);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Static method which returns an existing `Popover` instance associated
|
|
* to a target `Element`.
|
|
*
|
|
* @type {BSN.GetInstance<Popover>}
|
|
*/
|
|
const getPopoverInstance = (element) => getInstance(element, popoverComponent);
|
|
|
|
/**
|
|
* A `Popover` initialization callback.
|
|
* @type {BSN.InitCallback<Popover>}
|
|
*/
|
|
const popoverInitCallback = (element) => new Popover(element);
|
|
|
|
ObjectAssign(Popover, {
|
|
selector: popoverSelector,
|
|
init: popoverInitCallback,
|
|
getInstance: getPopoverInstance,
|
|
styleTip,
|
|
});
|
|
|
|
/**
|
|
* Shortcut for `HTMLElement.getElementsByTagName` method. Some `Node` elements
|
|
* like `ShadowRoot` do not support `getElementsByTagName`.
|
|
*
|
|
* @param {string} selector the tag name
|
|
* @param {ParentNode=} parent optional Element to look into
|
|
* @return {HTMLCollectionOf<HTMLElement>} the 'HTMLCollection'
|
|
*/
|
|
function getElementsByTagName(selector, parent) {
|
|
const lookUp = isNode(parent) ? parent : getDocument();
|
|
return lookUp.getElementsByTagName(selector);
|
|
}
|
|
|
|
/** @type {string} */
|
|
const scrollspyString = 'scrollspy';
|
|
|
|
/** @type {string} */
|
|
const scrollspyComponent = 'ScrollSpy';
|
|
|
|
/* Native JavaScript for Bootstrap 5 | ScrollSpy
|
|
------------------------------------------------ */
|
|
|
|
// SCROLLSPY PRIVATE GC
|
|
// ====================
|
|
const scrollspySelector = '[data-bs-spy="scroll"]';
|
|
|
|
const scrollspyDefaults = {
|
|
offset: 10,
|
|
target: null,
|
|
};
|
|
|
|
/**
|
|
* Static method which returns an existing `ScrollSpy` instance associated
|
|
* to a target `Element`.
|
|
*
|
|
* @type {BSN.GetInstance<ScrollSpy>}
|
|
*/
|
|
const getScrollSpyInstance = (element) => getInstance(element, scrollspyComponent);
|
|
|
|
/**
|
|
* A `ScrollSpy` initialization callback.
|
|
* @type {BSN.InitCallback<ScrollSpy>}
|
|
*/
|
|
const scrollspyInitCallback = (element) => new ScrollSpy(element);
|
|
|
|
// SCROLLSPY CUSTOM EVENT
|
|
// ======================
|
|
const activateScrollSpy = OriginalEvent(`activate.bs.${scrollspyString}`);
|
|
|
|
// SCROLLSPY PRIVATE METHODS
|
|
// =========================
|
|
/**
|
|
* Update the state of all items.
|
|
* @param {ScrollSpy} self the `ScrollSpy` instance
|
|
*/
|
|
function updateSpyTargets(self) {
|
|
const {
|
|
target, scrollTarget, options, itemsLength, scrollHeight, element,
|
|
} = self;
|
|
const { offset } = options;
|
|
const isWin = isWindow(scrollTarget);
|
|
|
|
const links = target && getElementsByTagName('A', target);
|
|
const scrollHEIGHT = scrollTarget && getScrollHeight(scrollTarget);
|
|
|
|
self.scrollTop = isWin ? scrollTarget.scrollY : scrollTarget.scrollTop;
|
|
|
|
// only update items/offsets once or with each mutation
|
|
/* istanbul ignore else */
|
|
if (links && (itemsLength !== links.length || scrollHEIGHT !== scrollHeight)) {
|
|
let href;
|
|
let targetItem;
|
|
let rect;
|
|
|
|
// reset arrays & update
|
|
self.items = [];
|
|
self.offsets = [];
|
|
self.scrollHeight = scrollHEIGHT;
|
|
self.maxScroll = self.scrollHeight - getOffsetHeight(self);
|
|
|
|
[...links].forEach((link) => {
|
|
href = getAttribute(link, 'href');
|
|
targetItem = href && href.charAt(0) === '#' && href.slice(-1) !== '#'
|
|
&& querySelector(href, getDocument(element));
|
|
|
|
if (targetItem) {
|
|
self.items.push(link);
|
|
rect = getBoundingClientRect(targetItem);
|
|
self.offsets.push((isWin ? rect.top + self.scrollTop : targetItem.offsetTop) - offset);
|
|
}
|
|
});
|
|
self.itemsLength = self.items.length;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the `scrollHeight` property of the scrolling element.
|
|
* @param {Node | Window} scrollTarget the `ScrollSpy` instance
|
|
* @return {number} `scrollTarget` height
|
|
*/
|
|
function getScrollHeight(scrollTarget) {
|
|
return isHTMLElement(scrollTarget)
|
|
? scrollTarget.scrollHeight
|
|
: getDocumentElement(scrollTarget).scrollHeight;
|
|
}
|
|
|
|
/**
|
|
* Returns the height property of the scrolling element.
|
|
* @param {ScrollSpy} params the `ScrollSpy` instance
|
|
* @returns {number}
|
|
*/
|
|
function getOffsetHeight({ element, scrollTarget }) {
|
|
return (isWindow(scrollTarget))
|
|
? scrollTarget.innerHeight
|
|
: getBoundingClientRect(element).height;
|
|
}
|
|
|
|
/**
|
|
* Clear all items of the target.
|
|
* @param {HTMLElement} target a single item
|
|
*/
|
|
function clear(target) {
|
|
[...getElementsByTagName('A', target)].forEach((item) => {
|
|
if (hasClass(item, activeClass)) removeClass(item, activeClass);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Activates a new item.
|
|
* @param {ScrollSpy} self the `ScrollSpy` instance
|
|
* @param {HTMLElement} item a single item
|
|
*/
|
|
function activate(self, item) {
|
|
const { target, element } = self;
|
|
clear(target);
|
|
self.activeItem = item;
|
|
addClass(item, activeClass);
|
|
|
|
// activate all parents
|
|
const parents = [];
|
|
let parentItem = item;
|
|
while (parentItem !== getDocumentBody(element)) {
|
|
parentItem = parentItem.parentElement;
|
|
if (hasClass(parentItem, 'nav') || hasClass(parentItem, 'dropdown-menu')) parents.push(parentItem);
|
|
}
|
|
|
|
parents.forEach((menuItem) => {
|
|
/** @type {HTMLElement?} */
|
|
const parentLink = menuItem.previousElementSibling;
|
|
|
|
/* istanbul ignore else */
|
|
if (parentLink && !hasClass(parentLink, activeClass)) {
|
|
addClass(parentLink, activeClass);
|
|
}
|
|
});
|
|
|
|
// dispatch
|
|
activateScrollSpy.relatedTarget = item;
|
|
dispatchEvent(element, activateScrollSpy);
|
|
}
|
|
|
|
/**
|
|
* Toggles on/off the component event listener.
|
|
* @param {ScrollSpy} self the `ScrollSpy` instance
|
|
* @param {boolean=} add when `true`, listener is added
|
|
*/
|
|
function toggleSpyHandlers(self, add) {
|
|
const action = add ? addListener : removeListener;
|
|
action(self.scrollTarget, scrollEvent, self.refresh, passiveHandler);
|
|
}
|
|
|
|
// SCROLLSPY DEFINITION
|
|
// ====================
|
|
/** Returns a new `ScrollSpy` instance. */
|
|
class ScrollSpy extends BaseComponent {
|
|
/**
|
|
* @param {HTMLElement | string} target the target element
|
|
* @param {BSN.Options.ScrollSpy=} config the instance options
|
|
*/
|
|
constructor(target, config) {
|
|
super(target, config);
|
|
// bind
|
|
const self = this;
|
|
|
|
// initialization element & options
|
|
const { element, options } = self;
|
|
|
|
// additional properties
|
|
/** @type {HTMLElement?} */
|
|
self.target = querySelector(options.target, getDocument(element));
|
|
|
|
// invalidate
|
|
if (!self.target) return;
|
|
|
|
// set initial state
|
|
/** @type {HTMLElement | Window} */
|
|
self.scrollTarget = element.clientHeight < element.scrollHeight
|
|
? element : getWindow(element);
|
|
/** @type {number} */
|
|
self.scrollTop = 0;
|
|
/** @type {number} */
|
|
self.maxScroll = 0;
|
|
/** @type {number} */
|
|
self.scrollHeight = 0;
|
|
/** @type {HTMLElement?} */
|
|
self.activeItem = null;
|
|
/** @type {HTMLElement[]} */
|
|
self.items = [];
|
|
/** @type {number} */
|
|
self.itemsLength = 0;
|
|
/** @type {number[]} */
|
|
self.offsets = [];
|
|
|
|
// bind events
|
|
self.refresh = self.refresh.bind(self);
|
|
|
|
// add event handlers
|
|
toggleSpyHandlers(self, true);
|
|
|
|
self.refresh();
|
|
}
|
|
|
|
/* eslint-disable */
|
|
/**
|
|
* Returns component name string.
|
|
*/
|
|
get name() { return scrollspyComponent; }
|
|
/**
|
|
* Returns component default options.
|
|
*/
|
|
get defaults() { return scrollspyDefaults; }
|
|
/* eslint-enable */
|
|
|
|
// SCROLLSPY PUBLIC METHODS
|
|
// ========================
|
|
/** Updates all items. */
|
|
refresh() {
|
|
const self = this;
|
|
const { target } = self;
|
|
|
|
// check if target is visible and invalidate
|
|
/* istanbul ignore next */
|
|
if (target.offsetHeight === 0) return;
|
|
|
|
updateSpyTargets(self);
|
|
|
|
const {
|
|
scrollTop, maxScroll, itemsLength, items, activeItem,
|
|
} = self;
|
|
|
|
if (scrollTop >= maxScroll) {
|
|
const newActiveItem = items[itemsLength - 1];
|
|
|
|
/* istanbul ignore else */
|
|
if (activeItem !== newActiveItem) {
|
|
activate(self, newActiveItem);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const { offsets } = self;
|
|
|
|
if (activeItem && scrollTop < offsets[0] && offsets[0] > 0) {
|
|
self.activeItem = null;
|
|
clear(target);
|
|
return;
|
|
}
|
|
|
|
items.forEach((item, i) => {
|
|
if (activeItem !== item && scrollTop >= offsets[i]
|
|
&& (typeof offsets[i + 1] === 'undefined' || scrollTop < offsets[i + 1])) {
|
|
activate(self, item);
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Removes `ScrollSpy` from the target element. */
|
|
dispose() {
|
|
toggleSpyHandlers(this);
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
ObjectAssign(ScrollSpy, {
|
|
selector: scrollspySelector,
|
|
init: scrollspyInitCallback,
|
|
getInstance: getScrollSpyInstance,
|
|
});
|
|
|
|
/**
|
|
* A global namespace for aria-selected.
|
|
* @type {string}
|
|
*/
|
|
const ariaSelected = 'aria-selected';
|
|
|
|
/** @type {string} */
|
|
const tabString = 'tab';
|
|
|
|
/** @type {string} */
|
|
const tabComponent = 'Tab';
|
|
|
|
/* Native JavaScript for Bootstrap 5 | Tab
|
|
------------------------------------------ */
|
|
|
|
// TAB PRIVATE GC
|
|
// ================
|
|
const tabSelector = `[${dataBsToggle}="${tabString}"]`;
|
|
|
|
/**
|
|
* Static method which returns an existing `Tab` instance associated
|
|
* to a target `Element`.
|
|
*
|
|
* @type {BSN.GetInstance<Tab>}
|
|
*/
|
|
const getTabInstance = (element) => getInstance(element, tabComponent);
|
|
|
|
/**
|
|
* A `Tab` initialization callback.
|
|
* @type {BSN.InitCallback<Tab>}
|
|
*/
|
|
const tabInitCallback = (element) => new Tab(element);
|
|
|
|
// TAB CUSTOM EVENTS
|
|
// =================
|
|
const showTabEvent = OriginalEvent(`show.bs.${tabString}`);
|
|
const shownTabEvent = OriginalEvent(`shown.bs.${tabString}`);
|
|
const hideTabEvent = OriginalEvent(`hide.bs.${tabString}`);
|
|
const hiddenTabEvent = OriginalEvent(`hidden.bs.${tabString}`);
|
|
|
|
/**
|
|
* Stores the current active tab and its content
|
|
* for a given `.nav` element.
|
|
* @type {Map<HTMLElement, any>}
|
|
*/
|
|
const tabPrivate = new Map();
|
|
|
|
// TAB PRIVATE METHODS
|
|
// ===================
|
|
/**
|
|
* Executes after tab transition has finished.
|
|
* @param {Tab} self the `Tab` instance
|
|
*/
|
|
function triggerTabEnd(self) {
|
|
const { tabContent, nav } = self;
|
|
|
|
/* istanbul ignore else */
|
|
if (tabContent && hasClass(tabContent, collapsingClass)) {
|
|
tabContent.style.height = '';
|
|
removeClass(tabContent, collapsingClass);
|
|
}
|
|
|
|
/* istanbul ignore else */
|
|
if (nav) Timer.clear(nav);
|
|
}
|
|
|
|
/**
|
|
* Executes before showing the tab content.
|
|
* @param {Tab} self the `Tab` instance
|
|
*/
|
|
function triggerTabShow(self) {
|
|
const {
|
|
element, tabContent, content: nextContent, nav,
|
|
} = self;
|
|
const { tab } = nav && tabPrivate.get(nav);
|
|
|
|
/* istanbul ignore else */
|
|
if (tabContent && hasClass(nextContent, fadeClass)) {
|
|
const { currentHeight, nextHeight } = tabPrivate.get(element);
|
|
if (currentHeight === nextHeight) {
|
|
triggerTabEnd(self);
|
|
} else {
|
|
// enables height animation
|
|
setTimeout(() => {
|
|
tabContent.style.height = `${nextHeight}px`;
|
|
reflow(tabContent);
|
|
emulateTransitionEnd(tabContent, () => triggerTabEnd(self));
|
|
}, 50);
|
|
}
|
|
} else if (nav) Timer.clear(nav);
|
|
|
|
shownTabEvent.relatedTarget = tab;
|
|
dispatchEvent(element, shownTabEvent);
|
|
}
|
|
|
|
/**
|
|
* Executes before hiding the tab.
|
|
* @param {Tab} self the `Tab` instance
|
|
*/
|
|
function triggerTabHide(self) {
|
|
const {
|
|
element, content: nextContent, tabContent, nav,
|
|
} = self;
|
|
const { tab, content } = nav && tabPrivate.get(nav);
|
|
let currentHeight = 0;
|
|
|
|
/* istanbul ignore else */
|
|
if (tabContent && hasClass(nextContent, fadeClass)) {
|
|
[content, nextContent].forEach((c) => {
|
|
addClass(c, 'overflow-hidden');
|
|
});
|
|
currentHeight = content.scrollHeight || /* istanbul ignore next */0;
|
|
}
|
|
|
|
// update relatedTarget and dispatch event
|
|
showTabEvent.relatedTarget = tab;
|
|
hiddenTabEvent.relatedTarget = element;
|
|
dispatchEvent(element, showTabEvent);
|
|
if (showTabEvent.defaultPrevented) return;
|
|
|
|
addClass(nextContent, activeClass);
|
|
removeClass(content, activeClass);
|
|
|
|
/* istanbul ignore else */
|
|
if (tabContent && hasClass(nextContent, fadeClass)) {
|
|
const nextHeight = nextContent.scrollHeight;
|
|
tabPrivate.set(element, { currentHeight, nextHeight });
|
|
|
|
addClass(tabContent, collapsingClass);
|
|
tabContent.style.height = `${currentHeight}px`;
|
|
reflow(tabContent);
|
|
[content, nextContent].forEach((c) => {
|
|
removeClass(c, 'overflow-hidden');
|
|
});
|
|
}
|
|
|
|
if (nextContent && hasClass(nextContent, fadeClass)) {
|
|
setTimeout(() => {
|
|
addClass(nextContent, showClass);
|
|
emulateTransitionEnd(nextContent, () => {
|
|
triggerTabShow(self);
|
|
});
|
|
}, 1);
|
|
} else {
|
|
addClass(nextContent, showClass);
|
|
triggerTabShow(self);
|
|
}
|
|
|
|
dispatchEvent(tab, hiddenTabEvent);
|
|
}
|
|
|
|
/**
|
|
* Returns the current active tab and its target content.
|
|
* @param {Tab} self the `Tab` instance
|
|
* @returns {Record<string, any>} the query result
|
|
*/
|
|
function getActiveTab(self) {
|
|
const { nav } = self;
|
|
|
|
const activeTabs = getElementsByClassName(activeClass, nav);
|
|
/** @type {(HTMLElement)=} */
|
|
let tab;
|
|
/* istanbul ignore else */
|
|
if (activeTabs.length === 1
|
|
&& !dropdownMenuClasses.some((c) => hasClass(activeTabs[0].parentElement, c))) {
|
|
[tab] = activeTabs;
|
|
} else if (activeTabs.length > 1) {
|
|
tab = activeTabs[activeTabs.length - 1];
|
|
}
|
|
const content = tab ? getTargetElement(tab) : null;
|
|
return { tab, content };
|
|
}
|
|
|
|
/**
|
|
* Returns a parent dropdown.
|
|
* @param {HTMLElement} element the `Tab` element
|
|
* @returns {HTMLElement?} the parent dropdown
|
|
*/
|
|
function getParentDropdown(element) {
|
|
const dropdown = closest(element, `.${dropdownMenuClasses.join(',.')}`);
|
|
return dropdown ? querySelector(`.${dropdownMenuClasses[0]}-toggle`, dropdown) : null;
|
|
}
|
|
|
|
/**
|
|
* Toggles on/off the `click` event listener.
|
|
* @param {Tab} self the `Tab` instance
|
|
* @param {boolean=} add when `true`, event listener is added
|
|
*/
|
|
function toggleTabHandler(self, add) {
|
|
const action = add ? addListener : removeListener;
|
|
action(self.element, mouseclickEvent, tabClickHandler);
|
|
}
|
|
|
|
// TAB EVENT HANDLER
|
|
// =================
|
|
/**
|
|
* Handles the `click` event listener.
|
|
* @this {HTMLElement}
|
|
* @param {MouseEvent} e the `Event` object
|
|
*/
|
|
function tabClickHandler(e) {
|
|
const self = getTabInstance(this);
|
|
/* istanbul ignore next: must filter */
|
|
if (!self) return;
|
|
e.preventDefault();
|
|
|
|
self.show();
|
|
}
|
|
|
|
// TAB DEFINITION
|
|
// ==============
|
|
/** Creates a new `Tab` instance. */
|
|
class Tab extends BaseComponent {
|
|
/**
|
|
* @param {HTMLElement | string} target the target element
|
|
*/
|
|
constructor(target) {
|
|
super(target);
|
|
// bind
|
|
const self = this;
|
|
|
|
// initialization element
|
|
const { element } = self;
|
|
const content = getTargetElement(element);
|
|
|
|
// no point initializing a tab without a corresponding content
|
|
if (!content) return;
|
|
|
|
const nav = closest(element, '.nav');
|
|
const container = closest(content, '.tab-content');
|
|
|
|
/** @type {HTMLElement?} */
|
|
self.nav = nav;
|
|
/** @type {HTMLElement} */
|
|
self.content = content;
|
|
/** @type {HTMLElement?} */
|
|
self.tabContent = container;
|
|
|
|
// event targets
|
|
/** @type {HTMLElement?} */
|
|
self.dropdown = getParentDropdown(element);
|
|
|
|
// show first Tab instance of none is shown
|
|
// suggested on #432
|
|
const { tab } = getActiveTab(self);
|
|
if (nav && !tab) {
|
|
const firstTab = querySelector(tabSelector, nav);
|
|
const firstTabContent = firstTab && getTargetElement(firstTab);
|
|
|
|
/* istanbul ignore else */
|
|
if (firstTabContent) {
|
|
addClass(firstTab, activeClass);
|
|
addClass(firstTabContent, showClass);
|
|
addClass(firstTabContent, activeClass);
|
|
setAttribute(element, ariaSelected, 'true');
|
|
}
|
|
}
|
|
|
|
// add event listener
|
|
toggleTabHandler(self, true);
|
|
}
|
|
|
|
/* eslint-disable */
|
|
/**
|
|
* Returns component name string.
|
|
*/
|
|
get name() { return tabComponent; }
|
|
/* eslint-enable */
|
|
|
|
// TAB PUBLIC METHODS
|
|
// ==================
|
|
/** Shows the tab to the user. */
|
|
show() {
|
|
const self = this;
|
|
const {
|
|
element, content: nextContent, nav, dropdown,
|
|
} = self;
|
|
|
|
/* istanbul ignore else */
|
|
if (!(nav && Timer.get(nav)) && !hasClass(element, activeClass)) {
|
|
const { tab, content } = getActiveTab(self);
|
|
|
|
/* istanbul ignore else */
|
|
if (nav) tabPrivate.set(nav, { tab, content });
|
|
|
|
// update relatedTarget and dispatch
|
|
hideTabEvent.relatedTarget = element;
|
|
|
|
dispatchEvent(tab, hideTabEvent);
|
|
if (hideTabEvent.defaultPrevented) return;
|
|
|
|
addClass(element, activeClass);
|
|
setAttribute(element, ariaSelected, 'true');
|
|
|
|
const activeDropdown = getParentDropdown(tab);
|
|
if (activeDropdown && hasClass(activeDropdown, activeClass)) {
|
|
removeClass(activeDropdown, activeClass);
|
|
}
|
|
|
|
/* istanbul ignore else */
|
|
if (nav) {
|
|
const toggleTab = () => {
|
|
removeClass(tab, activeClass);
|
|
setAttribute(tab, ariaSelected, 'false');
|
|
if (dropdown && !hasClass(dropdown, activeClass)) addClass(dropdown, activeClass);
|
|
};
|
|
|
|
if (hasClass(content, fadeClass) || hasClass(nextContent, fadeClass)) {
|
|
Timer.set(nav, toggleTab, 1);
|
|
} else toggleTab();
|
|
}
|
|
|
|
removeClass(content, showClass);
|
|
if (hasClass(content, fadeClass)) {
|
|
emulateTransitionEnd(content, () => triggerTabHide(self));
|
|
} else {
|
|
triggerTabHide(self);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Removes the `Tab` component from the target element. */
|
|
dispose() {
|
|
toggleTabHandler(this);
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
ObjectAssign(Tab, {
|
|
selector: tabSelector,
|
|
init: tabInitCallback,
|
|
getInstance: getTabInstance,
|
|
});
|
|
|
|
/** @type {string} */
|
|
const toastString = 'toast';
|
|
|
|
/** @type {string} */
|
|
const toastComponent = 'Toast';
|
|
|
|
/* Native JavaScript for Bootstrap 5 | Toast
|
|
-------------------------------------------- */
|
|
|
|
// TOAST PRIVATE GC
|
|
// ================
|
|
const toastSelector = `.${toastString}`;
|
|
const toastDismissSelector = `[${dataBsDismiss}="${toastString}"]`;
|
|
const toastToggleSelector = `[${dataBsToggle}="${toastString}"]`;
|
|
const showingClass = 'showing';
|
|
/** @deprecated */
|
|
const hideClass = 'hide';
|
|
|
|
const toastDefaults = {
|
|
animation: true,
|
|
autohide: true,
|
|
delay: 5000,
|
|
};
|
|
|
|
/**
|
|
* Static method which returns an existing `Toast` instance associated
|
|
* to a target `Element`.
|
|
*
|
|
* @type {BSN.GetInstance<Toast>}
|
|
*/
|
|
const getToastInstance = (element) => getInstance(element, toastComponent);
|
|
|
|
/**
|
|
* A `Toast` initialization callback.
|
|
* @type {BSN.InitCallback<Toast>}
|
|
*/
|
|
const toastInitCallback = (element) => new Toast(element);
|
|
|
|
// TOAST CUSTOM EVENTS
|
|
// ===================
|
|
const showToastEvent = OriginalEvent(`show.bs.${toastString}`);
|
|
const shownToastEvent = OriginalEvent(`shown.bs.${toastString}`);
|
|
const hideToastEvent = OriginalEvent(`hide.bs.${toastString}`);
|
|
const hiddenToastEvent = OriginalEvent(`hidden.bs.${toastString}`);
|
|
|
|
// TOAST PRIVATE METHODS
|
|
// =====================
|
|
/**
|
|
* Executes after the toast is shown to the user.
|
|
* @param {Toast} self the `Toast` instance
|
|
*/
|
|
function showToastComplete(self) {
|
|
const { element, options } = self;
|
|
removeClass(element, showingClass);
|
|
Timer.clear(element, showingClass);
|
|
|
|
dispatchEvent(element, shownToastEvent);
|
|
/* istanbul ignore else */
|
|
if (options.autohide) {
|
|
Timer.set(element, () => self.hide(), options.delay, toastString);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Executes after the toast is hidden to the user.
|
|
* @param {Toast} self the `Toast` instance
|
|
*/
|
|
function hideToastComplete(self) {
|
|
const { element } = self;
|
|
removeClass(element, showingClass);
|
|
removeClass(element, showClass);
|
|
addClass(element, hideClass); // B/C
|
|
Timer.clear(element, toastString);
|
|
dispatchEvent(element, hiddenToastEvent);
|
|
}
|
|
|
|
/**
|
|
* Executes before hiding the toast.
|
|
* @param {Toast} self the `Toast` instance
|
|
*/
|
|
function hideToast(self) {
|
|
const { element, options } = self;
|
|
addClass(element, showingClass);
|
|
|
|
if (options.animation) {
|
|
reflow(element);
|
|
emulateTransitionEnd(element, () => hideToastComplete(self));
|
|
} else {
|
|
hideToastComplete(self);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Executes before showing the toast.
|
|
* @param {Toast} self the `Toast` instance
|
|
*/
|
|
function showToast(self) {
|
|
const { element, options } = self;
|
|
Timer.set(element, () => {
|
|
removeClass(element, hideClass); // B/C
|
|
reflow(element);
|
|
addClass(element, showClass);
|
|
addClass(element, showingClass);
|
|
|
|
if (options.animation) {
|
|
emulateTransitionEnd(element, () => showToastComplete(self));
|
|
} else {
|
|
showToastComplete(self);
|
|
}
|
|
}, 17, showingClass);
|
|
}
|
|
|
|
/**
|
|
* Toggles on/off the `click` event listener.
|
|
* @param {Toast} self the `Toast` instance
|
|
* @param {boolean=} add when `true`, it will add the listener
|
|
*/
|
|
function toggleToastHandlers(self, add) {
|
|
const action = add ? addListener : removeListener;
|
|
const {
|
|
element, triggers, dismiss, options,
|
|
} = self;
|
|
|
|
/* istanbul ignore else */
|
|
if (dismiss) {
|
|
action(dismiss, mouseclickEvent, self.hide);
|
|
}
|
|
|
|
/* istanbul ignore else */
|
|
if (options.autohide) {
|
|
[focusinEvent, focusoutEvent, mouseenterEvent, mouseleaveEvent]
|
|
.forEach((e) => action(element, e, interactiveToastHandler));
|
|
}
|
|
/* istanbul ignore else */
|
|
if (triggers.length) {
|
|
triggers.forEach((btn) => action(btn, mouseclickEvent, toastClickHandler));
|
|
}
|
|
}
|
|
|
|
// TOAST EVENT HANDLERS
|
|
// ====================
|
|
/**
|
|
* Executes after the instance has been disposed.
|
|
* @param {Toast} self the `Toast` instance
|
|
*/
|
|
function completeDisposeToast(self) {
|
|
Timer.clear(self.element, toastString);
|
|
toggleToastHandlers(self);
|
|
}
|
|
|
|
/**
|
|
* Handles the `click` event listener for toast.
|
|
* @param {MouseEvent} e the `Event` object
|
|
*/
|
|
function toastClickHandler(e) {
|
|
const { target } = e;
|
|
|
|
const trigger = target && closest(target, toastToggleSelector);
|
|
const element = trigger && getTargetElement(trigger);
|
|
const self = element && getToastInstance(element);
|
|
|
|
/* istanbul ignore else */
|
|
if (trigger && trigger.tagName === 'A') e.preventDefault();
|
|
self.relatedTarget = trigger;
|
|
self.show();
|
|
}
|
|
|
|
/**
|
|
* Executes when user interacts with the toast without closing it,
|
|
* usually by hovering or focusing it.
|
|
*
|
|
* @this {HTMLElement}
|
|
* @param {MouseEvent} e the `Toast` instance
|
|
*/
|
|
function interactiveToastHandler(e) {
|
|
const element = this;
|
|
const self = getToastInstance(element);
|
|
const { type, relatedTarget } = e;
|
|
|
|
/* istanbul ignore next: a solid filter is required */
|
|
if (!self || (element === relatedTarget || element.contains(relatedTarget))) return;
|
|
|
|
if ([mouseenterEvent, focusinEvent].includes(type)) {
|
|
Timer.clear(element, toastString);
|
|
} else {
|
|
Timer.set(element, () => self.hide(), self.options.delay, toastString);
|
|
}
|
|
}
|
|
|
|
// TOAST DEFINITION
|
|
// ================
|
|
/** Creates a new `Toast` instance. */
|
|
class Toast extends BaseComponent {
|
|
/**
|
|
* @param {HTMLElement | string} target the target `.toast` element
|
|
* @param {BSN.Options.Toast=} config the instance options
|
|
*/
|
|
constructor(target, config) {
|
|
super(target, config);
|
|
// bind
|
|
const self = this;
|
|
const { element, options } = self;
|
|
|
|
// set fadeClass, the options.animation will override the markup
|
|
if (options.animation && !hasClass(element, fadeClass)) addClass(element, fadeClass);
|
|
else if (!options.animation && hasClass(element, fadeClass)) removeClass(element, fadeClass);
|
|
|
|
// dismiss button
|
|
/** @type {HTMLElement?} */
|
|
self.dismiss = querySelector(toastDismissSelector, element);
|
|
|
|
// toast can have multiple triggering elements
|
|
/** @type {HTMLElement[]} */
|
|
self.triggers = [...querySelectorAll(toastToggleSelector, getDocument(element))]
|
|
.filter((btn) => getTargetElement(btn) === element);
|
|
|
|
// bind
|
|
self.show = self.show.bind(self);
|
|
self.hide = self.hide.bind(self);
|
|
|
|
// add event listener
|
|
toggleToastHandlers(self, true);
|
|
}
|
|
|
|
/* eslint-disable */
|
|
/**
|
|
* Returns component name string.
|
|
*/
|
|
get name() { return toastComponent; }
|
|
/**
|
|
* Returns component default options.
|
|
*/
|
|
get defaults() { return toastDefaults; }
|
|
/* eslint-enable */
|
|
|
|
/**
|
|
* Returns *true* when toast is visible.
|
|
*/
|
|
get isShown() { return hasClass(this.element, showClass); }
|
|
|
|
// TOAST PUBLIC METHODS
|
|
// ====================
|
|
/** Shows the toast. */
|
|
show() {
|
|
const self = this;
|
|
const { element, isShown } = self;
|
|
|
|
/* istanbul ignore else */
|
|
if (element && !isShown) {
|
|
dispatchEvent(element, showToastEvent);
|
|
if (showToastEvent.defaultPrevented) return;
|
|
|
|
showToast(self);
|
|
}
|
|
}
|
|
|
|
/** Hides the toast. */
|
|
hide() {
|
|
const self = this;
|
|
const { element, isShown } = self;
|
|
|
|
/* istanbul ignore else */
|
|
if (element && isShown) {
|
|
dispatchEvent(element, hideToastEvent);
|
|
if (hideToastEvent.defaultPrevented) return;
|
|
hideToast(self);
|
|
}
|
|
}
|
|
|
|
/** Removes the `Toast` component from the target element. */
|
|
dispose() {
|
|
const self = this;
|
|
const { element, isShown } = self;
|
|
|
|
/* istanbul ignore else */
|
|
if (isShown) {
|
|
removeClass(element, showClass);
|
|
}
|
|
|
|
completeDisposeToast(self);
|
|
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
ObjectAssign(Toast, {
|
|
selector: toastSelector,
|
|
init: toastInitCallback,
|
|
getInstance: getToastInstance,
|
|
});
|
|
|
|
/**
|
|
* Check if element matches a CSS selector.
|
|
*
|
|
* @param {HTMLElement} target
|
|
* @param {string} selector
|
|
* @returns {boolean}
|
|
*/
|
|
function matches(target, selector) {
|
|
return target.matches(selector);
|
|
}
|
|
|
|
/** @type {Record<string, any>} */
|
|
const componentsList = {
|
|
Alert,
|
|
Button,
|
|
Carousel,
|
|
Collapse,
|
|
Dropdown,
|
|
Modal,
|
|
Offcanvas,
|
|
Popover,
|
|
ScrollSpy,
|
|
Tab,
|
|
Toast,
|
|
Tooltip,
|
|
};
|
|
|
|
/**
|
|
* Initialize all matched `Element`s for one component.
|
|
* @param {BSN.InitCallback<any>} callback
|
|
* @param {NodeList | Node[]} collection
|
|
*/
|
|
function initComponentDataAPI(callback, collection) {
|
|
[...collection].forEach((x) => callback(x));
|
|
}
|
|
|
|
/**
|
|
* Remove one component from a target container element or all in the page.
|
|
* @param {string} component the component name
|
|
* @param {ParentNode} context parent `Node`
|
|
*/
|
|
function removeComponentDataAPI(component, context) {
|
|
const compData = Data.getAllFor(component);
|
|
|
|
if (compData) {
|
|
[...compData].forEach((x) => {
|
|
const [element, instance] = x;
|
|
if (context.contains(element)) instance.dispose();
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize all BSN components for a target container.
|
|
* @param {ParentNode=} context parent `Node`
|
|
*/
|
|
function initCallback(context) {
|
|
const lookUp = context && context.nodeName ? context : document;
|
|
const elemCollection = [...getElementsByTagName('*', lookUp)];
|
|
|
|
ObjectKeys(componentsList).forEach((comp) => {
|
|
const { init, selector } = componentsList[comp];
|
|
initComponentDataAPI(init, elemCollection.filter((item) => matches(item, selector)));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Remove all BSN components for a target container.
|
|
* @param {ParentNode=} context parent `Node`
|
|
*/
|
|
function removeDataAPI(context) {
|
|
const lookUp = context && context.nodeName ? context : document;
|
|
|
|
ObjectKeys(componentsList).forEach((comp) => {
|
|
removeComponentDataAPI(comp, lookUp);
|
|
});
|
|
}
|
|
|
|
// bulk initialize all components
|
|
if (document.body) initCallback();
|
|
else {
|
|
addListener(document, 'DOMContentLoaded', () => initCallback(), { once: true });
|
|
}
|
|
|
|
const BSN = {
|
|
Alert,
|
|
Button,
|
|
Carousel,
|
|
Collapse,
|
|
Dropdown,
|
|
Modal,
|
|
Offcanvas,
|
|
Popover,
|
|
ScrollSpy,
|
|
Tab,
|
|
Toast,
|
|
Tooltip,
|
|
|
|
initCallback,
|
|
removeDataAPI,
|
|
Version,
|
|
EventListener: Listener,
|
|
};
|
|
|
|
return BSN;
|
|
|
|
}));
|