/*! * Native JavaScript for Bootstrap v4.1.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. * * @this {Element | HTMLElement | Window | Document} * @param {Event} e * @returns {void} */ function globalListener(e) { const that = this; const { type } = e; const oneEvMap = EventRegistry[type] ? [...EventRegistry[type]] : []; oneEvMap.forEach((elementsMap) => { const [element, listenersMap] = elementsMap; [...listenersMap].forEach((listenerMap) => { if (element === that) { 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. * * @param {Element | HTMLElement | Window | Document} element * @param {string} eventType * @param {EventListenerObject['handleEvent']} listener * @param {AddEventListenerOptions=} options */ 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 if (oneElementMap) { 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. * * @param {Element | HTMLElement | Window | Document} element * @param {string} eventType * @param {EventListenerObject['handleEvent']} listener * @param {AddEventListenerOptions=} options */ 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 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 EventListener = { 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} element target * @param {string} property the css property * @return {string} the css property value */ function getElementStyle(element, property) { const computedStyle = getComputedStyle(element); // @ts-ignore -- must use camelcase strings, // or non-camelcase strings with `getPropertyValue` return property in computedStyle ? computedStyle[property] : ''; } /** * Utility to get the computed `transitionDelay` * from Element in miliseconds. * * @param {HTMLElement | Element} 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') ? 1 : 1000; const duration = propertyValue && propertyValue !== 'none' ? parseFloat(delayValue) * delayScale : 0; return !Number.isNaN(duration) ? duration : 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} 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') ? 1 : 1000; const duration = propertyValue && propertyValue !== 'none' ? parseFloat(durationValue) * durationScale : 0; return !Number.isNaN(duration) ? duration : 0; } /** * Utility to make sure callbacks are consistently * called when transition ends. * * @param {HTMLElement | Element} 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) => { if (e.target === element) { handler.apply(element, [e]); element.removeEventListener(transitionEndEvent, transitionEndWrapper); called = 1; } }; element.addEventListener(transitionEndEvent, transitionEndWrapper); setTimeout(() => { if (!called) element.dispatchEvent(endEvent); }, duration + delay + 17); } else { handler.apply(element, [endEvent]); } } /** * Returns the `document` or the `#document` element. * @see https://github.com/floating-ui/floating-ui * @param {(Node | HTMLElement | Element | globalThis)=} node * @returns {Document} */ function getDocument(node) { if (node instanceof HTMLElement) return node.ownerDocument; if (node instanceof Window) return node.document; return window.document; } /** * A global array of possible `ParentNode`. */ const parentNodes = [Document, Element, HTMLElement]; /** * A global array with `Element` | `HTMLElement`. */ const elementNodes = [Element, HTMLElement]; /** * Utility to check if target is typeof `HTMLElement`, `Element`, `Node` * or find one that matches a selector. * * @param {HTMLElement | Element | string} selector the input selector or target element * @param {(HTMLElement | Element | Document)=} parent optional node to look into * @return {(HTMLElement | Element)?} the `HTMLElement` or `querySelector` result */ function querySelector(selector, parent) { const lookUp = parentNodes.some((x) => parent instanceof x) ? parent : getDocument(); // @ts-ignore return elementNodes.some((x) => selector instanceof x) // @ts-ignore ? selector : 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 Element to look into * @param {string} selector the selector name * @return {(HTMLElement | Element)?} the query result */ function closest(element, selector) { return element ? (element.closest(selector) // @ts-ignore -- 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} 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} element target * @param {string} classNAME to remove * @returns {void} */ function removeClass(element, classNAME) { element.classList.remove(classNAME); } /** * Shortcut for the `Element.dispatchEvent(Event)` method. * * @param {HTMLElement | Element} element is the target * @param {Event} event is the `Event` object */ const dispatchEvent = (element, event) => element.dispatchEvent(event); /** @type {Map<string, Map<HTMLElement | Element, 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 | string} target target element * @param {string} component the component's name or a unique key * @param {Record<string, any>} instance the component instance */ set: (target, component, instance) => { const element = querySelector(target); if (!element) return; if (!componentData.has(component)) { componentData.set(component, new Map()); } const instanceMap = componentData.get(component); // @ts-ignore - 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 | Element, 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 | string} target target element * @param {string} component the component's name or a unique key * @returns {Record<string, any>?} the instance */ get: (target, component) => { const element = querySelector(target); const allForC = Data.getAllFor(component); const instance = element && allForC && allForC.get(element); return instance || null; }, /** * Removes web components data. * @param {HTMLElement | Element | string} target target element * @param {string} component the component's name or a unique key */ remove: (target, component) => { const element = querySelector(target); const instanceMap = componentData.get(component); if (!instanceMap || !element) return; instanceMap.delete(element); if (instanceMap.size === 0) { componentData.delete(component); } }, }; /** * An alias for `Data.get()`. * @type {SHORTER.getInstance<any>} */ const getInstance = (target, component) => Data.get(target, component); /** * Returns a namespaced `CustomEvent` specific to each component. * @param {string} EventType Event.type * @param {Record<string, any>=} config Event.options | Event.properties * @returns {SHORTER.OriginalEvent} a new namespaced event */ function OriginalEvent(EventType, config) { const OriginalCustomEvent = new CustomEvent(EventType, { cancelable: true, bubbles: true, }); if (config instanceof Object) { 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} 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 (value === 'true') { // boolean return true; } if (value === 'false') { // boolean return false; } if (!Number.isNaN(+value)) { // number return +value; } if (value === '' || value === 'null') { // null return null; } // 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} 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) { // @ts-ignore -- our targets are always `HTMLElement` 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) => { 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.1.0"; const Version = version; /* Native JavaScript for Bootstrap 5 | Base Component ----------------------------------------------------- */ /** Returns a new `BaseComponent` instance. */ class BaseComponent { /** * @param {HTMLElement | Element | 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 | Element} */ self.element = element; if (self.defaults && Object.keys(self.defaults).length) { self.options = normalizeOptions(element, self.defaults, (config || {}), 'bs'); } Data.set(element, self.name, self); } /* eslint-disable */ /** @static */ get version() { return Version; } /* eslint-enable */ /** @static */ get name() { return this.constructor.name; } /** @static */ // @ts-ignore get defaults() { return this.constructor.defaults; } /** * Removes component from target element; */ dispose() { const self = this; Data.remove(self.element, self.name); // @ts-ignore 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; if (dismiss) action(dismiss, mouseclickEvent, self.close); } // ALERT DEFINITION // ================ /** Creates a new Alert instance. */ class Alert extends BaseComponent { /** @param {HTMLElement | Element | string} target element or selector */ constructor(target) { super(target); // bind const self = this; // initialization element const { element } = self; // the dismiss button /** @static @type {(HTMLElement | Element)?} */ self.dismiss = querySelector(alertDismissSelector, element); // add event listener toggleAlertHandler(self, true); } /* eslint-disable */ /** * Returns component name string. * @readonly @static */ 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) { // @ts-ignore const self = e ? getAlertInstance(closest(this, alertSelector)) : this; if (!self) return; const { element } = self; if (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} 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} 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 | Element | 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. * @readonly @static */ 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(); // @ts-ignore const self = e ? getButtonInstance(this) : this; if (!self) return; const { element } = self; if (hasClass(element, 'disabled')) return; self.isActive = hasClass(element, activeClass); const { isActive } = self; const action = isActive ? removeClass : addClass; action(element, activeClass); setAttribute(element, ariaPressed, isActive ? 'false' : 'true'); } /** 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 `touchmove` event. * @type {string} */ const touchmoveEvent = 'touchmove'; /** * A global namespace for `touchend` event. * @type {string} */ const touchendEvent = 'touchend'; /** * A global namespace for `touchstart` event. * @type {string} */ const touchstartEvent = 'touchstart'; /** * 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'; /** * Returns the `Window` object of a target node. * @see https://github.com/floating-ui/floating-ui * * @param {(Node | HTMLElement | Element | Window)=} node target node * @returns {globalThis} */ function getWindow(node) { if (node == null) { return window; } if (!(node instanceof Window)) { const { ownerDocument } = node; return ownerDocument ? ownerDocument.defaultView || window : window; } // @ts-ignore return node; } /** * Returns the bounding client rect of a target `HTMLElement`. * * @see https://github.com/floating-ui/floating-ui * * @param {HTMLElement | Element} element event.target * @param {boolean=} includeScale when *true*, the target scale is also computed * @returns {SHORTER.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 && element instanceof HTMLElement) { const { offsetWidth, offsetHeight } = element; scaleX = offsetWidth > 0 ? Math.round(width) / offsetWidth || 1 : 1; scaleY = offsetHeight > 0 ? Math.round(height) / offsetHeight || 1 : 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 | HTMLElement | Element | globalThis)=} node * @returns {HTMLElement | HTMLHtmlElement} */ function getDocumentElement(node) { return getDocument(node).documentElement; } /** * Utility to determine if an `HTMLElement` * is partially visible in viewport. * * @param {HTMLElement | Element} element target * @return {boolean} the query result */ const isElementInScrollRange = (element) => { const { top, bottom } = getBoundingClientRect(element); const { clientHeight } = getDocumentElement(element); // checks bottom && top return top <= clientHeight && bottom >= 0; }; /** * Checks if a page is Right To Left. * @param {(HTMLElement | Element)=} 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 {(HTMLElement | Element | Document | Node)=} parent optional node to look into * @return {NodeListOf<HTMLElement | Element>} the query result */ function querySelectorAll(selector, parent) { const lookUp = parent && parentNodes .some((x) => parent instanceof x) ? parent : getDocument(); // @ts-ignore -- `ShadowRoot` is also a node 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 {(HTMLElement | Element | Document)=} parent optional Element to look into * @return {HTMLCollectionOf<HTMLElement | Element>} the 'HTMLCollection' */ function getElementsByClassName(selector, parent) { const lookUp = parent && parentNodes.some((x) => parent instanceof x) ? parent : getDocument(); return lookUp.getElementsByClassName(selector); } /** @type {Map<HTMLElement | Element, 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 | string} target target element * @param {ReturnType<TimerHandler>} callback the callback * @param {number} delay the execution delay * @param {string=} key a unique key */ set: (target, callback, delay, key) => { const element = querySelector(target); if (!element) return; if (key && key.length) { 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 | string} target target element * @param {string=} key a unique * @returns {number?} the timer */ get: (target, key) => { const element = querySelector(target); if (!element) return null; const keyTimers = TimeCache.get(element); if (key && key.length && keyTimers && keyTimers.get) { return keyTimers.get(key) || null; } return keyTimers || null; }, /** * Clears the element's timer. * @param {HTMLElement | Element | string} target target element * @param {string=} key a unique key */ clear: (target, key) => { const element = querySelector(target); if (!element) return; if (key && key.length) { const keyTimers = TimeCache.get(element); if (keyTimers && keyTimers.get) { clearTimeout(keyTimers.get(key)); keyTimers.delete(key); 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} element is the target * @return {number} the `Element.offsetHeight` value */ // @ts-ignore 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} element the target element * @returns {(HTMLElement | Element)?} 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 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` / `touchstart` events when *options.pause* * is set to `hover`. * * @this {HTMLElement | Element} */ function carouselPauseHandler() { const element = this; const self = getCarouselInstance(element); if (self && !self.isPaused && !Timer.get(element, pausedClass)) { addClass(element, pausedClass); } } /** * Handles the `mouseleave` / `touchend` events when *options.pause* * is set to `hover`. * * @this {HTMLElement | Element} */ function carouselResumeHandler() { const element = this; const self = getCarouselInstance(element); 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); if (!element) return; const self = getCarouselInstance(element); if (!self || self.isAnimating) return; // @ts-ignore 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 = element && getCarouselInstance(element); if (!self || self.isAnimating) return; const orientation = getAttribute(control, dataBsSlide); 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 }) { const [element] = [...querySelectorAll(carouselSelector)] .filter((x) => isElementInScrollRange(x)); const self = getCarouselInstance(element); if (!self) return; const RTL = isRTL(); const arrowKeyNext = !RTL ? keyArrowRight : keyArrowLeft; const arrowKeyPrev = !RTL ? keyArrowLeft : keyArrowRight; if (code === arrowKeyPrev) self.prev(); else if (code === arrowKeyNext) self.next(); } // CAROUSEL TOUCH HANDLERS // ======================= /** * Handles the `touchdown` event for the `Carousel` element. * * @this {HTMLElement | Element} * @param {TouchEvent} e the `Event` object */ function carouselTouchDownHandler(e) { const element = this; const self = getCarouselInstance(element); if (!self || self.isTouch) { return; } startX = e.changedTouches[0].pageX; // @ts-ignore if (element.contains(e.target)) { self.isTouch = true; toggleCarouselTouchHandlers(self, true); } } /** * Handles the `touchmove` event for the `Carousel` element. * * @this {HTMLElement | Element} * @param {TouchEvent} e */ function carouselTouchMoveHandler(e) { const { changedTouches, type } = e; const self = getCarouselInstance(this); if (!self || !self.isTouch) { return; } currentX = changedTouches[0].pageX; // cancel touch if more than one changedTouches detected if (type === touchmoveEvent && changedTouches.length > 1) { e.preventDefault(); } } /** * Handles the `touchend` event for the `Carousel` element. * * @this {HTMLElement | Element} * @param {TouchEvent} e */ function carouselTouchEndHandler(e) { const element = this; const self = getCarouselInstance(element); if (!self || !self.isTouch) { return; } endX = currentX || e.changedTouches[0].pageX; if (self.isTouch) { // the event target is outside the carousel OR carousel doens't include the related target // @ts-ignore if ((!element.contains(e.target) || !element.contains(e.relatedTarget)) && Math.abs(startX - endX) < 75) { // AND swipe distance is less than 75px // when the above conditions are satisfied, no need to continue return; } // OR determine next index to slide to if (currentX < startX) { self.index += 1; } else if (currentX > startX) { self.index -= 1; } self.isTouch = false; self.to(self.index); // do the slide toggleCarouselTouchHandlers(self); // remove touch events handlers } } // 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)); if (self.indicators[pageIndex]) addClass(indicators[pageIndex], activeClass); } /** * Toggles the touch 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(element, touchmoveEvent, carouselTouchMoveHandler, passiveHandler); action(element, touchendEvent, carouselTouchEndHandler, 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); action(element, touchstartEvent, carouselPauseHandler, passiveHandler); action(element, touchendEvent, carouselResumeHandler, passiveHandler); } if (touch && slides.length > 1) { action(element, touchstartEvent, carouselTouchDownHandler, passiveHandler); } if (controls.length) { controls.forEach((arrow) => { if (arrow) action(arrow, mouseclickEvent, carouselControlsHandler); }); } if (indicators.length) { indicators.forEach((indicator) => { action(indicator, mouseclickEvent, carouselIndicatorHandler); }); } // @ts-ignore if (keyboard) action(getWindow(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); // @ts-ignore return [...slides].indexOf(activeItem); } // CAROUSEL DEFINITION // =================== /** Creates a new `Carousel` instance. */ class Carousel extends BaseComponent { /** * @param {HTMLElement | Element | string} target mostly a `.carousel` element * @param {BSN.Options.Carousel=} config instance options */ constructor(target, config) { super(target, config); // bind const self = this; // additional properties /** @type {string} */ self.direction = isRTL() ? 'right' : 'left'; /** @type {number} */ self.index = 0; /** @type {boolean} */ self.isTouch = false; // initialization element const { element } = self; // 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; } self.controls = [ ...querySelectorAll(`[${dataBsSlide}]`, element), ...querySelectorAll(`[${dataBsSlide}][${dataBsTarget}="#${element.id}"]`), ]; /** @type {(HTMLElement | Element)?} */ self.indicator = querySelector(`.${carouselString}-indicators`, element); // a LIVE collection is prefferable /** @type {(HTMLElement | Element)[]} */ self.indicators = [ ...(self.indicator ? querySelectorAll(`[${dataBsSlideTo}]`, self.indicator) : []), ...querySelectorAll(`[${dataBsSlideTo}][${dataBsTarget}="#${element.id}"]`), ]; // 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 if (getActiveIndex(self) < 0) { if (slides.length) addClass(slides[0], activeClass); 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. * @readonly @static */ get name() { return carouselComponent; } /** * Returns component default options. * @readonly @static */ 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 } = self; Timer.clear(element, carouselString); if (isPaused) { Timer.clear(element, pausedClass); removeClass(element, pausedClass); } Timer.set(element, () => { if (!self.isPaused && isElementInScrollRange(element)) { self.index += 1; self.to(self.index); } }, options.interval, carouselString); } /** Pause the automatic cycle. */ pause() { const self = this; const { element, options } = self; if (!self.isPaused && options.interval) { addClass(element, pausedClass); Timer.set(element, () => {}, 1, pausedClass); } } /** Slide to the next item. */ next() { const self = this; if (!self.isAnimating) { self.index += 1; self.to(self.index); } } /** Slide to the previous item. */ prev() { const self = this; if (!self.isAnimating) { self.index -= 1; self.to(self.index); } } /** * 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(); let next = idx; // when controled via methods, make sure to check again // first return if we're on the same item #227 if (self.isAnimating || activeItem === next) return; // determine transition direction 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)); }, 17, dataBsSlide); } else { addClass(slides[next], activeClass); removeClass(slides[activeItem], activeClass); Timer.set(element, () => { Timer.clear(element, dataBsSlide); // check for element, might have been disposed if (element && options.interval && !self.isPaused) { self.cycle(); } dispatchEvent(element, carouselSlidEvent); }, 17, 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'; /** * 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); // @ts-ignore element.style.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); // @ts-ignore element.style.height = ''; dispatchEvent(element, shownCollapseEvent); }); } /** * Collapse the designated `Element`. * @param {Collapse} self the `Collapse` instance */ function collapseContent(self) { const { // @ts-ignore element, parent, triggers, } = self; dispatchEvent(element, hideCollapseEvent); if (hideCollapseEvent.defaultPrevented) return; Timer.set(element, () => {}, 17); if (parent) Timer.set(parent, () => {}, 17); // @ts-ignore element.style.height = `${element.scrollHeight}px`; removeClass(element, collapseString); removeClass(element, showClass); addClass(element, collapsingClass); reflow(element); // @ts-ignore element.style.height = '0px'; emulateTransitionEnd(element, () => { Timer.clear(element); if (parent) Timer.clear(parent); triggers.forEach((btn) => setAttribute(btn, ariaExpanded, 'false')); removeClass(element, collapsingClass); addClass(element, collapseString); // @ts-ignore element.style.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; 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; // @ts-ignore - our target is `HTMLElement` const trigger = target && closest(target, collapseToggleSelector); const element = trigger && getTargetElement(trigger); const self = element && getCollapseInstance(element); 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 | Element | 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; // set triggering elements /** @type {(HTMLElement | Element)[]} */ self.triggers = [...querySelectorAll(collapseToggleSelector)] .filter((btn) => getTargetElement(btn) === element); // set parent accordion /** @type {(HTMLElement | Element)?} */ self.parent = querySelector(options.parent); // add event listeners toggleCollapseHandler(self, true); } /* eslint-disable */ /** * Returns component name string. * @readonly @static */ get name() { return collapseComponent; } /** * Returns component default options. * @readonly @static */ 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); 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 || (parent && !Timer.get(parent))) && !Timer.get(element)) { if (activeCollapseInstance && activeCollapse !== element) { collapseContent(activeCollapseInstance); activeCollapseInstance.triggers.forEach((btn) => { addClass(btn, `${collapseString}d`); }); } expandCollapse(self); 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} element target element * @param {string} attribute attribute name * @returns {boolean} the query result */ const hasAttribute = (element, attribute) => element.hasAttribute(attribute); /** * Shortcut for multiple uses of `HTMLElement.style.propertyName` method. * @param {HTMLElement | Element} element target element * @param {Partial<CSSStyleDeclaration>} styles attribute value */ // @ts-ignore const setElementStyle = (element, styles) => { ObjectAssign(element.style, styles); }; /** * Utility to focus an `HTMLElement` target. * * @param {HTMLElement | Element} element is the target */ // @ts-ignore -- `Element`s resulted from querySelector can focus too const focus = (element) => element.focus(); /** * 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 {HTMLElement | HTMLAnchorElement | EventTarget} element the target element * @returns {boolean} the query result */ function isEmptyAnchor(element) { // @ts-ignore -- `EventTarget` must be `HTMLElement` const parentAnchor = closest(element, 'A'); // @ts-ignore -- anchor href starts with # return element && ((hasAttribute(element, 'href') && element.href.slice(-1) === '#') // @ts-ignore -- 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 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 if (getElementStyle(menu, 'position') === 'static') return; const RTL = isRTL(element); const menuEnd = hasClass(parentElement, dropdownMenuEndClass); // reset menu offset and position const resetProps = ['margin', 'top', 'bottom', 'left', 'right']; // @ts-ignore resetProps.forEach((p) => { menu.style[p] = ''; }); // set initial position class // take into account .btn-group parent as .dropdown let positionClass = dropdownMenuClasses.find((c) => hasClass(parentElement, c)) || 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' }, menuEnd: RTL ? { right: 'auto', left: 0 } : { right: 0, left: 'auto' }, }; // @ts-ignore 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 if (targetLeft + targetWidth + Math.abs(menuWidth - targetWidth) + offset < clientWidth) { ObjectAssign(dropdownPosition[positionClass], leftExceed ? { left: 0, right: 'auto' } : { left: 'auto', right: 0 }); } } dropdownMargin = dropdownMargin[positionClass]; // @ts-ignore menu.style.margin = `${dropdownMargin.map((x) => (x ? `${x}px` : x)).join(' ')}`; setElementStyle(menu, dropdownPosition[positionClass]); // update dropdown-menu-end if (hasClass(menu, dropdownMenuEndClass)) { setElementStyle(menu, dropdownPosition.menuEnd); } } /** * Returns an `Array` of focusable items in the given dropdown-menu. * @param {HTMLElement | Element} menu * @returns {(HTMLElement | Element)[]} */ function getMenuItems(menu) { // @ts-ignore 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 } = 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); if (self.options.display === 'dynamic') { [scrollEvent, resizeEvent].forEach((ev) => { // @ts-ignore 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 {(Document | HTMLElement | Element | globalThis)=} 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) { // @ts-ignore -- HTMLElement is also Element 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; // @ts-ignore if (!target || !target.closest) return; // some weird FF bug #409 // @ts-ignore const element = getCurrentOpenDropdown(target); if (!element) return; const self = getDropdownInstance(element); if (!self) return; const { parentElement, menu } = self; // @ts-ignore const hasData = closest(target, dropdownSelector) !== null; // @ts-ignore const isForm = parentElement && parentElement.contains(target) // @ts-ignore && (target.tagName === 'form' || closest(target, 'form') !== null); // @ts-ignore if (type === mouseclickEvent && isEmptyAnchor(target)) { e.preventDefault(); } if (type === focusEvent // @ts-ignore && (target === element || target === menu || menu.contains(target))) { return; } if (isForm || hasData) ; else if (self) { self.hide(); } } /** * Handles `click` event listener for `Dropdown`. * @this {HTMLElement | Element} * @param {MouseEvent} e event object */ function dropdownClickHandler(e) { const element = this; const { target } = e; const self = getDropdownInstance(element); if (self) { self.toggle(); if (target && isEmptyAnchor(target)) e.preventDefault(); } } /** * Prevents scroll when dropdown-menu is visible. * @param {KeyboardEvent} e event object */ function dropdownPreventScroll(e) { 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 activeItem = element && getDocument(element).activeElement; if (!self || !activeItem) return; const { menu, open } = self; const menuItems = getMenuItems(menu); // arrow up & down if (menuItems && menuItems.length && [keyArrowDown, keyArrowUp].includes(code)) { let idx = menuItems.indexOf(activeItem); if (activeItem === 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; } 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); if (self && self.open) styleDropdown(self); } // DROPDOWN DEFINITION // =================== /** Returns a new Dropdown instance. */ class Dropdown extends BaseComponent { /** * @param {HTMLElement | Element | 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)} */ // @ts-ignore self.parentElement = parentElement; /** @type {(Element | HTMLElement)} */ // @ts-ignore 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. * @readonly @static */ get name() { return dropdownComponent; } /** * Returns component default options. * @readonly @static */ 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; const currentElement = getCurrentOpenDropdown(element); const currentInstance = currentElement && getDropdownInstance(currentElement); if (currentInstance) currentInstance.hide(); // dispatch [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; setTimeout(() => { focus(element); // focus the element toggleDropdownDismiss(self); dispatchEvent(parentElement, shownDropdownEvent); }, 1); } /** Hides the dropdown menu from the user. */ hide() { const self = this; const { element, open, menu, parentElement, } = self; [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 setTimeout(() => toggleDropdownDismiss(self), 1); dispatchEvent(parentElement, hiddenDropdownEvent); } /** Removes the `Dropdown` component from the target element. */ dispose() { const self = this; const { parentElement } = self; if (hasClass(parentElement, showClass) && 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} 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 | HTMLElement | Element | globalThis)=} node * @returns {HTMLElement | 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) => { const OwnElement = getWindow(element).ShadowRoot; return element instanceof OwnElement || element instanceof ShadowRoot; }; /** * Returns the `parentNode` also going through `ShadowRoot`. * @see https://github.com/floating-ui/floating-ui * * @param {Node | HTMLElement | Element} node the target node * @returns {Node | HTMLElement | Element} 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 ( // @ts-ignore node.assignedSlot // step into the shadow DOM of the parent of a slotted node || node.parentNode // @ts-ignore DOM Element detected || (isShadowRoot(node) ? node.host : null) // ShadowRoot detected || getDocumentElement(node) // fallback ); } /** * Check if a target element is a `<table>`, `<td>` or `<th>`. * @param {any} element the target element * @returns {boolean} the query result */ const isTableElement = (element) => ['TABLE', 'TD', 'TH'].includes(element.tagName); /** * Checks if an element is an `HTMLElement`. * * @param {any} element the target object * @returns {boolean} the query result */ const isHTMLElement = (element) => element instanceof HTMLElement; /** * 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} element the target * @param {boolean=} getOffset when *true* it will return an `offsetParent` * @returns {HTMLElement | HTMLBodyElement | Window | globalThis} the query result */ function getElementContainer(element, getOffset) { const majorBlockTags = ['HTML', 'BODY']; if (getOffset) { /** @type {any} */ let { offsetParent } = element; const win = getWindow(element); // const { innerWidth } = getDocumentElement(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 || (offsetParent && (majorBlockTags.includes(offsetParent.tagName) || getElementStyle(offsetParent, 'position') === 'static'))) { offsetParent = win; } return offsetParent; } /** @type {(HTMLElement)[]} */ const containers = []; /** @type {any} */ let { parentNode } = element; while (parentNode && !majorBlockTags.includes(parentNode.nodeName)) { parentNode = getParentNode(parentNode); 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 | Element | 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)=} 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)=} 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} 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); if (overflow) { setElementStyle(bd, { overflow: 'hidden', paddingRight: `${bodyPad + sbWidth}px`, }); if (fixedItems.length) { fixedItems.forEach((fixed) => { const itemPadValue = getElementStyle(fixed, 'paddingRight'); // @ts-ignore fixed.style.paddingRight = `${parseInt(itemPadValue, 10) + sbWidth}px`; if ([stickyTopClass, positionStickyClass].some((c) => hasClass(fixed, c))) { const itemMValue = getElementStyle(fixed, 'marginRight'); // @ts-ignore fixed.style.marginRight = `${parseInt(itemMValue, 10) - sbWidth}px`; } }); } } } /** @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 = getDocument().createElement('div'); /** * Returns the current active modal / offcancas element. * @param {(HTMLElement | Element)=} element the context element * @returns {(HTMLElement | Element)?} 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 | Element} 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() { addClass(overlay, showClass); reflow(overlay); } /** * Hides the overlay from the user. */ function hideOverlay() { removeClass(overlay, showClass); } /** * Removes the overlay from DOM. * @param {(HTMLElement | Element)=} element */ function removeOverlay(element) { if (!getCurrentOpen(element)) { removeClass(overlay, fadeClass); overlay.remove(); resetScrollbar(element); } } /** * @param {HTMLElement | Element} element target * @returns {boolean} */ function isVisible(element) { return element && getElementStyle(element, 'visibility') !== 'hidden' // @ts-ignore && 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; if (!modalOverflow && scrollbarWidth) { const pad = isRTL(element) ? 'paddingLeft' : 'paddingRight'; // @ts-ignore element.style[pad] = `${scrollbarWidth}px`; } 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); // @ts-ignore 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; 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 */ function afterModalHide(self) { const { triggers, element } = self; removeOverlay(element); // @ts-ignore element.style.paddingRight = ''; if (triggers.length) { const visibleTrigger = triggers.find((x) => isVisible(x)); if (visibleTrigger) focus(visibleTrigger); } } /** * 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; // @ts-ignore element.style.display = 'block'; setModalScrollbar(self); if (!getCurrentOpen(element)) { getDocumentBody(element).style.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 {boolean=} force when `true` skip animation */ function beforeModalHide(self, force) { const { element, options, relatedTarget, hasFade, } = self; // @ts-ignore element.style.display = ''; // force can also be the transitionEvent object, we wanna make sure it's not // call is not forced and overlay is visible if (options.backdrop && !force && hasFade && hasClass(overlay, showClass) && !getCurrentOpen(element)) { // AND no modal is visible hideOverlay(); emulateTransitionEnd(overlay, () => afterModalHide(self)); } else { afterModalHide(self); } toggleModalDismiss(self); hiddenModalEvent.relatedTarget = relatedTarget; dispatchEvent(element, hiddenModalEvent); } // MODAL EVENT HANDLERS // ==================== /** * Handles the `click` event listener for modal. * @param {MouseEvent} e the `Event` object * @this {HTMLElement | Element} */ function modalClickHandler(e) { const { target } = e; const trigger = target && closest(this, modalToggleSelector); const element = trigger && getTargetElement(trigger); const self = element && getModalInstance(element); if (!self) return; 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 }) { const element = querySelector(modalActiveSelector); const self = element && getModalInstance(element); if (!self) return; const { options } = self; 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 | Element} * @param {MouseEvent} e the `Event` object */ function modalDismissHandler(e) { const element = this; const self = getModalInstance(element); // this timer is needed if (!self || Timer.get(element)) return; const { options, isStatic, modalDialog } = self; const { backdrop } = options; const { target } = e; // @ts-ignore const selectedText = getDocument(element).getSelection().toString().length; // @ts-ignore const targetInsideDialog = modalDialog.contains(target); // @ts-ignore const dismiss = target && closest(target, modalDismissSelector); 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 | Element | 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 | Element)} */ // @ts-ignore self.modalDialog = querySelector(`.${modalString}-dialog`, element); // modal can have multiple triggering elements /** @type {(HTMLElement | Element)[]} */ self.triggers = [...querySelectorAll(modalToggleSelector)] .filter((btn) => getTargetElement(btn) === element); // additional internals /** @type {boolean} */ self.isStatic = self.options.backdrop === 'static'; /** @type {boolean} */ self.hasFade = hasClass(element, fadeClass); /** @type {(HTMLElement | Element)?} */ self.relatedTarget = null; /** @type {HTMLBodyElement | HTMLElement | Element} */ // @ts-ignore self.container = getElementContainer(element); // attach event listeners toggleModalHandler(self, true); // bind self.update = self.update.bind(self); } /* eslint-disable */ /** * Returns component name string. * @readonly @static */ get name() { return modalComponent; } /** * Returns component default options. * @readonly @static */ 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 || getInstance(currentOpen, 'Offcanvas'); that1.hide(); } if (backdrop) { if (!currentOpen && !hasClass(overlay, showClass)) { appendOverlay(container, hasFade, true); } else { toggleOverlayType(true); } overlayDelay = getElementTransitionDuration(overlay); if (!hasClass(overlay, showClass)) showOverlay(); setTimeout(() => beforeModalShow(self), overlayDelay); } else { beforeModalShow(self); if (currentOpen && hasClass(overlay, showClass)) { hideOverlay(); } } } /** * Hide the modal from the user. * @param {boolean=} force when `true` it will skip animation */ hide(force) { 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 && force !== false) { emulateTransitionEnd(element, () => beforeModalHide(self)); } else { beforeModalHide(self, force); } } /** Updates the modal layout. */ update() { const self = this; if (hasClass(self.element, showClass)) setModalScrollbar(self); } /** Removes the `Modal` component from target element. */ dispose() { const self = this; self.hide(true); // forced call toggleModalHandler(self); 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; if (!options.scroll) { setOffCanvasScrollbar(self); getDocumentBody(element).style.overflow = 'hidden'; } addClass(element, offcanvasTogglingClass); addClass(element, showClass); // @ts-ignore element.style.visibility = 'visible'; emulateTransitionEnd(element, () => showOffcanvasComplete(self)); } /** * Executes before hiding the offcanvas. * * @param {Offcanvas} self the `Offcanvas` instance */ function beforeOffcanvasHide(self) { const { element, options } = self; const currentOpen = getCurrentOpen(element); // @ts-ignore element.blur(); if (!currentOpen && options.backdrop && hasClass(overlay, showClass)) { hideOverlay(); emulateTransitionEnd(overlay, () => hideOffcanvasComplete(self)); } else hideOffcanvasComplete(self); } // OFFCANVAS EVENT HANDLERS // ======================== /** * Handles the `click` event listeners. * * @this {HTMLElement | Element} * @param {MouseEvent} e the `Event` object */ function offcanvasTriggerHandler(e) { const trigger = closest(this, offcanvasToggleSelector); const element = trigger && getTargetElement(trigger); const self = element && getOffcanvasInstance(element); if (self) { self.relatedTarget = trigger; self.toggle(); if (trigger && trigger.tagName === 'A') { e.preventDefault(); } } } /** * Handles the event listeners that close the offcanvas. * * @this {Document} * @param {MouseEvent} e the `Event` object */ function offcanvasDismissHandler(e) { const element = querySelector(offcanvasActiveSelector, this); if (!element) return; const offCanvasDismiss = querySelector(offcanvasDismissSelector, element); const self = getOffcanvasInstance(element); if (!self) return; const { options, triggers } = self; const { target } = e; // @ts-ignore -- `EventTarget` is `HTMLElement` const trigger = closest(target, offcanvasToggleSelector); const selection = getDocument(element).getSelection(); if (!(selection && selection.toString().length) // @ts-ignore && ((!element.contains(target) && options.backdrop && (!trigger || (trigger && !triggers.includes(trigger)))) // @ts-ignore || (offCanvasDismiss && offCanvasDismiss.contains(target)))) { // @ts-ignore self.relatedTarget = offCanvasDismiss && offCanvasDismiss.contains(target) ? offCanvasDismiss : null; self.hide(); } 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 * @this {Document} */ function offcanvasKeyDismissHandler({ code }) { const element = querySelector(offcanvasActiveSelector, this); if (!element) return; const self = getOffcanvasInstance(element); if (self && 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, triggers } = self; removeClass(element, offcanvasTogglingClass); removeAttribute(element, ariaHidden); setAttribute(element, ariaModal, 'true'); setAttribute(element, 'role', 'dialog'); if (triggers.length) { triggers.forEach((btn) => setAttribute(btn, ariaExpanded, 'true')); } dispatchEvent(element, shownOffcanvasEvent); toggleOffCanvasDismiss(self, true); focus(element); } /** * Handles the `transitionend` when hiding the offcanvas. * * @param {Offcanvas} self the `Offcanvas` instance */ function hideOffcanvasComplete(self) { const { element, triggers } = self; setAttribute(element, ariaHidden, 'true'); removeAttribute(element, ariaModal); removeAttribute(element, 'role'); // @ts-ignore element.style.visibility = ''; if (triggers.length) { triggers.forEach((btn) => setAttribute(btn, ariaExpanded, 'false')); const visibleTrigger = triggers.find((x) => isVisible(x)); if (visibleTrigger) focus(visibleTrigger); } removeOverlay(element); dispatchEvent(element, hiddenOffcanvasEvent); removeClass(element, offcanvasTogglingClass); // must check for open instances if (!getCurrentOpen(element)) { toggleOffCanvasDismiss(self); } } // OFFCANVAS DEFINITION // ==================== /** Returns a new `Offcanvas` instance. */ class Offcanvas extends BaseComponent { /** * @param {HTMLElement | Element | 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 | Element)[]} */ self.triggers = [...querySelectorAll(offcanvasToggleSelector)] .filter((btn) => getTargetElement(btn) === element); // additional instance property /** @type {HTMLBodyElement | HTMLElement | Element} */ // @ts-ignore self.container = getElementContainer(element); /** @type {(HTMLElement | Element)?} */ self.relatedTarget = null; // attach event listeners toggleOffcanvasEvents(self, true); } /* eslint-disable */ /** * Returns component name string. * @readonly @static */ get name() { return offcanvasComponent; } /** * Returns component default options. * @readonly @static */ 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 || getInstance(currentOpen, 'Modal'); that1.hide(); } if (options.backdrop) { if (!currentOpen) { appendOverlay(container, true); } else { toggleOverlayType(); } overlayDelay = getElementTransitionDuration(overlay); if (!hasClass(overlay, showClass)) showOverlay(); setTimeout(() => beforeOffcanvasShow(self), overlayDelay); } else { beforeOffcanvasShow(self); if (currentOpen && hasClass(overlay, showClass)) { hideOverlay(); } } } /** * Hides the offcanvas from the user. * @param {boolean=} force when `true` it will skip animation */ hide(force) { 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 (!force) { emulateTransitionEnd(element, () => beforeOffcanvasHide(self)); } else beforeOffcanvasHide(self); } /** Removes the `Offcanvas` from the target element. */ dispose() { const self = this; self.hide(true); toggleOffcanvasEvents(self); 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 && [SVGElement, HTMLImageElement, HTMLVideoElement] .some((mediaType) => element instanceof mediaType); /** * Returns an `{x,y}` object with the target * `HTMLElement` / `Node` scroll position. * * @see https://github.com/floating-ui/floating-ui * * @param {HTMLElement | Element | 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) { 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} element target * @param {HTMLElement | Element | Window} offsetParent the container / offset parent * @param {{x: number, y: number}} scroll * @returns {SHORTER.OffsetRect} */ function getRectRelativeToOffsetParent(element, offsetParent, scroll) { const isParentAnElement = offsetParent instanceof HTMLElement; const rect = getBoundingClientRect(element, isParentAnElement && isScaledElement(offsetParent)); const offsets = { x: 0, y: 0 }; 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>} */ var 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 }; // reset tooltip style (top: 0, left: 0 works best) setElementStyle(tooltip, { top: '0px', left: '0px', right: '' }); // @ts-ignore const isPopover = self.name === popoverComponent; const tipWidth = tooltip.offsetWidth; const tipHeight = tooltip.offsetHeight; const RTL = isRTL(element); if (RTL) { tipPositions.left = 'end'; tipPositions.right = 'start'; } const documentElement = getDocumentElement(element); const windowWidth = documentElement.clientWidth; const windowHeight = documentElement.clientHeight; const { container } = options; let { placement } = options; const { left: parentLeft, right: parentRight, top: parentTop, } = getBoundingClientRect(container, true); const parentWidth = container.clientWidth; const scrollbarWidth = Math.abs(parentWidth - container.offsetWidth); 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 ? parentWidth + parentLeft + (RTL ? scrollbarWidth : 0) : parentWidth + parentLeft + (windowWidth - 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: '' }); 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 >= windowHeight; 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 >= windowHeight : bottomExceed; leftExceed = vertical.includes(placement) ? elemRectLeft + elemWidth / 2 - tipWidth / 2 < leftBoundry : leftExceed; rightExceed = vertical.includes(placement) ? elemRectLeft + tipWidth / 2 + elemWidth / 2 >= rightBoundry : rightExceed; // recompute placement // first, when both left and right limits are exceeded, we fall back to top|bottom placement = (horizontal.includes(placement)) && leftExceed && rightExceed ? 'top' : 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 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 if (arrow instanceof HTMLElement) { 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 | Element)?} */ 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'; let elementUID = 1; const elementIDMap = new Map(); /** * Returns a unique identifier for popover, tooltip, scrollspy. * * @param {HTMLElement | Element} element target element * @param {string=} key predefined key * @returns {number} an existing or new unique ID */ function getUID(element, key) { elementUID += 1; let elMap = elementIDMap.get(element); let result = elementUID; if (key && key.length) { if (elMap) { const elMapId = elMap.get(key); if (!Number.isNaN(elMapId)) { result = elMapId; } else { elMap.set(key, result); } } else { elementIDMap.set(element, new Map()); elMap = elementIDMap.get(element); elMap.set(key, result); } } else if (!Number.isNaN(elMap)) { result = elMap; } else { elementIDMap.set(element, result); } return result; } // @ts-ignore 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 ? appleBrands.test(userAgent) : userAgentData.brands.some((/** @type {Record<string, any>} */x) => appleBrands.test(x.brand)); /** * Global namespace for `data-bs-title` attribute. */ const dataOriginalTitle = 'data-original-title'; /** @type {string} */ const tooltipComponent = 'Tooltip'; /** * 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} element target * @param {HTMLElement | Element | string} content the `Element` to append / string * @param {ReturnType<any>} sanitizeFn a function to sanitize string content */ function setHtml(element, content, sanitizeFn) { if (typeof content === 'string' && !content.length) return; if (typeof content === 'string') { let dirty = content.trim(); // fixing #233 if (typeof sanitizeFn === 'function') dirty = sanitizeFn(dirty); const domParser = new DOMParser(); const tempDocument = domParser.parseFromString(dirty, 'text/html'); const { body } = tempDocument; const method = body.children.length ? 'innerHTML' : 'innerText'; // @ts-ignore element[method] = body[method]; } else if (content instanceof HTMLElement) { element.append(content); } } /** * Creates a new tooltip / popover. * * @param {BSN.Popover | BSN.Tooltip} self the `Popover` instance */ function createTip(self) { const { id, element, options } = self; const { animation, customClass, sanitizeFn, placement, dismissible, } = options; let { title, content } = options; const isTooltip = self.name === tooltipComponent; const tipString = isTooltip ? tooltipString : popoverString; const { template, btnClose } = options; const tipPositions = { ...tipClassPositions }; if (isRTL(element)) { tipPositions.left = 'end'; tipPositions.right = 'start'; } // set initial popover class const placementClass = `bs-${tipString}-${tipPositions[placement]}`; // load template /** @type {(HTMLElement | Element)?} */ let popoverTemplate; if ([Element, HTMLElement].some((x) => template instanceof x)) { popoverTemplate = template; } else { const htmlMarkup = getDocument(element).createElement('div'); setHtml(htmlMarkup, template, sanitizeFn); popoverTemplate = htmlMarkup.firstElementChild; } // set popover markup self.tooltip = popoverTemplate && popoverTemplate.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); // set dismissible button if (dismissible) { if (title) { if (title instanceof HTMLElement) setHtml(title, btnClose, sanitizeFn); else title += btnClose; } else { if (tooltipHeader) tooltipHeader.remove(); if (content instanceof HTMLElement) setHtml(content, btnClose, sanitizeFn); else content += btnClose; } } // fill the template with content from options / data attributes // also sanitize title && content if (!isTooltip) { if (title && tooltipHeader) setHtml(tooltipHeader, title, sanitizeFn); if (content && tooltipBody) setHtml(tooltipBody, content, sanitizeFn); // @ts-ignore -- set btn self.btn = querySelector('.btn-close', tooltip); } else if (title && tooltipBody) setHtml(tooltipBody, title, sanitizeFn); // set popover animation and placement if (!hasClass(tooltip, tipString)) addClass(tooltip, tipString); if (animation && !hasClass(tooltip, fadeClass)) addClass(tooltip, fadeClass); if (customClass && !hasClass(tooltip, customClass)) { addClass(tooltip, customClass); } if (!hasClass(tooltip, placementClass)) addClass(tooltip, placementClass); } /** * @param {(HTMLElement | Element)?} tip target * @param {HTMLElement | ParentNode} container parent container * @returns {boolean} */ function isVisibleTip(tip, container) { return tip instanceof HTMLElement && 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 */ function disposeTooltipComplete(self) { const { element } = self; toggleTooltipHandlers(self); if (element.hasAttribute(dataOriginalTitle) && self.name === tooltipString) { toggleTooltipTitle(self); } } /** * 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); if (!isMedia(element)) { [scrollEvent, resizeEvent].forEach((ev) => { // @ts-ignore 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 */ function tooltipHiddenAction(self) { const { element } = self; const hiddenTooltipEvent = OriginalEvent(`hidden.bs.${toLowerCase(self.name)}`); toggleTooltipAction(self); removeTooltip(self); dispatchEvent(element, hiddenTooltipEvent); 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; // @ts-ignore -- 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) => { if (elemIsMedia || tr === mousehoverEvent) { action(element, mousedownEvent, self.show); action(element, mouseenterEvent, self.show); 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); if (!dismissible) action(element, focusoutEvent, self.hide); 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}`); if (!isMedia(element)) { const win = getWindow(element); const overflow = offsetHeight !== scrollHeight; const scrollTarget = overflow || offsetParent !== win ? container : win; // @ts-ignore 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], // @ts-ignore (content || getAttribute(element, titleAtt[0]))); removeAttribute(element, titleAtt[content ? 1 : 0]); } // TOOLTIP DEFINITION // ================== /** Creates a new `Tooltip` instance. */ class Tooltip extends BaseComponent { /** * @param {HTMLElement | Element | 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; getTooltipInstance = (elem) => getInstance(elem, tipComponent); // additional properties /** @type {any} */ self.tooltip = {}; if (!isTooltip) { /** @type {any?} */ // @ts-ignore 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)) return; const container = querySelector(options.container); 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 : 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 if (element.hasAttribute(titleAttr) && isTooltip) { toggleTooltipTitle(self, options.title); } // create tooltip here createTip(self); // attach events toggleTooltipHandlers(self, true); } /* eslint-disable */ /** * Returns component name string. * @readonly @static */ get name() { return tooltipComponent; } /** * Returns component default options. * @readonly @static */ 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); if (!hasClass(tooltip, showClass)) addClass(tooltip, showClass); if (animation) emulateTransitionEnd(tooltip, () => tooltipShownAction(self)); else tooltipShownAction(self); }, 17, 'in'); } } /** * Hides the tooltip. * * @this {Tooltip} */ hide() { const self = this; const { options, tooltip, element } = self; const { container, animation, delay } = options; Timer.clear(element, 'in'); if (tooltip && isVisibleTip(tooltip, container)) { Timer.set(element, () => { const hideTooltipEvent = OriginalEvent(`hide.bs.${toLowerCase(self.name)}`); dispatchEvent(element, hideTooltipEvent); if (hideTooltipEvent.defaultPrevented) return; // @ts-ignore removeClass(tooltip, showClass); toggleTooltipOpenHandlers(self); if (animation) emulateTransitionEnd(tooltip, () => tooltipHiddenAction(self)); else tooltipHiddenAction(self); }, delay + 17, 'out'); } } /** * Updates the tooltip position. * * @param {Event=} e the `Event` object * @this {Tooltip} the `Tooltip` instance */ update(e) { // @ts-ignore 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; if (!enabled) { toggleTooltipHandlers(self, true); self.enabled = !enabled; } } /** Disables the tooltip. */ disable() { const self = this; const { element, tooltip, options, enabled, } = self; const { animation, container, delay } = options; if (enabled) { if (isVisibleTip(tooltip, container) && animation) { self.hide(); Timer.set(element, () => { toggleTooltipHandlers(self); Timer.clear(element, tooltipString); }, getElementTransitionDuration(tooltip) + delay + 17, tooltipString); } 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; if (tooltip.contains(target) || target === element // @ts-ignore || (target && element.contains(target))) ; else { this.hide(); } } /** Removes the `Tooltip` from the target element. */ dispose() { const self = this; const { tooltip, options } = self; if (options.animation && isVisibleTip(tooltip, options.container)) { options.delay = 0; // reset delay self.hide(); emulateTransitionEnd(tooltip, () => disposeTooltipComplete(self)); } else { disposeTooltipComplete(self); } super.dispose(); } } 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 | Element | string} target the target element * @param {BSN.Options.Popover=} config the instance options */ constructor(target, config) { super(target, config); } /** * Returns component name string. * @readonly @static */ get name() { return popoverComponent; } /** * Returns component default options. * @readonly @static */ get defaults() { return popoverDefaults; } /* eslint-enable */ /* extend original `show()` */ show() { super.show(); // @ts-ignore -- btn only exists within dismissible popover const { options, btn } = this; 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 {(HTMLElement | Element | Document)=} parent optional Element to look into * @return {HTMLCollectionOf<HTMLElement | Element>} the 'HTMLCollection' */ function getElementsByTagName(selector, parent) { const lookUp = parent && parentNodes .some((x) => parent instanceof x) ? parent : getDocument(); return lookUp.getElementsByTagName(selector); } /** @type {string} */ const scrollspyString = 'scrollspy'; /** @type {string} */ const scrollspyComponent = 'ScrollSpy'; /* Native JavaScript for Bootstrap 5 | ScrollSpy ------------------------------------------------ */ // console.log(typeof addEventListener) // 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 = scrollTarget instanceof Window; const links = target && getElementsByTagName('A', target); const scrollHEIGHT = scrollTarget && getScrollHeight(scrollTarget); // @ts-ignore self.scrollTop = isWin ? scrollTarget.scrollY : scrollTarget.scrollTop; // only update items/offsets once or with each mutation 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); // @ts-ignore 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 {HTMLElement | Element | Window | globalThis} scrollTarget the `ScrollSpy` instance * @return {number} `scrollTarget` height */ function getScrollHeight(scrollTarget) { return scrollTarget instanceof HTMLElement ? scrollTarget.scrollHeight // @ts-ignore : getDocumentElement(scrollTarget).scrollHeight; } /** * Returns the height property of the scrolling element. * @param {ScrollSpy} params the `ScrollSpy` instance * @returns {number} */ function getOffsetHeight({ element, scrollTarget }) { return (scrollTarget instanceof Window) ? scrollTarget.innerHeight : getBoundingClientRect(element).height; } /** * Clear all items of the target. * @param {HTMLElement | Element} 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 | Element} item a single item */ function activate(self, item) { const { target, element } = self; // @ts-ignore clear(target); // @ts-ignore self.activeItem = item; addClass(item, activeClass); // activate all parents const parents = []; let parentItem = item; while (parentItem !== getDocumentBody(element)) { // @ts-ignore parentItem = parentItem.parentElement; if (hasClass(parentItem, 'nav') || hasClass(parentItem, 'dropdown-menu')) parents.push(parentItem); } parents.forEach((menuItem) => { /** @type {(HTMLElement | Element)?} */ const parentLink = menuItem.previousElementSibling; 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; // @ts-ignore action(self.scrollTarget, scrollEvent, self.refresh, passiveHandler); } // SCROLLSPY DEFINITION // ==================== /** Returns a new `ScrollSpy` instance. */ class ScrollSpy extends BaseComponent { /** * @param {HTMLElement | Element | 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 | Element)?} */ self.target = querySelector(options.target, getDocument(element)); // invalidate if (!self.target) return; const win = getWindow(element); // set initial state /** @type {HTMLElement | Element | Window | globalThis} */ self.scrollTarget = element.clientHeight < element.scrollHeight ? element : win; /** @type {number} */ self.scrollTop = 0; /** @type {number} */ self.maxScroll = 0; /** @type {number} */ self.scrollHeight = 0; /** @type {(HTMLElement | Element)?} */ self.activeItem = null; /** @type {(HTMLElement | Element)[]} */ 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. * @readonly @static */ get name() { return scrollspyComponent; } /** * Returns component default options. * @readonly @static */ 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 // @ts-ignore if (target.offsetHeight === 0) return; updateSpyTargets(self); const { scrollTop, maxScroll, itemsLength, items, activeItem, } = self; if (scrollTop >= maxScroll) { const newActiveItem = items[itemsLength - 1]; if (activeItem !== newActiveItem) { activate(self, newActiveItem); } return; } const { offsets } = self; if (activeItem && scrollTop < offsets[0] && offsets[0] > 0) { self.activeItem = null; // @ts-ignore 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}`); /** * @type {Map<(HTMLElement | Element), 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; if (tabContent) { // @ts-ignore tabContent.style.height = ''; removeClass(tabContent, collapsingClass); } if (nav) Timer.clear(nav); } /** * Executes before showing the tab content. * @param {Tab} self the `Tab` instance */ function triggerTabShow(self) { const { element, tabContent, nav } = self; const { currentHeight, nextHeight } = tabPrivate.get(element); const { tab } = nav && tabPrivate.get(nav); if (tabContent) { // height animation if (currentHeight === nextHeight) { triggerTabEnd(self); } else { setTimeout(() => { // enables height animation // @ts-ignore tabContent.style.height = `${nextHeight}px`; // height animation 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; if (tabContent) { [content, nextContent].forEach((c) => addClass(c, 'overflow-hidden')); currentHeight = content.scrollHeight; } // update relatedTarget and dispatch event showTabEvent.relatedTarget = tab; hiddenTabEvent.relatedTarget = element; dispatchEvent(element, showTabEvent); if (showTabEvent.defaultPrevented) return; addClass(nextContent, activeClass); removeClass(content, activeClass); if (tabContent) { const nextHeight = nextContent.scrollHeight; tabPrivate.set(element, { currentHeight, nextHeight }); addClass(tabContent, collapsingClass); // @ts-ignore -- height animation 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); }); }, 17); } else { 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; // @ts-ignore const activeTabs = getElementsByClassName(activeClass, nav); /** @type {(HTMLElement | Element)=} */ let tab; if (activeTabs.length === 1 // @ts-ignore && !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; // @ts-ignore return { tab, content }; } /** * 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 | Element} * @param {MouseEvent} e the `Event` object */ function tabClickHandler(e) { const self = getTabInstance(this); if (!self) return; e.preventDefault(); self.show(); } // TAB DEFINITION // ============== /** Creates a new `Tab` instance. */ class Tab extends BaseComponent { /** * @param {HTMLElement | Element | 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 | Element)?} */ self.nav = nav; /** @type {HTMLElement | Element} */ self.content = content; /** @type {(HTMLElement | Element)?} */ self.tabContent = container; // event targets /** @type {(HTMLElement | Element)?} */ self.dropdown = nav && querySelector(`.${dropdownMenuClasses[0]}-toggle`, nav); // add event listener toggleTabHandler(self, true); } /* eslint-disable */ /** * Returns component name string. * @readonly @static */ get name() { return tabComponent; } /* eslint-enable */ // TAB PUBLIC METHODS // ================== /** Shows the tab to the user. */ show() { const self = this; const { element, nav, dropdown } = self; if (!(nav && Timer.get(nav)) && !hasClass(element, activeClass)) { const { tab, content } = getActiveTab(self); if (nav) tabPrivate.set(nav, { tab, content }); // update relatedTarget and dispatch hideTabEvent.relatedTarget = element; dispatchEvent(tab, hideTabEvent); if (hideTabEvent.defaultPrevented) return; if (nav) Timer.set(nav, () => {}, 17); removeClass(tab, activeClass); setAttribute(tab, ariaSelected, 'false'); addClass(element, activeClass); setAttribute(element, ariaSelected, 'true'); if (dropdown) { // @ts-ignore if (!hasClass(element.parentNode, dropdownMenuClass)) { if (hasClass(dropdown, activeClass)) removeClass(dropdown, activeClass); } else if (!hasClass(dropdown, activeClass)) addClass(dropdown, activeClass); } if (hasClass(content, fadeClass)) { removeClass(content, showClass); 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 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); 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, dismiss, options } = self; if (dismiss) { action(dismiss, mouseclickEvent, self.hide); } if (options.autohide) { [focusinEvent, focusoutEvent, mouseenterEvent, mouseleaveEvent] .forEach((e) => action(element, e, interactiveToastHandler)); } } // 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); } /** * Executes when user interacts with the toast without closing it, * usually by hovering or focusing it. * * @this {HTMLElement | Element} * @param {MouseEvent} e the `Toast` instance */ function interactiveToastHandler(e) { const element = this; const self = getToastInstance(element); const { type, relatedTarget } = e; // @ts-ignore 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 | Element | 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 | Element)?} */ self.dismiss = querySelector(toastDismissSelector, 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. * @readonly @static */ get name() { return toastComponent; } /** * Returns component default options. * @readonly @static */ get defaults() { return toastDefaults; } /* eslint-enable */ // TOAST PUBLIC METHODS // ==================== /** Shows the toast. */ show() { const self = this; const { element } = self; if (element && !hasClass(element, showClass)) { dispatchEvent(element, showToastEvent); if (showToastEvent.defaultPrevented) return; showToast(self); } } /** Hides the toast. */ hide() { const self = this; const { element } = self; if (element && hasClass(element, showClass)) { dispatchEvent(element, hideToastEvent); if (hideToastEvent.defaultPrevented) return; hideToast(self); } } /** Removes the `Toast` component from the target element. */ dispose() { const self = this; const { element } = self; if (hasClass(element, showClass)) { removeClass(element, showClass); } completeDisposeToast(self); super.dispose(); } } ObjectAssign(Toast, { selector: toastSelector, init: toastInitCallback, getInstance: getToastInstance, }); /** * Check if element matches a CSS selector. * * @param {HTMLElement | Element} 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 {NodeListOf<HTMLElement | Element> | (HTMLElement | Element)[]} 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 {(Element | HTMLElement | Document)=} context parent `Element` */ function removeComponentDataAPI(component, context) { const compData = Data.getAllFor(component); if (compData) { [...compData].forEach((x) => { const [element, instance] = x; if (context && context.contains(element)) instance.dispose(); }); } } /** * Initialize all BSN components for a target container. * @param {(Element | HTMLElement | Document)=} context parent `Element` */ function initCallback(context) { const lookUp = context && parentNodes.some((x) => context instanceof x) ? context : undefined; 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 {(Element | HTMLElement | Document)=} context parent `Element` */ function removeDataAPI(context) { const lookUp = context && parentNodes.some((x) => context instanceof x) ? context : undefined; 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, }; return BSN; }));