From 4dbb9324ae50bd4d1762c937651167b9692f151c Mon Sep 17 00:00:00 2001 From: BlackDex Date: Thu, 27 Jul 2023 22:51:22 +0200 Subject: [PATCH] Update admin interface - Updated the admin interface dependencies. - Replace bootstrap-native with bootstrap - Added auto theme with an option to switch to dark/light - Some small color changes - Added an dev only function to always load static files from disk --- src/api/web.rs | 43 +- src/static/scripts/admin.js | 85 +- src/static/scripts/admin_diagnostics.js | 4 +- src/static/scripts/admin_users.js | 14 +- src/static/scripts/bootstrap-native.js | 5991 ---------------- src/static/scripts/bootstrap.bundle.js | 6313 +++++++++++++++++ src/static/scripts/bootstrap.css | 2393 +++++-- src/static/scripts/datatables.css | 47 +- src/static/scripts/datatables.js | 56 +- ...ery-3.6.4.slim.js => jquery-3.7.0.slim.js} | 1837 ++--- src/static/templates/admin/base.hbs | 57 +- src/static/templates/admin/diagnostics.hbs | 6 +- src/static/templates/admin/login.hbs | 10 +- src/static/templates/admin/organizations.hbs | 4 +- src/static/templates/admin/settings.hbs | 6 +- src/static/templates/admin/users.hbs | 6 +- 16 files changed, 9146 insertions(+), 7726 deletions(-) delete mode 100644 src/static/scripts/bootstrap-native.js create mode 100644 src/static/scripts/bootstrap.bundle.js rename src/static/scripts/{jquery-3.6.4.slim.js => jquery-3.7.0.slim.js} (85%) diff --git a/src/api/web.rs b/src/api/web.rs index 5cdcb15e521..a75b9e4e752 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -14,11 +14,17 @@ use crate::{ pub fn routes() -> Vec { // If addding more routes here, consider also adding them to // crate::utils::LOGGED_ROUTES to make sure they appear in the log + let mut routes = routes![attachments, alive, alive_head, static_files]; if CONFIG.web_vault_enabled() { - routes![web_index, web_index_head, app_id, web_files, attachments, alive, alive_head, static_files] - } else { - routes![attachments, alive, alive_head, static_files] + routes.append(&mut routes![web_index, web_index_head, app_id, web_files]); + } + + #[cfg(debug_assertions)] + if CONFIG.reload_templates() { + routes.append(&mut routes![_static_files_dev]); } + + routes } pub fn catchers() -> Vec { @@ -116,7 +122,30 @@ fn alive_head(_conn: DbConn) -> EmptyResult { Ok(()) } -#[get("/vw_static/")] +// This endpoint/function is used during development and development only. +// It allows to easily develop the admin interface by always loading the files from disk instead from a slice of bytes +// This will only be active during a debug build and only when `RELOAD_TEMPLATES` is set to `true` +// NOTE: Do not forget to add any new files added to the `static_files` function below! +#[cfg(debug_assertions)] +#[get("/vw_static/", rank = 1)] +pub async fn _static_files_dev(filename: PathBuf) -> Option { + warn!("LOADING STATIC FILES FROM DISK"); + let file = filename.to_str().unwrap_or_default(); + let ext = filename.extension().unwrap_or_default(); + + let path = if ext == "png" || ext == "svg" { + tokio::fs::canonicalize(Path::new(file!()).parent().unwrap().join("../static/images/").join(file)).await + } else { + tokio::fs::canonicalize(Path::new(file!()).parent().unwrap().join("../static/scripts/").join(file)).await + }; + + if let Ok(path) = path { + return NamedFile::open(path).await.ok(); + }; + None +} + +#[get("/vw_static/", rank = 2)] pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Error> { match filename { "404.png" => Ok((ContentType::PNG, include_bytes!("../static/images/404.png"))), @@ -138,12 +167,12 @@ pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Erro Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_diagnostics.js"))) } "bootstrap.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/bootstrap.css"))), - "bootstrap-native.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/bootstrap-native.js"))), + "bootstrap.bundle.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/bootstrap.bundle.js"))), "jdenticon.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jdenticon.js"))), "datatables.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/datatables.js"))), "datatables.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))), - "jquery-3.6.4.slim.js" => { - Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jquery-3.6.4.slim.js"))) + "jquery-3.7.0.slim.js" => { + Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jquery-3.7.0.slim.js"))) } _ => err!(format!("Static file not found: {filename}")), } diff --git a/src/static/scripts/admin.js b/src/static/scripts/admin.js index a9c197397de..b35f3fb14fc 100644 --- a/src/static/scripts/admin.js +++ b/src/static/scripts/admin.js @@ -37,36 +37,107 @@ function _post(url, successMsg, errMsg, body, reload_page = true) { mode: "same-origin", credentials: "same-origin", headers: { "Content-Type": "application/json" } - }).then( resp => { + }).then(resp => { if (resp.ok) { msg(successMsg, reload_page); // Abuse the catch handler by setting error to false and continue - return Promise.reject({error: false}); + return Promise.reject({ error: false }); } respStatus = resp.status; respStatusText = resp.statusText; return resp.text(); - }).then( respText => { + }).then(respText => { try { const respJson = JSON.parse(respText); if (respJson.ErrorModel && respJson.ErrorModel.Message) { return respJson.ErrorModel.Message; } else { - return Promise.reject({body:`${respStatus} - ${respStatusText}\n\nUnknown error`, error: true}); + return Promise.reject({ body: `${respStatus} - ${respStatusText}\n\nUnknown error`, error: true }); } } catch (e) { - return Promise.reject({body:`${respStatus} - ${respStatusText}\n\n[Catch] ${e}`, error: true}); + return Promise.reject({ body: `${respStatus} - ${respStatusText}\n\n[Catch] ${e}`, error: true }); } - }).then( apiMsg => { + }).then(apiMsg => { msg(`${errMsg}\n${apiMsg}`, reload_page); - }).catch( e => { + }).catch(e => { if (e.error === false) { return true; } else { msg(`${errMsg}\n${e.body}`, reload_page); } }); } +// Bootstrap Theme Selector +const getStoredTheme = () => localStorage.getItem("theme"); +const setStoredTheme = theme => localStorage.setItem("theme", theme); + +const getPreferredTheme = () => { + const storedTheme = getStoredTheme(); + if (storedTheme) { + return storedTheme; + } + + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +}; + +const setTheme = theme => { + if (theme === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches) { + document.documentElement.setAttribute("data-bs-theme", "dark"); + } else { + document.documentElement.setAttribute("data-bs-theme", theme); + } +}; + +setTheme(getPreferredTheme()); + +const showActiveTheme = (theme, focus = false) => { + const themeSwitcher = document.querySelector("#bd-theme"); + + if (!themeSwitcher) { + return; + } + + const themeSwitcherText = document.querySelector("#bd-theme-text"); + const activeThemeIcon = document.querySelector(".theme-icon-active use"); + const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`); + const svgOfActiveBtn = btnToActive.querySelector("span use").innerText; + + document.querySelectorAll("[data-bs-theme-value]").forEach(element => { + element.classList.remove("active"); + element.setAttribute("aria-pressed", "false"); + }); + + btnToActive.classList.add("active"); + btnToActive.setAttribute("aria-pressed", "true"); + activeThemeIcon.innerText = svgOfActiveBtn; + const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`; + themeSwitcher.setAttribute("aria-label", themeSwitcherLabel); + + if (focus) { + themeSwitcher.focus(); + } +}; + +window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { + const storedTheme = getStoredTheme(); + if (storedTheme !== "light" && storedTheme !== "dark") { + setTheme(getPreferredTheme()); + } +}); + + // onLoad events document.addEventListener("DOMContentLoaded", (/*event*/) => { + showActiveTheme(getPreferredTheme()); + + document.querySelectorAll("[data-bs-theme-value]") + .forEach(toggle => { + toggle.addEventListener("click", () => { + const theme = toggle.getAttribute("data-bs-theme-value"); + setStoredTheme(theme); + setTheme(theme); + showActiveTheme(theme, true); + }); + }); + // get current URL path and assign "active" class to the correct nav-item const pathname = window.location.pathname; if (pathname === "") return; diff --git a/src/static/scripts/admin_diagnostics.js b/src/static/scripts/admin_diagnostics.js index 5fbed2dad53..0b1d06226fe 100644 --- a/src/static/scripts/admin_diagnostics.js +++ b/src/static/scripts/admin_diagnostics.js @@ -1,6 +1,6 @@ "use strict"; /* eslint-env es2017, browser */ -/* global BASE_URL:readable, BSN:readable */ +/* global BASE_URL:readable, bootstrap:readable */ var dnsCheck = false; var timeCheck = false; @@ -135,7 +135,7 @@ function copyToClipboard(event) { document.execCommand("copy"); tmpCopyEl.remove(); - new BSN.Toast("#toastClipboardCopy").show(); + new bootstrap.Toast("#toastClipboardCopy").show(); } function checkTimeDrift(utcTimeA, utcTimeB, statusPrefix) { diff --git a/src/static/scripts/admin_users.js b/src/static/scripts/admin_users.js index a931a4a96bd..8b5692969f6 100644 --- a/src/static/scripts/admin_users.js +++ b/src/static/scripts/admin_users.js @@ -141,19 +141,20 @@ function resendUserInvite (event) { const ORG_TYPES = { "0": { "name": "Owner", - "color": "orange" + "bg": "orange", + "font": "black" }, "1": { "name": "Admin", - "color": "blueviolet" + "bg": "blueviolet" }, "2": { "name": "User", - "color": "blue" + "bg": "blue" }, "3": { "name": "Manager", - "color": "green" + "bg": "green" }, }; @@ -227,7 +228,10 @@ function initUserTable() { // Color all the org buttons per type document.querySelectorAll("button[data-vw-org-type]").forEach(function(e) { const orgType = ORG_TYPES[e.dataset.vwOrgType]; - e.style.backgroundColor = orgType.color; + e.style.backgroundColor = orgType.bg; + if (orgType.font !== undefined) { + e.style.color = orgType.font; + } e.title = orgType.name; }); diff --git a/src/static/scripts/bootstrap-native.js b/src/static/scripts/bootstrap-native.js deleted file mode 100644 index bf26cef8796..00000000000 --- a/src/static/scripts/bootstrap-native.js +++ /dev/null @@ -1,5991 +0,0 @@ -/*! - * Native JavaScript for Bootstrap v4.2.0 (https://thednp.github.io/bootstrap.native/) - * Copyright 2015-2022 © dnp_theme - * Licensed under MIT (https://github.com/thednp/bootstrap.native/blob/master/LICENSE) - */ -(function (global, factory) { - typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : - typeof define === 'function' && define.amd ? define(factory) : - (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.BSN = factory()); -})(this, (function () { 'use strict'; - - /** @type {Record} */ - const EventRegistry = {}; - - /** - * The global event listener. - * - * @type {EventListener} - * @this {EventTarget} - */ - function globalListener(e) { - const that = this; - const { type } = e; - - [...EventRegistry[type]].forEach((elementsMap) => { - const [element, listenersMap] = elementsMap; - /* istanbul ignore else */ - if (element === that) { - [...listenersMap].forEach((listenerMap) => { - const [listener, options] = listenerMap; - listener.apply(element, [e]); - - if (options && options.once) { - removeListener(element, type, listener, options); - } - }); - } - }); - } - - /** - * Register a new listener with its options and attach the `globalListener` - * to the target if this is the first listener. - * - * @type {Listener.ListenerAction} - */ - const addListener = (element, eventType, listener, options) => { - // get element listeners first - if (!EventRegistry[eventType]) { - EventRegistry[eventType] = new Map(); - } - const oneEventMap = EventRegistry[eventType]; - - if (!oneEventMap.has(element)) { - oneEventMap.set(element, new Map()); - } - const oneElementMap = oneEventMap.get(element); - - // get listeners size - const { size } = oneElementMap; - - // register listener with its options - oneElementMap.set(listener, options); - - // add listener last - if (!size) { - element.addEventListener(eventType, globalListener, options); - } - }; - - /** - * Remove a listener from registry and detach the `globalListener` - * if no listeners are found in the registry. - * - * @type {Listener.ListenerAction} - */ - const removeListener = (element, eventType, listener, options) => { - // get listener first - const oneEventMap = EventRegistry[eventType]; - const oneElementMap = oneEventMap && oneEventMap.get(element); - const savedOptions = oneElementMap && oneElementMap.get(listener); - - // also recover initial options - const { options: eventOptions } = savedOptions !== undefined - ? savedOptions - : { options }; - - // unsubscribe second, remove from registry - if (oneElementMap && oneElementMap.has(listener)) oneElementMap.delete(listener); - if (oneEventMap && (!oneElementMap || !oneElementMap.size)) oneEventMap.delete(element); - if (!oneEventMap || !oneEventMap.size) delete EventRegistry[eventType]; - - // remove listener last - /* istanbul ignore else */ - if (!oneElementMap || !oneElementMap.size) { - element.removeEventListener(eventType, globalListener, eventOptions); - } - }; - - /** - * Advanced event listener based on subscribe / publish pattern. - * @see https://www.patterns.dev/posts/classic-design-patterns/#observerpatternjavascript - * @see https://gist.github.com/shystruk/d16c0ee7ac7d194da9644e5d740c8338#file-subpub-js - * @see https://hackernoon.com/do-you-still-register-window-event-listeners-in-each-component-react-in-example-31a4b1f6f1c8 - */ - const Listener = { - on: addListener, - off: removeListener, - globalListener, - registry: EventRegistry, - }; - - /** - * A global namespace for `click` event. - * @type {string} - */ - const mouseclickEvent = 'click'; - - /** - * A global namespace for 'transitionend' string. - * @type {string} - */ - const transitionEndEvent = 'transitionend'; - - /** - * A global namespace for 'transitionDelay' string. - * @type {string} - */ - const transitionDelay = 'transitionDelay'; - - /** - * A global namespace for `transitionProperty` string for modern browsers. - * - * @type {string} - */ - const transitionProperty = 'transitionProperty'; - - /** - * Shortcut for `window.getComputedStyle(element).propertyName` - * static method. - * - * * If `element` parameter is not an `HTMLElement`, `getComputedStyle` - * throws a `ReferenceError`. - * - * @param {HTMLElement} element target - * @param {string} property the css property - * @return {string} the css property value - */ - function getElementStyle(element, property) { - const computedStyle = getComputedStyle(element); - - // must use camelcase strings, - // or non-camelcase strings with `getPropertyValue` - return property.includes('--') - ? computedStyle.getPropertyValue(property) - : computedStyle[property]; - } - - /** - * Utility to get the computed `transitionDelay` - * from Element in miliseconds. - * - * @param {HTMLElement} element target - * @return {number} the value in miliseconds - */ - function getElementTransitionDelay(element) { - const propertyValue = getElementStyle(element, transitionProperty); - const delayValue = getElementStyle(element, transitionDelay); - const delayScale = delayValue.includes('ms') ? /* istanbul ignore next */1 : 1000; - const duration = propertyValue && propertyValue !== 'none' - ? parseFloat(delayValue) * delayScale : 0; - - return !Number.isNaN(duration) ? duration : /* istanbul ignore next */0; - } - - /** - * A global namespace for 'transitionDuration' string. - * @type {string} - */ - const transitionDuration = 'transitionDuration'; - - /** - * Utility to get the computed `transitionDuration` - * from Element in miliseconds. - * - * @param {HTMLElement} element target - * @return {number} the value in miliseconds - */ - function getElementTransitionDuration(element) { - const propertyValue = getElementStyle(element, transitionProperty); - const durationValue = getElementStyle(element, transitionDuration); - const durationScale = durationValue.includes('ms') ? /* istanbul ignore next */1 : 1000; - const duration = propertyValue && propertyValue !== 'none' - ? parseFloat(durationValue) * durationScale : 0; - - return !Number.isNaN(duration) ? duration : /* istanbul ignore next */0; - } - - /** - * Shortcut for the `Element.dispatchEvent(Event)` method. - * - * @param {HTMLElement} element is the target - * @param {Event} event is the `Event` object - */ - const dispatchEvent = (element, event) => element.dispatchEvent(event); - - /** - * Utility to make sure callbacks are consistently - * called when transition ends. - * - * @param {HTMLElement} element target - * @param {EventListener} handler `transitionend` callback - */ - function emulateTransitionEnd(element, handler) { - let called = 0; - const endEvent = new Event(transitionEndEvent); - const duration = getElementTransitionDuration(element); - const delay = getElementTransitionDelay(element); - - if (duration) { - /** - * Wrap the handler in on -> off callback - * @type {EventListener} e Event object - */ - const transitionEndWrapper = (e) => { - /* istanbul ignore else */ - if (e.target === element) { - handler.apply(element, [e]); - element.removeEventListener(transitionEndEvent, transitionEndWrapper); - called = 1; - } - }; - element.addEventListener(transitionEndEvent, transitionEndWrapper); - setTimeout(() => { - /* istanbul ignore next */ - if (!called) dispatchEvent(element, endEvent); - }, duration + delay + 17); - } else { - handler.apply(element, [endEvent]); - } - } - - /** - * Checks if an object is a `Node`. - * - * @param {any} node the target object - * @returns {boolean} the query result - */ - const isNode = (element) => (element && [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] - .some((x) => +element.nodeType === x)) || false; - - /** - * Check if a target object is `Window`. - * => equivalent to `object instanceof Window` - * - * @param {any} object the target object - * @returns {boolean} the query result - */ - const isWindow = (object) => (object && object.constructor.name === 'Window') || false; - - /** - * Checks if an object is a `Document`. - * @see https://dom.spec.whatwg.org/#node - * - * @param {any} object the target object - * @returns {boolean} the query result - */ - const isDocument = (object) => (object && object.nodeType === 9) || false; - - /** - * Returns the `document` or the `#document` element. - * @see https://github.com/floating-ui/floating-ui - * @param {(Node | Window)=} node - * @returns {Document} - */ - function getDocument(node) { - // node instanceof Document - if (isDocument(node)) return node; - // node instanceof Node - if (isNode(node)) return node.ownerDocument; - // node instanceof Window - if (isWindow(node)) return node.document; - // node is undefined | NULL - return window.document; - } - - /** - * Utility to check if target is typeof `HTMLElement`, `Element`, `Node` - * or find one that matches a selector. - * - * @param {Node | string} selector the input selector or target element - * @param {ParentNode=} parent optional node to look into - * @return {HTMLElement?} the `HTMLElement` or `querySelector` result - */ - function querySelector(selector, parent) { - if (isNode(selector)) { - return selector; - } - const lookUp = isNode(parent) ? parent : getDocument(); - - return lookUp.querySelector(selector); - } - - /** - * Shortcut for `HTMLElement.closest` method which also works - * with children of `ShadowRoot`. The order of the parameters - * is intentional since they're both required. - * - * @see https://stackoverflow.com/q/54520554/803358 - * - * @param {HTMLElement} element Element to look into - * @param {string} selector the selector name - * @return {HTMLElement?} the query result - */ - function closest(element, selector) { - return element ? (element.closest(selector) - // break out of `ShadowRoot` - || closest(element.getRootNode().host, selector)) : null; - } - - /** - * Shortcut for `Object.assign()` static method. - * @param {Record} obj a target object - * @param {Record} source a source object - */ - const ObjectAssign = (obj, source) => Object.assign(obj, source); - - /** - * Check class in `HTMLElement.classList`. - * - * @param {HTMLElement} element target - * @param {string} classNAME to check - * @returns {boolean} - */ - function hasClass(element, classNAME) { - return element.classList.contains(classNAME); - } - - /** - * Remove class from `HTMLElement.classList`. - * - * @param {HTMLElement} element target - * @param {string} classNAME to remove - * @returns {void} - */ - function removeClass(element, classNAME) { - element.classList.remove(classNAME); - } - - /** - * Checks if an element is an `HTMLElement`. - * @see https://dom.spec.whatwg.org/#node - * - * @param {any} element the target object - * @returns {boolean} the query result - */ - const isHTMLElement = (element) => (element && element.nodeType === 1) || false; - - /** @type {Map>>} */ - const componentData = new Map(); - /** - * An interface for web components background data. - * @see https://github.com/thednp/bootstrap.native/blob/master/src/components/base-component.js - */ - const Data = { - /** - * Sets web components data. - * @param {HTMLElement} element target element - * @param {string} component the component's name or a unique key - * @param {Record} instance the component instance - */ - set: (element, component, instance) => { - if (!isHTMLElement(element)) return; - - /* istanbul ignore else */ - if (!componentData.has(component)) { - componentData.set(component, new Map()); - } - - const instanceMap = componentData.get(component); - // not undefined, but defined right above - instanceMap.set(element, instance); - }, - - /** - * Returns all instances for specified component. - * @param {string} component the component's name or a unique key - * @returns {Map>?} all the component instances - */ - getAllFor: (component) => { - const instanceMap = componentData.get(component); - - return instanceMap || null; - }, - - /** - * Returns the instance associated with the target. - * @param {HTMLElement} element target element - * @param {string} component the component's name or a unique key - * @returns {Record?} the instance - */ - get: (element, component) => { - if (!isHTMLElement(element) || !component) return null; - const allForC = Data.getAllFor(component); - const instance = element && allForC && allForC.get(element); - - return instance || null; - }, - - /** - * Removes web components data. - * @param {HTMLElement} element target element - * @param {string} component the component's name or a unique key - */ - remove: (element, component) => { - const instanceMap = componentData.get(component); - if (!instanceMap || !isHTMLElement(element)) return; - - instanceMap.delete(element); - - /* istanbul ignore else */ - if (instanceMap.size === 0) { - componentData.delete(component); - } - }, - }; - - /** - * An alias for `Data.get()`. - * @type {SHORTY.getInstance} - */ - const getInstance = (target, component) => Data.get(target, component); - - /** - * Checks if an object is an `Object`. - * - * @param {any} obj the target object - * @returns {boolean} the query result - */ - const isObject = (obj) => (typeof obj === 'object') || false; - - /** - * Returns a namespaced `CustomEvent` specific to each component. - * @param {string} EventType Event.type - * @param {Record=} config Event.options | Event.properties - * @returns {SHORTY.OriginalEvent} a new namespaced event - */ - function OriginalEvent(EventType, config) { - const OriginalCustomEvent = new CustomEvent(EventType, { - cancelable: true, bubbles: true, - }); - - /* istanbul ignore else */ - if (isObject(config)) { - ObjectAssign(OriginalCustomEvent, config); - } - return OriginalCustomEvent; - } - - /** - * Global namespace for most components `fade` class. - */ - const fadeClass = 'fade'; - - /** - * Global namespace for most components `show` class. - */ - const showClass = 'show'; - - /** - * Global namespace for most components `dismiss` option. - */ - const dataBsDismiss = 'data-bs-dismiss'; - - /** @type {string} */ - const alertString = 'alert'; - - /** @type {string} */ - const alertComponent = 'Alert'; - - /** - * Shortcut for `HTMLElement.getAttribute()` method. - * @param {HTMLElement} element target element - * @param {string} attribute attribute name - * @returns {string?} attribute value - */ - const getAttribute = (element, attribute) => element.getAttribute(attribute); - - /** - * The raw value or a given component option. - * - * @typedef {string | HTMLElement | Function | number | boolean | null} niceValue - */ - - /** - * Utility to normalize component options - * - * @param {any} value the input value - * @return {niceValue} the normalized value - */ - function normalizeValue(value) { - if (['true', true].includes(value)) { // boolean - // if ('true' === value) { // boolean - return true; - } - - if (['false', false].includes(value)) { // boolean - // if ('false' === value) { // boolean - return false; - } - - if (value === '' || value === 'null') { // null - return null; - } - - if (value !== '' && !Number.isNaN(+value)) { // number - return +value; - } - - // string / function / HTMLElement / object - return value; - } - - /** - * Shortcut for `Object.keys()` static method. - * @param {Record} obj a target object - * @returns {string[]} - */ - const ObjectKeys = (obj) => Object.keys(obj); - - /** - * Shortcut for `String.toLowerCase()`. - * - * @param {string} source input string - * @returns {string} lowercase output string - */ - const toLowerCase = (source) => source.toLowerCase(); - - /** - * Utility to normalize component options. - * - * @param {HTMLElement} element target - * @param {Record} defaultOps component default options - * @param {Record} inputOps component instance options - * @param {string=} ns component namespace - * @return {Record} normalized component options object - */ - function normalizeOptions(element, defaultOps, inputOps, ns) { - const data = { ...element.dataset }; - /** @type {Record} */ - const normalOps = {}; - /** @type {Record} */ - const dataOps = {}; - const title = 'title'; - - ObjectKeys(data).forEach((k) => { - const key = ns && k.includes(ns) - ? k.replace(ns, '').replace(/[A-Z]/, (match) => toLowerCase(match)) - : k; - - dataOps[key] = normalizeValue(data[k]); - }); - - ObjectKeys(inputOps).forEach((k) => { - inputOps[k] = normalizeValue(inputOps[k]); - }); - - ObjectKeys(defaultOps).forEach((k) => { - /* istanbul ignore else */ - if (k in inputOps) { - normalOps[k] = inputOps[k]; - } else if (k in dataOps) { - normalOps[k] = dataOps[k]; - } else { - normalOps[k] = k === title - ? getAttribute(element, title) - : defaultOps[k]; - } - }); - - return normalOps; - } - - var version = "4.2.0"; - - const Version = version; - - /* Native JavaScript for Bootstrap 5 | Base Component - ----------------------------------------------------- */ - - /** Returns a new `BaseComponent` instance. */ - class BaseComponent { - /** - * @param {HTMLElement | string} target `Element` or selector string - * @param {BSN.ComponentOptions=} config component instance options - */ - constructor(target, config) { - const self = this; - const element = querySelector(target); - - if (!element) { - throw Error(`${self.name} Error: "${target}" is not a valid selector.`); - } - - /** @static @type {BSN.ComponentOptions} */ - self.options = {}; - - const prevInstance = Data.get(element, self.name); - if (prevInstance) prevInstance.dispose(); - - /** @type {HTMLElement} */ - self.element = element; - - /* istanbul ignore else */ - if (self.defaults && ObjectKeys(self.defaults).length) { - self.options = normalizeOptions(element, self.defaults, (config || {}), 'bs'); - } - - Data.set(element, self.name, self); - } - - /* eslint-disable */ - /* istanbul ignore next */ - /** @static */ - get version() { return Version; } - - /* eslint-enable */ - /* istanbul ignore next */ - /** @static */ - get name() { return this.constructor.name; } - - /* istanbul ignore next */ - /** @static */ - get defaults() { return this.constructor.defaults; } - - /** - * Removes component from target element; - */ - dispose() { - const self = this; - Data.remove(self.element, self.name); - ObjectKeys(self).forEach((prop) => { self[prop] = null; }); - } - } - - /* Native JavaScript for Bootstrap 5 | Alert - -------------------------------------------- */ - - // ALERT PRIVATE GC - // ================ - const alertSelector = `.${alertString}`; - const alertDismissSelector = `[${dataBsDismiss}="${alertString}"]`; - - /** - * Static method which returns an existing `Alert` instance associated - * to a target `Element`. - * - * @type {BSN.GetInstance} - */ - const getAlertInstance = (element) => getInstance(element, alertComponent); - - /** - * An `Alert` initialization callback. - * @type {BSN.InitCallback} - */ - const alertInitCallback = (element) => new Alert(element); - - // ALERT CUSTOM EVENTS - // =================== - const closeAlertEvent = OriginalEvent(`close.bs.${alertString}`); - const closedAlertEvent = OriginalEvent(`closed.bs.${alertString}`); - - // ALERT EVENT HANDLER - // =================== - /** - * Alert `transitionend` callback. - * @param {Alert} self target Alert instance - */ - function alertTransitionEnd(self) { - const { element } = self; - toggleAlertHandler(self); - - dispatchEvent(element, closedAlertEvent); - - self.dispose(); - element.remove(); - } - - // ALERT PRIVATE METHOD - // ==================== - /** - * Toggle on / off the `click` event listener. - * @param {Alert} self the target alert instance - * @param {boolean=} add when `true`, event listener is added - */ - function toggleAlertHandler(self, add) { - const action = add ? addListener : removeListener; - const { dismiss } = self; - /* istanbul ignore else */ - if (dismiss) action(dismiss, mouseclickEvent, self.close); - } - - // ALERT DEFINITION - // ================ - /** Creates a new Alert instance. */ - class Alert extends BaseComponent { - /** @param {HTMLElement | string} target element or selector */ - constructor(target) { - super(target); - // bind - const self = this; - - // initialization element - const { element } = self; - - // the dismiss button - /** @static @type {HTMLElement?} */ - self.dismiss = querySelector(alertDismissSelector, element); - - // add event listener - toggleAlertHandler(self, true); - } - - /* eslint-disable */ - /** - * Returns component name string. - */ - get name() { return alertComponent; } - /* eslint-enable */ - - // ALERT PUBLIC METHODS - // ==================== - /** - * Public method that hides the `.alert` element from the user, - * disposes the instance once animation is complete, then - * removes the element from the DOM. - * - * @param {Event=} e most likely the `click` event - * @this {Alert} the `Alert` instance or `EventTarget` - */ - close(e) { - const self = e ? getAlertInstance(closest(this, alertSelector)) : this; - const { element } = self; - - /* istanbul ignore else */ - if (element && hasClass(element, showClass)) { - dispatchEvent(element, closeAlertEvent); - if (closeAlertEvent.defaultPrevented) return; - - removeClass(element, showClass); - - if (hasClass(element, fadeClass)) { - emulateTransitionEnd(element, () => alertTransitionEnd(self)); - } else alertTransitionEnd(self); - } - } - - /** Remove the component from target element. */ - dispose() { - toggleAlertHandler(this); - super.dispose(); - } - } - - ObjectAssign(Alert, { - selector: alertSelector, - init: alertInitCallback, - getInstance: getAlertInstance, - }); - - /** - * A global namespace for aria-pressed. - * @type {string} - */ - const ariaPressed = 'aria-pressed'; - - /** - * Shortcut for `HTMLElement.setAttribute()` method. - * @param {HTMLElement} element target element - * @param {string} attribute attribute name - * @param {string} value attribute value - * @returns {void} - */ - const setAttribute = (element, attribute, value) => element.setAttribute(attribute, value); - - /** - * Add class to `HTMLElement.classList`. - * - * @param {HTMLElement} element target - * @param {string} classNAME to add - * @returns {void} - */ - function addClass(element, classNAME) { - element.classList.add(classNAME); - } - - /** - * Global namespace for most components active class. - */ - const activeClass = 'active'; - - /** - * Global namespace for most components `toggle` option. - */ - const dataBsToggle = 'data-bs-toggle'; - - /** @type {string} */ - const buttonString = 'button'; - - /** @type {string} */ - const buttonComponent = 'Button'; - - /* Native JavaScript for Bootstrap 5 | Button - ---------------------------------------------*/ - - // BUTTON PRIVATE GC - // ================= - const buttonSelector = `[${dataBsToggle}="${buttonString}"]`; - - /** - * Static method which returns an existing `Button` instance associated - * to a target `Element`. - * - * @type {BSN.GetInstance