summaryrefslogtreecommitdiffstats
path: root/js
diff options
context:
space:
mode:
Diffstat (limited to 'js')
-rw-r--r--js/index.esm.js19
-rw-r--r--js/index.umd.js34
-rw-r--r--js/src/alert.js87
-rw-r--r--js/src/base-component.js85
-rw-r--r--js/src/button.js72
-rw-r--r--js/src/carousel.js475
-rw-r--r--js/src/collapse.js302
-rw-r--r--js/src/dom/data.js55
-rw-r--r--js/src/dom/event-handler.js320
-rw-r--r--js/src/dom/manipulator.js71
-rw-r--r--js/src/dom/selector-engine.js83
-rw-r--r--js/src/dropdown.js454
-rw-r--r--js/src/modal.js377
-rw-r--r--js/src/offcanvas.js283
-rw-r--r--js/src/popover.js97
-rw-r--r--js/src/scrollspy.js294
-rw-r--r--js/src/tab.js305
-rw-r--r--js/src/toast.js225
-rw-r--r--js/src/tooltip.js633
-rw-r--r--js/src/util/backdrop.js149
-rw-r--r--js/src/util/component-functions.js34
-rw-r--r--js/src/util/config.js66
-rw-r--r--js/src/util/focustrap.js115
-rw-r--r--js/src/util/index.js336
-rw-r--r--js/src/util/sanitizer.js118
-rw-r--r--js/src/util/scrollbar.js114
-rw-r--r--js/src/util/swipe.js146
-rw-r--r--js/src/util/template-factory.js160
-rw-r--r--js/tests/README.md73
-rw-r--r--js/tests/browsers.js79
-rw-r--r--js/tests/helpers/fixture.js47
-rw-r--r--js/tests/integration/bundle-modularity.js7
-rw-r--r--js/tests/integration/bundle.js6
-rw-r--r--js/tests/integration/index.html67
-rw-r--r--js/tests/integration/rollup.bundle-modularity.js17
-rw-r--r--js/tests/integration/rollup.bundle.js24
-rw-r--r--js/tests/karma.conf.js171
-rw-r--r--js/tests/unit/.eslintrc.json13
-rw-r--r--js/tests/unit/alert.spec.js259
-rw-r--r--js/tests/unit/base-component.spec.js168
-rw-r--r--js/tests/unit/button.spec.js183
-rw-r--r--js/tests/unit/carousel.spec.js1570
-rw-r--r--js/tests/unit/collapse.spec.js1062
-rw-r--r--js/tests/unit/dom/data.spec.js106
-rw-r--r--js/tests/unit/dom/event-handler.spec.js480
-rw-r--r--js/tests/unit/dom/manipulator.spec.js135
-rw-r--r--js/tests/unit/dom/selector-engine.spec.js236
-rw-r--r--js/tests/unit/dropdown.spec.js2430
-rw-r--r--js/tests/unit/jquery.spec.js60
-rw-r--r--js/tests/unit/modal.spec.js1298
-rw-r--r--js/tests/unit/offcanvas.spec.js912
-rw-r--r--js/tests/unit/popover.spec.js413
-rw-r--r--js/tests/unit/scrollspy.spec.js946
-rw-r--r--js/tests/unit/tab.spec.js1101
-rw-r--r--js/tests/unit/toast.spec.js670
-rw-r--r--js/tests/unit/tooltip.spec.js1551
-rw-r--r--js/tests/unit/util/backdrop.spec.js321
-rw-r--r--js/tests/unit/util/component-functions.spec.js108
-rw-r--r--js/tests/unit/util/config.spec.js166
-rw-r--r--js/tests/unit/util/focustrap.spec.js218
-rw-r--r--js/tests/unit/util/index.spec.js814
-rw-r--r--js/tests/unit/util/sanitizer.spec.js105
-rw-r--r--js/tests/unit/util/scrollbar.spec.js363
-rw-r--r--js/tests/unit/util/swipe.spec.js291
-rw-r--r--js/tests/unit/util/template-factory.spec.js306
-rw-r--r--js/tests/visual/.eslintrc.json19
-rw-r--r--js/tests/visual/alert.html48
-rw-r--r--js/tests/visual/button.html49
-rw-r--r--js/tests/visual/carousel.html65
-rw-r--r--js/tests/visual/collapse.html76
-rw-r--r--js/tests/visual/dropdown.html205
-rw-r--r--js/tests/visual/modal.html275
-rw-r--r--js/tests/visual/popover.html41
-rw-r--r--js/tests/visual/scrollspy.html91
-rw-r--r--js/tests/visual/tab.html223
-rw-r--r--js/tests/visual/toast.html70
-rw-r--r--js/tests/visual/tooltip.html138
77 files changed, 23585 insertions, 0 deletions
diff --git a/js/index.esm.js b/js/index.esm.js
new file mode 100644
index 0000000..b837649
--- /dev/null
+++ b/js/index.esm.js
@@ -0,0 +1,19 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): index.esm.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+export { default as Alert } from './src/alert'
+export { default as Button } from './src/button'
+export { default as Carousel } from './src/carousel'
+export { default as Collapse } from './src/collapse'
+export { default as Dropdown } from './src/dropdown'
+export { default as Modal } from './src/modal'
+export { default as Offcanvas } from './src/offcanvas'
+export { default as Popover } from './src/popover'
+export { default as ScrollSpy } from './src/scrollspy'
+export { default as Tab } from './src/tab'
+export { default as Toast } from './src/toast'
+export { default as Tooltip } from './src/tooltip'
diff --git a/js/index.umd.js b/js/index.umd.js
new file mode 100644
index 0000000..5abe8db
--- /dev/null
+++ b/js/index.umd.js
@@ -0,0 +1,34 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): index.umd.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import Alert from './src/alert'
+import Button from './src/button'
+import Carousel from './src/carousel'
+import Collapse from './src/collapse'
+import Dropdown from './src/dropdown'
+import Modal from './src/modal'
+import Offcanvas from './src/offcanvas'
+import Popover from './src/popover'
+import ScrollSpy from './src/scrollspy'
+import Tab from './src/tab'
+import Toast from './src/toast'
+import Tooltip from './src/tooltip'
+
+export default {
+ Alert,
+ Button,
+ Carousel,
+ Collapse,
+ Dropdown,
+ Modal,
+ Offcanvas,
+ Popover,
+ ScrollSpy,
+ Tab,
+ Toast,
+ Tooltip
+}
diff --git a/js/src/alert.js b/js/src/alert.js
new file mode 100644
index 0000000..59de828
--- /dev/null
+++ b/js/src/alert.js
@@ -0,0 +1,87 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): alert.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { defineJQueryPlugin } from './util/index'
+import EventHandler from './dom/event-handler'
+import BaseComponent from './base-component'
+import { enableDismissTrigger } from './util/component-functions'
+
+/**
+ * Constants
+ */
+
+const NAME = 'alert'
+const DATA_KEY = 'bs.alert'
+const EVENT_KEY = `.${DATA_KEY}`
+
+const EVENT_CLOSE = `close${EVENT_KEY}`
+const EVENT_CLOSED = `closed${EVENT_KEY}`
+const CLASS_NAME_FADE = 'fade'
+const CLASS_NAME_SHOW = 'show'
+
+/**
+ * Class definition
+ */
+
+class Alert extends BaseComponent {
+ // Getters
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ close() {
+ const closeEvent = EventHandler.trigger(this._element, EVENT_CLOSE)
+
+ if (closeEvent.defaultPrevented) {
+ return
+ }
+
+ this._element.classList.remove(CLASS_NAME_SHOW)
+
+ const isAnimated = this._element.classList.contains(CLASS_NAME_FADE)
+ this._queueCallback(() => this._destroyElement(), this._element, isAnimated)
+ }
+
+ // Private
+ _destroyElement() {
+ this._element.remove()
+ EventHandler.trigger(this._element, EVENT_CLOSED)
+ this.dispose()
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ return this.each(function () {
+ const data = Alert.getOrCreateInstance(this)
+
+ if (typeof config !== 'string') {
+ return
+ }
+
+ if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config](this)
+ })
+ }
+}
+
+/**
+ * Data API implementation
+ */
+
+enableDismissTrigger(Alert, 'close')
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Alert)
+
+export default Alert
diff --git a/js/src/base-component.js b/js/src/base-component.js
new file mode 100644
index 0000000..0c1a259
--- /dev/null
+++ b/js/src/base-component.js
@@ -0,0 +1,85 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): base-component.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import Data from './dom/data'
+import { executeAfterTransition, getElement } from './util/index'
+import EventHandler from './dom/event-handler'
+import Config from './util/config'
+
+/**
+ * Constants
+ */
+
+const VERSION = '5.2.3'
+
+/**
+ * Class definition
+ */
+
+class BaseComponent extends Config {
+ constructor(element, config) {
+ super()
+
+ element = getElement(element)
+ if (!element) {
+ return
+ }
+
+ this._element = element
+ this._config = this._getConfig(config)
+
+ Data.set(this._element, this.constructor.DATA_KEY, this)
+ }
+
+ // Public
+ dispose() {
+ Data.remove(this._element, this.constructor.DATA_KEY)
+ EventHandler.off(this._element, this.constructor.EVENT_KEY)
+
+ for (const propertyName of Object.getOwnPropertyNames(this)) {
+ this[propertyName] = null
+ }
+ }
+
+ _queueCallback(callback, element, isAnimated = true) {
+ executeAfterTransition(callback, element, isAnimated)
+ }
+
+ _getConfig(config) {
+ config = this._mergeConfigObj(config, this._element)
+ config = this._configAfterMerge(config)
+ this._typeCheckConfig(config)
+ return config
+ }
+
+ // Static
+ static getInstance(element) {
+ return Data.get(getElement(element), this.DATA_KEY)
+ }
+
+ static getOrCreateInstance(element, config = {}) {
+ return this.getInstance(element) || new this(element, typeof config === 'object' ? config : null)
+ }
+
+ static get VERSION() {
+ return VERSION
+ }
+
+ static get DATA_KEY() {
+ return `bs.${this.NAME}`
+ }
+
+ static get EVENT_KEY() {
+ return `.${this.DATA_KEY}`
+ }
+
+ static eventName(name) {
+ return `${name}${this.EVENT_KEY}`
+ }
+}
+
+export default BaseComponent
diff --git a/js/src/button.js b/js/src/button.js
new file mode 100644
index 0000000..03e7604
--- /dev/null
+++ b/js/src/button.js
@@ -0,0 +1,72 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): button.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { defineJQueryPlugin } from './util/index'
+import EventHandler from './dom/event-handler'
+import BaseComponent from './base-component'
+
+/**
+ * Constants
+ */
+
+const NAME = 'button'
+const DATA_KEY = 'bs.button'
+const EVENT_KEY = `.${DATA_KEY}`
+const DATA_API_KEY = '.data-api'
+
+const CLASS_NAME_ACTIVE = 'active'
+const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="button"]'
+const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
+
+/**
+ * Class definition
+ */
+
+class Button extends BaseComponent {
+ // Getters
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ toggle() {
+ // Toggle class and sync the `aria-pressed` attribute with the return value of the `.toggle()` method
+ this._element.setAttribute('aria-pressed', this._element.classList.toggle(CLASS_NAME_ACTIVE))
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ return this.each(function () {
+ const data = Button.getOrCreateInstance(this)
+
+ if (config === 'toggle') {
+ data[config]()
+ }
+ })
+ }
+}
+
+/**
+ * Data API implementation
+ */
+
+EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, event => {
+ event.preventDefault()
+
+ const button = event.target.closest(SELECTOR_DATA_TOGGLE)
+ const data = Button.getOrCreateInstance(button)
+
+ data.toggle()
+})
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Button)
+
+export default Button
diff --git a/js/src/carousel.js b/js/src/carousel.js
new file mode 100644
index 0000000..24bbe39
--- /dev/null
+++ b/js/src/carousel.js
@@ -0,0 +1,475 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): carousel.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import {
+ defineJQueryPlugin,
+ getElementFromSelector,
+ getNextActiveElement,
+ isRTL,
+ isVisible,
+ reflow,
+ triggerTransitionEnd
+} from './util/index'
+import EventHandler from './dom/event-handler'
+import Manipulator from './dom/manipulator'
+import SelectorEngine from './dom/selector-engine'
+import Swipe from './util/swipe'
+import BaseComponent from './base-component'
+
+/**
+ * Constants
+ */
+
+const NAME = 'carousel'
+const DATA_KEY = 'bs.carousel'
+const EVENT_KEY = `.${DATA_KEY}`
+const DATA_API_KEY = '.data-api'
+
+const ARROW_LEFT_KEY = 'ArrowLeft'
+const ARROW_RIGHT_KEY = 'ArrowRight'
+const TOUCHEVENT_COMPAT_WAIT = 500 // Time for mouse compat events to fire after touch
+
+const ORDER_NEXT = 'next'
+const ORDER_PREV = 'prev'
+const DIRECTION_LEFT = 'left'
+const DIRECTION_RIGHT = 'right'
+
+const EVENT_SLIDE = `slide${EVENT_KEY}`
+const EVENT_SLID = `slid${EVENT_KEY}`
+const EVENT_KEYDOWN = `keydown${EVENT_KEY}`
+const EVENT_MOUSEENTER = `mouseenter${EVENT_KEY}`
+const EVENT_MOUSELEAVE = `mouseleave${EVENT_KEY}`
+const EVENT_DRAG_START = `dragstart${EVENT_KEY}`
+const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`
+const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
+
+const CLASS_NAME_CAROUSEL = 'carousel'
+const CLASS_NAME_ACTIVE = 'active'
+const CLASS_NAME_SLIDE = 'slide'
+const CLASS_NAME_END = 'carousel-item-end'
+const CLASS_NAME_START = 'carousel-item-start'
+const CLASS_NAME_NEXT = 'carousel-item-next'
+const CLASS_NAME_PREV = 'carousel-item-prev'
+
+const SELECTOR_ACTIVE = '.active'
+const SELECTOR_ITEM = '.carousel-item'
+const SELECTOR_ACTIVE_ITEM = SELECTOR_ACTIVE + SELECTOR_ITEM
+const SELECTOR_ITEM_IMG = '.carousel-item img'
+const SELECTOR_INDICATORS = '.carousel-indicators'
+const SELECTOR_DATA_SLIDE = '[data-bs-slide], [data-bs-slide-to]'
+const SELECTOR_DATA_RIDE = '[data-bs-ride="carousel"]'
+
+const KEY_TO_DIRECTION = {
+ [ARROW_LEFT_KEY]: DIRECTION_RIGHT,
+ [ARROW_RIGHT_KEY]: DIRECTION_LEFT
+}
+
+const Default = {
+ interval: 5000,
+ keyboard: true,
+ pause: 'hover',
+ ride: false,
+ touch: true,
+ wrap: true
+}
+
+const DefaultType = {
+ interval: '(number|boolean)', // TODO:v6 remove boolean support
+ keyboard: 'boolean',
+ pause: '(string|boolean)',
+ ride: '(boolean|string)',
+ touch: 'boolean',
+ wrap: 'boolean'
+}
+
+/**
+ * Class definition
+ */
+
+class Carousel extends BaseComponent {
+ constructor(element, config) {
+ super(element, config)
+
+ this._interval = null
+ this._activeElement = null
+ this._isSliding = false
+ this.touchTimeout = null
+ this._swipeHelper = null
+
+ this._indicatorsElement = SelectorEngine.findOne(SELECTOR_INDICATORS, this._element)
+ this._addEventListeners()
+
+ if (this._config.ride === CLASS_NAME_CAROUSEL) {
+ this.cycle()
+ }
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ next() {
+ this._slide(ORDER_NEXT)
+ }
+
+ nextWhenVisible() {
+ // FIXME TODO use `document.visibilityState`
+ // Don't call next when the page isn't visible
+ // or the carousel or its parent isn't visible
+ if (!document.hidden && isVisible(this._element)) {
+ this.next()
+ }
+ }
+
+ prev() {
+ this._slide(ORDER_PREV)
+ }
+
+ pause() {
+ if (this._isSliding) {
+ triggerTransitionEnd(this._element)
+ }
+
+ this._clearInterval()
+ }
+
+ cycle() {
+ this._clearInterval()
+ this._updateInterval()
+
+ this._interval = setInterval(() => this.nextWhenVisible(), this._config.interval)
+ }
+
+ _maybeEnableCycle() {
+ if (!this._config.ride) {
+ return
+ }
+
+ if (this._isSliding) {
+ EventHandler.one(this._element, EVENT_SLID, () => this.cycle())
+ return
+ }
+
+ this.cycle()
+ }
+
+ to(index) {
+ const items = this._getItems()
+ if (index > items.length - 1 || index < 0) {
+ return
+ }
+
+ if (this._isSliding) {
+ EventHandler.one(this._element, EVENT_SLID, () => this.to(index))
+ return
+ }
+
+ const activeIndex = this._getItemIndex(this._getActive())
+ if (activeIndex === index) {
+ return
+ }
+
+ const order = index > activeIndex ? ORDER_NEXT : ORDER_PREV
+
+ this._slide(order, items[index])
+ }
+
+ dispose() {
+ if (this._swipeHelper) {
+ this._swipeHelper.dispose()
+ }
+
+ super.dispose()
+ }
+
+ // Private
+ _configAfterMerge(config) {
+ config.defaultInterval = config.interval
+ return config
+ }
+
+ _addEventListeners() {
+ if (this._config.keyboard) {
+ EventHandler.on(this._element, EVENT_KEYDOWN, event => this._keydown(event))
+ }
+
+ if (this._config.pause === 'hover') {
+ EventHandler.on(this._element, EVENT_MOUSEENTER, () => this.pause())
+ EventHandler.on(this._element, EVENT_MOUSELEAVE, () => this._maybeEnableCycle())
+ }
+
+ if (this._config.touch && Swipe.isSupported()) {
+ this._addTouchEventListeners()
+ }
+ }
+
+ _addTouchEventListeners() {
+ for (const img of SelectorEngine.find(SELECTOR_ITEM_IMG, this._element)) {
+ EventHandler.on(img, EVENT_DRAG_START, event => event.preventDefault())
+ }
+
+ const endCallBack = () => {
+ if (this._config.pause !== 'hover') {
+ return
+ }
+
+ // If it's a touch-enabled device, mouseenter/leave are fired as
+ // part of the mouse compatibility events on first tap - the carousel
+ // would stop cycling until user tapped out of it;
+ // here, we listen for touchend, explicitly pause the carousel
+ // (as if it's the second time we tap on it, mouseenter compat event
+ // is NOT fired) and after a timeout (to allow for mouse compatibility
+ // events to fire) we explicitly restart cycling
+
+ this.pause()
+ if (this.touchTimeout) {
+ clearTimeout(this.touchTimeout)
+ }
+
+ this.touchTimeout = setTimeout(() => this._maybeEnableCycle(), TOUCHEVENT_COMPAT_WAIT + this._config.interval)
+ }
+
+ const swipeConfig = {
+ leftCallback: () => this._slide(this._directionToOrder(DIRECTION_LEFT)),
+ rightCallback: () => this._slide(this._directionToOrder(DIRECTION_RIGHT)),
+ endCallback: endCallBack
+ }
+
+ this._swipeHelper = new Swipe(this._element, swipeConfig)
+ }
+
+ _keydown(event) {
+ if (/input|textarea/i.test(event.target.tagName)) {
+ return
+ }
+
+ const direction = KEY_TO_DIRECTION[event.key]
+ if (direction) {
+ event.preventDefault()
+ this._slide(this._directionToOrder(direction))
+ }
+ }
+
+ _getItemIndex(element) {
+ return this._getItems().indexOf(element)
+ }
+
+ _setActiveIndicatorElement(index) {
+ if (!this._indicatorsElement) {
+ return
+ }
+
+ const activeIndicator = SelectorEngine.findOne(SELECTOR_ACTIVE, this._indicatorsElement)
+
+ activeIndicator.classList.remove(CLASS_NAME_ACTIVE)
+ activeIndicator.removeAttribute('aria-current')
+
+ const newActiveIndicator = SelectorEngine.findOne(`[data-bs-slide-to="${index}"]`, this._indicatorsElement)
+
+ if (newActiveIndicator) {
+ newActiveIndicator.classList.add(CLASS_NAME_ACTIVE)
+ newActiveIndicator.setAttribute('aria-current', 'true')
+ }
+ }
+
+ _updateInterval() {
+ const element = this._activeElement || this._getActive()
+
+ if (!element) {
+ return
+ }
+
+ const elementInterval = Number.parseInt(element.getAttribute('data-bs-interval'), 10)
+
+ this._config.interval = elementInterval || this._config.defaultInterval
+ }
+
+ _slide(order, element = null) {
+ if (this._isSliding) {
+ return
+ }
+
+ const activeElement = this._getActive()
+ const isNext = order === ORDER_NEXT
+ const nextElement = element || getNextActiveElement(this._getItems(), activeElement, isNext, this._config.wrap)
+
+ if (nextElement === activeElement) {
+ return
+ }
+
+ const nextElementIndex = this._getItemIndex(nextElement)
+
+ const triggerEvent = eventName => {
+ return EventHandler.trigger(this._element, eventName, {
+ relatedTarget: nextElement,
+ direction: this._orderToDirection(order),
+ from: this._getItemIndex(activeElement),
+ to: nextElementIndex
+ })
+ }
+
+ const slideEvent = triggerEvent(EVENT_SLIDE)
+
+ if (slideEvent.defaultPrevented) {
+ return
+ }
+
+ if (!activeElement || !nextElement) {
+ // Some weirdness is happening, so we bail
+ // todo: change tests that use empty divs to avoid this check
+ return
+ }
+
+ const isCycling = Boolean(this._interval)
+ this.pause()
+
+ this._isSliding = true
+
+ this._setActiveIndicatorElement(nextElementIndex)
+ this._activeElement = nextElement
+
+ const directionalClassName = isNext ? CLASS_NAME_START : CLASS_NAME_END
+ const orderClassName = isNext ? CLASS_NAME_NEXT : CLASS_NAME_PREV
+
+ nextElement.classList.add(orderClassName)
+
+ reflow(nextElement)
+
+ activeElement.classList.add(directionalClassName)
+ nextElement.classList.add(directionalClassName)
+
+ const completeCallBack = () => {
+ nextElement.classList.remove(directionalClassName, orderClassName)
+ nextElement.classList.add(CLASS_NAME_ACTIVE)
+
+ activeElement.classList.remove(CLASS_NAME_ACTIVE, orderClassName, directionalClassName)
+
+ this._isSliding = false
+
+ triggerEvent(EVENT_SLID)
+ }
+
+ this._queueCallback(completeCallBack, activeElement, this._isAnimated())
+
+ if (isCycling) {
+ this.cycle()
+ }
+ }
+
+ _isAnimated() {
+ return this._element.classList.contains(CLASS_NAME_SLIDE)
+ }
+
+ _getActive() {
+ return SelectorEngine.findOne(SELECTOR_ACTIVE_ITEM, this._element)
+ }
+
+ _getItems() {
+ return SelectorEngine.find(SELECTOR_ITEM, this._element)
+ }
+
+ _clearInterval() {
+ if (this._interval) {
+ clearInterval(this._interval)
+ this._interval = null
+ }
+ }
+
+ _directionToOrder(direction) {
+ if (isRTL()) {
+ return direction === DIRECTION_LEFT ? ORDER_PREV : ORDER_NEXT
+ }
+
+ return direction === DIRECTION_LEFT ? ORDER_NEXT : ORDER_PREV
+ }
+
+ _orderToDirection(order) {
+ if (isRTL()) {
+ return order === ORDER_PREV ? DIRECTION_LEFT : DIRECTION_RIGHT
+ }
+
+ return order === ORDER_PREV ? DIRECTION_RIGHT : DIRECTION_LEFT
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ return this.each(function () {
+ const data = Carousel.getOrCreateInstance(this, config)
+
+ if (typeof config === 'number') {
+ data.to(config)
+ return
+ }
+
+ if (typeof config === 'string') {
+ if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config]()
+ }
+ })
+ }
+}
+
+/**
+ * Data API implementation
+ */
+
+EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_SLIDE, function (event) {
+ const target = getElementFromSelector(this)
+
+ if (!target || !target.classList.contains(CLASS_NAME_CAROUSEL)) {
+ return
+ }
+
+ event.preventDefault()
+
+ const carousel = Carousel.getOrCreateInstance(target)
+ const slideIndex = this.getAttribute('data-bs-slide-to')
+
+ if (slideIndex) {
+ carousel.to(slideIndex)
+ carousel._maybeEnableCycle()
+ return
+ }
+
+ if (Manipulator.getDataAttribute(this, 'slide') === 'next') {
+ carousel.next()
+ carousel._maybeEnableCycle()
+ return
+ }
+
+ carousel.prev()
+ carousel._maybeEnableCycle()
+})
+
+EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
+ const carousels = SelectorEngine.find(SELECTOR_DATA_RIDE)
+
+ for (const carousel of carousels) {
+ Carousel.getOrCreateInstance(carousel)
+ }
+})
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Carousel)
+
+export default Carousel
diff --git a/js/src/collapse.js b/js/src/collapse.js
new file mode 100644
index 0000000..204d180
--- /dev/null
+++ b/js/src/collapse.js
@@ -0,0 +1,302 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): collapse.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import {
+ defineJQueryPlugin,
+ getElement,
+ getElementFromSelector,
+ getSelectorFromElement,
+ reflow
+} from './util/index'
+import EventHandler from './dom/event-handler'
+import SelectorEngine from './dom/selector-engine'
+import BaseComponent from './base-component'
+
+/**
+ * Constants
+ */
+
+const NAME = 'collapse'
+const DATA_KEY = 'bs.collapse'
+const EVENT_KEY = `.${DATA_KEY}`
+const DATA_API_KEY = '.data-api'
+
+const EVENT_SHOW = `show${EVENT_KEY}`
+const EVENT_SHOWN = `shown${EVENT_KEY}`
+const EVENT_HIDE = `hide${EVENT_KEY}`
+const EVENT_HIDDEN = `hidden${EVENT_KEY}`
+const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
+
+const CLASS_NAME_SHOW = 'show'
+const CLASS_NAME_COLLAPSE = 'collapse'
+const CLASS_NAME_COLLAPSING = 'collapsing'
+const CLASS_NAME_COLLAPSED = 'collapsed'
+const CLASS_NAME_DEEPER_CHILDREN = `:scope .${CLASS_NAME_COLLAPSE} .${CLASS_NAME_COLLAPSE}`
+const CLASS_NAME_HORIZONTAL = 'collapse-horizontal'
+
+const WIDTH = 'width'
+const HEIGHT = 'height'
+
+const SELECTOR_ACTIVES = '.collapse.show, .collapse.collapsing'
+const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="collapse"]'
+
+const Default = {
+ parent: null,
+ toggle: true
+}
+
+const DefaultType = {
+ parent: '(null|element)',
+ toggle: 'boolean'
+}
+
+/**
+ * Class definition
+ */
+
+class Collapse extends BaseComponent {
+ constructor(element, config) {
+ super(element, config)
+
+ this._isTransitioning = false
+ this._triggerArray = []
+
+ const toggleList = SelectorEngine.find(SELECTOR_DATA_TOGGLE)
+
+ for (const elem of toggleList) {
+ const selector = getSelectorFromElement(elem)
+ const filterElement = SelectorEngine.find(selector)
+ .filter(foundElement => foundElement === this._element)
+
+ if (selector !== null && filterElement.length) {
+ this._triggerArray.push(elem)
+ }
+ }
+
+ this._initializeChildren()
+
+ if (!this._config.parent) {
+ this._addAriaAndCollapsedClass(this._triggerArray, this._isShown())
+ }
+
+ if (this._config.toggle) {
+ this.toggle()
+ }
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ toggle() {
+ if (this._isShown()) {
+ this.hide()
+ } else {
+ this.show()
+ }
+ }
+
+ show() {
+ if (this._isTransitioning || this._isShown()) {
+ return
+ }
+
+ let activeChildren = []
+
+ // find active children
+ if (this._config.parent) {
+ activeChildren = this._getFirstLevelChildren(SELECTOR_ACTIVES)
+ .filter(element => element !== this._element)
+ .map(element => Collapse.getOrCreateInstance(element, { toggle: false }))
+ }
+
+ if (activeChildren.length && activeChildren[0]._isTransitioning) {
+ return
+ }
+
+ const startEvent = EventHandler.trigger(this._element, EVENT_SHOW)
+ if (startEvent.defaultPrevented) {
+ return
+ }
+
+ for (const activeInstance of activeChildren) {
+ activeInstance.hide()
+ }
+
+ const dimension = this._getDimension()
+
+ this._element.classList.remove(CLASS_NAME_COLLAPSE)
+ this._element.classList.add(CLASS_NAME_COLLAPSING)
+
+ this._element.style[dimension] = 0
+
+ this._addAriaAndCollapsedClass(this._triggerArray, true)
+ this._isTransitioning = true
+
+ const complete = () => {
+ this._isTransitioning = false
+
+ this._element.classList.remove(CLASS_NAME_COLLAPSING)
+ this._element.classList.add(CLASS_NAME_COLLAPSE, CLASS_NAME_SHOW)
+
+ this._element.style[dimension] = ''
+
+ EventHandler.trigger(this._element, EVENT_SHOWN)
+ }
+
+ const capitalizedDimension = dimension[0].toUpperCase() + dimension.slice(1)
+ const scrollSize = `scroll${capitalizedDimension}`
+
+ this._queueCallback(complete, this._element, true)
+ this._element.style[dimension] = `${this._element[scrollSize]}px`
+ }
+
+ hide() {
+ if (this._isTransitioning || !this._isShown()) {
+ return
+ }
+
+ const startEvent = EventHandler.trigger(this._element, EVENT_HIDE)
+ if (startEvent.defaultPrevented) {
+ return
+ }
+
+ const dimension = this._getDimension()
+
+ this._element.style[dimension] = `${this._element.getBoundingClientRect()[dimension]}px`
+
+ reflow(this._element)
+
+ this._element.classList.add(CLASS_NAME_COLLAPSING)
+ this._element.classList.remove(CLASS_NAME_COLLAPSE, CLASS_NAME_SHOW)
+
+ for (const trigger of this._triggerArray) {
+ const element = getElementFromSelector(trigger)
+
+ if (element && !this._isShown(element)) {
+ this._addAriaAndCollapsedClass([trigger], false)
+ }
+ }
+
+ this._isTransitioning = true
+
+ const complete = () => {
+ this._isTransitioning = false
+ this._element.classList.remove(CLASS_NAME_COLLAPSING)
+ this._element.classList.add(CLASS_NAME_COLLAPSE)
+ EventHandler.trigger(this._element, EVENT_HIDDEN)
+ }
+
+ this._element.style[dimension] = ''
+
+ this._queueCallback(complete, this._element, true)
+ }
+
+ _isShown(element = this._element) {
+ return element.classList.contains(CLASS_NAME_SHOW)
+ }
+
+ // Private
+ _configAfterMerge(config) {
+ config.toggle = Boolean(config.toggle) // Coerce string values
+ config.parent = getElement(config.parent)
+ return config
+ }
+
+ _getDimension() {
+ return this._element.classList.contains(CLASS_NAME_HORIZONTAL) ? WIDTH : HEIGHT
+ }
+
+ _initializeChildren() {
+ if (!this._config.parent) {
+ return
+ }
+
+ const children = this._getFirstLevelChildren(SELECTOR_DATA_TOGGLE)
+
+ for (const element of children) {
+ const selected = getElementFromSelector(element)
+
+ if (selected) {
+ this._addAriaAndCollapsedClass([element], this._isShown(selected))
+ }
+ }
+ }
+
+ _getFirstLevelChildren(selector) {
+ const children = SelectorEngine.find(CLASS_NAME_DEEPER_CHILDREN, this._config.parent)
+ // remove children if greater depth
+ return SelectorEngine.find(selector, this._config.parent).filter(element => !children.includes(element))
+ }
+
+ _addAriaAndCollapsedClass(triggerArray, isOpen) {
+ if (!triggerArray.length) {
+ return
+ }
+
+ for (const element of triggerArray) {
+ element.classList.toggle(CLASS_NAME_COLLAPSED, !isOpen)
+ element.setAttribute('aria-expanded', isOpen)
+ }
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ const _config = {}
+ if (typeof config === 'string' && /show|hide/.test(config)) {
+ _config.toggle = false
+ }
+
+ return this.each(function () {
+ const data = Collapse.getOrCreateInstance(this, _config)
+
+ if (typeof config === 'string') {
+ if (typeof data[config] === 'undefined') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config]()
+ }
+ })
+ }
+}
+
+/**
+ * Data API implementation
+ */
+
+EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
+ // preventDefault only for <a> elements (which change the URL) not inside the collapsible element
+ if (event.target.tagName === 'A' || (event.delegateTarget && event.delegateTarget.tagName === 'A')) {
+ event.preventDefault()
+ }
+
+ const selector = getSelectorFromElement(this)
+ const selectorElements = SelectorEngine.find(selector)
+
+ for (const element of selectorElements) {
+ Collapse.getOrCreateInstance(element, { toggle: false }).toggle()
+ }
+})
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Collapse)
+
+export default Collapse
diff --git a/js/src/dom/data.js b/js/src/dom/data.js
new file mode 100644
index 0000000..2c6a46e
--- /dev/null
+++ b/js/src/dom/data.js
@@ -0,0 +1,55 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): dom/data.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+/**
+ * Constants
+ */
+
+const elementMap = new Map()
+
+export default {
+ set(element, key, instance) {
+ if (!elementMap.has(element)) {
+ elementMap.set(element, new Map())
+ }
+
+ const instanceMap = elementMap.get(element)
+
+ // make it clear we only want one instance per element
+ // can be removed later when multiple key/instances are fine to be used
+ if (!instanceMap.has(key) && instanceMap.size !== 0) {
+ // eslint-disable-next-line no-console
+ console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(instanceMap.keys())[0]}.`)
+ return
+ }
+
+ instanceMap.set(key, instance)
+ },
+
+ get(element, key) {
+ if (elementMap.has(element)) {
+ return elementMap.get(element).get(key) || null
+ }
+
+ return null
+ },
+
+ remove(element, key) {
+ if (!elementMap.has(element)) {
+ return
+ }
+
+ const instanceMap = elementMap.get(element)
+
+ instanceMap.delete(key)
+
+ // free up element references if there are no instances left for an element
+ if (instanceMap.size === 0) {
+ elementMap.delete(element)
+ }
+ }
+}
diff --git a/js/src/dom/event-handler.js b/js/src/dom/event-handler.js
new file mode 100644
index 0000000..9876d77
--- /dev/null
+++ b/js/src/dom/event-handler.js
@@ -0,0 +1,320 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): dom/event-handler.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { getjQuery } from '../util/index'
+
+/**
+ * Constants
+ */
+
+const namespaceRegex = /[^.]*(?=\..*)\.|.*/
+const stripNameRegex = /\..*/
+const stripUidRegex = /::\d+$/
+const eventRegistry = {} // Events storage
+let uidEvent = 1
+const customEvents = {
+ mouseenter: 'mouseover',
+ mouseleave: 'mouseout'
+}
+
+const nativeEvents = new Set([
+ 'click',
+ 'dblclick',
+ 'mouseup',
+ 'mousedown',
+ 'contextmenu',
+ 'mousewheel',
+ 'DOMMouseScroll',
+ 'mouseover',
+ 'mouseout',
+ 'mousemove',
+ 'selectstart',
+ 'selectend',
+ 'keydown',
+ 'keypress',
+ 'keyup',
+ 'orientationchange',
+ 'touchstart',
+ 'touchmove',
+ 'touchend',
+ 'touchcancel',
+ 'pointerdown',
+ 'pointermove',
+ 'pointerup',
+ 'pointerleave',
+ 'pointercancel',
+ 'gesturestart',
+ 'gesturechange',
+ 'gestureend',
+ 'focus',
+ 'blur',
+ 'change',
+ 'reset',
+ 'select',
+ 'submit',
+ 'focusin',
+ 'focusout',
+ 'load',
+ 'unload',
+ 'beforeunload',
+ 'resize',
+ 'move',
+ 'DOMContentLoaded',
+ 'readystatechange',
+ 'error',
+ 'abort',
+ 'scroll'
+])
+
+/**
+ * Private methods
+ */
+
+function makeEventUid(element, uid) {
+ return (uid && `${uid}::${uidEvent++}`) || element.uidEvent || uidEvent++
+}
+
+function getElementEvents(element) {
+ const uid = makeEventUid(element)
+
+ element.uidEvent = uid
+ eventRegistry[uid] = eventRegistry[uid] || {}
+
+ return eventRegistry[uid]
+}
+
+function bootstrapHandler(element, fn) {
+ return function handler(event) {
+ hydrateObj(event, { delegateTarget: element })
+
+ if (handler.oneOff) {
+ EventHandler.off(element, event.type, fn)
+ }
+
+ return fn.apply(element, [event])
+ }
+}
+
+function bootstrapDelegationHandler(element, selector, fn) {
+ return function handler(event) {
+ const domElements = element.querySelectorAll(selector)
+
+ for (let { target } = event; target && target !== this; target = target.parentNode) {
+ for (const domElement of domElements) {
+ if (domElement !== target) {
+ continue
+ }
+
+ hydrateObj(event, { delegateTarget: target })
+
+ if (handler.oneOff) {
+ EventHandler.off(element, event.type, selector, fn)
+ }
+
+ return fn.apply(target, [event])
+ }
+ }
+ }
+}
+
+function findHandler(events, callable, delegationSelector = null) {
+ return Object.values(events)
+ .find(event => event.callable === callable && event.delegationSelector === delegationSelector)
+}
+
+function normalizeParameters(originalTypeEvent, handler, delegationFunction) {
+ const isDelegated = typeof handler === 'string'
+ // todo: tooltip passes `false` instead of selector, so we need to check
+ const callable = isDelegated ? delegationFunction : (handler || delegationFunction)
+ let typeEvent = getTypeEvent(originalTypeEvent)
+
+ if (!nativeEvents.has(typeEvent)) {
+ typeEvent = originalTypeEvent
+ }
+
+ return [isDelegated, callable, typeEvent]
+}
+
+function addHandler(element, originalTypeEvent, handler, delegationFunction, oneOff) {
+ if (typeof originalTypeEvent !== 'string' || !element) {
+ return
+ }
+
+ let [isDelegated, callable, typeEvent] = normalizeParameters(originalTypeEvent, handler, delegationFunction)
+
+ // in case of mouseenter or mouseleave wrap the handler within a function that checks for its DOM position
+ // this prevents the handler from being dispatched the same way as mouseover or mouseout does
+ if (originalTypeEvent in customEvents) {
+ const wrapFunction = fn => {
+ return function (event) {
+ if (!event.relatedTarget || (event.relatedTarget !== event.delegateTarget && !event.delegateTarget.contains(event.relatedTarget))) {
+ return fn.call(this, event)
+ }
+ }
+ }
+
+ callable = wrapFunction(callable)
+ }
+
+ const events = getElementEvents(element)
+ const handlers = events[typeEvent] || (events[typeEvent] = {})
+ const previousFunction = findHandler(handlers, callable, isDelegated ? handler : null)
+
+ if (previousFunction) {
+ previousFunction.oneOff = previousFunction.oneOff && oneOff
+
+ return
+ }
+
+ const uid = makeEventUid(callable, originalTypeEvent.replace(namespaceRegex, ''))
+ const fn = isDelegated ?
+ bootstrapDelegationHandler(element, handler, callable) :
+ bootstrapHandler(element, callable)
+
+ fn.delegationSelector = isDelegated ? handler : null
+ fn.callable = callable
+ fn.oneOff = oneOff
+ fn.uidEvent = uid
+ handlers[uid] = fn
+
+ element.addEventListener(typeEvent, fn, isDelegated)
+}
+
+function removeHandler(element, events, typeEvent, handler, delegationSelector) {
+ const fn = findHandler(events[typeEvent], handler, delegationSelector)
+
+ if (!fn) {
+ return
+ }
+
+ element.removeEventListener(typeEvent, fn, Boolean(delegationSelector))
+ delete events[typeEvent][fn.uidEvent]
+}
+
+function removeNamespacedHandlers(element, events, typeEvent, namespace) {
+ const storeElementEvent = events[typeEvent] || {}
+
+ for (const handlerKey of Object.keys(storeElementEvent)) {
+ if (handlerKey.includes(namespace)) {
+ const event = storeElementEvent[handlerKey]
+ removeHandler(element, events, typeEvent, event.callable, event.delegationSelector)
+ }
+ }
+}
+
+function getTypeEvent(event) {
+ // allow to get the native events from namespaced events ('click.bs.button' --> 'click')
+ event = event.replace(stripNameRegex, '')
+ return customEvents[event] || event
+}
+
+const EventHandler = {
+ on(element, event, handler, delegationFunction) {
+ addHandler(element, event, handler, delegationFunction, false)
+ },
+
+ one(element, event, handler, delegationFunction) {
+ addHandler(element, event, handler, delegationFunction, true)
+ },
+
+ off(element, originalTypeEvent, handler, delegationFunction) {
+ if (typeof originalTypeEvent !== 'string' || !element) {
+ return
+ }
+
+ const [isDelegated, callable, typeEvent] = normalizeParameters(originalTypeEvent, handler, delegationFunction)
+ const inNamespace = typeEvent !== originalTypeEvent
+ const events = getElementEvents(element)
+ const storeElementEvent = events[typeEvent] || {}
+ const isNamespace = originalTypeEvent.startsWith('.')
+
+ if (typeof callable !== 'undefined') {
+ // Simplest case: handler is passed, remove that listener ONLY.
+ if (!Object.keys(storeElementEvent).length) {
+ return
+ }
+
+ removeHandler(element, events, typeEvent, callable, isDelegated ? handler : null)
+ return
+ }
+
+ if (isNamespace) {
+ for (const elementEvent of Object.keys(events)) {
+ removeNamespacedHandlers(element, events, elementEvent, originalTypeEvent.slice(1))
+ }
+ }
+
+ for (const keyHandlers of Object.keys(storeElementEvent)) {
+ const handlerKey = keyHandlers.replace(stripUidRegex, '')
+
+ if (!inNamespace || originalTypeEvent.includes(handlerKey)) {
+ const event = storeElementEvent[keyHandlers]
+ removeHandler(element, events, typeEvent, event.callable, event.delegationSelector)
+ }
+ }
+ },
+
+ trigger(element, event, args) {
+ if (typeof event !== 'string' || !element) {
+ return null
+ }
+
+ const $ = getjQuery()
+ const typeEvent = getTypeEvent(event)
+ const inNamespace = event !== typeEvent
+
+ let jQueryEvent = null
+ let bubbles = true
+ let nativeDispatch = true
+ let defaultPrevented = false
+
+ if (inNamespace && $) {
+ jQueryEvent = $.Event(event, args)
+
+ $(element).trigger(jQueryEvent)
+ bubbles = !jQueryEvent.isPropagationStopped()
+ nativeDispatch = !jQueryEvent.isImmediatePropagationStopped()
+ defaultPrevented = jQueryEvent.isDefaultPrevented()
+ }
+
+ let evt = new Event(event, { bubbles, cancelable: true })
+ evt = hydrateObj(evt, args)
+
+ if (defaultPrevented) {
+ evt.preventDefault()
+ }
+
+ if (nativeDispatch) {
+ element.dispatchEvent(evt)
+ }
+
+ if (evt.defaultPrevented && jQueryEvent) {
+ jQueryEvent.preventDefault()
+ }
+
+ return evt
+ }
+}
+
+function hydrateObj(obj, meta) {
+ for (const [key, value] of Object.entries(meta || {})) {
+ try {
+ obj[key] = value
+ } catch {
+ Object.defineProperty(obj, key, {
+ configurable: true,
+ get() {
+ return value
+ }
+ })
+ }
+ }
+
+ return obj
+}
+
+export default EventHandler
diff --git a/js/src/dom/manipulator.js b/js/src/dom/manipulator.js
new file mode 100644
index 0000000..38ecfe4
--- /dev/null
+++ b/js/src/dom/manipulator.js
@@ -0,0 +1,71 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): dom/manipulator.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+function normalizeData(value) {
+ if (value === 'true') {
+ return true
+ }
+
+ if (value === 'false') {
+ return false
+ }
+
+ if (value === Number(value).toString()) {
+ return Number(value)
+ }
+
+ if (value === '' || value === 'null') {
+ return null
+ }
+
+ if (typeof value !== 'string') {
+ return value
+ }
+
+ try {
+ return JSON.parse(decodeURIComponent(value))
+ } catch {
+ return value
+ }
+}
+
+function normalizeDataKey(key) {
+ return key.replace(/[A-Z]/g, chr => `-${chr.toLowerCase()}`)
+}
+
+const Manipulator = {
+ setDataAttribute(element, key, value) {
+ element.setAttribute(`data-bs-${normalizeDataKey(key)}`, value)
+ },
+
+ removeDataAttribute(element, key) {
+ element.removeAttribute(`data-bs-${normalizeDataKey(key)}`)
+ },
+
+ getDataAttributes(element) {
+ if (!element) {
+ return {}
+ }
+
+ const attributes = {}
+ const bsKeys = Object.keys(element.dataset).filter(key => key.startsWith('bs') && !key.startsWith('bsConfig'))
+
+ for (const key of bsKeys) {
+ let pureKey = key.replace(/^bs/, '')
+ pureKey = pureKey.charAt(0).toLowerCase() + pureKey.slice(1, pureKey.length)
+ attributes[pureKey] = normalizeData(element.dataset[key])
+ }
+
+ return attributes
+ },
+
+ getDataAttribute(element, key) {
+ return normalizeData(element.getAttribute(`data-bs-${normalizeDataKey(key)}`))
+ }
+}
+
+export default Manipulator
diff --git a/js/src/dom/selector-engine.js b/js/src/dom/selector-engine.js
new file mode 100644
index 0000000..1ba104f
--- /dev/null
+++ b/js/src/dom/selector-engine.js
@@ -0,0 +1,83 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): dom/selector-engine.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { isDisabled, isVisible } from '../util/index'
+
+/**
+ * Constants
+ */
+
+const SelectorEngine = {
+ find(selector, element = document.documentElement) {
+ return [].concat(...Element.prototype.querySelectorAll.call(element, selector))
+ },
+
+ findOne(selector, element = document.documentElement) {
+ return Element.prototype.querySelector.call(element, selector)
+ },
+
+ children(element, selector) {
+ return [].concat(...element.children).filter(child => child.matches(selector))
+ },
+
+ parents(element, selector) {
+ const parents = []
+ let ancestor = element.parentNode.closest(selector)
+
+ while (ancestor) {
+ parents.push(ancestor)
+ ancestor = ancestor.parentNode.closest(selector)
+ }
+
+ return parents
+ },
+
+ prev(element, selector) {
+ let previous = element.previousElementSibling
+
+ while (previous) {
+ if (previous.matches(selector)) {
+ return [previous]
+ }
+
+ previous = previous.previousElementSibling
+ }
+
+ return []
+ },
+ // TODO: this is now unused; remove later along with prev()
+ next(element, selector) {
+ let next = element.nextElementSibling
+
+ while (next) {
+ if (next.matches(selector)) {
+ return [next]
+ }
+
+ next = next.nextElementSibling
+ }
+
+ return []
+ },
+
+ focusableChildren(element) {
+ const focusables = [
+ 'a',
+ 'button',
+ 'input',
+ 'textarea',
+ 'select',
+ 'details',
+ '[tabindex]',
+ '[contenteditable="true"]'
+ ].map(selector => `${selector}:not([tabindex^="-"])`).join(',')
+
+ return this.find(focusables, element).filter(el => !isDisabled(el) && isVisible(el))
+ }
+}
+
+export default SelectorEngine
diff --git a/js/src/dropdown.js b/js/src/dropdown.js
new file mode 100644
index 0000000..9596baa
--- /dev/null
+++ b/js/src/dropdown.js
@@ -0,0 +1,454 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): dropdown.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import * as Popper from '@popperjs/core'
+import {
+ defineJQueryPlugin,
+ getElement,
+ getNextActiveElement,
+ isDisabled,
+ isElement,
+ isRTL,
+ isVisible,
+ noop
+} from './util/index'
+import EventHandler from './dom/event-handler'
+import Manipulator from './dom/manipulator'
+import SelectorEngine from './dom/selector-engine'
+import BaseComponent from './base-component'
+
+/**
+ * Constants
+ */
+
+const NAME = 'dropdown'
+const DATA_KEY = 'bs.dropdown'
+const EVENT_KEY = `.${DATA_KEY}`
+const DATA_API_KEY = '.data-api'
+
+const ESCAPE_KEY = 'Escape'
+const TAB_KEY = 'Tab'
+const ARROW_UP_KEY = 'ArrowUp'
+const ARROW_DOWN_KEY = 'ArrowDown'
+const RIGHT_MOUSE_BUTTON = 2 // MouseEvent.button value for the secondary button, usually the right button
+
+const EVENT_HIDE = `hide${EVENT_KEY}`
+const EVENT_HIDDEN = `hidden${EVENT_KEY}`
+const EVENT_SHOW = `show${EVENT_KEY}`
+const EVENT_SHOWN = `shown${EVENT_KEY}`
+const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
+const EVENT_KEYDOWN_DATA_API = `keydown${EVENT_KEY}${DATA_API_KEY}`
+const EVENT_KEYUP_DATA_API = `keyup${EVENT_KEY}${DATA_API_KEY}`
+
+const CLASS_NAME_SHOW = 'show'
+const CLASS_NAME_DROPUP = 'dropup'
+const CLASS_NAME_DROPEND = 'dropend'
+const CLASS_NAME_DROPSTART = 'dropstart'
+const CLASS_NAME_DROPUP_CENTER = 'dropup-center'
+const CLASS_NAME_DROPDOWN_CENTER = 'dropdown-center'
+
+const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="dropdown"]:not(.disabled):not(:disabled)'
+const SELECTOR_DATA_TOGGLE_SHOWN = `${SELECTOR_DATA_TOGGLE}.${CLASS_NAME_SHOW}`
+const SELECTOR_MENU = '.dropdown-menu'
+const SELECTOR_NAVBAR = '.navbar'
+const SELECTOR_NAVBAR_NAV = '.navbar-nav'
+const SELECTOR_VISIBLE_ITEMS = '.dropdown-menu .dropdown-item:not(.disabled):not(:disabled)'
+
+const PLACEMENT_TOP = isRTL() ? 'top-end' : 'top-start'
+const PLACEMENT_TOPEND = isRTL() ? 'top-start' : 'top-end'
+const PLACEMENT_BOTTOM = isRTL() ? 'bottom-end' : 'bottom-start'
+const PLACEMENT_BOTTOMEND = isRTL() ? 'bottom-start' : 'bottom-end'
+const PLACEMENT_RIGHT = isRTL() ? 'left-start' : 'right-start'
+const PLACEMENT_LEFT = isRTL() ? 'right-start' : 'left-start'
+const PLACEMENT_TOPCENTER = 'top'
+const PLACEMENT_BOTTOMCENTER = 'bottom'
+
+const Default = {
+ autoClose: true,
+ boundary: 'clippingParents',
+ display: 'dynamic',
+ offset: [0, 2],
+ popperConfig: null,
+ reference: 'toggle'
+}
+
+const DefaultType = {
+ autoClose: '(boolean|string)',
+ boundary: '(string|element)',
+ display: 'string',
+ offset: '(array|string|function)',
+ popperConfig: '(null|object|function)',
+ reference: '(string|element|object)'
+}
+
+/**
+ * Class definition
+ */
+
+class Dropdown extends BaseComponent {
+ constructor(element, config) {
+ super(element, config)
+
+ this._popper = null
+ this._parent = this._element.parentNode // dropdown wrapper
+ // todo: v6 revert #37011 & change markup https://getbootstrap.com/docs/5.2/forms/input-group/
+ this._menu = SelectorEngine.next(this._element, SELECTOR_MENU)[0] ||
+ SelectorEngine.prev(this._element, SELECTOR_MENU)[0] ||
+ SelectorEngine.findOne(SELECTOR_MENU, this._parent)
+ this._inNavbar = this._detectNavbar()
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ toggle() {
+ return this._isShown() ? this.hide() : this.show()
+ }
+
+ show() {
+ if (isDisabled(this._element) || this._isShown()) {
+ return
+ }
+
+ const relatedTarget = {
+ relatedTarget: this._element
+ }
+
+ const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, relatedTarget)
+
+ if (showEvent.defaultPrevented) {
+ return
+ }
+
+ this._createPopper()
+
+ // If this is a touch-enabled device we add extra
+ // empty mouseover listeners to the body's immediate children;
+ // only needed because of broken event delegation on iOS
+ // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html
+ if ('ontouchstart' in document.documentElement && !this._parent.closest(SELECTOR_NAVBAR_NAV)) {
+ for (const element of [].concat(...document.body.children)) {
+ EventHandler.on(element, 'mouseover', noop)
+ }
+ }
+
+ this._element.focus()
+ this._element.setAttribute('aria-expanded', true)
+
+ this._menu.classList.add(CLASS_NAME_SHOW)
+ this._element.classList.add(CLASS_NAME_SHOW)
+ EventHandler.trigger(this._element, EVENT_SHOWN, relatedTarget)
+ }
+
+ hide() {
+ if (isDisabled(this._element) || !this._isShown()) {
+ return
+ }
+
+ const relatedTarget = {
+ relatedTarget: this._element
+ }
+
+ this._completeHide(relatedTarget)
+ }
+
+ dispose() {
+ if (this._popper) {
+ this._popper.destroy()
+ }
+
+ super.dispose()
+ }
+
+ update() {
+ this._inNavbar = this._detectNavbar()
+ if (this._popper) {
+ this._popper.update()
+ }
+ }
+
+ // Private
+ _completeHide(relatedTarget) {
+ const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE, relatedTarget)
+ if (hideEvent.defaultPrevented) {
+ return
+ }
+
+ // If this is a touch-enabled device we remove the extra
+ // empty mouseover listeners we added for iOS support
+ if ('ontouchstart' in document.documentElement) {
+ for (const element of [].concat(...document.body.children)) {
+ EventHandler.off(element, 'mouseover', noop)
+ }
+ }
+
+ if (this._popper) {
+ this._popper.destroy()
+ }
+
+ this._menu.classList.remove(CLASS_NAME_SHOW)
+ this._element.classList.remove(CLASS_NAME_SHOW)
+ this._element.setAttribute('aria-expanded', 'false')
+ Manipulator.removeDataAttribute(this._menu, 'popper')
+ EventHandler.trigger(this._element, EVENT_HIDDEN, relatedTarget)
+ }
+
+ _getConfig(config) {
+ config = super._getConfig(config)
+
+ if (typeof config.reference === 'object' && !isElement(config.reference) &&
+ typeof config.reference.getBoundingClientRect !== 'function'
+ ) {
+ // Popper virtual elements require a getBoundingClientRect method
+ throw new TypeError(`${NAME.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`)
+ }
+
+ return config
+ }
+
+ _createPopper() {
+ if (typeof Popper === 'undefined') {
+ throw new TypeError('Bootstrap\'s dropdowns require Popper (https://popper.js.org)')
+ }
+
+ let referenceElement = this._element
+
+ if (this._config.reference === 'parent') {
+ referenceElement = this._parent
+ } else if (isElement(this._config.reference)) {
+ referenceElement = getElement(this._config.reference)
+ } else if (typeof this._config.reference === 'object') {
+ referenceElement = this._config.reference
+ }
+
+ const popperConfig = this._getPopperConfig()
+ this._popper = Popper.createPopper(referenceElement, this._menu, popperConfig)
+ }
+
+ _isShown() {
+ return this._menu.classList.contains(CLASS_NAME_SHOW)
+ }
+
+ _getPlacement() {
+ const parentDropdown = this._parent
+
+ if (parentDropdown.classList.contains(CLASS_NAME_DROPEND)) {
+ return PLACEMENT_RIGHT
+ }
+
+ if (parentDropdown.classList.contains(CLASS_NAME_DROPSTART)) {
+ return PLACEMENT_LEFT
+ }
+
+ if (parentDropdown.classList.contains(CLASS_NAME_DROPUP_CENTER)) {
+ return PLACEMENT_TOPCENTER
+ }
+
+ if (parentDropdown.classList.contains(CLASS_NAME_DROPDOWN_CENTER)) {
+ return PLACEMENT_BOTTOMCENTER
+ }
+
+ // We need to trim the value because custom properties can also include spaces
+ const isEnd = getComputedStyle(this._menu).getPropertyValue('--bs-position').trim() === 'end'
+
+ if (parentDropdown.classList.contains(CLASS_NAME_DROPUP)) {
+ return isEnd ? PLACEMENT_TOPEND : PLACEMENT_TOP
+ }
+
+ return isEnd ? PLACEMENT_BOTTOMEND : PLACEMENT_BOTTOM
+ }
+
+ _detectNavbar() {
+ return this._element.closest(SELECTOR_NAVBAR) !== null
+ }
+
+ _getOffset() {
+ const { offset } = this._config
+
+ if (typeof offset === 'string') {
+ return offset.split(',').map(value => Number.parseInt(value, 10))
+ }
+
+ if (typeof offset === 'function') {
+ return popperData => offset(popperData, this._element)
+ }
+
+ return offset
+ }
+
+ _getPopperConfig() {
+ const defaultBsPopperConfig = {
+ placement: this._getPlacement(),
+ modifiers: [{
+ name: 'preventOverflow',
+ options: {
+ boundary: this._config.boundary
+ }
+ },
+ {
+ name: 'offset',
+ options: {
+ offset: this._getOffset()
+ }
+ }]
+ }
+
+ // Disable Popper if we have a static display or Dropdown is in Navbar
+ if (this._inNavbar || this._config.display === 'static') {
+ Manipulator.setDataAttribute(this._menu, 'popper', 'static') // todo:v6 remove
+ defaultBsPopperConfig.modifiers = [{
+ name: 'applyStyles',
+ enabled: false
+ }]
+ }
+
+ return {
+ ...defaultBsPopperConfig,
+ ...(typeof this._config.popperConfig === 'function' ? this._config.popperConfig(defaultBsPopperConfig) : this._config.popperConfig)
+ }
+ }
+
+ _selectMenuItem({ key, target }) {
+ const items = SelectorEngine.find(SELECTOR_VISIBLE_ITEMS, this._menu).filter(element => isVisible(element))
+
+ if (!items.length) {
+ return
+ }
+
+ // if target isn't included in items (e.g. when expanding the dropdown)
+ // allow cycling to get the last item in case key equals ARROW_UP_KEY
+ getNextActiveElement(items, target, key === ARROW_DOWN_KEY, !items.includes(target)).focus()
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ return this.each(function () {
+ const data = Dropdown.getOrCreateInstance(this, config)
+
+ if (typeof config !== 'string') {
+ return
+ }
+
+ if (typeof data[config] === 'undefined') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config]()
+ })
+ }
+
+ static clearMenus(event) {
+ if (event.button === RIGHT_MOUSE_BUTTON || (event.type === 'keyup' && event.key !== TAB_KEY)) {
+ return
+ }
+
+ const openToggles = SelectorEngine.find(SELECTOR_DATA_TOGGLE_SHOWN)
+
+ for (const toggle of openToggles) {
+ const context = Dropdown.getInstance(toggle)
+ if (!context || context._config.autoClose === false) {
+ continue
+ }
+
+ const composedPath = event.composedPath()
+ const isMenuTarget = composedPath.includes(context._menu)
+ if (
+ composedPath.includes(context._element) ||
+ (context._config.autoClose === 'inside' && !isMenuTarget) ||
+ (context._config.autoClose === 'outside' && isMenuTarget)
+ ) {
+ continue
+ }
+
+ // Tab navigation through the dropdown menu or events from contained inputs shouldn't close the menu
+ if (context._menu.contains(event.target) && ((event.type === 'keyup' && event.key === TAB_KEY) || /input|select|option|textarea|form/i.test(event.target.tagName))) {
+ continue
+ }
+
+ const relatedTarget = { relatedTarget: context._element }
+
+ if (event.type === 'click') {
+ relatedTarget.clickEvent = event
+ }
+
+ context._completeHide(relatedTarget)
+ }
+ }
+
+ static dataApiKeydownHandler(event) {
+ // If not an UP | DOWN | ESCAPE key => not a dropdown command
+ // If input/textarea && if key is other than ESCAPE => not a dropdown command
+
+ const isInput = /input|textarea/i.test(event.target.tagName)
+ const isEscapeEvent = event.key === ESCAPE_KEY
+ const isUpOrDownEvent = [ARROW_UP_KEY, ARROW_DOWN_KEY].includes(event.key)
+
+ if (!isUpOrDownEvent && !isEscapeEvent) {
+ return
+ }
+
+ if (isInput && !isEscapeEvent) {
+ return
+ }
+
+ event.preventDefault()
+
+ // todo: v6 revert #37011 & change markup https://getbootstrap.com/docs/5.2/forms/input-group/
+ const getToggleButton = this.matches(SELECTOR_DATA_TOGGLE) ?
+ this :
+ (SelectorEngine.prev(this, SELECTOR_DATA_TOGGLE)[0] ||
+ SelectorEngine.next(this, SELECTOR_DATA_TOGGLE)[0] ||
+ SelectorEngine.findOne(SELECTOR_DATA_TOGGLE, event.delegateTarget.parentNode))
+
+ const instance = Dropdown.getOrCreateInstance(getToggleButton)
+
+ if (isUpOrDownEvent) {
+ event.stopPropagation()
+ instance.show()
+ instance._selectMenuItem(event)
+ return
+ }
+
+ if (instance._isShown()) { // else is escape and we check if it is shown
+ event.stopPropagation()
+ instance.hide()
+ getToggleButton.focus()
+ }
+ }
+}
+
+/**
+ * Data API implementation
+ */
+
+EventHandler.on(document, EVENT_KEYDOWN_DATA_API, SELECTOR_DATA_TOGGLE, Dropdown.dataApiKeydownHandler)
+EventHandler.on(document, EVENT_KEYDOWN_DATA_API, SELECTOR_MENU, Dropdown.dataApiKeydownHandler)
+EventHandler.on(document, EVENT_CLICK_DATA_API, Dropdown.clearMenus)
+EventHandler.on(document, EVENT_KEYUP_DATA_API, Dropdown.clearMenus)
+EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
+ event.preventDefault()
+ Dropdown.getOrCreateInstance(this).toggle()
+})
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Dropdown)
+
+export default Dropdown
diff --git a/js/src/modal.js b/js/src/modal.js
new file mode 100644
index 0000000..26c7e8c
--- /dev/null
+++ b/js/src/modal.js
@@ -0,0 +1,377 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): modal.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { defineJQueryPlugin, getElementFromSelector, isRTL, isVisible, reflow } from './util/index'
+import EventHandler from './dom/event-handler'
+import SelectorEngine from './dom/selector-engine'
+import ScrollBarHelper from './util/scrollbar'
+import BaseComponent from './base-component'
+import Backdrop from './util/backdrop'
+import FocusTrap from './util/focustrap'
+import { enableDismissTrigger } from './util/component-functions'
+
+/**
+ * Constants
+ */
+
+const NAME = 'modal'
+const DATA_KEY = 'bs.modal'
+const EVENT_KEY = `.${DATA_KEY}`
+const DATA_API_KEY = '.data-api'
+const ESCAPE_KEY = 'Escape'
+
+const EVENT_HIDE = `hide${EVENT_KEY}`
+const EVENT_HIDE_PREVENTED = `hidePrevented${EVENT_KEY}`
+const EVENT_HIDDEN = `hidden${EVENT_KEY}`
+const EVENT_SHOW = `show${EVENT_KEY}`
+const EVENT_SHOWN = `shown${EVENT_KEY}`
+const EVENT_RESIZE = `resize${EVENT_KEY}`
+const EVENT_CLICK_DISMISS = `click.dismiss${EVENT_KEY}`
+const EVENT_MOUSEDOWN_DISMISS = `mousedown.dismiss${EVENT_KEY}`
+const EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY}`
+const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
+
+const CLASS_NAME_OPEN = 'modal-open'
+const CLASS_NAME_FADE = 'fade'
+const CLASS_NAME_SHOW = 'show'
+const CLASS_NAME_STATIC = 'modal-static'
+
+const OPEN_SELECTOR = '.modal.show'
+const SELECTOR_DIALOG = '.modal-dialog'
+const SELECTOR_MODAL_BODY = '.modal-body'
+const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="modal"]'
+
+const Default = {
+ backdrop: true,
+ focus: true,
+ keyboard: true
+}
+
+const DefaultType = {
+ backdrop: '(boolean|string)',
+ focus: 'boolean',
+ keyboard: 'boolean'
+}
+
+/**
+ * Class definition
+ */
+
+class Modal extends BaseComponent {
+ constructor(element, config) {
+ super(element, config)
+
+ this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, this._element)
+ this._backdrop = this._initializeBackDrop()
+ this._focustrap = this._initializeFocusTrap()
+ this._isShown = false
+ this._isTransitioning = false
+ this._scrollBar = new ScrollBarHelper()
+
+ this._addEventListeners()
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ toggle(relatedTarget) {
+ return this._isShown ? this.hide() : this.show(relatedTarget)
+ }
+
+ show(relatedTarget) {
+ if (this._isShown || this._isTransitioning) {
+ return
+ }
+
+ const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, {
+ relatedTarget
+ })
+
+ if (showEvent.defaultPrevented) {
+ return
+ }
+
+ this._isShown = true
+ this._isTransitioning = true
+
+ this._scrollBar.hide()
+
+ document.body.classList.add(CLASS_NAME_OPEN)
+
+ this._adjustDialog()
+
+ this._backdrop.show(() => this._showElement(relatedTarget))
+ }
+
+ hide() {
+ if (!this._isShown || this._isTransitioning) {
+ return
+ }
+
+ const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE)
+
+ if (hideEvent.defaultPrevented) {
+ return
+ }
+
+ this._isShown = false
+ this._isTransitioning = true
+ this._focustrap.deactivate()
+
+ this._element.classList.remove(CLASS_NAME_SHOW)
+
+ this._queueCallback(() => this._hideModal(), this._element, this._isAnimated())
+ }
+
+ dispose() {
+ for (const htmlElement of [window, this._dialog]) {
+ EventHandler.off(htmlElement, EVENT_KEY)
+ }
+
+ this._backdrop.dispose()
+ this._focustrap.deactivate()
+ super.dispose()
+ }
+
+ handleUpdate() {
+ this._adjustDialog()
+ }
+
+ // Private
+ _initializeBackDrop() {
+ return new Backdrop({
+ isVisible: Boolean(this._config.backdrop), // 'static' option will be translated to true, and booleans will keep their value,
+ isAnimated: this._isAnimated()
+ })
+ }
+
+ _initializeFocusTrap() {
+ return new FocusTrap({
+ trapElement: this._element
+ })
+ }
+
+ _showElement(relatedTarget) {
+ // try to append dynamic modal
+ if (!document.body.contains(this._element)) {
+ document.body.append(this._element)
+ }
+
+ this._element.style.display = 'block'
+ this._element.removeAttribute('aria-hidden')
+ this._element.setAttribute('aria-modal', true)
+ this._element.setAttribute('role', 'dialog')
+ this._element.scrollTop = 0
+
+ const modalBody = SelectorEngine.findOne(SELECTOR_MODAL_BODY, this._dialog)
+ if (modalBody) {
+ modalBody.scrollTop = 0
+ }
+
+ reflow(this._element)
+
+ this._element.classList.add(CLASS_NAME_SHOW)
+
+ const transitionComplete = () => {
+ if (this._config.focus) {
+ this._focustrap.activate()
+ }
+
+ this._isTransitioning = false
+ EventHandler.trigger(this._element, EVENT_SHOWN, {
+ relatedTarget
+ })
+ }
+
+ this._queueCallback(transitionComplete, this._dialog, this._isAnimated())
+ }
+
+ _addEventListeners() {
+ EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, event => {
+ if (event.key !== ESCAPE_KEY) {
+ return
+ }
+
+ if (this._config.keyboard) {
+ event.preventDefault()
+ this.hide()
+ return
+ }
+
+ this._triggerBackdropTransition()
+ })
+
+ EventHandler.on(window, EVENT_RESIZE, () => {
+ if (this._isShown && !this._isTransitioning) {
+ this._adjustDialog()
+ }
+ })
+
+ EventHandler.on(this._element, EVENT_MOUSEDOWN_DISMISS, event => {
+ // a bad trick to segregate clicks that may start inside dialog but end outside, and avoid listen to scrollbar clicks
+ EventHandler.one(this._element, EVENT_CLICK_DISMISS, event2 => {
+ if (this._element !== event.target || this._element !== event2.target) {
+ return
+ }
+
+ if (this._config.backdrop === 'static') {
+ this._triggerBackdropTransition()
+ return
+ }
+
+ if (this._config.backdrop) {
+ this.hide()
+ }
+ })
+ })
+ }
+
+ _hideModal() {
+ this._element.style.display = 'none'
+ this._element.setAttribute('aria-hidden', true)
+ this._element.removeAttribute('aria-modal')
+ this._element.removeAttribute('role')
+ this._isTransitioning = false
+
+ this._backdrop.hide(() => {
+ document.body.classList.remove(CLASS_NAME_OPEN)
+ this._resetAdjustments()
+ this._scrollBar.reset()
+ EventHandler.trigger(this._element, EVENT_HIDDEN)
+ })
+ }
+
+ _isAnimated() {
+ return this._element.classList.contains(CLASS_NAME_FADE)
+ }
+
+ _triggerBackdropTransition() {
+ const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED)
+ if (hideEvent.defaultPrevented) {
+ return
+ }
+
+ const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight
+ const initialOverflowY = this._element.style.overflowY
+ // return if the following background transition hasn't yet completed
+ if (initialOverflowY === 'hidden' || this._element.classList.contains(CLASS_NAME_STATIC)) {
+ return
+ }
+
+ if (!isModalOverflowing) {
+ this._element.style.overflowY = 'hidden'
+ }
+
+ this._element.classList.add(CLASS_NAME_STATIC)
+ this._queueCallback(() => {
+ this._element.classList.remove(CLASS_NAME_STATIC)
+ this._queueCallback(() => {
+ this._element.style.overflowY = initialOverflowY
+ }, this._dialog)
+ }, this._dialog)
+
+ this._element.focus()
+ }
+
+ /**
+ * The following methods are used to handle overflowing modals
+ */
+
+ _adjustDialog() {
+ const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight
+ const scrollbarWidth = this._scrollBar.getWidth()
+ const isBodyOverflowing = scrollbarWidth > 0
+
+ if (isBodyOverflowing && !isModalOverflowing) {
+ const property = isRTL() ? 'paddingLeft' : 'paddingRight'
+ this._element.style[property] = `${scrollbarWidth}px`
+ }
+
+ if (!isBodyOverflowing && isModalOverflowing) {
+ const property = isRTL() ? 'paddingRight' : 'paddingLeft'
+ this._element.style[property] = `${scrollbarWidth}px`
+ }
+ }
+
+ _resetAdjustments() {
+ this._element.style.paddingLeft = ''
+ this._element.style.paddingRight = ''
+ }
+
+ // Static
+ static jQueryInterface(config, relatedTarget) {
+ return this.each(function () {
+ const data = Modal.getOrCreateInstance(this, config)
+
+ if (typeof config !== 'string') {
+ return
+ }
+
+ if (typeof data[config] === 'undefined') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config](relatedTarget)
+ })
+ }
+}
+
+/**
+ * Data API implementation
+ */
+
+EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
+ const target = getElementFromSelector(this)
+
+ if (['A', 'AREA'].includes(this.tagName)) {
+ event.preventDefault()
+ }
+
+ EventHandler.one(target, EVENT_SHOW, showEvent => {
+ if (showEvent.defaultPrevented) {
+ // only register focus restorer if modal will actually get shown
+ return
+ }
+
+ EventHandler.one(target, EVENT_HIDDEN, () => {
+ if (isVisible(this)) {
+ this.focus()
+ }
+ })
+ })
+
+ // avoid conflict when clicking modal toggler while another one is open
+ const alreadyOpen = SelectorEngine.findOne(OPEN_SELECTOR)
+ if (alreadyOpen) {
+ Modal.getInstance(alreadyOpen).hide()
+ }
+
+ const data = Modal.getOrCreateInstance(target)
+
+ data.toggle(this)
+})
+
+enableDismissTrigger(Modal)
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Modal)
+
+export default Modal
diff --git a/js/src/offcanvas.js b/js/src/offcanvas.js
new file mode 100644
index 0000000..7dd06fd
--- /dev/null
+++ b/js/src/offcanvas.js
@@ -0,0 +1,283 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): offcanvas.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import {
+ defineJQueryPlugin,
+ getElementFromSelector,
+ isDisabled,
+ isVisible
+} from './util/index'
+import ScrollBarHelper from './util/scrollbar'
+import EventHandler from './dom/event-handler'
+import BaseComponent from './base-component'
+import SelectorEngine from './dom/selector-engine'
+import Backdrop from './util/backdrop'
+import FocusTrap from './util/focustrap'
+import { enableDismissTrigger } from './util/component-functions'
+
+/**
+ * Constants
+ */
+
+const NAME = 'offcanvas'
+const DATA_KEY = 'bs.offcanvas'
+const EVENT_KEY = `.${DATA_KEY}`
+const DATA_API_KEY = '.data-api'
+const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`
+const ESCAPE_KEY = 'Escape'
+
+const CLASS_NAME_SHOW = 'show'
+const CLASS_NAME_SHOWING = 'showing'
+const CLASS_NAME_HIDING = 'hiding'
+const CLASS_NAME_BACKDROP = 'offcanvas-backdrop'
+const OPEN_SELECTOR = '.offcanvas.show'
+
+const EVENT_SHOW = `show${EVENT_KEY}`
+const EVENT_SHOWN = `shown${EVENT_KEY}`
+const EVENT_HIDE = `hide${EVENT_KEY}`
+const EVENT_HIDE_PREVENTED = `hidePrevented${EVENT_KEY}`
+const EVENT_HIDDEN = `hidden${EVENT_KEY}`
+const EVENT_RESIZE = `resize${EVENT_KEY}`
+const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
+const EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY}`
+
+const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="offcanvas"]'
+
+const Default = {
+ backdrop: true,
+ keyboard: true,
+ scroll: false
+}
+
+const DefaultType = {
+ backdrop: '(boolean|string)',
+ keyboard: 'boolean',
+ scroll: 'boolean'
+}
+
+/**
+ * Class definition
+ */
+
+class Offcanvas extends BaseComponent {
+ constructor(element, config) {
+ super(element, config)
+
+ this._isShown = false
+ this._backdrop = this._initializeBackDrop()
+ this._focustrap = this._initializeFocusTrap()
+ this._addEventListeners()
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ toggle(relatedTarget) {
+ return this._isShown ? this.hide() : this.show(relatedTarget)
+ }
+
+ show(relatedTarget) {
+ if (this._isShown) {
+ return
+ }
+
+ const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, { relatedTarget })
+
+ if (showEvent.defaultPrevented) {
+ return
+ }
+
+ this._isShown = true
+ this._backdrop.show()
+
+ if (!this._config.scroll) {
+ new ScrollBarHelper().hide()
+ }
+
+ this._element.setAttribute('aria-modal', true)
+ this._element.setAttribute('role', 'dialog')
+ this._element.classList.add(CLASS_NAME_SHOWING)
+
+ const completeCallBack = () => {
+ if (!this._config.scroll || this._config.backdrop) {
+ this._focustrap.activate()
+ }
+
+ this._element.classList.add(CLASS_NAME_SHOW)
+ this._element.classList.remove(CLASS_NAME_SHOWING)
+ EventHandler.trigger(this._element, EVENT_SHOWN, { relatedTarget })
+ }
+
+ this._queueCallback(completeCallBack, this._element, true)
+ }
+
+ hide() {
+ if (!this._isShown) {
+ return
+ }
+
+ const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE)
+
+ if (hideEvent.defaultPrevented) {
+ return
+ }
+
+ this._focustrap.deactivate()
+ this._element.blur()
+ this._isShown = false
+ this._element.classList.add(CLASS_NAME_HIDING)
+ this._backdrop.hide()
+
+ const completeCallback = () => {
+ this._element.classList.remove(CLASS_NAME_SHOW, CLASS_NAME_HIDING)
+ this._element.removeAttribute('aria-modal')
+ this._element.removeAttribute('role')
+
+ if (!this._config.scroll) {
+ new ScrollBarHelper().reset()
+ }
+
+ EventHandler.trigger(this._element, EVENT_HIDDEN)
+ }
+
+ this._queueCallback(completeCallback, this._element, true)
+ }
+
+ dispose() {
+ this._backdrop.dispose()
+ this._focustrap.deactivate()
+ super.dispose()
+ }
+
+ // Private
+ _initializeBackDrop() {
+ const clickCallback = () => {
+ if (this._config.backdrop === 'static') {
+ EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED)
+ return
+ }
+
+ this.hide()
+ }
+
+ // 'static' option will be translated to true, and booleans will keep their value
+ const isVisible = Boolean(this._config.backdrop)
+
+ return new Backdrop({
+ className: CLASS_NAME_BACKDROP,
+ isVisible,
+ isAnimated: true,
+ rootElement: this._element.parentNode,
+ clickCallback: isVisible ? clickCallback : null
+ })
+ }
+
+ _initializeFocusTrap() {
+ return new FocusTrap({
+ trapElement: this._element
+ })
+ }
+
+ _addEventListeners() {
+ EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, event => {
+ if (event.key !== ESCAPE_KEY) {
+ return
+ }
+
+ if (!this._config.keyboard) {
+ EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED)
+ return
+ }
+
+ this.hide()
+ })
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ return this.each(function () {
+ const data = Offcanvas.getOrCreateInstance(this, config)
+
+ if (typeof config !== 'string') {
+ return
+ }
+
+ if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config](this)
+ })
+ }
+}
+
+/**
+ * Data API implementation
+ */
+
+EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
+ const target = getElementFromSelector(this)
+
+ if (['A', 'AREA'].includes(this.tagName)) {
+ event.preventDefault()
+ }
+
+ if (isDisabled(this)) {
+ return
+ }
+
+ EventHandler.one(target, EVENT_HIDDEN, () => {
+ // focus on trigger when it is closed
+ if (isVisible(this)) {
+ this.focus()
+ }
+ })
+
+ // avoid conflict when clicking a toggler of an offcanvas, while another is open
+ const alreadyOpen = SelectorEngine.findOne(OPEN_SELECTOR)
+ if (alreadyOpen && alreadyOpen !== target) {
+ Offcanvas.getInstance(alreadyOpen).hide()
+ }
+
+ const data = Offcanvas.getOrCreateInstance(target)
+ data.toggle(this)
+})
+
+EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
+ for (const selector of SelectorEngine.find(OPEN_SELECTOR)) {
+ Offcanvas.getOrCreateInstance(selector).show()
+ }
+})
+
+EventHandler.on(window, EVENT_RESIZE, () => {
+ for (const element of SelectorEngine.find('[aria-modal][class*=show][class*=offcanvas-]')) {
+ if (getComputedStyle(element).position !== 'fixed') {
+ Offcanvas.getOrCreateInstance(element).hide()
+ }
+ }
+})
+
+enableDismissTrigger(Offcanvas)
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Offcanvas)
+
+export default Offcanvas
diff --git a/js/src/popover.js b/js/src/popover.js
new file mode 100644
index 0000000..1b09dd4
--- /dev/null
+++ b/js/src/popover.js
@@ -0,0 +1,97 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): popover.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { defineJQueryPlugin } from './util/index'
+import Tooltip from './tooltip'
+
+/**
+ * Constants
+ */
+
+const NAME = 'popover'
+
+const SELECTOR_TITLE = '.popover-header'
+const SELECTOR_CONTENT = '.popover-body'
+
+const Default = {
+ ...Tooltip.Default,
+ content: '',
+ offset: [0, 8],
+ placement: 'right',
+ template: '<div class="popover" role="tooltip">' +
+ '<div class="popover-arrow"></div>' +
+ '<h3 class="popover-header"></h3>' +
+ '<div class="popover-body"></div>' +
+ '</div>',
+ trigger: 'click'
+}
+
+const DefaultType = {
+ ...Tooltip.DefaultType,
+ content: '(null|string|element|function)'
+}
+
+/**
+ * Class definition
+ */
+
+class Popover extends Tooltip {
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Overrides
+ _isWithContent() {
+ return this._getTitle() || this._getContent()
+ }
+
+ // Private
+ _getContentForTemplate() {
+ return {
+ [SELECTOR_TITLE]: this._getTitle(),
+ [SELECTOR_CONTENT]: this._getContent()
+ }
+ }
+
+ _getContent() {
+ return this._resolvePossibleFunction(this._config.content)
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ return this.each(function () {
+ const data = Popover.getOrCreateInstance(this, config)
+
+ if (typeof config !== 'string') {
+ return
+ }
+
+ if (typeof data[config] === 'undefined') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config]()
+ })
+ }
+}
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Popover)
+
+export default Popover
diff --git a/js/src/scrollspy.js b/js/src/scrollspy.js
new file mode 100644
index 0000000..01aba99
--- /dev/null
+++ b/js/src/scrollspy.js
@@ -0,0 +1,294 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): scrollspy.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { defineJQueryPlugin, getElement, isDisabled, isVisible } from './util/index'
+import EventHandler from './dom/event-handler'
+import SelectorEngine from './dom/selector-engine'
+import BaseComponent from './base-component'
+
+/**
+ * Constants
+ */
+
+const NAME = 'scrollspy'
+const DATA_KEY = 'bs.scrollspy'
+const EVENT_KEY = `.${DATA_KEY}`
+const DATA_API_KEY = '.data-api'
+
+const EVENT_ACTIVATE = `activate${EVENT_KEY}`
+const EVENT_CLICK = `click${EVENT_KEY}`
+const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`
+
+const CLASS_NAME_DROPDOWN_ITEM = 'dropdown-item'
+const CLASS_NAME_ACTIVE = 'active'
+
+const SELECTOR_DATA_SPY = '[data-bs-spy="scroll"]'
+const SELECTOR_TARGET_LINKS = '[href]'
+const SELECTOR_NAV_LIST_GROUP = '.nav, .list-group'
+const SELECTOR_NAV_LINKS = '.nav-link'
+const SELECTOR_NAV_ITEMS = '.nav-item'
+const SELECTOR_LIST_ITEMS = '.list-group-item'
+const SELECTOR_LINK_ITEMS = `${SELECTOR_NAV_LINKS}, ${SELECTOR_NAV_ITEMS} > ${SELECTOR_NAV_LINKS}, ${SELECTOR_LIST_ITEMS}`
+const SELECTOR_DROPDOWN = '.dropdown'
+const SELECTOR_DROPDOWN_TOGGLE = '.dropdown-toggle'
+
+const Default = {
+ offset: null, // TODO: v6 @deprecated, keep it for backwards compatibility reasons
+ rootMargin: '0px 0px -25%',
+ smoothScroll: false,
+ target: null,
+ threshold: [0.1, 0.5, 1]
+}
+
+const DefaultType = {
+ offset: '(number|null)', // TODO v6 @deprecated, keep it for backwards compatibility reasons
+ rootMargin: 'string',
+ smoothScroll: 'boolean',
+ target: 'element',
+ threshold: 'array'
+}
+
+/**
+ * Class definition
+ */
+
+class ScrollSpy extends BaseComponent {
+ constructor(element, config) {
+ super(element, config)
+
+ // this._element is the observablesContainer and config.target the menu links wrapper
+ this._targetLinks = new Map()
+ this._observableSections = new Map()
+ this._rootElement = getComputedStyle(this._element).overflowY === 'visible' ? null : this._element
+ this._activeTarget = null
+ this._observer = null
+ this._previousScrollData = {
+ visibleEntryTop: 0,
+ parentScrollTop: 0
+ }
+ this.refresh() // initialize
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ refresh() {
+ this._initializeTargetsAndObservables()
+ this._maybeEnableSmoothScroll()
+
+ if (this._observer) {
+ this._observer.disconnect()
+ } else {
+ this._observer = this._getNewObserver()
+ }
+
+ for (const section of this._observableSections.values()) {
+ this._observer.observe(section)
+ }
+ }
+
+ dispose() {
+ this._observer.disconnect()
+ super.dispose()
+ }
+
+ // Private
+ _configAfterMerge(config) {
+ // TODO: on v6 target should be given explicitly & remove the {target: 'ss-target'} case
+ config.target = getElement(config.target) || document.body
+
+ // TODO: v6 Only for backwards compatibility reasons. Use rootMargin only
+ config.rootMargin = config.offset ? `${config.offset}px 0px -30%` : config.rootMargin
+
+ if (typeof config.threshold === 'string') {
+ config.threshold = config.threshold.split(',').map(value => Number.parseFloat(value))
+ }
+
+ return config
+ }
+
+ _maybeEnableSmoothScroll() {
+ if (!this._config.smoothScroll) {
+ return
+ }
+
+ // unregister any previous listeners
+ EventHandler.off(this._config.target, EVENT_CLICK)
+
+ EventHandler.on(this._config.target, EVENT_CLICK, SELECTOR_TARGET_LINKS, event => {
+ const observableSection = this._observableSections.get(event.target.hash)
+ if (observableSection) {
+ event.preventDefault()
+ const root = this._rootElement || window
+ const height = observableSection.offsetTop - this._element.offsetTop
+ if (root.scrollTo) {
+ root.scrollTo({ top: height, behavior: 'smooth' })
+ return
+ }
+
+ // Chrome 60 doesn't support `scrollTo`
+ root.scrollTop = height
+ }
+ })
+ }
+
+ _getNewObserver() {
+ const options = {
+ root: this._rootElement,
+ threshold: this._config.threshold,
+ rootMargin: this._config.rootMargin
+ }
+
+ return new IntersectionObserver(entries => this._observerCallback(entries), options)
+ }
+
+ // The logic of selection
+ _observerCallback(entries) {
+ const targetElement = entry => this._targetLinks.get(`#${entry.target.id}`)
+ const activate = entry => {
+ this._previousScrollData.visibleEntryTop = entry.target.offsetTop
+ this._process(targetElement(entry))
+ }
+
+ const parentScrollTop = (this._rootElement || document.documentElement).scrollTop
+ const userScrollsDown = parentScrollTop >= this._previousScrollData.parentScrollTop
+ this._previousScrollData.parentScrollTop = parentScrollTop
+
+ for (const entry of entries) {
+ if (!entry.isIntersecting) {
+ this._activeTarget = null
+ this._clearActiveClass(targetElement(entry))
+
+ continue
+ }
+
+ const entryIsLowerThanPrevious = entry.target.offsetTop >= this._previousScrollData.visibleEntryTop
+ // if we are scrolling down, pick the bigger offsetTop
+ if (userScrollsDown && entryIsLowerThanPrevious) {
+ activate(entry)
+ // if parent isn't scrolled, let's keep the first visible item, breaking the iteration
+ if (!parentScrollTop) {
+ return
+ }
+
+ continue
+ }
+
+ // if we are scrolling up, pick the smallest offsetTop
+ if (!userScrollsDown && !entryIsLowerThanPrevious) {
+ activate(entry)
+ }
+ }
+ }
+
+ _initializeTargetsAndObservables() {
+ this._targetLinks = new Map()
+ this._observableSections = new Map()
+
+ const targetLinks = SelectorEngine.find(SELECTOR_TARGET_LINKS, this._config.target)
+
+ for (const anchor of targetLinks) {
+ // ensure that the anchor has an id and is not disabled
+ if (!anchor.hash || isDisabled(anchor)) {
+ continue
+ }
+
+ const observableSection = SelectorEngine.findOne(anchor.hash, this._element)
+
+ // ensure that the observableSection exists & is visible
+ if (isVisible(observableSection)) {
+ this._targetLinks.set(anchor.hash, anchor)
+ this._observableSections.set(anchor.hash, observableSection)
+ }
+ }
+ }
+
+ _process(target) {
+ if (this._activeTarget === target) {
+ return
+ }
+
+ this._clearActiveClass(this._config.target)
+ this._activeTarget = target
+ target.classList.add(CLASS_NAME_ACTIVE)
+ this._activateParents(target)
+
+ EventHandler.trigger(this._element, EVENT_ACTIVATE, { relatedTarget: target })
+ }
+
+ _activateParents(target) {
+ // Activate dropdown parents
+ if (target.classList.contains(CLASS_NAME_DROPDOWN_ITEM)) {
+ SelectorEngine.findOne(SELECTOR_DROPDOWN_TOGGLE, target.closest(SELECTOR_DROPDOWN))
+ .classList.add(CLASS_NAME_ACTIVE)
+ return
+ }
+
+ for (const listGroup of SelectorEngine.parents(target, SELECTOR_NAV_LIST_GROUP)) {
+ // Set triggered links parents as active
+ // With both <ul> and <nav> markup a parent is the previous sibling of any nav ancestor
+ for (const item of SelectorEngine.prev(listGroup, SELECTOR_LINK_ITEMS)) {
+ item.classList.add(CLASS_NAME_ACTIVE)
+ }
+ }
+ }
+
+ _clearActiveClass(parent) {
+ parent.classList.remove(CLASS_NAME_ACTIVE)
+
+ const activeNodes = SelectorEngine.find(`${SELECTOR_TARGET_LINKS}.${CLASS_NAME_ACTIVE}`, parent)
+ for (const node of activeNodes) {
+ node.classList.remove(CLASS_NAME_ACTIVE)
+ }
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ return this.each(function () {
+ const data = ScrollSpy.getOrCreateInstance(this, config)
+
+ if (typeof config !== 'string') {
+ return
+ }
+
+ if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config]()
+ })
+ }
+}
+
+/**
+ * Data API implementation
+ */
+
+EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
+ for (const spy of SelectorEngine.find(SELECTOR_DATA_SPY)) {
+ ScrollSpy.getOrCreateInstance(spy)
+ }
+})
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(ScrollSpy)
+
+export default ScrollSpy
diff --git a/js/src/tab.js b/js/src/tab.js
new file mode 100644
index 0000000..8dc4644
--- /dev/null
+++ b/js/src/tab.js
@@ -0,0 +1,305 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): tab.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { defineJQueryPlugin, getElementFromSelector, getNextActiveElement, isDisabled } from './util/index'
+import EventHandler from './dom/event-handler'
+import SelectorEngine from './dom/selector-engine'
+import BaseComponent from './base-component'
+
+/**
+ * Constants
+ */
+
+const NAME = 'tab'
+const DATA_KEY = 'bs.tab'
+const EVENT_KEY = `.${DATA_KEY}`
+
+const EVENT_HIDE = `hide${EVENT_KEY}`
+const EVENT_HIDDEN = `hidden${EVENT_KEY}`
+const EVENT_SHOW = `show${EVENT_KEY}`
+const EVENT_SHOWN = `shown${EVENT_KEY}`
+const EVENT_CLICK_DATA_API = `click${EVENT_KEY}`
+const EVENT_KEYDOWN = `keydown${EVENT_KEY}`
+const EVENT_LOAD_DATA_API = `load${EVENT_KEY}`
+
+const ARROW_LEFT_KEY = 'ArrowLeft'
+const ARROW_RIGHT_KEY = 'ArrowRight'
+const ARROW_UP_KEY = 'ArrowUp'
+const ARROW_DOWN_KEY = 'ArrowDown'
+
+const CLASS_NAME_ACTIVE = 'active'
+const CLASS_NAME_FADE = 'fade'
+const CLASS_NAME_SHOW = 'show'
+const CLASS_DROPDOWN = 'dropdown'
+
+const SELECTOR_DROPDOWN_TOGGLE = '.dropdown-toggle'
+const SELECTOR_DROPDOWN_MENU = '.dropdown-menu'
+const NOT_SELECTOR_DROPDOWN_TOGGLE = ':not(.dropdown-toggle)'
+
+const SELECTOR_TAB_PANEL = '.list-group, .nav, [role="tablist"]'
+const SELECTOR_OUTER = '.nav-item, .list-group-item'
+const SELECTOR_INNER = `.nav-link${NOT_SELECTOR_DROPDOWN_TOGGLE}, .list-group-item${NOT_SELECTOR_DROPDOWN_TOGGLE}, [role="tab"]${NOT_SELECTOR_DROPDOWN_TOGGLE}`
+const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]' // todo:v6: could be only `tab`
+const SELECTOR_INNER_ELEM = `${SELECTOR_INNER}, ${SELECTOR_DATA_TOGGLE}`
+
+const SELECTOR_DATA_TOGGLE_ACTIVE = `.${CLASS_NAME_ACTIVE}[data-bs-toggle="tab"], .${CLASS_NAME_ACTIVE}[data-bs-toggle="pill"], .${CLASS_NAME_ACTIVE}[data-bs-toggle="list"]`
+
+/**
+ * Class definition
+ */
+
+class Tab extends BaseComponent {
+ constructor(element) {
+ super(element)
+ this._parent = this._element.closest(SELECTOR_TAB_PANEL)
+
+ if (!this._parent) {
+ return
+ // todo: should Throw exception on v6
+ // throw new TypeError(`${element.outerHTML} has not a valid parent ${SELECTOR_INNER_ELEM}`)
+ }
+
+ // Set up initial aria attributes
+ this._setInitialAttributes(this._parent, this._getChildren())
+
+ EventHandler.on(this._element, EVENT_KEYDOWN, event => this._keydown(event))
+ }
+
+ // Getters
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ show() { // Shows this elem and deactivate the active sibling if exists
+ const innerElem = this._element
+ if (this._elemIsActive(innerElem)) {
+ return
+ }
+
+ // Search for active tab on same parent to deactivate it
+ const active = this._getActiveElem()
+
+ const hideEvent = active ?
+ EventHandler.trigger(active, EVENT_HIDE, { relatedTarget: innerElem }) :
+ null
+
+ const showEvent = EventHandler.trigger(innerElem, EVENT_SHOW, { relatedTarget: active })
+
+ if (showEvent.defaultPrevented || (hideEvent && hideEvent.defaultPrevented)) {
+ return
+ }
+
+ this._deactivate(active, innerElem)
+ this._activate(innerElem, active)
+ }
+
+ // Private
+ _activate(element, relatedElem) {
+ if (!element) {
+ return
+ }
+
+ element.classList.add(CLASS_NAME_ACTIVE)
+
+ this._activate(getElementFromSelector(element)) // Search and activate/show the proper section
+
+ const complete = () => {
+ if (element.getAttribute('role') !== 'tab') {
+ element.classList.add(CLASS_NAME_SHOW)
+ return
+ }
+
+ element.removeAttribute('tabindex')
+ element.setAttribute('aria-selected', true)
+ this._toggleDropDown(element, true)
+ EventHandler.trigger(element, EVENT_SHOWN, {
+ relatedTarget: relatedElem
+ })
+ }
+
+ this._queueCallback(complete, element, element.classList.contains(CLASS_NAME_FADE))
+ }
+
+ _deactivate(element, relatedElem) {
+ if (!element) {
+ return
+ }
+
+ element.classList.remove(CLASS_NAME_ACTIVE)
+ element.blur()
+
+ this._deactivate(getElementFromSelector(element)) // Search and deactivate the shown section too
+
+ const complete = () => {
+ if (element.getAttribute('role') !== 'tab') {
+ element.classList.remove(CLASS_NAME_SHOW)
+ return
+ }
+
+ element.setAttribute('aria-selected', false)
+ element.setAttribute('tabindex', '-1')
+ this._toggleDropDown(element, false)
+ EventHandler.trigger(element, EVENT_HIDDEN, { relatedTarget: relatedElem })
+ }
+
+ this._queueCallback(complete, element, element.classList.contains(CLASS_NAME_FADE))
+ }
+
+ _keydown(event) {
+ if (!([ARROW_LEFT_KEY, ARROW_RIGHT_KEY, ARROW_UP_KEY, ARROW_DOWN_KEY].includes(event.key))) {
+ return
+ }
+
+ event.stopPropagation()// stopPropagation/preventDefault both added to support up/down keys without scrolling the page
+ event.preventDefault()
+ const isNext = [ARROW_RIGHT_KEY, ARROW_DOWN_KEY].includes(event.key)
+ const nextActiveElement = getNextActiveElement(this._getChildren().filter(element => !isDisabled(element)), event.target, isNext, true)
+
+ if (nextActiveElement) {
+ nextActiveElement.focus({ preventScroll: true })
+ Tab.getOrCreateInstance(nextActiveElement).show()
+ }
+ }
+
+ _getChildren() { // collection of inner elements
+ return SelectorEngine.find(SELECTOR_INNER_ELEM, this._parent)
+ }
+
+ _getActiveElem() {
+ return this._getChildren().find(child => this._elemIsActive(child)) || null
+ }
+
+ _setInitialAttributes(parent, children) {
+ this._setAttributeIfNotExists(parent, 'role', 'tablist')
+
+ for (const child of children) {
+ this._setInitialAttributesOnChild(child)
+ }
+ }
+
+ _setInitialAttributesOnChild(child) {
+ child = this._getInnerElement(child)
+ const isActive = this._elemIsActive(child)
+ const outerElem = this._getOuterElement(child)
+ child.setAttribute('aria-selected', isActive)
+
+ if (outerElem !== child) {
+ this._setAttributeIfNotExists(outerElem, 'role', 'presentation')
+ }
+
+ if (!isActive) {
+ child.setAttribute('tabindex', '-1')
+ }
+
+ this._setAttributeIfNotExists(child, 'role', 'tab')
+
+ // set attributes to the related panel too
+ this._setInitialAttributesOnTargetPanel(child)
+ }
+
+ _setInitialAttributesOnTargetPanel(child) {
+ const target = getElementFromSelector(child)
+
+ if (!target) {
+ return
+ }
+
+ this._setAttributeIfNotExists(target, 'role', 'tabpanel')
+
+ if (child.id) {
+ this._setAttributeIfNotExists(target, 'aria-labelledby', `#${child.id}`)
+ }
+ }
+
+ _toggleDropDown(element, open) {
+ const outerElem = this._getOuterElement(element)
+ if (!outerElem.classList.contains(CLASS_DROPDOWN)) {
+ return
+ }
+
+ const toggle = (selector, className) => {
+ const element = SelectorEngine.findOne(selector, outerElem)
+ if (element) {
+ element.classList.toggle(className, open)
+ }
+ }
+
+ toggle(SELECTOR_DROPDOWN_TOGGLE, CLASS_NAME_ACTIVE)
+ toggle(SELECTOR_DROPDOWN_MENU, CLASS_NAME_SHOW)
+ outerElem.setAttribute('aria-expanded', open)
+ }
+
+ _setAttributeIfNotExists(element, attribute, value) {
+ if (!element.hasAttribute(attribute)) {
+ element.setAttribute(attribute, value)
+ }
+ }
+
+ _elemIsActive(elem) {
+ return elem.classList.contains(CLASS_NAME_ACTIVE)
+ }
+
+ // Try to get the inner element (usually the .nav-link)
+ _getInnerElement(elem) {
+ return elem.matches(SELECTOR_INNER_ELEM) ? elem : SelectorEngine.findOne(SELECTOR_INNER_ELEM, elem)
+ }
+
+ // Try to get the outer element (usually the .nav-item)
+ _getOuterElement(elem) {
+ return elem.closest(SELECTOR_OUTER) || elem
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ return this.each(function () {
+ const data = Tab.getOrCreateInstance(this)
+
+ if (typeof config !== 'string') {
+ return
+ }
+
+ if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config]()
+ })
+ }
+}
+
+/**
+ * Data API implementation
+ */
+
+EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
+ if (['A', 'AREA'].includes(this.tagName)) {
+ event.preventDefault()
+ }
+
+ if (isDisabled(this)) {
+ return
+ }
+
+ Tab.getOrCreateInstance(this).show()
+})
+
+/**
+ * Initialize on focus
+ */
+EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
+ for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE_ACTIVE)) {
+ Tab.getOrCreateInstance(element)
+ }
+})
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Tab)
+
+export default Tab
diff --git a/js/src/toast.js b/js/src/toast.js
new file mode 100644
index 0000000..a7fe775
--- /dev/null
+++ b/js/src/toast.js
@@ -0,0 +1,225 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): toast.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { defineJQueryPlugin, reflow } from './util/index'
+import EventHandler from './dom/event-handler'
+import BaseComponent from './base-component'
+import { enableDismissTrigger } from './util/component-functions'
+
+/**
+ * Constants
+ */
+
+const NAME = 'toast'
+const DATA_KEY = 'bs.toast'
+const EVENT_KEY = `.${DATA_KEY}`
+
+const EVENT_MOUSEOVER = `mouseover${EVENT_KEY}`
+const EVENT_MOUSEOUT = `mouseout${EVENT_KEY}`
+const EVENT_FOCUSIN = `focusin${EVENT_KEY}`
+const EVENT_FOCUSOUT = `focusout${EVENT_KEY}`
+const EVENT_HIDE = `hide${EVENT_KEY}`
+const EVENT_HIDDEN = `hidden${EVENT_KEY}`
+const EVENT_SHOW = `show${EVENT_KEY}`
+const EVENT_SHOWN = `shown${EVENT_KEY}`
+
+const CLASS_NAME_FADE = 'fade'
+const CLASS_NAME_HIDE = 'hide' // @deprecated - kept here only for backwards compatibility
+const CLASS_NAME_SHOW = 'show'
+const CLASS_NAME_SHOWING = 'showing'
+
+const DefaultType = {
+ animation: 'boolean',
+ autohide: 'boolean',
+ delay: 'number'
+}
+
+const Default = {
+ animation: true,
+ autohide: true,
+ delay: 5000
+}
+
+/**
+ * Class definition
+ */
+
+class Toast extends BaseComponent {
+ constructor(element, config) {
+ super(element, config)
+
+ this._timeout = null
+ this._hasMouseInteraction = false
+ this._hasKeyboardInteraction = false
+ this._setListeners()
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ show() {
+ const showEvent = EventHandler.trigger(this._element, EVENT_SHOW)
+
+ if (showEvent.defaultPrevented) {
+ return
+ }
+
+ this._clearTimeout()
+
+ if (this._config.animation) {
+ this._element.classList.add(CLASS_NAME_FADE)
+ }
+
+ const complete = () => {
+ this._element.classList.remove(CLASS_NAME_SHOWING)
+ EventHandler.trigger(this._element, EVENT_SHOWN)
+
+ this._maybeScheduleHide()
+ }
+
+ this._element.classList.remove(CLASS_NAME_HIDE) // @deprecated
+ reflow(this._element)
+ this._element.classList.add(CLASS_NAME_SHOW, CLASS_NAME_SHOWING)
+
+ this._queueCallback(complete, this._element, this._config.animation)
+ }
+
+ hide() {
+ if (!this.isShown()) {
+ return
+ }
+
+ const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE)
+
+ if (hideEvent.defaultPrevented) {
+ return
+ }
+
+ const complete = () => {
+ this._element.classList.add(CLASS_NAME_HIDE) // @deprecated
+ this._element.classList.remove(CLASS_NAME_SHOWING, CLASS_NAME_SHOW)
+ EventHandler.trigger(this._element, EVENT_HIDDEN)
+ }
+
+ this._element.classList.add(CLASS_NAME_SHOWING)
+ this._queueCallback(complete, this._element, this._config.animation)
+ }
+
+ dispose() {
+ this._clearTimeout()
+
+ if (this.isShown()) {
+ this._element.classList.remove(CLASS_NAME_SHOW)
+ }
+
+ super.dispose()
+ }
+
+ isShown() {
+ return this._element.classList.contains(CLASS_NAME_SHOW)
+ }
+
+ // Private
+
+ _maybeScheduleHide() {
+ if (!this._config.autohide) {
+ return
+ }
+
+ if (this._hasMouseInteraction || this._hasKeyboardInteraction) {
+ return
+ }
+
+ this._timeout = setTimeout(() => {
+ this.hide()
+ }, this._config.delay)
+ }
+
+ _onInteraction(event, isInteracting) {
+ switch (event.type) {
+ case 'mouseover':
+ case 'mouseout': {
+ this._hasMouseInteraction = isInteracting
+ break
+ }
+
+ case 'focusin':
+ case 'focusout': {
+ this._hasKeyboardInteraction = isInteracting
+ break
+ }
+
+ default: {
+ break
+ }
+ }
+
+ if (isInteracting) {
+ this._clearTimeout()
+ return
+ }
+
+ const nextElement = event.relatedTarget
+ if (this._element === nextElement || this._element.contains(nextElement)) {
+ return
+ }
+
+ this._maybeScheduleHide()
+ }
+
+ _setListeners() {
+ EventHandler.on(this._element, EVENT_MOUSEOVER, event => this._onInteraction(event, true))
+ EventHandler.on(this._element, EVENT_MOUSEOUT, event => this._onInteraction(event, false))
+ EventHandler.on(this._element, EVENT_FOCUSIN, event => this._onInteraction(event, true))
+ EventHandler.on(this._element, EVENT_FOCUSOUT, event => this._onInteraction(event, false))
+ }
+
+ _clearTimeout() {
+ clearTimeout(this._timeout)
+ this._timeout = null
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ return this.each(function () {
+ const data = Toast.getOrCreateInstance(this, config)
+
+ if (typeof config === 'string') {
+ if (typeof data[config] === 'undefined') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config](this)
+ }
+ })
+ }
+}
+
+/**
+ * Data API implementation
+ */
+
+enableDismissTrigger(Toast)
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Toast)
+
+export default Toast
diff --git a/js/src/tooltip.js b/js/src/tooltip.js
new file mode 100644
index 0000000..748a0e1
--- /dev/null
+++ b/js/src/tooltip.js
@@ -0,0 +1,633 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): tooltip.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import * as Popper from '@popperjs/core'
+import { defineJQueryPlugin, findShadowRoot, getElement, getUID, isRTL, noop } from './util/index'
+import { DefaultAllowlist } from './util/sanitizer'
+import EventHandler from './dom/event-handler'
+import Manipulator from './dom/manipulator'
+import BaseComponent from './base-component'
+import TemplateFactory from './util/template-factory'
+
+/**
+ * Constants
+ */
+
+const NAME = 'tooltip'
+const DISALLOWED_ATTRIBUTES = new Set(['sanitize', 'allowList', 'sanitizeFn'])
+
+const CLASS_NAME_FADE = 'fade'
+const CLASS_NAME_MODAL = 'modal'
+const CLASS_NAME_SHOW = 'show'
+
+const SELECTOR_TOOLTIP_INNER = '.tooltip-inner'
+const SELECTOR_MODAL = `.${CLASS_NAME_MODAL}`
+
+const EVENT_MODAL_HIDE = 'hide.bs.modal'
+
+const TRIGGER_HOVER = 'hover'
+const TRIGGER_FOCUS = 'focus'
+const TRIGGER_CLICK = 'click'
+const TRIGGER_MANUAL = 'manual'
+
+const EVENT_HIDE = 'hide'
+const EVENT_HIDDEN = 'hidden'
+const EVENT_SHOW = 'show'
+const EVENT_SHOWN = 'shown'
+const EVENT_INSERTED = 'inserted'
+const EVENT_CLICK = 'click'
+const EVENT_FOCUSIN = 'focusin'
+const EVENT_FOCUSOUT = 'focusout'
+const EVENT_MOUSEENTER = 'mouseenter'
+const EVENT_MOUSELEAVE = 'mouseleave'
+
+const AttachmentMap = {
+ AUTO: 'auto',
+ TOP: 'top',
+ RIGHT: isRTL() ? 'left' : 'right',
+ BOTTOM: 'bottom',
+ LEFT: isRTL() ? 'right' : 'left'
+}
+
+const Default = {
+ allowList: DefaultAllowlist,
+ animation: true,
+ boundary: 'clippingParents',
+ container: false,
+ customClass: '',
+ delay: 0,
+ fallbackPlacements: ['top', 'right', 'bottom', 'left'],
+ html: false,
+ offset: [0, 0],
+ placement: 'top',
+ popperConfig: null,
+ sanitize: true,
+ sanitizeFn: null,
+ selector: false,
+ template: '<div class="tooltip" role="tooltip">' +
+ '<div class="tooltip-arrow"></div>' +
+ '<div class="tooltip-inner"></div>' +
+ '</div>',
+ title: '',
+ trigger: 'hover focus'
+}
+
+const DefaultType = {
+ allowList: 'object',
+ animation: 'boolean',
+ boundary: '(string|element)',
+ container: '(string|element|boolean)',
+ customClass: '(string|function)',
+ delay: '(number|object)',
+ fallbackPlacements: 'array',
+ html: 'boolean',
+ offset: '(array|string|function)',
+ placement: '(string|function)',
+ popperConfig: '(null|object|function)',
+ sanitize: 'boolean',
+ sanitizeFn: '(null|function)',
+ selector: '(string|boolean)',
+ template: 'string',
+ title: '(string|element|function)',
+ trigger: 'string'
+}
+
+/**
+ * Class definition
+ */
+
+class Tooltip extends BaseComponent {
+ constructor(element, config) {
+ if (typeof Popper === 'undefined') {
+ throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org)')
+ }
+
+ super(element, config)
+
+ // Private
+ this._isEnabled = true
+ this._timeout = 0
+ this._isHovered = null
+ this._activeTrigger = {}
+ this._popper = null
+ this._templateFactory = null
+ this._newContent = null
+
+ // Protected
+ this.tip = null
+
+ this._setListeners()
+
+ if (!this._config.selector) {
+ this._fixTitle()
+ }
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ enable() {
+ this._isEnabled = true
+ }
+
+ disable() {
+ this._isEnabled = false
+ }
+
+ toggleEnabled() {
+ this._isEnabled = !this._isEnabled
+ }
+
+ toggle() {
+ if (!this._isEnabled) {
+ return
+ }
+
+ this._activeTrigger.click = !this._activeTrigger.click
+ if (this._isShown()) {
+ this._leave()
+ return
+ }
+
+ this._enter()
+ }
+
+ dispose() {
+ clearTimeout(this._timeout)
+
+ EventHandler.off(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler)
+
+ if (this._element.getAttribute('data-bs-original-title')) {
+ this._element.setAttribute('title', this._element.getAttribute('data-bs-original-title'))
+ }
+
+ this._disposePopper()
+ super.dispose()
+ }
+
+ show() {
+ if (this._element.style.display === 'none') {
+ throw new Error('Please use show on visible elements')
+ }
+
+ if (!(this._isWithContent() && this._isEnabled)) {
+ return
+ }
+
+ const showEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOW))
+ const shadowRoot = findShadowRoot(this._element)
+ const isInTheDom = (shadowRoot || this._element.ownerDocument.documentElement).contains(this._element)
+
+ if (showEvent.defaultPrevented || !isInTheDom) {
+ return
+ }
+
+ // todo v6 remove this OR make it optional
+ this._disposePopper()
+
+ const tip = this._getTipElement()
+
+ this._element.setAttribute('aria-describedby', tip.getAttribute('id'))
+
+ const { container } = this._config
+
+ if (!this._element.ownerDocument.documentElement.contains(this.tip)) {
+ container.append(tip)
+ EventHandler.trigger(this._element, this.constructor.eventName(EVENT_INSERTED))
+ }
+
+ this._popper = this._createPopper(tip)
+
+ tip.classList.add(CLASS_NAME_SHOW)
+
+ // If this is a touch-enabled device we add extra
+ // empty mouseover listeners to the body's immediate children;
+ // only needed because of broken event delegation on iOS
+ // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html
+ if ('ontouchstart' in document.documentElement) {
+ for (const element of [].concat(...document.body.children)) {
+ EventHandler.on(element, 'mouseover', noop)
+ }
+ }
+
+ const complete = () => {
+ EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOWN))
+
+ if (this._isHovered === false) {
+ this._leave()
+ }
+
+ this._isHovered = false
+ }
+
+ this._queueCallback(complete, this.tip, this._isAnimated())
+ }
+
+ hide() {
+ if (!this._isShown()) {
+ return
+ }
+
+ const hideEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_HIDE))
+ if (hideEvent.defaultPrevented) {
+ return
+ }
+
+ const tip = this._getTipElement()
+ tip.classList.remove(CLASS_NAME_SHOW)
+
+ // If this is a touch-enabled device we remove the extra
+ // empty mouseover listeners we added for iOS support
+ if ('ontouchstart' in document.documentElement) {
+ for (const element of [].concat(...document.body.children)) {
+ EventHandler.off(element, 'mouseover', noop)
+ }
+ }
+
+ this._activeTrigger[TRIGGER_CLICK] = false
+ this._activeTrigger[TRIGGER_FOCUS] = false
+ this._activeTrigger[TRIGGER_HOVER] = false
+ this._isHovered = null // it is a trick to support manual triggering
+
+ const complete = () => {
+ if (this._isWithActiveTrigger()) {
+ return
+ }
+
+ if (!this._isHovered) {
+ this._disposePopper()
+ }
+
+ this._element.removeAttribute('aria-describedby')
+ EventHandler.trigger(this._element, this.constructor.eventName(EVENT_HIDDEN))
+ }
+
+ this._queueCallback(complete, this.tip, this._isAnimated())
+ }
+
+ update() {
+ if (this._popper) {
+ this._popper.update()
+ }
+ }
+
+ // Protected
+ _isWithContent() {
+ return Boolean(this._getTitle())
+ }
+
+ _getTipElement() {
+ if (!this.tip) {
+ this.tip = this._createTipElement(this._newContent || this._getContentForTemplate())
+ }
+
+ return this.tip
+ }
+
+ _createTipElement(content) {
+ const tip = this._getTemplateFactory(content).toHtml()
+
+ // todo: remove this check on v6
+ if (!tip) {
+ return null
+ }
+
+ tip.classList.remove(CLASS_NAME_FADE, CLASS_NAME_SHOW)
+ // todo: on v6 the following can be achieved with CSS only
+ tip.classList.add(`bs-${this.constructor.NAME}-auto`)
+
+ const tipId = getUID(this.constructor.NAME).toString()
+
+ tip.setAttribute('id', tipId)
+
+ if (this._isAnimated()) {
+ tip.classList.add(CLASS_NAME_FADE)
+ }
+
+ return tip
+ }
+
+ setContent(content) {
+ this._newContent = content
+ if (this._isShown()) {
+ this._disposePopper()
+ this.show()
+ }
+ }
+
+ _getTemplateFactory(content) {
+ if (this._templateFactory) {
+ this._templateFactory.changeContent(content)
+ } else {
+ this._templateFactory = new TemplateFactory({
+ ...this._config,
+ // the `content` var has to be after `this._config`
+ // to override config.content in case of popover
+ content,
+ extraClass: this._resolvePossibleFunction(this._config.customClass)
+ })
+ }
+
+ return this._templateFactory
+ }
+
+ _getContentForTemplate() {
+ return {
+ [SELECTOR_TOOLTIP_INNER]: this._getTitle()
+ }
+ }
+
+ _getTitle() {
+ return this._resolvePossibleFunction(this._config.title) || this._element.getAttribute('data-bs-original-title')
+ }
+
+ // Private
+ _initializeOnDelegatedTarget(event) {
+ return this.constructor.getOrCreateInstance(event.delegateTarget, this._getDelegateConfig())
+ }
+
+ _isAnimated() {
+ return this._config.animation || (this.tip && this.tip.classList.contains(CLASS_NAME_FADE))
+ }
+
+ _isShown() {
+ return this.tip && this.tip.classList.contains(CLASS_NAME_SHOW)
+ }
+
+ _createPopper(tip) {
+ const placement = typeof this._config.placement === 'function' ?
+ this._config.placement.call(this, tip, this._element) :
+ this._config.placement
+ const attachment = AttachmentMap[placement.toUpperCase()]
+ return Popper.createPopper(this._element, tip, this._getPopperConfig(attachment))
+ }
+
+ _getOffset() {
+ const { offset } = this._config
+
+ if (typeof offset === 'string') {
+ return offset.split(',').map(value => Number.parseInt(value, 10))
+ }
+
+ if (typeof offset === 'function') {
+ return popperData => offset(popperData, this._element)
+ }
+
+ return offset
+ }
+
+ _resolvePossibleFunction(arg) {
+ return typeof arg === 'function' ? arg.call(this._element) : arg
+ }
+
+ _getPopperConfig(attachment) {
+ const defaultBsPopperConfig = {
+ placement: attachment,
+ modifiers: [
+ {
+ name: 'flip',
+ options: {
+ fallbackPlacements: this._config.fallbackPlacements
+ }
+ },
+ {
+ name: 'offset',
+ options: {
+ offset: this._getOffset()
+ }
+ },
+ {
+ name: 'preventOverflow',
+ options: {
+ boundary: this._config.boundary
+ }
+ },
+ {
+ name: 'arrow',
+ options: {
+ element: `.${this.constructor.NAME}-arrow`
+ }
+ },
+ {
+ name: 'preSetPlacement',
+ enabled: true,
+ phase: 'beforeMain',
+ fn: data => {
+ // Pre-set Popper's placement attribute in order to read the arrow sizes properly.
+ // Otherwise, Popper mixes up the width and height dimensions since the initial arrow style is for top placement
+ this._getTipElement().setAttribute('data-popper-placement', data.state.placement)
+ }
+ }
+ ]
+ }
+
+ return {
+ ...defaultBsPopperConfig,
+ ...(typeof this._config.popperConfig === 'function' ? this._config.popperConfig(defaultBsPopperConfig) : this._config.popperConfig)
+ }
+ }
+
+ _setListeners() {
+ const triggers = this._config.trigger.split(' ')
+
+ for (const trigger of triggers) {
+ if (trigger === 'click') {
+ EventHandler.on(this._element, this.constructor.eventName(EVENT_CLICK), this._config.selector, event => {
+ const context = this._initializeOnDelegatedTarget(event)
+ context.toggle()
+ })
+ } else if (trigger !== TRIGGER_MANUAL) {
+ const eventIn = trigger === TRIGGER_HOVER ?
+ this.constructor.eventName(EVENT_MOUSEENTER) :
+ this.constructor.eventName(EVENT_FOCUSIN)
+ const eventOut = trigger === TRIGGER_HOVER ?
+ this.constructor.eventName(EVENT_MOUSELEAVE) :
+ this.constructor.eventName(EVENT_FOCUSOUT)
+
+ EventHandler.on(this._element, eventIn, this._config.selector, event => {
+ const context = this._initializeOnDelegatedTarget(event)
+ context._activeTrigger[event.type === 'focusin' ? TRIGGER_FOCUS : TRIGGER_HOVER] = true
+ context._enter()
+ })
+ EventHandler.on(this._element, eventOut, this._config.selector, event => {
+ const context = this._initializeOnDelegatedTarget(event)
+ context._activeTrigger[event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER] =
+ context._element.contains(event.relatedTarget)
+
+ context._leave()
+ })
+ }
+ }
+
+ this._hideModalHandler = () => {
+ if (this._element) {
+ this.hide()
+ }
+ }
+
+ EventHandler.on(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler)
+ }
+
+ _fixTitle() {
+ const title = this._element.getAttribute('title')
+
+ if (!title) {
+ return
+ }
+
+ if (!this._element.getAttribute('aria-label') && !this._element.textContent.trim()) {
+ this._element.setAttribute('aria-label', title)
+ }
+
+ this._element.setAttribute('data-bs-original-title', title) // DO NOT USE IT. Is only for backwards compatibility
+ this._element.removeAttribute('title')
+ }
+
+ _enter() {
+ if (this._isShown() || this._isHovered) {
+ this._isHovered = true
+ return
+ }
+
+ this._isHovered = true
+
+ this._setTimeout(() => {
+ if (this._isHovered) {
+ this.show()
+ }
+ }, this._config.delay.show)
+ }
+
+ _leave() {
+ if (this._isWithActiveTrigger()) {
+ return
+ }
+
+ this._isHovered = false
+
+ this._setTimeout(() => {
+ if (!this._isHovered) {
+ this.hide()
+ }
+ }, this._config.delay.hide)
+ }
+
+ _setTimeout(handler, timeout) {
+ clearTimeout(this._timeout)
+ this._timeout = setTimeout(handler, timeout)
+ }
+
+ _isWithActiveTrigger() {
+ return Object.values(this._activeTrigger).includes(true)
+ }
+
+ _getConfig(config) {
+ const dataAttributes = Manipulator.getDataAttributes(this._element)
+
+ for (const dataAttribute of Object.keys(dataAttributes)) {
+ if (DISALLOWED_ATTRIBUTES.has(dataAttribute)) {
+ delete dataAttributes[dataAttribute]
+ }
+ }
+
+ config = {
+ ...dataAttributes,
+ ...(typeof config === 'object' && config ? config : {})
+ }
+ config = this._mergeConfigObj(config)
+ config = this._configAfterMerge(config)
+ this._typeCheckConfig(config)
+ return config
+ }
+
+ _configAfterMerge(config) {
+ config.container = config.container === false ? document.body : getElement(config.container)
+
+ if (typeof config.delay === 'number') {
+ config.delay = {
+ show: config.delay,
+ hide: config.delay
+ }
+ }
+
+ if (typeof config.title === 'number') {
+ config.title = config.title.toString()
+ }
+
+ if (typeof config.content === 'number') {
+ config.content = config.content.toString()
+ }
+
+ return config
+ }
+
+ _getDelegateConfig() {
+ const config = {}
+
+ for (const key in this._config) {
+ if (this.constructor.Default[key] !== this._config[key]) {
+ config[key] = this._config[key]
+ }
+ }
+
+ config.selector = false
+ config.trigger = 'manual'
+
+ // In the future can be replaced with:
+ // const keysWithDifferentValues = Object.entries(this._config).filter(entry => this.constructor.Default[entry[0]] !== this._config[entry[0]])
+ // `Object.fromEntries(keysWithDifferentValues)`
+ return config
+ }
+
+ _disposePopper() {
+ if (this._popper) {
+ this._popper.destroy()
+ this._popper = null
+ }
+
+ if (this.tip) {
+ this.tip.remove()
+ this.tip = null
+ }
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ return this.each(function () {
+ const data = Tooltip.getOrCreateInstance(this, config)
+
+ if (typeof config !== 'string') {
+ return
+ }
+
+ if (typeof data[config] === 'undefined') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config]()
+ })
+ }
+}
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Tooltip)
+
+export default Tooltip
diff --git a/js/src/util/backdrop.js b/js/src/util/backdrop.js
new file mode 100644
index 0000000..78279e0
--- /dev/null
+++ b/js/src/util/backdrop.js
@@ -0,0 +1,149 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): util/backdrop.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import EventHandler from '../dom/event-handler'
+import { execute, executeAfterTransition, getElement, reflow } from './index'
+import Config from './config'
+
+/**
+ * Constants
+ */
+
+const NAME = 'backdrop'
+const CLASS_NAME_FADE = 'fade'
+const CLASS_NAME_SHOW = 'show'
+const EVENT_MOUSEDOWN = `mousedown.bs.${NAME}`
+
+const Default = {
+ className: 'modal-backdrop',
+ clickCallback: null,
+ isAnimated: false,
+ isVisible: true, // if false, we use the backdrop helper without adding any element to the dom
+ rootElement: 'body' // give the choice to place backdrop under different elements
+}
+
+const DefaultType = {
+ className: 'string',
+ clickCallback: '(function|null)',
+ isAnimated: 'boolean',
+ isVisible: 'boolean',
+ rootElement: '(element|string)'
+}
+
+/**
+ * Class definition
+ */
+
+class Backdrop extends Config {
+ constructor(config) {
+ super()
+ this._config = this._getConfig(config)
+ this._isAppended = false
+ this._element = null
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ show(callback) {
+ if (!this._config.isVisible) {
+ execute(callback)
+ return
+ }
+
+ this._append()
+
+ const element = this._getElement()
+ if (this._config.isAnimated) {
+ reflow(element)
+ }
+
+ element.classList.add(CLASS_NAME_SHOW)
+
+ this._emulateAnimation(() => {
+ execute(callback)
+ })
+ }
+
+ hide(callback) {
+ if (!this._config.isVisible) {
+ execute(callback)
+ return
+ }
+
+ this._getElement().classList.remove(CLASS_NAME_SHOW)
+
+ this._emulateAnimation(() => {
+ this.dispose()
+ execute(callback)
+ })
+ }
+
+ dispose() {
+ if (!this._isAppended) {
+ return
+ }
+
+ EventHandler.off(this._element, EVENT_MOUSEDOWN)
+
+ this._element.remove()
+ this._isAppended = false
+ }
+
+ // Private
+ _getElement() {
+ if (!this._element) {
+ const backdrop = document.createElement('div')
+ backdrop.className = this._config.className
+ if (this._config.isAnimated) {
+ backdrop.classList.add(CLASS_NAME_FADE)
+ }
+
+ this._element = backdrop
+ }
+
+ return this._element
+ }
+
+ _configAfterMerge(config) {
+ // use getElement() with the default "body" to get a fresh Element on each instantiation
+ config.rootElement = getElement(config.rootElement)
+ return config
+ }
+
+ _append() {
+ if (this._isAppended) {
+ return
+ }
+
+ const element = this._getElement()
+ this._config.rootElement.append(element)
+
+ EventHandler.on(element, EVENT_MOUSEDOWN, () => {
+ execute(this._config.clickCallback)
+ })
+
+ this._isAppended = true
+ }
+
+ _emulateAnimation(callback) {
+ executeAfterTransition(callback, this._getElement(), this._config.isAnimated)
+ }
+}
+
+export default Backdrop
diff --git a/js/src/util/component-functions.js b/js/src/util/component-functions.js
new file mode 100644
index 0000000..c2f99cc
--- /dev/null
+++ b/js/src/util/component-functions.js
@@ -0,0 +1,34 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): util/component-functions.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import EventHandler from '../dom/event-handler'
+import { getElementFromSelector, isDisabled } from './index'
+
+const enableDismissTrigger = (component, method = 'hide') => {
+ const clickEvent = `click.dismiss${component.EVENT_KEY}`
+ const name = component.NAME
+
+ EventHandler.on(document, clickEvent, `[data-bs-dismiss="${name}"]`, function (event) {
+ if (['A', 'AREA'].includes(this.tagName)) {
+ event.preventDefault()
+ }
+
+ if (isDisabled(this)) {
+ return
+ }
+
+ const target = getElementFromSelector(this) || this.closest(`.${name}`)
+ const instance = component.getOrCreateInstance(target)
+
+ // Method argument is left, for Alert and only, as it doesn't implement the 'hide' method
+ instance[method]()
+ })
+}
+
+export {
+ enableDismissTrigger
+}
diff --git a/js/src/util/config.js b/js/src/util/config.js
new file mode 100644
index 0000000..1205905
--- /dev/null
+++ b/js/src/util/config.js
@@ -0,0 +1,66 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): util/config.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { isElement, toType } from './index'
+import Manipulator from '../dom/manipulator'
+
+/**
+ * Class definition
+ */
+
+class Config {
+ // Getters
+ static get Default() {
+ return {}
+ }
+
+ static get DefaultType() {
+ return {}
+ }
+
+ static get NAME() {
+ throw new Error('You have to implement the static method "NAME", for each component!')
+ }
+
+ _getConfig(config) {
+ config = this._mergeConfigObj(config)
+ config = this._configAfterMerge(config)
+ this._typeCheckConfig(config)
+ return config
+ }
+
+ _configAfterMerge(config) {
+ return config
+ }
+
+ _mergeConfigObj(config, element) {
+ const jsonConfig = isElement(element) ? Manipulator.getDataAttribute(element, 'config') : {} // try to parse
+
+ return {
+ ...this.constructor.Default,
+ ...(typeof jsonConfig === 'object' ? jsonConfig : {}),
+ ...(isElement(element) ? Manipulator.getDataAttributes(element) : {}),
+ ...(typeof config === 'object' ? config : {})
+ }
+ }
+
+ _typeCheckConfig(config, configTypes = this.constructor.DefaultType) {
+ for (const property of Object.keys(configTypes)) {
+ const expectedTypes = configTypes[property]
+ const value = config[property]
+ const valueType = isElement(value) ? 'element' : toType(value)
+
+ if (!new RegExp(expectedTypes).test(valueType)) {
+ throw new TypeError(
+ `${this.constructor.NAME.toUpperCase()}: Option "${property}" provided type "${valueType}" but expected type "${expectedTypes}".`
+ )
+ }
+ }
+ }
+}
+
+export default Config
diff --git a/js/src/util/focustrap.js b/js/src/util/focustrap.js
new file mode 100644
index 0000000..ef69166
--- /dev/null
+++ b/js/src/util/focustrap.js
@@ -0,0 +1,115 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): util/focustrap.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import EventHandler from '../dom/event-handler'
+import SelectorEngine from '../dom/selector-engine'
+import Config from './config'
+
+/**
+ * Constants
+ */
+
+const NAME = 'focustrap'
+const DATA_KEY = 'bs.focustrap'
+const EVENT_KEY = `.${DATA_KEY}`
+const EVENT_FOCUSIN = `focusin${EVENT_KEY}`
+const EVENT_KEYDOWN_TAB = `keydown.tab${EVENT_KEY}`
+
+const TAB_KEY = 'Tab'
+const TAB_NAV_FORWARD = 'forward'
+const TAB_NAV_BACKWARD = 'backward'
+
+const Default = {
+ autofocus: true,
+ trapElement: null // The element to trap focus inside of
+}
+
+const DefaultType = {
+ autofocus: 'boolean',
+ trapElement: 'element'
+}
+
+/**
+ * Class definition
+ */
+
+class FocusTrap extends Config {
+ constructor(config) {
+ super()
+ this._config = this._getConfig(config)
+ this._isActive = false
+ this._lastTabNavDirection = null
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ activate() {
+ if (this._isActive) {
+ return
+ }
+
+ if (this._config.autofocus) {
+ this._config.trapElement.focus()
+ }
+
+ EventHandler.off(document, EVENT_KEY) // guard against infinite focus loop
+ EventHandler.on(document, EVENT_FOCUSIN, event => this._handleFocusin(event))
+ EventHandler.on(document, EVENT_KEYDOWN_TAB, event => this._handleKeydown(event))
+
+ this._isActive = true
+ }
+
+ deactivate() {
+ if (!this._isActive) {
+ return
+ }
+
+ this._isActive = false
+ EventHandler.off(document, EVENT_KEY)
+ }
+
+ // Private
+ _handleFocusin(event) {
+ const { trapElement } = this._config
+
+ if (event.target === document || event.target === trapElement || trapElement.contains(event.target)) {
+ return
+ }
+
+ const elements = SelectorEngine.focusableChildren(trapElement)
+
+ if (elements.length === 0) {
+ trapElement.focus()
+ } else if (this._lastTabNavDirection === TAB_NAV_BACKWARD) {
+ elements[elements.length - 1].focus()
+ } else {
+ elements[0].focus()
+ }
+ }
+
+ _handleKeydown(event) {
+ if (event.key !== TAB_KEY) {
+ return
+ }
+
+ this._lastTabNavDirection = event.shiftKey ? TAB_NAV_BACKWARD : TAB_NAV_FORWARD
+ }
+}
+
+export default FocusTrap
diff --git a/js/src/util/index.js b/js/src/util/index.js
new file mode 100644
index 0000000..297e571
--- /dev/null
+++ b/js/src/util/index.js
@@ -0,0 +1,336 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): util/index.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+const MAX_UID = 1_000_000
+const MILLISECONDS_MULTIPLIER = 1000
+const TRANSITION_END = 'transitionend'
+
+// Shout-out Angus Croll (https://goo.gl/pxwQGp)
+const toType = object => {
+ if (object === null || object === undefined) {
+ return `${object}`
+ }
+
+ return Object.prototype.toString.call(object).match(/\s([a-z]+)/i)[1].toLowerCase()
+}
+
+/**
+ * Public Util API
+ */
+
+const getUID = prefix => {
+ do {
+ prefix += Math.floor(Math.random() * MAX_UID)
+ } while (document.getElementById(prefix))
+
+ return prefix
+}
+
+const getSelector = element => {
+ let selector = element.getAttribute('data-bs-target')
+
+ if (!selector || selector === '#') {
+ let hrefAttribute = element.getAttribute('href')
+
+ // The only valid content that could double as a selector are IDs or classes,
+ // so everything starting with `#` or `.`. If a "real" URL is used as the selector,
+ // `document.querySelector` will rightfully complain it is invalid.
+ // See https://github.com/twbs/bootstrap/issues/32273
+ if (!hrefAttribute || (!hrefAttribute.includes('#') && !hrefAttribute.startsWith('.'))) {
+ return null
+ }
+
+ // Just in case some CMS puts out a full URL with the anchor appended
+ if (hrefAttribute.includes('#') && !hrefAttribute.startsWith('#')) {
+ hrefAttribute = `#${hrefAttribute.split('#')[1]}`
+ }
+
+ selector = hrefAttribute && hrefAttribute !== '#' ? hrefAttribute.trim() : null
+ }
+
+ return selector
+}
+
+const getSelectorFromElement = element => {
+ const selector = getSelector(element)
+
+ if (selector) {
+ return document.querySelector(selector) ? selector : null
+ }
+
+ return null
+}
+
+const getElementFromSelector = element => {
+ const selector = getSelector(element)
+
+ return selector ? document.querySelector(selector) : null
+}
+
+const getTransitionDurationFromElement = element => {
+ if (!element) {
+ return 0
+ }
+
+ // Get transition-duration of the element
+ let { transitionDuration, transitionDelay } = window.getComputedStyle(element)
+
+ const floatTransitionDuration = Number.parseFloat(transitionDuration)
+ const floatTransitionDelay = Number.parseFloat(transitionDelay)
+
+ // Return 0 if element or transition duration is not found
+ if (!floatTransitionDuration && !floatTransitionDelay) {
+ return 0
+ }
+
+ // If multiple durations are defined, take the first
+ transitionDuration = transitionDuration.split(',')[0]
+ transitionDelay = transitionDelay.split(',')[0]
+
+ return (Number.parseFloat(transitionDuration) + Number.parseFloat(transitionDelay)) * MILLISECONDS_MULTIPLIER
+}
+
+const triggerTransitionEnd = element => {
+ element.dispatchEvent(new Event(TRANSITION_END))
+}
+
+const isElement = object => {
+ if (!object || typeof object !== 'object') {
+ return false
+ }
+
+ if (typeof object.jquery !== 'undefined') {
+ object = object[0]
+ }
+
+ return typeof object.nodeType !== 'undefined'
+}
+
+const getElement = object => {
+ // it's a jQuery object or a node element
+ if (isElement(object)) {
+ return object.jquery ? object[0] : object
+ }
+
+ if (typeof object === 'string' && object.length > 0) {
+ return document.querySelector(object)
+ }
+
+ return null
+}
+
+const isVisible = element => {
+ if (!isElement(element) || element.getClientRects().length === 0) {
+ return false
+ }
+
+ const elementIsVisible = getComputedStyle(element).getPropertyValue('visibility') === 'visible'
+ // Handle `details` element as its content may falsie appear visible when it is closed
+ const closedDetails = element.closest('details:not([open])')
+
+ if (!closedDetails) {
+ return elementIsVisible
+ }
+
+ if (closedDetails !== element) {
+ const summary = element.closest('summary')
+ if (summary && summary.parentNode !== closedDetails) {
+ return false
+ }
+
+ if (summary === null) {
+ return false
+ }
+ }
+
+ return elementIsVisible
+}
+
+const isDisabled = element => {
+ if (!element || element.nodeType !== Node.ELEMENT_NODE) {
+ return true
+ }
+
+ if (element.classList.contains('disabled')) {
+ return true
+ }
+
+ if (typeof element.disabled !== 'undefined') {
+ return element.disabled
+ }
+
+ return element.hasAttribute('disabled') && element.getAttribute('disabled') !== 'false'
+}
+
+const findShadowRoot = element => {
+ if (!document.documentElement.attachShadow) {
+ return null
+ }
+
+ // Can find the shadow root otherwise it'll return the document
+ if (typeof element.getRootNode === 'function') {
+ const root = element.getRootNode()
+ return root instanceof ShadowRoot ? root : null
+ }
+
+ if (element instanceof ShadowRoot) {
+ return element
+ }
+
+ // when we don't find a shadow root
+ if (!element.parentNode) {
+ return null
+ }
+
+ return findShadowRoot(element.parentNode)
+}
+
+const noop = () => {}
+
+/**
+ * Trick to restart an element's animation
+ *
+ * @param {HTMLElement} element
+ * @return void
+ *
+ * @see https://www.charistheo.io/blog/2021/02/restart-a-css-animation-with-javascript/#restarting-a-css-animation
+ */
+const reflow = element => {
+ element.offsetHeight // eslint-disable-line no-unused-expressions
+}
+
+const getjQuery = () => {
+ if (window.jQuery && !document.body.hasAttribute('data-bs-no-jquery')) {
+ return window.jQuery
+ }
+
+ return null
+}
+
+const DOMContentLoadedCallbacks = []
+
+const onDOMContentLoaded = callback => {
+ if (document.readyState === 'loading') {
+ // add listener on the first call when the document is in loading state
+ if (!DOMContentLoadedCallbacks.length) {
+ document.addEventListener('DOMContentLoaded', () => {
+ for (const callback of DOMContentLoadedCallbacks) {
+ callback()
+ }
+ })
+ }
+
+ DOMContentLoadedCallbacks.push(callback)
+ } else {
+ callback()
+ }
+}
+
+const isRTL = () => document.documentElement.dir === 'rtl'
+
+const defineJQueryPlugin = plugin => {
+ onDOMContentLoaded(() => {
+ const $ = getjQuery()
+ /* istanbul ignore if */
+ if ($) {
+ const name = plugin.NAME
+ const JQUERY_NO_CONFLICT = $.fn[name]
+ $.fn[name] = plugin.jQueryInterface
+ $.fn[name].Constructor = plugin
+ $.fn[name].noConflict = () => {
+ $.fn[name] = JQUERY_NO_CONFLICT
+ return plugin.jQueryInterface
+ }
+ }
+ })
+}
+
+const execute = callback => {
+ if (typeof callback === 'function') {
+ callback()
+ }
+}
+
+const executeAfterTransition = (callback, transitionElement, waitForTransition = true) => {
+ if (!waitForTransition) {
+ execute(callback)
+ return
+ }
+
+ const durationPadding = 5
+ const emulatedDuration = getTransitionDurationFromElement(transitionElement) + durationPadding
+
+ let called = false
+
+ const handler = ({ target }) => {
+ if (target !== transitionElement) {
+ return
+ }
+
+ called = true
+ transitionElement.removeEventListener(TRANSITION_END, handler)
+ execute(callback)
+ }
+
+ transitionElement.addEventListener(TRANSITION_END, handler)
+ setTimeout(() => {
+ if (!called) {
+ triggerTransitionEnd(transitionElement)
+ }
+ }, emulatedDuration)
+}
+
+/**
+ * Return the previous/next element of a list.
+ *
+ * @param {array} list The list of elements
+ * @param activeElement The active element
+ * @param shouldGetNext Choose to get next or previous element
+ * @param isCycleAllowed
+ * @return {Element|elem} The proper element
+ */
+const getNextActiveElement = (list, activeElement, shouldGetNext, isCycleAllowed) => {
+ const listLength = list.length
+ let index = list.indexOf(activeElement)
+
+ // if the element does not exist in the list return an element
+ // depending on the direction and if cycle is allowed
+ if (index === -1) {
+ return !shouldGetNext && isCycleAllowed ? list[listLength - 1] : list[0]
+ }
+
+ index += shouldGetNext ? 1 : -1
+
+ if (isCycleAllowed) {
+ index = (index + listLength) % listLength
+ }
+
+ return list[Math.max(0, Math.min(index, listLength - 1))]
+}
+
+export {
+ defineJQueryPlugin,
+ execute,
+ executeAfterTransition,
+ findShadowRoot,
+ getElement,
+ getElementFromSelector,
+ getjQuery,
+ getNextActiveElement,
+ getSelectorFromElement,
+ getTransitionDurationFromElement,
+ getUID,
+ isDisabled,
+ isElement,
+ isRTL,
+ isVisible,
+ noop,
+ onDOMContentLoaded,
+ reflow,
+ triggerTransitionEnd,
+ toType
+}
diff --git a/js/src/util/sanitizer.js b/js/src/util/sanitizer.js
new file mode 100644
index 0000000..5328691
--- /dev/null
+++ b/js/src/util/sanitizer.js
@@ -0,0 +1,118 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): util/sanitizer.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+const uriAttributes = new Set([
+ 'background',
+ 'cite',
+ 'href',
+ 'itemtype',
+ 'longdesc',
+ 'poster',
+ 'src',
+ 'xlink:href'
+])
+
+const ARIA_ATTRIBUTE_PATTERN = /^aria-[\w-]*$/i
+
+/**
+ * A pattern that recognizes a commonly useful subset of URLs that are safe.
+ *
+ * Shout-out to Angular https://github.com/angular/angular/blob/12.2.x/packages/core/src/sanitization/url_sanitizer.ts
+ */
+const SAFE_URL_PATTERN = /^(?:(?:https?|mailto|ftp|tel|file|sms):|[^#&/:?]*(?:[#/?]|$))/i
+
+/**
+ * A pattern that matches safe data URLs. Only matches image, video and audio types.
+ *
+ * Shout-out to Angular https://github.com/angular/angular/blob/12.2.x/packages/core/src/sanitization/url_sanitizer.ts
+ */
+const DATA_URL_PATTERN = /^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[\d+/a-z]+=*$/i
+
+const allowedAttribute = (attribute, allowedAttributeList) => {
+ const attributeName = attribute.nodeName.toLowerCase()
+
+ if (allowedAttributeList.includes(attributeName)) {
+ if (uriAttributes.has(attributeName)) {
+ return Boolean(SAFE_URL_PATTERN.test(attribute.nodeValue) || DATA_URL_PATTERN.test(attribute.nodeValue))
+ }
+
+ return true
+ }
+
+ // Check if a regular expression validates the attribute.
+ return allowedAttributeList.filter(attributeRegex => attributeRegex instanceof RegExp)
+ .some(regex => regex.test(attributeName))
+}
+
+export const DefaultAllowlist = {
+ // Global attributes allowed on any supplied element below.
+ '*': ['class', 'dir', 'id', 'lang', 'role', ARIA_ATTRIBUTE_PATTERN],
+ a: ['target', 'href', 'title', 'rel'],
+ area: [],
+ b: [],
+ br: [],
+ col: [],
+ code: [],
+ div: [],
+ em: [],
+ hr: [],
+ h1: [],
+ h2: [],
+ h3: [],
+ h4: [],
+ h5: [],
+ h6: [],
+ i: [],
+ img: ['src', 'srcset', 'alt', 'title', 'width', 'height'],
+ li: [],
+ ol: [],
+ p: [],
+ pre: [],
+ s: [],
+ small: [],
+ span: [],
+ sub: [],
+ sup: [],
+ strong: [],
+ u: [],
+ ul: []
+}
+
+export function sanitizeHtml(unsafeHtml, allowList, sanitizeFunction) {
+ if (!unsafeHtml.length) {
+ return unsafeHtml
+ }
+
+ if (sanitizeFunction && typeof sanitizeFunction === 'function') {
+ return sanitizeFunction(unsafeHtml)
+ }
+
+ const domParser = new window.DOMParser()
+ const createdDocument = domParser.parseFromString(unsafeHtml, 'text/html')
+ const elements = [].concat(...createdDocument.body.querySelectorAll('*'))
+
+ for (const element of elements) {
+ const elementName = element.nodeName.toLowerCase()
+
+ if (!Object.keys(allowList).includes(elementName)) {
+ element.remove()
+
+ continue
+ }
+
+ const attributeList = [].concat(...element.attributes)
+ const allowedAttributes = [].concat(allowList['*'] || [], allowList[elementName] || [])
+
+ for (const attribute of attributeList) {
+ if (!allowedAttribute(attribute, allowedAttributes)) {
+ element.removeAttribute(attribute.nodeName)
+ }
+ }
+ }
+
+ return createdDocument.body.innerHTML
+}
diff --git a/js/src/util/scrollbar.js b/js/src/util/scrollbar.js
new file mode 100644
index 0000000..5cac7b6
--- /dev/null
+++ b/js/src/util/scrollbar.js
@@ -0,0 +1,114 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): util/scrollBar.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import SelectorEngine from '../dom/selector-engine'
+import Manipulator from '../dom/manipulator'
+import { isElement } from './index'
+
+/**
+ * Constants
+ */
+
+const SELECTOR_FIXED_CONTENT = '.fixed-top, .fixed-bottom, .is-fixed, .sticky-top'
+const SELECTOR_STICKY_CONTENT = '.sticky-top'
+const PROPERTY_PADDING = 'padding-right'
+const PROPERTY_MARGIN = 'margin-right'
+
+/**
+ * Class definition
+ */
+
+class ScrollBarHelper {
+ constructor() {
+ this._element = document.body
+ }
+
+ // Public
+ getWidth() {
+ // https://developer.mozilla.org/en-US/docs/Web/API/Window/innerWidth#usage_notes
+ const documentWidth = document.documentElement.clientWidth
+ return Math.abs(window.innerWidth - documentWidth)
+ }
+
+ hide() {
+ const width = this.getWidth()
+ this._disableOverFlow()
+ // give padding to element to balance the hidden scrollbar width
+ this._setElementAttributes(this._element, PROPERTY_PADDING, calculatedValue => calculatedValue + width)
+ // trick: We adjust positive paddingRight and negative marginRight to sticky-top elements to keep showing fullwidth
+ this._setElementAttributes(SELECTOR_FIXED_CONTENT, PROPERTY_PADDING, calculatedValue => calculatedValue + width)
+ this._setElementAttributes(SELECTOR_STICKY_CONTENT, PROPERTY_MARGIN, calculatedValue => calculatedValue - width)
+ }
+
+ reset() {
+ this._resetElementAttributes(this._element, 'overflow')
+ this._resetElementAttributes(this._element, PROPERTY_PADDING)
+ this._resetElementAttributes(SELECTOR_FIXED_CONTENT, PROPERTY_PADDING)
+ this._resetElementAttributes(SELECTOR_STICKY_CONTENT, PROPERTY_MARGIN)
+ }
+
+ isOverflowing() {
+ return this.getWidth() > 0
+ }
+
+ // Private
+ _disableOverFlow() {
+ this._saveInitialAttribute(this._element, 'overflow')
+ this._element.style.overflow = 'hidden'
+ }
+
+ _setElementAttributes(selector, styleProperty, callback) {
+ const scrollbarWidth = this.getWidth()
+ const manipulationCallBack = element => {
+ if (element !== this._element && window.innerWidth > element.clientWidth + scrollbarWidth) {
+ return
+ }
+
+ this._saveInitialAttribute(element, styleProperty)
+ const calculatedValue = window.getComputedStyle(element).getPropertyValue(styleProperty)
+ element.style.setProperty(styleProperty, `${callback(Number.parseFloat(calculatedValue))}px`)
+ }
+
+ this._applyManipulationCallback(selector, manipulationCallBack)
+ }
+
+ _saveInitialAttribute(element, styleProperty) {
+ const actualValue = element.style.getPropertyValue(styleProperty)
+ if (actualValue) {
+ Manipulator.setDataAttribute(element, styleProperty, actualValue)
+ }
+ }
+
+ _resetElementAttributes(selector, styleProperty) {
+ const manipulationCallBack = element => {
+ const value = Manipulator.getDataAttribute(element, styleProperty)
+ // We only want to remove the property if the value is `null`; the value can also be zero
+ if (value === null) {
+ element.style.removeProperty(styleProperty)
+ return
+ }
+
+ Manipulator.removeDataAttribute(element, styleProperty)
+ element.style.setProperty(styleProperty, value)
+ }
+
+ this._applyManipulationCallback(selector, manipulationCallBack)
+ }
+
+ _applyManipulationCallback(selector, callBack) {
+ if (isElement(selector)) {
+ callBack(selector)
+ return
+ }
+
+ for (const sel of SelectorEngine.find(selector, this._element)) {
+ callBack(sel)
+ }
+ }
+}
+
+export default ScrollBarHelper
diff --git a/js/src/util/swipe.js b/js/src/util/swipe.js
new file mode 100644
index 0000000..7126360
--- /dev/null
+++ b/js/src/util/swipe.js
@@ -0,0 +1,146 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): util/swipe.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import Config from './config'
+import EventHandler from '../dom/event-handler'
+import { execute } from './index'
+
+/**
+ * Constants
+ */
+
+const NAME = 'swipe'
+const EVENT_KEY = '.bs.swipe'
+const EVENT_TOUCHSTART = `touchstart${EVENT_KEY}`
+const EVENT_TOUCHMOVE = `touchmove${EVENT_KEY}`
+const EVENT_TOUCHEND = `touchend${EVENT_KEY}`
+const EVENT_POINTERDOWN = `pointerdown${EVENT_KEY}`
+const EVENT_POINTERUP = `pointerup${EVENT_KEY}`
+const POINTER_TYPE_TOUCH = 'touch'
+const POINTER_TYPE_PEN = 'pen'
+const CLASS_NAME_POINTER_EVENT = 'pointer-event'
+const SWIPE_THRESHOLD = 40
+
+const Default = {
+ endCallback: null,
+ leftCallback: null,
+ rightCallback: null
+}
+
+const DefaultType = {
+ endCallback: '(function|null)',
+ leftCallback: '(function|null)',
+ rightCallback: '(function|null)'
+}
+
+/**
+ * Class definition
+ */
+
+class Swipe extends Config {
+ constructor(element, config) {
+ super()
+ this._element = element
+
+ if (!element || !Swipe.isSupported()) {
+ return
+ }
+
+ this._config = this._getConfig(config)
+ this._deltaX = 0
+ this._supportPointerEvents = Boolean(window.PointerEvent)
+ this._initEvents()
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ dispose() {
+ EventHandler.off(this._element, EVENT_KEY)
+ }
+
+ // Private
+ _start(event) {
+ if (!this._supportPointerEvents) {
+ this._deltaX = event.touches[0].clientX
+
+ return
+ }
+
+ if (this._eventIsPointerPenTouch(event)) {
+ this._deltaX = event.clientX
+ }
+ }
+
+ _end(event) {
+ if (this._eventIsPointerPenTouch(event)) {
+ this._deltaX = event.clientX - this._deltaX
+ }
+
+ this._handleSwipe()
+ execute(this._config.endCallback)
+ }
+
+ _move(event) {
+ this._deltaX = event.touches && event.touches.length > 1 ?
+ 0 :
+ event.touches[0].clientX - this._deltaX
+ }
+
+ _handleSwipe() {
+ const absDeltaX = Math.abs(this._deltaX)
+
+ if (absDeltaX <= SWIPE_THRESHOLD) {
+ return
+ }
+
+ const direction = absDeltaX / this._deltaX
+
+ this._deltaX = 0
+
+ if (!direction) {
+ return
+ }
+
+ execute(direction > 0 ? this._config.rightCallback : this._config.leftCallback)
+ }
+
+ _initEvents() {
+ if (this._supportPointerEvents) {
+ EventHandler.on(this._element, EVENT_POINTERDOWN, event => this._start(event))
+ EventHandler.on(this._element, EVENT_POINTERUP, event => this._end(event))
+
+ this._element.classList.add(CLASS_NAME_POINTER_EVENT)
+ } else {
+ EventHandler.on(this._element, EVENT_TOUCHSTART, event => this._start(event))
+ EventHandler.on(this._element, EVENT_TOUCHMOVE, event => this._move(event))
+ EventHandler.on(this._element, EVENT_TOUCHEND, event => this._end(event))
+ }
+ }
+
+ _eventIsPointerPenTouch(event) {
+ return this._supportPointerEvents && (event.pointerType === POINTER_TYPE_PEN || event.pointerType === POINTER_TYPE_TOUCH)
+ }
+
+ // Static
+ static isSupported() {
+ return 'ontouchstart' in document.documentElement || navigator.maxTouchPoints > 0
+ }
+}
+
+export default Swipe
diff --git a/js/src/util/template-factory.js b/js/src/util/template-factory.js
new file mode 100644
index 0000000..cf402fa
--- /dev/null
+++ b/js/src/util/template-factory.js
@@ -0,0 +1,160 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): util/template-factory.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { DefaultAllowlist, sanitizeHtml } from './sanitizer'
+import { getElement, isElement } from '../util/index'
+import SelectorEngine from '../dom/selector-engine'
+import Config from './config'
+
+/**
+ * Constants
+ */
+
+const NAME = 'TemplateFactory'
+
+const Default = {
+ allowList: DefaultAllowlist,
+ content: {}, // { selector : text , selector2 : text2 , }
+ extraClass: '',
+ html: false,
+ sanitize: true,
+ sanitizeFn: null,
+ template: '<div></div>'
+}
+
+const DefaultType = {
+ allowList: 'object',
+ content: 'object',
+ extraClass: '(string|function)',
+ html: 'boolean',
+ sanitize: 'boolean',
+ sanitizeFn: '(null|function)',
+ template: 'string'
+}
+
+const DefaultContentType = {
+ entry: '(string|element|function|null)',
+ selector: '(string|element)'
+}
+
+/**
+ * Class definition
+ */
+
+class TemplateFactory extends Config {
+ constructor(config) {
+ super()
+ this._config = this._getConfig(config)
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ getContent() {
+ return Object.values(this._config.content)
+ .map(config => this._resolvePossibleFunction(config))
+ .filter(Boolean)
+ }
+
+ hasContent() {
+ return this.getContent().length > 0
+ }
+
+ changeContent(content) {
+ this._checkContent(content)
+ this._config.content = { ...this._config.content, ...content }
+ return this
+ }
+
+ toHtml() {
+ const templateWrapper = document.createElement('div')
+ templateWrapper.innerHTML = this._maybeSanitize(this._config.template)
+
+ for (const [selector, text] of Object.entries(this._config.content)) {
+ this._setContent(templateWrapper, text, selector)
+ }
+
+ const template = templateWrapper.children[0]
+ const extraClass = this._resolvePossibleFunction(this._config.extraClass)
+
+ if (extraClass) {
+ template.classList.add(...extraClass.split(' '))
+ }
+
+ return template
+ }
+
+ // Private
+ _typeCheckConfig(config) {
+ super._typeCheckConfig(config)
+ this._checkContent(config.content)
+ }
+
+ _checkContent(arg) {
+ for (const [selector, content] of Object.entries(arg)) {
+ super._typeCheckConfig({ selector, entry: content }, DefaultContentType)
+ }
+ }
+
+ _setContent(template, content, selector) {
+ const templateElement = SelectorEngine.findOne(selector, template)
+
+ if (!templateElement) {
+ return
+ }
+
+ content = this._resolvePossibleFunction(content)
+
+ if (!content) {
+ templateElement.remove()
+ return
+ }
+
+ if (isElement(content)) {
+ this._putElementInTemplate(getElement(content), templateElement)
+ return
+ }
+
+ if (this._config.html) {
+ templateElement.innerHTML = this._maybeSanitize(content)
+ return
+ }
+
+ templateElement.textContent = content
+ }
+
+ _maybeSanitize(arg) {
+ return this._config.sanitize ? sanitizeHtml(arg, this._config.allowList, this._config.sanitizeFn) : arg
+ }
+
+ _resolvePossibleFunction(arg) {
+ return typeof arg === 'function' ? arg(this) : arg
+ }
+
+ _putElementInTemplate(element, templateElement) {
+ if (this._config.html) {
+ templateElement.innerHTML = ''
+ templateElement.append(element)
+ return
+ }
+
+ templateElement.textContent = element.textContent
+ }
+}
+
+export default TemplateFactory
diff --git a/js/tests/README.md b/js/tests/README.md
new file mode 100644
index 0000000..79d05d4
--- /dev/null
+++ b/js/tests/README.md
@@ -0,0 +1,73 @@
+## How does Bootstrap's test suite work?
+
+Bootstrap uses [Jasmine](https://jasmine.github.io/). Each plugin has a file dedicated to its tests in `tests/unit/<plugin-name>.spec.js`.
+
+- `visual/` contains "visual" tests which are run interactively in real browsers and require manual verification by humans.
+
+To run the unit test suite via [Karma](https://karma-runner.github.io/), run `npm run js-test`.
+To run the unit test suite via [Karma](https://karma-runner.github.io/) and debug, run `npm run js-debug`.
+
+## How do I add a new unit test?
+
+1. Locate and open the file dedicated to the plugin which you need to add tests to (`tests/unit/<plugin-name>.spec.js`).
+2. Review the [Jasmine API Documentation](https://jasmine.github.io/pages/docs_home.html) and use the existing tests as references for how to structure your new tests.
+3. Write the necessary unit test(s) for the new or revised functionality.
+4. Run `npm run js-test` to see the results of your newly-added test(s).
+
+**Note:** Your new unit tests should fail before your changes are applied to the plugin, and should pass after your changes are applied to the plugin.
+
+## What should a unit test look like?
+
+- Each test should have a unique name clearly stating what unit is being tested.
+- Each test should be in the corresponding `describe`.
+- Each test should test only one unit per test, although one test can include several assertions. Create multiple tests for multiple units of functionality.
+- Each test should use [`expect`](https://jasmine.github.io/api/edge/matchers.html) to ensure something is expected.
+- Each test should follow the project's [JavaScript Code Guidelines](https://github.com/twbs/bootstrap/blob/main/.github/CONTRIBUTING.md#js)
+
+## Code coverage
+
+Currently we're aiming for at least 90% test coverage for our code. To ensure your changes meet or exceed this limit, run `npm run js-test-karma` and open the file in `js/coverage/lcov-report/index.html` to see the code coverage for each plugin. See more details when you select a plugin and ensure your change is fully covered by unit tests.
+
+### Example tests
+
+```js
+// Synchronous test
+describe('getInstance', () => {
+ it('should return null if there is no instance', () => {
+ // Make assertion
+ expect(Tab.getInstance(fixtureEl)).toBeNull()
+ })
+
+ it('should return this instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const divEl = fixtureEl.querySelector('div')
+ const tab = new Tab(divEl)
+
+ // Make assertion
+ expect(Tab.getInstance(divEl)).toEqual(tab)
+ })
+})
+
+// Asynchronous test
+it('should show a tooltip without the animation', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"></a>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ animation: false
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tip = document.querySelector('.tooltip')
+
+ expect(tip).not.toBeNull()
+ expect(tip.classList.contains('fade')).toEqual(false)
+ resolve()
+ })
+
+ tooltip.show()
+ })
+})
+```
diff --git a/js/tests/browsers.js b/js/tests/browsers.js
new file mode 100644
index 0000000..8adedc6
--- /dev/null
+++ b/js/tests/browsers.js
@@ -0,0 +1,79 @@
+/* eslint-env node */
+/* eslint-disable camelcase */
+
+const browsers = {
+ safariMac: {
+ base: 'BrowserStack',
+ os: 'OS X',
+ os_version: 'Catalina',
+ browser: 'Safari',
+ browser_version: 'latest'
+ },
+ chromeMac: {
+ base: 'BrowserStack',
+ os: 'OS X',
+ os_version: 'Catalina',
+ browser: 'Chrome',
+ browser_version: 'latest'
+ },
+ firefoxMac: {
+ base: 'BrowserStack',
+ os: 'OS X',
+ os_version: 'Catalina',
+ browser: 'Firefox',
+ browser_version: 'latest'
+ },
+ chromeWin10: {
+ base: 'BrowserStack',
+ os: 'Windows',
+ os_version: '10',
+ browser: 'Chrome',
+ browser_version: '60'
+ },
+ firefoxWin10: {
+ base: 'BrowserStack',
+ os: 'Windows',
+ os_version: '10',
+ browser: 'Firefox',
+ browser_version: '60'
+ },
+ chromeWin10Latest: {
+ base: 'BrowserStack',
+ os: 'Windows',
+ os_version: '10',
+ browser: 'Chrome',
+ browser_version: 'latest'
+ },
+ firefoxWin10Latest: {
+ base: 'BrowserStack',
+ os: 'Windows',
+ os_version: '10',
+ browser: 'Firefox',
+ browser_version: 'latest'
+ },
+ iphone7: {
+ base: 'BrowserStack',
+ os: 'ios',
+ os_version: '12.0',
+ device: 'iPhone 7',
+ real_mobile: true
+ },
+ iphone12: {
+ base: 'BrowserStack',
+ os: 'ios',
+ os_version: '14.0',
+ device: 'iPhone 12',
+ real_mobile: true
+ },
+ pixel2: {
+ base: 'BrowserStack',
+ os: 'android',
+ os_version: '8.0',
+ device: 'Google Pixel 2',
+ real_mobile: true
+ }
+}
+
+module.exports = {
+ browsers
+}
diff --git a/js/tests/helpers/fixture.js b/js/tests/helpers/fixture.js
new file mode 100644
index 0000000..5ad14e1
--- /dev/null
+++ b/js/tests/helpers/fixture.js
@@ -0,0 +1,47 @@
+const fixtureId = 'fixture'
+
+export const getFixture = () => {
+ let fixtureElement = document.getElementById(fixtureId)
+
+ if (!fixtureElement) {
+ fixtureElement = document.createElement('div')
+ fixtureElement.setAttribute('id', fixtureId)
+ fixtureElement.style.position = 'absolute'
+ fixtureElement.style.top = '-10000px'
+ fixtureElement.style.left = '-10000px'
+ fixtureElement.style.width = '10000px'
+ fixtureElement.style.height = '10000px'
+ document.body.append(fixtureElement)
+ }
+
+ return fixtureElement
+}
+
+export const clearFixture = () => {
+ const fixtureElement = getFixture()
+
+ fixtureElement.innerHTML = ''
+}
+
+export const createEvent = (eventName, parameters = {}) => {
+ return new Event(eventName, parameters)
+}
+
+export const jQueryMock = {
+ elements: undefined,
+ fn: {},
+ each(fn) {
+ for (const element of this.elements) {
+ fn.call(element)
+ }
+ }
+}
+
+export const clearBodyAndDocument = () => {
+ const attributes = ['data-bs-padding-right', 'style']
+
+ for (const attribute of attributes) {
+ document.documentElement.removeAttribute(attribute)
+ document.body.removeAttribute(attribute)
+ }
+}
diff --git a/js/tests/integration/bundle-modularity.js b/js/tests/integration/bundle-modularity.js
new file mode 100644
index 0000000..8546141
--- /dev/null
+++ b/js/tests/integration/bundle-modularity.js
@@ -0,0 +1,7 @@
+import Tooltip from '../../dist/tooltip'
+import '../../dist/carousel'
+
+window.addEventListener('load', () => {
+ [].concat(...document.querySelectorAll('[data-bs-toggle="tooltip"]'))
+ .map(tooltipNode => new Tooltip(tooltipNode))
+})
diff --git a/js/tests/integration/bundle.js b/js/tests/integration/bundle.js
new file mode 100644
index 0000000..452088a
--- /dev/null
+++ b/js/tests/integration/bundle.js
@@ -0,0 +1,6 @@
+import { Tooltip } from '../../../dist/js/bootstrap.esm.js'
+
+window.addEventListener('load', () => {
+ [].concat(...document.querySelectorAll('[data-bs-toggle="tooltip"]'))
+ .map(tooltipNode => new Tooltip(tooltipNode))
+})
diff --git a/js/tests/integration/index.html b/js/tests/integration/index.html
new file mode 100644
index 0000000..4c71bad
--- /dev/null
+++ b/js/tests/integration/index.html
@@ -0,0 +1,67 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <!-- Required meta tags -->
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+
+ <!-- Bootstrap CSS -->
+ <link href="../../../dist/css/bootstrap.min.css" rel="stylesheet">
+
+ <title>Hello, world!</title>
+ </head>
+ <body>
+ <div class="container py-4">
+ <h1>Hello, world!</h1>
+
+ <div class="mt-5">
+ <button type="button" class="btn btn-secondary mb-3" data-bs-toggle="tooltip" data-bs-placement="top" title="Tooltip on top">
+ Tooltip on top
+ </button>
+
+ <div id="carouselExampleIndicators" class="carousel slide mt-2" data-bs-ride="carousel">
+ <div class="carousel-indicators">
+ <button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="0" aria-label="Slide 1"></button>
+ <button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="1" class="active" aria-current="true" aria-label="Slide 2"></button>
+ <button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="2" aria-label="Slide 3"></button>
+ </div>
+
+ <div class="carousel-inner">
+ <div class="carousel-item">
+ <img class="d-block w-100" alt="First slide" src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22800%22%20height%3D%22400%22%20preserveAspectRatio%3D%22none%22%20viewBox%3D%220%200%20800%20400%22%3E%3Cpath%20fill%3D%22%23777%22%20d%3D%22M0%200h800v400H0z%22%2F%3E%3Ctext%20x%3D%22285.922%22%20y%3D%22217.7%22%20fill%3D%22%23555%22%20font-family%3D%22Helvetica%2Cmonospace%22%20font-size%3D%2240pt%22%20font-weight%3D%22400%22%3EFirst%20slide%3C%2Ftext%3E%3C%2Fsvg%3E">
+ <div class="carousel-caption d-none d-md-block">
+ <h5>First slide label</h5>
+ <p>Nulla vitae elit libero, a pharetra augue mollis interdum.</p>
+ </div>
+ </div>
+ <div class="carousel-item active">
+ <img class="d-block w-100" alt="Second slide" src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22800%22%20height%3D%22400%22%20preserveAspectRatio%3D%22none%22%20viewBox%3D%220%200%20800%20400%22%3E%3Cpath%20fill%3D%22%23777%22%20d%3D%22M0%200h800v400H0z%22%2F%3E%3Ctext%20x%3D%22285.922%22%20y%3D%22217.7%22%20fill%3D%22%23555%22%20font-family%3D%22Helvetica%2Cmonospace%22%20font-size%3D%2240pt%22%20font-weight%3D%22400%22%3ESecond%20slide%3C%2Ftext%3E%3C%2Fsvg%3E">
+ <div class="carousel-caption d-none d-md-block">
+ <h5>Second slide label</h5>
+ <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
+ </div>
+ </div>
+ <div class="carousel-item">
+ <img class="d-block w-100" alt="Third slide" src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22800%22%20height%3D%22400%22%20preserveAspectRatio%3D%22none%22%20viewBox%3D%220%200%20800%20400%22%3E%3Cpath%20fill%3D%22%23777%22%20d%3D%22M0%200h800v400H0z%22%2F%3E%3Ctext%20x%3D%22285.922%22%20y%3D%22217.7%22%20fill%3D%22%23555%22%20font-family%3D%22Helvetica%2Cmonospace%22%20font-size%3D%2240pt%22%20font-weight%3D%22400%22%3EThird%20slide%3C%2Ftext%3E%3C%2Fsvg%3E">
+ <div class="carousel-caption d-none d-md-block">
+ <h5>Third slide label</h5>
+ <p>Praesent commodo cursus magna, vel scelerisque nisl consectetur.</p>
+ </div>
+ </div>
+ </div>
+
+ <a class="carousel-control-prev" href="#carouselExampleIndicators" role="button" data-bs-slide="prev">
+ <span class="carousel-control-prev-icon" aria-hidden="true"></span>
+ <span class="visually-hidden">Previous</span>
+ </a>
+ <a class="carousel-control-next" href="#carouselExampleIndicators" role="button" data-bs-slide="next">
+ <span class="carousel-control-next-icon" aria-hidden="true"></span>
+ <span class="visually-hidden">Next</span>
+ </a>
+ </div>
+ </div>
+ </div>
+
+ <script src="../../coverage/bundle.js"></script>
+ </body>
+</html>
diff --git a/js/tests/integration/rollup.bundle-modularity.js b/js/tests/integration/rollup.bundle-modularity.js
new file mode 100644
index 0000000..a8670ca
--- /dev/null
+++ b/js/tests/integration/rollup.bundle-modularity.js
@@ -0,0 +1,17 @@
+/* eslint-env node */
+
+const commonjs = require('@rollup/plugin-commonjs')
+const configRollup = require('./rollup.bundle')
+
+const config = {
+ ...configRollup,
+ input: 'js/tests/integration/bundle-modularity.js',
+ output: {
+ file: 'js/coverage/bundle-modularity.js',
+ format: 'iife'
+ }
+}
+
+config.plugins.unshift(commonjs())
+
+module.exports = config
diff --git a/js/tests/integration/rollup.bundle.js b/js/tests/integration/rollup.bundle.js
new file mode 100644
index 0000000..caddcab
--- /dev/null
+++ b/js/tests/integration/rollup.bundle.js
@@ -0,0 +1,24 @@
+/* eslint-env node */
+
+const { babel } = require('@rollup/plugin-babel')
+const { nodeResolve } = require('@rollup/plugin-node-resolve')
+const replace = require('@rollup/plugin-replace')
+
+module.exports = {
+ input: 'js/tests/integration/bundle.js',
+ output: {
+ file: 'js/coverage/bundle.js',
+ format: 'iife'
+ },
+ plugins: [
+ replace({
+ 'process.env.NODE_ENV': '"production"',
+ preventAssignment: true
+ }),
+ nodeResolve(),
+ babel({
+ exclude: 'node_modules/**',
+ babelHelpers: 'bundled'
+ })
+ ]
+}
diff --git a/js/tests/karma.conf.js b/js/tests/karma.conf.js
new file mode 100644
index 0000000..6636ff1
--- /dev/null
+++ b/js/tests/karma.conf.js
@@ -0,0 +1,171 @@
+/* eslint-env node */
+
+'use strict'
+
+const path = require('node:path')
+const ip = require('ip')
+const { babel } = require('@rollup/plugin-babel')
+const istanbul = require('rollup-plugin-istanbul')
+const { nodeResolve } = require('@rollup/plugin-node-resolve')
+const replace = require('@rollup/plugin-replace')
+const { browsers } = require('./browsers')
+
+const ENV = process.env
+const BROWSERSTACK = Boolean(ENV.BROWSERSTACK)
+const DEBUG = Boolean(ENV.DEBUG)
+const JQUERY_TEST = Boolean(ENV.JQUERY)
+
+const frameworks = [
+ 'jasmine'
+]
+
+const plugins = [
+ 'karma-jasmine',
+ 'karma-rollup-preprocessor'
+]
+
+const reporters = ['dots']
+
+const detectBrowsers = {
+ usePhantomJS: false,
+ postDetection(availableBrowser) {
+ // On CI just use Chrome
+ if (ENV.CI === true) {
+ return ['ChromeHeadless']
+ }
+
+ if (availableBrowser.includes('Chrome')) {
+ return DEBUG ? ['Chrome'] : ['ChromeHeadless']
+ }
+
+ if (availableBrowser.includes('Chromium')) {
+ return DEBUG ? ['Chromium'] : ['ChromiumHeadless']
+ }
+
+ if (availableBrowser.includes('Firefox')) {
+ return DEBUG ? ['Firefox'] : ['FirefoxHeadless']
+ }
+
+ throw new Error('Please install Chrome, Chromium or Firefox')
+ }
+}
+
+const config = {
+ basePath: '../..',
+ port: 9876,
+ colors: true,
+ autoWatch: false,
+ singleRun: true,
+ concurrency: Number.POSITIVE_INFINITY,
+ client: {
+ clearContext: false
+ },
+ files: [
+ 'node_modules/hammer-simulator/index.js',
+ {
+ pattern: 'js/tests/unit/**/!(jquery).spec.js',
+ watched: !BROWSERSTACK
+ }
+ ],
+ preprocessors: {
+ 'js/tests/unit/**/*.spec.js': ['rollup']
+ },
+ rollupPreprocessor: {
+ plugins: [
+ replace({
+ 'process.env.NODE_ENV': '"dev"',
+ preventAssignment: true
+ }),
+ istanbul({
+ exclude: [
+ 'node_modules/**',
+ 'js/tests/unit/**/*.spec.js',
+ 'js/tests/helpers/**/*.js'
+ ]
+ }),
+ babel({
+ // Only transpile our source code
+ exclude: 'node_modules/**',
+ // Inline the required helpers in each file
+ babelHelpers: 'inline'
+ }),
+ nodeResolve()
+ ],
+ output: {
+ format: 'iife',
+ name: 'bootstrapTest',
+ sourcemap: 'inline',
+ generatedCode: 'es2015'
+ }
+ }
+}
+
+if (BROWSERSTACK) {
+ config.hostname = ip.address()
+ config.browserStack = {
+ username: ENV.BROWSER_STACK_USERNAME,
+ accessKey: ENV.BROWSER_STACK_ACCESS_KEY,
+ build: `bootstrap-${ENV.GITHUB_SHA ? ENV.GITHUB_SHA.slice(0, 7) + '-' : ''}${new Date().toISOString()}`,
+ project: 'Bootstrap',
+ retryLimit: 2
+ }
+ plugins.push('karma-browserstack-launcher', 'karma-jasmine-html-reporter')
+ config.customLaunchers = browsers
+ config.browsers = Object.keys(browsers)
+ reporters.push('BrowserStack', 'kjhtml')
+} else if (JQUERY_TEST) {
+ frameworks.push('detectBrowsers')
+ plugins.push(
+ 'karma-chrome-launcher',
+ 'karma-firefox-launcher',
+ 'karma-detect-browsers'
+ )
+ config.detectBrowsers = detectBrowsers
+ config.files = [
+ 'node_modules/jquery/dist/jquery.slim.min.js',
+ {
+ pattern: 'js/tests/unit/jquery.spec.js',
+ watched: false
+ }
+ ]
+} else {
+ frameworks.push('detectBrowsers')
+ plugins.push(
+ 'karma-chrome-launcher',
+ 'karma-firefox-launcher',
+ 'karma-detect-browsers',
+ 'karma-coverage-istanbul-reporter'
+ )
+ reporters.push('coverage-istanbul')
+ config.detectBrowsers = detectBrowsers
+ config.coverageIstanbulReporter = {
+ dir: path.resolve(__dirname, '../coverage/'),
+ reports: ['lcov', 'text-summary'],
+ thresholds: {
+ emitWarning: false,
+ global: {
+ statements: 90,
+ branches: 89,
+ functions: 90,
+ lines: 90
+ }
+ }
+ }
+
+ if (DEBUG) {
+ config.hostname = ip.address()
+ plugins.push('karma-jasmine-html-reporter')
+ reporters.push('kjhtml')
+ config.singleRun = false
+ config.autoWatch = true
+ }
+}
+
+config.frameworks = frameworks
+config.plugins = plugins
+config.reporters = reporters
+
+module.exports = karmaConfig => {
+ config.logLevel = karmaConfig.LOG_ERROR
+ karmaConfig.set(config)
+}
diff --git a/js/tests/unit/.eslintrc.json b/js/tests/unit/.eslintrc.json
new file mode 100644
index 0000000..6362a1a
--- /dev/null
+++ b/js/tests/unit/.eslintrc.json
@@ -0,0 +1,13 @@
+{
+ "extends": [
+ "../../../.eslintrc.json"
+ ],
+ "env": {
+ "jasmine": true
+ },
+ "rules": {
+ "unicorn/consistent-function-scoping": "off",
+ "unicorn/no-useless-undefined": "off",
+ "unicorn/prefer-add-event-listener": "off"
+ }
+}
diff --git a/js/tests/unit/alert.spec.js b/js/tests/unit/alert.spec.js
new file mode 100644
index 0000000..d3740c9
--- /dev/null
+++ b/js/tests/unit/alert.spec.js
@@ -0,0 +1,259 @@
+import Alert from '../../src/alert'
+import { getTransitionDurationFromElement } from '../../src/util/index'
+import { clearFixture, getFixture, jQueryMock } from '../helpers/fixture'
+
+describe('Alert', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ it('should take care of element either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = '<div class="alert"></div>'
+
+ const alertEl = fixtureEl.querySelector('.alert')
+ const alertBySelector = new Alert('.alert')
+ const alertByElement = new Alert(alertEl)
+
+ expect(alertBySelector._element).toEqual(alertEl)
+ expect(alertByElement._element).toEqual(alertEl)
+ })
+
+ it('should return version', () => {
+ expect(Alert.VERSION).toEqual(jasmine.any(String))
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(Alert.DATA_KEY).toEqual('bs.alert')
+ })
+ })
+
+ describe('data-api', () => {
+ it('should close an alert without instantiating it manually', () => {
+ fixtureEl.innerHTML = [
+ '<div class="alert">',
+ ' <button type="button" data-bs-dismiss="alert">x</button>',
+ '</div>'
+ ].join('')
+
+ const button = document.querySelector('button')
+
+ button.click()
+ expect(document.querySelectorAll('.alert')).toHaveSize(0)
+ })
+
+ it('should close an alert without instantiating it manually with the parent selector', () => {
+ fixtureEl.innerHTML = [
+ '<div class="alert">',
+ ' <button type="button" data-bs-target=".alert" data-bs-dismiss="alert">x</button>',
+ '</div>'
+ ].join('')
+
+ const button = document.querySelector('button')
+
+ button.click()
+ expect(document.querySelectorAll('.alert')).toHaveSize(0)
+ })
+ })
+
+ describe('close', () => {
+ it('should close an alert', () => {
+ return new Promise(resolve => {
+ const spy = jasmine.createSpy('spy', getTransitionDurationFromElement)
+ fixtureEl.innerHTML = '<div class="alert"></div>'
+
+ const alertEl = document.querySelector('.alert')
+ const alert = new Alert(alertEl)
+
+ alertEl.addEventListener('closed.bs.alert', () => {
+ expect(document.querySelectorAll('.alert')).toHaveSize(0)
+ expect(spy).not.toHaveBeenCalled()
+ resolve()
+ })
+
+ alert.close()
+ })
+ })
+
+ it('should close alert with fade class', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="alert fade"></div>'
+
+ const alertEl = document.querySelector('.alert')
+ const alert = new Alert(alertEl)
+
+ alertEl.addEventListener('transitionend', () => {
+ expect().nothing()
+ })
+
+ alertEl.addEventListener('closed.bs.alert', () => {
+ expect(document.querySelectorAll('.alert')).toHaveSize(0)
+ resolve()
+ })
+
+ alert.close()
+ })
+ })
+
+ it('should not remove alert if close event is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '<div class="alert"></div>'
+
+ const getAlert = () => document.querySelector('.alert')
+ const alertEl = getAlert()
+ const alert = new Alert(alertEl)
+
+ alertEl.addEventListener('close.bs.alert', event => {
+ event.preventDefault()
+ setTimeout(() => {
+ expect(getAlert()).not.toBeNull()
+ resolve()
+ }, 10)
+ })
+
+ alertEl.addEventListener('closed.bs.alert', () => {
+ reject(new Error('should not fire closed event'))
+ })
+
+ alert.close()
+ })
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose an alert', () => {
+ fixtureEl.innerHTML = '<div class="alert"></div>'
+
+ const alertEl = document.querySelector('.alert')
+ const alert = new Alert(alertEl)
+
+ expect(Alert.getInstance(alertEl)).not.toBeNull()
+
+ alert.dispose()
+
+ expect(Alert.getInstance(alertEl)).toBeNull()
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should handle config passed and toggle existing alert', () => {
+ fixtureEl.innerHTML = '<div class="alert"></div>'
+
+ const alertEl = fixtureEl.querySelector('.alert')
+ const alert = new Alert(alertEl)
+
+ const spy = spyOn(alert, 'close')
+
+ jQueryMock.fn.alert = Alert.jQueryInterface
+ jQueryMock.elements = [alertEl]
+
+ jQueryMock.fn.alert.call(jQueryMock, 'close')
+
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should create new alert instance and call close', () => {
+ fixtureEl.innerHTML = '<div class="alert"></div>'
+
+ const alertEl = fixtureEl.querySelector('.alert')
+
+ jQueryMock.fn.alert = Alert.jQueryInterface
+ jQueryMock.elements = [alertEl]
+
+ expect(Alert.getInstance(alertEl)).toBeNull()
+ jQueryMock.fn.alert.call(jQueryMock, 'close')
+
+ expect(fixtureEl.querySelector('.alert')).toBeNull()
+ })
+
+ it('should just create an alert instance without calling close', () => {
+ fixtureEl.innerHTML = '<div class="alert"></div>'
+
+ const alertEl = fixtureEl.querySelector('.alert')
+
+ jQueryMock.fn.alert = Alert.jQueryInterface
+ jQueryMock.elements = [alertEl]
+
+ jQueryMock.fn.alert.call(jQueryMock)
+
+ expect(Alert.getInstance(alertEl)).not.toBeNull()
+ expect(fixtureEl.querySelector('.alert')).not.toBeNull()
+ })
+
+ it('should throw an error on undefined method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.alert = Alert.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.alert.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+
+ it('should throw an error on protected method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = '_getConfig'
+
+ jQueryMock.fn.alert = Alert.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.alert.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return alert instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const alert = new Alert(div)
+
+ expect(Alert.getInstance(div)).toEqual(alert)
+ expect(Alert.getInstance(div)).toBeInstanceOf(Alert)
+ })
+
+ it('should return null when there is no alert instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Alert.getInstance(div)).toBeNull()
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return alert instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const alert = new Alert(div)
+
+ expect(Alert.getOrCreateInstance(div)).toEqual(alert)
+ expect(Alert.getInstance(div)).toEqual(Alert.getOrCreateInstance(div, {}))
+ expect(Alert.getOrCreateInstance(div)).toBeInstanceOf(Alert)
+ })
+
+ it('should return new instance when there is no alert instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Alert.getInstance(div)).toBeNull()
+ expect(Alert.getOrCreateInstance(div)).toBeInstanceOf(Alert)
+ })
+ })
+})
diff --git a/js/tests/unit/base-component.spec.js b/js/tests/unit/base-component.spec.js
new file mode 100644
index 0000000..b2352d6
--- /dev/null
+++ b/js/tests/unit/base-component.spec.js
@@ -0,0 +1,168 @@
+import BaseComponent from '../../src/base-component'
+import { clearFixture, getFixture } from '../helpers/fixture'
+import EventHandler from '../../src/dom/event-handler'
+import { noop } from '../../src/util'
+
+class DummyClass extends BaseComponent {
+ constructor(element) {
+ super(element)
+
+ EventHandler.on(this._element, `click${DummyClass.EVENT_KEY}`, noop)
+ }
+
+ static get NAME() {
+ return 'dummy'
+ }
+}
+
+describe('Base Component', () => {
+ let fixtureEl
+ const name = 'dummy'
+ let element
+ let instance
+ const createInstance = () => {
+ fixtureEl.innerHTML = '<div id="foo"></div>'
+ element = fixtureEl.querySelector('#foo')
+ instance = new DummyClass(element)
+ }
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('Static Methods', () => {
+ describe('VERSION', () => {
+ it('should return version', () => {
+ expect(DummyClass.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(DummyClass.DATA_KEY).toEqual(`bs.${name}`)
+ })
+ })
+
+ describe('NAME', () => {
+ it('should throw an Error if it is not initialized', () => {
+ expect(() => {
+ // eslint-disable-next-line no-unused-expressions
+ BaseComponent.NAME
+ }).toThrowError(Error)
+ })
+
+ it('should return plugin NAME', () => {
+ expect(DummyClass.NAME).toEqual(name)
+ })
+ })
+
+ describe('EVENT_KEY', () => {
+ it('should return plugin event key', () => {
+ expect(DummyClass.EVENT_KEY).toEqual(`.bs.${name}`)
+ })
+ })
+ })
+
+ describe('Public Methods', () => {
+ describe('constructor', () => {
+ it('should accept element, either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = [
+ '<div id="foo"></div>',
+ '<div id="bar"></div>'
+ ].join('')
+
+ const el = fixtureEl.querySelector('#foo')
+ const elInstance = new DummyClass(el)
+ const selectorInstance = new DummyClass('#bar')
+
+ expect(elInstance._element).toEqual(el)
+ expect(selectorInstance._element).toEqual(fixtureEl.querySelector('#bar'))
+ })
+
+ it('should not initialize and add element record to Data (caching), if argument `element` is not an HTML element', () => {
+ fixtureEl.innerHTML = ''
+
+ const el = fixtureEl.querySelector('#foo')
+ const elInstance = new DummyClass(el)
+ const selectorInstance = new DummyClass('#bar')
+
+ expect(elInstance._element).not.toBeDefined()
+ expect(selectorInstance._element).not.toBeDefined()
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose an component', () => {
+ createInstance()
+ expect(DummyClass.getInstance(element)).not.toBeNull()
+
+ instance.dispose()
+
+ expect(DummyClass.getInstance(element)).toBeNull()
+ expect(instance._element).toBeNull()
+ })
+
+ it('should de-register element event listeners', () => {
+ createInstance()
+ const spy = spyOn(EventHandler, 'off')
+
+ instance.dispose()
+
+ expect(spy).toHaveBeenCalledWith(element, DummyClass.EVENT_KEY)
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return an instance', () => {
+ createInstance()
+
+ expect(DummyClass.getInstance(element)).toEqual(instance)
+ expect(DummyClass.getInstance(element)).toBeInstanceOf(DummyClass)
+ })
+
+ it('should accept element, either passed as a CSS selector, jQuery element, or DOM element', () => {
+ createInstance()
+
+ expect(DummyClass.getInstance('#foo')).toEqual(instance)
+ expect(DummyClass.getInstance(element)).toEqual(instance)
+
+ const fakejQueryObject = {
+ 0: element,
+ jquery: 'foo'
+ }
+
+ expect(DummyClass.getInstance(fakejQueryObject)).toEqual(instance)
+ })
+
+ it('should return null when there is no instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(DummyClass.getInstance(div)).toBeNull()
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return an instance', () => {
+ createInstance()
+
+ expect(DummyClass.getOrCreateInstance(element)).toEqual(instance)
+ expect(DummyClass.getInstance(element)).toEqual(DummyClass.getOrCreateInstance(element, {}))
+ expect(DummyClass.getOrCreateInstance(element)).toBeInstanceOf(DummyClass)
+ })
+
+ it('should return new instance when there is no alert instance', () => {
+ fixtureEl.innerHTML = '<div id="foo"></div>'
+ element = fixtureEl.querySelector('#foo')
+
+ expect(DummyClass.getInstance(element)).toBeNull()
+ expect(DummyClass.getOrCreateInstance(element)).toBeInstanceOf(DummyClass)
+ })
+ })
+ })
+})
diff --git a/js/tests/unit/button.spec.js b/js/tests/unit/button.spec.js
new file mode 100644
index 0000000..09ed17e
--- /dev/null
+++ b/js/tests/unit/button.spec.js
@@ -0,0 +1,183 @@
+import Button from '../../src/button'
+import { getFixture, clearFixture, jQueryMock } from '../helpers/fixture'
+
+describe('Button', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ it('should take care of element either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = '<button data-bs-toggle="button">Placeholder</button>'
+ const buttonEl = fixtureEl.querySelector('[data-bs-toggle="button"]')
+ const buttonBySelector = new Button('[data-bs-toggle="button"]')
+ const buttonByElement = new Button(buttonEl)
+
+ expect(buttonBySelector._element).toEqual(buttonEl)
+ expect(buttonByElement._element).toEqual(buttonEl)
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Button.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(Button.DATA_KEY).toEqual('bs.button')
+ })
+ })
+
+ describe('data-api', () => {
+ it('should toggle active class on click', () => {
+ fixtureEl.innerHTML = [
+ '<button class="btn" data-bs-toggle="button">btn</button>',
+ '<button class="btn testParent" data-bs-toggle="button"><div class="test"></div></button>'
+ ].join('')
+
+ const btn = fixtureEl.querySelector('.btn')
+ const divTest = fixtureEl.querySelector('.test')
+ const btnTestParent = fixtureEl.querySelector('.testParent')
+
+ expect(btn).not.toHaveClass('active')
+
+ btn.click()
+
+ expect(btn).toHaveClass('active')
+
+ btn.click()
+
+ expect(btn).not.toHaveClass('active')
+
+ divTest.click()
+
+ expect(btnTestParent).toHaveClass('active')
+ })
+ })
+
+ describe('toggle', () => {
+ it('should toggle aria-pressed', () => {
+ fixtureEl.innerHTML = '<button class="btn" data-bs-toggle="button" aria-pressed="false"></button>'
+
+ const btnEl = fixtureEl.querySelector('.btn')
+ const button = new Button(btnEl)
+
+ expect(btnEl.getAttribute('aria-pressed')).toEqual('false')
+ expect(btnEl).not.toHaveClass('active')
+
+ button.toggle()
+
+ expect(btnEl.getAttribute('aria-pressed')).toEqual('true')
+ expect(btnEl).toHaveClass('active')
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose a button', () => {
+ fixtureEl.innerHTML = '<button class="btn" data-bs-toggle="button"></button>'
+
+ const btnEl = fixtureEl.querySelector('.btn')
+ const button = new Button(btnEl)
+
+ expect(Button.getInstance(btnEl)).not.toBeNull()
+
+ button.dispose()
+
+ expect(Button.getInstance(btnEl)).toBeNull()
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should handle config passed and toggle existing button', () => {
+ fixtureEl.innerHTML = '<button class="btn" data-bs-toggle="button"></button>'
+
+ const btnEl = fixtureEl.querySelector('.btn')
+ const button = new Button(btnEl)
+
+ const spy = spyOn(button, 'toggle')
+
+ jQueryMock.fn.button = Button.jQueryInterface
+ jQueryMock.elements = [btnEl]
+
+ jQueryMock.fn.button.call(jQueryMock, 'toggle')
+
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should create new button instance and call toggle', () => {
+ fixtureEl.innerHTML = '<button class="btn" data-bs-toggle="button"></button>'
+
+ const btnEl = fixtureEl.querySelector('.btn')
+
+ jQueryMock.fn.button = Button.jQueryInterface
+ jQueryMock.elements = [btnEl]
+
+ jQueryMock.fn.button.call(jQueryMock, 'toggle')
+
+ expect(Button.getInstance(btnEl)).not.toBeNull()
+ expect(btnEl).toHaveClass('active')
+ })
+
+ it('should just create a button instance without calling toggle', () => {
+ fixtureEl.innerHTML = '<button class="btn" data-bs-toggle="button"></button>'
+
+ const btnEl = fixtureEl.querySelector('.btn')
+
+ jQueryMock.fn.button = Button.jQueryInterface
+ jQueryMock.elements = [btnEl]
+
+ jQueryMock.fn.button.call(jQueryMock)
+
+ expect(Button.getInstance(btnEl)).not.toBeNull()
+ expect(btnEl).not.toHaveClass('active')
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return button instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const button = new Button(div)
+
+ expect(Button.getInstance(div)).toEqual(button)
+ expect(Button.getInstance(div)).toBeInstanceOf(Button)
+ })
+
+ it('should return null when there is no button instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Button.getInstance(div)).toBeNull()
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return button instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const button = new Button(div)
+
+ expect(Button.getOrCreateInstance(div)).toEqual(button)
+ expect(Button.getInstance(div)).toEqual(Button.getOrCreateInstance(div, {}))
+ expect(Button.getOrCreateInstance(div)).toBeInstanceOf(Button)
+ })
+
+ it('should return new instance when there is no button instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Button.getInstance(div)).toBeNull()
+ expect(Button.getOrCreateInstance(div)).toBeInstanceOf(Button)
+ })
+ })
+})
diff --git a/js/tests/unit/carousel.spec.js b/js/tests/unit/carousel.spec.js
new file mode 100644
index 0000000..d951bd5
--- /dev/null
+++ b/js/tests/unit/carousel.spec.js
@@ -0,0 +1,1570 @@
+import Carousel from '../../src/carousel'
+import EventHandler from '../../src/dom/event-handler'
+import { clearFixture, createEvent, getFixture, jQueryMock } from '../helpers/fixture'
+import { isRTL, noop } from '../../src/util/index'
+import Swipe from '../../src/util/swipe'
+
+describe('Carousel', () => {
+ const { Simulator, PointerEvent } = window
+ const originWinPointerEvent = PointerEvent
+ const supportPointerEvent = Boolean(PointerEvent)
+
+ const cssStyleCarousel = '.carousel.pointer-event { touch-action: none; }'
+
+ const stylesCarousel = document.createElement('style')
+ stylesCarousel.type = 'text/css'
+ stylesCarousel.append(document.createTextNode(cssStyleCarousel))
+
+ const clearPointerEvents = () => {
+ window.PointerEvent = null
+ }
+
+ const restorePointerEvents = () => {
+ window.PointerEvent = originWinPointerEvent
+ }
+
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Carousel.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin default config', () => {
+ expect(Carousel.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(Carousel.DATA_KEY).toEqual('bs.carousel')
+ })
+ })
+
+ describe('constructor', () => {
+ it('should take care of element either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carouselBySelector = new Carousel('#myCarousel')
+ const carouselByElement = new Carousel(carouselEl)
+
+ expect(carouselBySelector._element).toEqual(carouselEl)
+ expect(carouselByElement._element).toEqual(carouselEl)
+ })
+
+ it('should start cycling if `ride`===`carousel`', () => {
+ fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide" data-bs-ride="carousel"></div>'
+
+ const carousel = new Carousel('#myCarousel')
+ expect(carousel._interval).not.toBeNull()
+ })
+
+ it('should not start cycling if `ride`!==`carousel`', () => {
+ fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide" data-bs-ride="true"></div>'
+
+ const carousel = new Carousel('#myCarousel')
+ expect(carousel._interval).toBeNull()
+ })
+
+ it('should go to next item if right arrow key is pressed', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div id="item2" class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {
+ keyboard: true
+ })
+
+ const spy = spyOn(carousel, '_keydown').and.callThrough()
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item2'))
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'ArrowRight'
+
+ carouselEl.dispatchEvent(keydown)
+ })
+ })
+
+ it('should ignore keyboard events if data-bs-keyboard=false', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide" data-bs-keyboard="false">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div id="item2" class="carousel-item">item 2</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const spy = spyOn(EventHandler, 'trigger').and.callThrough()
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ // eslint-disable-next-line no-new
+ new Carousel('#myCarousel')
+ expect(spy).not.toHaveBeenCalledWith(carouselEl, 'keydown.bs.carousel', jasmine.any(Function))
+ })
+
+ it('should ignore mouse events if data-bs-pause=false', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide" data-bs-pause="false">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div id="item2" class="carousel-item">item 2</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const spy = spyOn(EventHandler, 'trigger').and.callThrough()
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ // eslint-disable-next-line no-new
+ new Carousel('#myCarousel')
+ expect(spy).not.toHaveBeenCalledWith(carouselEl, 'hover.bs.carousel', jasmine.any(Function))
+ })
+
+ it('should go to previous item if left arrow key is pressed', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div id="item1" class="carousel-item">item 1</div>',
+ ' <div class="carousel-item active">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {
+ keyboard: true
+ })
+
+ const spy = spyOn(carousel, '_keydown').and.callThrough()
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item1'))
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'ArrowLeft'
+
+ carouselEl.dispatchEvent(keydown)
+ })
+ })
+
+ it('should not prevent keydown if key is not ARROW_LEFT or ARROW_RIGHT', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {
+ keyboard: true
+ })
+
+ const spy = spyOn(carousel, '_keydown').and.callThrough()
+
+ carouselEl.addEventListener('keydown', event => {
+ expect(spy).toHaveBeenCalled()
+ expect(event.defaultPrevented).toBeFalse()
+ resolve()
+ })
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'ArrowDown'
+
+ carouselEl.dispatchEvent(keydown)
+ })
+ })
+
+ it('should ignore keyboard events within <input>s and <textarea>s', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">',
+ ' <input type="text">',
+ ' <textarea></textarea>',
+ ' </div>',
+ ' <div class="carousel-item"></div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const input = fixtureEl.querySelector('input')
+ const textarea = fixtureEl.querySelector('textarea')
+ const carousel = new Carousel(carouselEl, {
+ keyboard: true
+ })
+
+ const spyKeydown = spyOn(carousel, '_keydown').and.callThrough()
+ const spySlide = spyOn(carousel, '_slide')
+
+ const keydown = createEvent('keydown', { bubbles: true, cancelable: true })
+ keydown.key = 'ArrowRight'
+ Object.defineProperty(keydown, 'target', {
+ value: input,
+ writable: true,
+ configurable: true
+ })
+
+ input.dispatchEvent(keydown)
+
+ expect(spyKeydown).toHaveBeenCalled()
+ expect(spySlide).not.toHaveBeenCalled()
+
+ spyKeydown.calls.reset()
+ spySlide.calls.reset()
+
+ Object.defineProperty(keydown, 'target', {
+ value: textarea
+ })
+ textarea.dispatchEvent(keydown)
+
+ expect(spyKeydown).toHaveBeenCalled()
+ expect(spySlide).not.toHaveBeenCalled()
+ })
+
+ it('should not slide if arrow key is pressed and carousel is sliding', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const carousel = new Carousel(carouselEl, {})
+
+ const spy = spyOn(EventHandler, 'trigger')
+
+ carousel._isSliding = true
+
+ for (const key of ['ArrowLeft', 'ArrowRight']) {
+ const keydown = createEvent('keydown')
+ keydown.key = key
+
+ carouselEl.dispatchEvent(keydown)
+ }
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should wrap around from end to start when wrap option is true', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div id="one" class="carousel-item active"></div>',
+ ' <div id="two" class="carousel-item"></div>',
+ ' <div id="three" class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, { wrap: true })
+ const getActiveId = () => carouselEl.querySelector('.carousel-item.active').getAttribute('id')
+
+ carouselEl.addEventListener('slid.bs.carousel', event => {
+ const activeId = getActiveId()
+
+ if (activeId === 'two') {
+ carousel.next()
+ return
+ }
+
+ if (activeId === 'three') {
+ carousel.next()
+ return
+ }
+
+ if (activeId === 'one') {
+ // carousel wrapped around and slid from 3rd to 1st slide
+ expect(activeId).toEqual('one')
+ expect(event.from + 1).toEqual(3)
+ resolve()
+ }
+ })
+
+ carousel.next()
+ })
+ })
+
+ it('should stay at the start when the prev method is called and wrap is false', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div id="one" class="carousel-item active"></div>',
+ ' <div id="two" class="carousel-item"></div>',
+ ' <div id="three" class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const firstElement = fixtureEl.querySelector('#one')
+ const carousel = new Carousel(carouselEl, { wrap: false })
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ reject(new Error('carousel slid when it should not have slid'))
+ })
+
+ carousel.prev()
+
+ setTimeout(() => {
+ expect(firstElement).toHaveClass('active')
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should not add touch event listeners if touch = false', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+
+ const spy = spyOn(Carousel.prototype, '_addTouchEventListeners')
+
+ const carousel = new Carousel(carouselEl, {
+ touch: false
+ })
+
+ expect(spy).not.toHaveBeenCalled()
+ expect(carousel._swipeHelper).toBeNull()
+ })
+
+ it('should not add touch event listeners if touch supported = false', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ spyOn(Swipe, 'isSupported').and.returnValue(false)
+
+ const carousel = new Carousel(carouselEl)
+ EventHandler.off(carouselEl, Carousel.EVENT_KEY)
+
+ const spy = spyOn(carousel, '_addTouchEventListeners')
+
+ carousel._addEventListeners()
+
+ expect(spy).not.toHaveBeenCalled()
+ expect(carousel._swipeHelper).toBeNull()
+ })
+
+ it('should add touch event listeners by default', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+
+ spyOn(Carousel.prototype, '_addTouchEventListeners')
+
+ // Headless browser does not support touch events, so need to fake it
+ // to test that touch events are add properly.
+ document.documentElement.ontouchstart = noop
+ const carousel = new Carousel(carouselEl)
+
+ expect(carousel._addTouchEventListeners).toHaveBeenCalled()
+ })
+
+ it('should allow swiperight and call _slide (prev) with pointer events', () => {
+ return new Promise(resolve => {
+ if (!supportPointerEvent) {
+ expect().nothing()
+ resolve()
+ return
+ }
+
+ document.documentElement.ontouchstart = noop
+ document.head.append(stylesCarousel)
+ Simulator.setType('pointer')
+
+ fixtureEl.innerHTML = [
+ '<div class="carousel">',
+ ' <div class="carousel-inner">',
+ ' <div id="item" class="carousel-item">',
+ ' <img alt="">',
+ ' </div>',
+ ' <div class="carousel-item active">',
+ ' <img alt="">',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const item = fixtureEl.querySelector('#item')
+ const carousel = new Carousel(carouselEl)
+
+ const spy = spyOn(carousel, '_slide').and.callThrough()
+
+ carouselEl.addEventListener('slid.bs.carousel', event => {
+ expect(item).toHaveClass('active')
+ expect(spy).toHaveBeenCalledWith('prev')
+ expect(event.direction).toEqual('right')
+ stylesCarousel.remove()
+ delete document.documentElement.ontouchstart
+ resolve()
+ })
+
+ Simulator.gestures.swipe(carouselEl, {
+ deltaX: 300,
+ deltaY: 0
+ })
+ })
+ })
+
+ it('should allow swipeleft and call next with pointer events', () => {
+ return new Promise(resolve => {
+ if (!supportPointerEvent) {
+ expect().nothing()
+ resolve()
+ return
+ }
+
+ document.documentElement.ontouchstart = noop
+ document.head.append(stylesCarousel)
+ Simulator.setType('pointer')
+
+ fixtureEl.innerHTML = [
+ '<div class="carousel">',
+ ' <div class="carousel-inner">',
+ ' <div id="item" class="carousel-item active">',
+ ' <img alt="">',
+ ' </div>',
+ ' <div class="carousel-item">',
+ ' <img alt="">',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const item = fixtureEl.querySelector('#item')
+ const carousel = new Carousel(carouselEl)
+
+ const spy = spyOn(carousel, '_slide').and.callThrough()
+
+ carouselEl.addEventListener('slid.bs.carousel', event => {
+ expect(item).not.toHaveClass('active')
+ expect(spy).toHaveBeenCalledWith('next')
+ expect(event.direction).toEqual('left')
+ stylesCarousel.remove()
+ delete document.documentElement.ontouchstart
+ resolve()
+ })
+
+ Simulator.gestures.swipe(carouselEl, {
+ pos: [300, 10],
+ deltaX: -300,
+ deltaY: 0
+ })
+ })
+ })
+
+ it('should allow swiperight and call _slide (prev) with touch events', () => {
+ return new Promise(resolve => {
+ Simulator.setType('touch')
+ clearPointerEvents()
+ document.documentElement.ontouchstart = noop
+
+ fixtureEl.innerHTML = [
+ '<div class="carousel">',
+ ' <div class="carousel-inner">',
+ ' <div id="item" class="carousel-item">',
+ ' <img alt="">',
+ ' </div>',
+ ' <div class="carousel-item active">',
+ ' <img alt="">',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const item = fixtureEl.querySelector('#item')
+ const carousel = new Carousel(carouselEl)
+
+ const spy = spyOn(carousel, '_slide').and.callThrough()
+
+ carouselEl.addEventListener('slid.bs.carousel', event => {
+ expect(item).toHaveClass('active')
+ expect(spy).toHaveBeenCalledWith('prev')
+ expect(event.direction).toEqual('right')
+ delete document.documentElement.ontouchstart
+ restorePointerEvents()
+ resolve()
+ })
+
+ Simulator.gestures.swipe(carouselEl, {
+ deltaX: 300,
+ deltaY: 0
+ })
+ })
+ })
+
+ it('should allow swipeleft and call _slide (next) with touch events', () => {
+ return new Promise(resolve => {
+ Simulator.setType('touch')
+ clearPointerEvents()
+ document.documentElement.ontouchstart = noop
+
+ fixtureEl.innerHTML = [
+ '<div class="carousel">',
+ ' <div class="carousel-inner">',
+ ' <div id="item" class="carousel-item active">',
+ ' <img alt="">',
+ ' </div>',
+ ' <div class="carousel-item">',
+ ' <img alt="">',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const item = fixtureEl.querySelector('#item')
+ const carousel = new Carousel(carouselEl)
+
+ const spy = spyOn(carousel, '_slide').and.callThrough()
+
+ carouselEl.addEventListener('slid.bs.carousel', event => {
+ expect(item).not.toHaveClass('active')
+ expect(spy).toHaveBeenCalledWith('next')
+ expect(event.direction).toEqual('left')
+ delete document.documentElement.ontouchstart
+ restorePointerEvents()
+ resolve()
+ })
+
+ Simulator.gestures.swipe(carouselEl, {
+ pos: [300, 10],
+ deltaX: -300,
+ deltaY: 0
+ })
+ })
+ })
+
+ it('should not slide when swiping and carousel is sliding', () => {
+ return new Promise(resolve => {
+ Simulator.setType('touch')
+ clearPointerEvents()
+ document.documentElement.ontouchstart = noop
+
+ fixtureEl.innerHTML = [
+ '<div class="carousel">',
+ ' <div class="carousel-inner">',
+ ' <div id="item" class="carousel-item active">',
+ ' <img alt="">',
+ ' </div>',
+ ' <div class="carousel-item">',
+ ' <img alt="">',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const carousel = new Carousel(carouselEl)
+ carousel._isSliding = true
+
+ const spy = spyOn(EventHandler, 'trigger')
+
+ Simulator.gestures.swipe(carouselEl, {
+ deltaX: 300,
+ deltaY: 0
+ })
+
+ Simulator.gestures.swipe(carouselEl, {
+ pos: [300, 10],
+ deltaX: -300,
+ deltaY: 0
+ })
+
+ setTimeout(() => {
+ expect(spy).not.toHaveBeenCalled()
+ delete document.documentElement.ontouchstart
+ restorePointerEvents()
+ resolve()
+ }, 300)
+ })
+ })
+
+ it('should not allow pinch with touch events', () => {
+ return new Promise(resolve => {
+ Simulator.setType('touch')
+ clearPointerEvents()
+ document.documentElement.ontouchstart = noop
+
+ fixtureEl.innerHTML = '<div class="carousel"></div>'
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const carousel = new Carousel(carouselEl)
+
+ Simulator.gestures.swipe(carouselEl, {
+ pos: [300, 10],
+ deltaX: -300,
+ deltaY: 0,
+ touches: 2
+ }, () => {
+ restorePointerEvents()
+ delete document.documentElement.ontouchstart
+ expect(carousel._swipeHelper._deltaX).toEqual(0)
+ resolve()
+ })
+ })
+ })
+
+ it('should call pause method on mouse over with pause equal to hover', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="carousel"></div>'
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const carousel = new Carousel(carouselEl)
+
+ const spy = spyOn(carousel, 'pause')
+
+ const mouseOverEvent = createEvent('mouseover')
+ carouselEl.dispatchEvent(mouseOverEvent)
+
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should call `maybeEnableCycle` on mouse out with pause equal to hover', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="carousel" data-bs-ride="true"></div>'
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const carousel = new Carousel(carouselEl)
+
+ const spyEnable = spyOn(carousel, '_maybeEnableCycle').and.callThrough()
+ const spyCycle = spyOn(carousel, 'cycle')
+
+ const mouseOutEvent = createEvent('mouseout')
+ carouselEl.dispatchEvent(mouseOutEvent)
+
+ setTimeout(() => {
+ expect(spyEnable).toHaveBeenCalled()
+ expect(spyCycle).toHaveBeenCalled()
+ resolve()
+ }, 10)
+ })
+ })
+ })
+
+ describe('next', () => {
+ it('should not slide if the carousel is sliding', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const carousel = new Carousel(carouselEl, {})
+
+ const spy = spyOn(EventHandler, 'trigger')
+
+ carousel._isSliding = true
+ carousel.next()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should not fire slid when slide is prevented', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const carousel = new Carousel(carouselEl, {})
+ let slidEvent = false
+
+ const doneTest = () => {
+ setTimeout(() => {
+ expect(slidEvent).toBeFalse()
+ resolve()
+ }, 20)
+ }
+
+ carouselEl.addEventListener('slide.bs.carousel', event => {
+ event.preventDefault()
+ doneTest()
+ })
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ slidEvent = true
+ })
+
+ carousel.next()
+ })
+ })
+
+ it('should fire slide event with: direction, relatedTarget, from and to', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {})
+
+ const onSlide = event => {
+ expect(event.direction).toEqual('left')
+ expect(event.relatedTarget).toHaveClass('carousel-item')
+ expect(event.from).toEqual(0)
+ expect(event.to).toEqual(1)
+
+ carouselEl.removeEventListener('slide.bs.carousel', onSlide)
+ carouselEl.addEventListener('slide.bs.carousel', onSlide2)
+
+ carousel.prev()
+ }
+
+ const onSlide2 = event => {
+ expect(event.direction).toEqual('right')
+ resolve()
+ }
+
+ carouselEl.addEventListener('slide.bs.carousel', onSlide)
+ carousel.next()
+ })
+ })
+
+ it('should fire slid event with: direction, relatedTarget, from and to', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {})
+
+ const onSlid = event => {
+ expect(event.direction).toEqual('left')
+ expect(event.relatedTarget).toHaveClass('carousel-item')
+ expect(event.from).toEqual(0)
+ expect(event.to).toEqual(1)
+
+ carouselEl.removeEventListener('slid.bs.carousel', onSlid)
+ carouselEl.addEventListener('slid.bs.carousel', onSlid2)
+
+ carousel.prev()
+ }
+
+ const onSlid2 = event => {
+ expect(event.direction).toEqual('right')
+ resolve()
+ }
+
+ carouselEl.addEventListener('slid.bs.carousel', onSlid)
+ carousel.next()
+ })
+ })
+
+ it('should update the active element to the next item before sliding', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div id="secondItem" class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const secondItemEl = fixtureEl.querySelector('#secondItem')
+ const carousel = new Carousel(carouselEl)
+
+ carousel.next()
+
+ expect(carousel._activeElement).toEqual(secondItemEl)
+ })
+
+ it('should continue cycling if it was already', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item">item 2</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl)
+ const spy = spyOn(carousel, 'cycle')
+
+ carousel.next()
+ expect(spy).not.toHaveBeenCalled()
+
+ carousel.cycle()
+ carousel.next()
+ expect(spy).toHaveBeenCalledTimes(1)
+ })
+
+ it('should update indicators if present', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-indicators">',
+ ' <button type="button" id="firstIndicator" data-bs-target="myCarousel" data-bs-slide-to="0" class="active" aria-current="true" aria-label="Slide 1"></button>',
+ ' <button type="button" id="secondIndicator" data-bs-target="myCarousel" data-bs-slide-to="1" aria-label="Slide 2"></button>',
+ ' <button type="button" data-bs-target="myCarousel" data-bs-slide-to="2" aria-label="Slide 3"></button>',
+ ' </div>',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item" data-bs-interval="7">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const firstIndicator = fixtureEl.querySelector('#firstIndicator')
+ const secondIndicator = fixtureEl.querySelector('#secondIndicator')
+ const carousel = new Carousel(carouselEl)
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ expect(firstIndicator).not.toHaveClass('active')
+ expect(firstIndicator.hasAttribute('aria-current')).toBeFalse()
+ expect(secondIndicator).toHaveClass('active')
+ expect(secondIndicator.getAttribute('aria-current')).toEqual('true')
+ resolve()
+ })
+
+ carousel.next()
+ })
+ })
+
+ it('should call next()/prev() instance methods when clicking the respective direction buttons', () => {
+ fixtureEl.innerHTML = [
+ '<div id="carousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <button class="carousel-control-prev" type="button" data-bs-target="#carousel" data-bs-slide="prev"></button>',
+ ' <button class="carousel-control-next" type="button" data-bs-target="#carousel" data-bs-slide="next"></button>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#carousel')
+ const prevBtnEl = fixtureEl.querySelector('.carousel-control-prev')
+ const nextBtnEl = fixtureEl.querySelector('.carousel-control-next')
+
+ const carousel = new Carousel(carouselEl)
+ const nextSpy = spyOn(carousel, 'next')
+ const prevSpy = spyOn(carousel, 'prev')
+ const spyEnable = spyOn(carousel, '_maybeEnableCycle')
+
+ nextBtnEl.click()
+ prevBtnEl.click()
+
+ expect(nextSpy).toHaveBeenCalled()
+ expect(prevSpy).toHaveBeenCalled()
+ expect(spyEnable).toHaveBeenCalled()
+ })
+ })
+
+ describe('nextWhenVisible', () => {
+ it('should not call next when the page is not visible', () => {
+ fixtureEl.innerHTML = [
+ '<div style="display: none;">',
+ ' <div class="carousel"></div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const carousel = new Carousel(carouselEl)
+
+ const spy = spyOn(carousel, 'next')
+
+ carousel.nextWhenVisible()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('prev', () => {
+ it('should not slide if the carousel is sliding', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const carousel = new Carousel(carouselEl, {})
+
+ const spy = spyOn(EventHandler, 'trigger')
+
+ carousel._isSliding = true
+ carousel.prev()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('pause', () => {
+ it('should trigger transitionend if the carousel have carousel-item-next or carousel-item-prev class, cause is sliding', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item carousel-item-next">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <div class="carousel-control-prev"></div>',
+ ' <div class="carousel-control-next"></div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl)
+ const spy = spyOn(carousel, '_clearInterval')
+
+ carouselEl.addEventListener('transitionend', () => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ carousel._slide('next')
+ carousel.pause()
+ })
+ })
+ })
+
+ describe('cycle', () => {
+ it('should set an interval', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <div class="carousel-control-prev"></div>',
+ ' <div class="carousel-control-next"></div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl)
+
+ const spy = spyOn(window, 'setInterval').and.callThrough()
+
+ carousel.cycle()
+
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should clear interval if there is one', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <div class="carousel-control-prev"></div>',
+ ' <div class="carousel-control-next"></div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl)
+
+ carousel._interval = setInterval(noop, 10)
+
+ const spySet = spyOn(window, 'setInterval').and.callThrough()
+ const spyClear = spyOn(window, 'clearInterval').and.callThrough()
+
+ carousel.cycle()
+
+ expect(spySet).toHaveBeenCalled()
+ expect(spyClear).toHaveBeenCalled()
+ })
+
+ it('should get interval from data attribute on the active item element', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active" data-bs-interval="7">item 1</div>',
+ ' <div id="secondItem" class="carousel-item" data-bs-interval="9385">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const secondItemEl = fixtureEl.querySelector('#secondItem')
+ const carousel = new Carousel(carouselEl, {
+ interval: 1814
+ })
+
+ expect(carousel._config.interval).toEqual(1814)
+
+ carousel.cycle()
+
+ expect(carousel._config.interval).toEqual(7)
+
+ carousel._activeElement = secondItemEl
+ carousel.cycle()
+
+ expect(carousel._config.interval).toEqual(9385)
+ })
+ })
+
+ describe('to', () => {
+ it('should go directly to the provided index', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div id="item1" class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item">item 2</div>',
+ ' <div id="item3" class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {})
+
+ expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item1'))
+
+ carousel.to(2)
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item3'))
+ resolve()
+ })
+ })
+ })
+
+ it('should return to a previous slide if the provided index is lower than the current', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item">item 1</div>',
+ ' <div id="item2" class="carousel-item">item 2</div>',
+ ' <div id="item3" class="carousel-item active">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {})
+
+ expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item3'))
+
+ carousel.to(1)
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item2'))
+ resolve()
+ })
+ })
+ })
+
+ it('should do nothing if a wrong index is provided', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item" data-bs-interval="7">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {})
+
+ const spy = spyOn(carousel, '_slide')
+
+ carousel.to(25)
+
+ expect(spy).not.toHaveBeenCalled()
+
+ spy.calls.reset()
+
+ carousel.to(-5)
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should not continue if the provided is the same compare to the current one', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item" data-bs-interval="7">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {})
+
+ const spy = spyOn(carousel, '_slide')
+
+ carousel.to(0)
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should wait before performing to if a slide is sliding', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item" data-bs-interval="7">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {})
+
+ const spyOne = spyOn(EventHandler, 'one').and.callThrough()
+ const spySlide = spyOn(carousel, '_slide')
+
+ carousel._isSliding = true
+ carousel.to(1)
+
+ expect(spySlide).not.toHaveBeenCalled()
+ expect(spyOne).toHaveBeenCalled()
+
+ const spyTo = spyOn(carousel, 'to')
+
+ EventHandler.trigger(carouselEl, 'slid.bs.carousel')
+
+ setTimeout(() => {
+ expect(spyTo).toHaveBeenCalledWith(1)
+ resolve()
+ })
+ })
+ })
+ })
+
+ describe('rtl function', () => {
+ it('"_directionToOrder" and "_orderToDirection" must return the right results', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const carousel = new Carousel(carouselEl, {})
+
+ expect(carousel._directionToOrder('left')).toEqual('next')
+ expect(carousel._directionToOrder('right')).toEqual('prev')
+
+ expect(carousel._orderToDirection('next')).toEqual('left')
+ expect(carousel._orderToDirection('prev')).toEqual('right')
+ })
+
+ it('"_directionToOrder" and "_orderToDirection" must return the right results when rtl=true', () => {
+ document.documentElement.dir = 'rtl'
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const carousel = new Carousel(carouselEl, {})
+ expect(isRTL()).toBeTrue()
+
+ expect(carousel._directionToOrder('left')).toEqual('prev')
+ expect(carousel._directionToOrder('right')).toEqual('next')
+
+ expect(carousel._orderToDirection('next')).toEqual('right')
+ expect(carousel._orderToDirection('prev')).toEqual('left')
+ document.documentElement.dir = 'ltl'
+ })
+
+ it('"_slide" has to call _directionToOrder and "_orderToDirection"', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const carousel = new Carousel(carouselEl, {})
+
+ const spy = spyOn(carousel, '_orderToDirection').and.callThrough()
+
+ carousel._slide(carousel._directionToOrder('left'))
+ expect(spy).toHaveBeenCalledWith('next')
+
+ carousel._slide(carousel._directionToOrder('right'))
+ expect(spy).toHaveBeenCalledWith('prev')
+ })
+
+ it('"_slide" has to call "_directionToOrder" and "_orderToDirection" when rtl=true', () => {
+ document.documentElement.dir = 'rtl'
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const carousel = new Carousel(carouselEl, {})
+ const spy = spyOn(carousel, '_orderToDirection').and.callThrough()
+
+ carousel._slide(carousel._directionToOrder('left'))
+ expect(spy).toHaveBeenCalledWith('prev')
+
+ carousel._slide(carousel._directionToOrder('right'))
+ expect(spy).toHaveBeenCalledWith('next')
+
+ document.documentElement.dir = 'ltl'
+ })
+ })
+
+ describe('dispose', () => {
+ it('should destroy a carousel', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item" data-bs-interval="7">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const addEventSpy = spyOn(carouselEl, 'addEventListener').and.callThrough()
+ const removeEventSpy = spyOn(EventHandler, 'off').and.callThrough()
+
+ // Headless browser does not support touch events, so need to fake it
+ // to test that touch events are add/removed properly.
+ document.documentElement.ontouchstart = noop
+
+ const carousel = new Carousel(carouselEl)
+ const swipeHelperSpy = spyOn(carousel._swipeHelper, 'dispose').and.callThrough()
+
+ const expectedArgs = [
+ ['keydown', jasmine.any(Function), jasmine.any(Boolean)],
+ ['mouseover', jasmine.any(Function), jasmine.any(Boolean)],
+ ['mouseout', jasmine.any(Function), jasmine.any(Boolean)],
+ ...(carousel._swipeHelper._supportPointerEvents ?
+ [
+ ['pointerdown', jasmine.any(Function), jasmine.any(Boolean)],
+ ['pointerup', jasmine.any(Function), jasmine.any(Boolean)]
+ ] :
+ [
+ ['touchstart', jasmine.any(Function), jasmine.any(Boolean)],
+ ['touchmove', jasmine.any(Function), jasmine.any(Boolean)],
+ ['touchend', jasmine.any(Function), jasmine.any(Boolean)]
+ ])
+ ]
+
+ expect(addEventSpy.calls.allArgs()).toEqual(expectedArgs)
+
+ carousel.dispose()
+
+ expect(carousel._swipeHelper).toBeNull()
+ expect(removeEventSpy).toHaveBeenCalledWith(carouselEl, Carousel.EVENT_KEY)
+ expect(swipeHelperSpy).toHaveBeenCalled()
+
+ delete document.documentElement.ontouchstart
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return carousel instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const carousel = new Carousel(div)
+
+ expect(Carousel.getInstance(div)).toEqual(carousel)
+ expect(Carousel.getInstance(div)).toBeInstanceOf(Carousel)
+ })
+
+ it('should return null when there is no carousel instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Carousel.getInstance(div)).toBeNull()
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return carousel instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const carousel = new Carousel(div)
+
+ expect(Carousel.getOrCreateInstance(div)).toEqual(carousel)
+ expect(Carousel.getInstance(div)).toEqual(Carousel.getOrCreateInstance(div, {}))
+ expect(Carousel.getOrCreateInstance(div)).toBeInstanceOf(Carousel)
+ })
+
+ it('should return new instance when there is no carousel instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Carousel.getInstance(div)).toBeNull()
+ expect(Carousel.getOrCreateInstance(div)).toBeInstanceOf(Carousel)
+ })
+
+ it('should return new instance when there is no carousel instance with given configuration', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Carousel.getInstance(div)).toBeNull()
+ const carousel = Carousel.getOrCreateInstance(div, {
+ interval: 1
+ })
+ expect(carousel).toBeInstanceOf(Carousel)
+
+ expect(carousel._config.interval).toEqual(1)
+ })
+
+ it('should return the instance when exists without given configuration', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const carousel = new Carousel(div, {
+ interval: 1
+ })
+ expect(Carousel.getInstance(div)).toEqual(carousel)
+
+ const carousel2 = Carousel.getOrCreateInstance(div, {
+ interval: 2
+ })
+ expect(carousel).toBeInstanceOf(Carousel)
+ expect(carousel2).toEqual(carousel)
+
+ expect(carousel2._config.interval).toEqual(1)
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a carousel', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.carousel = Carousel.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.carousel.call(jQueryMock)
+
+ expect(Carousel.getInstance(div)).not.toBeNull()
+ })
+
+ it('should not re create a carousel', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const carousel = new Carousel(div)
+
+ jQueryMock.fn.carousel = Carousel.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.carousel.call(jQueryMock)
+
+ expect(Carousel.getInstance(div)).toEqual(carousel)
+ })
+
+ it('should call to if the config is a number', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const carousel = new Carousel(div)
+ const slideTo = 2
+
+ const spy = spyOn(carousel, 'to')
+
+ jQueryMock.fn.carousel = Carousel.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.carousel.call(jQueryMock, slideTo)
+
+ expect(spy).toHaveBeenCalledWith(slideTo)
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.carousel = Carousel.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.carousel.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+ })
+
+ describe('data-api', () => {
+ it('should init carousels with data-bs-ride="carousel" on load', () => {
+ fixtureEl.innerHTML = '<div data-bs-ride="carousel"></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const loadEvent = createEvent('load')
+
+ window.dispatchEvent(loadEvent)
+ const carousel = Carousel.getInstance(carouselEl)
+ expect(carousel._interval).not.toBeNull()
+ })
+
+ it('should create carousel and go to the next slide on click (with real button controls)', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div id="item2" class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <button class="carousel-control-prev" data-bs-target="#myCarousel" type="button" data-bs-slide="prev"></button>',
+ ' <button id="next" class="carousel-control-next" data-bs-target="#myCarousel" type="button" data-bs-slide="next"></button>',
+ '</div>'
+ ].join('')
+
+ const next = fixtureEl.querySelector('#next')
+ const item2 = fixtureEl.querySelector('#item2')
+
+ next.click()
+
+ setTimeout(() => {
+ expect(item2).toHaveClass('active')
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should create carousel and go to the next slide on click (using links as controls)', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div id="item2" class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <a class="carousel-control-prev" href="#myCarousel" role="button" data-bs-slide="prev"></a>',
+ ' <a id="next" class="carousel-control-next" href="#myCarousel" role="button" data-bs-slide="next"></a>',
+ '</div>'
+ ].join('')
+
+ const next = fixtureEl.querySelector('#next')
+ const item2 = fixtureEl.querySelector('#item2')
+
+ next.click()
+
+ setTimeout(() => {
+ expect(item2).toHaveClass('active')
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should create carousel and go to the next slide on click with data-bs-slide-to', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide" data-bs-ride="true">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div id="item2" class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <div id="next" data-bs-target="#myCarousel" data-bs-slide-to="1"></div>',
+ '</div>'
+ ].join('')
+
+ const next = fixtureEl.querySelector('#next')
+ const item2 = fixtureEl.querySelector('#item2')
+
+ next.click()
+
+ setTimeout(() => {
+ expect(item2).toHaveClass('active')
+ expect(Carousel.getInstance('#myCarousel')._interval).not.toBeNull()
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should do nothing if no selector on click on arrows', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <button class="carousel-control-prev" data-bs-target="#myCarousel" type="button" data-bs-slide="prev"></button>',
+ ' <button id="next" class="carousel-control-next" type="button" data-bs-slide="next"></button>',
+ '</div>'
+ ].join('')
+
+ const next = fixtureEl.querySelector('#next')
+
+ next.click()
+
+ expect().nothing()
+ })
+
+ it('should do nothing if no carousel class on click on arrows', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div id="item2" class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <button class="carousel-control-prev" data-bs-target="#myCarousel" type="button" data-bs-slide="prev"></button>',
+ ' <button id="next" class="carousel-control-next" data-bs-target="#myCarousel" type="button" data-bs-slide="next"></button>',
+ '</div>'
+ ].join('')
+
+ const next = fixtureEl.querySelector('#next')
+
+ next.click()
+
+ expect().nothing()
+ })
+ })
+})
diff --git a/js/tests/unit/collapse.spec.js b/js/tests/unit/collapse.spec.js
new file mode 100644
index 0000000..9c86719
--- /dev/null
+++ b/js/tests/unit/collapse.spec.js
@@ -0,0 +1,1062 @@
+import Collapse from '../../src/collapse'
+import EventHandler from '../../src/dom/event-handler'
+import { clearFixture, getFixture, jQueryMock } from '../helpers/fixture'
+
+describe('Collapse', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Collapse.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin default config', () => {
+ expect(Collapse.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(Collapse.DATA_KEY).toEqual('bs.collapse')
+ })
+ })
+
+ describe('constructor', () => {
+ it('should take care of element either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = '<div class="my-collapse"></div>'
+
+ const collapseEl = fixtureEl.querySelector('div.my-collapse')
+ const collapseBySelector = new Collapse('div.my-collapse')
+ const collapseByElement = new Collapse(collapseEl)
+
+ expect(collapseBySelector._element).toEqual(collapseEl)
+ expect(collapseByElement._element).toEqual(collapseEl)
+ })
+
+ it('should allow jquery object in parent config', () => {
+ fixtureEl.innerHTML = [
+ '<div class="my-collapse">',
+ ' <div class="item">',
+ ' <a data-bs-toggle="collapse" href="#">Toggle item</a>',
+ ' <div class="collapse">Lorem ipsum</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const collapseEl = fixtureEl.querySelector('div.collapse')
+ const myCollapseEl = fixtureEl.querySelector('.my-collapse')
+ const fakejQueryObject = {
+ 0: myCollapseEl,
+ jquery: 'foo'
+ }
+ const collapse = new Collapse(collapseEl, {
+ parent: fakejQueryObject
+ })
+
+ expect(collapse._config.parent).toEqual(myCollapseEl)
+ })
+
+ it('should allow non jquery object in parent config', () => {
+ fixtureEl.innerHTML = [
+ '<div class="my-collapse">',
+ ' <div class="item">',
+ ' <a data-bs-toggle="collapse" href="#">Toggle item</a>',
+ ' <div class="collapse">Lorem ipsum</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const collapseEl = fixtureEl.querySelector('div.collapse')
+ const myCollapseEl = fixtureEl.querySelector('.my-collapse')
+ const collapse = new Collapse(collapseEl, {
+ parent: myCollapseEl
+ })
+
+ expect(collapse._config.parent).toEqual(myCollapseEl)
+ })
+
+ it('should allow string selector in parent config', () => {
+ fixtureEl.innerHTML = [
+ '<div class="my-collapse">',
+ ' <div class="item">',
+ ' <a data-bs-toggle="collapse" href="#">Toggle item</a>',
+ ' <div class="collapse">Lorem ipsum</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const collapseEl = fixtureEl.querySelector('div.collapse')
+ const myCollapseEl = fixtureEl.querySelector('.my-collapse')
+ const collapse = new Collapse(collapseEl, {
+ parent: 'div.my-collapse'
+ })
+
+ expect(collapse._config.parent).toEqual(myCollapseEl)
+ })
+ })
+
+ describe('toggle', () => {
+ it('should call show method if show class is not present', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl)
+
+ const spy = spyOn(collapse, 'show')
+
+ collapse.toggle()
+
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should call hide method if show class is present', () => {
+ fixtureEl.innerHTML = '<div class="show"></div>'
+
+ const collapseEl = fixtureEl.querySelector('.show')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ const spy = spyOn(collapse, 'hide')
+
+ collapse.toggle()
+
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should find collapse children if they have collapse class too not only data-bs-parent', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="my-collapse">',
+ ' <div class="item">',
+ ' <a data-bs-toggle="collapse" href="#">Toggle item 1</a>',
+ ' <div id="collapse1" class="collapse show">Lorem ipsum 1</div>',
+ ' </div>',
+ ' <div class="item">',
+ ' <a id="triggerCollapse2" data-bs-toggle="collapse" href="#">Toggle item 2</a>',
+ ' <div id="collapse2" class="collapse">Lorem ipsum 2</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const parent = fixtureEl.querySelector('.my-collapse')
+ const collapseEl1 = fixtureEl.querySelector('#collapse1')
+ const collapseEl2 = fixtureEl.querySelector('#collapse2')
+
+ const collapseList = [].concat(...fixtureEl.querySelectorAll('.collapse'))
+ .map(el => new Collapse(el, {
+ parent,
+ toggle: false
+ }))
+
+ collapseEl2.addEventListener('shown.bs.collapse', () => {
+ expect(collapseEl2).toHaveClass('show')
+ expect(collapseEl1).not.toHaveClass('show')
+ resolve()
+ })
+
+ collapseList[1].toggle()
+ })
+ })
+ })
+
+ describe('show', () => {
+ it('should do nothing if is transitioning', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const spy = spyOn(EventHandler, 'trigger')
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ collapse._isTransitioning = true
+ collapse.show()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should do nothing if already shown', () => {
+ fixtureEl.innerHTML = '<div class="show"></div>'
+
+ const spy = spyOn(EventHandler, 'trigger')
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ collapse.show()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should show a collapsed element', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="collapse" style="height: 0px;"></div>'
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ collapseEl.addEventListener('show.bs.collapse', () => {
+ expect(collapseEl.style.height).toEqual('0px')
+ })
+ collapseEl.addEventListener('shown.bs.collapse', () => {
+ expect(collapseEl).toHaveClass('show')
+ expect(collapseEl.style.height).toEqual('')
+ resolve()
+ })
+
+ collapse.show()
+ })
+ })
+
+ it('should show a collapsed element on width', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="collapse collapse-horizontal" style="width: 0px;"></div>'
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ collapseEl.addEventListener('show.bs.collapse', () => {
+ expect(collapseEl.style.width).toEqual('0px')
+ })
+ collapseEl.addEventListener('shown.bs.collapse', () => {
+ expect(collapseEl).toHaveClass('show')
+ expect(collapseEl.style.width).toEqual('')
+ resolve()
+ })
+
+ collapse.show()
+ })
+ })
+
+ it('should collapse only the first collapse', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="card" id="accordion1">',
+ ' <div id="collapse1" class="collapse"></div>',
+ '</div>',
+ '<div class="card" id="accordion2">',
+ ' <div id="collapse2" class="collapse show"></div>',
+ '</div>'
+ ].join('')
+
+ const el1 = fixtureEl.querySelector('#collapse1')
+ const el2 = fixtureEl.querySelector('#collapse2')
+ const collapse = new Collapse(el1, {
+ toggle: false
+ })
+
+ el1.addEventListener('shown.bs.collapse', () => {
+ expect(el1).toHaveClass('show')
+ expect(el2).toHaveClass('show')
+ resolve()
+ })
+
+ collapse.show()
+ })
+ })
+
+ it('should be able to handle toggling of other children siblings', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="parentGroup" class="accordion">',
+ ' <div id="parentHeader" class="accordion-header">',
+ ' <button data-bs-target="#parentContent" data-bs-toggle="collapse" role="button" class="accordion-toggle">Parent</button>',
+ ' </div>',
+ ' <div id="parentContent" class="accordion-collapse collapse" aria-labelledby="parentHeader" data-bs-parent="#parentGroup">',
+ ' <div class="accordion-body">',
+ ' <div id="childGroup" class="accordion">',
+ ' <div class="accordion-item">',
+ ' <div id="childHeader1" class="accordion-header">',
+ ' <button data-bs-target="#childContent1" data-bs-toggle="collapse" role="button" class="accordion-toggle">Child 1</button>',
+ ' </div>',
+ ' <div id="childContent1" class="accordion-collapse collapse" aria-labelledby="childHeader1" data-bs-parent="#childGroup">',
+ ' <div>content</div>',
+ ' </div>',
+ ' </div>',
+ ' <div class="accordion-item">',
+ ' <div id="childHeader2" class="accordion-header">',
+ ' <button data-bs-target="#childContent2" data-bs-toggle="collapse" role="button" class="accordion-toggle">Child 2</button>',
+ ' </div>',
+ ' <div id="childContent2" class="accordion-collapse collapse" aria-labelledby="childHeader2" data-bs-parent="#childGroup">',
+ ' <div>content</div>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const el = selector => fixtureEl.querySelector(selector)
+
+ const parentBtn = el('[data-bs-target="#parentContent"]')
+ const childBtn1 = el('[data-bs-target="#childContent1"]')
+ const childBtn2 = el('[data-bs-target="#childContent2"]')
+
+ const parentCollapseEl = el('#parentContent')
+ const childCollapseEl1 = el('#childContent1')
+ const childCollapseEl2 = el('#childContent2')
+
+ parentCollapseEl.addEventListener('shown.bs.collapse', () => {
+ expect(parentCollapseEl).toHaveClass('show')
+ childBtn1.click()
+ })
+ childCollapseEl1.addEventListener('shown.bs.collapse', () => {
+ expect(childCollapseEl1).toHaveClass('show')
+ childBtn2.click()
+ })
+ childCollapseEl2.addEventListener('shown.bs.collapse', () => {
+ expect(childCollapseEl2).toHaveClass('show')
+ expect(childCollapseEl1).not.toHaveClass('show')
+ resolve()
+ })
+
+ parentBtn.click()
+ })
+ })
+
+ it('should not change tab tabpanels descendants on accordion', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="accordion" id="accordionExample">',
+ ' <div class="accordion-item">',
+ ' <h2 class="accordion-header" id="headingOne">',
+ ' <button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseOne" aria-expanded="true" aria-controls="collapseOne">',
+ ' Accordion Item #1',
+ ' </button>',
+ ' </h2>',
+ ' <div id="collapseOne" class="accordion-collapse collapse show" aria-labelledby="headingOne" data-bs-parent="#accordionExample">',
+ ' <div class="accordion-body">',
+ ' <nav>',
+ ' <div class="nav nav-tabs" id="nav-tab" role="tablist">',
+ ' <button class="nav-link active" id="nav-home-tab" data-bs-toggle="tab" data-bs-target="#nav-home" type="button" role="tab" aria-controls="nav-home" aria-selected="true">Home</button>',
+ ' <button class="nav-link" id="nav-profile-tab" data-bs-toggle="tab" data-bs-target="#nav-profile" type="button" role="tab" aria-controls="nav-profile" aria-selected="false">Profile</button>',
+ ' </div>',
+ ' </nav>',
+ ' <div class="tab-content" id="nav-tabContent">',
+ ' <div class="tab-pane fade show active" id="nav-home" role="tabpanel" aria-labelledby="nav-home-tab">Home</div>',
+ ' <div class="tab-pane fade" id="nav-profile" role="tabpanel" aria-labelledby="nav-profile-tab">Profile</div>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const el = fixtureEl.querySelector('#collapseOne')
+ const activeTabPane = fixtureEl.querySelector('#nav-home')
+ const collapse = new Collapse(el)
+ let times = 1
+
+ el.addEventListener('hidden.bs.collapse', () => {
+ collapse.show()
+ })
+
+ el.addEventListener('shown.bs.collapse', () => {
+ expect(activeTabPane).toHaveClass('show')
+ times++
+ if (times === 2) {
+ resolve()
+ }
+
+ collapse.hide()
+ })
+
+ collapse.show()
+ })
+ })
+
+ it('should not fire shown when show is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '<div class="collapse"></div>'
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ const expectEnd = () => {
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 10)
+ }
+
+ collapseEl.addEventListener('show.bs.collapse', event => {
+ event.preventDefault()
+ expectEnd()
+ })
+
+ collapseEl.addEventListener('shown.bs.collapse', () => {
+ reject(new Error('should not fire shown event'))
+ })
+
+ collapse.show()
+ })
+ })
+ })
+
+ describe('hide', () => {
+ it('should do nothing if is transitioning', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const spy = spyOn(EventHandler, 'trigger')
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ collapse._isTransitioning = true
+ collapse.hide()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should do nothing if already shown', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const spy = spyOn(EventHandler, 'trigger')
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ collapse.hide()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should hide a collapsed element', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="collapse show"></div>'
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ collapseEl.addEventListener('hidden.bs.collapse', () => {
+ expect(collapseEl).not.toHaveClass('show')
+ expect(collapseEl.style.height).toEqual('')
+ resolve()
+ })
+
+ collapse.hide()
+ })
+ })
+
+ it('should not fire hidden when hide is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '<div class="collapse show"></div>'
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ const expectEnd = () => {
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 10)
+ }
+
+ collapseEl.addEventListener('hide.bs.collapse', event => {
+ event.preventDefault()
+ expectEnd()
+ })
+
+ collapseEl.addEventListener('hidden.bs.collapse', () => {
+ reject(new Error('should not fire hidden event'))
+ })
+
+ collapse.hide()
+ })
+ })
+ })
+
+ describe('dispose', () => {
+ it('should destroy a collapse', () => {
+ fixtureEl.innerHTML = '<div class="collapse show"></div>'
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ expect(Collapse.getInstance(collapseEl)).toEqual(collapse)
+
+ collapse.dispose()
+
+ expect(Collapse.getInstance(collapseEl)).toBeNull()
+ })
+ })
+
+ describe('data-api', () => {
+ it('should prevent url change if click on nested elements', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a role="button" data-bs-toggle="collapse" class="collapsed" href="#collapse">',
+ ' <span id="nested"></span>',
+ '</a>',
+ '<div id="collapse" class="collapse"></div>'
+ ].join('')
+
+ const triggerEl = fixtureEl.querySelector('a')
+ const nestedTriggerEl = fixtureEl.querySelector('#nested')
+
+ const spy = spyOn(Event.prototype, 'preventDefault').and.callThrough()
+
+ triggerEl.addEventListener('click', event => {
+ expect(event.target.isEqualNode(nestedTriggerEl)).toBeTrue()
+ expect(event.delegateTarget.isEqualNode(triggerEl)).toBeTrue()
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ nestedTriggerEl.click()
+ })
+ })
+
+ it('should show multiple collapsed elements', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a role="button" data-bs-toggle="collapse" class="collapsed" href=".multi"></a>',
+ '<div id="collapse1" class="collapse multi"></div>',
+ '<div id="collapse2" class="collapse multi"></div>'
+ ].join('')
+
+ const trigger = fixtureEl.querySelector('a')
+ const collapse1 = fixtureEl.querySelector('#collapse1')
+ const collapse2 = fixtureEl.querySelector('#collapse2')
+
+ collapse2.addEventListener('shown.bs.collapse', () => {
+ expect(trigger.getAttribute('aria-expanded')).toEqual('true')
+ expect(trigger).not.toHaveClass('collapsed')
+ expect(collapse1).toHaveClass('show')
+ expect(collapse1).toHaveClass('show')
+ resolve()
+ })
+
+ trigger.click()
+ })
+ })
+
+ it('should hide multiple collapsed elements', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a role="button" data-bs-toggle="collapse" href=".multi"></a>',
+ '<div id="collapse1" class="collapse multi show"></div>',
+ '<div id="collapse2" class="collapse multi show"></div>'
+ ].join('')
+
+ const trigger = fixtureEl.querySelector('a')
+ const collapse1 = fixtureEl.querySelector('#collapse1')
+ const collapse2 = fixtureEl.querySelector('#collapse2')
+
+ collapse2.addEventListener('hidden.bs.collapse', () => {
+ expect(trigger.getAttribute('aria-expanded')).toEqual('false')
+ expect(trigger).toHaveClass('collapsed')
+ expect(collapse1).not.toHaveClass('show')
+ expect(collapse1).not.toHaveClass('show')
+ resolve()
+ })
+
+ trigger.click()
+ })
+ })
+
+ it('should remove "collapsed" class from target when collapse is shown', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a id="link1" role="button" data-bs-toggle="collapse" class="collapsed" href="#" data-bs-target="#test1"></a>',
+ '<a id="link2" role="button" data-bs-toggle="collapse" class="collapsed" href="#" data-bs-target="#test1"></a>',
+ '<div id="test1"></div>'
+ ].join('')
+
+ const link1 = fixtureEl.querySelector('#link1')
+ const link2 = fixtureEl.querySelector('#link2')
+ const collapseTest1 = fixtureEl.querySelector('#test1')
+
+ collapseTest1.addEventListener('shown.bs.collapse', () => {
+ expect(link1.getAttribute('aria-expanded')).toEqual('true')
+ expect(link2.getAttribute('aria-expanded')).toEqual('true')
+ expect(link1).not.toHaveClass('collapsed')
+ expect(link2).not.toHaveClass('collapsed')
+ resolve()
+ })
+
+ link1.click()
+ })
+ })
+
+ it('should add "collapsed" class to target when collapse is hidden', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a id="link1" role="button" data-bs-toggle="collapse" href="#" data-bs-target="#test1"></a>',
+ '<a id="link2" role="button" data-bs-toggle="collapse" href="#" data-bs-target="#test1"></a>',
+ '<div id="test1" class="show"></div>'
+ ].join('')
+
+ const link1 = fixtureEl.querySelector('#link1')
+ const link2 = fixtureEl.querySelector('#link2')
+ const collapseTest1 = fixtureEl.querySelector('#test1')
+
+ collapseTest1.addEventListener('hidden.bs.collapse', () => {
+ expect(link1.getAttribute('aria-expanded')).toEqual('false')
+ expect(link2.getAttribute('aria-expanded')).toEqual('false')
+ expect(link1).toHaveClass('collapsed')
+ expect(link2).toHaveClass('collapsed')
+ resolve()
+ })
+
+ link1.click()
+ })
+ })
+
+ it('should allow accordion to use children other than card', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="accordion">',
+ ' <div class="item">',
+ ' <a id="linkTrigger" data-bs-toggle="collapse" href="#collapseOne" aria-expanded="false" aria-controls="collapseOne"></a>',
+ ' <div id="collapseOne" class="collapse" role="tabpanel" aria-labelledby="headingThree" data-bs-parent="#accordion"></div>',
+ ' </div>',
+ ' <div class="item">',
+ ' <a id="linkTriggerTwo" data-bs-toggle="collapse" href="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo"></a>',
+ ' <div id="collapseTwo" class="collapse show" role="tabpanel" aria-labelledby="headingTwo" data-bs-parent="#accordion"></div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const trigger = fixtureEl.querySelector('#linkTrigger')
+ const triggerTwo = fixtureEl.querySelector('#linkTriggerTwo')
+ const collapseOne = fixtureEl.querySelector('#collapseOne')
+ const collapseTwo = fixtureEl.querySelector('#collapseTwo')
+
+ collapseOne.addEventListener('shown.bs.collapse', () => {
+ expect(collapseOne).toHaveClass('show')
+ expect(collapseTwo).not.toHaveClass('show')
+
+ collapseTwo.addEventListener('shown.bs.collapse', () => {
+ expect(collapseOne).not.toHaveClass('show')
+ expect(collapseTwo).toHaveClass('show')
+ resolve()
+ })
+
+ triggerTwo.click()
+ })
+
+ trigger.click()
+ })
+ })
+
+ it('should not prevent event for input', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<input type="checkbox" data-bs-toggle="collapse" data-bs-target="#collapsediv1">',
+ '<div id="collapsediv1"></div>'
+ ].join('')
+
+ const target = fixtureEl.querySelector('input')
+ const collapseEl = fixtureEl.querySelector('#collapsediv1')
+
+ collapseEl.addEventListener('shown.bs.collapse', () => {
+ expect(collapseEl).toHaveClass('show')
+ expect(target.checked).toBeTrue()
+ resolve()
+ })
+
+ target.click()
+ })
+ })
+
+ it('should allow accordion to contain nested elements', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="accordion">',
+ ' <div class="row">',
+ ' <div class="col-lg-6">',
+ ' <div class="item">',
+ ' <a id="linkTrigger" data-bs-toggle="collapse" href="#collapseOne" aria-expanded="false" aria-controls="collapseOne"></a>',
+ ' <div id="collapseOne" class="collapse" role="tabpanel" aria-labelledby="headingThree" data-bs-parent="#accordion"></div>',
+ ' </div>',
+ ' </div>',
+ ' <div class="col-lg-6">',
+ ' <div class="item">',
+ ' <a id="linkTriggerTwo" data-bs-toggle="collapse" href="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo"></a>',
+ ' <div id="collapseTwo" class="collapse show" role="tabpanel" aria-labelledby="headingTwo" data-bs-parent="#accordion"></div>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerEl = fixtureEl.querySelector('#linkTrigger')
+ const triggerTwoEl = fixtureEl.querySelector('#linkTriggerTwo')
+ const collapseOneEl = fixtureEl.querySelector('#collapseOne')
+ const collapseTwoEl = fixtureEl.querySelector('#collapseTwo')
+
+ collapseOneEl.addEventListener('shown.bs.collapse', () => {
+ expect(collapseOneEl).toHaveClass('show')
+ expect(triggerEl).not.toHaveClass('collapsed')
+ expect(triggerEl.getAttribute('aria-expanded')).toEqual('true')
+
+ expect(collapseTwoEl).not.toHaveClass('show')
+ expect(triggerTwoEl).toHaveClass('collapsed')
+ expect(triggerTwoEl.getAttribute('aria-expanded')).toEqual('false')
+
+ collapseTwoEl.addEventListener('shown.bs.collapse', () => {
+ expect(collapseOneEl).not.toHaveClass('show')
+ expect(triggerEl).toHaveClass('collapsed')
+ expect(triggerEl.getAttribute('aria-expanded')).toEqual('false')
+
+ expect(collapseTwoEl).toHaveClass('show')
+ expect(triggerTwoEl).not.toHaveClass('collapsed')
+ expect(triggerTwoEl.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ triggerTwoEl.click()
+ })
+
+ triggerEl.click()
+ })
+ })
+
+ it('should allow accordion to target multiple elements', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="accordion">',
+ ' <a id="linkTriggerOne" data-bs-toggle="collapse" data-bs-target=".collapseOne" href="#" aria-expanded="false" aria-controls="collapseOne"></a>',
+ ' <a id="linkTriggerTwo" data-bs-toggle="collapse" data-bs-target=".collapseTwo" href="#" aria-expanded="false" aria-controls="collapseTwo"></a>',
+ ' <div id="collapseOneOne" class="collapse collapseOne" role="tabpanel" data-bs-parent="#accordion"></div>',
+ ' <div id="collapseOneTwo" class="collapse collapseOne" role="tabpanel" data-bs-parent="#accordion"></div>',
+ ' <div id="collapseTwoOne" class="collapse collapseTwo" role="tabpanel" data-bs-parent="#accordion"></div>',
+ ' <div id="collapseTwoTwo" class="collapse collapseTwo" role="tabpanel" data-bs-parent="#accordion"></div>',
+ '</div>'
+ ].join('')
+
+ const trigger = fixtureEl.querySelector('#linkTriggerOne')
+ const triggerTwo = fixtureEl.querySelector('#linkTriggerTwo')
+ const collapseOneOne = fixtureEl.querySelector('#collapseOneOne')
+ const collapseOneTwo = fixtureEl.querySelector('#collapseOneTwo')
+ const collapseTwoOne = fixtureEl.querySelector('#collapseTwoOne')
+ const collapseTwoTwo = fixtureEl.querySelector('#collapseTwoTwo')
+ const collapsedElements = {
+ one: false,
+ two: false
+ }
+
+ function firstTest() {
+ expect(collapseOneOne).toHaveClass('show')
+ expect(collapseOneTwo).toHaveClass('show')
+
+ expect(collapseTwoOne).not.toHaveClass('show')
+ expect(collapseTwoTwo).not.toHaveClass('show')
+
+ triggerTwo.click()
+ }
+
+ function secondTest() {
+ expect(collapseOneOne).not.toHaveClass('show')
+ expect(collapseOneTwo).not.toHaveClass('show')
+
+ expect(collapseTwoOne).toHaveClass('show')
+ expect(collapseTwoTwo).toHaveClass('show')
+ resolve()
+ }
+
+ collapseOneOne.addEventListener('shown.bs.collapse', () => {
+ if (collapsedElements.one) {
+ firstTest()
+ } else {
+ collapsedElements.one = true
+ }
+ })
+
+ collapseOneTwo.addEventListener('shown.bs.collapse', () => {
+ if (collapsedElements.one) {
+ firstTest()
+ } else {
+ collapsedElements.one = true
+ }
+ })
+
+ collapseTwoOne.addEventListener('shown.bs.collapse', () => {
+ if (collapsedElements.two) {
+ secondTest()
+ } else {
+ collapsedElements.two = true
+ }
+ })
+
+ collapseTwoTwo.addEventListener('shown.bs.collapse', () => {
+ if (collapsedElements.two) {
+ secondTest()
+ } else {
+ collapsedElements.two = true
+ }
+ })
+
+ trigger.click()
+ })
+ })
+
+ it('should collapse accordion children but not nested accordion children', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="accordion">',
+ ' <div class="item">',
+ ' <a id="linkTrigger" data-bs-toggle="collapse" href="#collapseOne" aria-expanded="false" aria-controls="collapseOne"></a>',
+ ' <div id="collapseOne" data-bs-parent="#accordion" class="collapse" role="tabpanel" aria-labelledby="headingThree">',
+ ' <div id="nestedAccordion">',
+ ' <div class="item">',
+ ' <a id="nestedLinkTrigger" data-bs-toggle="collapse" href="#nestedCollapseOne" aria-expanded="false" aria-controls="nestedCollapseOne"></a>',
+ ' <div id="nestedCollapseOne" data-bs-parent="#nestedAccordion" class="collapse" role="tabpanel" aria-labelledby="headingThree"></div>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ ' <div class="item">',
+ ' <a id="linkTriggerTwo" data-bs-toggle="collapse" href="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo"></a>',
+ ' <div id="collapseTwo" data-bs-parent="#accordion" class="collapse show" role="tabpanel" aria-labelledby="headingTwo"></div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const trigger = fixtureEl.querySelector('#linkTrigger')
+ const triggerTwo = fixtureEl.querySelector('#linkTriggerTwo')
+ const nestedTrigger = fixtureEl.querySelector('#nestedLinkTrigger')
+ const collapseOne = fixtureEl.querySelector('#collapseOne')
+ const collapseTwo = fixtureEl.querySelector('#collapseTwo')
+ const nestedCollapseOne = fixtureEl.querySelector('#nestedCollapseOne')
+
+ function handlerCollapseOne() {
+ expect(collapseOne).toHaveClass('show')
+ expect(collapseTwo).not.toHaveClass('show')
+ expect(nestedCollapseOne).not.toHaveClass('show')
+
+ nestedCollapseOne.addEventListener('shown.bs.collapse', handlerNestedCollapseOne)
+ nestedTrigger.click()
+ collapseOne.removeEventListener('shown.bs.collapse', handlerCollapseOne)
+ }
+
+ function handlerNestedCollapseOne() {
+ expect(collapseOne).toHaveClass('show')
+ expect(collapseTwo).not.toHaveClass('show')
+ expect(nestedCollapseOne).toHaveClass('show')
+
+ collapseTwo.addEventListener('shown.bs.collapse', () => {
+ expect(collapseOne).not.toHaveClass('show')
+ expect(collapseTwo).toHaveClass('show')
+ expect(nestedCollapseOne).toHaveClass('show')
+ resolve()
+ })
+
+ triggerTwo.click()
+ nestedCollapseOne.removeEventListener('shown.bs.collapse', handlerNestedCollapseOne)
+ }
+
+ collapseOne.addEventListener('shown.bs.collapse', handlerCollapseOne)
+ trigger.click()
+ })
+ })
+
+ it('should add "collapsed" class and set aria-expanded to triggers only when all the targeted collapse are hidden', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a id="trigger1" role="button" data-bs-toggle="collapse" href="#test1"></a>',
+ '<a id="trigger2" role="button" data-bs-toggle="collapse" href="#test2"></a>',
+ '<a id="trigger3" role="button" data-bs-toggle="collapse" href=".multi"></a>',
+ '<div id="test1" class="multi"></div>',
+ '<div id="test2" class="multi"></div>'
+ ].join('')
+
+ const trigger1 = fixtureEl.querySelector('#trigger1')
+ const trigger2 = fixtureEl.querySelector('#trigger2')
+ const trigger3 = fixtureEl.querySelector('#trigger3')
+ const target1 = fixtureEl.querySelector('#test1')
+ const target2 = fixtureEl.querySelector('#test2')
+
+ const target2Shown = () => {
+ expect(trigger1).not.toHaveClass('collapsed')
+ expect(trigger1.getAttribute('aria-expanded')).toEqual('true')
+
+ expect(trigger2).not.toHaveClass('collapsed')
+ expect(trigger2.getAttribute('aria-expanded')).toEqual('true')
+
+ expect(trigger3).not.toHaveClass('collapsed')
+ expect(trigger3.getAttribute('aria-expanded')).toEqual('true')
+
+ target2.addEventListener('hidden.bs.collapse', () => {
+ expect(trigger1).not.toHaveClass('collapsed')
+ expect(trigger1.getAttribute('aria-expanded')).toEqual('true')
+
+ expect(trigger2).toHaveClass('collapsed')
+ expect(trigger2.getAttribute('aria-expanded')).toEqual('false')
+
+ expect(trigger3).not.toHaveClass('collapsed')
+ expect(trigger3.getAttribute('aria-expanded')).toEqual('true')
+
+ target1.addEventListener('hidden.bs.collapse', () => {
+ expect(trigger1).toHaveClass('collapsed')
+ expect(trigger1.getAttribute('aria-expanded')).toEqual('false')
+
+ expect(trigger2).toHaveClass('collapsed')
+ expect(trigger2.getAttribute('aria-expanded')).toEqual('false')
+
+ expect(trigger3).toHaveClass('collapsed')
+ expect(trigger3.getAttribute('aria-expanded')).toEqual('false')
+ resolve()
+ })
+
+ trigger1.click()
+ })
+
+ trigger2.click()
+ }
+
+ target2.addEventListener('shown.bs.collapse', target2Shown)
+ trigger3.click()
+ })
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a collapse', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.collapse = Collapse.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.collapse.call(jQueryMock)
+
+ expect(Collapse.getInstance(div)).not.toBeNull()
+ })
+
+ it('should not re create a collapse', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const collapse = new Collapse(div)
+
+ jQueryMock.fn.collapse = Collapse.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.collapse.call(jQueryMock)
+
+ expect(Collapse.getInstance(div)).toEqual(collapse)
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.collapse = Collapse.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.collapse.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return collapse instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const collapse = new Collapse(div)
+
+ expect(Collapse.getInstance(div)).toEqual(collapse)
+ expect(Collapse.getInstance(div)).toBeInstanceOf(Collapse)
+ })
+
+ it('should return null when there is no collapse instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Collapse.getInstance(div)).toBeNull()
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return collapse instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const collapse = new Collapse(div)
+
+ expect(Collapse.getOrCreateInstance(div)).toEqual(collapse)
+ expect(Collapse.getInstance(div)).toEqual(Collapse.getOrCreateInstance(div, {}))
+ expect(Collapse.getOrCreateInstance(div)).toBeInstanceOf(Collapse)
+ })
+
+ it('should return new instance when there is no collapse instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Collapse.getInstance(div)).toBeNull()
+ expect(Collapse.getOrCreateInstance(div)).toBeInstanceOf(Collapse)
+ })
+
+ it('should return new instance when there is no collapse instance with given configuration', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Collapse.getInstance(div)).toBeNull()
+ const collapse = Collapse.getOrCreateInstance(div, {
+ toggle: false
+ })
+ expect(collapse).toBeInstanceOf(Collapse)
+
+ expect(collapse._config.toggle).toBeFalse()
+ })
+
+ it('should return the instance when exists without given configuration', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const collapse = new Collapse(div, {
+ toggle: false
+ })
+ expect(Collapse.getInstance(div)).toEqual(collapse)
+
+ const collapse2 = Collapse.getOrCreateInstance(div, {
+ toggle: true
+ })
+ expect(collapse).toBeInstanceOf(Collapse)
+ expect(collapse2).toEqual(collapse)
+
+ expect(collapse2._config.toggle).toBeFalse()
+ })
+ })
+})
diff --git a/js/tests/unit/dom/data.spec.js b/js/tests/unit/dom/data.spec.js
new file mode 100644
index 0000000..e898cbb
--- /dev/null
+++ b/js/tests/unit/dom/data.spec.js
@@ -0,0 +1,106 @@
+import Data from '../../../src/dom/data'
+import { getFixture, clearFixture } from '../../helpers/fixture'
+
+describe('Data', () => {
+ const TEST_KEY = 'bs.test'
+ const UNKNOWN_KEY = 'bs.unknown'
+ const TEST_DATA = {
+ test: 'bsData'
+ }
+
+ let fixtureEl
+ let div
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ beforeEach(() => {
+ fixtureEl.innerHTML = '<div></div>'
+ div = fixtureEl.querySelector('div')
+ })
+
+ afterEach(() => {
+ Data.remove(div, TEST_KEY)
+ clearFixture()
+ })
+
+ it('should return null for unknown elements', () => {
+ const data = { ...TEST_DATA }
+
+ Data.set(div, TEST_KEY, data)
+
+ expect(Data.get(null)).toBeNull()
+ expect(Data.get(undefined)).toBeNull()
+ expect(Data.get(document.createElement('div'), TEST_KEY)).toBeNull()
+ })
+
+ it('should return null for unknown keys', () => {
+ const data = { ...TEST_DATA }
+
+ Data.set(div, TEST_KEY, data)
+
+ expect(Data.get(div, null)).toBeNull()
+ expect(Data.get(div, undefined)).toBeNull()
+ expect(Data.get(div, UNKNOWN_KEY)).toBeNull()
+ })
+
+ it('should store data for an element with a given key and return it', () => {
+ const data = { ...TEST_DATA }
+
+ Data.set(div, TEST_KEY, data)
+
+ expect(Data.get(div, TEST_KEY)).toEqual(data)
+ })
+
+ it('should overwrite data if something is already stored', () => {
+ const data = { ...TEST_DATA }
+ const copy = { ...data }
+
+ Data.set(div, TEST_KEY, data)
+ Data.set(div, TEST_KEY, copy)
+
+ // Using `toBe` since spread creates a shallow copy
+ expect(Data.get(div, TEST_KEY)).not.toBe(data)
+ expect(Data.get(div, TEST_KEY)).toBe(copy)
+ })
+
+ it('should do nothing when an element has nothing stored', () => {
+ Data.remove(div, TEST_KEY)
+
+ expect().nothing()
+ })
+
+ it('should remove nothing for an unknown key', () => {
+ const data = { ...TEST_DATA }
+
+ Data.set(div, TEST_KEY, data)
+ Data.remove(div, UNKNOWN_KEY)
+
+ expect(Data.get(div, TEST_KEY)).toEqual(data)
+ })
+
+ it('should remove data for a given key', () => {
+ const data = { ...TEST_DATA }
+
+ Data.set(div, TEST_KEY, data)
+ Data.remove(div, TEST_KEY)
+
+ expect(Data.get(div, TEST_KEY)).toBeNull()
+ })
+
+ /* eslint-disable no-console */
+ it('should console.error a message if called with multiple keys', () => {
+ console.error = jasmine.createSpy('console.error')
+
+ const data = { ...TEST_DATA }
+ const copy = { ...data }
+
+ Data.set(div, TEST_KEY, data)
+ Data.set(div, UNKNOWN_KEY, copy)
+
+ expect(console.error).toHaveBeenCalled()
+ expect(Data.get(div, UNKNOWN_KEY)).toBeNull()
+ })
+ /* eslint-enable no-console */
+})
diff --git a/js/tests/unit/dom/event-handler.spec.js b/js/tests/unit/dom/event-handler.spec.js
new file mode 100644
index 0000000..623b9c1
--- /dev/null
+++ b/js/tests/unit/dom/event-handler.spec.js
@@ -0,0 +1,480 @@
+import EventHandler from '../../../src/dom/event-handler'
+import { clearFixture, getFixture } from '../../helpers/fixture'
+import { noop } from '../../../src/util'
+
+describe('EventHandler', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('on', () => {
+ it('should not add event listener if the event is not a string', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ EventHandler.on(div, null, noop)
+ EventHandler.on(null, 'click', noop)
+
+ expect().nothing()
+ })
+
+ it('should add event listener', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ EventHandler.on(div, 'click', () => {
+ expect().nothing()
+ resolve()
+ })
+
+ div.click()
+ })
+ })
+
+ it('should add namespaced event listener', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ EventHandler.on(div, 'bs.namespace', () => {
+ expect().nothing()
+ resolve()
+ })
+
+ EventHandler.trigger(div, 'bs.namespace')
+ })
+ })
+
+ it('should add native namespaced event listener', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ EventHandler.on(div, 'click.namespace', () => {
+ expect().nothing()
+ resolve()
+ })
+
+ EventHandler.trigger(div, 'click')
+ })
+ })
+
+ it('should handle event delegation', () => {
+ return new Promise(resolve => {
+ EventHandler.on(document, 'click', '.test', () => {
+ expect().nothing()
+ resolve()
+ })
+
+ fixtureEl.innerHTML = '<div class="test"></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ div.click()
+ })
+ })
+
+ it('should handle mouseenter/mouseleave like the native counterpart', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="outer">',
+ '<div class="inner">',
+ '<div class="nested">',
+ '<div class="deep"></div>',
+ '</div>',
+ '</div>',
+ '<div class="sibling"></div>',
+ '</div>'
+ ].join('')
+
+ const outer = fixtureEl.querySelector('.outer')
+ const inner = fixtureEl.querySelector('.inner')
+ const nested = fixtureEl.querySelector('.nested')
+ const deep = fixtureEl.querySelector('.deep')
+ const sibling = fixtureEl.querySelector('.sibling')
+
+ const enterSpy = jasmine.createSpy('mouseenter')
+ const leaveSpy = jasmine.createSpy('mouseleave')
+ const delegateEnterSpy = jasmine.createSpy('mouseenter')
+ const delegateLeaveSpy = jasmine.createSpy('mouseleave')
+
+ EventHandler.on(inner, 'mouseenter', enterSpy)
+ EventHandler.on(inner, 'mouseleave', leaveSpy)
+ EventHandler.on(outer, 'mouseenter', '.inner', delegateEnterSpy)
+ EventHandler.on(outer, 'mouseleave', '.inner', delegateLeaveSpy)
+
+ EventHandler.on(sibling, 'mouseenter', () => {
+ expect(enterSpy.calls.count()).toEqual(2)
+ expect(leaveSpy.calls.count()).toEqual(2)
+ expect(delegateEnterSpy.calls.count()).toEqual(2)
+ expect(delegateLeaveSpy.calls.count()).toEqual(2)
+ resolve()
+ })
+
+ const moveMouse = (from, to) => {
+ from.dispatchEvent(new MouseEvent('mouseout', {
+ bubbles: true,
+ relatedTarget: to
+ }))
+
+ to.dispatchEvent(new MouseEvent('mouseover', {
+ bubbles: true,
+ relatedTarget: from
+ }))
+ }
+
+ // from outer to deep and back to outer (nested)
+ moveMouse(outer, inner)
+ moveMouse(inner, nested)
+ moveMouse(nested, deep)
+ moveMouse(deep, nested)
+ moveMouse(nested, inner)
+ moveMouse(inner, outer)
+
+ setTimeout(() => {
+ expect(enterSpy.calls.count()).toEqual(1)
+ expect(leaveSpy.calls.count()).toEqual(1)
+ expect(delegateEnterSpy.calls.count()).toEqual(1)
+ expect(delegateLeaveSpy.calls.count()).toEqual(1)
+
+ // from outer to inner to sibling (adjacent)
+ moveMouse(outer, inner)
+ moveMouse(inner, sibling)
+ }, 20)
+ })
+ })
+ })
+
+ describe('one', () => {
+ it('should call listener just once', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ let called = 0
+ const div = fixtureEl.querySelector('div')
+ const obj = {
+ oneListener() {
+ called++
+ }
+ }
+
+ EventHandler.one(div, 'bootstrap', obj.oneListener)
+
+ EventHandler.trigger(div, 'bootstrap')
+ EventHandler.trigger(div, 'bootstrap')
+
+ setTimeout(() => {
+ expect(called).toEqual(1)
+ resolve()
+ }, 20)
+ })
+ })
+
+ it('should call delegated listener just once', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ let called = 0
+ const div = fixtureEl.querySelector('div')
+ const obj = {
+ oneListener() {
+ called++
+ }
+ }
+
+ EventHandler.one(fixtureEl, 'bootstrap', 'div', obj.oneListener)
+
+ EventHandler.trigger(div, 'bootstrap')
+ EventHandler.trigger(div, 'bootstrap')
+
+ setTimeout(() => {
+ expect(called).toEqual(1)
+ resolve()
+ }, 20)
+ })
+ })
+ })
+
+ describe('off', () => {
+ it('should not remove a listener', () => {
+ fixtureEl.innerHTML = '<div></div>'
+ const div = fixtureEl.querySelector('div')
+
+ EventHandler.off(div, null, noop)
+ EventHandler.off(null, 'click', noop)
+ expect().nothing()
+ })
+
+ it('should remove a listener', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+ const div = fixtureEl.querySelector('div')
+
+ let called = 0
+ const handler = () => {
+ called++
+ }
+
+ EventHandler.on(div, 'foobar', handler)
+ EventHandler.trigger(div, 'foobar')
+
+ EventHandler.off(div, 'foobar', handler)
+ EventHandler.trigger(div, 'foobar')
+
+ setTimeout(() => {
+ expect(called).toEqual(1)
+ resolve()
+ }, 20)
+ })
+ })
+
+ it('should remove all the events', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+ const div = fixtureEl.querySelector('div')
+
+ let called = 0
+
+ EventHandler.on(div, 'foobar', () => {
+ called++
+ })
+ EventHandler.on(div, 'foobar', () => {
+ called++
+ })
+ EventHandler.trigger(div, 'foobar')
+
+ EventHandler.off(div, 'foobar')
+ EventHandler.trigger(div, 'foobar')
+
+ setTimeout(() => {
+ expect(called).toEqual(2)
+ resolve()
+ }, 20)
+ })
+ })
+
+ it('should remove all the namespaced listeners if namespace is passed', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+ const div = fixtureEl.querySelector('div')
+
+ let called = 0
+
+ EventHandler.on(div, 'foobar.namespace', () => {
+ called++
+ })
+ EventHandler.on(div, 'foofoo.namespace', () => {
+ called++
+ })
+ EventHandler.trigger(div, 'foobar.namespace')
+ EventHandler.trigger(div, 'foofoo.namespace')
+
+ EventHandler.off(div, '.namespace')
+ EventHandler.trigger(div, 'foobar.namespace')
+ EventHandler.trigger(div, 'foofoo.namespace')
+
+ setTimeout(() => {
+ expect(called).toEqual(2)
+ resolve()
+ }, 20)
+ })
+ })
+
+ it('should remove the namespaced listeners', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+ const div = fixtureEl.querySelector('div')
+
+ let calledCallback1 = 0
+ let calledCallback2 = 0
+
+ EventHandler.on(div, 'foobar.namespace', () => {
+ calledCallback1++
+ })
+ EventHandler.on(div, 'foofoo.namespace', () => {
+ calledCallback2++
+ })
+
+ EventHandler.trigger(div, 'foobar.namespace')
+ EventHandler.off(div, 'foobar.namespace')
+ EventHandler.trigger(div, 'foobar.namespace')
+
+ EventHandler.trigger(div, 'foofoo.namespace')
+
+ setTimeout(() => {
+ expect(calledCallback1).toEqual(1)
+ expect(calledCallback2).toEqual(1)
+ resolve()
+ }, 20)
+ })
+ })
+
+ it('should remove the all the namespaced listeners for native events', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+ const div = fixtureEl.querySelector('div')
+
+ let called = 0
+
+ EventHandler.on(div, 'click.namespace', () => {
+ called++
+ })
+ EventHandler.on(div, 'click.namespace2', () => {
+ called++
+ })
+
+ EventHandler.trigger(div, 'click')
+ EventHandler.off(div, 'click')
+ EventHandler.trigger(div, 'click')
+
+ setTimeout(() => {
+ expect(called).toEqual(2)
+ resolve()
+ }, 20)
+ })
+ })
+
+ it('should remove the specified namespaced listeners for native events', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+ const div = fixtureEl.querySelector('div')
+
+ let called1 = 0
+ let called2 = 0
+
+ EventHandler.on(div, 'click.namespace', () => {
+ called1++
+ })
+ EventHandler.on(div, 'click.namespace2', () => {
+ called2++
+ })
+ EventHandler.trigger(div, 'click')
+
+ EventHandler.off(div, 'click.namespace')
+ EventHandler.trigger(div, 'click')
+
+ setTimeout(() => {
+ expect(called1).toEqual(1)
+ expect(called2).toEqual(2)
+ resolve()
+ }, 20)
+ })
+ })
+
+ it('should remove a listener registered by .one', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const handler = () => {
+ reject(new Error('called'))
+ }
+
+ EventHandler.one(div, 'foobar', handler)
+ EventHandler.off(div, 'foobar', handler)
+
+ EventHandler.trigger(div, 'foobar')
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 20)
+ })
+ })
+
+ it('should remove the correct delegated event listener', () => {
+ const element = document.createElement('div')
+ const subelement = document.createElement('span')
+ element.append(subelement)
+
+ const anchor = document.createElement('a')
+ element.append(anchor)
+
+ let i = 0
+ const handler = () => {
+ i++
+ }
+
+ EventHandler.on(element, 'click', 'a', handler)
+ EventHandler.on(element, 'click', 'span', handler)
+
+ fixtureEl.append(element)
+
+ EventHandler.trigger(anchor, 'click')
+ EventHandler.trigger(subelement, 'click')
+
+ // first listeners called
+ expect(i).toEqual(2)
+
+ EventHandler.off(element, 'click', 'span', handler)
+ EventHandler.trigger(subelement, 'click')
+
+ // removed listener not called
+ expect(i).toEqual(2)
+
+ EventHandler.trigger(anchor, 'click')
+
+ // not removed listener called
+ expect(i).toEqual(3)
+
+ EventHandler.on(element, 'click', 'span', handler)
+ EventHandler.trigger(anchor, 'click')
+ EventHandler.trigger(subelement, 'click')
+
+ // listener re-registered
+ expect(i).toEqual(5)
+
+ EventHandler.off(element, 'click', 'span')
+ EventHandler.trigger(subelement, 'click')
+
+ // listener removed again
+ expect(i).toEqual(5)
+ })
+ })
+
+ describe('general functionality', () => {
+ it('should hydrate properties, and make them configurable', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="div1">',
+ ' <div id="div2"></div>',
+ ' <div id="div3"></div>',
+ '</div>'
+ ].join('')
+
+ const div1 = fixtureEl.querySelector('#div1')
+ const div2 = fixtureEl.querySelector('#div2')
+
+ EventHandler.on(div1, 'click', event => {
+ expect(event.currentTarget).toBe(div2)
+ expect(event.delegateTarget).toBe(div1)
+ expect(event.originalTarget).toBeNull()
+
+ Object.defineProperty(event, 'currentTarget', {
+ configurable: true,
+ get() {
+ return div1
+ }
+ })
+
+ expect(event.currentTarget).toBe(div1)
+ resolve()
+ })
+
+ expect(() => {
+ EventHandler.trigger(div1, 'click', { originalTarget: null, currentTarget: div2 })
+ }).not.toThrowError(TypeError)
+ })
+ })
+ })
+})
diff --git a/js/tests/unit/dom/manipulator.spec.js b/js/tests/unit/dom/manipulator.spec.js
new file mode 100644
index 0000000..4561e2e
--- /dev/null
+++ b/js/tests/unit/dom/manipulator.spec.js
@@ -0,0 +1,135 @@
+import Manipulator from '../../../src/dom/manipulator'
+import { clearFixture, getFixture } from '../../helpers/fixture'
+
+describe('Manipulator', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('setDataAttribute', () => {
+ it('should set data attribute prefixed with bs', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ Manipulator.setDataAttribute(div, 'key', 'value')
+ expect(div.getAttribute('data-bs-key')).toEqual('value')
+ })
+
+ it('should set data attribute in kebab case', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ Manipulator.setDataAttribute(div, 'testKey', 'value')
+ expect(div.getAttribute('data-bs-test-key')).toEqual('value')
+ })
+ })
+
+ describe('removeDataAttribute', () => {
+ it('should only remove bs-prefixed data attribute', () => {
+ fixtureEl.innerHTML = '<div data-bs-key="value" data-key-bs="postfixed" data-key="value"></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ Manipulator.removeDataAttribute(div, 'key')
+ expect(div.getAttribute('data-bs-key')).toBeNull()
+ expect(div.getAttribute('data-key-bs')).toEqual('postfixed')
+ expect(div.getAttribute('data-key')).toEqual('value')
+ })
+
+ it('should remove data attribute in kebab case', () => {
+ fixtureEl.innerHTML = '<div data-bs-test-key="value"></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ Manipulator.removeDataAttribute(div, 'testKey')
+ expect(div.getAttribute('data-bs-test-key')).toBeNull()
+ })
+ })
+
+ describe('getDataAttributes', () => {
+ it('should return an empty object for null', () => {
+ expect(Manipulator.getDataAttributes(null)).toEqual({})
+ expect().nothing()
+ })
+
+ it('should get only bs-prefixed data attributes without bs namespace', () => {
+ fixtureEl.innerHTML = '<div data-bs-toggle="tabs" data-bs-target="#element" data-another="value" data-target-bs="#element" data-in-bs-out="in-between"></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Manipulator.getDataAttributes(div)).toEqual({
+ toggle: 'tabs',
+ target: '#element'
+ })
+ })
+
+ it('should omit `bs-config` data attribute', () => {
+ fixtureEl.innerHTML = '<div data-bs-toggle="tabs" data-bs-target="#element" data-bs-config=\'{"testBool":false}\'></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Manipulator.getDataAttributes(div)).toEqual({
+ toggle: 'tabs',
+ target: '#element'
+ })
+ })
+ })
+
+ describe('getDataAttribute', () => {
+ it('should only get bs-prefixed data attribute', () => {
+ fixtureEl.innerHTML = '<div data-bs-key="value" data-test-bs="postFixed" data-toggle="tab"></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Manipulator.getDataAttribute(div, 'key')).toEqual('value')
+ expect(Manipulator.getDataAttribute(div, 'test')).toBeNull()
+ expect(Manipulator.getDataAttribute(div, 'toggle')).toBeNull()
+ })
+
+ it('should get data attribute in kebab case', () => {
+ fixtureEl.innerHTML = '<div data-bs-test-key="value" ></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Manipulator.getDataAttribute(div, 'testKey')).toEqual('value')
+ })
+
+ it('should normalize data', () => {
+ fixtureEl.innerHTML = '<div data-bs-test="false" ></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Manipulator.getDataAttribute(div, 'test')).toBeFalse()
+
+ div.setAttribute('data-bs-test', 'true')
+ expect(Manipulator.getDataAttribute(div, 'test')).toBeTrue()
+
+ div.setAttribute('data-bs-test', '1')
+ expect(Manipulator.getDataAttribute(div, 'test')).toEqual(1)
+ })
+
+ it('should normalize json data', () => {
+ fixtureEl.innerHTML = '<div data-bs-test=\'{"delay":{"show":100,"hide":10}}\'></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Manipulator.getDataAttribute(div, 'test')).toEqual({ delay: { show: 100, hide: 10 } })
+
+ const objectData = { 'Super Hero': ['Iron Man', 'Super Man'], testNum: 90, url: 'http://localhost:8080/test?foo=bar' }
+ const dataStr = JSON.stringify(objectData)
+ div.setAttribute('data-bs-test', encodeURIComponent(dataStr))
+ expect(Manipulator.getDataAttribute(div, 'test')).toEqual(objectData)
+
+ div.setAttribute('data-bs-test', dataStr)
+ expect(Manipulator.getDataAttribute(div, 'test')).toEqual(objectData)
+ })
+ })
+})
diff --git a/js/tests/unit/dom/selector-engine.spec.js b/js/tests/unit/dom/selector-engine.spec.js
new file mode 100644
index 0000000..0245896
--- /dev/null
+++ b/js/tests/unit/dom/selector-engine.spec.js
@@ -0,0 +1,236 @@
+import SelectorEngine from '../../../src/dom/selector-engine'
+import { getFixture, clearFixture } from '../../helpers/fixture'
+
+describe('SelectorEngine', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('find', () => {
+ it('should find elements', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(SelectorEngine.find('div', fixtureEl)).toEqual([div])
+ })
+
+ it('should find elements globally', () => {
+ fixtureEl.innerHTML = '<div id="test"></div>'
+
+ const div = fixtureEl.querySelector('#test')
+
+ expect(SelectorEngine.find('#test')).toEqual([div])
+ })
+
+ it('should handle :scope selectors', () => {
+ fixtureEl.innerHTML = [
+ '<ul>',
+ ' <li></li>',
+ ' <li>',
+ ' <a href="#" class="active">link</a>',
+ ' </li>',
+ ' <li></li>',
+ '</ul>'
+ ].join('')
+
+ const listEl = fixtureEl.querySelector('ul')
+ const aActive = fixtureEl.querySelector('.active')
+
+ expect(SelectorEngine.find(':scope > li > .active', listEl)).toEqual([aActive])
+ })
+ })
+
+ describe('findOne', () => {
+ it('should return one element', () => {
+ fixtureEl.innerHTML = '<div id="test"></div>'
+
+ const div = fixtureEl.querySelector('#test')
+
+ expect(SelectorEngine.findOne('#test')).toEqual(div)
+ })
+ })
+
+ describe('children', () => {
+ it('should find children', () => {
+ fixtureEl.innerHTML = [
+ '<ul>',
+ ' <li></li>',
+ ' <li></li>',
+ ' <li></li>',
+ '</ul>'
+ ].join('')
+
+ const list = fixtureEl.querySelector('ul')
+ const liList = [].concat(...fixtureEl.querySelectorAll('li'))
+ const result = SelectorEngine.children(list, 'li')
+
+ expect(result).toEqual(liList)
+ })
+ })
+
+ describe('parents', () => {
+ it('should return parents', () => {
+ expect(SelectorEngine.parents(fixtureEl, 'body')).toHaveSize(1)
+ })
+ })
+
+ describe('prev', () => {
+ it('should return previous element', () => {
+ fixtureEl.innerHTML = '<div class="test"></div><button class="btn"></button>'
+
+ const btn = fixtureEl.querySelector('.btn')
+ const divTest = fixtureEl.querySelector('.test')
+
+ expect(SelectorEngine.prev(btn, '.test')).toEqual([divTest])
+ })
+
+ it('should return previous element with an extra element between', () => {
+ fixtureEl.innerHTML = [
+ '<div class="test"></div>',
+ '<span></span>',
+ '<button class="btn"></button>'
+ ].join('')
+
+ const btn = fixtureEl.querySelector('.btn')
+ const divTest = fixtureEl.querySelector('.test')
+
+ expect(SelectorEngine.prev(btn, '.test')).toEqual([divTest])
+ })
+
+ it('should return previous element with comments or text nodes between', () => {
+ fixtureEl.innerHTML = [
+ '<div class="test"></div>',
+ '<div class="test"></div>',
+ '<!-- Comment-->',
+ 'Text',
+ '<button class="btn"></button>'
+ ].join('')
+
+ const btn = fixtureEl.querySelector('.btn')
+ const divTest = fixtureEl.querySelectorAll('.test')[1]
+
+ expect(SelectorEngine.prev(btn, '.test')).toEqual([divTest])
+ })
+ })
+
+ describe('next', () => {
+ it('should return next element', () => {
+ fixtureEl.innerHTML = '<div class="test"></div><button class="btn"></button>'
+
+ const btn = fixtureEl.querySelector('.btn')
+ const divTest = fixtureEl.querySelector('.test')
+
+ expect(SelectorEngine.next(divTest, '.btn')).toEqual([btn])
+ })
+
+ it('should return next element with an extra element between', () => {
+ fixtureEl.innerHTML = [
+ '<div class="test"></div>',
+ '<span></span>',
+ '<button class="btn"></button>'
+ ].join('')
+
+ const btn = fixtureEl.querySelector('.btn')
+ const divTest = fixtureEl.querySelector('.test')
+
+ expect(SelectorEngine.next(divTest, '.btn')).toEqual([btn])
+ })
+
+ it('should return next element with comments or text nodes between', () => {
+ fixtureEl.innerHTML = [
+ '<div class="test"></div>',
+ '<!-- Comment-->',
+ 'Text',
+ '<button class="btn"></button>',
+ '<button class="btn"></button>'
+ ].join('')
+
+ const btn = fixtureEl.querySelector('.btn')
+ const divTest = fixtureEl.querySelector('.test')
+
+ expect(SelectorEngine.next(divTest, '.btn')).toEqual([btn])
+ })
+ })
+
+ describe('focusableChildren', () => {
+ it('should return only elements with specific tag names', () => {
+ fixtureEl.innerHTML = [
+ '<div>lorem</div>',
+ '<span>lorem</span>',
+ '<a>lorem</a>',
+ '<button>lorem</button>',
+ '<input>',
+ '<textarea></textarea>',
+ '<select></select>',
+ '<details>lorem</details>'
+ ].join('')
+
+ const expectedElements = [
+ fixtureEl.querySelector('a'),
+ fixtureEl.querySelector('button'),
+ fixtureEl.querySelector('input'),
+ fixtureEl.querySelector('textarea'),
+ fixtureEl.querySelector('select'),
+ fixtureEl.querySelector('details')
+ ]
+
+ expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements)
+ })
+
+ it('should return any element with non negative tab index', () => {
+ fixtureEl.innerHTML = [
+ '<div tabindex>lorem</div>',
+ '<div tabindex="0">lorem</div>',
+ '<div tabindex="10">lorem</div>'
+ ].join('')
+
+ const expectedElements = [
+ fixtureEl.querySelector('[tabindex]'),
+ fixtureEl.querySelector('[tabindex="0"]'),
+ fixtureEl.querySelector('[tabindex="10"]')
+ ]
+
+ expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements)
+ })
+
+ it('should return not return elements with negative tab index', () => {
+ fixtureEl.innerHTML = '<button tabindex="-1">lorem</button>'
+
+ const expectedElements = []
+
+ expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements)
+ })
+
+ it('should return contenteditable elements', () => {
+ fixtureEl.innerHTML = '<div contenteditable="true">lorem</div>'
+
+ const expectedElements = [fixtureEl.querySelector('[contenteditable="true"]')]
+
+ expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements)
+ })
+
+ it('should not return disabled elements', () => {
+ fixtureEl.innerHTML = '<button disabled="true">lorem</button>'
+
+ const expectedElements = []
+
+ expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements)
+ })
+
+ it('should not return invisible elements', () => {
+ fixtureEl.innerHTML = '<button style="display:none;">lorem</button>'
+
+ const expectedElements = []
+
+ expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements)
+ })
+ })
+})
+
diff --git a/js/tests/unit/dropdown.spec.js b/js/tests/unit/dropdown.spec.js
new file mode 100644
index 0000000..2bbd7c0
--- /dev/null
+++ b/js/tests/unit/dropdown.spec.js
@@ -0,0 +1,2430 @@
+import Dropdown from '../../src/dropdown'
+import EventHandler from '../../src/dom/event-handler'
+import { noop } from '../../src/util/index'
+import { clearFixture, createEvent, getFixture, jQueryMock } from '../helpers/fixture'
+
+describe('Dropdown', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Dropdown.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin default config', () => {
+ expect(Dropdown.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('DefaultType', () => {
+ it('should return plugin default type config', () => {
+ expect(Dropdown.DefaultType).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(Dropdown.DATA_KEY).toEqual('bs.dropdown')
+ })
+ })
+
+ describe('constructor', () => {
+ it('should take care of element either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdownBySelector = new Dropdown('[data-bs-toggle="dropdown"]')
+ const dropdownByElement = new Dropdown(btnDropdown)
+
+ expect(dropdownBySelector._element).toEqual(btnDropdown)
+ expect(dropdownByElement._element).toEqual(btnDropdown)
+ })
+
+ it('should work on invalid markup', () => {
+ return new Promise(resolve => {
+ // TODO: REMOVE in v6
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const dropdownElem = fixtureEl.querySelector('.dropdown-menu')
+ const dropdown = new Dropdown(dropdownElem)
+
+ dropdownElem.addEventListener('shown.bs.dropdown', () => {
+ resolve()
+ })
+
+ dropdown.show()
+ })
+ })
+
+ it('should create offset modifier correctly when offset option is a function', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const getOffset = jasmine.createSpy('getOffset').and.returnValue([10, 20])
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown, {
+ offset: getOffset,
+ popperConfig: {
+ onFirstUpdate(state) {
+ expect(getOffset).toHaveBeenCalledWith({
+ popper: state.rects.popper,
+ reference: state.rects.reference,
+ placement: state.placement
+ }, btnDropdown)
+ resolve()
+ }
+ }
+ })
+ const offset = dropdown._getOffset()
+
+ expect(typeof offset).toEqual('function')
+
+ dropdown.show()
+ })
+ })
+
+ it('should create offset modifier correctly when offset option is a string into data attribute', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-offset="10,20">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ expect(dropdown._getOffset()).toEqual([10, 20])
+ })
+
+ it('should allow to pass config to Popper with `popperConfig`', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown, {
+ popperConfig: {
+ placement: 'left'
+ }
+ })
+
+ const popperConfig = dropdown._getPopperConfig()
+
+ expect(popperConfig.placement).toEqual('left')
+ })
+
+ it('should allow to pass config to Popper with `popperConfig` as a function', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-placement="right">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const getPopperConfig = jasmine.createSpy('getPopperConfig').and.returnValue({ placement: 'left' })
+ const dropdown = new Dropdown(btnDropdown, {
+ popperConfig: getPopperConfig
+ })
+
+ const popperConfig = dropdown._getPopperConfig()
+
+ expect(getPopperConfig).toHaveBeenCalled()
+ expect(popperConfig.placement).toEqual('left')
+ })
+ })
+
+ describe('toggle', () => {
+ it('should toggle a dropdown', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should destroy old popper references on toggle', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="first dropdown">',
+ ' <button class="firstBtn btn" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>',
+ '<div class="second dropdown">',
+ ' <button class="secondBtn btn" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown1 = fixtureEl.querySelector('.firstBtn')
+ const btnDropdown2 = fixtureEl.querySelector('.secondBtn')
+ const firstDropdownEl = fixtureEl.querySelector('.first')
+ const secondDropdownEl = fixtureEl.querySelector('.second')
+ const dropdown1 = new Dropdown(btnDropdown1)
+
+ firstDropdownEl.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown1).toHaveClass('show')
+ spyOn(dropdown1._popper, 'destroy')
+ btnDropdown2.click()
+ })
+
+ secondDropdownEl.addEventListener('shown.bs.dropdown', () => setTimeout(() => {
+ expect(dropdown1._popper.destroy).toHaveBeenCalled()
+ resolve()
+ }))
+
+ dropdown1.toggle()
+ })
+ })
+
+ it('should toggle a dropdown and add/remove event listener on mobile', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const defaultValueOnTouchStart = document.documentElement.ontouchstart
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ document.documentElement.ontouchstart = noop
+ const spy = spyOn(EventHandler, 'on')
+ const spyOff = spyOn(EventHandler, 'off')
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ expect(spy).toHaveBeenCalledWith(jasmine.any(Object), 'mouseover', noop)
+
+ dropdown.toggle()
+ })
+
+ btnDropdown.addEventListener('hidden.bs.dropdown', () => {
+ expect(btnDropdown).not.toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('false')
+ expect(spyOff).toHaveBeenCalledWith(jasmine.any(Object), 'mouseover', noop)
+
+ document.documentElement.ontouchstart = defaultValueOnTouchStart
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a dropdown at the right', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu dropdown-menu-end">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a centered dropdown', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown-center">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a dropup', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropup">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropupEl = fixtureEl.querySelector('.dropup')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropupEl.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a dropup centered', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropup-center">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropupEl = fixtureEl.querySelector('.dropup-center')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropupEl.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a dropup at the right', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropup">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu dropdown-menu-end">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropupEl = fixtureEl.querySelector('.dropup')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropupEl.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a dropend', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropend">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropendEl = fixtureEl.querySelector('.dropend')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropendEl.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a dropstart', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropstart">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropstartEl = fixtureEl.querySelector('.dropstart')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropstartEl.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a dropdown with parent reference', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown, {
+ reference: 'parent'
+ })
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a dropdown with a dom node reference', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown, {
+ reference: fixtureEl
+ })
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a dropdown with a jquery object reference', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown, {
+ reference: { 0: fixtureEl, jquery: 'jQuery' }
+ })
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a dropdown with a valid virtual element reference', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle visually-hidden" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const virtualElement = {
+ nodeType: 1,
+ getBoundingClientRect() {
+ return {
+ width: 0,
+ height: 0,
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0
+ }
+ }
+ }
+
+ expect(() => new Dropdown(btnDropdown, {
+ reference: {}
+ })).toThrowError(TypeError, 'DROPDOWN: Option "reference" provided type "object" without a required "getBoundingClientRect" method.')
+
+ expect(() => new Dropdown(btnDropdown, {
+ reference: {
+ getBoundingClientRect: 'not-a-function'
+ }
+ })).toThrowError(TypeError, 'DROPDOWN: Option "reference" provided type "object" without a required "getBoundingClientRect" method.')
+
+ // use onFirstUpdate as Poppers internal update is executed async
+ const dropdown = new Dropdown(btnDropdown, {
+ reference: virtualElement,
+ popperConfig: {
+ onFirstUpdate() {
+ expect(spy).toHaveBeenCalled()
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ }
+ }
+ })
+
+ const spy = spyOn(virtualElement, 'getBoundingClientRect').and.callThrough()
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should not toggle a dropdown if the element is disabled', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button disabled class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ reject(new Error('should not throw shown.bs.dropdown event'))
+ })
+
+ dropdown.toggle()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ })
+ })
+ })
+
+ it('should not toggle a dropdown if the element contains .disabled', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle disabled" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ reject(new Error('should not throw shown.bs.dropdown event'))
+ })
+
+ dropdown.toggle()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ })
+ })
+ })
+
+ it('should not toggle a dropdown if the menu is shown', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu show">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ reject(new Error('should not throw shown.bs.dropdown event'))
+ })
+
+ dropdown.toggle()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ })
+ })
+ })
+
+ it('should not toggle a dropdown if show event is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('show.bs.dropdown', event => {
+ event.preventDefault()
+ })
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ reject(new Error('should not throw shown.bs.dropdown event'))
+ })
+
+ dropdown.toggle()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ })
+ })
+ })
+ })
+
+ describe('show', () => {
+ it('should show a dropdown', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ resolve()
+ })
+
+ dropdown.show()
+ })
+ })
+
+ it('should not show a dropdown if the element is disabled', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button disabled class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ reject(new Error('should not throw shown.bs.dropdown event'))
+ })
+
+ dropdown.show()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should not show a dropdown if the element contains .disabled', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle disabled" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ reject(new Error('should not throw shown.bs.dropdown event'))
+ })
+
+ dropdown.show()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should not show a dropdown if the menu is shown', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu show">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ reject(new Error('should not throw shown.bs.dropdown event'))
+ })
+
+ dropdown.show()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should not show a dropdown if show event is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('show.bs.dropdown', event => {
+ event.preventDefault()
+ })
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ reject(new Error('should not throw shown.bs.dropdown event'))
+ })
+
+ dropdown.show()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 10)
+ })
+ })
+ })
+
+ describe('hide', () => {
+ it('should hide a dropdown', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="true">Dropdown</button>',
+ ' <div class="dropdown-menu show">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('hidden.bs.dropdown', () => {
+ expect(dropdownMenu).not.toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('false')
+ resolve()
+ })
+
+ dropdown.hide()
+ })
+ })
+
+ it('should hide a dropdown and destroy popper', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ spyOn(dropdown._popper, 'destroy')
+ dropdown.hide()
+ })
+
+ btnDropdown.addEventListener('hidden.bs.dropdown', () => {
+ expect(dropdown._popper.destroy).toHaveBeenCalled()
+ resolve()
+ })
+
+ dropdown.show()
+ })
+ })
+
+ it('should not hide a dropdown if the element is disabled', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button disabled class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu show">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('hidden.bs.dropdown', () => {
+ reject(new Error('should not throw hidden.bs.dropdown event'))
+ })
+
+ dropdown.hide()
+
+ setTimeout(() => {
+ expect(dropdownMenu).toHaveClass('show')
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should not hide a dropdown if the element contains .disabled', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle disabled" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu show">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('hidden.bs.dropdown', () => {
+ reject(new Error('should not throw hidden.bs.dropdown event'))
+ })
+
+ dropdown.hide()
+
+ setTimeout(() => {
+ expect(dropdownMenu).toHaveClass('show')
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should not hide a dropdown if the menu is not shown', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('hidden.bs.dropdown', () => {
+ reject(new Error('should not throw hidden.bs.dropdown event'))
+ })
+
+ dropdown.hide()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should not hide a dropdown if hide event is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu show">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('hide.bs.dropdown', event => {
+ event.preventDefault()
+ })
+
+ btnDropdown.addEventListener('hidden.bs.dropdown', () => {
+ reject(new Error('should not throw hidden.bs.dropdown event'))
+ })
+
+ dropdown.hide()
+
+ setTimeout(() => {
+ expect(dropdownMenu).toHaveClass('show')
+ resolve()
+ })
+ })
+ })
+
+ it('should remove event listener on touch-enabled device that was added in show method', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Dropdown item</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const defaultValueOnTouchStart = document.documentElement.ontouchstart
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ document.documentElement.ontouchstart = noop
+ const spy = spyOn(EventHandler, 'off')
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ dropdown.hide()
+ })
+
+ btnDropdown.addEventListener('hidden.bs.dropdown', () => {
+ expect(btnDropdown).not.toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('false')
+ expect(spy).toHaveBeenCalled()
+
+ document.documentElement.ontouchstart = defaultValueOnTouchStart
+ resolve()
+ })
+
+ dropdown.show()
+ })
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose dropdown', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+
+ const dropdown = new Dropdown(btnDropdown)
+
+ expect(dropdown._popper).toBeNull()
+ expect(dropdown._menu).not.toBeNull()
+ expect(dropdown._element).not.toBeNull()
+ const spy = spyOn(EventHandler, 'off')
+
+ dropdown.dispose()
+
+ expect(dropdown._menu).toBeNull()
+ expect(dropdown._element).toBeNull()
+ expect(spy).toHaveBeenCalledWith(btnDropdown, Dropdown.EVENT_KEY)
+ })
+
+ it('should dispose dropdown with Popper', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdown.toggle()
+
+ expect(dropdown._popper).not.toBeNull()
+ expect(dropdown._menu).not.toBeNull()
+ expect(dropdown._element).not.toBeNull()
+
+ dropdown.dispose()
+
+ expect(dropdown._popper).toBeNull()
+ expect(dropdown._menu).toBeNull()
+ expect(dropdown._element).toBeNull()
+ })
+ })
+
+ describe('update', () => {
+ it('should call Popper and detect navbar on update', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdown.toggle()
+
+ expect(dropdown._popper).not.toBeNull()
+
+ const spyUpdate = spyOn(dropdown._popper, 'update')
+ const spyDetect = spyOn(dropdown, '_detectNavbar')
+
+ dropdown.update()
+
+ expect(spyUpdate).toHaveBeenCalled()
+ expect(spyDetect).toHaveBeenCalled()
+ })
+
+ it('should just detect navbar on update', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ const spy = spyOn(dropdown, '_detectNavbar')
+
+ dropdown.update()
+
+ expect(dropdown._popper).toBeNull()
+ expect(spy).toHaveBeenCalled()
+ })
+ })
+
+ describe('data-api', () => {
+ it('should show and hide a dropdown', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ let showEventTriggered = false
+ let hideEventTriggered = false
+
+ btnDropdown.addEventListener('show.bs.dropdown', () => {
+ showEventTriggered = true
+ })
+
+ btnDropdown.addEventListener('shown.bs.dropdown', event => setTimeout(() => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ expect(showEventTriggered).toBeTrue()
+ expect(event.relatedTarget).toEqual(btnDropdown)
+ document.body.click()
+ }))
+
+ btnDropdown.addEventListener('hide.bs.dropdown', () => {
+ hideEventTriggered = true
+ })
+
+ btnDropdown.addEventListener('hidden.bs.dropdown', event => {
+ expect(btnDropdown).not.toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('false')
+ expect(hideEventTriggered).toBeTrue()
+ expect(event.relatedTarget).toEqual(btnDropdown)
+ resolve()
+ })
+
+ btnDropdown.click()
+ })
+ })
+
+ it('should not use "static" Popper in navbar', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<nav class="navbar navbar-expand-md bg-light">',
+ ' <div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ ' </div>',
+ '</nav>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(dropdown._popper).not.toBeNull()
+ expect(dropdownMenu.getAttribute('data-bs-popper')).toEqual('static')
+ resolve()
+ })
+
+ dropdown.show()
+ })
+ })
+
+ it('should not collapse the dropdown when clicking a select option nested in the dropdown', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <select>',
+ ' <option selected>Open this select menu</option>',
+ ' <option value="1">One</option>',
+ ' </select>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+ const dropdown = new Dropdown(btnDropdown)
+
+ const hideSpy = spyOn(dropdown, '_completeHide')
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ const clickEvent = new MouseEvent('click', {
+ bubbles: true
+ })
+
+ dropdownMenu.querySelector('option').dispatchEvent(clickEvent)
+ })
+
+ dropdownMenu.addEventListener('click', event => {
+ expect(event.target.tagName).toMatch(/select|option/i)
+
+ Dropdown.clearMenus(event)
+
+ setTimeout(() => {
+ expect(hideSpy).not.toHaveBeenCalled()
+ resolve()
+ }, 10)
+ })
+
+ dropdown.show()
+ })
+ })
+
+ it('should manage bs attribute `data-bs-popper`="static" when dropdown is in navbar', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<nav class="navbar navbar-expand-md bg-light">',
+ ' <div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ ' </div>',
+ '</nav>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(dropdownMenu.getAttribute('data-bs-popper')).toEqual('static')
+ dropdown.hide()
+ })
+
+ btnDropdown.addEventListener('hidden.bs.dropdown', () => {
+ expect(dropdownMenu.getAttribute('data-bs-popper')).toBeNull()
+ resolve()
+ })
+
+ dropdown.show()
+ })
+ })
+
+ it('should not use Popper if display set to static', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-display="static">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ // Popper adds this attribute when we use it
+ expect(dropdownMenu.getAttribute('data-popper-placement')).toBeNull()
+ resolve()
+ })
+
+ btnDropdown.click()
+ })
+ })
+
+ it('should manage bs attribute `data-bs-popper`="static" when display set to static', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-display="static">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(dropdownMenu.getAttribute('data-bs-popper')).toEqual('static')
+ dropdown.hide()
+ })
+
+ btnDropdown.addEventListener('hidden.bs.dropdown', () => {
+ expect(dropdownMenu.getAttribute('data-bs-popper')).toBeNull()
+ resolve()
+ })
+
+ dropdown.show()
+ })
+ })
+
+ it('should remove "show" class if tabbing outside of menu', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+
+ const keyup = createEvent('keyup')
+
+ keyup.key = 'Tab'
+ document.dispatchEvent(keyup)
+ })
+
+ btnDropdown.addEventListener('hidden.bs.dropdown', () => {
+ expect(btnDropdown).not.toHaveClass('show')
+ resolve()
+ })
+
+ btnDropdown.click()
+ })
+ })
+
+ it('should remove "show" class if body is clicked, with multiple dropdowns', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="nav">',
+ ' <div class="dropdown" id="testmenu">',
+ ' <a class="dropdown-toggle" data-bs-toggle="dropdown" href="#testmenu">Test menu</a>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#sub1">Submenu 1</a>',
+ ' </div>',
+ ' </div>',
+ '</div>',
+ '<div class="btn-group">',
+ ' <button class="btn">Actions</button>',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown"></button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Action 1</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdownList = fixtureEl.querySelectorAll('[data-bs-toggle="dropdown"]')
+
+ expect(triggerDropdownList).toHaveSize(2)
+
+ const [triggerDropdownFirst, triggerDropdownLast] = triggerDropdownList
+
+ triggerDropdownFirst.addEventListener('shown.bs.dropdown', () => {
+ expect(triggerDropdownFirst).toHaveClass('show')
+ expect(fixtureEl.querySelectorAll('.dropdown-menu.show')).toHaveSize(1)
+ document.body.click()
+ })
+
+ triggerDropdownFirst.addEventListener('hidden.bs.dropdown', () => {
+ expect(fixtureEl.querySelectorAll('.dropdown-menu.show')).toHaveSize(0)
+ triggerDropdownLast.click()
+ })
+
+ triggerDropdownLast.addEventListener('shown.bs.dropdown', () => {
+ expect(triggerDropdownLast).toHaveClass('show')
+ expect(fixtureEl.querySelectorAll('.dropdown-menu.show')).toHaveSize(1)
+ document.body.click()
+ })
+
+ triggerDropdownLast.addEventListener('hidden.bs.dropdown', () => {
+ expect(fixtureEl.querySelectorAll('.dropdown-menu.show')).toHaveSize(0)
+ resolve()
+ })
+
+ triggerDropdownFirst.click()
+ })
+ })
+
+ it('should remove "show" class if body if tabbing outside of menu, with multiple dropdowns', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <a class="dropdown-toggle" data-bs-toggle="dropdown" href="#testmenu">Test menu</a>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#sub1">Submenu 1</a>',
+ ' </div>',
+ '</div>',
+ '<div class="btn-group">',
+ ' <button class="btn">Actions</button>',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown"></button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Action 1</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdownList = fixtureEl.querySelectorAll('[data-bs-toggle="dropdown"]')
+
+ expect(triggerDropdownList).toHaveSize(2)
+
+ const [triggerDropdownFirst, triggerDropdownLast] = triggerDropdownList
+
+ triggerDropdownFirst.addEventListener('shown.bs.dropdown', () => {
+ expect(triggerDropdownFirst).toHaveClass('show')
+ expect(fixtureEl.querySelectorAll('.dropdown-menu.show')).toHaveSize(1)
+
+ const keyup = createEvent('keyup')
+ keyup.key = 'Tab'
+
+ document.dispatchEvent(keyup)
+ })
+
+ triggerDropdownFirst.addEventListener('hidden.bs.dropdown', () => {
+ expect(fixtureEl.querySelectorAll('.dropdown-menu.show')).toHaveSize(0)
+ triggerDropdownLast.click()
+ })
+
+ triggerDropdownLast.addEventListener('shown.bs.dropdown', () => {
+ expect(triggerDropdownLast).toHaveClass('show')
+ expect(fixtureEl.querySelectorAll('.dropdown-menu.show')).toHaveSize(1)
+
+ const keyup = createEvent('keyup')
+ keyup.key = 'Tab'
+
+ document.dispatchEvent(keyup)
+ })
+
+ triggerDropdownLast.addEventListener('hidden.bs.dropdown', () => {
+ expect(fixtureEl.querySelectorAll('.dropdown-menu.show')).toHaveSize(0)
+ resolve()
+ })
+
+ triggerDropdownFirst.click()
+ })
+ })
+
+ it('should be able to identify clicked dropdown, even with multiple dropdowns in the same tag', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button id="dropdown1" class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown toggle</button>',
+ ' <div id="menu1" class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Dropdown item</a>',
+ ' </div>',
+ ' <button id="dropdown2" class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown toggle</button>',
+ ' <div id="menu2" class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Dropdown item</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const dropdownToggle1 = fixtureEl.querySelector('#dropdown1')
+ const dropdownToggle2 = fixtureEl.querySelector('#dropdown2')
+ const dropdownMenu1 = fixtureEl.querySelector('#menu1')
+ const dropdownMenu2 = fixtureEl.querySelector('#menu2')
+ const spy = spyOn(Dropdown, 'getOrCreateInstance').and.callThrough()
+
+ dropdownToggle1.click()
+ expect(spy).toHaveBeenCalledWith(dropdownToggle1)
+
+ dropdownToggle2.click()
+ expect(spy).toHaveBeenCalledWith(dropdownToggle2)
+
+ dropdownMenu1.click()
+ expect(spy).toHaveBeenCalledWith(dropdownToggle1)
+
+ dropdownMenu2.click()
+ expect(spy).toHaveBeenCalledWith(dropdownToggle2)
+ })
+
+ it('should be able to show the proper menu, even with multiple dropdowns in the same tag', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button id="dropdown1" class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown toggle</button>',
+ ' <div id="menu1" class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Dropdown item</a>',
+ ' </div>',
+ ' <button id="dropdown2" class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown toggle</button>',
+ ' <div id="menu2" class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Dropdown item</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const dropdownToggle1 = fixtureEl.querySelector('#dropdown1')
+ const dropdownToggle2 = fixtureEl.querySelector('#dropdown2')
+ const dropdownMenu1 = fixtureEl.querySelector('#menu1')
+ const dropdownMenu2 = fixtureEl.querySelector('#menu2')
+
+ dropdownToggle1.click()
+ expect(dropdownMenu1).toHaveClass('show')
+ expect(dropdownMenu2).not.toHaveClass('show')
+
+ dropdownToggle2.click()
+ expect(dropdownMenu1).not.toHaveClass('show')
+ expect(dropdownMenu2).toHaveClass('show')
+ })
+
+ it('should fire hide and hidden event without a clickEvent if event type is not click', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#sub1">Submenu 1</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+
+ triggerDropdown.addEventListener('hide.bs.dropdown', event => {
+ expect(event.clickEvent).toBeUndefined()
+ })
+
+ triggerDropdown.addEventListener('hidden.bs.dropdown', event => {
+ expect(event.clickEvent).toBeUndefined()
+ resolve()
+ })
+
+ triggerDropdown.addEventListener('shown.bs.dropdown', () => {
+ const keydown = createEvent('keydown')
+
+ keydown.key = 'Escape'
+ triggerDropdown.dispatchEvent(keydown)
+ })
+
+ triggerDropdown.click()
+ })
+ })
+
+ it('should bubble up the events to the parent elements', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#subMenu">Sub menu</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdownParent = fixtureEl.querySelector('.dropdown')
+ const dropdown = new Dropdown(triggerDropdown)
+
+ const showFunction = jasmine.createSpy('showFunction')
+ dropdownParent.addEventListener('show.bs.dropdown', showFunction)
+
+ const shownFunction = jasmine.createSpy('shownFunction')
+ dropdownParent.addEventListener('shown.bs.dropdown', () => {
+ shownFunction()
+ dropdown.hide()
+ })
+
+ const hideFunction = jasmine.createSpy('hideFunction')
+ dropdownParent.addEventListener('hide.bs.dropdown', hideFunction)
+
+ dropdownParent.addEventListener('hidden.bs.dropdown', () => {
+ expect(showFunction).toHaveBeenCalled()
+ expect(shownFunction).toHaveBeenCalled()
+ expect(hideFunction).toHaveBeenCalled()
+ resolve()
+ })
+
+ dropdown.show()
+ })
+ })
+
+ it('should ignore keyboard events within <input>s and <textarea>s', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#sub1">Submenu 1</a>',
+ ' <input type="text">',
+ ' <textarea></textarea>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const input = fixtureEl.querySelector('input')
+ const textarea = fixtureEl.querySelector('textarea')
+
+ triggerDropdown.addEventListener('shown.bs.dropdown', () => {
+ input.focus()
+ const keydown = createEvent('keydown')
+
+ keydown.key = 'ArrowUp'
+ input.dispatchEvent(keydown)
+
+ expect(document.activeElement).toEqual(input, 'input still focused')
+
+ textarea.focus()
+ textarea.dispatchEvent(keydown)
+
+ expect(document.activeElement).toEqual(textarea, 'textarea still focused')
+ resolve()
+ })
+
+ triggerDropdown.click()
+ })
+ })
+
+ it('should skip disabled element when using keyboard navigation', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item disabled" href="#sub1">Submenu 1</a>',
+ ' <button class="dropdown-item" type="button" disabled>Disabled button</button>',
+ ' <a id="item1" class="dropdown-item" href="#">Another link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+
+ triggerDropdown.addEventListener('shown.bs.dropdown', () => {
+ const keydown = createEvent('keydown')
+ keydown.key = 'ArrowDown'
+
+ triggerDropdown.dispatchEvent(keydown)
+ triggerDropdown.dispatchEvent(keydown)
+
+ expect(document.activeElement).not.toHaveClass('disabled')
+ expect(document.activeElement.hasAttribute('disabled')).toBeFalse()
+ resolve()
+ })
+
+ triggerDropdown.click()
+ })
+ })
+
+ it('should skip hidden element when using keyboard navigation', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<style>',
+ ' .d-none {',
+ ' display: none;',
+ ' }',
+ '</style>',
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <button class="dropdown-item d-none" type="button">Hidden button by class</button>',
+ ' <a class="dropdown-item" href="#sub1" style="display: none">Hidden link</a>',
+ ' <a class="dropdown-item" href="#sub1" style="visibility: hidden">Hidden link</a>',
+ ' <a id="item1" class="dropdown-item" href="#">Another link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+
+ triggerDropdown.addEventListener('shown.bs.dropdown', () => {
+ const keydown = createEvent('keydown')
+ keydown.key = 'ArrowDown'
+
+ triggerDropdown.dispatchEvent(keydown)
+
+ expect(document.activeElement).not.toHaveClass('d-none')
+ expect(document.activeElement.style.display).not.toEqual('none')
+ expect(document.activeElement.style.visibility).not.toEqual('hidden')
+
+ resolve()
+ })
+
+ triggerDropdown.click()
+ })
+ })
+
+ it('should focus next/previous element when using keyboard navigation', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a id="item1" class="dropdown-item" href="#">A link</a>',
+ ' <a id="item2" class="dropdown-item" href="#">Another link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const item1 = fixtureEl.querySelector('#item1')
+ const item2 = fixtureEl.querySelector('#item2')
+
+ triggerDropdown.addEventListener('shown.bs.dropdown', () => {
+ const keydownArrowDown = createEvent('keydown')
+ keydownArrowDown.key = 'ArrowDown'
+
+ triggerDropdown.dispatchEvent(keydownArrowDown)
+ expect(document.activeElement).toEqual(item1, 'item1 is focused')
+
+ document.activeElement.dispatchEvent(keydownArrowDown)
+ expect(document.activeElement).toEqual(item2, 'item2 is focused')
+
+ const keydownArrowUp = createEvent('keydown')
+ keydownArrowUp.key = 'ArrowUp'
+
+ document.activeElement.dispatchEvent(keydownArrowUp)
+ expect(document.activeElement).toEqual(item1, 'item1 is focused')
+
+ resolve()
+ })
+
+ triggerDropdown.click()
+ })
+ })
+
+ it('should open the dropdown and focus on the last item when using ArrowUp for the first time', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a id="item1" class="dropdown-item" href="#">A link</a>',
+ ' <a id="item2" class="dropdown-item" href="#">Another link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const lastItem = fixtureEl.querySelector('#item2')
+
+ triggerDropdown.addEventListener('shown.bs.dropdown', () => {
+ setTimeout(() => {
+ expect(document.activeElement).toEqual(lastItem, 'item2 is focused')
+ resolve()
+ })
+ })
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'ArrowUp'
+ triggerDropdown.dispatchEvent(keydown)
+ })
+ })
+
+ it('should open the dropdown and focus on the first item when using ArrowDown for the first time', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a id="item1" class="dropdown-item" href="#">A link</a>',
+ ' <a id="item2" class="dropdown-item" href="#">Another link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const firstItem = fixtureEl.querySelector('#item1')
+
+ triggerDropdown.addEventListener('shown.bs.dropdown', () => {
+ setTimeout(() => {
+ expect(document.activeElement).toEqual(firstItem, 'item1 is focused')
+ resolve()
+ })
+ })
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'ArrowDown'
+ triggerDropdown.dispatchEvent(keydown)
+ })
+ })
+
+ it('should not close the dropdown if the user clicks on a text field within dropdown-menu', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <input type="text">',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const input = fixtureEl.querySelector('input')
+
+ input.addEventListener('click', () => {
+ expect(triggerDropdown).toHaveClass('show')
+ resolve()
+ })
+
+ triggerDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(triggerDropdown).toHaveClass('show')
+ input.dispatchEvent(createEvent('click'))
+ })
+
+ triggerDropdown.click()
+ })
+ })
+
+ it('should not close the dropdown if the user clicks on a textarea within dropdown-menu', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <textarea></textarea>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const textarea = fixtureEl.querySelector('textarea')
+
+ textarea.addEventListener('click', () => {
+ expect(triggerDropdown).toHaveClass('show')
+ resolve()
+ })
+
+ triggerDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(triggerDropdown).toHaveClass('show')
+ textarea.dispatchEvent(createEvent('click'))
+ })
+
+ triggerDropdown.click()
+ })
+ })
+
+ it('should close the dropdown if the user clicks on a text field that is not contained within dropdown-menu', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' </div>',
+ '</div>',
+ '<input type="text">'
+ ].join('')
+
+ const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const input = fixtureEl.querySelector('input')
+
+ triggerDropdown.addEventListener('hidden.bs.dropdown', () => {
+ expect().nothing()
+ resolve()
+ })
+
+ triggerDropdown.addEventListener('shown.bs.dropdown', () => {
+ input.dispatchEvent(createEvent('click', {
+ bubbles: true
+ }))
+ })
+
+ triggerDropdown.click()
+ })
+ })
+
+ it('should ignore keyboard events for <input>s and <textarea>s within dropdown-menu, except for escape key', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#sub1">Submenu 1</a>',
+ ' <input type="text">',
+ ' <textarea></textarea>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const input = fixtureEl.querySelector('input')
+ const textarea = fixtureEl.querySelector('textarea')
+
+ const test = (eventKey, elementToDispatch) => {
+ const event = createEvent('keydown')
+ event.key = eventKey
+ elementToDispatch.focus()
+ elementToDispatch.dispatchEvent(event)
+ expect(document.activeElement).toEqual(elementToDispatch, `${elementToDispatch.tagName} still focused`)
+ }
+
+ const keydownEscape = createEvent('keydown')
+ keydownEscape.key = 'Escape'
+
+ triggerDropdown.addEventListener('shown.bs.dropdown', () => {
+ // Key Space
+ test('Space', input)
+
+ test('Space', textarea)
+
+ // Key ArrowUp
+ test('ArrowUp', input)
+
+ test('ArrowUp', textarea)
+
+ // Key ArrowDown
+ test('ArrowDown', input)
+
+ test('ArrowDown', textarea)
+
+ // Key Escape
+ input.focus()
+ input.dispatchEvent(keydownEscape)
+
+ expect(triggerDropdown).not.toHaveClass('show')
+ resolve()
+ })
+
+ triggerDropdown.click()
+ })
+ })
+
+ it('should not open dropdown if escape key was pressed on the toggle', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="tabs">',
+ ' <div class="dropdown">',
+ ' <button disabled class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' <a class="dropdown-item" href="#">Something else here</a>',
+ ' <div class="divider"></div>',
+ ' <a class="dropdown-item" href="#">Another link</a>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(triggerDropdown)
+ const button = fixtureEl.querySelector('button[data-bs-toggle="dropdown"]')
+
+ const spy = spyOn(dropdown, 'toggle')
+
+ // Key escape
+ button.focus()
+ // Key escape
+ const keydownEscape = createEvent('keydown')
+ keydownEscape.key = 'Escape'
+ button.dispatchEvent(keydownEscape)
+
+ setTimeout(() => {
+ expect(spy).not.toHaveBeenCalled()
+ expect(triggerDropdown).not.toHaveClass('show')
+ resolve()
+ }, 20)
+ })
+ })
+
+ it('should propagate escape key events if dropdown is closed', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="parent">',
+ ' <div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Some Item</a>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const parent = fixtureEl.querySelector('.parent')
+ const toggle = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+
+ const parentKeyHandler = jasmine.createSpy('parentKeyHandler')
+
+ parent.addEventListener('keydown', parentKeyHandler)
+ parent.addEventListener('keyup', () => {
+ expect(parentKeyHandler).toHaveBeenCalled()
+ resolve()
+ })
+
+ const keydownEscape = createEvent('keydown', { bubbles: true })
+ keydownEscape.key = 'Escape'
+ const keyupEscape = createEvent('keyup', { bubbles: true })
+ keyupEscape.key = 'Escape'
+
+ toggle.focus()
+ toggle.dispatchEvent(keydownEscape)
+ toggle.dispatchEvent(keyupEscape)
+ })
+ })
+
+ it('should not propagate escape key events if dropdown is open', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="parent">',
+ ' <div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Some Item</a>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const parent = fixtureEl.querySelector('.parent')
+ const toggle = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+
+ const parentKeyHandler = jasmine.createSpy('parentKeyHandler')
+
+ parent.addEventListener('keydown', parentKeyHandler)
+ parent.addEventListener('keyup', () => {
+ expect(parentKeyHandler).not.toHaveBeenCalled()
+ resolve()
+ })
+
+ const keydownEscape = createEvent('keydown', { bubbles: true })
+ keydownEscape.key = 'Escape'
+ const keyupEscape = createEvent('keyup', { bubbles: true })
+ keyupEscape.key = 'Escape'
+
+ toggle.click()
+ toggle.dispatchEvent(keydownEscape)
+ toggle.dispatchEvent(keyupEscape)
+ })
+ })
+
+ it('should close dropdown using `escape` button, and return focus to its trigger', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Some Item</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const toggle = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+
+ toggle.addEventListener('shown.bs.dropdown', () => {
+ const keydownEvent = createEvent('keydown', { bubbles: true })
+ keydownEvent.key = 'ArrowDown'
+ toggle.dispatchEvent(keydownEvent)
+ keydownEvent.key = 'Escape'
+ toggle.dispatchEvent(keydownEvent)
+ })
+
+ toggle.addEventListener('hidden.bs.dropdown', () => setTimeout(() => {
+ expect(document.activeElement).toEqual(toggle)
+ resolve()
+ }))
+
+ toggle.click()
+ })
+ })
+
+ it('should close dropdown (only) by clicking inside the dropdown menu when it has data-attribute `data-bs-auto-close="inside"`', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-auto-close="inside">Dropdown toggle</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Dropdown item</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const dropdownToggle = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+
+ const expectDropdownToBeOpened = () => setTimeout(() => {
+ expect(dropdownToggle).toHaveClass('show')
+ dropdownMenu.click()
+ }, 150)
+
+ dropdownToggle.addEventListener('shown.bs.dropdown', () => {
+ document.documentElement.click()
+ expectDropdownToBeOpened()
+ })
+
+ dropdownToggle.addEventListener('hidden.bs.dropdown', () => setTimeout(() => {
+ expect(dropdownToggle).not.toHaveClass('show')
+ resolve()
+ }))
+
+ dropdownToggle.click()
+ })
+ })
+
+ it('should close dropdown (only) by clicking outside the dropdown menu when it has data-attribute `data-bs-auto-close="outside"`', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-auto-close="outside">Dropdown toggle</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Dropdown item</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const dropdownToggle = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+
+ const expectDropdownToBeOpened = () => setTimeout(() => {
+ expect(dropdownToggle).toHaveClass('show')
+ document.documentElement.click()
+ }, 150)
+
+ dropdownToggle.addEventListener('shown.bs.dropdown', () => {
+ dropdownMenu.click()
+ expectDropdownToBeOpened()
+ })
+
+ dropdownToggle.addEventListener('hidden.bs.dropdown', () => {
+ expect(dropdownToggle).not.toHaveClass('show')
+ resolve()
+ })
+
+ dropdownToggle.click()
+ })
+ })
+
+ it('should not close dropdown by clicking inside or outside the dropdown menu when it has data-attribute `data-bs-auto-close="false"`', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-auto-close="false">Dropdown toggle</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Dropdown item</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const dropdownToggle = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+
+ const expectDropdownToBeOpened = (shouldTriggerClick = true) => setTimeout(() => {
+ expect(dropdownToggle).toHaveClass('show')
+ if (shouldTriggerClick) {
+ document.documentElement.click()
+ } else {
+ resolve()
+ }
+
+ expectDropdownToBeOpened(false)
+ }, 150)
+
+ dropdownToggle.addEventListener('shown.bs.dropdown', () => {
+ dropdownMenu.click()
+ expectDropdownToBeOpened()
+ })
+
+ dropdownToggle.click()
+ })
+ })
+
+ it('should be able to identify clicked dropdown, no matter the markup order', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Dropdown item</a>',
+ ' </div>',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown toggle</button>',
+ '</div>'
+ ].join('')
+
+ const dropdownToggle = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+ const spy = spyOn(Dropdown, 'getOrCreateInstance').and.callThrough()
+
+ dropdownToggle.click()
+ expect(spy).toHaveBeenCalledWith(dropdownToggle)
+ dropdownMenu.click()
+ expect(spy).toHaveBeenCalledWith(dropdownToggle)
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a dropdown', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.dropdown = Dropdown.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.dropdown.call(jQueryMock)
+
+ expect(Dropdown.getInstance(div)).not.toBeNull()
+ })
+
+ it('should not re create a dropdown', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const dropdown = new Dropdown(div)
+
+ jQueryMock.fn.dropdown = Dropdown.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.dropdown.call(jQueryMock)
+
+ expect(Dropdown.getInstance(div)).toEqual(dropdown)
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.dropdown = Dropdown.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.dropdown.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return dropdown instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const dropdown = new Dropdown(div)
+
+ expect(Dropdown.getInstance(div)).toEqual(dropdown)
+ expect(Dropdown.getInstance(div)).toBeInstanceOf(Dropdown)
+ })
+
+ it('should return null when there is no dropdown instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Dropdown.getInstance(div)).toBeNull()
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return dropdown instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const dropdown = new Dropdown(div)
+
+ expect(Dropdown.getOrCreateInstance(div)).toEqual(dropdown)
+ expect(Dropdown.getInstance(div)).toEqual(Dropdown.getOrCreateInstance(div, {}))
+ expect(Dropdown.getOrCreateInstance(div)).toBeInstanceOf(Dropdown)
+ })
+
+ it('should return new instance when there is no dropdown instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Dropdown.getInstance(div)).toBeNull()
+ expect(Dropdown.getOrCreateInstance(div)).toBeInstanceOf(Dropdown)
+ })
+
+ it('should return new instance when there is no dropdown instance with given configuration', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Dropdown.getInstance(div)).toBeNull()
+ const dropdown = Dropdown.getOrCreateInstance(div, {
+ display: 'dynamic'
+ })
+ expect(dropdown).toBeInstanceOf(Dropdown)
+
+ expect(dropdown._config.display).toEqual('dynamic')
+ })
+
+ it('should return the instance when exists without given configuration', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const dropdown = new Dropdown(div, {
+ display: 'dynamic'
+ })
+ expect(Dropdown.getInstance(div)).toEqual(dropdown)
+
+ const dropdown2 = Dropdown.getOrCreateInstance(div, {
+ display: 'static'
+ })
+ expect(dropdown).toBeInstanceOf(Dropdown)
+ expect(dropdown2).toEqual(dropdown)
+
+ expect(dropdown2._config.display).toEqual('dynamic')
+ })
+ })
+
+ it('should open dropdown when pressing keydown or keyup', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item disabled" href="#sub1">Submenu 1</a>',
+ ' <button class="dropdown-item" type="button" disabled>Disabled button</button>',
+ ' <a id="item1" class="dropdown-item" href="#">Another link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = fixtureEl.querySelector('.dropdown')
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'ArrowDown'
+
+ const keyup = createEvent('keyup')
+ keyup.key = 'ArrowUp'
+
+ const handleArrowDown = () => {
+ expect(triggerDropdown).toHaveClass('show')
+ expect(triggerDropdown.getAttribute('aria-expanded')).toEqual('true')
+ setTimeout(() => {
+ dropdown.hide()
+ keydown.key = 'ArrowUp'
+ triggerDropdown.dispatchEvent(keyup)
+ }, 20)
+ }
+
+ const handleArrowUp = () => {
+ expect(triggerDropdown).toHaveClass('show')
+ expect(triggerDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ }
+
+ dropdown.addEventListener('shown.bs.dropdown', event => {
+ if (event.target.key === 'ArrowDown') {
+ handleArrowDown()
+ } else {
+ handleArrowUp()
+ }
+ })
+
+ triggerDropdown.dispatchEvent(keydown)
+ })
+ })
+
+ it('should allow `data-bs-toggle="dropdown"` click events to bubble up', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const clickListener = jasmine.createSpy('clickListener')
+ const delegatedClickListener = jasmine.createSpy('delegatedClickListener')
+
+ btnDropdown.addEventListener('click', clickListener)
+ document.addEventListener('click', delegatedClickListener)
+
+ btnDropdown.click()
+
+ expect(clickListener).toHaveBeenCalled()
+ expect(delegatedClickListener).toHaveBeenCalled()
+ })
+
+ it('should open the dropdown when clicking the child element inside `data-bs-toggle="dropdown"`', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="container">',
+ ' <div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown"><span id="childElement">Dropdown</span></button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#subMenu">Sub menu</a>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const childElement = fixtureEl.querySelector('#childElement')
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => setTimeout(() => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ }))
+
+ childElement.click()
+ })
+ })
+})
diff --git a/js/tests/unit/jquery.spec.js b/js/tests/unit/jquery.spec.js
new file mode 100644
index 0000000..7da39d6
--- /dev/null
+++ b/js/tests/unit/jquery.spec.js
@@ -0,0 +1,60 @@
+/* eslint-env jquery */
+
+import Alert from '../../src/alert'
+import Button from '../../src/button'
+import Carousel from '../../src/carousel'
+import Collapse from '../../src/collapse'
+import Dropdown from '../../src/dropdown'
+import Modal from '../../src/modal'
+import Offcanvas from '../../src/offcanvas'
+import Popover from '../../src/popover'
+import ScrollSpy from '../../src/scrollspy'
+import Tab from '../../src/tab'
+import Toast from '../../src/toast'
+import Tooltip from '../../src/tooltip'
+import { clearFixture, getFixture } from '../helpers/fixture'
+
+describe('jQuery', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ it('should add all plugins in jQuery', () => {
+ expect(Alert.jQueryInterface).toEqual(jQuery.fn.alert)
+ expect(Button.jQueryInterface).toEqual(jQuery.fn.button)
+ expect(Carousel.jQueryInterface).toEqual(jQuery.fn.carousel)
+ expect(Collapse.jQueryInterface).toEqual(jQuery.fn.collapse)
+ expect(Dropdown.jQueryInterface).toEqual(jQuery.fn.dropdown)
+ expect(Modal.jQueryInterface).toEqual(jQuery.fn.modal)
+ expect(Offcanvas.jQueryInterface).toEqual(jQuery.fn.offcanvas)
+ expect(Popover.jQueryInterface).toEqual(jQuery.fn.popover)
+ expect(ScrollSpy.jQueryInterface).toEqual(jQuery.fn.scrollspy)
+ expect(Tab.jQueryInterface).toEqual(jQuery.fn.tab)
+ expect(Toast.jQueryInterface).toEqual(jQuery.fn.toast)
+ expect(Tooltip.jQueryInterface).toEqual(jQuery.fn.tooltip)
+ })
+
+ it('should use jQuery event system', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="alert">',
+ ' <button type="button" data-bs-dismiss="alert">x</button>',
+ '</div>'
+ ].join('')
+
+ $(fixtureEl).find('.alert')
+ .one('closed.bs.alert', () => {
+ expect($(fixtureEl).find('.alert')).toHaveSize(0)
+ resolve()
+ })
+
+ $(fixtureEl).find('button').trigger('click')
+ })
+ })
+})
diff --git a/js/tests/unit/modal.spec.js b/js/tests/unit/modal.spec.js
new file mode 100644
index 0000000..fdee29e
--- /dev/null
+++ b/js/tests/unit/modal.spec.js
@@ -0,0 +1,1298 @@
+import Modal from '../../src/modal'
+import EventHandler from '../../src/dom/event-handler'
+import ScrollBarHelper from '../../src/util/scrollbar'
+import { clearBodyAndDocument, clearFixture, createEvent, getFixture, jQueryMock } from '../helpers/fixture'
+
+describe('Modal', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ clearBodyAndDocument()
+ document.body.classList.remove('modal-open')
+
+ for (const backdrop of document.querySelectorAll('.modal-backdrop')) {
+ backdrop.remove()
+ }
+ })
+
+ beforeEach(() => {
+ clearBodyAndDocument()
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Modal.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin default config', () => {
+ expect(Modal.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(Modal.DATA_KEY).toEqual('bs.modal')
+ })
+ })
+
+ describe('constructor', () => {
+ it('should take care of element either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modalBySelector = new Modal('.modal')
+ const modalByElement = new Modal(modalEl)
+
+ expect(modalBySelector._element).toEqual(modalEl)
+ expect(modalByElement._element).toEqual(modalEl)
+ })
+ })
+
+ describe('toggle', () => {
+ it('should call ScrollBarHelper to handle scrollBar on body', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const spyHide = spyOn(ScrollBarHelper.prototype, 'hide').and.callThrough()
+ const spyReset = spyOn(ScrollBarHelper.prototype, 'reset').and.callThrough()
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(spyHide).toHaveBeenCalled()
+ modal.toggle()
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ expect(spyReset).toHaveBeenCalled()
+ resolve()
+ })
+
+ modal.toggle()
+ })
+ })
+ })
+
+ describe('show', () => {
+ it('should show a modal', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ modalEl.addEventListener('show.bs.modal', event => {
+ expect(event).toBeDefined()
+ })
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(modalEl.getAttribute('aria-modal')).toEqual('true')
+ expect(modalEl.getAttribute('role')).toEqual('dialog')
+ expect(modalEl.getAttribute('aria-hidden')).toBeNull()
+ expect(modalEl.style.display).toEqual('block')
+ expect(document.querySelector('.modal-backdrop')).not.toBeNull()
+ resolve()
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should show a modal without backdrop', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl, {
+ backdrop: false
+ })
+
+ modalEl.addEventListener('show.bs.modal', event => {
+ expect(event).toBeDefined()
+ })
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(modalEl.getAttribute('aria-modal')).toEqual('true')
+ expect(modalEl.getAttribute('role')).toEqual('dialog')
+ expect(modalEl.getAttribute('aria-hidden')).toBeNull()
+ expect(modalEl.style.display).toEqual('block')
+ expect(document.querySelector('.modal-backdrop')).toBeNull()
+ resolve()
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should show a modal and append the element', () => {
+ return new Promise(resolve => {
+ const modalEl = document.createElement('div')
+ const id = 'dynamicModal'
+
+ modalEl.setAttribute('id', id)
+ modalEl.classList.add('modal')
+ modalEl.innerHTML = '<div class="modal-dialog"></div>'
+
+ const modal = new Modal(modalEl)
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ const dynamicModal = document.getElementById(id)
+ expect(dynamicModal).not.toBeNull()
+ dynamicModal.remove()
+ resolve()
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should do nothing if a modal is shown', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ const spy = spyOn(EventHandler, 'trigger')
+ modal._isShown = true
+
+ modal.show()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should do nothing if a modal is transitioning', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ const spy = spyOn(EventHandler, 'trigger')
+ modal._isTransitioning = true
+
+ modal.show()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should not fire shown event when show is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ modalEl.addEventListener('show.bs.modal', event => {
+ event.preventDefault()
+
+ const expectedDone = () => {
+ expect().nothing()
+ resolve()
+ }
+
+ setTimeout(expectedDone, 10)
+ })
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ reject(new Error('shown event triggered'))
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should be shown after the first call to show() has been prevented while fading is enabled ', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="modal fade"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ let prevented = false
+ modalEl.addEventListener('show.bs.modal', event => {
+ if (!prevented) {
+ event.preventDefault()
+ prevented = true
+
+ setTimeout(() => {
+ modal.show()
+ })
+ }
+ })
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(prevented).toBeTrue()
+ expect(modal._isAnimated()).toBeTrue()
+ resolve()
+ })
+
+ modal.show()
+ })
+ })
+ it('should set is transitioning if fade class is present', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="modal fade"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ modalEl.addEventListener('show.bs.modal', () => {
+ setTimeout(() => {
+ expect(modal._isTransitioning).toBeTrue()
+ })
+ })
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(modal._isTransitioning).toBeFalse()
+ resolve()
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should close modal when a click occurred on data-bs-dismiss="modal" inside modal', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="modal fade">',
+ ' <div class="modal-dialog">',
+ ' <div class="modal-header">',
+ ' <button type="button" data-bs-dismiss="modal"></button>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const btnClose = fixtureEl.querySelector('[data-bs-dismiss="modal"]')
+ const modal = new Modal(modalEl)
+
+ const spy = spyOn(modal, 'hide').and.callThrough()
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ btnClose.click()
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should close modal when a click occurred on a data-bs-dismiss="modal" with "bs-target" outside of modal element', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<button type="button" data-bs-dismiss="modal" data-bs-target="#modal1"></button>',
+ '<div id="modal1" class="modal fade">',
+ ' <div class="modal-dialog"></div>',
+ '</div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const btnClose = fixtureEl.querySelector('[data-bs-dismiss="modal"]')
+ const modal = new Modal(modalEl)
+
+ const spy = spyOn(modal, 'hide').and.callThrough()
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ btnClose.click()
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should set .modal\'s scroll top to 0', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="modal fade">',
+ ' <div class="modal-dialog"></div>',
+ '</div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(modalEl.scrollTop).toEqual(0)
+ resolve()
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should set modal body scroll top to 0 if modal body do not exists', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="modal fade">',
+ ' <div class="modal-dialog">',
+ ' <div class="modal-body"></div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modalBody = modalEl.querySelector('.modal-body')
+ const modal = new Modal(modalEl)
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(modalBody.scrollTop).toEqual(0)
+ resolve()
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should not trap focus if focus equal to false', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="modal fade"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl, {
+ focus: false
+ })
+
+ const spy = spyOn(modal._focustrap, 'activate').and.callThrough()
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(spy).not.toHaveBeenCalled()
+ resolve()
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should add listener when escape touch is pressed', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ const spy = spyOn(modal, 'hide').and.callThrough()
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ const keydownEscape = createEvent('keydown')
+ keydownEscape.key = 'Escape'
+
+ modalEl.dispatchEvent(keydownEscape)
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should do nothing when the pressed key is not escape', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ const spy = spyOn(modal, 'hide')
+
+ const expectDone = () => {
+ expect(spy).not.toHaveBeenCalled()
+
+ resolve()
+ }
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ const keydownTab = createEvent('keydown')
+ keydownTab.key = 'Tab'
+
+ modalEl.dispatchEvent(keydownTab)
+ setTimeout(expectDone, 30)
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should adjust dialog on resize', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ const spy = spyOn(modal, '_adjustDialog').and.callThrough()
+
+ const expectDone = () => {
+ expect(spy).toHaveBeenCalled()
+
+ resolve()
+ }
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ const resizeEvent = createEvent('resize')
+
+ window.dispatchEvent(resizeEvent)
+ setTimeout(expectDone, 10)
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should not close modal when clicking on modal-content', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="modal">',
+ ' <div class="modal-dialog">',
+ ' <div class="modal-content"></div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ const shownCallback = () => {
+ setTimeout(() => {
+ expect(modal._isShown).toEqual(true)
+ resolve()
+ }, 10)
+ }
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ fixtureEl.querySelector('.modal-dialog').click()
+ fixtureEl.querySelector('.modal-content').click()
+ shownCallback()
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ reject(new Error('Should not hide a modal'))
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should not close modal when clicking outside of modal-content if backdrop = false', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl, {
+ backdrop: false
+ })
+
+ const shownCallback = () => {
+ setTimeout(() => {
+ expect(modal._isShown).toBeTrue()
+ resolve()
+ }, 10)
+ }
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ modalEl.click()
+ shownCallback()
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ reject(new Error('Should not hide a modal'))
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should not close modal when clicking outside of modal-content if backdrop = static', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl, {
+ backdrop: 'static'
+ })
+
+ const shownCallback = () => {
+ setTimeout(() => {
+ expect(modal._isShown).toBeTrue()
+ resolve()
+ }, 10)
+ }
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ modalEl.click()
+ shownCallback()
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ reject(new Error('Should not hide a modal'))
+ })
+
+ modal.show()
+ })
+ })
+ it('should close modal when escape key is pressed with keyboard = true and backdrop is static', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl, {
+ backdrop: 'static',
+ keyboard: true
+ })
+
+ const shownCallback = () => {
+ setTimeout(() => {
+ expect(modal._isShown).toBeFalse()
+ resolve()
+ }, 10)
+ }
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ const keydownEscape = createEvent('keydown')
+ keydownEscape.key = 'Escape'
+
+ modalEl.dispatchEvent(keydownEscape)
+ shownCallback()
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should not close modal when escape key is pressed with keyboard = false', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl, {
+ keyboard: false
+ })
+
+ const shownCallback = () => {
+ setTimeout(() => {
+ expect(modal._isShown).toBeTrue()
+ resolve()
+ }, 10)
+ }
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ const keydownEscape = createEvent('keydown')
+ keydownEscape.key = 'Escape'
+
+ modalEl.dispatchEvent(keydownEscape)
+ shownCallback()
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ reject(new Error('Should not hide a modal'))
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should not overflow when clicking outside of modal-content if backdrop = static', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" style="transition-duration: 20ms;"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl, {
+ backdrop: 'static'
+ })
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ modalEl.click()
+ setTimeout(() => {
+ expect(modalEl.clientHeight).toEqual(modalEl.scrollHeight)
+ resolve()
+ }, 20)
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should not queue multiple callbacks when clicking outside of modal-content and backdrop = static', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" style="transition-duration: 50ms;"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl, {
+ backdrop: 'static'
+ })
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ const spy = spyOn(modal, '_queueCallback').and.callThrough()
+ const mouseDown = createEvent('mousedown')
+
+ modalEl.dispatchEvent(mouseDown)
+ modalEl.click()
+ modalEl.dispatchEvent(mouseDown)
+ modalEl.click()
+
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalledTimes(1)
+ resolve()
+ }, 20)
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should trap focus', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ const spy = spyOn(modal._focustrap, 'activate').and.callThrough()
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ modal.show()
+ })
+ })
+ })
+
+ describe('hide', () => {
+ it('should hide a modal', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+ const backdropSpy = spyOn(modal._backdrop, 'hide').and.callThrough()
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ modal.hide()
+ })
+
+ modalEl.addEventListener('hide.bs.modal', event => {
+ expect(event).toBeDefined()
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ expect(modalEl.getAttribute('aria-modal')).toBeNull()
+ expect(modalEl.getAttribute('role')).toBeNull()
+ expect(modalEl.getAttribute('aria-hidden')).toEqual('true')
+ expect(modalEl.style.display).toEqual('none')
+ expect(backdropSpy).toHaveBeenCalled()
+ resolve()
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should close modal when clicking outside of modal-content', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const dialogEl = modalEl.querySelector('.modal-dialog')
+ const modal = new Modal(modalEl)
+
+ const spy = spyOn(modal, 'hide')
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ const mouseDown = createEvent('mousedown')
+
+ dialogEl.dispatchEvent(mouseDown)
+ modalEl.click()
+ expect(spy).not.toHaveBeenCalled()
+
+ modalEl.dispatchEvent(mouseDown)
+ modalEl.click()
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should not close modal when clicking on an element removed from modal content', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="modal">',
+ ' <div class="modal-dialog">',
+ ' <button class="btn">BTN</button>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const buttonEl = modalEl.querySelector('.btn')
+ const modal = new Modal(modalEl)
+
+ const spy = spyOn(modal, 'hide')
+ buttonEl.addEventListener('click', () => {
+ buttonEl.remove()
+ })
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ modalEl.dispatchEvent(createEvent('mousedown'))
+ buttonEl.click()
+ expect(spy).not.toHaveBeenCalled()
+ resolve()
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should do nothing is the modal is not shown', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ modal.hide()
+
+ expect().nothing()
+ })
+
+ it('should do nothing is the modal is transitioning', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ modal._isTransitioning = true
+ modal.hide()
+
+ expect().nothing()
+ })
+
+ it('should not hide a modal if hide is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ modal.hide()
+ })
+
+ const hideCallback = () => {
+ setTimeout(() => {
+ expect(modal._isShown).toBeTrue()
+ resolve()
+ }, 10)
+ }
+
+ modalEl.addEventListener('hide.bs.modal', event => {
+ event.preventDefault()
+ hideCallback()
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ reject(new Error('should not trigger hidden'))
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should release focus trap', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+ const spy = spyOn(modal._focustrap, 'deactivate').and.callThrough()
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ modal.hide()
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ modal.show()
+ })
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose a modal', () => {
+ fixtureEl.innerHTML = '<div id="exampleModal" class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+ const focustrap = modal._focustrap
+ const spyDeactivate = spyOn(focustrap, 'deactivate').and.callThrough()
+
+ expect(Modal.getInstance(modalEl)).toEqual(modal)
+
+ const spyOff = spyOn(EventHandler, 'off')
+
+ modal.dispose()
+
+ expect(Modal.getInstance(modalEl)).toBeNull()
+ expect(spyOff).toHaveBeenCalledTimes(3)
+ expect(spyDeactivate).toHaveBeenCalled()
+ })
+ })
+
+ describe('handleUpdate', () => {
+ it('should call adjust dialog', () => {
+ fixtureEl.innerHTML = '<div id="exampleModal" class="modal"><div class="modal-dialog"></div></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ const spy = spyOn(modal, '_adjustDialog')
+
+ modal.handleUpdate()
+
+ expect(spy).toHaveBeenCalled()
+ })
+ })
+
+ describe('data-api', () => {
+ it('should toggle modal', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<button type="button" data-bs-toggle="modal" data-bs-target="#exampleModal"></button>',
+ '<div id="exampleModal" class="modal"><div class="modal-dialog"></div></div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const trigger = fixtureEl.querySelector('[data-bs-toggle="modal"]')
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(modalEl.getAttribute('aria-modal')).toEqual('true')
+ expect(modalEl.getAttribute('role')).toEqual('dialog')
+ expect(modalEl.getAttribute('aria-hidden')).toBeNull()
+ expect(modalEl.style.display).toEqual('block')
+ expect(document.querySelector('.modal-backdrop')).not.toBeNull()
+ setTimeout(() => trigger.click(), 10)
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ expect(modalEl.getAttribute('aria-modal')).toBeNull()
+ expect(modalEl.getAttribute('role')).toBeNull()
+ expect(modalEl.getAttribute('aria-hidden')).toEqual('true')
+ expect(modalEl.style.display).toEqual('none')
+ expect(document.querySelector('.modal-backdrop')).toBeNull()
+ resolve()
+ })
+
+ trigger.click()
+ })
+ })
+
+ it('should not recreate a new modal', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<button type="button" data-bs-toggle="modal" data-bs-target="#exampleModal"></button>',
+ '<div id="exampleModal" class="modal"><div class="modal-dialog"></div></div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+ const trigger = fixtureEl.querySelector('[data-bs-toggle="modal"]')
+
+ const spy = spyOn(modal, 'show').and.callThrough()
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ trigger.click()
+ })
+ })
+
+ it('should prevent default when the trigger is <a> or <area>', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a data-bs-toggle="modal" href="#" data-bs-target="#exampleModal"></a>',
+ '<div id="exampleModal" class="modal"><div class="modal-dialog"></div></div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const trigger = fixtureEl.querySelector('[data-bs-toggle="modal"]')
+
+ const spy = spyOn(Event.prototype, 'preventDefault').and.callThrough()
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(modalEl.getAttribute('aria-modal')).toEqual('true')
+ expect(modalEl.getAttribute('role')).toEqual('dialog')
+ expect(modalEl.getAttribute('aria-hidden')).toBeNull()
+ expect(modalEl.style.display).toEqual('block')
+ expect(document.querySelector('.modal-backdrop')).not.toBeNull()
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ trigger.click()
+ })
+ })
+
+ it('should focus the trigger on hide', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a data-bs-toggle="modal" href="#" data-bs-target="#exampleModal"></a>',
+ '<div id="exampleModal" class="modal"><div class="modal-dialog"></div></div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const trigger = fixtureEl.querySelector('[data-bs-toggle="modal"]')
+
+ const spy = spyOn(trigger, 'focus')
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ const modal = Modal.getInstance(modalEl)
+
+ modal.hide()
+ })
+
+ const hideListener = () => {
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ }, 20)
+ }
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ hideListener()
+ })
+
+ trigger.click()
+ })
+ })
+ it('should not prevent default when a click occurred on data-bs-dismiss="modal" where tagName is DIFFERENT than <a> or <area>', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="modal">',
+ ' <div class="modal-dialog">',
+ ' <button type="button" data-bs-dismiss="modal"></button>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const btnClose = fixtureEl.querySelector('button[data-bs-dismiss="modal"]')
+ const modal = new Modal(modalEl)
+
+ const spy = spyOn(Event.prototype, 'preventDefault').and.callThrough()
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ btnClose.click()
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ expect(spy).not.toHaveBeenCalled()
+ resolve()
+ })
+
+ modal.show()
+ })
+ })
+
+ it('should prevent default when a click occurred on data-bs-dismiss="modal" where tagName is <a> or <area>', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="modal">',
+ ' <div class="modal-dialog">',
+ ' <a type="button" data-bs-dismiss="modal"></a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const btnClose = fixtureEl.querySelector('a[data-bs-dismiss="modal"]')
+ const modal = new Modal(modalEl)
+
+ const spy = spyOn(Event.prototype, 'preventDefault').and.callThrough()
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ btnClose.click()
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ modal.show()
+ })
+ })
+ it('should not focus the trigger if the modal is not visible', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a data-bs-toggle="modal" href="#" data-bs-target="#exampleModal" style="display: none;"></a>',
+ '<div id="exampleModal" class="modal" style="display: none;"><div class="modal-dialog"></div></div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const trigger = fixtureEl.querySelector('[data-bs-toggle="modal"]')
+
+ const spy = spyOn(trigger, 'focus')
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ const modal = Modal.getInstance(modalEl)
+
+ modal.hide()
+ })
+
+ const hideListener = () => {
+ setTimeout(() => {
+ expect(spy).not.toHaveBeenCalled()
+ resolve()
+ }, 20)
+ }
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ hideListener()
+ })
+
+ trigger.click()
+ })
+ })
+ it('should not focus the trigger if the modal is not shown', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a data-bs-toggle="modal" href="#" data-bs-target="#exampleModal"></a>',
+ '<div id="exampleModal" class="modal"><div class="modal-dialog"></div></div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const trigger = fixtureEl.querySelector('[data-bs-toggle="modal"]')
+
+ const spy = spyOn(trigger, 'focus')
+
+ const showListener = () => {
+ setTimeout(() => {
+ expect(spy).not.toHaveBeenCalled()
+ resolve()
+ }, 10)
+ }
+
+ modalEl.addEventListener('show.bs.modal', event => {
+ event.preventDefault()
+ showListener()
+ })
+
+ trigger.click()
+ })
+ })
+
+ it('should call hide first, if another modal is open', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<button data-bs-toggle="modal" data-bs-target="#modal2"></button>',
+ '<div id="modal1" class="modal fade"><div class="modal-dialog"></div></div>',
+ '<div id="modal2" class="modal"><div class="modal-dialog"></div></div>'
+ ].join('')
+
+ const trigger2 = fixtureEl.querySelector('button')
+ const modalEl1 = document.querySelector('#modal1')
+ const modalEl2 = document.querySelector('#modal2')
+ const modal1 = new Modal(modalEl1)
+
+ modalEl1.addEventListener('shown.bs.modal', () => {
+ trigger2.click()
+ })
+ modalEl1.addEventListener('hidden.bs.modal', () => {
+ expect(Modal.getInstance(modalEl2)).not.toBeNull()
+ expect(modalEl2).toHaveClass('show')
+ resolve()
+ })
+ modal1.show()
+ })
+ })
+ })
+ describe('jQueryInterface', () => {
+ it('should create a modal', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.modal = Modal.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.modal.call(jQueryMock)
+
+ expect(Modal.getInstance(div)).not.toBeNull()
+ })
+
+ it('should create a modal with given config', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.modal = Modal.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.modal.call(jQueryMock, { keyboard: false })
+ const spy = spyOn(Modal.prototype, 'constructor')
+ expect(spy).not.toHaveBeenCalledWith(div, { keyboard: false })
+
+ const modal = Modal.getInstance(div)
+ expect(modal).not.toBeNull()
+ expect(modal._config.keyboard).toBeFalse()
+ })
+
+ it('should not re create a modal', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const modal = new Modal(div)
+
+ jQueryMock.fn.modal = Modal.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.modal.call(jQueryMock)
+
+ expect(Modal.getInstance(div)).toEqual(modal)
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.modal = Modal.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.modal.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+
+ it('should call show method', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const modal = new Modal(div)
+
+ jQueryMock.fn.modal = Modal.jQueryInterface
+ jQueryMock.elements = [div]
+
+ const spy = spyOn(modal, 'show')
+
+ jQueryMock.fn.modal.call(jQueryMock, 'show')
+
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should not call show method', () => {
+ fixtureEl.innerHTML = '<div class="modal" data-bs-show="false"><div class="modal-dialog"></div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.modal = Modal.jQueryInterface
+ jQueryMock.elements = [div]
+
+ const spy = spyOn(Modal.prototype, 'show')
+
+ jQueryMock.fn.modal.call(jQueryMock)
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return modal instance', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const modal = new Modal(div)
+
+ expect(Modal.getInstance(div)).toEqual(modal)
+ expect(Modal.getInstance(div)).toBeInstanceOf(Modal)
+ })
+
+ it('should return null when there is no modal instance', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Modal.getInstance(div)).toBeNull()
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return modal instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const modal = new Modal(div)
+
+ expect(Modal.getOrCreateInstance(div)).toEqual(modal)
+ expect(Modal.getInstance(div)).toEqual(Modal.getOrCreateInstance(div, {}))
+ expect(Modal.getOrCreateInstance(div)).toBeInstanceOf(Modal)
+ })
+
+ it('should return new instance when there is no modal instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Modal.getInstance(div)).toBeNull()
+ expect(Modal.getOrCreateInstance(div)).toBeInstanceOf(Modal)
+ })
+
+ it('should return new instance when there is no modal instance with given configuration', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Modal.getInstance(div)).toBeNull()
+ const modal = Modal.getOrCreateInstance(div, {
+ backdrop: true
+ })
+ expect(modal).toBeInstanceOf(Modal)
+
+ expect(modal._config.backdrop).toBeTrue()
+ })
+
+ it('should return the instance when exists without given configuration', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const modal = new Modal(div, {
+ backdrop: true
+ })
+ expect(Modal.getInstance(div)).toEqual(modal)
+
+ const modal2 = Modal.getOrCreateInstance(div, {
+ backdrop: false
+ })
+ expect(modal).toBeInstanceOf(Modal)
+ expect(modal2).toEqual(modal)
+
+ expect(modal2._config.backdrop).toBeTrue()
+ })
+ })
+})
diff --git a/js/tests/unit/offcanvas.spec.js b/js/tests/unit/offcanvas.spec.js
new file mode 100644
index 0000000..da2fb97
--- /dev/null
+++ b/js/tests/unit/offcanvas.spec.js
@@ -0,0 +1,912 @@
+import Offcanvas from '../../src/offcanvas'
+import EventHandler from '../../src/dom/event-handler'
+import { clearBodyAndDocument, clearFixture, createEvent, getFixture, jQueryMock } from '../helpers/fixture'
+import { isVisible } from '../../src/util/index'
+import ScrollBarHelper from '../../src/util/scrollbar'
+
+describe('Offcanvas', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ document.body.classList.remove('offcanvas-open')
+ clearBodyAndDocument()
+ })
+
+ beforeEach(() => {
+ clearBodyAndDocument()
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Offcanvas.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin default config', () => {
+ expect(Offcanvas.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(Offcanvas.DATA_KEY).toEqual('bs.offcanvas')
+ })
+ })
+
+ describe('constructor', () => {
+ it('should call hide when a element with data-bs-dismiss="offcanvas" is clicked', () => {
+ fixtureEl.innerHTML = [
+ '<div class="offcanvas">',
+ ' <a href="#" data-bs-dismiss="offcanvas">Close</a>',
+ '</div>'
+ ].join('')
+
+ const offCanvasEl = fixtureEl.querySelector('.offcanvas')
+ const closeEl = fixtureEl.querySelector('a')
+ const offCanvas = new Offcanvas(offCanvasEl)
+
+ const spy = spyOn(offCanvas, 'hide')
+
+ closeEl.click()
+
+ expect(offCanvas._config.keyboard).toBeTrue()
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should hide if esc is pressed', () => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('.offcanvas')
+ const offCanvas = new Offcanvas(offCanvasEl)
+ const keyDownEsc = createEvent('keydown')
+ keyDownEsc.key = 'Escape'
+
+ const spy = spyOn(offCanvas, 'hide')
+
+ offCanvasEl.dispatchEvent(keyDownEsc)
+
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should hide if esc is pressed and backdrop is static', () => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('.offcanvas')
+ const offCanvas = new Offcanvas(offCanvasEl, { backdrop: 'static' })
+ const keyDownEsc = createEvent('keydown')
+ keyDownEsc.key = 'Escape'
+
+ const spy = spyOn(offCanvas, 'hide')
+
+ offCanvasEl.dispatchEvent(keyDownEsc)
+
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should not hide if esc is not pressed', () => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('.offcanvas')
+ const offCanvas = new Offcanvas(offCanvasEl)
+ const keydownTab = createEvent('keydown')
+ keydownTab.key = 'Tab'
+
+ const spy = spyOn(offCanvas, 'hide')
+
+ offCanvasEl.dispatchEvent(keydownTab)
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should not hide if esc is pressed but with keyboard = false', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('.offcanvas')
+ const offCanvas = new Offcanvas(offCanvasEl, { keyboard: false })
+ const keyDownEsc = createEvent('keydown')
+ keyDownEsc.key = 'Escape'
+
+ const spy = spyOn(offCanvas, 'hide')
+ const hidePreventedSpy = jasmine.createSpy('hidePrevented')
+ offCanvasEl.addEventListener('hidePrevented.bs.offcanvas', hidePreventedSpy)
+
+ offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
+ expect(offCanvas._config.keyboard).toBeFalse()
+ offCanvasEl.dispatchEvent(keyDownEsc)
+
+ expect(hidePreventedSpy).toHaveBeenCalled()
+ expect(spy).not.toHaveBeenCalled()
+ resolve()
+ })
+
+ offCanvas.show()
+ })
+ })
+
+ it('should not hide if user clicks on static backdrop', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('div')
+ const offCanvas = new Offcanvas(offCanvasEl, { backdrop: 'static' })
+
+ const clickEvent = new Event('mousedown', { bubbles: true, cancelable: true })
+ const spyClick = spyOn(offCanvas._backdrop._config, 'clickCallback').and.callThrough()
+ const spyHide = spyOn(offCanvas._backdrop, 'hide').and.callThrough()
+ const hidePreventedSpy = jasmine.createSpy('hidePrevented')
+ offCanvasEl.addEventListener('hidePrevented.bs.offcanvas', hidePreventedSpy)
+
+ offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
+ expect(spyClick).toEqual(jasmine.any(Function))
+
+ offCanvas._backdrop._getElement().dispatchEvent(clickEvent)
+ expect(hidePreventedSpy).toHaveBeenCalled()
+ expect(spyHide).not.toHaveBeenCalled()
+ resolve()
+ })
+
+ offCanvas.show()
+ })
+ })
+
+ it('should call `hide` on resize, if element\'s position is not fixed any more', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="offcanvas-lg"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('div')
+ const offCanvas = new Offcanvas(offCanvasEl)
+
+ const spy = spyOn(offCanvas, 'hide').and.callThrough()
+
+ offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
+ const resizeEvent = createEvent('resize')
+ offCanvasEl.style.removeProperty('position')
+
+ window.dispatchEvent(resizeEvent)
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ offCanvas.show()
+ })
+ })
+ })
+
+ describe('config', () => {
+ it('should have default values', () => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('.offcanvas')
+ const offCanvas = new Offcanvas(offCanvasEl)
+
+ expect(offCanvas._config.backdrop).toBeTrue()
+ expect(offCanvas._backdrop._config.isVisible).toBeTrue()
+ expect(offCanvas._config.keyboard).toBeTrue()
+ expect(offCanvas._config.scroll).toBeFalse()
+ })
+
+ it('should read data attributes and override default config', () => {
+ fixtureEl.innerHTML = '<div class="offcanvas" data-bs-scroll="true" data-bs-backdrop="false" data-bs-keyboard="false"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('.offcanvas')
+ const offCanvas = new Offcanvas(offCanvasEl)
+
+ expect(offCanvas._config.backdrop).toBeFalse()
+ expect(offCanvas._backdrop._config.isVisible).toBeFalse()
+ expect(offCanvas._config.keyboard).toBeFalse()
+ expect(offCanvas._config.scroll).toBeTrue()
+ })
+
+ it('given a config object must override data attributes', () => {
+ fixtureEl.innerHTML = '<div class="offcanvas" data-bs-scroll="true" data-bs-backdrop="false" data-bs-keyboard="false"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('.offcanvas')
+ const offCanvas = new Offcanvas(offCanvasEl, {
+ backdrop: true,
+ keyboard: true,
+ scroll: false
+ })
+ expect(offCanvas._config.backdrop).toBeTrue()
+ expect(offCanvas._config.keyboard).toBeTrue()
+ expect(offCanvas._config.scroll).toBeFalse()
+ })
+ })
+
+ describe('options', () => {
+ it('if scroll is enabled, should allow body to scroll while offcanvas is open', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+
+ const spyHide = spyOn(ScrollBarHelper.prototype, 'hide').and.callThrough()
+ const spyReset = spyOn(ScrollBarHelper.prototype, 'reset').and.callThrough()
+ const offCanvasEl = fixtureEl.querySelector('.offcanvas')
+ const offCanvas = new Offcanvas(offCanvasEl, { scroll: true })
+
+ offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
+ expect(spyHide).not.toHaveBeenCalled()
+ offCanvas.hide()
+ })
+ offCanvasEl.addEventListener('hidden.bs.offcanvas', () => {
+ expect(spyReset).not.toHaveBeenCalled()
+ resolve()
+ })
+ offCanvas.show()
+ })
+ })
+
+ it('if scroll is disabled, should call ScrollBarHelper to handle scrollBar on body', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+
+ const spyHide = spyOn(ScrollBarHelper.prototype, 'hide').and.callThrough()
+ const spyReset = spyOn(ScrollBarHelper.prototype, 'reset').and.callThrough()
+ const offCanvasEl = fixtureEl.querySelector('.offcanvas')
+ const offCanvas = new Offcanvas(offCanvasEl, { scroll: false })
+
+ offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
+ expect(spyHide).toHaveBeenCalled()
+ offCanvas.hide()
+ })
+ offCanvasEl.addEventListener('hidden.bs.offcanvas', () => {
+ expect(spyReset).toHaveBeenCalled()
+ resolve()
+ })
+ offCanvas.show()
+ })
+ })
+
+ it('should hide a shown element if user click on backdrop', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('div')
+ const offCanvas = new Offcanvas(offCanvasEl, { backdrop: true })
+
+ const clickEvent = new Event('mousedown', { bubbles: true, cancelable: true })
+ const spy = spyOn(offCanvas._backdrop._config, 'clickCallback').and.callThrough()
+
+ offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
+ expect(offCanvas._backdrop._config.clickCallback).toEqual(jasmine.any(Function))
+
+ offCanvas._backdrop._getElement().dispatchEvent(clickEvent)
+ })
+
+ offCanvasEl.addEventListener('hidden.bs.offcanvas', () => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ offCanvas.show()
+ })
+ })
+
+ it('should not trap focus if scroll is allowed', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('.offcanvas')
+ const offCanvas = new Offcanvas(offCanvasEl, {
+ scroll: true,
+ backdrop: false
+ })
+
+ const spy = spyOn(offCanvas._focustrap, 'activate').and.callThrough()
+
+ offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
+ expect(spy).not.toHaveBeenCalled()
+ resolve()
+ })
+
+ offCanvas.show()
+ })
+ })
+
+ it('should trap focus if scroll is allowed OR backdrop is enabled', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('.offcanvas')
+ const offCanvas = new Offcanvas(offCanvasEl, {
+ scroll: true,
+ backdrop: true
+ })
+
+ const spy = spyOn(offCanvas._focustrap, 'activate').and.callThrough()
+
+ offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ offCanvas.show()
+ })
+ })
+ })
+
+ describe('toggle', () => {
+ it('should call show method if show class is not present', () => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('.offcanvas')
+ const offCanvas = new Offcanvas(offCanvasEl)
+
+ const spy = spyOn(offCanvas, 'show')
+
+ offCanvas.toggle()
+
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should call hide method if show class is present', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('.offcanvas')
+ const offCanvas = new Offcanvas(offCanvasEl)
+
+ offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
+ expect(offCanvasEl).toHaveClass('show')
+ const spy = spyOn(offCanvas, 'hide')
+
+ offCanvas.toggle()
+
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ offCanvas.show()
+ })
+ })
+ })
+
+ describe('show', () => {
+ it('should add `showing` class during opening and `show` class on end', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+ const offCanvasEl = fixtureEl.querySelector('.offcanvas')
+ const offCanvas = new Offcanvas(offCanvasEl)
+
+ offCanvasEl.addEventListener('show.bs.offcanvas', () => {
+ expect(offCanvasEl).not.toHaveClass('show')
+ })
+
+ offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
+ expect(offCanvasEl).not.toHaveClass('showing')
+ expect(offCanvasEl).toHaveClass('show')
+ resolve()
+ })
+
+ offCanvas.show()
+ expect(offCanvasEl).toHaveClass('showing')
+ })
+ })
+
+ it('should do nothing if already shown', () => {
+ fixtureEl.innerHTML = '<div class="offcanvas show"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('div')
+ const offCanvas = new Offcanvas(offCanvasEl)
+ offCanvas.show()
+
+ expect(offCanvasEl).toHaveClass('show')
+
+ const spyShow = spyOn(offCanvas._backdrop, 'show').and.callThrough()
+ const spyTrigger = spyOn(EventHandler, 'trigger').and.callThrough()
+ offCanvas.show()
+
+ expect(spyTrigger).not.toHaveBeenCalled()
+ expect(spyShow).not.toHaveBeenCalled()
+ })
+
+ it('should show a hidden element', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('div')
+ const offCanvas = new Offcanvas(offCanvasEl)
+ const spy = spyOn(offCanvas._backdrop, 'show').and.callThrough()
+
+ offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
+ expect(offCanvasEl).toHaveClass('show')
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ offCanvas.show()
+ })
+ })
+
+ it('should not fire shown when show is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('div')
+ const offCanvas = new Offcanvas(offCanvasEl)
+ const spy = spyOn(offCanvas._backdrop, 'show').and.callThrough()
+
+ const expectEnd = () => {
+ setTimeout(() => {
+ expect(spy).not.toHaveBeenCalled()
+ resolve()
+ }, 10)
+ }
+
+ offCanvasEl.addEventListener('show.bs.offcanvas', event => {
+ event.preventDefault()
+ expectEnd()
+ })
+
+ offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
+ reject(new Error('should not fire shown event'))
+ })
+
+ offCanvas.show()
+ })
+ })
+
+ it('on window load, should make visible an offcanvas element, if its markup contains class "show"', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="offcanvas show"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('div')
+ const spy = spyOn(Offcanvas.prototype, 'show').and.callThrough()
+
+ offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
+ resolve()
+ })
+
+ window.dispatchEvent(createEvent('load'))
+
+ const instance = Offcanvas.getInstance(offCanvasEl)
+ expect(instance).not.toBeNull()
+ expect(spy).toHaveBeenCalled()
+ })
+ })
+
+ it('should trap focus', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('.offcanvas')
+ const offCanvas = new Offcanvas(offCanvasEl)
+
+ const spy = spyOn(offCanvas._focustrap, 'activate').and.callThrough()
+
+ offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ offCanvas.show()
+ })
+ })
+ })
+
+ describe('hide', () => {
+ it('should add `hiding` class during closing and remover `show` & `hiding` classes on end', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+ const offCanvasEl = fixtureEl.querySelector('.offcanvas')
+ const offCanvas = new Offcanvas(offCanvasEl)
+
+ offCanvasEl.addEventListener('hide.bs.offcanvas', () => {
+ expect(offCanvasEl).not.toHaveClass('showing')
+ expect(offCanvasEl).toHaveClass('show')
+ })
+
+ offCanvasEl.addEventListener('hidden.bs.offcanvas', () => {
+ expect(offCanvasEl).not.toHaveClass('hiding')
+ expect(offCanvasEl).not.toHaveClass('show')
+ resolve()
+ })
+
+ offCanvas.show()
+ offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
+ offCanvas.hide()
+ expect(offCanvasEl).not.toHaveClass('showing')
+ expect(offCanvasEl).toHaveClass('hiding')
+ })
+ })
+ })
+
+ it('should do nothing if already shown', () => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+
+ const spyTrigger = spyOn(EventHandler, 'trigger').and.callThrough()
+
+ const offCanvasEl = fixtureEl.querySelector('div')
+ const offCanvas = new Offcanvas(offCanvasEl)
+ const spyHide = spyOn(offCanvas._backdrop, 'hide').and.callThrough()
+
+ offCanvas.hide()
+ expect(spyHide).not.toHaveBeenCalled()
+ expect(spyTrigger).not.toHaveBeenCalled()
+ })
+
+ it('should hide a shown element', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('div')
+ const offCanvas = new Offcanvas(offCanvasEl)
+ const spy = spyOn(offCanvas._backdrop, 'hide').and.callThrough()
+ offCanvas.show()
+
+ offCanvasEl.addEventListener('hidden.bs.offcanvas', () => {
+ expect(offCanvasEl).not.toHaveClass('show')
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ offCanvas.hide()
+ })
+ })
+
+ it('should not fire hidden when hide is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('div')
+ const offCanvas = new Offcanvas(offCanvasEl)
+ const spy = spyOn(offCanvas._backdrop, 'hide').and.callThrough()
+
+ offCanvas.show()
+
+ const expectEnd = () => {
+ setTimeout(() => {
+ expect(spy).not.toHaveBeenCalled()
+ resolve()
+ }, 10)
+ }
+
+ offCanvasEl.addEventListener('hide.bs.offcanvas', event => {
+ event.preventDefault()
+ expectEnd()
+ })
+
+ offCanvasEl.addEventListener('hidden.bs.offcanvas', () => {
+ reject(new Error('should not fire hidden event'))
+ })
+
+ offCanvas.hide()
+ })
+ })
+
+ it('should release focus trap', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('div')
+ const offCanvas = new Offcanvas(offCanvasEl)
+ const spy = spyOn(offCanvas._focustrap, 'deactivate').and.callThrough()
+ offCanvas.show()
+
+ offCanvasEl.addEventListener('hidden.bs.offcanvas', () => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ offCanvas.hide()
+ })
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose an offcanvas', () => {
+ fixtureEl.innerHTML = '<div class="offcanvas"></div>'
+
+ const offCanvasEl = fixtureEl.querySelector('div')
+ const offCanvas = new Offcanvas(offCanvasEl)
+ const backdrop = offCanvas._backdrop
+ const spyDispose = spyOn(backdrop, 'dispose').and.callThrough()
+ const focustrap = offCanvas._focustrap
+ const spyDeactivate = spyOn(focustrap, 'deactivate').and.callThrough()
+
+ expect(Offcanvas.getInstance(offCanvasEl)).toEqual(offCanvas)
+
+ offCanvas.dispose()
+
+ expect(spyDispose).toHaveBeenCalled()
+ expect(offCanvas._backdrop).toBeNull()
+ expect(spyDeactivate).toHaveBeenCalled()
+ expect(offCanvas._focustrap).toBeNull()
+ expect(Offcanvas.getInstance(offCanvasEl)).toBeNull()
+ })
+ })
+
+ describe('data-api', () => {
+ it('should not prevent event for input', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<input type="checkbox" data-bs-toggle="offcanvas" data-bs-target="#offcanvasdiv1">',
+ '<div id="offcanvasdiv1" class="offcanvas"></div>'
+ ].join('')
+
+ const target = fixtureEl.querySelector('input')
+ const offCanvasEl = fixtureEl.querySelector('#offcanvasdiv1')
+
+ offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
+ expect(offCanvasEl).toHaveClass('show')
+ expect(target.checked).toBeTrue()
+ resolve()
+ })
+
+ target.click()
+ })
+ })
+
+ it('should not call toggle on disabled elements', () => {
+ fixtureEl.innerHTML = [
+ '<a href="#" data-bs-toggle="offcanvas" data-bs-target="#offcanvasdiv1" class="disabled"></a>',
+ '<div id="offcanvasdiv1" class="offcanvas"></div>'
+ ].join('')
+
+ const target = fixtureEl.querySelector('a')
+
+ const spy = spyOn(Offcanvas.prototype, 'toggle')
+
+ target.click()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should call hide first, if another offcanvas is open', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<button id="btn2" data-bs-toggle="offcanvas" data-bs-target="#offcanvas2"></button>',
+ '<div id="offcanvas1" class="offcanvas"></div>',
+ '<div id="offcanvas2" class="offcanvas"></div>'
+ ].join('')
+
+ const trigger2 = fixtureEl.querySelector('#btn2')
+ const offcanvasEl1 = document.querySelector('#offcanvas1')
+ const offcanvasEl2 = document.querySelector('#offcanvas2')
+ const offcanvas1 = new Offcanvas(offcanvasEl1)
+
+ offcanvasEl1.addEventListener('shown.bs.offcanvas', () => {
+ trigger2.click()
+ })
+ offcanvasEl1.addEventListener('hidden.bs.offcanvas', () => {
+ expect(Offcanvas.getInstance(offcanvasEl2)).not.toBeNull()
+ resolve()
+ })
+ offcanvas1.show()
+ })
+ })
+
+ it('should focus on trigger element after closing offcanvas', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<button id="btn" data-bs-toggle="offcanvas" data-bs-target="#offcanvas"></button>',
+ '<div id="offcanvas" class="offcanvas"></div>'
+ ].join('')
+
+ const trigger = fixtureEl.querySelector('#btn')
+ const offcanvasEl = fixtureEl.querySelector('#offcanvas')
+ const offcanvas = new Offcanvas(offcanvasEl)
+ const spy = spyOn(trigger, 'focus')
+
+ offcanvasEl.addEventListener('shown.bs.offcanvas', () => {
+ offcanvas.hide()
+ })
+ offcanvasEl.addEventListener('hidden.bs.offcanvas', () => {
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ }, 5)
+ })
+
+ trigger.click()
+ })
+ })
+
+ it('should not focus on trigger element after closing offcanvas, if it is not visible', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<button id="btn" data-bs-toggle="offcanvas" data-bs-target="#offcanvas"></button>',
+ '<div id="offcanvas" class="offcanvas"></div>'
+ ].join('')
+
+ const trigger = fixtureEl.querySelector('#btn')
+ const offcanvasEl = fixtureEl.querySelector('#offcanvas')
+ const offcanvas = new Offcanvas(offcanvasEl)
+ const spy = spyOn(trigger, 'focus')
+
+ offcanvasEl.addEventListener('shown.bs.offcanvas', () => {
+ trigger.style.display = 'none'
+ offcanvas.hide()
+ })
+ offcanvasEl.addEventListener('hidden.bs.offcanvas', () => {
+ setTimeout(() => {
+ expect(isVisible(trigger)).toBeFalse()
+ expect(spy).not.toHaveBeenCalled()
+ resolve()
+ }, 5)
+ })
+
+ trigger.click()
+ })
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create an offcanvas', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.offcanvas = Offcanvas.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.offcanvas.call(jQueryMock)
+
+ expect(Offcanvas.getInstance(div)).not.toBeNull()
+ })
+
+ it('should not re create an offcanvas', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const offCanvas = new Offcanvas(div)
+
+ jQueryMock.fn.offcanvas = Offcanvas.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.offcanvas.call(jQueryMock)
+
+ expect(Offcanvas.getInstance(div)).toEqual(offCanvas)
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.offcanvas = Offcanvas.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.offcanvas.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+
+ it('should throw error on protected method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = '_getConfig'
+
+ jQueryMock.fn.offcanvas = Offcanvas.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.offcanvas.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+
+ it('should throw error if method "constructor" is being called', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'constructor'
+
+ jQueryMock.fn.offcanvas = Offcanvas.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.offcanvas.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+
+ it('should call offcanvas method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ const spy = spyOn(Offcanvas.prototype, 'show')
+
+ jQueryMock.fn.offcanvas = Offcanvas.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.offcanvas.call(jQueryMock, 'show')
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should create a offcanvas with given config', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.offcanvas = Offcanvas.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.offcanvas.call(jQueryMock, { scroll: true })
+
+ const offcanvas = Offcanvas.getInstance(div)
+ expect(offcanvas).not.toBeNull()
+ expect(offcanvas._config.scroll).toBeTrue()
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return offcanvas instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const offCanvas = new Offcanvas(div)
+
+ expect(Offcanvas.getInstance(div)).toEqual(offCanvas)
+ expect(Offcanvas.getInstance(div)).toBeInstanceOf(Offcanvas)
+ })
+
+ it('should return null when there is no offcanvas instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Offcanvas.getInstance(div)).toBeNull()
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return offcanvas instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const offcanvas = new Offcanvas(div)
+
+ expect(Offcanvas.getOrCreateInstance(div)).toEqual(offcanvas)
+ expect(Offcanvas.getInstance(div)).toEqual(Offcanvas.getOrCreateInstance(div, {}))
+ expect(Offcanvas.getOrCreateInstance(div)).toBeInstanceOf(Offcanvas)
+ })
+
+ it('should return new instance when there is no Offcanvas instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Offcanvas.getInstance(div)).toBeNull()
+ expect(Offcanvas.getOrCreateInstance(div)).toBeInstanceOf(Offcanvas)
+ })
+
+ it('should return new instance when there is no offcanvas instance with given configuration', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Offcanvas.getInstance(div)).toBeNull()
+ const offcanvas = Offcanvas.getOrCreateInstance(div, {
+ scroll: true
+ })
+ expect(offcanvas).toBeInstanceOf(Offcanvas)
+
+ expect(offcanvas._config.scroll).toBeTrue()
+ })
+
+ it('should return the instance when exists without given configuration', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const offcanvas = new Offcanvas(div, {
+ scroll: true
+ })
+ expect(Offcanvas.getInstance(div)).toEqual(offcanvas)
+
+ const offcanvas2 = Offcanvas.getOrCreateInstance(div, {
+ scroll: false
+ })
+ expect(offcanvas).toBeInstanceOf(Offcanvas)
+ expect(offcanvas2).toEqual(offcanvas)
+
+ expect(offcanvas2._config.scroll).toBeTrue()
+ })
+ })
+})
diff --git a/js/tests/unit/popover.spec.js b/js/tests/unit/popover.spec.js
new file mode 100644
index 0000000..baf691c
--- /dev/null
+++ b/js/tests/unit/popover.spec.js
@@ -0,0 +1,413 @@
+import Popover from '../../src/popover'
+import EventHandler from '../../src/dom/event-handler'
+import { clearFixture, getFixture, jQueryMock } from '../helpers/fixture'
+
+describe('Popover', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+
+ const popoverList = document.querySelectorAll('.popover')
+
+ for (const popoverEl of popoverList) {
+ popoverEl.remove()
+ }
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Popover.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin default config', () => {
+ expect(Popover.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('NAME', () => {
+ it('should return plugin name', () => {
+ expect(Popover.NAME).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(Popover.DATA_KEY).toEqual('bs.popover')
+ })
+ })
+
+ describe('EVENT_KEY', () => {
+ it('should return plugin event key', () => {
+ expect(Popover.EVENT_KEY).toEqual('.bs.popover')
+ })
+ })
+
+ describe('DefaultType', () => {
+ it('should return plugin default type', () => {
+ expect(Popover.DefaultType).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('show', () => {
+ it('should show a popover', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="https://twitter.com/getbootstrap">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const popover = new Popover(popoverEl)
+
+ popoverEl.addEventListener('shown.bs.popover', () => {
+ expect(document.querySelector('.popover')).not.toBeNull()
+ resolve()
+ })
+
+ popover.show()
+ })
+ })
+
+ it('should set title and content from functions', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const popover = new Popover(popoverEl, {
+ title: () => 'Bootstrap',
+ content: () => 'loves writing tests (╯°□°)╯︵ ┻━┻'
+ })
+
+ popoverEl.addEventListener('shown.bs.popover', () => {
+ const popoverDisplayed = document.querySelector('.popover')
+
+ expect(popoverDisplayed).not.toBeNull()
+ expect(popoverDisplayed.querySelector('.popover-header').textContent).toEqual('Bootstrap')
+ expect(popoverDisplayed.querySelector('.popover-body').textContent).toEqual('loves writing tests (╯°□°)╯︵ ┻━┻')
+ resolve()
+ })
+
+ popover.show()
+ })
+ })
+
+ it('should show a popover with just content without having header', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#">Nice link</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const popover = new Popover(popoverEl, {
+ content: 'Some beautiful content :)'
+ })
+
+ popoverEl.addEventListener('shown.bs.popover', () => {
+ const popoverDisplayed = document.querySelector('.popover')
+
+ expect(popoverDisplayed).not.toBeNull()
+ expect(popoverDisplayed.querySelector('.popover-header')).toBeNull()
+ expect(popoverDisplayed.querySelector('.popover-body').textContent).toEqual('Some beautiful content :)')
+ resolve()
+ })
+
+ popover.show()
+ })
+ })
+
+ it('should show a popover with just title without having body', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#">Nice link</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const popover = new Popover(popoverEl, {
+ title: 'Title which does not require content'
+ })
+
+ popoverEl.addEventListener('shown.bs.popover', () => {
+ const popoverDisplayed = document.querySelector('.popover')
+
+ expect(popoverDisplayed).not.toBeNull()
+ expect(popoverDisplayed.querySelector('.popover-body')).toBeNull()
+ expect(popoverDisplayed.querySelector('.popover-header').textContent).toEqual('Title which does not require content')
+ resolve()
+ })
+
+ popover.show()
+ })
+ })
+
+ it('should show a popover with just title without having body using data-attribute to get config', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" data-bs-content="" title="Title which does not require content">Nice link</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const popover = new Popover(popoverEl)
+
+ popoverEl.addEventListener('shown.bs.popover', () => {
+ const popoverDisplayed = document.querySelector('.popover')
+
+ expect(popoverDisplayed).not.toBeNull()
+ expect(popoverDisplayed.querySelector('.popover-body')).toBeNull()
+ expect(popoverDisplayed.querySelector('.popover-header').textContent).toEqual('Title which does not require content')
+ resolve()
+ })
+
+ popover.show()
+ })
+ })
+
+ it('should NOT show a popover without `title` and `content`', () => {
+ fixtureEl.innerHTML = '<a href="#" data-bs-content="" title="">Nice link</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const popover = new Popover(popoverEl, { animation: false })
+ const spy = spyOn(EventHandler, 'trigger').and.callThrough()
+
+ popover.show()
+
+ expect(spy).not.toHaveBeenCalledWith(popoverEl, Popover.eventName('show'))
+ expect(document.querySelector('.popover')).toBeNull()
+ })
+
+ it('"setContent" should keep the initial template', () => {
+ fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="https://twitter.com/getbootstrap" data-bs-custom-class="custom-class">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const popover = new Popover(popoverEl)
+
+ popover.setContent({ '.tooltip-inner': 'foo' })
+ const tip = popover._getTipElement()
+
+ expect(tip).toHaveClass('popover')
+ expect(tip).toHaveClass('bs-popover-auto')
+ expect(tip.querySelector('.popover-arrow')).not.toBeNull()
+ expect(tip.querySelector('.popover-header')).not.toBeNull()
+ expect(tip.querySelector('.popover-body')).not.toBeNull()
+ })
+
+ it('should call setContent once', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const popover = new Popover(popoverEl, {
+ content: 'Popover content'
+ })
+ expect(popover._templateFactory).toBeNull()
+ let spy = null
+ let times = 1
+
+ popoverEl.addEventListener('hidden.bs.popover', () => {
+ popover.show()
+ })
+
+ popoverEl.addEventListener('shown.bs.popover', () => {
+ spy = spy || spyOn(popover._templateFactory, 'constructor').and.callThrough()
+ const popoverDisplayed = document.querySelector('.popover')
+
+ expect(popoverDisplayed).not.toBeNull()
+ expect(popoverDisplayed.querySelector('.popover-body').textContent).toEqual('Popover content')
+ expect(spy).toHaveBeenCalledTimes(0)
+ if (times > 1) {
+ resolve()
+ }
+
+ times++
+ popover.hide()
+ })
+ popover.show()
+ })
+ })
+
+ it('should show a popover with provided custom class', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="https://twitter.com/getbootstrap" data-bs-custom-class="custom-class">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const popover = new Popover(popoverEl)
+
+ popoverEl.addEventListener('shown.bs.popover', () => {
+ const tip = document.querySelector('.popover')
+ expect(tip).not.toBeNull()
+ expect(tip).toHaveClass('custom-class')
+ resolve()
+ })
+
+ popover.show()
+ })
+ })
+ })
+
+ describe('hide', () => {
+ it('should hide a popover', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="https://twitter.com/getbootstrap">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const popover = new Popover(popoverEl)
+
+ popoverEl.addEventListener('shown.bs.popover', () => {
+ popover.hide()
+ })
+
+ popoverEl.addEventListener('hidden.bs.popover', () => {
+ expect(document.querySelector('.popover')).toBeNull()
+ resolve()
+ })
+
+ popover.show()
+ })
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a popover', () => {
+ fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="https://twitter.com/getbootstrap">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+
+ jQueryMock.fn.popover = Popover.jQueryInterface
+ jQueryMock.elements = [popoverEl]
+
+ jQueryMock.fn.popover.call(jQueryMock)
+
+ expect(Popover.getInstance(popoverEl)).not.toBeNull()
+ })
+
+ it('should create a popover with a config object', () => {
+ fixtureEl.innerHTML = '<a href="#" title="Popover">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+
+ jQueryMock.fn.popover = Popover.jQueryInterface
+ jQueryMock.elements = [popoverEl]
+
+ jQueryMock.fn.popover.call(jQueryMock, {
+ content: 'Popover content'
+ })
+
+ expect(Popover.getInstance(popoverEl)).not.toBeNull()
+ })
+
+ it('should not re create a popover', () => {
+ fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="https://twitter.com/getbootstrap">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const popover = new Popover(popoverEl)
+
+ jQueryMock.fn.popover = Popover.jQueryInterface
+ jQueryMock.elements = [popoverEl]
+
+ jQueryMock.fn.popover.call(jQueryMock)
+
+ expect(Popover.getInstance(popoverEl)).toEqual(popover)
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="https://twitter.com/getbootstrap">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.popover = Popover.jQueryInterface
+ jQueryMock.elements = [popoverEl]
+
+ expect(() => {
+ jQueryMock.fn.popover.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+
+ it('should should call show method', () => {
+ fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="https://twitter.com/getbootstrap">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const popover = new Popover(popoverEl)
+
+ jQueryMock.fn.popover = Popover.jQueryInterface
+ jQueryMock.elements = [popoverEl]
+
+ const spy = spyOn(popover, 'show')
+
+ jQueryMock.fn.popover.call(jQueryMock, 'show')
+
+ expect(spy).toHaveBeenCalled()
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return popover instance', () => {
+ fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="https://twitter.com/getbootstrap">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const popover = new Popover(popoverEl)
+
+ expect(Popover.getInstance(popoverEl)).toEqual(popover)
+ expect(Popover.getInstance(popoverEl)).toBeInstanceOf(Popover)
+ })
+
+ it('should return null when there is no popover instance', () => {
+ fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="https://twitter.com/getbootstrap">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+
+ expect(Popover.getInstance(popoverEl)).toBeNull()
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return popover instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const popover = new Popover(div)
+
+ expect(Popover.getOrCreateInstance(div)).toEqual(popover)
+ expect(Popover.getInstance(div)).toEqual(Popover.getOrCreateInstance(div, {}))
+ expect(Popover.getOrCreateInstance(div)).toBeInstanceOf(Popover)
+ })
+
+ it('should return new instance when there is no popover instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Popover.getInstance(div)).toBeNull()
+ expect(Popover.getOrCreateInstance(div)).toBeInstanceOf(Popover)
+ })
+
+ it('should return new instance when there is no popover instance with given configuration', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Popover.getInstance(div)).toBeNull()
+ const popover = Popover.getOrCreateInstance(div, {
+ placement: 'top'
+ })
+ expect(popover).toBeInstanceOf(Popover)
+
+ expect(popover._config.placement).toEqual('top')
+ })
+
+ it('should return the instance when exists without given configuration', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const popover = new Popover(div, {
+ placement: 'top'
+ })
+ expect(Popover.getInstance(div)).toEqual(popover)
+
+ const popover2 = Popover.getOrCreateInstance(div, {
+ placement: 'bottom'
+ })
+ expect(popover).toBeInstanceOf(Popover)
+ expect(popover2).toEqual(popover)
+
+ expect(popover2._config.placement).toEqual('top')
+ })
+ })
+})
diff --git a/js/tests/unit/scrollspy.spec.js b/js/tests/unit/scrollspy.spec.js
new file mode 100644
index 0000000..c7951e6
--- /dev/null
+++ b/js/tests/unit/scrollspy.spec.js
@@ -0,0 +1,946 @@
+import ScrollSpy from '../../src/scrollspy'
+
+/** Test helpers */
+import { clearFixture, createEvent, getFixture, jQueryMock } from '../helpers/fixture'
+import EventHandler from '../../src/dom/event-handler'
+
+describe('ScrollSpy', () => {
+ let fixtureEl
+
+ const getElementScrollSpy = element => element.scrollTo ?
+ spyOn(element, 'scrollTo').and.callThrough() :
+ spyOnProperty(element, 'scrollTop', 'set').and.callThrough()
+
+ const scrollTo = (el, height) => {
+ el.scrollTop = height
+ }
+
+ const onScrollStop = (callback, element, timeout = 30) => {
+ let handle = null
+ const onScroll = function () {
+ if (handle) {
+ window.clearTimeout(handle)
+ }
+
+ handle = setTimeout(() => {
+ element.removeEventListener('scroll', onScroll)
+ callback()
+ }, timeout + 1)
+ }
+
+ element.addEventListener('scroll', onScroll)
+ }
+
+ const getDummyFixture = () => {
+ return [
+ '<nav id="navBar" class="navbar">',
+ ' <ul class="nav">',
+ ' <li class="nav-item"><a id="li-jsm-1" class="nav-link" href="#div-jsm-1">div 1</a></li>',
+ ' </ul>',
+ '</nav>',
+ '<div class="content" data-bs-target="#navBar" style="overflow-y: auto">',
+ ' <div id="div-jsm-1">div 1</div>',
+ '</div>'
+ ].join('')
+ }
+
+ const testElementIsActiveAfterScroll = ({ elementSelector, targetSelector, contentEl, scrollSpy, cb }) => {
+ const element = fixtureEl.querySelector(elementSelector)
+ const target = fixtureEl.querySelector(targetSelector)
+ // add top padding to fix Chrome on Android failures
+ const paddingTop = 0
+ const parentOffset = getComputedStyle(contentEl).getPropertyValue('position') === 'relative' ? 0 : contentEl.offsetTop
+ const scrollHeight = (target.offsetTop - parentOffset) + paddingTop
+
+ contentEl.addEventListener('activate.bs.scrollspy', event => {
+ if (scrollSpy._activeTarget !== element) {
+ return
+ }
+
+ expect(element).toHaveClass('active')
+ expect(scrollSpy._activeTarget).toEqual(element)
+ expect(event.relatedTarget).toEqual(element)
+ cb()
+ })
+
+ setTimeout(() => { // in case we scroll something before the test
+ scrollTo(contentEl, scrollHeight)
+ }, 100)
+ }
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(ScrollSpy.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin default config', () => {
+ expect(ScrollSpy.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(ScrollSpy.DATA_KEY).toEqual('bs.scrollspy')
+ })
+ })
+
+ describe('constructor', () => {
+ it('should take care of element either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const sSpyEl = fixtureEl.querySelector('.content')
+ const sSpyBySelector = new ScrollSpy('.content')
+ const sSpyByElement = new ScrollSpy(sSpyEl)
+
+ expect(sSpyBySelector._element).toEqual(sSpyEl)
+ expect(sSpyByElement._element).toEqual(sSpyEl)
+ })
+
+ it('should null, if element is not scrollable', () => {
+ fixtureEl.innerHTML = [
+ '<nav id="navigation" class="navbar">',
+ ' <ul class="navbar-nav">' +
+ ' <li class="nav-item"><a class="nav-link active" id="one-link" href="#">One</a></li>' +
+ ' </ul>',
+ '</nav>',
+ '<div id="content">',
+ ' <div id="1" style="height: 300px;">test</div>',
+ '</div>'
+ ].join('')
+
+ const scrollSpy = new ScrollSpy(fixtureEl.querySelector('#content'), {
+ target: '#navigation'
+ })
+
+ expect(scrollSpy._observer.root).toBeNull()
+ expect(scrollSpy._rootElement).toBeNull()
+ })
+
+ it('should respect threshold option', () => {
+ fixtureEl.innerHTML = [
+ '<ul id="navigation" class="navbar">',
+ ' <a class="nav-link active" id="one-link" href="#">One</a>' +
+ '</ul>',
+ '<div id="content">',
+ ' <div id="one-link">test</div>',
+ '</div>'
+ ].join('')
+
+ const scrollSpy = new ScrollSpy('#content', {
+ target: '#navigation',
+ threshold: [1]
+ })
+
+ expect(scrollSpy._observer.thresholds).toEqual([1])
+ })
+
+ it('should respect threshold option markup', () => {
+ fixtureEl.innerHTML = [
+ '<ul id="navigation" class="navbar">',
+ ' <a class="nav-link active" id="one-link" href="#">One</a>' +
+ '</ul>',
+ '<div id="content" data-bs-threshold="0,0.2,1">',
+ ' <div id="one-link">test</div>',
+ '</div>'
+ ].join('')
+
+ const scrollSpy = new ScrollSpy('#content', {
+ target: '#navigation'
+ })
+
+ // See https://stackoverflow.com/a/45592926
+ const expectToBeCloseToArray = (actual, expected) => {
+ expect(actual.length).toBe(expected.length)
+ for (const x of actual) {
+ const i = actual.indexOf(x)
+ expect(x).withContext(`[${i}]`).toBeCloseTo(expected[i])
+ }
+ }
+
+ expectToBeCloseToArray(scrollSpy._observer.thresholds, [0, 0.2, 1])
+ })
+
+ it('should not take count to not visible sections', () => {
+ fixtureEl.innerHTML = [
+ '<nav id="navigation" class="navbar">',
+ ' <ul class="navbar-nav">',
+ ' <li class="nav-item"><a class="nav-link active" id="one-link" href="#one">One</a></li>',
+ ' <li class="nav-item"><a class="nav-link" id="two-link" href="#two">Two</a></li>',
+ ' <li class="nav-item"><a class="nav-link" id="three-link" href="#three">Three</a></li>',
+ ' </ul>',
+ '</nav>',
+ '<div id="content" style="height: 200px; overflow-y: auto;">',
+ ' <div id="one" style="height: 300px;">test</div>',
+ ' <div id="two" hidden style="height: 300px;">test</div>',
+ ' <div id="three" style="display: none;">test</div>',
+ '</div>'
+ ].join('')
+
+ const scrollSpy = new ScrollSpy(fixtureEl.querySelector('#content'), {
+ target: '#navigation'
+ })
+
+ expect(scrollSpy._observableSections.size).toBe(1)
+ expect(scrollSpy._targetLinks.size).toBe(1)
+ })
+
+ it('should not process element without target', () => {
+ fixtureEl.innerHTML = [
+ '<nav id="navigation" class="navbar">',
+ ' <ul class="navbar-nav">',
+ ' <li class="nav-item"><a class="nav-link active" id="one-link" href="#">One</a></li>',
+ ' <li class="nav-item"><a class="nav-link" id="two-link" href="#two">Two</a></li>',
+ ' <li class="nav-item"><a class="nav-link" id="three-link" href="#three">Three</a></li>',
+ ' </ul>',
+ '</nav>',
+ '<div id="content" style="height: 200px; overflow-y: auto;">',
+ ' <div id="two" style="height: 300px;">test</div>',
+ ' <div id="three" style="height: 10px;">test2</div>',
+ '</div>'
+ ].join('')
+
+ const scrollSpy = new ScrollSpy(fixtureEl.querySelector('#content'), {
+ target: '#navigation'
+ })
+
+ expect(scrollSpy._targetLinks).toHaveSize(2)
+ })
+
+ it('should only switch "active" class on current target', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="root" class="active" style="display: block">',
+ ' <div class="topbar">',
+ ' <div class="topbar-inner">',
+ ' <div class="container" id="ss-target">',
+ ' <ul class="nav">',
+ ' <li class="nav-item"><a href="#masthead">Overview</a></li>',
+ ' <li class="nav-item"><a href="#detail">Detail</a></li>',
+ ' </ul>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ ' <div id="scrollspy-example" style="height: 100px; overflow: auto;">',
+ ' <div style="height: 200px;" id="masthead">Overview</div>',
+ ' <div style="height: 200px;" id="detail">Detail</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const scrollSpyEl = fixtureEl.querySelector('#scrollspy-example')
+ const rootEl = fixtureEl.querySelector('#root')
+ const scrollSpy = new ScrollSpy(scrollSpyEl, {
+ target: 'ss-target'
+ })
+
+ const spy = spyOn(scrollSpy, '_process').and.callThrough()
+
+ onScrollStop(() => {
+ expect(rootEl).toHaveClass('active')
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ }, scrollSpyEl)
+
+ scrollTo(scrollSpyEl, 350)
+ })
+ })
+
+ it('should not process data if `activeTarget` is same as given target', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<nav class="navbar">',
+ ' <ul class="nav">',
+ ' <li class="nav-item"><a class="nav-link" id="a-1" href="#div-1">div 1</a></li>',
+ ' <li class="nav-item"><a class="nav-link" id="a-2" href="#div-2">div 2</a></li>',
+ ' </ul>',
+ '</nav>',
+ '<div class="content" style="overflow: auto; height: 50px">',
+ ' <div id="div-1" style="height: 100px; padding: 0; margin: 0">div 1</div>',
+ ' <div id="div-2" style="height: 200px; padding: 0; margin: 0">div 2</div>',
+ '</div>'
+ ].join('')
+
+ const contentEl = fixtureEl.querySelector('.content')
+ const scrollSpy = new ScrollSpy(contentEl, {
+ offset: 0,
+ target: '.navbar'
+ })
+
+ const triggerSpy = spyOn(EventHandler, 'trigger').and.callThrough()
+
+ scrollSpy._activeTarget = fixtureEl.querySelector('#a-1')
+ testElementIsActiveAfterScroll({
+ elementSelector: '#a-1',
+ targetSelector: '#div-1',
+ contentEl,
+ scrollSpy,
+ cb: reject
+ })
+
+ setTimeout(() => {
+ expect(triggerSpy).not.toHaveBeenCalled()
+ resolve()
+ }, 100)
+ })
+ })
+
+ it('should only switch "active" class on current target specified w element', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="root" class="active" style="display: block">',
+ ' <div class="topbar">',
+ ' <div class="topbar-inner">',
+ ' <div class="container" id="ss-target">',
+ ' <ul class="nav">',
+ ' <li class="nav-item"><a href="#masthead">Overview</a></li>',
+ ' <li class="nav-item"><a href="#detail">Detail</a></li>',
+ ' </ul>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ ' <div id="scrollspy-example" style="height: 100px; overflow: auto;">',
+ ' <div style="height: 200px;" id="masthead">Overview</div>',
+ ' <div style="height: 200px;" id="detail">Detail</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const scrollSpyEl = fixtureEl.querySelector('#scrollspy-example')
+ const rootEl = fixtureEl.querySelector('#root')
+ const scrollSpy = new ScrollSpy(scrollSpyEl, {
+ target: fixtureEl.querySelector('#ss-target')
+ })
+
+ const spy = spyOn(scrollSpy, '_process').and.callThrough()
+
+ onScrollStop(() => {
+ expect(rootEl).toHaveClass('active')
+ expect(scrollSpy._activeTarget).toEqual(fixtureEl.querySelector('[href="#detail"]'))
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ }, scrollSpyEl)
+
+ scrollTo(scrollSpyEl, 350)
+ })
+ })
+
+ it('should add the active class to the correct element', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<nav class="navbar">',
+ ' <ul class="nav">',
+ ' <li class="nav-item"><a class="nav-link" id="a-1" href="#div-1">div 1</a></li>',
+ ' <li class="nav-item"><a class="nav-link" id="a-2" href="#div-2">div 2</a></li>',
+ ' </ul>',
+ '</nav>',
+ '<div class="content" style="overflow: auto; height: 50px">',
+ ' <div id="div-1" style="height: 100px; padding: 0; margin: 0">div 1</div>',
+ ' <div id="div-2" style="height: 200px; padding: 0; margin: 0">div 2</div>',
+ '</div>'
+ ].join('')
+
+ const contentEl = fixtureEl.querySelector('.content')
+ const scrollSpy = new ScrollSpy(contentEl, {
+ offset: 0,
+ target: '.navbar'
+ })
+
+ testElementIsActiveAfterScroll({
+ elementSelector: '#a-1',
+ targetSelector: '#div-1',
+ contentEl,
+ scrollSpy,
+ cb() {
+ testElementIsActiveAfterScroll({
+ elementSelector: '#a-2',
+ targetSelector: '#div-2',
+ contentEl,
+ scrollSpy,
+ cb: resolve
+ })
+ }
+ })
+ })
+ })
+
+ it('should add to nav the active class to the correct element (nav markup)', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<nav class="navbar">',
+ ' <nav class="nav">',
+ ' <a class="nav-link" id="a-1" href="#div-1">div 1</a>',
+ ' <a class="nav-link" id="a-2" href="#div-2">div 2</a>',
+ ' </nav>',
+ '</nav>',
+ '<div class="content" style="overflow: auto; height: 50px">',
+ ' <div id="div-1" style="height: 100px; padding: 0; margin: 0">div 1</div>',
+ ' <div id="div-2" style="height: 200px; padding: 0; margin: 0">div 2</div>',
+ '</div>'
+ ].join('')
+
+ const contentEl = fixtureEl.querySelector('.content')
+ const scrollSpy = new ScrollSpy(contentEl, {
+ offset: 0,
+ target: '.navbar'
+ })
+
+ testElementIsActiveAfterScroll({
+ elementSelector: '#a-1',
+ targetSelector: '#div-1',
+ contentEl,
+ scrollSpy,
+ cb() {
+ testElementIsActiveAfterScroll({
+ elementSelector: '#a-2',
+ targetSelector: '#div-2',
+ contentEl,
+ scrollSpy,
+ cb: resolve
+ })
+ }
+ })
+ })
+ })
+
+ it('should add to list-group, the active class to the correct element (list-group markup)', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<nav class="navbar">',
+ ' <div class="list-group">',
+ ' <a class="list-group-item" id="a-1" href="#div-1">div 1</a>',
+ ' <a class="list-group-item" id="a-2" href="#div-2">div 2</a>',
+ ' </div>',
+ '</nav>',
+ '<div class="content" style="overflow: auto; height: 50px">',
+ ' <div id="div-1" style="height: 100px; padding: 0; margin: 0">div 1</div>',
+ ' <div id="div-2" style="height: 200px; padding: 0; margin: 0">div 2</div>',
+ '</div>'
+ ].join('')
+
+ const contentEl = fixtureEl.querySelector('.content')
+ const scrollSpy = new ScrollSpy(contentEl, {
+ offset: 0,
+ target: '.navbar'
+ })
+
+ testElementIsActiveAfterScroll({
+ elementSelector: '#a-1',
+ targetSelector: '#div-1',
+ contentEl,
+ scrollSpy,
+ cb() {
+ testElementIsActiveAfterScroll({
+ elementSelector: '#a-2',
+ targetSelector: '#div-2',
+ contentEl,
+ scrollSpy,
+ cb: resolve
+ })
+ }
+ })
+ })
+ })
+
+ it('should clear selection if above the first section', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="header" style="height: 500px;"></div>',
+ '<nav id="navigation" class="navbar">',
+ ' <ul class="navbar-nav">',
+ ' <li class="nav-item"><a id="one-link" class="nav-link active" href="#one">One</a></li>',
+ ' <li class="nav-item"><a id="two-link" class="nav-link" href="#two">Two</a></li>',
+ ' <li class="nav-item"><a id="three-link" class="nav-link" href="#three">Three</a></li>',
+ ' </ul>',
+ '</nav>',
+ '<div id="content" style="height: 200px; overflow-y: auto;">',
+ ' <div id="spacer" style="height: 200px;"></div>',
+ ' <div id="one" style="height: 100px;">text</div>',
+ ' <div id="two" style="height: 100px;">text</div>',
+ ' <div id="three" style="height: 100px;">text</div>',
+ ' <div id="spacer" style="height: 100px;"></div>',
+ '</div>'
+ ].join('')
+
+ const contentEl = fixtureEl.querySelector('#content')
+ const scrollSpy = new ScrollSpy(contentEl, {
+ target: '#navigation',
+ offset: contentEl.offsetTop
+ })
+ const spy = spyOn(scrollSpy, '_process').and.callThrough()
+
+ onScrollStop(() => {
+ const active = () => fixtureEl.querySelector('.active')
+ expect(spy).toHaveBeenCalled()
+
+ expect(fixtureEl.querySelectorAll('.active')).toHaveSize(1)
+ expect(active().getAttribute('id')).toEqual('two-link')
+ onScrollStop(() => {
+ expect(active()).toBeNull()
+ resolve()
+ }, contentEl)
+ scrollTo(contentEl, 0)
+ }, contentEl)
+
+ scrollTo(contentEl, 200)
+ })
+ })
+
+ it('should not clear selection if above the first section and first section is at the top', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="header" style="height: 500px;"></div>',
+ '<nav id="navigation" class="navbar">',
+ ' <ul class="navbar-nav">',
+ ' <li class="nav-item"><a id="one-link" class="nav-link active" href="#one">One</a></li>',
+ ' <li class="nav-item"><a id="two-link" class="nav-link" href="#two">Two</a></li>',
+ ' <li class="nav-item"><a id="three-link" class="nav-link" href="#three">Three</a></li>',
+ ' </ul>',
+ '</nav>',
+ '<div id="content" style="height: 150px; overflow-y: auto;">',
+ ' <div id="one" style="height: 100px;">test</div>',
+ ' <div id="two" style="height: 100px;">test</div>',
+ ' <div id="three" style="height: 100px;">test</div>',
+ ' <div id="spacer" style="height: 100px;">test</div>',
+ '</div>'
+ ].join('')
+
+ const negativeHeight = 0
+ const startOfSectionTwo = 101
+ const contentEl = fixtureEl.querySelector('#content')
+ // eslint-disable-next-line no-unused-vars
+ const scrollSpy = new ScrollSpy(contentEl, {
+ target: '#navigation',
+ rootMargin: '0px 0px -50%'
+ })
+
+ onScrollStop(() => {
+ const activeId = () => fixtureEl.querySelector('.active').getAttribute('id')
+
+ expect(fixtureEl.querySelectorAll('.active')).toHaveSize(1)
+ expect(activeId()).toEqual('two-link')
+ scrollTo(contentEl, negativeHeight)
+
+ onScrollStop(() => {
+ expect(fixtureEl.querySelectorAll('.active')).toHaveSize(1)
+ expect(activeId()).toEqual('one-link')
+ resolve()
+ }, contentEl)
+
+ scrollTo(contentEl, 0)
+ }, contentEl)
+
+ scrollTo(contentEl, startOfSectionTwo)
+ })
+ })
+
+ it('should correctly select navigation element on backward scrolling when each target section height is 100%', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<nav class="navbar">',
+ ' <ul class="nav">',
+ ' <li class="nav-item"><a id="li-100-1" class="nav-link" href="#div-100-1">div 1</a></li>',
+ ' <li class="nav-item"><a id="li-100-2" class="nav-link" href="#div-100-2">div 2</a></li>',
+ ' <li class="nav-item"><a id="li-100-3" class="nav-link" href="#div-100-3">div 3</a></li>',
+ ' <li class="nav-item"><a id="li-100-4" class="nav-link" href="#div-100-4">div 4</a></li>',
+ ' <li class="nav-item"><a id="li-100-5" class="nav-link" href="#div-100-5">div 5</a></li>',
+ ' </ul>',
+ '</nav>',
+ '<div class="content" style="position: relative; overflow: auto; height: 100px">',
+ ' <div id="div-100-1" style="position: relative; height: 100%; padding: 0; margin: 0">div 1</div>',
+ ' <div id="div-100-2" style="position: relative; height: 100%; padding: 0; margin: 0">div 2</div>',
+ ' <div id="div-100-3" style="position: relative; height: 100%; padding: 0; margin: 0">div 3</div>',
+ ' <div id="div-100-4" style="position: relative; height: 100%; padding: 0; margin: 0">div 4</div>',
+ ' <div id="div-100-5" style="position: relative; height: 100%; padding: 0; margin: 0">div 5</div>',
+ '</div>'
+ ].join('')
+
+ const contentEl = fixtureEl.querySelector('.content')
+ const scrollSpy = new ScrollSpy(contentEl, {
+ offset: 0,
+ target: '.navbar'
+ })
+
+ scrollTo(contentEl, 0)
+ testElementIsActiveAfterScroll({
+ elementSelector: '#li-100-5',
+ targetSelector: '#div-100-5',
+ contentEl,
+ scrollSpy,
+ cb() {
+ scrollTo(contentEl, 0)
+ testElementIsActiveAfterScroll({
+ elementSelector: '#li-100-2',
+ targetSelector: '#div-100-2',
+ contentEl,
+ scrollSpy,
+ cb() {
+ scrollTo(contentEl, 0)
+ testElementIsActiveAfterScroll({
+ elementSelector: '#li-100-3',
+ targetSelector: '#div-100-3',
+ contentEl,
+ scrollSpy,
+ cb() {
+ scrollTo(contentEl, 0)
+ testElementIsActiveAfterScroll({
+ elementSelector: '#li-100-2',
+ targetSelector: '#div-100-2',
+ contentEl,
+ scrollSpy,
+ cb() {
+ scrollTo(contentEl, 0)
+ testElementIsActiveAfterScroll({
+ elementSelector: '#li-100-1',
+ targetSelector: '#div-100-1',
+ contentEl,
+ scrollSpy,
+ cb: resolve
+ })
+ }
+ })
+ }
+ })
+ }
+ })
+ }
+ })
+ })
+ })
+ })
+
+ describe('refresh', () => {
+ it('should disconnect existing observer', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const el = fixtureEl.querySelector('.content')
+ const scrollSpy = new ScrollSpy(el)
+
+ const spy = spyOn(scrollSpy._observer, 'disconnect')
+
+ scrollSpy.refresh()
+
+ expect(spy).toHaveBeenCalled()
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose a scrollspy', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const el = fixtureEl.querySelector('.content')
+ const scrollSpy = new ScrollSpy(el)
+
+ expect(ScrollSpy.getInstance(el)).not.toBeNull()
+
+ scrollSpy.dispose()
+
+ expect(ScrollSpy.getInstance(el)).toBeNull()
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a scrollspy', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const div = fixtureEl.querySelector('.content')
+
+ jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.scrollspy.call(jQueryMock, { target: '#navBar' })
+
+ expect(ScrollSpy.getInstance(div)).not.toBeNull()
+ })
+
+ it('should create a scrollspy with given config', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const div = fixtureEl.querySelector('.content')
+
+ jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.scrollspy.call(jQueryMock, { rootMargin: '100px' })
+ const spy = spyOn(ScrollSpy.prototype, 'constructor')
+ expect(spy).not.toHaveBeenCalledWith(div, { rootMargin: '100px' })
+
+ const scrollspy = ScrollSpy.getInstance(div)
+ expect(scrollspy).not.toBeNull()
+ expect(scrollspy._config.rootMargin).toEqual('100px')
+ })
+
+ it('should not re create a scrollspy', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const div = fixtureEl.querySelector('.content')
+ const scrollSpy = new ScrollSpy(div)
+
+ jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.scrollspy.call(jQueryMock)
+
+ expect(ScrollSpy.getInstance(div)).toEqual(scrollSpy)
+ })
+
+ it('should call a scrollspy method', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const div = fixtureEl.querySelector('.content')
+ const scrollSpy = new ScrollSpy(div)
+
+ const spy = spyOn(scrollSpy, 'refresh')
+
+ jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.scrollspy.call(jQueryMock, 'refresh')
+
+ expect(ScrollSpy.getInstance(div)).toEqual(scrollSpy)
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const div = fixtureEl.querySelector('.content')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.scrollspy.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+
+ it('should throw error on protected method', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const div = fixtureEl.querySelector('.content')
+ const action = '_getConfig'
+
+ jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.scrollspy.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+
+ it('should throw error if method "constructor" is being called', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const div = fixtureEl.querySelector('.content')
+ const action = 'constructor'
+
+ jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.scrollspy.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return scrollspy instance', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const div = fixtureEl.querySelector('.content')
+ const scrollSpy = new ScrollSpy(div, { target: fixtureEl.querySelector('#navBar') })
+
+ expect(ScrollSpy.getInstance(div)).toEqual(scrollSpy)
+ expect(ScrollSpy.getInstance(div)).toBeInstanceOf(ScrollSpy)
+ })
+
+ it('should return null if there is no instance', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const div = fixtureEl.querySelector('.content')
+ expect(ScrollSpy.getInstance(div)).toBeNull()
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return scrollspy instance', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const div = fixtureEl.querySelector('.content')
+ const scrollspy = new ScrollSpy(div)
+
+ expect(ScrollSpy.getOrCreateInstance(div)).toEqual(scrollspy)
+ expect(ScrollSpy.getInstance(div)).toEqual(ScrollSpy.getOrCreateInstance(div, {}))
+ expect(ScrollSpy.getOrCreateInstance(div)).toBeInstanceOf(ScrollSpy)
+ })
+
+ it('should return new instance when there is no scrollspy instance', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const div = fixtureEl.querySelector('.content')
+
+ expect(ScrollSpy.getInstance(div)).toBeNull()
+ expect(ScrollSpy.getOrCreateInstance(div)).toBeInstanceOf(ScrollSpy)
+ })
+
+ it('should return new instance when there is no scrollspy instance with given configuration', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const div = fixtureEl.querySelector('.content')
+
+ expect(ScrollSpy.getInstance(div)).toBeNull()
+ const scrollspy = ScrollSpy.getOrCreateInstance(div, {
+ offset: 1
+ })
+ expect(scrollspy).toBeInstanceOf(ScrollSpy)
+
+ expect(scrollspy._config.offset).toEqual(1)
+ })
+
+ it('should return the instance when exists without given configuration', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const div = fixtureEl.querySelector('.content')
+ const scrollspy = new ScrollSpy(div, {
+ offset: 1
+ })
+ expect(ScrollSpy.getInstance(div)).toEqual(scrollspy)
+
+ const scrollspy2 = ScrollSpy.getOrCreateInstance(div, {
+ offset: 2
+ })
+ expect(scrollspy).toBeInstanceOf(ScrollSpy)
+ expect(scrollspy2).toEqual(scrollspy)
+
+ expect(scrollspy2._config.offset).toEqual(1)
+ })
+ })
+
+ describe('event handler', () => {
+ it('should create scrollspy on window load event', () => {
+ fixtureEl.innerHTML = [
+ '<div id="nav"></div>' +
+ '<div id="wrapper" data-bs-spy="scroll" data-bs-target="#nav" style="overflow-y: auto"></div>'
+ ].join('')
+
+ const scrollSpyEl = fixtureEl.querySelector('#wrapper')
+
+ window.dispatchEvent(createEvent('load'))
+
+ expect(ScrollSpy.getInstance(scrollSpyEl)).not.toBeNull()
+ })
+ })
+
+ describe('SmoothScroll', () => {
+ it('should not enable smoothScroll', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+ const offSpy = spyOn(EventHandler, 'off').and.callThrough()
+ const onSpy = spyOn(EventHandler, 'on').and.callThrough()
+
+ const div = fixtureEl.querySelector('.content')
+ const target = fixtureEl.querySelector('#navBar')
+ // eslint-disable-next-line no-new
+ new ScrollSpy(div, {
+ offset: 1
+ })
+
+ expect(offSpy).not.toHaveBeenCalledWith(target, 'click.bs.scrollspy')
+ expect(onSpy).not.toHaveBeenCalledWith(target, 'click.bs.scrollspy')
+ })
+
+ it('should enable smoothScroll', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+ const offSpy = spyOn(EventHandler, 'off').and.callThrough()
+ const onSpy = spyOn(EventHandler, 'on').and.callThrough()
+
+ const div = fixtureEl.querySelector('.content')
+ const target = fixtureEl.querySelector('#navBar')
+ // eslint-disable-next-line no-new
+ new ScrollSpy(div, {
+ offset: 1,
+ smoothScroll: true
+ })
+
+ expect(offSpy).toHaveBeenCalledWith(target, 'click.bs.scrollspy')
+ expect(onSpy).toHaveBeenCalledWith(target, 'click.bs.scrollspy', '[href]', jasmine.any(Function))
+ })
+
+ it('should not smoothScroll to element if it not handles a scrollspy section', () => {
+ fixtureEl.innerHTML = [
+ '<nav id="navBar" class="navbar">',
+ ' <ul class="nav">',
+ ' <a id="anchor-1" href="#div-jsm-1">div 1</a></li>',
+ ' <a id="anchor-2" href="#foo">div 2</a></li>',
+ ' </ul>',
+ '</nav>',
+ '<div class="content" data-bs-target="#navBar" style="overflow-y: auto">',
+ ' <div id="div-jsm-1">div 1</div>',
+ '</div>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('.content')
+ // eslint-disable-next-line no-new
+ new ScrollSpy(div, {
+ offset: 1,
+ smoothScroll: true
+ })
+
+ const clickSpy = getElementScrollSpy(div)
+
+ fixtureEl.querySelector('#anchor-2').click()
+ expect(clickSpy).not.toHaveBeenCalled()
+ })
+
+ it('should call `scrollTop` if element doesn\'t not support `scrollTo`', () => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const div = fixtureEl.querySelector('.content')
+ const link = fixtureEl.querySelector('[href="#div-jsm-1"]')
+ delete div.scrollTo
+ const clickSpy = getElementScrollSpy(div)
+ // eslint-disable-next-line no-new
+ new ScrollSpy(div, {
+ offset: 1,
+ smoothScroll: true
+ })
+
+ link.click()
+ expect(clickSpy).toHaveBeenCalled()
+ })
+
+ it('should smoothScroll to the proper observable element on anchor click', done => {
+ fixtureEl.innerHTML = getDummyFixture()
+
+ const div = fixtureEl.querySelector('.content')
+ const link = fixtureEl.querySelector('[href="#div-jsm-1"]')
+ const observable = fixtureEl.querySelector('#div-jsm-1')
+ const clickSpy = getElementScrollSpy(div)
+ // eslint-disable-next-line no-new
+ new ScrollSpy(div, {
+ offset: 1,
+ smoothScroll: true
+ })
+
+ setTimeout(() => {
+ if (div.scrollTo) {
+ expect(clickSpy).toHaveBeenCalledWith({ top: observable.offsetTop - div.offsetTop, behavior: 'smooth' })
+ } else {
+ expect(clickSpy).toHaveBeenCalledWith(observable.offsetTop - div.offsetTop)
+ }
+
+ done()
+ }, 100)
+ link.click()
+ })
+ })
+})
diff --git a/js/tests/unit/tab.spec.js b/js/tests/unit/tab.spec.js
new file mode 100644
index 0000000..e0c7d86
--- /dev/null
+++ b/js/tests/unit/tab.spec.js
@@ -0,0 +1,1101 @@
+import Tab from '../../src/tab'
+import { clearFixture, createEvent, getFixture, jQueryMock } from '../helpers/fixture'
+
+describe('Tab', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Tab.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('constructor', () => {
+ it('should take care of element either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav">',
+ ' <li><a href="#home" role="tab">Home</a></li>',
+ '</ul>',
+ '<ul>',
+ ' <li id="home"></li>',
+ '</ul>'
+ ].join('')
+
+ const tabEl = fixtureEl.querySelector('[href="#home"]')
+ const tabBySelector = new Tab('[href="#home"]')
+ const tabByElement = new Tab(tabEl)
+
+ expect(tabBySelector._element).toEqual(tabEl)
+ expect(tabByElement._element).toEqual(tabEl)
+ })
+
+ it('Do not Throw exception if not parent', () => {
+ fixtureEl.innerHTML = [
+ fixtureEl.innerHTML = '<div class=""><div class="nav-link"></div></div>'
+ ].join('')
+ const navEl = fixtureEl.querySelector('.nav-link')
+
+ expect(() => {
+ new Tab(navEl) // eslint-disable-line no-new
+ }).not.toThrowError(TypeError)
+ })
+ })
+
+ describe('show', () => {
+ it('should activate element by tab id (using buttons, the preferred semantic way)', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav" role="tablist">',
+ ' <li><button type="button" data-bs-target="#home" role="tab">Home</button></li>',
+ ' <li><button type="button" id="triggerProfile" data-bs-target="#profile" role="tab">Profile</button></li>',
+ '</ul>',
+ '<ul>',
+ ' <li id="home" role="tabpanel"></li>',
+ ' <li id="profile" role="tabpanel"></li>',
+ '</ul>'
+ ].join('')
+
+ const profileTriggerEl = fixtureEl.querySelector('#triggerProfile')
+ const tab = new Tab(profileTriggerEl)
+
+ profileTriggerEl.addEventListener('shown.bs.tab', () => {
+ expect(fixtureEl.querySelector('#profile')).toHaveClass('active')
+ expect(profileTriggerEl.getAttribute('aria-selected')).toEqual('true')
+ resolve()
+ })
+
+ tab.show()
+ })
+ })
+
+ it('should activate element by tab id (using links for tabs - not ideal, but still supported)', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav" role="tablist">',
+ ' <li><a href="#home" role="tab">Home</a></li>',
+ ' <li><a id="triggerProfile" href="#profile" role="tab">Profile</a></li>',
+ '</ul>',
+ '<ul>',
+ ' <li id="home" role="tabpanel"></li>',
+ ' <li id="profile" role="tabpanel"></li>',
+ '</ul>'
+ ].join('')
+
+ const profileTriggerEl = fixtureEl.querySelector('#triggerProfile')
+ const tab = new Tab(profileTriggerEl)
+
+ profileTriggerEl.addEventListener('shown.bs.tab', () => {
+ expect(fixtureEl.querySelector('#profile')).toHaveClass('active')
+ expect(profileTriggerEl.getAttribute('aria-selected')).toEqual('true')
+ resolve()
+ })
+
+ tab.show()
+ })
+ })
+
+ it('should activate element by tab id in ordered list', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<ol class="nav nav-pills">',
+ ' <li><button type="button" data-bs-target="#home" role="tab">Home</button></li>',
+ ' <li><button type="button" id="triggerProfile" href="#profile" role="tab">Profile</button></li>',
+ '</ol>',
+ '<ol>',
+ ' <li id="home" role="tabpanel"></li>',
+ ' <li id="profile" role="tabpanel"></li>',
+ '</ol>'
+ ].join('')
+
+ const profileTriggerEl = fixtureEl.querySelector('#triggerProfile')
+ const tab = new Tab(profileTriggerEl)
+
+ profileTriggerEl.addEventListener('shown.bs.tab', () => {
+ expect(fixtureEl.querySelector('#profile')).toHaveClass('active')
+ resolve()
+ })
+
+ tab.show()
+ })
+ })
+
+ it('should activate element by tab id in nav list', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<nav class="nav">',
+ ' <button type="button" data-bs-target="#home" role="tab">Home</button>',
+ ' <button type="button" id="triggerProfile" data-bs-target="#profile" role="tab">Profile</button>',
+ '</nav>',
+ '<div>',
+ ' <div id="home" role="tabpanel"></div>',
+ ' <div id="profile" role="tabpanel"></div>',
+ '</div>'
+ ].join('')
+
+ const profileTriggerEl = fixtureEl.querySelector('#triggerProfile')
+ const tab = new Tab(profileTriggerEl)
+
+ profileTriggerEl.addEventListener('shown.bs.tab', () => {
+ expect(fixtureEl.querySelector('#profile')).toHaveClass('active')
+ resolve()
+ })
+
+ tab.show()
+ })
+ })
+
+ it('should activate element by tab id in list group', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="list-group" role="tablist">',
+ ' <button type="button" data-bs-target="#home" role="tab">Home</button>',
+ ' <button type="button" id="triggerProfile" data-bs-target="#profile" role="tab">Profile</button>',
+ '</div>',
+ '<div>',
+ ' <div id="home" role="tabpanel"></div>',
+ ' <div id="profile" role="tabpanel"></div>',
+ '</div>'
+ ].join('')
+
+ const profileTriggerEl = fixtureEl.querySelector('#triggerProfile')
+ const tab = new Tab(profileTriggerEl)
+
+ profileTriggerEl.addEventListener('shown.bs.tab', () => {
+ expect(fixtureEl.querySelector('#profile')).toHaveClass('active')
+ resolve()
+ })
+
+ tab.show()
+ })
+ })
+
+ it('should not fire shown when show is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
+
+ const navEl = fixtureEl.querySelector('.nav > div')
+ const tab = new Tab(navEl)
+ const expectDone = () => {
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 30)
+ }
+
+ navEl.addEventListener('show.bs.tab', ev => {
+ ev.preventDefault()
+ expectDone()
+ })
+
+ navEl.addEventListener('shown.bs.tab', () => {
+ reject(new Error('should not trigger shown event'))
+ })
+
+ tab.show()
+ })
+ })
+
+ it('should not fire shown when tab is already active', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav nav-tabs" role="tablist">',
+ ' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#home" class="nav-link active" role="tab" aria-selected="true">Home</button></li>',
+ ' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#profile" class="nav-link" role="tab">Profile</button></li>',
+ '</ul>',
+ '<div class="tab-content">',
+ ' <div class="tab-pane active" id="home" role="tabpanel"></div>',
+ ' <div class="tab-pane" id="profile" role="tabpanel"></div>',
+ '</div>'
+ ].join('')
+
+ const triggerActive = fixtureEl.querySelector('button.active')
+ const tab = new Tab(triggerActive)
+
+ triggerActive.addEventListener('shown.bs.tab', () => {
+ reject(new Error('should not trigger shown event'))
+ })
+
+ tab.show()
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 30)
+ })
+ })
+
+ it('show and shown events should reference correct relatedTarget', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav nav-tabs" role="tablist">',
+ ' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#home" class="nav-link active" role="tab" aria-selected="true">Home</button></li>',
+ ' <li class="nav-item" role="presentation"><button type="button" id="triggerProfile" data-bs-target="#profile" class="nav-link" role="tab">Profile</button></li>',
+ '</ul>',
+ '<div class="tab-content">',
+ ' <div class="tab-pane active" id="home" role="tabpanel"></div>',
+ ' <div class="tab-pane" id="profile" role="tabpanel"></div>',
+ '</div>'
+ ].join('')
+
+ const secondTabTrigger = fixtureEl.querySelector('#triggerProfile')
+ const secondTab = new Tab(secondTabTrigger)
+
+ secondTabTrigger.addEventListener('show.bs.tab', ev => {
+ expect(ev.relatedTarget.getAttribute('data-bs-target')).toEqual('#home')
+ })
+
+ secondTabTrigger.addEventListener('shown.bs.tab', ev => {
+ expect(ev.relatedTarget.getAttribute('data-bs-target')).toEqual('#home')
+ expect(secondTabTrigger.getAttribute('aria-selected')).toEqual('true')
+ expect(fixtureEl.querySelector('button:not(.active)').getAttribute('aria-selected')).toEqual('false')
+ resolve()
+ })
+
+ secondTab.show()
+ })
+ })
+
+ it('should fire hide and hidden events', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav" role="tablist">',
+ ' <li><button type="button" data-bs-target="#home" role="tab">Home</button></li>',
+ ' <li><button type="button" data-bs-target="#profile" role="tab">Profile</button></li>',
+ '</ul>'
+ ].join('')
+
+ const triggerList = fixtureEl.querySelectorAll('button')
+ const firstTab = new Tab(triggerList[0])
+ const secondTab = new Tab(triggerList[1])
+
+ let hideCalled = false
+ triggerList[0].addEventListener('shown.bs.tab', () => {
+ secondTab.show()
+ })
+
+ triggerList[0].addEventListener('hide.bs.tab', ev => {
+ hideCalled = true
+ expect(ev.relatedTarget.getAttribute('data-bs-target')).toEqual('#profile')
+ })
+
+ triggerList[0].addEventListener('hidden.bs.tab', ev => {
+ expect(hideCalled).toBeTrue()
+ expect(ev.relatedTarget.getAttribute('data-bs-target')).toEqual('#profile')
+ resolve()
+ })
+
+ firstTab.show()
+ })
+ })
+
+ it('should not fire hidden when hide is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav" role="tablist">',
+ ' <li><button type="button" data-bs-target="#home" role="tab">Home</button></li>',
+ ' <li><button type="button" data-bs-target="#profile" role="tab">Profile</button></li>',
+ '</ul>'
+ ].join('')
+
+ const triggerList = fixtureEl.querySelectorAll('button')
+ const firstTab = new Tab(triggerList[0])
+ const secondTab = new Tab(triggerList[1])
+ const expectDone = () => {
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 30)
+ }
+
+ triggerList[0].addEventListener('shown.bs.tab', () => {
+ secondTab.show()
+ })
+
+ triggerList[0].addEventListener('hide.bs.tab', ev => {
+ ev.preventDefault()
+ expectDone()
+ })
+
+ triggerList[0].addEventListener('hidden.bs.tab', () => {
+ reject(new Error('should not trigger hidden'))
+ })
+
+ firstTab.show()
+ })
+ })
+
+ it('should handle removed tabs', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav nav-tabs" role="tablist">',
+ ' <li class="nav-item" role="presentation">',
+ ' <a class="nav-link nav-tab" href="#profile" role="tab" data-bs-toggle="tab">',
+ ' <button class="btn-close" aria-label="Close"></button>',
+ ' </a>',
+ ' </li>',
+ ' <li class="nav-item" role="presentation">',
+ ' <a id="secondNav" class="nav-link nav-tab" href="#buzz" role="tab" data-bs-toggle="tab">',
+ ' <button class="btn-close" aria-label="Close"></button>',
+ ' </a>',
+ ' </li>',
+ ' <li class="nav-item" role="presentation">',
+ ' <a class="nav-link nav-tab" href="#references" role="tab" data-bs-toggle="tab">',
+ ' <button id="btnClose" class="btn-close" aria-label="Close"></button>',
+ ' </a>',
+ ' </li>',
+ '</ul>',
+ '<div class="tab-content">',
+ ' <div role="tabpanel" class="tab-pane fade show active" id="profile">test 1</div>',
+ ' <div role="tabpanel" class="tab-pane fade" id="buzz">test 2</div>',
+ ' <div role="tabpanel" class="tab-pane fade" id="references">test 3</div>',
+ '</div>'
+ ].join('')
+
+ const secondNavEl = fixtureEl.querySelector('#secondNav')
+ const btnCloseEl = fixtureEl.querySelector('#btnClose')
+ const secondNavTab = new Tab(secondNavEl)
+
+ secondNavEl.addEventListener('shown.bs.tab', () => {
+ expect(fixtureEl.querySelectorAll('.nav-tab')).toHaveSize(2)
+ resolve()
+ })
+
+ btnCloseEl.addEventListener('click', () => {
+ const linkEl = btnCloseEl.parentNode
+ const liEl = linkEl.parentNode
+ const tabId = linkEl.getAttribute('href')
+ const tabIdEl = fixtureEl.querySelector(tabId)
+
+ liEl.remove()
+ tabIdEl.remove()
+ secondNavTab.show()
+ })
+
+ btnCloseEl.click()
+ })
+ })
+
+ it('should not focus on opened tab', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav" role="tablist">',
+ ' <li><button type="button" id="home" data-bs-target="#home" role="tab">Home</button></li>',
+ ' <li><button type="button" id="triggerProfile" data-bs-target="#profile" role="tab">Profile</button></li>',
+ '</ul>',
+ '<ul>',
+ ' <li id="home" role="tabpanel"></li>',
+ ' <li id="profile" role="tabpanel"></li>',
+ '</ul>'
+ ].join('')
+
+ const firstTab = fixtureEl.querySelector('#home')
+ firstTab.focus()
+
+ const profileTriggerEl = fixtureEl.querySelector('#triggerProfile')
+ const tab = new Tab(profileTriggerEl)
+
+ profileTriggerEl.addEventListener('shown.bs.tab', () => {
+ expect(document.activeElement).toBe(firstTab)
+ expect(document.activeElement).not.toBe(profileTriggerEl)
+ resolve()
+ })
+
+ tab.show()
+ })
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose a tab', () => {
+ fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
+
+ const el = fixtureEl.querySelector('.nav > div')
+ const tab = new Tab(fixtureEl.querySelector('.nav > div'))
+
+ expect(Tab.getInstance(el)).not.toBeNull()
+
+ tab.dispose()
+
+ expect(Tab.getInstance(el)).toBeNull()
+ })
+ })
+
+ describe('_activate', () => {
+ it('should not be called if element argument is null', () => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav" role="tablist">',
+ ' <li class="nav-link"></li>',
+ '</ul>'
+ ].join('')
+
+ const tabEl = fixtureEl.querySelector('.nav-link')
+ const tab = new Tab(tabEl)
+ const spy = jasmine.createSpy('spy')
+
+ const spyQueue = spyOn(tab, '_queueCallback')
+ tab._activate(null, spy)
+ expect(spyQueue).not.toHaveBeenCalled()
+ expect(spy).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('_setInitialAttributes', () => {
+ it('should put aria attributes', () => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav">',
+ ' <li class="nav-link" id="foo" data-bs-target="#panel"></li>',
+ ' <li class="nav-link" data-bs-target="#panel2"></li>',
+ '</ul>',
+ '<div id="panel"></div>',
+ '<div id="panel2"></div>'
+ ].join('')
+
+ const tabEl = fixtureEl.querySelector('.nav-link')
+ const parent = fixtureEl.querySelector('.nav')
+ const children = fixtureEl.querySelectorAll('.nav-link')
+ const tabPanel = fixtureEl.querySelector('#panel')
+ const tabPanel2 = fixtureEl.querySelector('#panel2')
+
+ expect(parent.getAttribute('role')).toEqual(null)
+ expect(tabEl.getAttribute('role')).toEqual(null)
+ expect(tabPanel.getAttribute('role')).toEqual(null)
+ const tab = new Tab(tabEl)
+ tab._setInitialAttributes(parent, children)
+
+ expect(parent.getAttribute('role')).toEqual('tablist')
+ expect(tabEl.getAttribute('role')).toEqual('tab')
+
+ expect(tabPanel.getAttribute('role')).toEqual('tabpanel')
+ expect(tabPanel2.getAttribute('role')).toEqual('tabpanel')
+ expect(tabPanel.hasAttribute('tabindex')).toBeFalse()
+ expect(tabPanel.hasAttribute('tabindex2')).toBeFalse()
+
+ expect(tabPanel.getAttribute('aria-labelledby')).toEqual('#foo')
+ expect(tabPanel2.hasAttribute('aria-labelledby')).toBeFalse()
+ })
+ })
+
+ describe('_keydown', () => {
+ it('if event is not one of left/right/up/down arrow, ignore it', () => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav">',
+ ' <li class="nav-link" data-bs-toggle="tab"></li>',
+ '</ul>'
+ ].join('')
+
+ const tabEl = fixtureEl.querySelector('.nav-link')
+ const tab = new Tab(tabEl)
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'Enter'
+ const spyStop = spyOn(Event.prototype, 'stopPropagation').and.callThrough()
+ const spyPrevent = spyOn(Event.prototype, 'preventDefault').and.callThrough()
+ const spyKeydown = spyOn(tab, '_keydown')
+ const spyGet = spyOn(tab, '_getChildren')
+
+ tabEl.dispatchEvent(keydown)
+ expect(spyKeydown).toHaveBeenCalled()
+ expect(spyGet).not.toHaveBeenCalled()
+
+ expect(spyStop).not.toHaveBeenCalled()
+ expect(spyPrevent).not.toHaveBeenCalled()
+ })
+
+ it('if keydown event is right/down arrow, handle it', () => {
+ fixtureEl.innerHTML = [
+ '<div class="nav">',
+ ' <span id="tab1" class="nav-link" data-bs-toggle="tab"></span>',
+ ' <span id="tab2" class="nav-link" data-bs-toggle="tab"></span>',
+ ' <span id="tab3" class="nav-link" data-bs-toggle="tab"></span>',
+ '</div>'
+ ].join('')
+
+ const tabEl1 = fixtureEl.querySelector('#tab1')
+ const tabEl2 = fixtureEl.querySelector('#tab2')
+ const tabEl3 = fixtureEl.querySelector('#tab3')
+ const tab1 = new Tab(tabEl1)
+ const tab2 = new Tab(tabEl2)
+ const tab3 = new Tab(tabEl3)
+ const spyShow1 = spyOn(tab1, 'show').and.callThrough()
+ const spyShow2 = spyOn(tab2, 'show').and.callThrough()
+ const spyShow3 = spyOn(tab3, 'show').and.callThrough()
+ const spyFocus1 = spyOn(tabEl1, 'focus').and.callThrough()
+ const spyFocus2 = spyOn(tabEl2, 'focus').and.callThrough()
+ const spyFocus3 = spyOn(tabEl3, 'focus').and.callThrough()
+
+ const spyStop = spyOn(Event.prototype, 'stopPropagation').and.callThrough()
+ const spyPrevent = spyOn(Event.prototype, 'preventDefault').and.callThrough()
+
+ let keydown = createEvent('keydown')
+ keydown.key = 'ArrowRight'
+
+ tabEl1.dispatchEvent(keydown)
+ expect(spyShow2).toHaveBeenCalled()
+ expect(spyFocus2).toHaveBeenCalled()
+
+ keydown = createEvent('keydown')
+ keydown.key = 'ArrowDown'
+
+ tabEl2.dispatchEvent(keydown)
+ expect(spyShow3).toHaveBeenCalled()
+ expect(spyFocus3).toHaveBeenCalled()
+
+ tabEl3.dispatchEvent(keydown)
+ expect(spyShow1).toHaveBeenCalled()
+ expect(spyFocus1).toHaveBeenCalled()
+
+ expect(spyStop).toHaveBeenCalledTimes(3)
+ expect(spyPrevent).toHaveBeenCalledTimes(3)
+ })
+
+ it('if keydown event is left arrow, handle it', () => {
+ fixtureEl.innerHTML = [
+ '<div class="nav">',
+ ' <span id="tab1" class="nav-link" data-bs-toggle="tab"></span>',
+ ' <span id="tab2" class="nav-link" data-bs-toggle="tab"></span>',
+ '</div>'
+ ].join('')
+
+ const tabEl1 = fixtureEl.querySelector('#tab1')
+ const tabEl2 = fixtureEl.querySelector('#tab2')
+ const tab1 = new Tab(tabEl1)
+ const tab2 = new Tab(tabEl2)
+ const spyShow1 = spyOn(tab1, 'show').and.callThrough()
+ const spyShow2 = spyOn(tab2, 'show').and.callThrough()
+ const spyFocus1 = spyOn(tabEl1, 'focus').and.callThrough()
+ const spyFocus2 = spyOn(tabEl2, 'focus').and.callThrough()
+
+ const spyStop = spyOn(Event.prototype, 'stopPropagation').and.callThrough()
+ const spyPrevent = spyOn(Event.prototype, 'preventDefault').and.callThrough()
+
+ let keydown = createEvent('keydown')
+ keydown.key = 'ArrowLeft'
+
+ tabEl2.dispatchEvent(keydown)
+ expect(spyShow1).toHaveBeenCalled()
+ expect(spyFocus1).toHaveBeenCalled()
+
+ keydown = createEvent('keydown')
+ keydown.key = 'ArrowUp'
+
+ tabEl1.dispatchEvent(keydown)
+ expect(spyShow2).toHaveBeenCalled()
+ expect(spyFocus2).toHaveBeenCalled()
+
+ expect(spyStop).toHaveBeenCalledTimes(2)
+ expect(spyPrevent).toHaveBeenCalledTimes(2)
+ })
+
+ it('if keydown event is right arrow and next element is disabled', () => {
+ fixtureEl.innerHTML = [
+ '<div class="nav">',
+ ' <span id="tab1" class="nav-link" data-bs-toggle="tab"></span>',
+ ' <span id="tab2" class="nav-link" data-bs-toggle="tab" disabled></span>',
+ ' <span id="tab3" class="nav-link disabled" data-bs-toggle="tab"></span>',
+ ' <span id="tab4" class="nav-link" data-bs-toggle="tab"></span>',
+ '</div>'
+ ].join('')
+
+ const tabEl = fixtureEl.querySelector('#tab1')
+ const tabEl2 = fixtureEl.querySelector('#tab2')
+ const tabEl3 = fixtureEl.querySelector('#tab3')
+ const tabEl4 = fixtureEl.querySelector('#tab4')
+ const tab = new Tab(tabEl)
+ const tab2 = new Tab(tabEl2)
+ const tab3 = new Tab(tabEl3)
+ const tab4 = new Tab(tabEl4)
+ const spy1 = spyOn(tab, 'show').and.callThrough()
+ const spy2 = spyOn(tab2, 'show').and.callThrough()
+ const spy3 = spyOn(tab3, 'show').and.callThrough()
+ const spy4 = spyOn(tab4, 'show').and.callThrough()
+ const spyFocus1 = spyOn(tabEl, 'focus').and.callThrough()
+ const spyFocus2 = spyOn(tabEl2, 'focus').and.callThrough()
+ const spyFocus3 = spyOn(tabEl3, 'focus').and.callThrough()
+ const spyFocus4 = spyOn(tabEl4, 'focus').and.callThrough()
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'ArrowRight'
+
+ tabEl.dispatchEvent(keydown)
+ expect(spy1).not.toHaveBeenCalled()
+ expect(spy2).not.toHaveBeenCalled()
+ expect(spy3).not.toHaveBeenCalled()
+ expect(spy4).toHaveBeenCalledTimes(1)
+ expect(spyFocus1).not.toHaveBeenCalled()
+ expect(spyFocus2).not.toHaveBeenCalled()
+ expect(spyFocus3).not.toHaveBeenCalled()
+ expect(spyFocus4).toHaveBeenCalledTimes(1)
+ })
+
+ it('if keydown event is left arrow and next element is disabled', () => {
+ fixtureEl.innerHTML = [
+ '<div class="nav">',
+ ' <span id="tab1" class="nav-link" data-bs-toggle="tab"></span>',
+ ' <span id="tab2" class="nav-link" data-bs-toggle="tab" disabled></span>',
+ ' <span id="tab3" class="nav-link disabled" data-bs-toggle="tab"></span>',
+ ' <span id="tab4" class="nav-link" data-bs-toggle="tab"></span>',
+ '</div>'
+ ].join('')
+
+ const tabEl = fixtureEl.querySelector('#tab1')
+ const tabEl2 = fixtureEl.querySelector('#tab2')
+ const tabEl3 = fixtureEl.querySelector('#tab3')
+ const tabEl4 = fixtureEl.querySelector('#tab4')
+ const tab = new Tab(tabEl)
+ const tab2 = new Tab(tabEl2)
+ const tab3 = new Tab(tabEl3)
+ const tab4 = new Tab(tabEl4)
+ const spy1 = spyOn(tab, 'show').and.callThrough()
+ const spy2 = spyOn(tab2, 'show').and.callThrough()
+ const spy3 = spyOn(tab3, 'show').and.callThrough()
+ const spy4 = spyOn(tab4, 'show').and.callThrough()
+ const spyFocus1 = spyOn(tabEl, 'focus').and.callThrough()
+ const spyFocus2 = spyOn(tabEl2, 'focus').and.callThrough()
+ const spyFocus3 = spyOn(tabEl3, 'focus').and.callThrough()
+ const spyFocus4 = spyOn(tabEl4, 'focus').and.callThrough()
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'ArrowLeft'
+
+ tabEl4.dispatchEvent(keydown)
+ expect(spy4).not.toHaveBeenCalled()
+ expect(spy3).not.toHaveBeenCalled()
+ expect(spy2).not.toHaveBeenCalled()
+ expect(spy1).toHaveBeenCalledTimes(1)
+ expect(spyFocus4).not.toHaveBeenCalled()
+ expect(spyFocus3).not.toHaveBeenCalled()
+ expect(spyFocus2).not.toHaveBeenCalled()
+ expect(spyFocus1).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a tab', () => {
+ fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
+
+ const div = fixtureEl.querySelector('.nav > div')
+
+ jQueryMock.fn.tab = Tab.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.tab.call(jQueryMock)
+
+ expect(Tab.getInstance(div)).not.toBeNull()
+ })
+
+ it('should not re create a tab', () => {
+ fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
+
+ const div = fixtureEl.querySelector('.nav > div')
+ const tab = new Tab(div)
+
+ jQueryMock.fn.tab = Tab.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.tab.call(jQueryMock)
+
+ expect(Tab.getInstance(div)).toEqual(tab)
+ })
+
+ it('should call a tab method', () => {
+ fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
+
+ const div = fixtureEl.querySelector('.nav > div')
+ const tab = new Tab(div)
+
+ const spy = spyOn(tab, 'show')
+
+ jQueryMock.fn.tab = Tab.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.tab.call(jQueryMock, 'show')
+
+ expect(Tab.getInstance(div)).toEqual(tab)
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
+
+ const div = fixtureEl.querySelector('.nav > div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.tab = Tab.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.tab.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return null if there is no instance', () => {
+ expect(Tab.getInstance(fixtureEl)).toBeNull()
+ })
+
+ it('should return this instance', () => {
+ fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
+
+ const divEl = fixtureEl.querySelector('.nav > div')
+ const tab = new Tab(divEl)
+
+ expect(Tab.getInstance(divEl)).toEqual(tab)
+ expect(Tab.getInstance(divEl)).toBeInstanceOf(Tab)
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return tab instance', () => {
+ fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const tab = new Tab(div)
+
+ expect(Tab.getOrCreateInstance(div)).toEqual(tab)
+ expect(Tab.getInstance(div)).toEqual(Tab.getOrCreateInstance(div, {}))
+ expect(Tab.getOrCreateInstance(div)).toBeInstanceOf(Tab)
+ })
+
+ it('should return new instance when there is no tab instance', () => {
+ fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Tab.getInstance(div)).toBeNull()
+ expect(Tab.getOrCreateInstance(div)).toBeInstanceOf(Tab)
+ })
+ })
+
+ describe('data-api', () => {
+ it('should create dynamically a tab', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav nav-tabs" role="tablist">',
+ ' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#home" class="nav-link active" role="tab" aria-selected="true">Home</button></li>',
+ ' <li class="nav-item" role="presentation"><button type="button" id="triggerProfile" data-bs-toggle="tab" data-bs-target="#profile" class="nav-link" role="tab">Profile</button></li>',
+ '</ul>',
+ '<div class="tab-content">',
+ ' <div class="tab-pane active" id="home" role="tabpanel"></div>',
+ ' <div class="tab-pane" id="profile" role="tabpanel"></div>',
+ '</div>'
+ ].join('')
+
+ const secondTabTrigger = fixtureEl.querySelector('#triggerProfile')
+
+ secondTabTrigger.addEventListener('shown.bs.tab', () => {
+ expect(secondTabTrigger).toHaveClass('active')
+ expect(fixtureEl.querySelector('#profile')).toHaveClass('active')
+ resolve()
+ })
+
+ secondTabTrigger.click()
+ })
+ })
+
+ it('selected tab should deactivate previous selected link in dropdown', () => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav nav-tabs">',
+ ' <li class="nav-item"><a class="nav-link" href="#home" data-bs-toggle="tab">Home</a></li>',
+ ' <li class="nav-item"><a class="nav-link" href="#profile" data-bs-toggle="tab">Profile</a></li>',
+ ' <li class="nav-item dropdown">',
+ ' <a class="nav-link dropdown-toggle active" data-bs-toggle="dropdown" href="#">Dropdown</a>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item active" href="#dropdown1" id="dropdown1-tab" data-bs-toggle="tab">@fat</a>',
+ ' <a class="dropdown-item" href="#dropdown2" id="dropdown2-tab" data-bs-toggle="tab">@mdo</a>',
+ ' </div>',
+ ' </li>',
+ '</ul>'
+ ].join('')
+
+ const firstLiLinkEl = fixtureEl.querySelector('li:first-child a')
+
+ firstLiLinkEl.click()
+ expect(firstLiLinkEl).toHaveClass('active')
+ expect(fixtureEl.querySelector('li:last-child a')).not.toHaveClass('active')
+ expect(fixtureEl.querySelector('li:last-child .dropdown-menu a:first-child')).not.toHaveClass('active')
+ })
+
+ it('selecting a dropdown tab does not activate another', () => {
+ const nav1 = [
+ '<ul class="nav nav-tabs" id="nav1">',
+ ' <li class="nav-item active"><a class="nav-link" href="#home" data-bs-toggle="tab">Home</a></li>',
+ ' <li class="nav-item dropdown">',
+ ' <a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#">Dropdown</a>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#dropdown1" id="dropdown1-tab" data-bs-toggle="tab">@fat</a>',
+ ' </div>',
+ ' </li>',
+ '</ul>'
+ ].join('')
+ const nav2 = [
+ '<ul class="nav nav-tabs" id="nav2">',
+ ' <li class="nav-item active"><a class="nav-link" href="#home" data-bs-toggle="tab">Home</a></li>',
+ ' <li class="nav-item dropdown">',
+ ' <a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#">Dropdown</a>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#dropdown1" id="dropdown1-tab" data-bs-toggle="tab">@fat</a>',
+ ' </div>',
+ ' </li>',
+ '</ul>'
+ ].join('')
+
+ fixtureEl.innerHTML = nav1 + nav2
+
+ const firstDropItem = fixtureEl.querySelector('#nav1 .dropdown-item')
+
+ firstDropItem.click()
+ expect(firstDropItem).toHaveClass('active')
+ expect(fixtureEl.querySelector('#nav1 .dropdown-toggle')).toHaveClass('active')
+ expect(fixtureEl.querySelector('#nav2 .dropdown-toggle')).not.toHaveClass('active')
+ expect(fixtureEl.querySelector('#nav2 .dropdown-item')).not.toHaveClass('active')
+ })
+
+ it('should support li > .dropdown-item', () => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav nav-tabs">',
+ ' <li class="nav-item"><a class="nav-link active" href="#home" data-bs-toggle="tab">Home</a></li>',
+ ' <li class="nav-item"><a class="nav-link" href="#profile" data-bs-toggle="tab">Profile</a></li>',
+ ' <li class="nav-item dropdown">',
+ ' <a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#">Dropdown</a>',
+ ' <ul class="dropdown-menu">',
+ ' <li><a class="dropdown-item" href="#dropdown1" id="dropdown1-tab" data-bs-toggle="tab">@fat</a></li>',
+ ' <li><a class="dropdown-item" href="#dropdown2" id="dropdown2-tab" data-bs-toggle="tab">@mdo</a></li>',
+ ' </ul>',
+ ' </li>',
+ '</ul>'
+ ].join('')
+
+ const dropItems = fixtureEl.querySelectorAll('.dropdown-item')
+
+ dropItems[1].click()
+ expect(dropItems[0]).not.toHaveClass('active')
+ expect(dropItems[1]).toHaveClass('active')
+ expect(fixtureEl.querySelector('.nav-link')).not.toHaveClass('active')
+ })
+
+ it('should handle nested tabs', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<nav class="nav nav-tabs" role="tablist">',
+ ' <button type="button" id="tab1" data-bs-target="#x-tab1" class="nav-link" data-bs-toggle="tab" role="tab" aria-controls="x-tab1">Tab 1</button>',
+ ' <button type="button" data-bs-target="#x-tab2" class="nav-link active" data-bs-toggle="tab" role="tab" aria-controls="x-tab2" aria-selected="true">Tab 2</button>',
+ ' <button type="button" data-bs-target="#x-tab3" class="nav-link" data-bs-toggle="tab" role="tab" aria-controls="x-tab3">Tab 3</button>',
+ '</nav>',
+ '<div class="tab-content">',
+ ' <div class="tab-pane" id="x-tab1" role="tabpanel">',
+ ' <nav class="nav nav-tabs" role="tablist">',
+ ' <button type="button" data-bs-target="#nested-tab1" class="nav-link active" data-bs-toggle="tab" role="tab" aria-controls="x-tab1" aria-selected="true">Nested Tab 1</button>',
+ ' <button type="button" id="tabNested2" data-bs-target="#nested-tab2" class="nav-link" data-bs-toggle="tab" role="tab" aria-controls="x-profile">Nested Tab2</button>',
+ ' </nav>',
+ ' <div class="tab-content">',
+ ' <div class="tab-pane active" id="nested-tab1" role="tabpanel">Nested Tab1 Content</div>',
+ ' <div class="tab-pane" id="nested-tab2" role="tabpanel">Nested Tab2 Content</div>',
+ ' </div>',
+ ' </div>',
+ ' <div class="tab-pane active" id="x-tab2" role="tabpanel">Tab2 Content</div>',
+ ' <div class="tab-pane" id="x-tab3" role="tabpanel">Tab3 Content</div>',
+ '</div>'
+ ].join('')
+
+ const tab1El = fixtureEl.querySelector('#tab1')
+ const tabNested2El = fixtureEl.querySelector('#tabNested2')
+ const xTab1El = fixtureEl.querySelector('#x-tab1')
+
+ tabNested2El.addEventListener('shown.bs.tab', () => {
+ expect(xTab1El).toHaveClass('active')
+ resolve()
+ })
+
+ tab1El.addEventListener('shown.bs.tab', () => {
+ expect(xTab1El).toHaveClass('active')
+ tabNested2El.click()
+ })
+
+ tab1El.click()
+ })
+ })
+
+ it('should not remove fade class if no active pane is present', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav nav-tabs" role="tablist">',
+ ' <li class="nav-item" role="presentation"><button type="button" id="tab-home" data-bs-target="#home" class="nav-link" data-bs-toggle="tab" role="tab">Home</button></li>',
+ ' <li class="nav-item" role="presentation"><button type="button" id="tab-profile" data-bs-target="#profile" class="nav-link" data-bs-toggle="tab" role="tab">Profile</button></li>',
+ '</ul>',
+ '<div class="tab-content">',
+ ' <div class="tab-pane fade" id="home" role="tabpanel"></div>',
+ ' <div class="tab-pane fade" id="profile" role="tabpanel"></div>',
+ '</div>'
+ ].join('')
+
+ const triggerTabProfileEl = fixtureEl.querySelector('#tab-profile')
+ const triggerTabHomeEl = fixtureEl.querySelector('#tab-home')
+ const tabProfileEl = fixtureEl.querySelector('#profile')
+ const tabHomeEl = fixtureEl.querySelector('#home')
+
+ triggerTabHomeEl.addEventListener('shown.bs.tab', () => {
+ setTimeout(() => {
+ expect(tabProfileEl).toHaveClass('fade')
+ expect(tabProfileEl).not.toHaveClass('show')
+
+ expect(tabHomeEl).toHaveClass('fade')
+ expect(tabHomeEl).toHaveClass('show')
+
+ resolve()
+ }, 10)
+ })
+
+ triggerTabProfileEl.addEventListener('shown.bs.tab', () => {
+ setTimeout(() => {
+ expect(tabProfileEl).toHaveClass('fade')
+ expect(tabProfileEl).toHaveClass('show')
+ triggerTabHomeEl.click()
+ }, 10)
+ })
+
+ triggerTabProfileEl.click()
+ })
+ })
+
+ it('should add `show` class to tab panes if there is no `.fade` class', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav nav-tabs" role="tablist">',
+ ' <li class="nav-item" role="presentation">',
+ ' <button type="button" class="nav-link nav-tab" data-bs-target="#home" role="tab" data-bs-toggle="tab">Home</button>',
+ ' </li>',
+ ' <li class="nav-item" role="presentation">',
+ ' <button type="button" id="secondNav" class="nav-link nav-tab" data-bs-target="#profile" role="tab" data-bs-toggle="tab">Profile</button>',
+ ' </li>',
+ '</ul>',
+ '<div class="tab-content">',
+ ' <div role="tabpanel" class="tab-pane" id="home">test 1</div>',
+ ' <div role="tabpanel" class="tab-pane" id="profile">test 2</div>',
+ '</div>'
+ ].join('')
+
+ const secondNavEl = fixtureEl.querySelector('#secondNav')
+
+ secondNavEl.addEventListener('shown.bs.tab', () => {
+ expect(fixtureEl.querySelectorAll('.tab-content .show')).toHaveSize(1)
+ resolve()
+ })
+
+ secondNavEl.click()
+ })
+ })
+
+ it('should add show class to tab panes if there is a `.fade` class', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav nav-tabs" role="tablist">',
+ ' <li class="nav-item" role="presentation">',
+ ' <button type="button" class="nav-link nav-tab" data-bs-target="#home" role="tab" data-bs-toggle="tab">Home</button>',
+ ' </li>',
+ ' <li class="nav-item" role="presentation">',
+ ' <button type="button" id="secondNav" class="nav-link nav-tab" data-bs-target="#profile" role="tab" data-bs-toggle="tab">Profile</button>',
+ ' </li>',
+ '</ul>',
+ '<div class="tab-content">',
+ ' <div role="tabpanel" class="tab-pane fade" id="home">test 1</div>',
+ ' <div role="tabpanel" class="tab-pane fade" id="profile">test 2</div>',
+ '</div>'
+ ].join('')
+
+ const secondNavEl = fixtureEl.querySelector('#secondNav')
+
+ secondNavEl.addEventListener('shown.bs.tab', () => {
+ setTimeout(() => {
+ expect(fixtureEl.querySelectorAll('.show')).toHaveSize(1)
+ resolve()
+ }, 10)
+ })
+
+ secondNavEl.click()
+ })
+ })
+
+ it('should prevent default when the trigger is <a> or <area>', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav" role="tablist">',
+ ' <li><a type="button" href="#test" class="active" role="tab" data-bs-toggle="tab">Home</a></li>',
+ ' <li><a type="button" href="#test2" role="tab" data-bs-toggle="tab">Home</a></li>',
+ '</ul>'
+ ].join('')
+
+ const tabEl = fixtureEl.querySelector('[href="#test2"]')
+ const spy = spyOn(Event.prototype, 'preventDefault').and.callThrough()
+
+ tabEl.addEventListener('shown.bs.tab', () => {
+ expect(tabEl).toHaveClass('active')
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ tabEl.click()
+ })
+ })
+
+ it('should not fire shown when tab has disabled attribute', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav nav-tabs" role="tablist">',
+ ' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#home" class="nav-link active" role="tab" aria-selected="true">Home</button></li>',
+ ' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#profile" class="nav-link" disabled role="tab">Profile</button></li>',
+ '</ul>',
+ '<div class="tab-content">',
+ ' <div class="tab-pane active" id="home" role="tabpanel"></div>',
+ ' <div class="tab-pane" id="profile" role="tabpanel"></div>',
+ '</div>'
+ ].join('')
+
+ const triggerDisabled = fixtureEl.querySelector('button[disabled]')
+ triggerDisabled.addEventListener('shown.bs.tab', () => {
+ reject(new Error('should not trigger shown event'))
+ })
+
+ triggerDisabled.click()
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 30)
+ })
+ })
+
+ it('should not fire shown when tab has disabled class', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav nav-tabs" role="tablist">',
+ ' <li class="nav-item" role="presentation"><a href="#home" class="nav-link active" role="tab" aria-selected="true">Home</a></li>',
+ ' <li class="nav-item" role="presentation"><a href="#profile" class="nav-link disabled" role="tab">Profile</a></li>',
+ '</ul>',
+ '<div class="tab-content">',
+ ' <div class="tab-pane active" id="home" role="tabpanel"></div>',
+ ' <div class="tab-pane" id="profile" role="tabpanel"></div>',
+ '</div>'
+ ].join('')
+
+ const triggerDisabled = fixtureEl.querySelector('a.disabled')
+
+ triggerDisabled.addEventListener('shown.bs.tab', () => {
+ reject(new Error('should not trigger shown event'))
+ })
+
+ triggerDisabled.click()
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 30)
+ })
+ })
+ })
+})
diff --git a/js/tests/unit/toast.spec.js b/js/tests/unit/toast.spec.js
new file mode 100644
index 0000000..42d2515
--- /dev/null
+++ b/js/tests/unit/toast.spec.js
@@ -0,0 +1,670 @@
+import Toast from '../../src/toast'
+import { clearFixture, createEvent, getFixture, jQueryMock } from '../helpers/fixture'
+
+describe('Toast', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Toast.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(Toast.DATA_KEY).toEqual('bs.toast')
+ })
+ })
+
+ describe('constructor', () => {
+ it('should take care of element either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = '<div class="toast"></div>'
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toastBySelector = new Toast('.toast')
+ const toastByElement = new Toast(toastEl)
+
+ expect(toastBySelector._element).toEqual(toastEl)
+ expect(toastByElement._element).toEqual(toastEl)
+ })
+
+ it('should allow to config in js', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="toast">',
+ ' <div class="toast-body">',
+ ' a simple toast',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('div')
+ const toast = new Toast(toastEl, {
+ delay: 1
+ })
+
+ toastEl.addEventListener('shown.bs.toast', () => {
+ expect(toastEl).toHaveClass('show')
+ resolve()
+ })
+
+ toast.show()
+ })
+ })
+
+ it('should close toast when close element with data-bs-dismiss attribute is set', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="toast" data-bs-delay="1" data-bs-autohide="false" data-bs-animation="false">',
+ ' <button type="button" class="ms-2 mb-1 btn-close" data-bs-dismiss="toast" aria-label="Close"></button>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('div')
+ const toast = new Toast(toastEl)
+
+ toastEl.addEventListener('shown.bs.toast', () => {
+ expect(toastEl).toHaveClass('show')
+
+ const button = toastEl.querySelector('.btn-close')
+
+ button.click()
+ })
+
+ toastEl.addEventListener('hidden.bs.toast', () => {
+ expect(toastEl).not.toHaveClass('show')
+ resolve()
+ })
+
+ toast.show()
+ })
+ })
+ })
+
+ describe('Default', () => {
+ it('should expose default setting to allow to override them', () => {
+ const defaultDelay = 1000
+
+ Toast.Default.delay = defaultDelay
+
+ fixtureEl.innerHTML = [
+ '<div class="toast" data-bs-autohide="false" data-bs-animation="false">',
+ ' <button type="button" class="ms-2 mb-1 btn-close" data-bs-dismiss="toast" aria-label="Close"></button>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('div')
+ const toast = new Toast(toastEl)
+
+ expect(toast._config.delay).toEqual(defaultDelay)
+ })
+ })
+
+ describe('DefaultType', () => {
+ it('should expose default setting types for read', () => {
+ expect(Toast.DefaultType).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('show', () => {
+ it('should auto hide', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="toast" data-bs-delay="1">',
+ ' <div class="toast-body">',
+ ' a simple toast',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ toastEl.addEventListener('hidden.bs.toast', () => {
+ expect(toastEl).not.toHaveClass('show')
+ resolve()
+ })
+
+ toast.show()
+ })
+ })
+
+ it('should not add fade class', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="toast" data-bs-delay="1" data-bs-animation="false">',
+ ' <div class="toast-body">',
+ ' a simple toast',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ toastEl.addEventListener('shown.bs.toast', () => {
+ expect(toastEl).not.toHaveClass('fade')
+ resolve()
+ })
+
+ toast.show()
+ })
+ })
+
+ it('should not trigger shown if show is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="toast" data-bs-delay="1" data-bs-animation="false">',
+ ' <div class="toast-body">',
+ ' a simple toast',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ const assertDone = () => {
+ setTimeout(() => {
+ expect(toastEl).not.toHaveClass('show')
+ resolve()
+ }, 20)
+ }
+
+ toastEl.addEventListener('show.bs.toast', event => {
+ event.preventDefault()
+ assertDone()
+ })
+
+ toastEl.addEventListener('shown.bs.toast', () => {
+ reject(new Error('shown event should not be triggered if show is prevented'))
+ })
+
+ toast.show()
+ })
+ })
+
+ it('should clear timeout if toast is shown again before it is hidden', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="toast">',
+ ' <div class="toast-body">',
+ ' a simple toast',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ setTimeout(() => {
+ toast._config.autohide = false
+ toastEl.addEventListener('shown.bs.toast', () => {
+ expect(spy).toHaveBeenCalled()
+ expect(toast._timeout).toBeNull()
+ resolve()
+ })
+ toast.show()
+ }, toast._config.delay / 2)
+
+ const spy = spyOn(toast, '_clearTimeout').and.callThrough()
+
+ toast.show()
+ })
+ })
+
+ it('should clear timeout if toast is interacted with mouse', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="toast">',
+ ' <div class="toast-body">',
+ ' a simple toast',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+ const spy = spyOn(toast, '_clearTimeout').and.callThrough()
+
+ setTimeout(() => {
+ spy.calls.reset()
+
+ toastEl.addEventListener('mouseover', () => {
+ expect(toast._clearTimeout).toHaveBeenCalledTimes(1)
+ expect(toast._timeout).toBeNull()
+ resolve()
+ })
+
+ const mouseOverEvent = createEvent('mouseover')
+ toastEl.dispatchEvent(mouseOverEvent)
+ }, toast._config.delay / 2)
+
+ toast.show()
+ })
+ })
+
+ it('should clear timeout if toast is interacted with keyboard', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<button id="outside-focusable">outside focusable</button>',
+ '<div class="toast">',
+ ' <div class="toast-body">',
+ ' a simple toast',
+ ' <button>with a button</button>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+ const spy = spyOn(toast, '_clearTimeout').and.callThrough()
+
+ setTimeout(() => {
+ spy.calls.reset()
+
+ toastEl.addEventListener('focusin', () => {
+ expect(toast._clearTimeout).toHaveBeenCalledTimes(1)
+ expect(toast._timeout).toBeNull()
+ resolve()
+ })
+
+ const insideFocusable = toastEl.querySelector('button')
+ insideFocusable.focus()
+ }, toast._config.delay / 2)
+
+ toast.show()
+ })
+ })
+
+ it('should still auto hide after being interacted with mouse and keyboard', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<button id="outside-focusable">outside focusable</button>',
+ '<div class="toast">',
+ ' <div class="toast-body">',
+ ' a simple toast',
+ ' <button>with a button</button>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ setTimeout(() => {
+ toastEl.addEventListener('mouseover', () => {
+ const insideFocusable = toastEl.querySelector('button')
+ insideFocusable.focus()
+ })
+
+ toastEl.addEventListener('focusin', () => {
+ const mouseOutEvent = createEvent('mouseout')
+ toastEl.dispatchEvent(mouseOutEvent)
+ })
+
+ toastEl.addEventListener('mouseout', () => {
+ const outsideFocusable = document.getElementById('outside-focusable')
+ outsideFocusable.focus()
+ })
+
+ toastEl.addEventListener('focusout', () => {
+ expect(toast._timeout).not.toBeNull()
+ resolve()
+ })
+
+ const mouseOverEvent = createEvent('mouseover')
+ toastEl.dispatchEvent(mouseOverEvent)
+ }, toast._config.delay / 2)
+
+ toast.show()
+ })
+ })
+
+ it('should not auto hide if focus leaves but mouse pointer remains inside', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<button id="outside-focusable">outside focusable</button>',
+ '<div class="toast">',
+ ' <div class="toast-body">',
+ ' a simple toast',
+ ' <button>with a button</button>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ setTimeout(() => {
+ toastEl.addEventListener('mouseover', () => {
+ const insideFocusable = toastEl.querySelector('button')
+ insideFocusable.focus()
+ })
+
+ toastEl.addEventListener('focusin', () => {
+ const outsideFocusable = document.getElementById('outside-focusable')
+ outsideFocusable.focus()
+ })
+
+ toastEl.addEventListener('focusout', () => {
+ expect(toast._timeout).toBeNull()
+ resolve()
+ })
+
+ const mouseOverEvent = createEvent('mouseover')
+ toastEl.dispatchEvent(mouseOverEvent)
+ }, toast._config.delay / 2)
+
+ toast.show()
+ })
+ })
+
+ it('should not auto hide if mouse pointer leaves but focus remains inside', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<button id="outside-focusable">outside focusable</button>',
+ '<div class="toast">',
+ ' <div class="toast-body">',
+ ' a simple toast',
+ ' <button>with a button</button>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ setTimeout(() => {
+ toastEl.addEventListener('mouseover', () => {
+ const insideFocusable = toastEl.querySelector('button')
+ insideFocusable.focus()
+ })
+
+ toastEl.addEventListener('focusin', () => {
+ const mouseOutEvent = createEvent('mouseout')
+ toastEl.dispatchEvent(mouseOutEvent)
+ })
+
+ toastEl.addEventListener('mouseout', () => {
+ expect(toast._timeout).toBeNull()
+ resolve()
+ })
+
+ const mouseOverEvent = createEvent('mouseover')
+ toastEl.dispatchEvent(mouseOverEvent)
+ }, toast._config.delay / 2)
+
+ toast.show()
+ })
+ })
+ })
+
+ describe('hide', () => {
+ it('should allow to hide toast manually', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="toast" data-bs-delay="1" data-bs-autohide="false">',
+ ' <div class="toast-body">',
+ ' a simple toast',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ toastEl.addEventListener('shown.bs.toast', () => {
+ toast.hide()
+ })
+
+ toastEl.addEventListener('hidden.bs.toast', () => {
+ expect(toastEl).not.toHaveClass('show')
+ resolve()
+ })
+
+ toast.show()
+ })
+ })
+
+ it('should do nothing when we call hide on a non shown toast', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const toastEl = fixtureEl.querySelector('div')
+ const toast = new Toast(toastEl)
+
+ const spy = spyOn(toastEl.classList, 'contains')
+
+ toast.hide()
+
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should not trigger hidden if hide is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="toast" data-bs-delay="1" data-bs-animation="false">',
+ ' <div class="toast-body">',
+ ' a simple toast',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ const assertDone = () => {
+ setTimeout(() => {
+ expect(toastEl).toHaveClass('show')
+ resolve()
+ }, 20)
+ }
+
+ toastEl.addEventListener('shown.bs.toast', () => {
+ toast.hide()
+ })
+
+ toastEl.addEventListener('hide.bs.toast', event => {
+ event.preventDefault()
+ assertDone()
+ })
+
+ toastEl.addEventListener('hidden.bs.toast', () => {
+ reject(new Error('hidden event should not be triggered if hide is prevented'))
+ })
+
+ toast.show()
+ })
+ })
+ })
+
+ describe('dispose', () => {
+ it('should allow to destroy toast', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const toastEl = fixtureEl.querySelector('div')
+
+ const toast = new Toast(toastEl)
+
+ expect(Toast.getInstance(toastEl)).not.toBeNull()
+
+ toast.dispose()
+
+ expect(Toast.getInstance(toastEl)).toBeNull()
+ })
+
+ it('should allow to destroy toast and hide it before that', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="toast" data-bs-delay="0" data-bs-autohide="false">',
+ ' <div class="toast-body">',
+ ' a simple toast',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('div')
+ const toast = new Toast(toastEl)
+ const expected = () => {
+ expect(toastEl).toHaveClass('show')
+ expect(Toast.getInstance(toastEl)).not.toBeNull()
+
+ toast.dispose()
+
+ expect(Toast.getInstance(toastEl)).toBeNull()
+ expect(toastEl).not.toHaveClass('show')
+
+ resolve()
+ }
+
+ toastEl.addEventListener('shown.bs.toast', () => {
+ setTimeout(expected, 1)
+ })
+
+ toast.show()
+ })
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a toast', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.toast = Toast.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.toast.call(jQueryMock)
+
+ expect(Toast.getInstance(div)).not.toBeNull()
+ })
+
+ it('should not re create a toast', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const toast = new Toast(div)
+
+ jQueryMock.fn.toast = Toast.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.toast.call(jQueryMock)
+
+ expect(Toast.getInstance(div)).toEqual(toast)
+ })
+
+ it('should call a toast method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const toast = new Toast(div)
+
+ const spy = spyOn(toast, 'show')
+
+ jQueryMock.fn.toast = Toast.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.toast.call(jQueryMock, 'show')
+
+ expect(Toast.getInstance(div)).toEqual(toast)
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.toast = Toast.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.toast.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return a toast instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const toast = new Toast(div)
+
+ expect(Toast.getInstance(div)).toEqual(toast)
+ expect(Toast.getInstance(div)).toBeInstanceOf(Toast)
+ })
+
+ it('should return null when there is no toast instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Toast.getInstance(div)).toBeNull()
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return toast instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const toast = new Toast(div)
+
+ expect(Toast.getOrCreateInstance(div)).toEqual(toast)
+ expect(Toast.getInstance(div)).toEqual(Toast.getOrCreateInstance(div, {}))
+ expect(Toast.getOrCreateInstance(div)).toBeInstanceOf(Toast)
+ })
+
+ it('should return new instance when there is no toast instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Toast.getInstance(div)).toBeNull()
+ expect(Toast.getOrCreateInstance(div)).toBeInstanceOf(Toast)
+ })
+
+ it('should return new instance when there is no toast instance with given configuration', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Toast.getInstance(div)).toBeNull()
+ const toast = Toast.getOrCreateInstance(div, {
+ delay: 1
+ })
+ expect(toast).toBeInstanceOf(Toast)
+
+ expect(toast._config.delay).toEqual(1)
+ })
+
+ it('should return the instance when exists without given configuration', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const toast = new Toast(div, {
+ delay: 1
+ })
+ expect(Toast.getInstance(div)).toEqual(toast)
+
+ const toast2 = Toast.getOrCreateInstance(div, {
+ delay: 2
+ })
+ expect(toast).toBeInstanceOf(Toast)
+ expect(toast2).toEqual(toast)
+
+ expect(toast2._config.delay).toEqual(1)
+ })
+ })
+})
diff --git a/js/tests/unit/tooltip.spec.js b/js/tests/unit/tooltip.spec.js
new file mode 100644
index 0000000..4330571
--- /dev/null
+++ b/js/tests/unit/tooltip.spec.js
@@ -0,0 +1,1551 @@
+import Tooltip from '../../src/tooltip'
+import EventHandler from '../../src/dom/event-handler'
+import { noop } from '../../src/util/index'
+import { clearFixture, createEvent, getFixture, jQueryMock } from '../helpers/fixture'
+
+describe('Tooltip', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+
+ for (const tooltipEl of document.querySelectorAll('.tooltip')) {
+ tooltipEl.remove()
+ }
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Tooltip.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin default config', () => {
+ expect(Tooltip.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('NAME', () => {
+ it('should return plugin name', () => {
+ expect(Tooltip.NAME).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(Tooltip.DATA_KEY).toEqual('bs.tooltip')
+ })
+ })
+
+ describe('EVENT_KEY', () => {
+ it('should return plugin event key', () => {
+ expect(Tooltip.EVENT_KEY).toEqual('.bs.tooltip')
+ })
+ })
+
+ describe('DefaultType', () => {
+ it('should return plugin default type', () => {
+ expect(Tooltip.DefaultType).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('constructor', () => {
+ it('should take care of element either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = '<a href="#" id="tooltipEl" rel="tooltip" title="Nice and short title">'
+
+ const tooltipEl = fixtureEl.querySelector('#tooltipEl')
+ const tooltipBySelector = new Tooltip('#tooltipEl')
+ const tooltipByElement = new Tooltip(tooltipEl)
+
+ expect(tooltipBySelector._element).toEqual(tooltipEl)
+ expect(tooltipByElement._element).toEqual(tooltipEl)
+ })
+
+ it('should not take care of disallowed data attributes', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" data-bs-sanitize="false" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(tooltip._config.sanitize).toBeTrue()
+ })
+
+ it('should convert title and content to string if numbers', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ title: 1,
+ content: 7
+ })
+
+ expect(tooltip._config.title).toEqual('1')
+ expect(tooltip._config.content).toEqual('7')
+ })
+
+ it('should enable selector delegation', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const containerEl = fixtureEl.querySelector('div')
+ const tooltipContainer = new Tooltip(containerEl, {
+ selector: 'a[rel="tooltip"]',
+ trigger: 'click'
+ })
+
+ containerEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipInContainerEl = containerEl.querySelector('a')
+
+ tooltipInContainerEl.addEventListener('shown.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).not.toBeNull()
+ tooltipContainer.dispose()
+ resolve()
+ })
+
+ tooltipInContainerEl.click()
+ })
+ })
+
+ it('should create offset modifier when offset is passed as a function', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Offset from function">'
+
+ const getOffset = jasmine.createSpy('getOffset').and.returnValue([10, 20])
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ offset: getOffset,
+ popperConfig: {
+ onFirstUpdate(state) {
+ expect(getOffset).toHaveBeenCalledWith({
+ popper: state.rects.popper,
+ reference: state.rects.reference,
+ placement: state.placement
+ }, tooltipEl)
+ resolve()
+ }
+ }
+ })
+
+ const offset = tooltip._getOffset()
+
+ expect(offset).toEqual(jasmine.any(Function))
+
+ tooltip.show()
+ })
+ })
+
+ it('should create offset modifier when offset option is passed in data attribute', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" data-bs-offset="10,20" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(tooltip._getOffset()).toEqual([10, 20])
+ })
+
+ it('should allow to pass config to Popper with `popperConfig`', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ popperConfig: {
+ placement: 'left'
+ }
+ })
+
+ const popperConfig = tooltip._getPopperConfig('top')
+
+ expect(popperConfig.placement).toEqual('left')
+ })
+
+ it('should allow to pass config to Popper with `popperConfig` as a function', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const getPopperConfig = jasmine.createSpy('getPopperConfig').and.returnValue({ placement: 'left' })
+ const tooltip = new Tooltip(tooltipEl, {
+ popperConfig: getPopperConfig
+ })
+
+ const popperConfig = tooltip._getPopperConfig('top')
+
+ expect(getPopperConfig).toHaveBeenCalled()
+ expect(popperConfig.placement).toEqual('left')
+ })
+
+ it('should use original title, if not "data-bs-title" is given', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"></a>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(tooltip._getTitle()).toEqual('Another tooltip')
+ })
+ })
+
+ describe('enable', () => {
+ it('should enable a tooltip', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.enable()
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).not.toBeNull()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+ })
+
+ describe('disable', () => {
+ it('should disable tooltip', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.disable()
+
+ tooltipEl.addEventListener('show.bs.tooltip', () => {
+ reject(new Error('should not show a disabled tooltip'))
+ })
+
+ tooltip.show()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 10)
+ })
+ })
+ })
+
+ describe('toggleEnabled', () => {
+ it('should toggle enabled', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(tooltip._isEnabled).toBeTrue()
+
+ tooltip.toggleEnabled()
+
+ expect(tooltip._isEnabled).toBeFalse()
+ })
+ })
+
+ describe('toggle', () => {
+ it('should do nothing if disabled', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.disable()
+
+ tooltipEl.addEventListener('show.bs.tooltip', () => {
+ reject(new Error('should not show a disabled tooltip'))
+ })
+
+ tooltip.toggle()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should show a tooltip', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).not.toBeNull()
+ resolve()
+ })
+
+ tooltip.toggle()
+ })
+ })
+
+ it('should call toggle and show the tooltip when trigger is "click"', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ trigger: 'click'
+ })
+
+ const spy = spyOn(tooltip, 'toggle').and.callThrough()
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ tooltipEl.click()
+ })
+ })
+
+ it('should hide a tooltip', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ tooltip.toggle()
+ })
+
+ tooltipEl.addEventListener('hidden.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).toBeNull()
+ resolve()
+ })
+
+ tooltip.toggle()
+ })
+ })
+
+ it('should call toggle and hide the tooltip when trigger is "click"', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ trigger: 'click'
+ })
+
+ const spy = spyOn(tooltip, 'toggle').and.callThrough()
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ tooltipEl.click()
+ })
+
+ tooltipEl.addEventListener('hidden.bs.tooltip', () => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ tooltipEl.click()
+ })
+ })
+ })
+
+ describe('dispose', () => {
+ it('should destroy a tooltip', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const addEventSpy = spyOn(tooltipEl, 'addEventListener').and.callThrough()
+ const removeEventSpy = spyOn(tooltipEl, 'removeEventListener').and.callThrough()
+
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(Tooltip.getInstance(tooltipEl)).toEqual(tooltip)
+
+ const expectedArgs = [
+ ['mouseover', jasmine.any(Function), jasmine.any(Boolean)],
+ ['mouseout', jasmine.any(Function), jasmine.any(Boolean)],
+ ['focusin', jasmine.any(Function), jasmine.any(Boolean)],
+ ['focusout', jasmine.any(Function), jasmine.any(Boolean)]
+ ]
+
+ expect(addEventSpy.calls.allArgs()).toEqual(expectedArgs)
+
+ tooltip.dispose()
+
+ expect(Tooltip.getInstance(tooltipEl)).toBeNull()
+ expect(removeEventSpy.calls.allArgs()).toEqual(expectedArgs)
+ })
+
+ it('should destroy a tooltip after it is shown and hidden', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ tooltip.hide()
+ })
+ tooltipEl.addEventListener('hidden.bs.tooltip', () => {
+ tooltip.dispose()
+ expect(tooltip.tip).toBeNull()
+ expect(Tooltip.getInstance(tooltipEl)).toBeNull()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should destroy a tooltip and remove it from the dom', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).not.toBeNull()
+
+ tooltip.dispose()
+
+ expect(document.querySelector('.tooltip')).toBeNull()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should destroy a tooltip and reset it\'s initial title', () => {
+ fixtureEl.innerHTML = [
+ '<span id="tooltipWithTitle" rel="tooltip" title="tooltipTitle"></span>',
+ '<span id="tooltipWithoutTitle" rel="tooltip" data-bs-title="tooltipTitle"></span>'
+ ].join('')
+
+ const tooltipWithTitleEl = fixtureEl.querySelector('#tooltipWithTitle')
+ const tooltip = new Tooltip('#tooltipWithTitle')
+ expect(tooltipWithTitleEl.getAttribute('title')).toBeNull()
+ tooltip.dispose()
+ expect(tooltipWithTitleEl.getAttribute('title')).toBe('tooltipTitle')
+
+ const tooltipWithoutTitleEl = fixtureEl.querySelector('#tooltipWithoutTitle')
+ const tooltip2 = new Tooltip('#tooltipWithTitle')
+ expect(tooltipWithoutTitleEl.getAttribute('title')).toBeNull()
+ tooltip2.dispose()
+ expect(tooltipWithoutTitleEl.getAttribute('title')).toBeNull()
+ })
+ })
+
+ describe('show', () => {
+ it('should show a tooltip', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tooltipShown = document.querySelector('.tooltip')
+
+ expect(tooltipShown).not.toBeNull()
+ expect(tooltipEl.getAttribute('aria-describedby')).toEqual(tooltipShown.getAttribute('id'))
+ expect(tooltipShown.getAttribute('id')).toContain('tooltip')
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should show a tooltip when hovering a child element', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a href="#" rel="tooltip" title="Another tooltip">',
+ ' <svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 100 100">',
+ ' <rect width="100%" fill="#563d7c"/>',
+ ' <circle cx="50" cy="50" r="30" fill="#fff"/>',
+ ' </svg>',
+ '</a>'
+ ].join('')
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ const spy = spyOn(tooltip, 'show')
+
+ tooltipEl.querySelector('rect').dispatchEvent(createEvent('mouseover', { bubbles: true }))
+
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ }, 0)
+ })
+ })
+
+ it('should show a tooltip on mobile', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+ document.documentElement.ontouchstart = noop
+
+ const spy = spyOn(EventHandler, 'on').and.callThrough()
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).not.toBeNull()
+ expect(spy).toHaveBeenCalledWith(jasmine.any(Object), 'mouseover', noop)
+ document.documentElement.ontouchstart = undefined
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should show a tooltip relative to placement option', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ placement: 'bottom'
+ })
+
+ tooltipEl.addEventListener('inserted.bs.tooltip', () => {
+ expect(tooltip._getTipElement()).toHaveClass('bs-tooltip-auto')
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(tooltip._getTipElement()).toHaveClass('bs-tooltip-auto')
+ expect(tooltip._getTipElement().getAttribute('data-popper-placement')).toEqual('bottom')
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should not error when trying to show a tooltip that has been removed from the dom', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ const firstCallback = () => {
+ tooltipEl.removeEventListener('shown.bs.tooltip', firstCallback)
+ let tooltipShown = document.querySelector('.tooltip')
+
+ tooltipShown.remove()
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ tooltipShown = document.querySelector('.tooltip')
+
+ expect(tooltipShown).not.toBeNull()
+ resolve()
+ })
+
+ tooltip.show()
+ }
+
+ tooltipEl.addEventListener('shown.bs.tooltip', firstCallback)
+
+ tooltip.show()
+ })
+ })
+
+ it('should show a tooltip with a dom element container', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ container: fixtureEl
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(fixtureEl.querySelector('.tooltip')).not.toBeNull()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should show a tooltip with a jquery element container', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ container: {
+ 0: fixtureEl,
+ jquery: 'jQuery'
+ }
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(fixtureEl.querySelector('.tooltip')).not.toBeNull()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should show a tooltip with a selector in container', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ container: '#fixture'
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(fixtureEl.querySelector('.tooltip')).not.toBeNull()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should show a tooltip with placement as a function', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const spy = jasmine.createSpy('placement').and.returnValue('top')
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ placement: spy
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).not.toBeNull()
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should show a tooltip without the animation', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ animation: false
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tip = document.querySelector('.tooltip')
+
+ expect(tip).not.toBeNull()
+ expect(tip).not.toHaveClass('fade')
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should throw an error the element is not visible', () => {
+ fixtureEl.innerHTML = '<a href="#" style="display: none" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ try {
+ tooltip.show()
+ } catch (error) {
+ expect(error.message).toEqual('Please use show on visible elements')
+ }
+ })
+
+ it('should not show a tooltip if show.bs.tooltip is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ const expectedDone = () => {
+ setTimeout(() => {
+ expect(document.querySelector('.tooltip')).toBeNull()
+ resolve()
+ }, 10)
+ }
+
+ tooltipEl.addEventListener('show.bs.tooltip', ev => {
+ ev.preventDefault()
+ expectedDone()
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ reject(new Error('Tooltip should not be shown'))
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should show tooltip if leave event hasn\'t occurred before delay expires', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ delay: 150
+ })
+
+ const spy = spyOn(tooltip, 'show')
+
+ setTimeout(() => {
+ expect(spy).not.toHaveBeenCalled()
+ }, 100)
+
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ }, 200)
+
+ tooltipEl.dispatchEvent(createEvent('mouseover'))
+ })
+ })
+
+ it('should not show tooltip if leave event occurs before delay expires', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ delay: 150
+ })
+
+ const spy = spyOn(tooltip, 'show')
+
+ setTimeout(() => {
+ expect(spy).not.toHaveBeenCalled()
+ tooltipEl.dispatchEvent(createEvent('mouseover'))
+ }, 100)
+
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled()
+ expect(document.querySelectorAll('.tooltip')).toHaveSize(0)
+ resolve()
+ }, 200)
+
+ tooltipEl.dispatchEvent(createEvent('mouseover'))
+ })
+ })
+
+ it('should not hide tooltip if leave event occurs and enter event occurs within the hide delay', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip" data-bs-delay=\'{"show":0,"hide":150}\'>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(tooltip._config.delay).toEqual({ show: 0, hide: 150 })
+
+ setTimeout(() => {
+ expect(tooltip._getTipElement()).toHaveClass('show')
+ tooltipEl.dispatchEvent(createEvent('mouseout'))
+
+ setTimeout(() => {
+ expect(tooltip._getTipElement()).toHaveClass('show')
+ tooltipEl.dispatchEvent(createEvent('mouseover'))
+ }, 100)
+
+ setTimeout(() => {
+ expect(tooltip._getTipElement()).toHaveClass('show')
+ expect(document.querySelectorAll('.tooltip')).toHaveSize(1)
+ resolve()
+ }, 200)
+ }, 10)
+
+ tooltipEl.dispatchEvent(createEvent('mouseover'))
+ })
+ })
+
+ it('should not hide tooltip if leave event occurs and interaction remains inside trigger', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a href="#" rel="tooltip" title="Another tooltip">',
+ '<b>Trigger</b>',
+ 'the tooltip',
+ '</a>'
+ ].join('')
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+ const triggerChild = tooltipEl.querySelector('b')
+
+ const spy = spyOn(tooltip, 'hide').and.callThrough()
+
+ tooltipEl.addEventListener('mouseover', () => {
+ const moveMouseToChildEvent = createEvent('mouseout')
+ Object.defineProperty(moveMouseToChildEvent, 'relatedTarget', {
+ value: triggerChild
+ })
+
+ tooltipEl.dispatchEvent(moveMouseToChildEvent)
+ })
+
+ tooltipEl.addEventListener('mouseout', () => {
+ expect(spy).not.toHaveBeenCalled()
+ resolve()
+ })
+
+ tooltipEl.dispatchEvent(createEvent('mouseover'))
+ })
+ })
+
+ it('should properly maintain tooltip state if leave event occurs and enter event occurs during hide transition', () => {
+ return new Promise(resolve => {
+ // Style this tooltip to give it plenty of room for popper to do what it wants
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip" data-bs-placement="top" style="position:fixed;left:50%;top:50%;">Trigger</a>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ spyOn(window, 'getComputedStyle').and.returnValue({
+ transitionDuration: '0.15s',
+ transitionDelay: '0s'
+ })
+
+ setTimeout(() => {
+ expect(tooltip._popper).not.toBeNull()
+ expect(tooltip._getTipElement().getAttribute('data-popper-placement')).toEqual('top')
+ tooltipEl.dispatchEvent(createEvent('mouseout'))
+
+ setTimeout(() => {
+ expect(tooltip._getTipElement()).not.toHaveClass('show')
+ tooltipEl.dispatchEvent(createEvent('mouseover'))
+ }, 100)
+
+ setTimeout(() => {
+ expect(tooltip._popper).not.toBeNull()
+ expect(tooltip._getTipElement().getAttribute('data-popper-placement')).toEqual('top')
+ resolve()
+ }, 200)
+ }, 10)
+
+ tooltipEl.dispatchEvent(createEvent('mouseover'))
+ })
+ })
+
+ it('should only trigger inserted event if a new tooltip element was created', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ spyOn(window, 'getComputedStyle').and.returnValue({
+ transitionDuration: '0.15s',
+ transitionDelay: '0s'
+ })
+
+ const insertedFunc = jasmine.createSpy()
+ tooltipEl.addEventListener('inserted.bs.tooltip', insertedFunc)
+
+ setTimeout(() => {
+ expect(insertedFunc).toHaveBeenCalledTimes(1)
+ tooltip.hide()
+
+ setTimeout(() => {
+ tooltip.show()
+ }, 100)
+
+ setTimeout(() => {
+ expect(insertedFunc).toHaveBeenCalledTimes(2)
+ resolve()
+ }, 200)
+ }, 0)
+
+ tooltip.show()
+ })
+ })
+
+ it('should show a tooltip with custom class provided in data attributes', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip" data-bs-custom-class="custom-class">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tip = document.querySelector('.tooltip')
+ expect(tip).not.toBeNull()
+ expect(tip).toHaveClass('custom-class')
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should show a tooltip with custom class provided as a string in config', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ customClass: 'custom-class custom-class-2'
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tip = document.querySelector('.tooltip')
+ expect(tip).not.toBeNull()
+ expect(tip).toHaveClass('custom-class')
+ expect(tip).toHaveClass('custom-class-2')
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should show a tooltip with custom class provided as a function in config', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const spy = jasmine.createSpy('customClass').and.returnValue('custom-class')
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ customClass: spy
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tip = document.querySelector('.tooltip')
+ expect(tip).not.toBeNull()
+ expect(spy).toHaveBeenCalled()
+ expect(tip).toHaveClass('custom-class')
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should remove `title` attribute if exists', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"></a>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(tooltipEl.getAttribute('title')).toBeNull()
+ resolve()
+ })
+ tooltip.show()
+ })
+ })
+ })
+
+ describe('hide', () => {
+ it('should hide a tooltip', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => tooltip.hide())
+ tooltipEl.addEventListener('hidden.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).toBeNull()
+ expect(tooltipEl.getAttribute('aria-describedby')).toBeNull()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should hide a tooltip on mobile', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+ const spy = spyOn(EventHandler, 'off')
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ document.documentElement.ontouchstart = noop
+ tooltip.hide()
+ })
+
+ tooltipEl.addEventListener('hidden.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).toBeNull()
+ expect(spy).toHaveBeenCalledWith(jasmine.any(Object), 'mouseover', noop)
+ document.documentElement.ontouchstart = undefined
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should hide a tooltip without animation', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ animation: false
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => tooltip.hide())
+ tooltipEl.addEventListener('hidden.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).toBeNull()
+ expect(tooltipEl.getAttribute('aria-describedby')).toBeNull()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should not hide a tooltip if hide event is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const assertDone = () => {
+ setTimeout(() => {
+ expect(document.querySelector('.tooltip')).not.toBeNull()
+ resolve()
+ }, 20)
+ }
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ animation: false
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => tooltip.hide())
+ tooltipEl.addEventListener('hide.bs.tooltip', event => {
+ event.preventDefault()
+ assertDone()
+ })
+ tooltipEl.addEventListener('hidden.bs.tooltip', () => {
+ reject(new Error('should not trigger hidden event'))
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should not throw error running hide if popper hasn\'t been shown', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const tooltip = new Tooltip(div)
+
+ try {
+ tooltip.hide()
+ expect().nothing()
+ } catch {
+ throw new Error('should not throw error')
+ }
+ })
+ })
+
+ describe('update', () => {
+ it('should call popper update', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const spy = spyOn(tooltip._popper, 'update')
+
+ tooltip.update()
+
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should do nothing if the tooltip is not shown', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.update()
+ expect().nothing()
+ })
+ })
+
+ describe('_isWithContent', () => {
+ it('should return true if there is content', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(tooltip._isWithContent()).toBeTrue()
+ })
+
+ it('should return false if there is no content', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(tooltip._isWithContent()).toBeFalse()
+ })
+ })
+
+ describe('_getTipElement', () => {
+ it('should create the tip element and return it', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ const spy = spyOn(document, 'createElement').and.callThrough()
+
+ expect(tooltip._getTipElement()).toBeDefined()
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should return the created tip element', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ const spy = spyOn(document, 'createElement').and.callThrough()
+
+ expect(tooltip._getTipElement()).toBeDefined()
+ expect(spy).toHaveBeenCalled()
+
+ spy.calls.reset()
+
+ expect(tooltip._getTipElement()).toBeDefined()
+ expect(spy).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('setContent', () => {
+ it('should set tip content', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, { animation: false })
+
+ const tip = tooltip._getTipElement()
+
+ tooltip.setContent(tip)
+
+ expect(tip).not.toHaveClass('show')
+ expect(tip).not.toHaveClass('fade')
+ expect(tip.querySelector('.tooltip-inner').textContent).toEqual('Another tooltip')
+ })
+
+ it('should re-show tip if it was already shown', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" data-bs-title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+ tooltip.show()
+ const tip = () => tooltip._getTipElement()
+
+ expect(tip()).toHaveClass('show')
+ tooltip.setContent({ '.tooltip-inner': 'foo' })
+
+ expect(tip()).toHaveClass('show')
+ expect(tip().querySelector('.tooltip-inner').textContent).toEqual('foo')
+ })
+
+ it('should keep tip hidden, if it was already hidden before', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" data-bs-title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+ const tip = () => tooltip._getTipElement()
+
+ expect(tip()).not.toHaveClass('show')
+ tooltip.setContent({ '.tooltip-inner': 'foo' })
+
+ expect(tip()).not.toHaveClass('show')
+ tooltip.show()
+ expect(tip().querySelector('.tooltip-inner').textContent).toEqual('foo')
+ })
+
+ it('"setContent" should keep the initial template', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.setContent({ '.tooltip-inner': 'foo' })
+ const tip = tooltip._getTipElement()
+
+ expect(tip).toHaveClass('tooltip')
+ expect(tip).toHaveClass('bs-tooltip-auto')
+ expect(tip.querySelector('.tooltip-arrow')).not.toBeNull()
+ expect(tip.querySelector('.tooltip-inner')).not.toBeNull()
+ })
+ })
+
+ describe('setContent', () => {
+ it('should do nothing if the element is null', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.setContent({ '.tooltip': null })
+ expect().nothing()
+ })
+
+ it('should do nothing if the content is a child of the element', () => {
+ fixtureEl.innerHTML = [
+ '<a href="#" rel="tooltip" title="Another tooltip">',
+ ' <div id="childContent"></div>',
+ '</a>'
+ ].join('')
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const childContent = fixtureEl.querySelector('div')
+ const tooltip = new Tooltip(tooltipEl, {
+ html: true
+ })
+
+ tooltip._getTipElement().append(childContent)
+ tooltip.setContent({ '.tooltip': childContent })
+
+ expect().nothing()
+ })
+
+ it('should add the content as a child of the element for jQuery elements', () => {
+ fixtureEl.innerHTML = [
+ '<a href="#" rel="tooltip" title="Another tooltip">',
+ ' <div id="childContent"></div>',
+ '</a>'
+ ].join('')
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const childContent = fixtureEl.querySelector('div')
+ const tooltip = new Tooltip(tooltipEl, {
+ html: true
+ })
+
+ tooltip.setContent({ '.tooltip': { 0: childContent, jquery: 'jQuery' } })
+ tooltip.show()
+
+ expect(childContent.parentNode).toEqual(tooltip._getTipElement())
+ })
+
+ it('should add the child text content in the element', () => {
+ fixtureEl.innerHTML = [
+ '<a href="#" rel="tooltip" title="Another tooltip">',
+ ' <div id="childContent">Tooltip</div>',
+ '</a>'
+ ].join('')
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const childContent = fixtureEl.querySelector('div')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.setContent({ '.tooltip': childContent })
+
+ expect(childContent.textContent).toEqual(tooltip._getTipElement().textContent)
+ })
+
+ it('should add html without sanitize it', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"></a>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ sanitize: false,
+ html: true
+ })
+
+ tooltip.setContent({ '.tooltip': '<div id="childContent">Tooltip</div>' })
+
+ expect(tooltip._getTipElement().querySelector('div').id).toEqual('childContent')
+ })
+
+ it('should add html sanitized', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"></a>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ html: true
+ })
+
+ const content = [
+ '<div id="childContent">',
+ ' <button type="button">test btn</button>',
+ '</div>'
+ ].join('')
+
+ tooltip.setContent({ '.tooltip': content })
+ expect(tooltip._getTipElement().querySelector('div').id).toEqual('childContent')
+ expect(tooltip._getTipElement().querySelector('button')).toBeNull()
+ })
+
+ it('should add text content', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"></a>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.setContent({ '.tooltip': 'test' })
+
+ expect(tooltip._getTipElement().textContent).toEqual('test')
+ })
+ })
+
+ describe('_getTitle', () => {
+ it('should return the title', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"></a>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(tooltip._getTitle()).toEqual('Another tooltip')
+ })
+
+ it('should call title function', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip"></a>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ title: () => 'test'
+ })
+
+ expect(tooltip._getTitle()).toEqual('test')
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return tooltip instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const alert = new Tooltip(div)
+
+ expect(Tooltip.getInstance(div)).toEqual(alert)
+ expect(Tooltip.getInstance(div)).toBeInstanceOf(Tooltip)
+ })
+
+ it('should return null when there is no tooltip instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Tooltip.getInstance(div)).toBeNull()
+ })
+ })
+
+ describe('aria-label', () => {
+ it('should add the aria-label attribute for referencing original title', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"></a>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tooltipShown = document.querySelector('.tooltip')
+
+ expect(tooltipShown).not.toBeNull()
+ expect(tooltipEl.getAttribute('aria-label')).toEqual('Another tooltip')
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should add the aria-label attribute when element text content is a whitespace string', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="A tooltip"><span> </span></a>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tooltipShown = document.querySelector('.tooltip')
+
+ expect(tooltipShown).not.toBeNull()
+ expect(tooltipEl.getAttribute('aria-label')).toEqual('A tooltip')
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should not add the aria-label attribute if the attribute already exists', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" aria-label="Different label" title="Another tooltip"></a>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tooltipShown = document.querySelector('.tooltip')
+
+ expect(tooltipShown).not.toBeNull()
+ expect(tooltipEl.getAttribute('aria-label')).toEqual('Different label')
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should not add the aria-label attribute if the element has text content', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">text content</a>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tooltipShown = document.querySelector('.tooltip')
+
+ expect(tooltipShown).not.toBeNull()
+ expect(tooltipEl.getAttribute('aria-label')).toBeNull()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return tooltip instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const tooltip = new Tooltip(div)
+
+ expect(Tooltip.getOrCreateInstance(div)).toEqual(tooltip)
+ expect(Tooltip.getInstance(div)).toEqual(Tooltip.getOrCreateInstance(div, {}))
+ expect(Tooltip.getOrCreateInstance(div)).toBeInstanceOf(Tooltip)
+ })
+
+ it('should return new instance when there is no tooltip instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Tooltip.getInstance(div)).toBeNull()
+ expect(Tooltip.getOrCreateInstance(div)).toBeInstanceOf(Tooltip)
+ })
+
+ it('should return new instance when there is no tooltip instance with given configuration', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Tooltip.getInstance(div)).toBeNull()
+ const tooltip = Tooltip.getOrCreateInstance(div, {
+ title: () => 'test'
+ })
+ expect(tooltip).toBeInstanceOf(Tooltip)
+
+ expect(tooltip._getTitle()).toEqual('test')
+ })
+
+ it('should return the instance when exists without given configuration', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const tooltip = new Tooltip(div, {
+ title: () => 'nothing'
+ })
+ expect(Tooltip.getInstance(div)).toEqual(tooltip)
+
+ const tooltip2 = Tooltip.getOrCreateInstance(div, {
+ title: () => 'test'
+ })
+ expect(tooltip).toBeInstanceOf(Tooltip)
+ expect(tooltip2).toEqual(tooltip)
+
+ expect(tooltip2._getTitle()).toEqual('nothing')
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a tooltip', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.tooltip = Tooltip.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.tooltip.call(jQueryMock)
+
+ expect(Tooltip.getInstance(div)).not.toBeNull()
+ })
+
+ it('should not re create a tooltip', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const tooltip = new Tooltip(div)
+
+ jQueryMock.fn.tooltip = Tooltip.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.tooltip.call(jQueryMock)
+
+ expect(Tooltip.getInstance(div)).toEqual(tooltip)
+ })
+
+ it('should call a tooltip method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const tooltip = new Tooltip(div)
+
+ const spy = spyOn(tooltip, 'show')
+
+ jQueryMock.fn.tooltip = Tooltip.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.tooltip.call(jQueryMock, 'show')
+
+ expect(Tooltip.getInstance(div)).toEqual(tooltip)
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.tooltip = Tooltip.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.tooltip.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+ })
+})
diff --git a/js/tests/unit/util/backdrop.spec.js b/js/tests/unit/util/backdrop.spec.js
new file mode 100644
index 0000000..73384fc
--- /dev/null
+++ b/js/tests/unit/util/backdrop.spec.js
@@ -0,0 +1,321 @@
+import Backdrop from '../../../src/util/backdrop'
+import { getTransitionDurationFromElement } from '../../../src/util/index'
+import { clearFixture, getFixture } from '../../helpers/fixture'
+
+const CLASS_BACKDROP = '.modal-backdrop'
+const CLASS_NAME_FADE = 'fade'
+const CLASS_NAME_SHOW = 'show'
+
+describe('Backdrop', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ const list = document.querySelectorAll(CLASS_BACKDROP)
+
+ for (const el of list) {
+ el.remove()
+ }
+ })
+
+ describe('show', () => {
+ it('should append the backdrop html once on show and include the "show" class if it is "shown"', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: true,
+ isAnimated: false
+ })
+ const getElements = () => document.querySelectorAll(CLASS_BACKDROP)
+
+ expect(getElements()).toHaveSize(0)
+
+ instance.show()
+ instance.show(() => {
+ expect(getElements()).toHaveSize(1)
+ for (const el of getElements()) {
+ expect(el).toHaveClass(CLASS_NAME_SHOW)
+ }
+
+ resolve()
+ })
+ })
+ })
+
+ it('should not append the backdrop html if it is not "shown"', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: false,
+ isAnimated: true
+ })
+ const getElements = () => document.querySelectorAll(CLASS_BACKDROP)
+
+ expect(getElements()).toHaveSize(0)
+ instance.show(() => {
+ expect(getElements()).toHaveSize(0)
+ resolve()
+ })
+ })
+ })
+
+ it('should append the backdrop html once and include the "fade" class if it is "shown" and "animated"', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: true,
+ isAnimated: true
+ })
+ const getElements = () => document.querySelectorAll(CLASS_BACKDROP)
+
+ expect(getElements()).toHaveSize(0)
+
+ instance.show(() => {
+ expect(getElements()).toHaveSize(1)
+ for (const el of getElements()) {
+ expect(el).toHaveClass(CLASS_NAME_FADE)
+ }
+
+ resolve()
+ })
+ })
+ })
+ })
+
+ describe('hide', () => {
+ it('should remove the backdrop html', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: true,
+ isAnimated: true
+ })
+
+ const getElements = () => document.body.querySelectorAll(CLASS_BACKDROP)
+
+ expect(getElements()).toHaveSize(0)
+ instance.show(() => {
+ expect(getElements()).toHaveSize(1)
+ instance.hide(() => {
+ expect(getElements()).toHaveSize(0)
+ resolve()
+ })
+ })
+ })
+ })
+
+ it('should remove the "show" class', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: true,
+ isAnimated: true
+ })
+ const elem = instance._getElement()
+
+ instance.show()
+ instance.hide(() => {
+ expect(elem).not.toHaveClass(CLASS_NAME_SHOW)
+ resolve()
+ })
+ })
+ })
+
+ it('should not try to remove Node on remove method if it is not "shown"', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: false,
+ isAnimated: true
+ })
+ const getElements = () => document.querySelectorAll(CLASS_BACKDROP)
+ const spy = spyOn(instance, 'dispose').and.callThrough()
+
+ expect(getElements()).toHaveSize(0)
+ expect(instance._isAppended).toBeFalse()
+ instance.show(() => {
+ instance.hide(() => {
+ expect(getElements()).toHaveSize(0)
+ expect(spy).not.toHaveBeenCalled()
+ expect(instance._isAppended).toBeFalse()
+ resolve()
+ })
+ })
+ })
+ })
+
+ it('should not error if the backdrop no longer has a parent', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div id="wrapper"></div>'
+
+ const wrapper = fixtureEl.querySelector('#wrapper')
+ const instance = new Backdrop({
+ isVisible: true,
+ isAnimated: true,
+ rootElement: wrapper
+ })
+
+ const getElements = () => document.querySelectorAll(CLASS_BACKDROP)
+
+ instance.show(() => {
+ wrapper.remove()
+ instance.hide(() => {
+ expect(getElements()).toHaveSize(0)
+ resolve()
+ })
+ })
+ })
+ })
+ })
+
+ describe('click callback', () => {
+ it('should execute callback on click', () => {
+ return new Promise(resolve => {
+ const spy = jasmine.createSpy('spy')
+
+ const instance = new Backdrop({
+ isVisible: true,
+ isAnimated: false,
+ clickCallback: () => spy()
+ })
+ const endTest = () => {
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ }, 10)
+ }
+
+ instance.show(() => {
+ const clickEvent = new Event('mousedown', { bubbles: true, cancelable: true })
+ document.querySelector(CLASS_BACKDROP).dispatchEvent(clickEvent)
+ endTest()
+ })
+ })
+ })
+
+ describe('animation callbacks', () => {
+ it('should show and hide backdrop after counting transition duration if it is animated', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: true,
+ isAnimated: true
+ })
+ const spy2 = jasmine.createSpy('spy2')
+
+ const execDone = () => {
+ setTimeout(() => {
+ expect(spy2).toHaveBeenCalledTimes(2)
+ resolve()
+ }, 10)
+ }
+
+ instance.show(spy2)
+ instance.hide(() => {
+ spy2()
+ execDone()
+ })
+ expect(spy2).not.toHaveBeenCalled()
+ })
+ })
+
+ it('should show and hide backdrop without a delay if it is not animated', () => {
+ return new Promise(resolve => {
+ const spy = jasmine.createSpy('spy', getTransitionDurationFromElement)
+ const instance = new Backdrop({
+ isVisible: true,
+ isAnimated: false
+ })
+ const spy2 = jasmine.createSpy('spy2')
+
+ instance.show(spy2)
+ instance.hide(spy2)
+
+ setTimeout(() => {
+ expect(spy2).toHaveBeenCalled()
+ expect(spy).not.toHaveBeenCalled()
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should not call delay callbacks if it is not "shown"', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: false,
+ isAnimated: true
+ })
+ const spy = jasmine.createSpy('spy', getTransitionDurationFromElement)
+
+ instance.show()
+ instance.hide(() => {
+ expect(spy).not.toHaveBeenCalled()
+ resolve()
+ })
+ })
+ })
+ })
+
+ describe('Config', () => {
+ describe('rootElement initialization', () => {
+ it('should be appended on "document.body" by default', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: true
+ })
+ const getElement = () => document.querySelector(CLASS_BACKDROP)
+ instance.show(() => {
+ expect(getElement().parentElement).toEqual(document.body)
+ resolve()
+ })
+ })
+ })
+
+ it('should find the rootElement if passed as a string', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: true,
+ rootElement: 'body'
+ })
+ const getElement = () => document.querySelector(CLASS_BACKDROP)
+ instance.show(() => {
+ expect(getElement().parentElement).toEqual(document.body)
+ resolve()
+ })
+ })
+ })
+
+ it('should be appended on any element given by the proper config', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div id="wrapper"></div>'
+
+ const wrapper = fixtureEl.querySelector('#wrapper')
+ const instance = new Backdrop({
+ isVisible: true,
+ rootElement: wrapper
+ })
+ const getElement = () => document.querySelector(CLASS_BACKDROP)
+ instance.show(() => {
+ expect(getElement().parentElement).toEqual(wrapper)
+ resolve()
+ })
+ })
+ })
+ })
+
+ describe('ClassName', () => {
+ it('should allow configuring className', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: true,
+ className: 'foo'
+ })
+ const getElement = () => document.querySelector('.foo')
+ instance.show(() => {
+ expect(getElement()).toEqual(instance._getElement())
+ instance.dispose()
+ resolve()
+ })
+ })
+ })
+ })
+ })
+ })
+})
diff --git a/js/tests/unit/util/component-functions.spec.js b/js/tests/unit/util/component-functions.spec.js
new file mode 100644
index 0000000..ec36672
--- /dev/null
+++ b/js/tests/unit/util/component-functions.spec.js
@@ -0,0 +1,108 @@
+/* Test helpers */
+
+import { clearFixture, createEvent, getFixture } from '../../helpers/fixture'
+import { enableDismissTrigger } from '../../../src/util/component-functions'
+import BaseComponent from '../../../src/base-component'
+
+class DummyClass2 extends BaseComponent {
+ static get NAME() {
+ return 'test'
+ }
+
+ hide() {
+ return true
+ }
+
+ testMethod() {
+ return true
+ }
+}
+
+describe('Plugin functions', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('data-bs-dismiss functionality', () => {
+ it('should get Plugin and execute the given method, when a click occurred on data-bs-dismiss="PluginName"', () => {
+ fixtureEl.innerHTML = [
+ '<div id="foo" class="test">',
+ ' <button type="button" data-bs-dismiss="test" data-bs-target="#foo"></button>',
+ '</div>'
+ ].join('')
+
+ const spyGet = spyOn(DummyClass2, 'getOrCreateInstance').and.callThrough()
+ const spyTest = spyOn(DummyClass2.prototype, 'testMethod')
+ const componentWrapper = fixtureEl.querySelector('#foo')
+ const btnClose = fixtureEl.querySelector('[data-bs-dismiss="test"]')
+ const event = createEvent('click')
+
+ enableDismissTrigger(DummyClass2, 'testMethod')
+ btnClose.dispatchEvent(event)
+
+ expect(spyGet).toHaveBeenCalledWith(componentWrapper)
+ expect(spyTest).toHaveBeenCalled()
+ })
+
+ it('if data-bs-dismiss="PluginName" hasn\'t got "data-bs-target", "getOrCreateInstance" has to be initialized by closest "plugin.Name" class', () => {
+ fixtureEl.innerHTML = [
+ '<div id="foo" class="test">',
+ ' <button type="button" data-bs-dismiss="test"></button>',
+ '</div>'
+ ].join('')
+
+ const spyGet = spyOn(DummyClass2, 'getOrCreateInstance').and.callThrough()
+ const spyHide = spyOn(DummyClass2.prototype, 'hide')
+ const componentWrapper = fixtureEl.querySelector('#foo')
+ const btnClose = fixtureEl.querySelector('[data-bs-dismiss="test"]')
+ const event = createEvent('click')
+
+ enableDismissTrigger(DummyClass2)
+ btnClose.dispatchEvent(event)
+
+ expect(spyGet).toHaveBeenCalledWith(componentWrapper)
+ expect(spyHide).toHaveBeenCalled()
+ })
+
+ it('if data-bs-dismiss="PluginName" is disabled, must not trigger function', () => {
+ fixtureEl.innerHTML = [
+ '<div id="foo" class="test">',
+ ' <button type="button" disabled data-bs-dismiss="test"></button>',
+ '</div>'
+ ].join('')
+
+ const spy = spyOn(DummyClass2, 'getOrCreateInstance').and.callThrough()
+ const btnClose = fixtureEl.querySelector('[data-bs-dismiss="test"]')
+ const event = createEvent('click')
+
+ enableDismissTrigger(DummyClass2)
+ btnClose.dispatchEvent(event)
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should prevent default when the trigger is <a> or <area>', () => {
+ fixtureEl.innerHTML = [
+ '<div id="foo" class="test">',
+ ' <a type="button" data-bs-dismiss="test"></a>',
+ '</div>'
+ ].join('')
+
+ const btnClose = fixtureEl.querySelector('[data-bs-dismiss="test"]')
+ const event = createEvent('click')
+
+ enableDismissTrigger(DummyClass2)
+ const spy = spyOn(Event.prototype, 'preventDefault').and.callThrough()
+
+ btnClose.dispatchEvent(event)
+
+ expect(spy).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/js/tests/unit/util/config.spec.js b/js/tests/unit/util/config.spec.js
new file mode 100644
index 0000000..e1693c0
--- /dev/null
+++ b/js/tests/unit/util/config.spec.js
@@ -0,0 +1,166 @@
+import Config from '../../../src/util/config'
+import { clearFixture, getFixture } from '../../helpers/fixture'
+
+class DummyConfigClass extends Config {
+ static get NAME() {
+ return 'dummy'
+ }
+}
+
+describe('Config', () => {
+ let fixtureEl
+ const name = 'dummy'
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('NAME', () => {
+ it('should return plugin NAME', () => {
+ expect(DummyConfigClass.NAME).toEqual(name)
+ })
+ })
+
+ describe('DefaultType', () => {
+ it('should return plugin default type', () => {
+ expect(DummyConfigClass.DefaultType).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin defaults', () => {
+ expect(DummyConfigClass.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('mergeConfigObj', () => {
+ it('should parse element\'s data attributes and merge it with default config. Element\'s data attributes must excel Defaults', () => {
+ fixtureEl.innerHTML = '<div id="test" data-bs-test-bool="false" data-bs-test-int="8" data-bs-test-string1="bar"></div>'
+
+ spyOnProperty(DummyConfigClass, 'Default', 'get').and.returnValue({
+ testBool: true,
+ testString: 'foo',
+ testString1: 'foo',
+ testInt: 7
+ })
+ const instance = new DummyConfigClass()
+ const configResult = instance._mergeConfigObj({}, fixtureEl.querySelector('#test'))
+
+ expect(configResult.testBool).toEqual(false)
+ expect(configResult.testString).toEqual('foo')
+ expect(configResult.testString1).toEqual('bar')
+ expect(configResult.testInt).toEqual(8)
+ })
+
+ it('should parse element\'s data attributes and merge it with default config, plug these given during method call. The programmatically given should excel all', () => {
+ fixtureEl.innerHTML = '<div id="test" data-bs-test-bool="false" data-bs-test-int="8" data-bs-test-string-1="bar"></div>'
+
+ spyOnProperty(DummyConfigClass, 'Default', 'get').and.returnValue({
+ testBool: true,
+ testString: 'foo',
+ testString1: 'foo',
+ testInt: 7
+ })
+ const instance = new DummyConfigClass()
+ const configResult = instance._mergeConfigObj({
+ testString1: 'test',
+ testInt: 3
+ }, fixtureEl.querySelector('#test'))
+
+ expect(configResult.testBool).toEqual(false)
+ expect(configResult.testString).toEqual('foo')
+ expect(configResult.testString1).toEqual('test')
+ expect(configResult.testInt).toEqual(3)
+ })
+
+ it('should parse element\'s data attribute `config` and any rest attributes. The programmatically given should excel all. Data attribute `config` should excel only Defaults', () => {
+ fixtureEl.innerHTML = '<div id="test" data-bs-config=\'{"testBool":false,"testInt":50,"testInt2":100}\' data-bs-test-int="8" data-bs-test-string-1="bar"></div>'
+
+ spyOnProperty(DummyConfigClass, 'Default', 'get').and.returnValue({
+ testBool: true,
+ testString: 'foo',
+ testString1: 'foo',
+ testInt: 7,
+ testInt2: 600
+ })
+ const instance = new DummyConfigClass()
+ const configResult = instance._mergeConfigObj({
+ testString1: 'test'
+ }, fixtureEl.querySelector('#test'))
+
+ expect(configResult.testBool).toEqual(false)
+ expect(configResult.testString).toEqual('foo')
+ expect(configResult.testString1).toEqual('test')
+ expect(configResult.testInt).toEqual(8)
+ expect(configResult.testInt2).toEqual(100)
+ })
+
+ it('should omit element\'s data attribute `config` if is not an object', () => {
+ fixtureEl.innerHTML = '<div id="test" data-bs-config="foo" data-bs-test-int="8"></div>'
+
+ spyOnProperty(DummyConfigClass, 'Default', 'get').and.returnValue({
+ testInt: 7,
+ testInt2: 79
+ })
+ const instance = new DummyConfigClass()
+ const configResult = instance._mergeConfigObj({}, fixtureEl.querySelector('#test'))
+
+ expect(configResult.testInt).toEqual(8)
+ expect(configResult.testInt2).toEqual(79)
+ })
+ })
+
+ describe('typeCheckConfig', () => {
+ it('should check type of the config object', () => {
+ spyOnProperty(DummyConfigClass, 'DefaultType', 'get').and.returnValue({
+ toggle: 'boolean',
+ parent: '(string|element)'
+ })
+ const config = {
+ toggle: true,
+ parent: 777
+ }
+
+ const obj = new DummyConfigClass()
+ expect(() => {
+ obj._typeCheckConfig(config)
+ }).toThrowError(TypeError, obj.constructor.NAME.toUpperCase() + ': Option "parent" provided type "number" but expected type "(string|element)".')
+ })
+
+ it('should return null stringified when null is passed', () => {
+ spyOnProperty(DummyConfigClass, 'DefaultType', 'get').and.returnValue({
+ toggle: 'boolean',
+ parent: '(null|element)'
+ })
+
+ const obj = new DummyConfigClass()
+ const config = {
+ toggle: true,
+ parent: null
+ }
+
+ obj._typeCheckConfig(config)
+ expect().nothing()
+ })
+
+ it('should return undefined stringified when undefined is passed', () => {
+ spyOnProperty(DummyConfigClass, 'DefaultType', 'get').and.returnValue({
+ toggle: 'boolean',
+ parent: '(undefined|element)'
+ })
+
+ const obj = new DummyConfigClass()
+ const config = {
+ toggle: true,
+ parent: undefined
+ }
+
+ obj._typeCheckConfig(config)
+ expect().nothing()
+ })
+ })
+})
diff --git a/js/tests/unit/util/focustrap.spec.js b/js/tests/unit/util/focustrap.spec.js
new file mode 100644
index 0000000..bedd124
--- /dev/null
+++ b/js/tests/unit/util/focustrap.spec.js
@@ -0,0 +1,218 @@
+import FocusTrap from '../../../src/util/focustrap'
+import EventHandler from '../../../src/dom/event-handler'
+import SelectorEngine from '../../../src/dom/selector-engine'
+import { clearFixture, createEvent, getFixture } from '../../helpers/fixture'
+
+describe('FocusTrap', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('activate', () => {
+ it('should autofocus itself by default', () => {
+ fixtureEl.innerHTML = '<div id="focustrap" tabindex="-1"></div>'
+
+ const trapElement = fixtureEl.querySelector('div')
+
+ const spy = spyOn(trapElement, 'focus')
+
+ const focustrap = new FocusTrap({ trapElement })
+ focustrap.activate()
+
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('if configured not to autofocus, should not autofocus itself', () => {
+ fixtureEl.innerHTML = '<div id="focustrap" tabindex="-1"></div>'
+
+ const trapElement = fixtureEl.querySelector('div')
+
+ const spy = spyOn(trapElement, 'focus')
+
+ const focustrap = new FocusTrap({ trapElement, autofocus: false })
+ focustrap.activate()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should force focus inside focus trap if it can', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a href="#" id="outside">outside</a>',
+ '<div id="focustrap" tabindex="-1">',
+ ' <a href="#" id="inside">inside</a>',
+ '</div>'
+ ].join('')
+
+ const trapElement = fixtureEl.querySelector('div')
+ const focustrap = new FocusTrap({ trapElement })
+ focustrap.activate()
+
+ const inside = document.getElementById('inside')
+
+ const focusInListener = () => {
+ expect(spy).toHaveBeenCalled()
+ document.removeEventListener('focusin', focusInListener)
+ resolve()
+ }
+
+ const spy = spyOn(inside, 'focus')
+ spyOn(SelectorEngine, 'focusableChildren').and.callFake(() => [inside])
+
+ document.addEventListener('focusin', focusInListener)
+
+ const focusInEvent = createEvent('focusin', { bubbles: true })
+ Object.defineProperty(focusInEvent, 'target', {
+ value: document.getElementById('outside')
+ })
+
+ document.dispatchEvent(focusInEvent)
+ })
+ })
+
+ it('should wrap focus around forward on tab', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a href="#" id="outside">outside</a>',
+ '<div id="focustrap" tabindex="-1">',
+ ' <a href="#" id="first">first</a>',
+ ' <a href="#" id="inside">inside</a>',
+ ' <a href="#" id="last">last</a>',
+ '</div>'
+ ].join('')
+
+ const trapElement = fixtureEl.querySelector('div')
+ const focustrap = new FocusTrap({ trapElement })
+ focustrap.activate()
+
+ const first = document.getElementById('first')
+ const inside = document.getElementById('inside')
+ const last = document.getElementById('last')
+ const outside = document.getElementById('outside')
+
+ spyOn(SelectorEngine, 'focusableChildren').and.callFake(() => [first, inside, last])
+ const spy = spyOn(first, 'focus').and.callThrough()
+
+ const focusInListener = () => {
+ expect(spy).toHaveBeenCalled()
+ first.removeEventListener('focusin', focusInListener)
+ resolve()
+ }
+
+ first.addEventListener('focusin', focusInListener)
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'Tab'
+
+ document.dispatchEvent(keydown)
+ outside.focus()
+ })
+ })
+
+ it('should wrap focus around backwards on shift-tab', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a href="#" id="outside">outside</a>',
+ '<div id="focustrap" tabindex="-1">',
+ ' <a href="#" id="first">first</a>',
+ ' <a href="#" id="inside">inside</a>',
+ ' <a href="#" id="last">last</a>',
+ '</div>'
+ ].join('')
+
+ const trapElement = fixtureEl.querySelector('div')
+ const focustrap = new FocusTrap({ trapElement })
+ focustrap.activate()
+
+ const first = document.getElementById('first')
+ const inside = document.getElementById('inside')
+ const last = document.getElementById('last')
+ const outside = document.getElementById('outside')
+
+ spyOn(SelectorEngine, 'focusableChildren').and.callFake(() => [first, inside, last])
+ const spy = spyOn(last, 'focus').and.callThrough()
+
+ const focusInListener = () => {
+ expect(spy).toHaveBeenCalled()
+ last.removeEventListener('focusin', focusInListener)
+ resolve()
+ }
+
+ last.addEventListener('focusin', focusInListener)
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'Tab'
+ keydown.shiftKey = true
+
+ document.dispatchEvent(keydown)
+ outside.focus()
+ })
+ })
+
+ it('should force focus on itself if there is no focusable content', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a href="#" id="outside">outside</a>',
+ '<div id="focustrap" tabindex="-1"></div>'
+ ].join('')
+
+ const trapElement = fixtureEl.querySelector('div')
+ const focustrap = new FocusTrap({ trapElement })
+ focustrap.activate()
+
+ const focusInListener = () => {
+ expect(spy).toHaveBeenCalled()
+ document.removeEventListener('focusin', focusInListener)
+ resolve()
+ }
+
+ const spy = spyOn(focustrap._config.trapElement, 'focus')
+
+ document.addEventListener('focusin', focusInListener)
+
+ const focusInEvent = createEvent('focusin', { bubbles: true })
+ Object.defineProperty(focusInEvent, 'target', {
+ value: document.getElementById('outside')
+ })
+
+ document.dispatchEvent(focusInEvent)
+ })
+ })
+ })
+
+ describe('deactivate', () => {
+ it('should flag itself as no longer active', () => {
+ const focustrap = new FocusTrap({ trapElement: fixtureEl })
+ focustrap.activate()
+ expect(focustrap._isActive).toBeTrue()
+
+ focustrap.deactivate()
+ expect(focustrap._isActive).toBeFalse()
+ })
+
+ it('should remove all event listeners', () => {
+ const focustrap = new FocusTrap({ trapElement: fixtureEl })
+ focustrap.activate()
+
+ const spy = spyOn(EventHandler, 'off')
+ focustrap.deactivate()
+
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('doesn\'t try removing event listeners unless it needs to (in case it hasn\'t been activated)', () => {
+ const focustrap = new FocusTrap({ trapElement: fixtureEl })
+
+ const spy = spyOn(EventHandler, 'off')
+ focustrap.deactivate()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/js/tests/unit/util/index.spec.js b/js/tests/unit/util/index.spec.js
new file mode 100644
index 0000000..9f28ce0
--- /dev/null
+++ b/js/tests/unit/util/index.spec.js
@@ -0,0 +1,814 @@
+import * as Util from '../../../src/util/index'
+import { clearFixture, getFixture } from '../../helpers/fixture'
+import { noop } from '../../../src/util/index'
+
+describe('Util', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('getUID', () => {
+ it('should generate uid', () => {
+ const uid = Util.getUID('bs')
+ const uid2 = Util.getUID('bs')
+
+ expect(uid).not.toEqual(uid2)
+ })
+ })
+
+ describe('getSelectorFromElement', () => {
+ it('should get selector from data-bs-target', () => {
+ fixtureEl.innerHTML = [
+ '<div id="test" data-bs-target=".target"></div>',
+ '<div class="target"></div>'
+ ].join('')
+
+ const testEl = fixtureEl.querySelector('#test')
+
+ expect(Util.getSelectorFromElement(testEl)).toEqual('.target')
+ })
+
+ it('should get selector from href if no data-bs-target set', () => {
+ fixtureEl.innerHTML = [
+ '<a id="test" href=".target"></a>',
+ '<div class="target"></div>'
+ ].join('')
+
+ const testEl = fixtureEl.querySelector('#test')
+
+ expect(Util.getSelectorFromElement(testEl)).toEqual('.target')
+ })
+
+ it('should get selector from href if data-bs-target equal to #', () => {
+ fixtureEl.innerHTML = [
+ '<a id="test" data-bs-target="#" href=".target"></a>',
+ '<div class="target"></div>'
+ ].join('')
+
+ const testEl = fixtureEl.querySelector('#test')
+
+ expect(Util.getSelectorFromElement(testEl)).toEqual('.target')
+ })
+
+ it('should return null if a selector from a href is a url without an anchor', () => {
+ fixtureEl.innerHTML = [
+ '<a id="test" data-bs-target="#" href="foo/bar.html"></a>',
+ '<div class="target"></div>'
+ ].join('')
+
+ const testEl = fixtureEl.querySelector('#test')
+
+ expect(Util.getSelectorFromElement(testEl)).toBeNull()
+ })
+
+ it('should return the anchor if a selector from a href is a url', () => {
+ fixtureEl.innerHTML = [
+ '<a id="test" data-bs-target="#" href="foo/bar.html#target"></a>',
+ '<div id="target"></div>'
+ ].join('')
+
+ const testEl = fixtureEl.querySelector('#test')
+
+ expect(Util.getSelectorFromElement(testEl)).toEqual('#target')
+ })
+
+ it('should return null if selector not found', () => {
+ fixtureEl.innerHTML = '<a id="test" href=".target"></a>'
+
+ const testEl = fixtureEl.querySelector('#test')
+
+ expect(Util.getSelectorFromElement(testEl)).toBeNull()
+ })
+
+ it('should return null if no selector', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const testEl = fixtureEl.querySelector('div')
+
+ expect(Util.getSelectorFromElement(testEl)).toBeNull()
+ })
+ })
+
+ describe('getElementFromSelector', () => {
+ it('should get element from data-bs-target', () => {
+ fixtureEl.innerHTML = [
+ '<div id="test" data-bs-target=".target"></div>',
+ '<div class="target"></div>'
+ ].join('')
+
+ const testEl = fixtureEl.querySelector('#test')
+
+ expect(Util.getElementFromSelector(testEl)).toEqual(fixtureEl.querySelector('.target'))
+ })
+
+ it('should get element from href if no data-bs-target set', () => {
+ fixtureEl.innerHTML = [
+ '<a id="test" href=".target"></a>',
+ '<div class="target"></div>'
+ ].join('')
+
+ const testEl = fixtureEl.querySelector('#test')
+
+ expect(Util.getElementFromSelector(testEl)).toEqual(fixtureEl.querySelector('.target'))
+ })
+
+ it('should return null if element not found', () => {
+ fixtureEl.innerHTML = '<a id="test" href=".target"></a>'
+
+ const testEl = fixtureEl.querySelector('#test')
+
+ expect(Util.getElementFromSelector(testEl)).toBeNull()
+ })
+
+ it('should return null if no selector', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const testEl = fixtureEl.querySelector('div')
+
+ expect(Util.getElementFromSelector(testEl)).toBeNull()
+ })
+ })
+
+ describe('getTransitionDurationFromElement', () => {
+ it('should get transition from element', () => {
+ fixtureEl.innerHTML = '<div style="transition: all 300ms ease-out;"></div>'
+
+ expect(Util.getTransitionDurationFromElement(fixtureEl.querySelector('div'))).toEqual(300)
+ })
+
+ it('should return 0 if the element is undefined or null', () => {
+ expect(Util.getTransitionDurationFromElement(null)).toEqual(0)
+ expect(Util.getTransitionDurationFromElement(undefined)).toEqual(0)
+ })
+
+ it('should return 0 if the element do not possess transition', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ expect(Util.getTransitionDurationFromElement(fixtureEl.querySelector('div'))).toEqual(0)
+ })
+ })
+
+ describe('triggerTransitionEnd', () => {
+ it('should trigger transitionend event', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const el = fixtureEl.querySelector('div')
+ const spy = spyOn(el, 'dispatchEvent').and.callThrough()
+
+ el.addEventListener('transitionend', () => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ Util.triggerTransitionEnd(el)
+ })
+ })
+ })
+
+ describe('isElement', () => {
+ it('should detect if the parameter is an element or not and return Boolean', () => {
+ fixtureEl.innerHTML = [
+ '<div id="foo" class="test"></div>',
+ '<div id="bar" class="test"></div>'
+ ].join('')
+
+ const el = fixtureEl.querySelector('#foo')
+
+ expect(Util.isElement(el)).toBeTrue()
+ expect(Util.isElement({})).toBeFalse()
+ expect(Util.isElement(fixtureEl.querySelectorAll('.test'))).toBeFalse()
+ })
+
+ it('should detect jQuery element', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const el = fixtureEl.querySelector('div')
+ const fakejQuery = {
+ 0: el,
+ jquery: 'foo'
+ }
+
+ expect(Util.isElement(fakejQuery)).toBeTrue()
+ })
+ })
+
+ describe('getElement', () => {
+ it('should try to parse element', () => {
+ fixtureEl.innerHTML = [
+ '<div id="foo" class="test"></div>',
+ '<div id="bar" class="test"></div>'
+ ].join('')
+
+ const el = fixtureEl.querySelector('div')
+
+ expect(Util.getElement(el)).toEqual(el)
+ expect(Util.getElement('#foo')).toEqual(el)
+ expect(Util.getElement('#fail')).toBeNull()
+ expect(Util.getElement({})).toBeNull()
+ expect(Util.getElement([])).toBeNull()
+ expect(Util.getElement()).toBeNull()
+ expect(Util.getElement(null)).toBeNull()
+ expect(Util.getElement(fixtureEl.querySelectorAll('.test'))).toBeNull()
+
+ const fakejQueryObject = {
+ 0: el,
+ jquery: 'foo'
+ }
+
+ expect(Util.getElement(fakejQueryObject)).toEqual(el)
+ })
+ })
+
+ describe('isVisible', () => {
+ it('should return false if the element is not defined', () => {
+ expect(Util.isVisible(null)).toBeFalse()
+ expect(Util.isVisible(undefined)).toBeFalse()
+ })
+
+ it('should return false if the element provided is not a dom element', () => {
+ expect(Util.isVisible({})).toBeFalse()
+ })
+
+ it('should return false if the element is not visible with display none', () => {
+ fixtureEl.innerHTML = '<div style="display: none;"></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Util.isVisible(div)).toBeFalse()
+ })
+
+ it('should return false if the element is not visible with visibility hidden', () => {
+ fixtureEl.innerHTML = '<div style="visibility: hidden;"></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Util.isVisible(div)).toBeFalse()
+ })
+
+ it('should return false if an ancestor element is display none', () => {
+ fixtureEl.innerHTML = [
+ '<div style="display: none;">',
+ ' <div>',
+ ' <div>',
+ ' <div class="content"></div>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('.content')
+
+ expect(Util.isVisible(div)).toBeFalse()
+ })
+
+ it('should return false if an ancestor element is visibility hidden', () => {
+ fixtureEl.innerHTML = [
+ '<div style="visibility: hidden;">',
+ ' <div>',
+ ' <div>',
+ ' <div class="content"></div>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('.content')
+
+ expect(Util.isVisible(div)).toBeFalse()
+ })
+
+ it('should return true if an ancestor element is visibility hidden, but reverted', () => {
+ fixtureEl.innerHTML = [
+ '<div style="visibility: hidden;">',
+ ' <div style="visibility: visible;">',
+ ' <div>',
+ ' <div class="content"></div>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('.content')
+
+ expect(Util.isVisible(div)).toBeTrue()
+ })
+
+ it('should return true if the element is visible', () => {
+ fixtureEl.innerHTML = [
+ '<div>',
+ ' <div id="element"></div>',
+ '</div>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('#element')
+
+ expect(Util.isVisible(div)).toBeTrue()
+ })
+
+ it('should return false if the element is hidden, but not via display or visibility', () => {
+ fixtureEl.innerHTML = [
+ '<details>',
+ ' <div id="element"></div>',
+ '</details>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('#element')
+
+ expect(Util.isVisible(div)).toBeFalse()
+ })
+
+ it('should return true if its a closed details element', () => {
+ fixtureEl.innerHTML = '<details id="element"></details>'
+
+ const div = fixtureEl.querySelector('#element')
+
+ expect(Util.isVisible(div)).toBeTrue()
+ })
+
+ it('should return true if the element is visible inside an open details element', () => {
+ fixtureEl.innerHTML = [
+ '<details open>',
+ ' <div id="element"></div>',
+ '</details>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('#element')
+
+ expect(Util.isVisible(div)).toBeTrue()
+ })
+
+ it('should return true if the element is a visible summary in a closed details element', () => {
+ fixtureEl.innerHTML = [
+ '<details>',
+ ' <summary id="element-1">',
+ ' <span id="element-2"></span>',
+ ' </summary>',
+ '</details>'
+ ].join('')
+
+ const element1 = fixtureEl.querySelector('#element-1')
+ const element2 = fixtureEl.querySelector('#element-2')
+
+ expect(Util.isVisible(element1)).toBeTrue()
+ expect(Util.isVisible(element2)).toBeTrue()
+ })
+ })
+
+ describe('isDisabled', () => {
+ it('should return true if the element is not defined', () => {
+ expect(Util.isDisabled(null)).toBeTrue()
+ expect(Util.isDisabled(undefined)).toBeTrue()
+ expect(Util.isDisabled()).toBeTrue()
+ })
+
+ it('should return true if the element provided is not a dom element', () => {
+ expect(Util.isDisabled({})).toBeTrue()
+ expect(Util.isDisabled('test')).toBeTrue()
+ })
+
+ it('should return true if the element has disabled attribute', () => {
+ fixtureEl.innerHTML = [
+ '<div>',
+ ' <div id="element" disabled="disabled"></div>',
+ ' <div id="element1" disabled="true"></div>',
+ ' <div id="element2" disabled></div>',
+ '</div>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('#element')
+ const div1 = fixtureEl.querySelector('#element1')
+ const div2 = fixtureEl.querySelector('#element2')
+
+ expect(Util.isDisabled(div)).toBeTrue()
+ expect(Util.isDisabled(div1)).toBeTrue()
+ expect(Util.isDisabled(div2)).toBeTrue()
+ })
+
+ it('should return false if the element has disabled attribute with "false" value, or doesn\'t have attribute', () => {
+ fixtureEl.innerHTML = [
+ '<div>',
+ ' <div id="element" disabled="false"></div>',
+ ' <div id="element1" ></div>',
+ '</div>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('#element')
+ const div1 = fixtureEl.querySelector('#element1')
+
+ expect(Util.isDisabled(div)).toBeFalse()
+ expect(Util.isDisabled(div1)).toBeFalse()
+ })
+
+ it('should return false if the element is not disabled ', () => {
+ fixtureEl.innerHTML = [
+ '<div>',
+ ' <button id="button"></button>',
+ ' <select id="select"></select>',
+ ' <select id="input"></select>',
+ '</div>'
+ ].join('')
+
+ const el = selector => fixtureEl.querySelector(selector)
+
+ expect(Util.isDisabled(el('#button'))).toBeFalse()
+ expect(Util.isDisabled(el('#select'))).toBeFalse()
+ expect(Util.isDisabled(el('#input'))).toBeFalse()
+ })
+
+ it('should return true if the element has disabled attribute', () => {
+ fixtureEl.innerHTML = [
+ '<div>',
+ ' <input id="input" disabled="disabled">',
+ ' <input id="input1" disabled="disabled">',
+ ' <button id="button" disabled="true"></button>',
+ ' <button id="button1" disabled="disabled"></button>',
+ ' <button id="button2" disabled></button>',
+ ' <select id="select" disabled></select>',
+ ' <select id="input" disabled></select>',
+ '</div>'
+ ].join('')
+
+ const el = selector => fixtureEl.querySelector(selector)
+
+ expect(Util.isDisabled(el('#input'))).toBeTrue()
+ expect(Util.isDisabled(el('#input1'))).toBeTrue()
+ expect(Util.isDisabled(el('#button'))).toBeTrue()
+ expect(Util.isDisabled(el('#button1'))).toBeTrue()
+ expect(Util.isDisabled(el('#button2'))).toBeTrue()
+ expect(Util.isDisabled(el('#input'))).toBeTrue()
+ })
+
+ it('should return true if the element has class "disabled"', () => {
+ fixtureEl.innerHTML = [
+ '<div>',
+ ' <div id="element" class="disabled"></div>',
+ '</div>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('#element')
+
+ expect(Util.isDisabled(div)).toBeTrue()
+ })
+
+ it('should return true if the element has class "disabled" but disabled attribute is false', () => {
+ fixtureEl.innerHTML = [
+ '<div>',
+ ' <input id="input" class="disabled" disabled="false">',
+ '</div>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('#input')
+
+ expect(Util.isDisabled(div)).toBeTrue()
+ })
+ })
+
+ describe('findShadowRoot', () => {
+ it('should return null if shadow dom is not available', () => {
+ // Only for newer browsers
+ if (!document.documentElement.attachShadow) {
+ expect().nothing()
+ return
+ }
+
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ spyOn(document.documentElement, 'attachShadow').and.returnValue(null)
+
+ expect(Util.findShadowRoot(div)).toBeNull()
+ })
+
+ it('should return null when we do not find a shadow root', () => {
+ // Only for newer browsers
+ if (!document.documentElement.attachShadow) {
+ expect().nothing()
+ return
+ }
+
+ spyOn(document, 'getRootNode').and.returnValue(undefined)
+
+ expect(Util.findShadowRoot(document)).toBeNull()
+ })
+
+ it('should return the shadow root when found', () => {
+ // Only for newer browsers
+ if (!document.documentElement.attachShadow) {
+ expect().nothing()
+ return
+ }
+
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const shadowRoot = div.attachShadow({
+ mode: 'open'
+ })
+
+ expect(Util.findShadowRoot(shadowRoot)).toEqual(shadowRoot)
+
+ shadowRoot.innerHTML = '<button>Shadow Button</button>'
+
+ expect(Util.findShadowRoot(shadowRoot.firstChild)).toEqual(shadowRoot)
+ })
+ })
+
+ describe('noop', () => {
+ it('should be a function', () => {
+ expect(Util.noop).toEqual(jasmine.any(Function))
+ })
+ })
+
+ describe('reflow', () => {
+ it('should return element offset height to force the reflow', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const spy = spyOnProperty(div, 'offsetHeight')
+ Util.reflow(div)
+ expect(spy).toHaveBeenCalled()
+ })
+ })
+
+ describe('getjQuery', () => {
+ const fakejQuery = { trigger() {} }
+
+ beforeEach(() => {
+ Object.defineProperty(window, 'jQuery', {
+ value: fakejQuery,
+ writable: true
+ })
+ })
+
+ afterEach(() => {
+ window.jQuery = undefined
+ })
+
+ it('should return jQuery object when present', () => {
+ expect(Util.getjQuery()).toEqual(fakejQuery)
+ })
+
+ it('should not return jQuery object when present if data-bs-no-jquery', () => {
+ document.body.setAttribute('data-bs-no-jquery', '')
+
+ expect(window.jQuery).toEqual(fakejQuery)
+ expect(Util.getjQuery()).toBeNull()
+
+ document.body.removeAttribute('data-bs-no-jquery')
+ })
+
+ it('should not return jQuery if not present', () => {
+ window.jQuery = undefined
+ expect(Util.getjQuery()).toBeNull()
+ })
+ })
+
+ describe('onDOMContentLoaded', () => {
+ it('should execute callbacks when DOMContentLoaded is fired and should not add more than one listener', () => {
+ const spy = jasmine.createSpy()
+ const spy2 = jasmine.createSpy()
+
+ const spyAdd = spyOn(document, 'addEventListener').and.callThrough()
+ spyOnProperty(document, 'readyState').and.returnValue('loading')
+
+ Util.onDOMContentLoaded(spy)
+ Util.onDOMContentLoaded(spy2)
+
+ document.dispatchEvent(new Event('DOMContentLoaded', {
+ bubbles: true,
+ cancelable: true
+ }))
+
+ expect(spy).toHaveBeenCalled()
+ expect(spy2).toHaveBeenCalled()
+ expect(spyAdd).toHaveBeenCalledTimes(1)
+ })
+
+ it('should execute callback if readyState is not "loading"', () => {
+ const spy = jasmine.createSpy()
+ Util.onDOMContentLoaded(spy)
+ expect(spy).toHaveBeenCalled()
+ })
+ })
+
+ describe('defineJQueryPlugin', () => {
+ const fakejQuery = { fn: {} }
+
+ beforeEach(() => {
+ Object.defineProperty(window, 'jQuery', {
+ value: fakejQuery,
+ writable: true
+ })
+ })
+
+ afterEach(() => {
+ window.jQuery = undefined
+ })
+
+ it('should define a plugin on the jQuery instance', () => {
+ const pluginMock = Util.noop
+ pluginMock.NAME = 'test'
+ pluginMock.jQueryInterface = Util.noop
+
+ Util.defineJQueryPlugin(pluginMock)
+ expect(fakejQuery.fn.test).toEqual(pluginMock.jQueryInterface)
+ expect(fakejQuery.fn.test.Constructor).toEqual(pluginMock)
+ expect(fakejQuery.fn.test.noConflict).toEqual(jasmine.any(Function))
+ })
+ })
+
+ describe('execute', () => {
+ it('should execute if arg is function', () => {
+ const spy = jasmine.createSpy('spy')
+ Util.execute(spy)
+ expect(spy).toHaveBeenCalled()
+ })
+ })
+
+ describe('executeAfterTransition', () => {
+ it('should immediately execute a function when waitForTransition parameter is false', () => {
+ const el = document.createElement('div')
+ const callbackSpy = jasmine.createSpy('callback spy')
+ const eventListenerSpy = spyOn(el, 'addEventListener')
+
+ Util.executeAfterTransition(callbackSpy, el, false)
+
+ expect(callbackSpy).toHaveBeenCalled()
+ expect(eventListenerSpy).not.toHaveBeenCalled()
+ })
+
+ it('should execute a function when a transitionend event is dispatched', () => {
+ const el = document.createElement('div')
+ const callbackSpy = jasmine.createSpy('callback spy')
+
+ spyOn(window, 'getComputedStyle').and.returnValue({
+ transitionDuration: '0.05s',
+ transitionDelay: '0s'
+ })
+
+ Util.executeAfterTransition(callbackSpy, el)
+
+ el.dispatchEvent(new TransitionEvent('transitionend'))
+
+ expect(callbackSpy).toHaveBeenCalled()
+ })
+
+ it('should execute a function after a computed CSS transition duration and there was no transitionend event dispatched', () => {
+ return new Promise(resolve => {
+ const el = document.createElement('div')
+ const callbackSpy = jasmine.createSpy('callback spy')
+
+ spyOn(window, 'getComputedStyle').and.returnValue({
+ transitionDuration: '0.05s',
+ transitionDelay: '0s'
+ })
+
+ Util.executeAfterTransition(callbackSpy, el)
+
+ setTimeout(() => {
+ expect(callbackSpy).toHaveBeenCalled()
+ resolve()
+ }, 70)
+ })
+ })
+
+ it('should not execute a function a second time after a computed CSS transition duration and if a transitionend event has already been dispatched', () => {
+ return new Promise(resolve => {
+ const el = document.createElement('div')
+ const callbackSpy = jasmine.createSpy('callback spy')
+
+ spyOn(window, 'getComputedStyle').and.returnValue({
+ transitionDuration: '0.05s',
+ transitionDelay: '0s'
+ })
+
+ Util.executeAfterTransition(callbackSpy, el)
+
+ setTimeout(() => {
+ el.dispatchEvent(new TransitionEvent('transitionend'))
+ }, 50)
+
+ setTimeout(() => {
+ expect(callbackSpy).toHaveBeenCalledTimes(1)
+ resolve()
+ }, 70)
+ })
+ })
+
+ it('should not trigger a transitionend event if another transitionend event had already happened', () => {
+ return new Promise(resolve => {
+ const el = document.createElement('div')
+
+ spyOn(window, 'getComputedStyle').and.returnValue({
+ transitionDuration: '0.05s',
+ transitionDelay: '0s'
+ })
+
+ Util.executeAfterTransition(noop, el)
+
+ // simulate a event dispatched by the browser
+ el.dispatchEvent(new TransitionEvent('transitionend'))
+
+ const dispatchSpy = spyOn(el, 'dispatchEvent').and.callThrough()
+
+ setTimeout(() => {
+ // setTimeout should not have triggered another transitionend event.
+ expect(dispatchSpy).not.toHaveBeenCalled()
+ resolve()
+ }, 70)
+ })
+ })
+
+ it('should ignore transitionend events from nested elements', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="outer">',
+ ' <div class="nested"></div>',
+ '</div>'
+ ].join('')
+
+ const outer = fixtureEl.querySelector('.outer')
+ const nested = fixtureEl.querySelector('.nested')
+ const callbackSpy = jasmine.createSpy('callback spy')
+
+ spyOn(window, 'getComputedStyle').and.returnValue({
+ transitionDuration: '0.05s',
+ transitionDelay: '0s'
+ })
+
+ Util.executeAfterTransition(callbackSpy, outer)
+
+ nested.dispatchEvent(new TransitionEvent('transitionend', {
+ bubbles: true
+ }))
+
+ setTimeout(() => {
+ expect(callbackSpy).not.toHaveBeenCalled()
+ }, 20)
+
+ setTimeout(() => {
+ expect(callbackSpy).toHaveBeenCalled()
+ resolve()
+ }, 70)
+ })
+ })
+ })
+
+ describe('getNextActiveElement', () => {
+ it('should return first element if active not exists or not given and shouldGetNext is either true, or false with cycling being disabled', () => {
+ const array = ['a', 'b', 'c', 'd']
+
+ expect(Util.getNextActiveElement(array, '', true, true)).toEqual('a')
+ expect(Util.getNextActiveElement(array, 'g', true, true)).toEqual('a')
+ expect(Util.getNextActiveElement(array, '', true, false)).toEqual('a')
+ expect(Util.getNextActiveElement(array, 'g', true, false)).toEqual('a')
+ expect(Util.getNextActiveElement(array, '', false, false)).toEqual('a')
+ expect(Util.getNextActiveElement(array, 'g', false, false)).toEqual('a')
+ })
+
+ it('should return last element if active not exists or not given and shouldGetNext is false but cycling is enabled', () => {
+ const array = ['a', 'b', 'c', 'd']
+
+ expect(Util.getNextActiveElement(array, '', false, true)).toEqual('d')
+ expect(Util.getNextActiveElement(array, 'g', false, true)).toEqual('d')
+ })
+
+ it('should return next element or same if is last', () => {
+ const array = ['a', 'b', 'c', 'd']
+
+ expect(Util.getNextActiveElement(array, 'a', true, true)).toEqual('b')
+ expect(Util.getNextActiveElement(array, 'b', true, true)).toEqual('c')
+ expect(Util.getNextActiveElement(array, 'd', true, false)).toEqual('d')
+ })
+
+ it('should return next element or first, if is last and "isCycleAllowed = true"', () => {
+ const array = ['a', 'b', 'c', 'd']
+
+ expect(Util.getNextActiveElement(array, 'c', true, true)).toEqual('d')
+ expect(Util.getNextActiveElement(array, 'd', true, true)).toEqual('a')
+ })
+
+ it('should return previous element or same if is first', () => {
+ const array = ['a', 'b', 'c', 'd']
+
+ expect(Util.getNextActiveElement(array, 'b', false, true)).toEqual('a')
+ expect(Util.getNextActiveElement(array, 'd', false, true)).toEqual('c')
+ expect(Util.getNextActiveElement(array, 'a', false, false)).toEqual('a')
+ })
+
+ it('should return next element or first, if is last and "isCycleAllowed = true"', () => {
+ const array = ['a', 'b', 'c', 'd']
+
+ expect(Util.getNextActiveElement(array, 'd', false, true)).toEqual('c')
+ expect(Util.getNextActiveElement(array, 'a', false, true)).toEqual('d')
+ })
+ })
+})
diff --git a/js/tests/unit/util/sanitizer.spec.js b/js/tests/unit/util/sanitizer.spec.js
new file mode 100644
index 0000000..c656aed
--- /dev/null
+++ b/js/tests/unit/util/sanitizer.spec.js
@@ -0,0 +1,105 @@
+import { DefaultAllowlist, sanitizeHtml } from '../../../src/util/sanitizer'
+
+describe('Sanitizer', () => {
+ describe('sanitizeHtml', () => {
+ it('should return the same on empty string', () => {
+ const empty = ''
+
+ const result = sanitizeHtml(empty, DefaultAllowlist, null)
+
+ expect(result).toEqual(empty)
+ })
+
+ it('should sanitize template by removing tags with XSS', () => {
+ const template = [
+ '<div>',
+ ' <a href="javascript:alert(7)">Click me</a>',
+ ' <span>Some content</span>',
+ '</div>'
+ ].join('')
+
+ const result = sanitizeHtml(template, DefaultAllowlist, null)
+
+ expect(result).not.toContain('href="javascript:alert(7)')
+ })
+
+ it('should sanitize template and work with multiple regex', () => {
+ const template = [
+ '<div>',
+ ' <a href="javascript:alert(7)" aria-label="This is a link" data-foo="bar">Click me</a>',
+ ' <span>Some content</span>',
+ '</div>'
+ ].join('')
+
+ const myDefaultAllowList = DefaultAllowlist
+ // With the default allow list
+ let result = sanitizeHtml(template, myDefaultAllowList, null)
+
+ // `data-foo` won't be present
+ expect(result).not.toContain('data-foo="bar"')
+
+ // Add the following regex too
+ myDefaultAllowList['*'].push(/^data-foo/)
+
+ result = sanitizeHtml(template, myDefaultAllowList, null)
+
+ expect(result).not.toContain('href="javascript:alert(7)') // This is in the default list
+ expect(result).toContain('aria-label="This is a link"') // This is in the default list
+ expect(result).toContain('data-foo="bar"') // We explicitly allow this
+ })
+
+ it('should allow aria attributes and safe attributes', () => {
+ const template = [
+ '<div aria-pressed="true">',
+ ' <span class="test">Some content</span>',
+ '</div>'
+ ].join('')
+
+ const result = sanitizeHtml(template, DefaultAllowlist, null)
+
+ expect(result).toContain('aria-pressed')
+ expect(result).toContain('class="test"')
+ })
+
+ it('should remove tags not in allowlist', () => {
+ const template = [
+ '<div>',
+ ' <script>alert(7)</script>',
+ '</div>'
+ ].join('')
+
+ const result = sanitizeHtml(template, DefaultAllowlist, null)
+
+ expect(result).not.toContain('<script>')
+ })
+
+ it('should not use native api to sanitize if a custom function passed', () => {
+ const template = [
+ '<div>',
+ ' <span>Some content</span>',
+ '</div>'
+ ].join('')
+
+ function mySanitize(htmlUnsafe) {
+ return htmlUnsafe
+ }
+
+ const spy = spyOn(DOMParser.prototype, 'parseFromString')
+
+ const result = sanitizeHtml(template, DefaultAllowlist, mySanitize)
+
+ expect(result).toEqual(template)
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should allow multiple sanitation passes of the same template', () => {
+ const template = '<img src="test.jpg">'
+
+ const firstResult = sanitizeHtml(template, DefaultAllowlist, null)
+ const secondResult = sanitizeHtml(template, DefaultAllowlist, null)
+
+ expect(firstResult).toContain('src')
+ expect(secondResult).toContain('src')
+ })
+ })
+})
diff --git a/js/tests/unit/util/scrollbar.spec.js b/js/tests/unit/util/scrollbar.spec.js
new file mode 100644
index 0000000..6fcf571
--- /dev/null
+++ b/js/tests/unit/util/scrollbar.spec.js
@@ -0,0 +1,363 @@
+import { clearBodyAndDocument, clearFixture, getFixture } from '../../helpers/fixture'
+import Manipulator from '../../../src/dom/manipulator'
+import ScrollBarHelper from '../../../src/util/scrollbar'
+
+describe('ScrollBar', () => {
+ let fixtureEl
+ const doc = document.documentElement
+ const parseIntDecimal = arg => Number.parseInt(arg, 10)
+ const getPaddingX = el => parseIntDecimal(window.getComputedStyle(el).paddingRight)
+ const getMarginX = el => parseIntDecimal(window.getComputedStyle(el).marginRight)
+ const getOverFlow = el => el.style.overflow
+ const getPaddingAttr = el => Manipulator.getDataAttribute(el, 'padding-right')
+ const getMarginAttr = el => Manipulator.getDataAttribute(el, 'margin-right')
+ const getOverFlowAttr = el => Manipulator.getDataAttribute(el, 'overflow')
+ const windowCalculations = () => {
+ return {
+ htmlClient: document.documentElement.clientWidth,
+ htmlOffset: document.documentElement.offsetWidth,
+ docClient: document.body.clientWidth,
+ htmlBound: document.documentElement.getBoundingClientRect().width,
+ bodyBound: document.body.getBoundingClientRect().width,
+ window: window.innerWidth,
+ width: Math.abs(window.innerWidth - document.documentElement.clientWidth)
+ }
+ }
+
+ // iOS, Android devices and macOS browsers hide scrollbar by default and show it only while scrolling.
+ // So the tests for scrollbar would fail
+ const isScrollBarHidden = () => {
+ const calc = windowCalculations()
+ return calc.htmlClient === calc.htmlOffset && calc.htmlClient === calc.window
+ }
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ // custom fixture to avoid extreme style values
+ fixtureEl.removeAttribute('style')
+ })
+
+ afterAll(() => {
+ fixtureEl.remove()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ clearBodyAndDocument()
+ })
+
+ beforeEach(() => {
+ clearBodyAndDocument()
+ })
+
+ describe('isBodyOverflowing', () => {
+ it('should return true if body is overflowing', () => {
+ document.documentElement.style.overflowY = 'scroll'
+ document.body.style.overflowY = 'scroll'
+ fixtureEl.innerHTML = '<div style="height: 110vh; width: 100%"></div>'
+ const result = new ScrollBarHelper().isOverflowing()
+
+ if (isScrollBarHidden()) {
+ expect(result).toBeFalse()
+ } else {
+ expect(result).toBeTrue()
+ }
+ })
+
+ it('should return false if body is not overflowing', () => {
+ doc.style.overflowY = 'hidden'
+ document.body.style.overflowY = 'hidden'
+ fixtureEl.innerHTML = '<div style="height: 110vh; width: 100%"></div>'
+ const scrollBar = new ScrollBarHelper()
+ const result = scrollBar.isOverflowing()
+
+ expect(result).toBeFalse()
+ })
+ })
+
+ describe('getWidth', () => {
+ it('should return an integer greater than zero, if body is overflowing', () => {
+ doc.style.overflowY = 'scroll'
+ document.body.style.overflowY = 'scroll'
+ fixtureEl.innerHTML = '<div style="height: 110vh; width: 100%"></div>'
+ const result = new ScrollBarHelper().getWidth()
+
+ if (isScrollBarHidden()) {
+ expect(result).toEqual(0)
+ } else {
+ expect(result).toBeGreaterThan(1)
+ }
+ })
+
+ it('should return 0 if body is not overflowing', () => {
+ document.documentElement.style.overflowY = 'hidden'
+ document.body.style.overflowY = 'hidden'
+ fixtureEl.innerHTML = '<div style="height: 110vh; width: 100%"></div>'
+
+ const result = new ScrollBarHelper().getWidth()
+
+ expect(result).toEqual(0)
+ })
+ })
+
+ describe('hide - reset', () => {
+ it('should adjust the inline padding of fixed elements which are full-width', () => {
+ fixtureEl.innerHTML = [
+ '<div style="height: 110vh; width: 100%">',
+ ' <div class="fixed-top" id="fixed1" style="padding-right: 0px; width: 100vw"></div>',
+ ' <div class="fixed-top" id="fixed2" style="padding-right: 5px; width: 100vw"></div>',
+ '</div>'
+ ].join('')
+ doc.style.overflowY = 'scroll'
+
+ const fixedEl = fixtureEl.querySelector('#fixed1')
+ const fixedEl2 = fixtureEl.querySelector('#fixed2')
+ const originalPadding = getPaddingX(fixedEl)
+ const originalPadding2 = getPaddingX(fixedEl2)
+ const scrollBar = new ScrollBarHelper()
+ const expectedPadding = originalPadding + scrollBar.getWidth()
+ const expectedPadding2 = originalPadding2 + scrollBar.getWidth()
+
+ scrollBar.hide()
+
+ let currentPadding = getPaddingX(fixedEl)
+ let currentPadding2 = getPaddingX(fixedEl2)
+ expect(getPaddingAttr(fixedEl)).toEqual(`${originalPadding}px`)
+ expect(getPaddingAttr(fixedEl2)).toEqual(`${originalPadding2}px`)
+ expect(currentPadding).toEqual(expectedPadding)
+ expect(currentPadding2).toEqual(expectedPadding2)
+
+ scrollBar.reset()
+ currentPadding = getPaddingX(fixedEl)
+ currentPadding2 = getPaddingX(fixedEl2)
+ expect(getPaddingAttr(fixedEl)).toBeNull()
+ expect(getPaddingAttr(fixedEl2)).toBeNull()
+ expect(currentPadding).toEqual(originalPadding)
+ expect(currentPadding2).toEqual(originalPadding2)
+ })
+
+ it('should remove padding & margin if not existed before adjustment', () => {
+ fixtureEl.innerHTML = [
+ '<div style="height: 110vh; width: 100%">',
+ ' <div class="fixed" id="fixed" style="width: 100vw;"></div>',
+ ' <div class="sticky-top" id="sticky" style=" width: 100vw;"></div>',
+ '</div>'
+ ].join('')
+ doc.style.overflowY = 'scroll'
+
+ const fixedEl = fixtureEl.querySelector('#fixed')
+ const stickyEl = fixtureEl.querySelector('#sticky')
+ const scrollBar = new ScrollBarHelper()
+
+ scrollBar.hide()
+ scrollBar.reset()
+
+ expect(fixedEl.getAttribute('style').includes('padding-right')).toBeFalse()
+ expect(stickyEl.getAttribute('style').includes('margin-right')).toBeFalse()
+ })
+
+ it('should adjust the inline margin and padding of sticky elements', () => {
+ fixtureEl.innerHTML = [
+ '<div style="height: 110vh">',
+ ' <div class="sticky-top" style="margin-right: 10px; padding-right: 20px; width: 100vw; height: 10px"></div>',
+ '</div>'
+ ].join('')
+ doc.style.overflowY = 'scroll'
+
+ const stickyTopEl = fixtureEl.querySelector('.sticky-top')
+ const originalMargin = getMarginX(stickyTopEl)
+ const originalPadding = getPaddingX(stickyTopEl)
+ const scrollBar = new ScrollBarHelper()
+ const expectedMargin = originalMargin - scrollBar.getWidth()
+ const expectedPadding = originalPadding + scrollBar.getWidth()
+ scrollBar.hide()
+
+ expect(getMarginAttr(stickyTopEl)).toEqual(`${originalMargin}px`)
+ expect(getMarginX(stickyTopEl)).toEqual(expectedMargin)
+ expect(getPaddingAttr(stickyTopEl)).toEqual(`${originalPadding}px`)
+ expect(getPaddingX(stickyTopEl)).toEqual(expectedPadding)
+
+ scrollBar.reset()
+ expect(getMarginAttr(stickyTopEl)).toBeNull()
+ expect(getMarginX(stickyTopEl)).toEqual(originalMargin)
+ expect(getPaddingAttr(stickyTopEl)).toBeNull()
+ expect(getPaddingX(stickyTopEl)).toEqual(originalPadding)
+ })
+
+ it('should not adjust the inline margin and padding of sticky and fixed elements when element do not have full width', () => {
+ fixtureEl.innerHTML = '<div class="sticky-top" style="margin-right: 0px; padding-right: 0px; width: 50vw"></div>'
+
+ const stickyTopEl = fixtureEl.querySelector('.sticky-top')
+ const originalMargin = getMarginX(stickyTopEl)
+ const originalPadding = getPaddingX(stickyTopEl)
+
+ const scrollBar = new ScrollBarHelper()
+ scrollBar.hide()
+
+ const currentMargin = getMarginX(stickyTopEl)
+ const currentPadding = getPaddingX(stickyTopEl)
+
+ expect(currentMargin).toEqual(originalMargin)
+ expect(currentPadding).toEqual(originalPadding)
+
+ scrollBar.reset()
+ })
+
+ it('should not put data-attribute if element doesn\'t have the proper style property, should just remove style property if element didn\'t had one', () => {
+ fixtureEl.innerHTML = [
+ '<div style="height: 110vh; width: 100%">',
+ ' <div class="sticky-top" id="sticky" style="width: 100vw"></div>',
+ '</div>'
+ ].join('')
+
+ document.body.style.overflowY = 'scroll'
+ const scrollBar = new ScrollBarHelper()
+
+ const hasPaddingAttr = el => el.hasAttribute('data-bs-padding-right')
+ const hasMarginAttr = el => el.hasAttribute('data-bs-margin-right')
+ const stickyEl = fixtureEl.querySelector('#sticky')
+ const originalPadding = getPaddingX(stickyEl)
+ const originalMargin = getMarginX(stickyEl)
+ const scrollBarWidth = scrollBar.getWidth()
+
+ scrollBar.hide()
+
+ expect(getPaddingX(stickyEl)).toEqual(scrollBarWidth + originalPadding)
+ const expectedMargin = scrollBarWidth + originalMargin
+ expect(getMarginX(stickyEl)).toEqual(expectedMargin === 0 ? expectedMargin : -expectedMargin)
+ expect(hasMarginAttr(stickyEl)).toBeFalse() // We do not have to keep css margin
+ expect(hasPaddingAttr(stickyEl)).toBeFalse() // We do not have to keep css padding
+
+ scrollBar.reset()
+
+ expect(getPaddingX(stickyEl)).toEqual(originalPadding)
+ expect(getPaddingX(stickyEl)).toEqual(originalPadding)
+ })
+
+ describe('Body Handling', () => {
+ it('should ignore other inline styles when trying to restore body defaults ', () => {
+ document.body.style.color = 'red'
+
+ const scrollBar = new ScrollBarHelper()
+ const scrollBarWidth = scrollBar.getWidth()
+ scrollBar.hide()
+
+ expect(getPaddingX(document.body)).toEqual(scrollBarWidth)
+ expect(document.body.style.color).toEqual('red')
+
+ scrollBar.reset()
+ })
+
+ it('should hide scrollbar and reset it to its initial value', () => {
+ const styleSheetPadding = '7px'
+ fixtureEl.innerHTML = [
+ '<style>',
+ ' body {',
+ ` padding-right: ${styleSheetPadding}`,
+ ' }',
+ '</style>'
+ ].join('')
+
+ const el = document.body
+ const inlineStylePadding = '10px'
+ el.style.paddingRight = inlineStylePadding
+
+ const originalPadding = getPaddingX(el)
+ expect(originalPadding).toEqual(parseIntDecimal(inlineStylePadding)) // Respect only the inline style as it has prevails this of css
+ const originalOverFlow = 'auto'
+ el.style.overflow = originalOverFlow
+ const scrollBar = new ScrollBarHelper()
+ const scrollBarWidth = scrollBar.getWidth()
+
+ scrollBar.hide()
+
+ const currentPadding = getPaddingX(el)
+
+ expect(currentPadding).toEqual(scrollBarWidth + originalPadding)
+ expect(currentPadding).toEqual(scrollBarWidth + parseIntDecimal(inlineStylePadding))
+ expect(getPaddingAttr(el)).toEqual(inlineStylePadding)
+ expect(getOverFlow(el)).toEqual('hidden')
+ expect(getOverFlowAttr(el)).toEqual(originalOverFlow)
+
+ scrollBar.reset()
+
+ const currentPadding1 = getPaddingX(el)
+ expect(currentPadding1).toEqual(originalPadding)
+ expect(getPaddingAttr(el)).toBeNull()
+ expect(getOverFlow(el)).toEqual(originalOverFlow)
+ expect(getOverFlowAttr(el)).toBeNull()
+ })
+
+ it('should hide scrollbar and reset it to its initial value - respecting css rules', () => {
+ const styleSheetPadding = '7px'
+ fixtureEl.innerHTML = [
+ '<style>',
+ ' body {',
+ ` padding-right: ${styleSheetPadding}`,
+ ' }',
+ '</style>'
+ ].join('')
+ const el = document.body
+ const originalPadding = getPaddingX(el)
+ const originalOverFlow = 'scroll'
+ el.style.overflow = originalOverFlow
+ const scrollBar = new ScrollBarHelper()
+ const scrollBarWidth = scrollBar.getWidth()
+
+ scrollBar.hide()
+
+ const currentPadding = getPaddingX(el)
+
+ expect(currentPadding).toEqual(scrollBarWidth + originalPadding)
+ expect(currentPadding).toEqual(scrollBarWidth + parseIntDecimal(styleSheetPadding))
+ expect(getPaddingAttr(el)).toBeNull() // We do not have to keep css padding
+ expect(getOverFlow(el)).toEqual('hidden')
+ expect(getOverFlowAttr(el)).toEqual(originalOverFlow)
+
+ scrollBar.reset()
+
+ const currentPadding1 = getPaddingX(el)
+ expect(currentPadding1).toEqual(originalPadding)
+ expect(getPaddingAttr(el)).toBeNull()
+ expect(getOverFlow(el)).toEqual(originalOverFlow)
+ expect(getOverFlowAttr(el)).toBeNull()
+ })
+
+ it('should not adjust the inline body padding when it does not overflow', () => {
+ const originalPadding = getPaddingX(document.body)
+ const scrollBar = new ScrollBarHelper()
+
+ // Hide scrollbars to prevent the body overflowing
+ doc.style.overflowY = 'hidden'
+ doc.style.paddingRight = '0px'
+
+ scrollBar.hide()
+ const currentPadding = getPaddingX(document.body)
+
+ expect(currentPadding).toEqual(originalPadding)
+ scrollBar.reset()
+ })
+
+ it('should not adjust the inline body padding when it does not overflow, even on a scaled display', () => {
+ const originalPadding = getPaddingX(document.body)
+ const scrollBar = new ScrollBarHelper()
+ // Remove body margins as would be done by Bootstrap css
+ document.body.style.margin = '0'
+
+ // Hide scrollbars to prevent the body overflowing
+ doc.style.overflowY = 'hidden'
+
+ // Simulate a discrepancy between exact, i.e. floating point body width, and rounded body width
+ // as it can occur when zooming or scaling the display to something else than 100%
+ doc.style.paddingRight = '.48px'
+ scrollBar.hide()
+
+ const currentPadding = getPaddingX(document.body)
+
+ expect(currentPadding).toEqual(originalPadding)
+
+ scrollBar.reset()
+ })
+ })
+ })
+})
diff --git a/js/tests/unit/util/swipe.spec.js b/js/tests/unit/util/swipe.spec.js
new file mode 100644
index 0000000..f92bb5d
--- /dev/null
+++ b/js/tests/unit/util/swipe.spec.js
@@ -0,0 +1,291 @@
+import { clearFixture, getFixture } from '../../helpers/fixture'
+import EventHandler from '../../../src/dom/event-handler'
+import Swipe from '../../../src/util/swipe'
+import { noop } from '../../../src/util'
+
+describe('Swipe', () => {
+ const { Simulator, PointerEvent } = window
+ const originWinPointerEvent = PointerEvent
+ const supportPointerEvent = Boolean(PointerEvent)
+
+ let fixtureEl
+ let swipeEl
+ const clearPointerEvents = () => {
+ window.PointerEvent = null
+ }
+
+ const restorePointerEvents = () => {
+ window.PointerEvent = originWinPointerEvent
+ }
+
+ // The headless browser does not support touch events, so we need to fake it
+ // in order to test that touch events are added properly
+ const defineDocumentElementOntouchstart = () => {
+ document.documentElement.ontouchstart = noop
+ }
+
+ const deleteDocumentElementOntouchstart = () => {
+ delete document.documentElement.ontouchstart
+ }
+
+ const mockSwipeGesture = (element, options = {}, type = 'touch') => {
+ Simulator.setType(type)
+ const _options = { deltaX: 0, deltaY: 0, ...options }
+
+ Simulator.gestures.swipe(element, _options)
+ }
+
+ beforeEach(() => {
+ fixtureEl = getFixture()
+ const cssStyle = [
+ '<style>',
+ ' #fixture .pointer-event {',
+ ' touch-action: pan-y;',
+ ' }',
+ ' #fixture div {',
+ ' width: 300px;',
+ ' height: 300px;',
+ ' }',
+ '</style>'
+ ].join('')
+
+ fixtureEl.innerHTML = `<div id="swipeEl"></div>${cssStyle}`
+ swipeEl = fixtureEl.querySelector('div')
+ })
+
+ afterEach(() => {
+ clearFixture()
+ deleteDocumentElementOntouchstart()
+ })
+
+ describe('constructor', () => {
+ it('should add touch event listeners by default', () => {
+ defineDocumentElementOntouchstart()
+
+ spyOn(Swipe.prototype, '_initEvents').and.callThrough()
+ const swipe = new Swipe(swipeEl)
+ expect(swipe._initEvents).toHaveBeenCalled()
+ })
+
+ it('should not add touch event listeners if touch is not supported', () => {
+ spyOn(Swipe, 'isSupported').and.returnValue(false)
+
+ spyOn(Swipe.prototype, '_initEvents').and.callThrough()
+ const swipe = new Swipe(swipeEl)
+
+ expect(swipe._initEvents).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Config', () => {
+ it('Test leftCallback', () => {
+ return new Promise(resolve => {
+ const spyRight = jasmine.createSpy('spy')
+ clearPointerEvents()
+ defineDocumentElementOntouchstart()
+ // eslint-disable-next-line no-new
+ new Swipe(swipeEl, {
+ leftCallback() {
+ expect(spyRight).not.toHaveBeenCalled()
+ restorePointerEvents()
+ resolve()
+ },
+ rightCallback: spyRight
+ })
+
+ mockSwipeGesture(swipeEl, {
+ pos: [300, 10],
+ deltaX: -300
+ })
+ })
+ })
+
+ it('Test rightCallback', () => {
+ return new Promise(resolve => {
+ const spyLeft = jasmine.createSpy('spy')
+ clearPointerEvents()
+ defineDocumentElementOntouchstart()
+ // eslint-disable-next-line no-new
+ new Swipe(swipeEl, {
+ rightCallback() {
+ expect(spyLeft).not.toHaveBeenCalled()
+ restorePointerEvents()
+ resolve()
+ },
+ leftCallback: spyLeft
+ })
+
+ mockSwipeGesture(swipeEl, {
+ pos: [10, 10],
+ deltaX: 300
+ })
+ })
+ })
+
+ it('Test endCallback', () => {
+ return new Promise(resolve => {
+ clearPointerEvents()
+ defineDocumentElementOntouchstart()
+ let isFirstTime = true
+
+ const callback = () => {
+ if (isFirstTime) {
+ isFirstTime = false
+ return
+ }
+
+ expect().nothing()
+ restorePointerEvents()
+ resolve()
+ }
+
+ // eslint-disable-next-line no-new
+ new Swipe(swipeEl, {
+ endCallback: callback
+ })
+ mockSwipeGesture(swipeEl, {
+ pos: [10, 10],
+ deltaX: 300
+ })
+
+ mockSwipeGesture(swipeEl, {
+ pos: [300, 10],
+ deltaX: -300
+ })
+ })
+ })
+ })
+
+ describe('Functionality on PointerEvents', () => {
+ it('should not allow pinch with touch events', () => {
+ Simulator.setType('touch')
+ clearPointerEvents()
+ deleteDocumentElementOntouchstart()
+
+ const swipe = new Swipe(swipeEl)
+ const spy = spyOn(swipe, '_handleSwipe')
+
+ mockSwipeGesture(swipeEl, {
+ pos: [300, 10],
+ deltaX: -300,
+ deltaY: 0,
+ touches: 2
+ })
+
+ restorePointerEvents()
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should allow swipeRight and call "rightCallback" with pointer events', () => {
+ return new Promise(resolve => {
+ if (!supportPointerEvent) {
+ expect().nothing()
+ resolve()
+ return
+ }
+
+ const style = '#fixture .pointer-event { touch-action: none !important; }'
+ fixtureEl.innerHTML += style
+
+ defineDocumentElementOntouchstart()
+ // eslint-disable-next-line no-new
+ new Swipe(swipeEl, {
+ rightCallback() {
+ deleteDocumentElementOntouchstart()
+ expect().nothing()
+ resolve()
+ }
+ })
+
+ mockSwipeGesture(swipeEl, { deltaX: 300 }, 'pointer')
+ })
+ })
+
+ it('should allow swipeLeft and call "leftCallback" with pointer events', () => {
+ return new Promise(resolve => {
+ if (!supportPointerEvent) {
+ expect().nothing()
+ resolve()
+ return
+ }
+
+ const style = '#fixture .pointer-event { touch-action: none !important; }'
+ fixtureEl.innerHTML += style
+
+ defineDocumentElementOntouchstart()
+ // eslint-disable-next-line no-new
+ new Swipe(swipeEl, {
+ leftCallback() {
+ expect().nothing()
+ deleteDocumentElementOntouchstart()
+ resolve()
+ }
+ })
+
+ mockSwipeGesture(swipeEl, {
+ pos: [300, 10],
+ deltaX: -300
+ }, 'pointer')
+ })
+ })
+ })
+
+ describe('Dispose', () => {
+ it('should call EventHandler.off', () => {
+ defineDocumentElementOntouchstart()
+ spyOn(EventHandler, 'off').and.callThrough()
+ const swipe = new Swipe(swipeEl)
+
+ swipe.dispose()
+ expect(EventHandler.off).toHaveBeenCalledWith(swipeEl, '.bs.swipe')
+ })
+
+ it('should destroy', () => {
+ const addEventSpy = spyOn(fixtureEl, 'addEventListener').and.callThrough()
+ const removeEventSpy = spyOn(EventHandler, 'off').and.callThrough()
+ defineDocumentElementOntouchstart()
+
+ const swipe = new Swipe(fixtureEl)
+
+ const expectedArgs =
+ swipe._supportPointerEvents ?
+ [
+ ['pointerdown', jasmine.any(Function), jasmine.any(Boolean)],
+ ['pointerup', jasmine.any(Function), jasmine.any(Boolean)]
+ ] :
+ [
+ ['touchstart', jasmine.any(Function), jasmine.any(Boolean)],
+ ['touchmove', jasmine.any(Function), jasmine.any(Boolean)],
+ ['touchend', jasmine.any(Function), jasmine.any(Boolean)]
+ ]
+
+ expect(addEventSpy.calls.allArgs()).toEqual(expectedArgs)
+
+ swipe.dispose()
+
+ expect(removeEventSpy).toHaveBeenCalledWith(fixtureEl, '.bs.swipe')
+ deleteDocumentElementOntouchstart()
+ })
+ })
+
+ describe('"isSupported" static', () => {
+ it('should return "true" if "touchstart" exists in document element)', () => {
+ Object.defineProperty(window.navigator, 'maxTouchPoints', () => 0)
+ defineDocumentElementOntouchstart()
+
+ expect(Swipe.isSupported()).toBeTrue()
+ })
+
+ it('should return "false" if "touchstart" not exists in document element and "navigator.maxTouchPoints" are zero (0)', () => {
+ Object.defineProperty(window.navigator, 'maxTouchPoints', () => 0)
+ deleteDocumentElementOntouchstart()
+
+ if ('ontouchstart' in document.documentElement) {
+ expect().nothing()
+ return
+ }
+
+ expect(Swipe.isSupported()).toBeFalse()
+ })
+ })
+})
diff --git a/js/tests/unit/util/template-factory.spec.js b/js/tests/unit/util/template-factory.spec.js
new file mode 100644
index 0000000..5e5724c
--- /dev/null
+++ b/js/tests/unit/util/template-factory.spec.js
@@ -0,0 +1,306 @@
+import { clearFixture, getFixture } from '../../helpers/fixture'
+import TemplateFactory from '../../../src/util/template-factory'
+
+describe('TemplateFactory', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('NAME', () => {
+ it('should return plugin NAME', () => {
+ expect(TemplateFactory.NAME).toEqual('TemplateFactory')
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin default config', () => {
+ expect(TemplateFactory.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('toHtml', () => {
+ describe('Sanitization', () => {
+ it('should use "sanitizeHtml" to sanitize template', () => {
+ const factory = new TemplateFactory({
+ sanitize: true,
+ template: '<div><a href="javascript:alert(7)">Click me</a></div>'
+ })
+ const spy = spyOn(factory, '_maybeSanitize').and.callThrough()
+
+ expect(factory.toHtml().innerHTML).not.toContain('href="javascript:alert(7)')
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should not sanitize template', () => {
+ const factory = new TemplateFactory({
+ sanitize: false,
+ template: '<div><a href="javascript:alert(7)">Click me</a></div>'
+ })
+ const spy = spyOn(factory, '_maybeSanitize').and.callThrough()
+
+ expect(factory.toHtml().innerHTML).toContain('href="javascript:alert(7)')
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should use "sanitizeHtml" to sanitize content', () => {
+ const factory = new TemplateFactory({
+ sanitize: true,
+ html: true,
+ template: '<div id="foo"></div>',
+ content: { '#foo': '<a href="javascript:alert(7)">Click me</a>' }
+ })
+ expect(factory.toHtml().innerHTML).not.toContain('href="javascript:alert(7)')
+ })
+
+ it('should not sanitize content', () => {
+ const factory = new TemplateFactory({
+ sanitize: false,
+ html: true,
+ template: '<div id="foo"></div>',
+ content: { '#foo': '<a href="javascript:alert(7)">Click me</a>' }
+ })
+ expect(factory.toHtml().innerHTML).toContain('href="javascript:alert(7)')
+ })
+
+ it('should sanitize content only if "config.html" is enabled', () => {
+ const factory = new TemplateFactory({
+ sanitize: true,
+ html: false,
+ template: '<div id="foo"></div>',
+ content: { '#foo': '<a href="javascript:alert(7)">Click me</a>' }
+ })
+ const spy = spyOn(factory, '_maybeSanitize').and.callThrough()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Extra Class', () => {
+ it('should add extra class', () => {
+ const factory = new TemplateFactory({
+ extraClass: 'testClass'
+ })
+ expect(factory.toHtml()).toHaveClass('testClass')
+ })
+
+ it('should add extra classes', () => {
+ const factory = new TemplateFactory({
+ extraClass: 'testClass testClass2'
+ })
+ expect(factory.toHtml()).toHaveClass('testClass')
+ expect(factory.toHtml()).toHaveClass('testClass2')
+ })
+
+ it('should resolve class if function is given', () => {
+ const factory = new TemplateFactory({
+ extraClass(arg) {
+ expect(arg).toEqual(factory)
+ return 'testClass'
+ }
+ })
+
+ expect(factory.toHtml()).toHaveClass('testClass')
+ })
+ })
+ })
+
+ describe('Content', () => {
+ it('add simple text content', () => {
+ const template = [
+ '<div>',
+ ' <div class="foo"></div>',
+ ' <div class="foo2"></div>',
+ '</div>'
+ ].join('')
+
+ const factory = new TemplateFactory({
+ template,
+ content: {
+ '.foo': 'bar',
+ '.foo2': 'bar2'
+ }
+ })
+
+ const html = factory.toHtml()
+ expect(html.querySelector('.foo').textContent).toEqual('bar')
+ expect(html.querySelector('.foo2').textContent).toEqual('bar2')
+ })
+
+ it('should not fill template if selector not exists', () => {
+ const factory = new TemplateFactory({
+ sanitize: true,
+ html: true,
+ template: '<div id="foo"></div>',
+ content: { '#bar': 'test' }
+ })
+
+ expect(factory.toHtml().outerHTML).toEqual('<div id="foo"></div>')
+ })
+
+ it('should remove template selector, if content is null', () => {
+ const factory = new TemplateFactory({
+ sanitize: true,
+ html: true,
+ template: '<div><div id="foo"></div></div>',
+ content: { '#foo': null }
+ })
+
+ expect(factory.toHtml().outerHTML).toEqual('<div></div>')
+ })
+
+ it('should resolve content if is function', () => {
+ const factory = new TemplateFactory({
+ sanitize: true,
+ html: true,
+ template: '<div><div id="foo"></div></div>',
+ content: { '#foo': () => null }
+ })
+
+ expect(factory.toHtml().outerHTML).toEqual('<div></div>')
+ })
+
+ it('if content is element and "config.html=false", should put content\'s textContent', () => {
+ fixtureEl.innerHTML = '<div>foo<span>bar</span></div>'
+ const contentElement = fixtureEl.querySelector('div')
+
+ const factory = new TemplateFactory({
+ html: false,
+ template: '<div><div id="foo"></div></div>',
+ content: { '#foo': contentElement }
+ })
+
+ const fooEl = factory.toHtml().querySelector('#foo')
+ expect(fooEl.innerHTML).not.toEqual(contentElement.innerHTML)
+ expect(fooEl.textContent).toEqual(contentElement.textContent)
+ expect(fooEl.textContent).toEqual('foobar')
+ })
+
+ it('if content is element and "config.html=true", should put content\'s outerHtml as child', () => {
+ fixtureEl.innerHTML = '<div>foo<span>bar</span></div>'
+ const contentElement = fixtureEl.querySelector('div')
+
+ const factory = new TemplateFactory({
+ html: true,
+ template: '<div><div id="foo"></div></div>',
+ content: { '#foo': contentElement }
+ })
+
+ const fooEl = factory.toHtml().querySelector('#foo')
+ expect(fooEl.innerHTML).toEqual(contentElement.outerHTML)
+ expect(fooEl.textContent).toEqual(contentElement.textContent)
+ })
+ })
+
+ describe('getContent', () => {
+ it('should get content as array', () => {
+ const factory = new TemplateFactory({
+ content: {
+ '.foo': 'bar',
+ '.foo2': 'bar2'
+ }
+ })
+ expect(factory.getContent()).toEqual(['bar', 'bar2'])
+ })
+
+ it('should filter empties', () => {
+ const factory = new TemplateFactory({
+ content: {
+ '.foo': 'bar',
+ '.foo2': '',
+ '.foo3': null,
+ '.foo4': () => 2,
+ '.foo5': () => null
+ }
+ })
+ expect(factory.getContent()).toEqual(['bar', 2])
+ })
+ })
+
+ describe('hasContent', () => {
+ it('should return true, if it has', () => {
+ const factory = new TemplateFactory({
+ content: {
+ '.foo': 'bar',
+ '.foo2': 'bar2',
+ '.foo3': ''
+ }
+ })
+ expect(factory.hasContent()).toBeTrue()
+ })
+
+ it('should return false, if filtered content is empty', () => {
+ const factory = new TemplateFactory({
+ content: {
+ '.foo2': '',
+ '.foo3': null,
+ '.foo4': () => null
+ }
+ })
+ expect(factory.hasContent()).toBeFalse()
+ })
+ })
+
+ describe('changeContent', () => {
+ it('should change Content', () => {
+ const template = [
+ '<div>',
+ ' <div class="foo"></div>',
+ ' <div class="foo2"></div>',
+ '</div>'
+ ].join('')
+
+ const factory = new TemplateFactory({
+ template,
+ content: {
+ '.foo': 'bar',
+ '.foo2': 'bar2'
+ }
+ })
+
+ const html = selector => factory.toHtml().querySelector(selector).textContent
+ expect(html('.foo')).toEqual('bar')
+ expect(html('.foo2')).toEqual('bar2')
+ factory.changeContent({
+ '.foo': 'test',
+ '.foo2': 'test2'
+ })
+
+ expect(html('.foo')).toEqual('test')
+ expect(html('.foo2')).toEqual('test2')
+ })
+
+ it('should change only the given, content', () => {
+ const template = [
+ '<div>',
+ ' <div class="foo"></div>',
+ ' <div class="foo2"></div>',
+ '</div>'
+ ].join('')
+
+ const factory = new TemplateFactory({
+ template,
+ content: {
+ '.foo': 'bar',
+ '.foo2': 'bar2'
+ }
+ })
+
+ const html = selector => factory.toHtml().querySelector(selector).textContent
+ expect(html('.foo')).toEqual('bar')
+ expect(html('.foo2')).toEqual('bar2')
+ factory.changeContent({
+ '.foo': 'test',
+ '.wrong': 'wrong'
+ })
+
+ expect(html('.foo')).toEqual('test')
+ expect(html('.foo2')).toEqual('bar2')
+ })
+ })
+})
diff --git a/js/tests/visual/.eslintrc.json b/js/tests/visual/.eslintrc.json
new file mode 100644
index 0000000..8a33225
--- /dev/null
+++ b/js/tests/visual/.eslintrc.json
@@ -0,0 +1,19 @@
+{
+ "plugins": [
+ "html"
+ ],
+ "extends": "../../../.eslintrc.json",
+ "parserOptions": {
+ "sourceType": "module"
+ },
+ "settings": {
+ "html/html-extensions": [
+ ".html"
+ ]
+ },
+ "rules": {
+ "no-console": "off",
+ "no-new": "off",
+ "unicorn/no-array-for-each": "off"
+ }
+}
diff --git a/js/tests/visual/alert.html b/js/tests/visual/alert.html
new file mode 100644
index 0000000..2433b9e
--- /dev/null
+++ b/js/tests/visual/alert.html
@@ -0,0 +1,48 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link href="../../../dist/css/bootstrap.min.css" rel="stylesheet">
+ <title>Alert</title>
+ </head>
+ <body>
+ <div class="container">
+ <h1>Alert <small>Bootstrap Visual Test</small></h1>
+
+ <div class="alert alert-warning alert-dismissible fade show" role="alert">
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+ <strong>Holy guacamole!</strong> You should check in on some of those fields below.
+ </div>
+
+ <div class="alert alert-danger alert-dismissible fade show" role="alert">
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+ <p>
+ <strong>Oh snap!</strong> <a href="#" class="alert-link">Change a few things up</a> and try submitting again.
+ </p>
+ <p>
+ <button type="button" class="btn btn-danger">Danger</button>
+ <button type="button" class="btn btn-secondary">Secondary</button>
+ </p>
+ </div>
+
+ <div class="alert alert-danger alert-dismissible fade show" role="alert">
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+ <p>
+ <strong>Oh snap!</strong> <a href="#" class="alert-link">Change a few things up</a> and try submitting again. Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Cras mattis consectetur purus sit amet fermentum.
+ </p>
+ <p>
+ <button type="button" class="btn btn-danger">Take this action</button>
+ <button type="button" class="btn btn-primary">Or do this</button>
+ </p>
+ </div>
+
+ <div class="alert alert-warning alert-dismissible fade show" role="alert" style="transition-duration: 5s;">
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+ This alert will take 5 seconds to fade out.
+ </div>
+ </div>
+
+ <script src="../../../dist/js/bootstrap.bundle.js"></script>
+ </body>
+</html>
diff --git a/js/tests/visual/button.html b/js/tests/visual/button.html
new file mode 100644
index 0000000..0c54934
--- /dev/null
+++ b/js/tests/visual/button.html
@@ -0,0 +1,49 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link href="../../../dist/css/bootstrap.min.css" rel="stylesheet">
+ <title>Button</title>
+ </head>
+ <body>
+ <div class="container">
+ <h1>Button <small>Bootstrap Visual Test</small></h1>
+
+ <button type="button" class="btn btn-primary" data-bs-toggle="button" aria-pressed="false" autocomplete="off">
+ Single toggle
+ </button>
+
+ <p>For checkboxes and radio buttons, ensure that keyboard behavior is functioning correctly.</p>
+ <p>Navigate to the checkboxes with the keyboard (generally, using <kbd>TAB</kbd> / <kbd>SHIFT + TAB</kbd>), and ensure that <kbd>SPACE</kbd> toggles the currently focused checkbox. Click on one of the checkboxes using the mouse, ensure that focus was correctly set on the actual checkbox, and that <kbd>SPACE</kbd> toggles the checkbox again.</p>
+
+ <div class="btn-group" data-bs-toggle="buttons">
+ <label class="btn btn-primary active">
+ <input type="checkbox" checked autocomplete="off"> Checkbox 1 (pre-checked)
+ </label>
+ <label class="btn btn-primary">
+ <input type="checkbox" autocomplete="off"> Checkbox 2
+ </label>
+ <label class="btn btn-primary">
+ <input type="checkbox" autocomplete="off"> Checkbox 3
+ </label>
+ </div>
+
+ <p>Navigate to the radio button group with the keyboard (generally, using <kbd>TAB</kbd> / <kbd>SHIFT + TAB</kbd>). If no radio button was initially set to be selected, the first/last radio button should receive focus (depending on whether you navigated "forward" to the group with <kbd>TAB</kbd> or "backwards" using <kbd>SHIFT + TAB</kbd>). If a radio button was already selected, navigating with the keyboard should set focus to that particular radio button. Only one radio button in a group should receive focus at any given time. Ensure that the selected radio button can be changed by using the <kbd>←</kbd> and <kbd>→</kbd> arrow keys. Click on one of the radio buttons with the mouse, ensure that focus was correctly set on the actual radio button, and that <kbd>←</kbd> and <kbd>→</kbd> change the selected radio button again.</p>
+
+ <div class="btn-group" data-bs-toggle="buttons">
+ <label class="btn btn-primary active">
+ <input type="radio" name="options" id="option1" autocomplete="off" checked> Radio 1 (preselected)
+ </label>
+ <label class="btn btn-primary">
+ <input type="radio" name="options" id="option2" autocomplete="off"> Radio 2
+ </label>
+ <label class="btn btn-primary">
+ <input type="radio" name="options" id="option3" autocomplete="off"> Radio 3
+ </label>
+ </div>
+ </div>
+
+ <script src="../../../dist/js/bootstrap.bundle.js"></script>
+ </body>
+</html>
diff --git a/js/tests/visual/carousel.html b/js/tests/visual/carousel.html
new file mode 100644
index 0000000..153c866
--- /dev/null
+++ b/js/tests/visual/carousel.html
@@ -0,0 +1,65 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link href="../../../dist/css/bootstrap.min.css" rel="stylesheet">
+ <title>Carousel</title>
+ <style>
+ .carousel-item {
+ transition: transform 2s ease, opacity .5s ease;
+ }
+ </style>
+ </head>
+ <body>
+ <div class="container">
+ <h1>Carousel <small>Bootstrap Visual Test</small></h1>
+
+ <p>The transition duration should be around 2s. Also, the carousel shouldn't slide when its window/tab is hidden. Check the console log.</p>
+
+ <div id="carousel-example-generic" class="carousel slide" data-bs-ride="carousel">
+ <div class="carousel-indicators">
+ <button type="button" data-bs-target="#carousel-example-generic" data-bs-slide-to="0" class="active" aria-current="true" aria-label="Slide 1"></button>
+ <button type="button" data-bs-target="#carousel-example-generic" data-bs-slide-to="1" aria-label="Slide 2"></button>
+ <button type="button" data-bs-target="#carousel-example-generic" data-bs-slide-to="2" aria-label="Slide 3"></button>
+ </div>
+ <div class="carousel-inner">
+ <div class="carousel-item active">
+ <img src="https://i.imgur.com/iEZgY7Y.jpg" alt="First slide">
+ </div>
+ <div class="carousel-item">
+ <img src="https://i.imgur.com/eNWn1Xs.jpg" alt="Second slide">
+ </div>
+ <div class="carousel-item">
+ <img src="https://i.imgur.com/Nm7xoti.jpg" alt="Third slide">
+ </div>
+ </div>
+ <button class="carousel-control-prev" data-bs-target="#carousel-example-generic" type="button" data-bs-slide="prev">
+ <span class="carousel-control-prev-icon" aria-hidden="true"></span>
+ <span class="visually-hidden">Previous</span>
+ </button>
+ <button class="carousel-control-next" data-bs-target="#carousel-example-generic" type="button" data-bs-slide="next">
+ <span class="carousel-control-next-icon" aria-hidden="true"></span>
+ <span class="visually-hidden">Next</span>
+ </button>
+ </div>
+ </div>
+
+ <script src="../../../dist/js/bootstrap.bundle.js"></script>
+ <script>
+ let t0
+ let t1
+ const carousel = document.getElementById('carousel-example-generic')
+
+ // Test to show that the carousel doesn't slide when the current tab isn't visible
+ // Test to show that transition-duration can be changed with css
+ carousel.addEventListener('slid.bs.carousel', event => {
+ t1 = performance.now()
+ console.log('transition-duration took ' + (t1 - t0) + 'ms, slid at ' + event.timeStamp)
+ })
+ carousel.addEventListener('slide.bs.carousel', () => {
+ t0 = performance.now()
+ })
+ </script>
+ </body>
+</html>
diff --git a/js/tests/visual/collapse.html b/js/tests/visual/collapse.html
new file mode 100644
index 0000000..2782c56
--- /dev/null
+++ b/js/tests/visual/collapse.html
@@ -0,0 +1,76 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link href="../../../dist/css/bootstrap.min.css" rel="stylesheet">
+ <title>Collapse</title>
+ </head>
+ <body>
+ <div class="container">
+ <h1>Collapse <small>Bootstrap Visual Test</small></h1>
+
+ <div id="accordion" role="tablist">
+ <div class="card" role="presentation">
+ <div class="card-header" role="tab" id="headingOne">
+ <h5 class="mb-0">
+ <a data-bs-toggle="collapse" href="#collapseOne" aria-expanded="true" aria-controls="collapseOne">
+ Collapsible Group Item #1
+ </a>
+ </h5>
+ </div>
+
+ <div id="collapseOne" class="collapse show" data-bs-parent="#accordion" role="tabpanel" aria-labelledby="headingOne">
+ <div class="card-body">
+ Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid. 3 wolf moon officia aute, non cupidatat skateboard dolor brunch. Food truck quinoa nesciunt laborum eiusmod. Brunch 3 wolf moon tempor, sunt aliqua put a bird on it squid single-origin coffee nulla assumenda shoreditch et. Nihil anim keffiyeh helvetica, craft beer labore wes anderson cred nesciunt sapiente ea proident. Ad vegan excepteur butcher vice lomo. Leggings occaecat craft beer farm-to-table, raw denim aesthetic synth nesciunt you probably haven't heard of them accusamus labore sustainable VHS.
+ </div>
+ </div>
+ </div>
+ <div class="card" role="presentation">
+ <div class="card-header" role="tab" id="headingTwo">
+ <h5 class="mb-0">
+ <a class="collapsed" data-bs-toggle="collapse" href="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo">
+ Collapsible Group Item #2
+ </a>
+ </h5>
+ </div>
+ <div id="collapseTwo" class="collapse" data-bs-parent="#accordion" role="tabpanel" aria-labelledby="headingTwo">
+ <div class="card-body">
+ Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid. 3 wolf moon officia aute, non cupidatat skateboard dolor brunch. Food truck quinoa nesciunt laborum eiusmod. Brunch 3 wolf moon tempor, sunt aliqua put a bird on it squid single-origin coffee nulla assumenda shoreditch et. Nihil anim keffiyeh helvetica, craft beer labore wes anderson cred nesciunt sapiente ea proident. Ad vegan excepteur butcher vice lomo. Leggings occaecat craft beer farm-to-table, raw denim aesthetic synth nesciunt you probably haven't heard of them accusamus labore sustainable VHS.
+ </div>
+ </div>
+ </div>
+ <div class="card" role="presentation">
+ <div class="card-header" role="tab" id="headingThree">
+ <h5 class="mb-0">
+ <a class="collapsed" data-bs-toggle="collapse" href="#collapseThree" aria-expanded="false" aria-controls="collapseThree">
+ Collapsible Group Item #3
+ </a>
+ </h5>
+ </div>
+ <div id="collapseThree" class="collapse" data-bs-parent="#accordion" role="tabpanel" aria-labelledby="headingThree">
+ <div class="card-body">
+ Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid. 3 wolf moon officia aute, non cupidatat skateboard dolor brunch. Food truck quinoa nesciunt laborum eiusmod. Brunch 3 wolf moon tempor, sunt aliqua put a bird on it squid single-origin coffee nulla assumenda shoreditch et. Nihil anim keffiyeh helvetica, craft beer labore wes anderson cred nesciunt sapiente ea proident. Ad vegan excepteur butcher vice lomo. Leggings occaecat craft beer farm-to-table, raw denim aesthetic synth nesciunt you probably haven't heard of them accusamus labore sustainable VHS.
+ </div>
+ </div>
+ </div>
+ <div class="card" role="presentation">
+ <div class="card-header" role="tab" id="headingFour">
+ <h5 class="mb-0">
+ <a class="collapsed" data-bs-toggle="collapse" href="#collapseFour" aria-expanded="false" aria-controls="collapseFour">
+ Collapsible Group Item with XSS in data-bs-parent
+ </a>
+ </h5>
+ </div>
+ <div id="collapseFour" class="collapse" data-bs-parent="<img src=1 onerror=alert(123)>" role="tabpanel" aria-labelledby="headingFour">
+ <div class="card-body">
+ Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid. 3 wolf moon officia aute, non cupidatat skateboard dolor brunch. Food truck quinoa nesciunt laborum eiusmod. Brunch 3 wolf moon tempor, sunt aliqua put a bird on it squid single-origin coffee nulla assumenda shoreditch et. Nihil anim keffiyeh helvetica, craft beer labore wes anderson cred nesciunt sapiente ea proident. Ad vegan excepteur butcher vice lomo. Leggings occaecat craft beer farm-to-table, raw denim aesthetic synth nesciunt you probably haven't heard of them accusamus labore sustainable VHS.
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <script src="../../../dist/js/bootstrap.bundle.js"></script>
+ </body>
+</html>
diff --git a/js/tests/visual/dropdown.html b/js/tests/visual/dropdown.html
new file mode 100644
index 0000000..04cf06d
--- /dev/null
+++ b/js/tests/visual/dropdown.html
@@ -0,0 +1,205 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link href="../../../dist/css/bootstrap.min.css" rel="stylesheet">
+ <title>Dropdown</title>
+ </head>
+ <body>
+ <div class="container">
+ <h1>Dropdown <small>Bootstrap Visual Test</small></h1>
+
+ <nav class="navbar navbar-expand-md bg-light">
+ <a class="navbar-brand" href="#">Navbar</a>
+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarResponsive" aria-controls="navbarResponsive" aria-expanded="false" aria-label="Toggle navigation">
+ <span class="navbar-toggler-icon"></span>
+ </button>
+
+ <div class="collapse navbar-collapse" id="navbarResponsive">
+ <ul class="navbar-nav">
+ <li class="nav-item">
+ <a class="nav-link active" href="#" aria-current="page">Home</a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link" href="#">Link</a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link" href="#">Link</a>
+ </li>
+ <li class="nav-item dropdown">
+ <a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</a>
+ <ul class="dropdown-menu">
+ <li><a class="dropdown-item" href="#">Action</a></li>
+ <li><a class="dropdown-item" href="#">Another action</a></li>
+ <li><a class="dropdown-item" href="#">Something else here</a></li>
+ </ul>
+ </li>
+ </ul>
+ </div>
+ </nav>
+
+ <ul class="nav nav-pills mt-3">
+ <li class="nav-item">
+ <a class="nav-link active" href="#">Active</a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link" href="#">Link</a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link" href="#">Link</a>
+ </li>
+ <li class="nav-item dropdown">
+ <a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</a>
+ <ul class="dropdown-menu">
+ <li><a class="dropdown-item" href="#">Action</a></li>
+ <li><a class="dropdown-item" href="#">Another action</a></li>
+ <li><a class="dropdown-item" href="#">Something else here</a></li>
+ </ul>
+ </li>
+ </ul>
+
+ <div class="row">
+ <div class="col-sm-12 mt-4">
+ <div class="dropdown">
+ <button type="button" class="btn btn-secondary" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>
+ <div class="dropdown-menu">
+ <input id="textField" type="text">
+ </div>
+ </div>
+ <div class="btn-group dropup">
+ <button type="button" class="btn btn-secondary">Dropup split</button>
+ <button type="button" class="btn btn-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
+ <span class="visually-hidden">Dropup split</span>
+ </button>
+ <ul class="dropdown-menu">
+ <li><a class="dropdown-item" href="#">Action</a></li>
+ <li><a class="dropdown-item" href="#">Another action</a></li>
+ <li><a class="dropdown-item" href="#">Something else here</a></li>
+ </ul>
+ </div>
+ </div>
+
+ <div class="col-sm-12 mt-4">
+ <div class="btn-group dropup">
+ <button type="button" class="btn btn-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropup</button>
+ <ul class="dropdown-menu">
+ <li><a class="dropdown-item" href="#">Action</a></li>
+ <li><a class="dropdown-item" href="#">Another action</a></li>
+ <li><a class="dropdown-item" href="#">Something else here</a></li>
+ </ul>
+ </div>
+
+ <div class="btn-group">
+ <button type="button" class="btn btn-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
+ This dropdown's menu is end-aligned
+ </button>
+ <div class="dropdown-menu dropdown-menu-end">
+ <button class="dropdown-item" type="button">Action</button>
+ <button class="dropdown-item" type="button">Another action</button>
+ <button class="dropdown-item" type="button">Something else here</button>
+ </div>
+ </div>
+ </div>
+
+ <div class="col-sm-12 mt-4">
+ <div class="btn-group dropup">
+ <a href="#" class="btn btn-secondary">Dropup split align end</a>
+ <button type="button" id="dropdown-page-subheader-button-3" class="btn btn-secondary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
+ <span class="visually-hidden">Product actions</span>
+ </button>
+ <div class="dropdown-menu dropdown-menu-end">
+ <button class="dropdown-item" type="button">Action</button>
+ <button class="dropdown-item" type="button">Another action</button>
+ <button class="dropdown-item" type="button">Something else here with a long text</button>
+ </div>
+ </div>
+ <div class="btn-group dropup">
+ <button type="button" class="btn btn-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropup align end</button>
+ <div class="dropdown-menu dropdown-menu-end">
+ <button class="dropdown-item" type="button">Action</button>
+ <button class="dropdown-item" type="button">Another action</button>
+ <button class="dropdown-item" type="button">Something else here with a long text</button>
+ </div>
+ </div>
+ </div>
+
+ <div class="col-sm-12 mt-4">
+ <div class="btn-group dropend">
+ <a href="#" class="btn btn-secondary">Dropend split</a>
+ <button type="button" id="dropdown-page-subheader-button-4" class="btn btn-secondary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
+ <span class="visually-hidden">Product actions</span>
+ </button>
+ <div class="dropdown-menu">
+ <button class="dropdown-item" type="button">Action</button>
+ <button class="dropdown-item" type="button">Another action</button>
+ <button class="dropdown-item" type="button">Something else here with a long text</button>
+ </div>
+ </div>
+ <div class="btn-group dropend">
+ <button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+ Dropend
+ </button>
+ <div class="dropdown-menu">
+ <button class="dropdown-item" type="button">Action</button>
+ <button class="dropdown-item" type="button">Another action</button>
+ <button class="dropdown-item" type="button">Something else here</button>
+ </div>
+ </div>
+ <!-- dropstart -->
+ <div class="btn-group dropstart">
+ <a href="#" class="btn btn-secondary">Dropstart split</a>
+ <button type="button" id="dropdown-page-subheader-button-5" class="btn btn-secondary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
+ <span class="visually-hidden">Product actions</span>
+ </button>
+ <div class="dropdown-menu">
+ <button class="dropdown-item" type="button">Action</button>
+ <button class="dropdown-item" type="button">Another action</button>
+ <button class="dropdown-item" type="button">Something else here with a long text</button>
+ </div>
+ </div>
+ <div class="btn-group dropstart">
+ <button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+ Dropstart
+ </button>
+ <div class="dropdown-menu">
+ <button class="dropdown-item" type="button">Action</button>
+ <button class="dropdown-item" type="button">Another action</button>
+ <button class="dropdown-item" type="button">Something else here</button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="row">
+ <div class="col-sm-3 mt-4">
+ <div class="btn-group dropdown">
+ <button type="button" class="btn btn-secondary">Dropdown reference</button>
+ <button type="button" class="btn btn-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" data-bs-reference="parent">
+ <span class="visually-hidden">Dropdown split</span>
+ </button>
+ <ul class="dropdown-menu">
+ <li><a class="dropdown-item" href="#">Action</a></li>
+ <li><a class="dropdown-item" href="#">Another action</a></li>
+ <li><a class="dropdown-item" href="#">Something else here</a></li>
+ </ul>
+ </div>
+ </div>
+ <div class="col-sm-3 mt-4">
+ <div class="dropdown">
+ <button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" data-bs-display="static" aria-expanded="false">
+ Dropdown menu without Popper
+ </button>
+ <ul class="dropdown-menu">
+ <li><a class="dropdown-item" href="#">Action</a></li>
+ <li><a class="dropdown-item" href="#">Another action</a></li>
+ <li><a class="dropdown-item" href="#">Something else here</a></li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <script src="../../../dist/js/bootstrap.bundle.js"></script>
+ </body>
+</html>
diff --git a/js/tests/visual/modal.html b/js/tests/visual/modal.html
new file mode 100644
index 0000000..b738d9e
--- /dev/null
+++ b/js/tests/visual/modal.html
@@ -0,0 +1,275 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link href="../../../dist/css/bootstrap.min.css" rel="stylesheet">
+ <title>Modal</title>
+ <style>
+ #tall {
+ height: 1500px;
+ width: 100px;
+ }
+ </style>
+ </head>
+ <body>
+ <nav class="navbar navbar-full navbar-dark bg-dark">
+ <button class="navbar-toggler hidden-lg-up" type="button" data-bs-toggle="collapse" data-bs-target="#navbarResponsive" aria-controls="navbarResponsive" aria-expanded="false" aria-label="Toggle navigation"></button>
+ <div class="collapse navbar-expand-md" id="navbarResponsive">
+ <a class="navbar-brand" href="#">This shouldn't jump!</a>
+ <ul class="navbar-nav">
+ <li class="nav-item">
+ <a class="nav-link active" href="#" aria-current="page">Home</a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link" href="#">Link</a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link" href="#">Link</a>
+ </li>
+ </ul>
+ </div>
+ </nav>
+
+ <div class="container mt-3">
+ <h1>Modal <small>Bootstrap Visual Test</small></h1>
+
+ <div class="modal fade" id="myModal" tabindex="-1" aria-labelledby="myModalLabel" aria-hidden="true">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h1 class="modal-title fs-4" id="myModalLabel">Modal title</h1>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div>
+ <div class="modal-body">
+ <h4>Text in a modal</h4>
+ <p>Duis mollis, est non commodo luctus, nisi erat porttitor ligula.</p>
+
+ <h4>Popover in a modal</h4>
+ <p>This <button type="button" class="btn btn-primary" data-bs-toggle="popover" data-bs-placement="left" title="Popover title" data-bs-content="And here's some amazing content. It's very engaging. Right?">button</button> should trigger a popover on click.</p>
+
+
+ <h4>Tooltips in a modal</h4>
+ <p><a href="#" data-bs-toggle="tooltip" data-bs-placement="top" title="Tooltip on top">This link</a> and <a href="#" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Tooltip on bottom">that link</a> should have tooltips on hover.</p>
+
+ <div id="accordion" role="tablist">
+ <div class="card" role="presentation">
+ <div class="card-header" role="tab" id="headingOne">
+ <h5 class="mb-0">
+ <a data-bs-toggle="collapse" href="#collapseOne" aria-expanded="true" aria-controls="collapseOne">
+ Collapsible Group Item #1
+ </a>
+ </h5>
+ </div>
+
+ <div id="collapseOne" class="collapse show" data-bs-parent="#accordion" role="tabpanel" aria-labelledby="headingOne">
+ <div class="card-body">
+ Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid. 3 wolf moon officia aute, non cupidatat skateboard dolor brunch. Food truck quinoa nesciunt laborum eiusmod. Brunch 3 wolf moon tempor, sunt aliqua put a bird on it squid single-origin coffee nulla assumenda shoreditch et. Nihil anim keffiyeh helvetica, craft beer labore wes anderson cred nesciunt sapiente ea proident. Ad vegan excepteur butcher vice lomo. Leggings occaecat craft beer farm-to-table, raw denim aesthetic synth nesciunt you probably haven't heard of them accusamus labore sustainable VHS.
+ </div>
+ </div>
+ </div>
+ <div class="card" role="presentation">
+ <div class="card-header" role="tab" id="headingTwo">
+ <h5 class="mb-0">
+ <a class="collapsed" data-bs-toggle="collapse" href="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo">
+ Collapsible Group Item #2
+ </a>
+ </h5>
+ </div>
+ <div id="collapseTwo" class="collapse" data-bs-parent="#accordion" role="tabpanel" aria-labelledby="headingTwo">
+ <div class="card-body">
+ Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid. 3 wolf moon officia aute, non cupidatat skateboard dolor brunch. Food truck quinoa nesciunt laborum eiusmod. Brunch 3 wolf moon tempor, sunt aliqua put a bird on it squid single-origin coffee nulla assumenda shoreditch et. Nihil anim keffiyeh helvetica, craft beer labore wes anderson cred nesciunt sapiente ea proident. Ad vegan excepteur butcher vice lomo. Leggings occaecat craft beer farm-to-table, raw denim aesthetic synth nesciunt you probably haven't heard of them accusamus labore sustainable VHS.
+ </div>
+ </div>
+ </div>
+ <div class="card" role="presentation">
+ <div class="card-header" role="tab" id="headingThree">
+ <h5 class="mb-0">
+ <a class="collapsed" data-bs-toggle="collapse" href="#collapseThree" aria-expanded="false" aria-controls="collapseThree">
+ Collapsible Group Item #3
+ </a>
+ </h5>
+ </div>
+ <div id="collapseThree" class="collapse" data-bs-parent="#accordion" role="tabpanel" aria-labelledby="headingThree">
+ <div class="card-body">
+ Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid. 3 wolf moon officia aute, non cupidatat skateboard dolor brunch. Food truck quinoa nesciunt laborum eiusmod. Brunch 3 wolf moon tempor, sunt aliqua put a bird on it squid single-origin coffee nulla assumenda shoreditch et. Nihil anim keffiyeh helvetica, craft beer labore wes anderson cred nesciunt sapiente ea proident. Ad vegan excepteur butcher vice lomo. Leggings occaecat craft beer farm-to-table, raw denim aesthetic synth nesciunt you probably haven't heard of them accusamus labore sustainable VHS.
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <hr>
+
+ <h4>Overflowing text to show scroll behavior</h4>
+ <p>Cras mattis consectetur purus sit amet fermentum. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Morbi leo risus, porta ac consectetur ac, vestibulum at eros.</p>
+ <p>Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.</p>
+ <p>Aenean lacinia bibendum nulla sed consectetur. Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Donec sed odio dui. Donec ullamcorper nulla non metus auctor fringilla.</p>
+ <p>Cras mattis consectetur purus sit amet fermentum. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Morbi leo risus, porta ac consectetur ac, vestibulum at eros.</p>
+ <p>Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.</p>
+ <p>Aenean lacinia bibendum nulla sed consectetur. Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Donec sed odio dui. Donec ullamcorper nulla non metus auctor fringilla.</p>
+ <p>Cras mattis consectetur purus sit amet fermentum. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Morbi leo risus, porta ac consectetur ac, vestibulum at eros.</p>
+ <p>Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.</p>
+ <p>Aenean lacinia bibendum nulla sed consectetur. Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Donec sed odio dui. Donec ullamcorper nulla non metus auctor fringilla.</p>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
+ <button type="button" class="btn btn-primary">Save changes</button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal fade" id="firefoxModal" tabindex="-1" aria-labelledby="firefoxModalLabel" aria-hidden="true">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h1 class="modal-title fs-4" id="firefoxModalLabel">Firefox Bug Test</h1>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div>
+ <div class="modal-body">
+ <ol>
+ <li>Ensure you're using Firefox.</li>
+ <li>Open a new tab and then switch back to this tab.</li>
+ <li>Click into this input: <input type="text" id="ff-bug-input"></li>
+ <li>Switch to the other tab and then back to this tab.</li>
+ </ol>
+ <p>Test result: <strong id="ff-bug-test-result"></strong></p>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
+ <button type="button" class="btn btn-primary">Save changes</button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal fade" id="slowModal" tabindex="-1" aria-labelledby="slowModalLabel" aria-hidden="true" style="transition-duration: 5s;">
+ <div class="modal-dialog" style="transition-duration: inherit;">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h1 class="modal-title fs-4" id="slowModalLabel">Lorem slowly</h1>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div>
+ <div class="modal-body">
+ <p>Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Donec sed odio dui. Nullam quis risus eget urna mollis ornare vel eu leo. Nulla vitae elit libero, a pharetra augue.</p>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
+ <button type="button" class="btn btn-primary">Save changes</button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <button type="button" class="btn btn-primary btn-lg" data-bs-toggle="modal" data-bs-target="#myModal">
+ Launch demo modal
+ </button>
+
+ <button type="button" class="btn btn-primary btn-lg" id="tall-toggle">
+ Toggle tall &lt;body&gt; content
+ </button>
+
+ <br><br>
+
+ <button type="button" class="btn btn-secondary btn-lg" data-bs-toggle="modal" data-bs-target="#firefoxModal">
+ Launch Firefox bug test modal
+ </button>
+ (<a href="https://github.com/twbs/bootstrap/issues/18365">See Issue #18365</a>)
+
+ <br><br>
+
+ <button type="button" class="btn btn-secondary btn-lg" data-bs-toggle="modal" data-bs-target="#slowModal">
+ Launch modal with slow transition
+ </button>
+
+ <br><br>
+
+ <div class="text-bg-dark p-2" id="tall" style="display: none;">
+ Tall body content to force the page to have a scrollbar.
+ </div>
+
+ <button type="button" class="btn btn-secondary btn-lg" data-bs-toggle="modal" data-bs-target="&#x3C;div class=&#x22;modal fade the-bad&#x22; tabindex=&#x22;-1&#x22;&#x3E;&#x3C;div class=&#x22;modal-dialog&#x22;&#x3E;&#x3C;div class=&#x22;modal-content&#x22;&#x3E;&#x3C;div class=&#x22;modal-header&#x22;&#x3E;&#x3C;button type=&#x22;button&#x22; class=&#x22;btn-close&#x22; data-bs-dismiss=&#x22;modal&#x22; aria-label=&#x22;Close&#x22;&#x3E;&#x3C;span aria-hidden=&#x22;true&#x22;&#x3E;&#x26;times;&#x3C;/span&#x3E;&#x3C;/button&#x3E;&#x3C;h1 class=&#x22;modal-title fs-4&#x22;&#x3E;The Bad Modal&#x3C;/h1&#x3E;&#x3C;/div&#x3E;&#x3C;div class=&#x22;modal-body&#x22;&#x3E;This modal&#x27;s HTTML source code is declared inline, inside the data-bs-target attribute of it&#x27;s show-button&#x3C;/div&#x3E;&#x3C;/div&#x3E;&#x3C;/div&#x3E;&#x3C;/div&#x3E;">
+ Modal with an XSS inside the data-bs-target
+ </button>
+
+ <br><br>
+
+ <button type="button" class="btn btn-secondary btn-lg" id="btnPreventModal">
+ Launch prevented modal on hide (to see the result open your console)
+ </button>
+ </div>
+
+ <script src="../../../dist/js/bootstrap.bundle.js"></script>
+ <script>
+ /* global bootstrap: false */
+
+ const ffBugTestResult = document.getElementById('ff-bug-test-result')
+ const firefoxTestDone = false
+
+ function reportFirefoxTestResult(result) {
+ if (!firefoxTestDone) {
+ ffBugTestResult.classList.add(result ? 'text-success' : 'text-danger')
+ ffBugTestResult.textContent = result ? 'PASS' : 'FAIL'
+ }
+ }
+
+ document.querySelectorAll('[data-bs-toggle="popover"]').forEach(popoverEl => new bootstrap.Popover(popoverEl))
+
+ document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(tooltipEl => new bootstrap.Tooltip(tooltipEl))
+
+ const tall = document.getElementById('tall')
+ document.getElementById('tall-toggle').addEventListener('click', () => {
+ tall.style.display = tall.style.display === 'none' ? 'block' : 'none'
+ })
+
+ const ffBugInput = document.getElementById('ff-bug-input')
+ const firefoxModal = document.getElementById('firefoxModal')
+ function handlerClickFfBugInput() {
+ firefoxModal.addEventListener('focus', reportFirefoxTestResult.bind(false))
+ ffBugInput.addEventListener('focus', reportFirefoxTestResult.bind(true))
+ ffBugInput.removeEventListener('focus', handlerClickFfBugInput)
+ }
+
+ ffBugInput.addEventListener('focus', handlerClickFfBugInput)
+
+ const modalFf = new bootstrap.Modal(firefoxModal)
+
+ document.getElementById('btnPreventModal').addEventListener('click', () => {
+ const shownFirefoxModal = () => {
+ modalFf.hide()
+ firefoxModal.removeEventListener('shown.bs.modal', hideFirefoxModal)
+ }
+
+ const hideFirefoxModal = event => {
+ event.preventDefault()
+ firefoxModal.removeEventListener('hide.bs.modal', hideFirefoxModal)
+
+ if (modalFf._isTransitioning) {
+ console.error('Modal plugin should not set _isTransitioning when hide event is prevented')
+ } else {
+ console.log('Test passed')
+ modalFf.hide() // work as expected
+ }
+ }
+
+ firefoxModal.addEventListener('shown.bs.modal', shownFirefoxModal)
+ firefoxModal.addEventListener('hide.bs.modal', hideFirefoxModal)
+ modalFf.show()
+ })
+
+ // Test transition duration
+ let t0
+ let t1
+ const slowModal = document.getElementById('slowModal')
+
+ slowModal.addEventListener('shown.bs.modal', () => {
+ t1 = performance.now()
+ console.log('transition-duration took ' + (t1 - t0) + 'ms.')
+ })
+
+ slowModal.addEventListener('show.bs.modal', () => {
+ t0 = performance.now()
+ })
+ </script>
+ </body>
+</html>
diff --git a/js/tests/visual/popover.html b/js/tests/visual/popover.html
new file mode 100644
index 0000000..73edf99
--- /dev/null
+++ b/js/tests/visual/popover.html
@@ -0,0 +1,41 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link href="../../../dist/css/bootstrap.min.css" rel="stylesheet">
+ <title>Popover</title>
+ </head>
+ <body>
+ <div class="container">
+ <h1>Popover <small>Bootstrap Visual Test</small></h1>
+
+ <button type="button" class="btn btn-secondary" data-bs-container="body" data-bs-toggle="popover" data-bs-placement="auto" data-bs-content="Vivamus sagittis lacus vel augue laoreet rutrum faucibus.">
+ Popover on auto
+ </button>
+
+ <button type="button" class="btn btn-secondary" data-bs-container="body" data-bs-toggle="popover" data-bs-placement="top" data-bs-content="Default placement was on top but not enough place">
+ Popover on top
+ </button>
+
+ <button type="button" class="btn btn-secondary" data-bs-container="body" data-bs-toggle="popover" data-bs-placement="right" data-bs-content="Vivamus sagittis lacus vel augue laoreet rutrum faucibus.">
+ Popover on end
+ </button>
+
+ <button type="button" class="btn btn-secondary" data-bs-container="body" data-bs-toggle="popover" data-bs-placement="bottom" data-bs-content="Vivamus sagittis lacus vel augue laoreet rutrum faucibus.">
+ Popover on bottom
+ </button>
+
+ <button type="button" class="btn btn-secondary" data-bs-container="body" data-bs-toggle="popover" data-bs-placement="left" data-bs-content="Vivamus sagittis lacus vel augue laoreet rutrum faucibus.">
+ Popover on start
+ </button>
+ </div>
+
+ <script src="../../../dist/js/bootstrap.bundle.js"></script>
+ <script>
+ /* global bootstrap: false */
+
+ document.querySelectorAll('[data-bs-toggle="popover"]').forEach(popoverEl => new bootstrap.Popover(popoverEl))
+ </script>
+ </body>
+</html>
diff --git a/js/tests/visual/scrollspy.html b/js/tests/visual/scrollspy.html
new file mode 100644
index 0000000..2daa7ab
--- /dev/null
+++ b/js/tests/visual/scrollspy.html
@@ -0,0 +1,91 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link href="../../../dist/css/bootstrap.min.css" rel="stylesheet">
+ <title>Scrollspy</title>
+ <style>
+ body { padding-top: 70px; }
+ </style>
+ </head>
+ <body data-bs-spy="scroll" data-bs-target=".navbar" data-bs-offset="70">
+ <nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
+ <a class="navbar-brand" href="#">Scrollspy test</a>
+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
+ <span class="navbar-toggler-icon"></span>
+ </button>
+ <div class="navbar-collapse collapse" id="navbarSupportedContent">
+ <ul class="navbar-nav me-auto">
+ <li class="nav-item">
+ <a class="nav-link" href="#fat">@fat</a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link" href="#mdo">@mdo</a>
+ </li>
+ <li class="nav-item dropdown">
+ <a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</a>
+ <ul class="dropdown-menu">
+ <li><a class="dropdown-item" href="#one">One</a></li>
+ <li><a class="dropdown-item" href="#two">Two</a></li>
+ <li><a class="dropdown-item" href="#three">Three</a></li>
+ </ul>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link" href="#final">Final</a>
+ </li>
+ </ul>
+ </div>
+ </nav>
+ <div class="container">
+ <h2 id="fat">@fat</h2>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <hr>
+ <h2 id="mdo">@mdo</h2>
+ <p>Veniam marfa mustache skateboard, adipisicing fugiat velit pitchfork beard. Freegan beard aliqua cupidatat mcsweeney's vero. Cupidatat four loko nisi, ea helvetica nulla carles. Tattooed cosby sweater food truck, mcsweeney's quis non freegan vinyl. Lo-fi wes anderson +1 sartorial. Carles non aesthetic exercitation quis gentrify. Brooklyn adipisicing craft beer vice keytar deserunt.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <hr>
+ <h2 id="one">one</h2>
+ <p>Occaecat commodo aliqua delectus. Fap craft beer deserunt skateboard ea. Lomo bicycle rights adipisicing banh mi, velit ea sunt next level locavore single-origin coffee in magna veniam. High life id vinyl, echo park consequat quis aliquip banh mi pitchfork. Vero VHS est adipisicing. Consectetur nisi DIY minim messenger bag. Cred ex in, sustainable delectus consectetur fanny pack iphone.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <hr>
+ <h2 id="two">two</h2>
+ <p>In incididunt echo park, officia deserunt mcsweeney's proident master cleanse thundercats sapiente veniam. Excepteur VHS elit, proident shoreditch +1 biodiesel laborum craft beer. Single-origin coffee wayfarers irure four loko, cupidatat terry richardson master cleanse. Assumenda you probably haven't heard of them art party fanny pack, tattooed nulla cardigan tempor ad. Proident wolf nesciunt sartorial keffiyeh eu banh mi sustainable. Elit wolf voluptate, lo-fi ea portland before they sold out four loko. Locavore enim nostrud mlkshk brooklyn nesciunt.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <hr>
+ <h2 id="three">three</h2>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Keytar twee blog, culpa messenger bag marfa whatever delectus food truck. Sapiente synth id assumenda. Locavore sed helvetica cliche irony, thundercats you probably haven't heard of them consequat hoodie gluten-free lo-fi fap aliquip. Labore elit placeat before they sold out, terry richardson proident brunch nesciunt quis cosby sweater pariatur keffiyeh ut helvetica artisan. Cardigan craft beer seitan readymade velit. VHS chambray laboris tempor veniam. Anim mollit minim commodo ullamco thundercats.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
+ <hr>
+ <h2 id="final">Final section</h2>
+ <p>Ad leggings keytar, brunch id art party dolor labore.</p>
+ </div>
+
+ <script src="../../../dist/js/bootstrap.bundle.js"></script>
+ </body>
+</html>
diff --git a/js/tests/visual/tab.html b/js/tests/visual/tab.html
new file mode 100644
index 0000000..4cbc86c
--- /dev/null
+++ b/js/tests/visual/tab.html
@@ -0,0 +1,223 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link href="../../../dist/css/bootstrap.min.css" rel="stylesheet">
+ <title>Tab</title>
+ <style>
+ h4 {
+ margin: 40px 0 10px;
+ }
+ </style>
+ </head>
+ <body>
+ <div class="container">
+ <h1>Tab <small>Bootstrap Visual Test</small></h1>
+
+ <h4>Tabs without fade</h4>
+
+ <ul class="nav nav-tabs" role="tablist">
+ <li class="nav-item" role="presentation">
+ <button type="button" class="nav-link active" data-bs-toggle="tab" data-bs-target="#home" role="tab" aria-selected="true">Home</button>
+ </li>
+ <li class="nav-item" role="presentation">
+ <button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#profile" role="tab">Profile</button>
+ </li>
+ <li class="nav-item" role="presentation">
+ <button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#fat" role="tab">@fat</button>
+ </li>
+ <li class="nav-item" role="presentation">
+ <button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#mdo" role="tab">@mdo</button>
+ </li>
+ </ul>
+
+ <div class="tab-content" role="tablist">
+ <div class="tab-pane active" id="home" role="tabpanel">
+ <p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
+ <p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
+ </div>
+ <div class="tab-pane" id="profile" role="tabpanel">
+ <p>Food truck fixie locavore, accusamus mcsweeney's marfa nulla single-origin coffee squid. Exercitation +1 labore velit, blog sartorial PBR leggings next level wes anderson artisan four loko farm-to-table craft beer twee. Qui photo booth letterpress, commodo enim craft beer mlkshk aliquip jean shorts ullamco ad vinyl cillum PBR. Homo nostrud organic, assumenda labore aesthetic magna delectus mollit. Keytar helvetica VHS salvia yr, vero magna velit sapiente labore stumptown. Vegan fanny pack odio cillum wes anderson 8-bit, sustainable jean shorts beard ut DIY ethical culpa terry richardson biodiesel. Art party scenester stumptown, tumblr butcher vero sint qui sapiente accusamus tattooed echo park.</p>
+ <p>Food truck fixie locavore, accusamus mcsweeney's marfa nulla single-origin coffee squid. Exercitation +1 labore velit, blog sartorial PBR leggings next level wes anderson artisan four loko farm-to-table craft beer twee. Qui photo booth letterpress, commodo enim craft beer mlkshk aliquip jean shorts ullamco ad vinyl cillum PBR. Homo nostrud organic, assumenda labore aesthetic magna delectus mollit. Keytar helvetica VHS salvia yr, vero magna velit sapiente labore stumptown. Vegan fanny pack odio cillum wes anderson 8-bit, sustainable jean shorts beard ut DIY ethical culpa terry richardson biodiesel. Art party scenester stumptown, tumblr butcher vero sint qui sapiente accusamus tattooed echo park.</p>
+ </div>
+ <div class="tab-pane" id="fat" role="tabpanel">
+ <p>Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable tofu synth chambray yr.</p>
+ <p>Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable tofu synth chambray yr.</p>
+ </div>
+ <div class="tab-pane" id="mdo" role="tabpanel">
+ <p>Trust fund seitan letterpress, keytar raw denim keffiyeh etsy art party before they sold out master cleanse gluten-free squid scenester freegan cosby sweater. Fanny pack portland seitan DIY, art party locavore wolf cliche high life echo park Austin. Cred vinyl keffiyeh DIY salvia PBR, banh mi before they sold out farm-to-table VHS viral locavore cosby sweater. Lomo wolf viral, mustache readymade thundercats keffiyeh craft beer marfa ethical. Wolf salvia freegan, sartorial keffiyeh echo park vegan.</p>
+ <p>Trust fund seitan letterpress, keytar raw denim keffiyeh etsy art party before they sold out master cleanse gluten-free squid scenester freegan cosby sweater. Fanny pack portland seitan DIY, art party locavore wolf cliche high life echo park Austin. Cred vinyl keffiyeh DIY salvia PBR, banh mi before they sold out farm-to-table VHS viral locavore cosby sweater. Lomo wolf viral, mustache readymade thundercats keffiyeh craft beer marfa ethical. Wolf salvia freegan, sartorial keffiyeh echo park vegan.</p>
+ </div>
+ </div>
+
+ <h4>Tabs with fade</h4>
+
+ <ul class="nav nav-tabs" role="tablist">
+ <li class="nav-item" role="presentation">
+ <button type="button" class="nav-link active" data-bs-toggle="tab" data-bs-target="#home2" role="tab" aria-selected="true">Home</button>
+ </li>
+ <li class="nav-item" role="presentation">
+ <button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#profile2" role="tab">Profile</button>
+ </li>
+ <li class="nav-item" role="presentation">
+ <button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#fat2" role="tab">@fat</button>
+ </li>
+ <li class="nav-item" role="presentation">
+ <button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#mdo2" role="tab">@mdo</button>
+ </li>
+ </ul>
+
+ <div class="tab-content" role="tablist">
+ <div class="tab-pane fade show active" id="home2" role="tabpanel">
+ <p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
+ <p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
+ </div>
+ <div class="tab-pane fade" id="profile2" role="tabpanel">
+ <p>Food truck fixie locavore, accusamus mcsweeney's marfa nulla single-origin coffee squid. Exercitation +1 labore velit, blog sartorial PBR leggings next level wes anderson artisan four loko farm-to-table craft beer twee. Qui photo booth letterpress, commodo enim craft beer mlkshk aliquip jean shorts ullamco ad vinyl cillum PBR. Homo nostrud organic, assumenda labore aesthetic magna delectus mollit. Keytar helvetica VHS salvia yr, vero magna velit sapiente labore stumptown. Vegan fanny pack odio cillum wes anderson 8-bit, sustainable jean shorts beard ut DIY ethical culpa terry richardson biodiesel. Art party scenester stumptown, tumblr butcher vero sint qui sapiente accusamus tattooed echo park.</p>
+ <p>Food truck fixie locavore, accusamus mcsweeney's marfa nulla single-origin coffee squid. Exercitation +1 labore velit, blog sartorial PBR leggings next level wes anderson artisan four loko farm-to-table craft beer twee. Qui photo booth letterpress, commodo enim craft beer mlkshk aliquip jean shorts ullamco ad vinyl cillum PBR. Homo nostrud organic, assumenda labore aesthetic magna delectus mollit. Keytar helvetica VHS salvia yr, vero magna velit sapiente labore stumptown. Vegan fanny pack odio cillum wes anderson 8-bit, sustainable jean shorts beard ut DIY ethical culpa terry richardson biodiesel. Art party scenester stumptown, tumblr butcher vero sint qui sapiente accusamus tattooed echo park.</p>
+ </div>
+ <div class="tab-pane fade" id="fat2" role="tabpanel">
+ <p>Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable tofu synth chambray yr.</p>
+ <p>Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable tofu synth chambray yr.</p>
+ </div>
+ <div class="tab-pane fade" id="mdo2" role="tabpanel">
+ <p>Trust fund seitan letterpress, keytar raw denim keffiyeh etsy art party before they sold out master cleanse gluten-free squid scenester freegan cosby sweater. Fanny pack portland seitan DIY, art party locavore wolf cliche high life echo park Austin. Cred vinyl keffiyeh DIY salvia PBR, banh mi before they sold out farm-to-table VHS viral locavore cosby sweater. Lomo wolf viral, mustache readymade thundercats keffiyeh craft beer marfa ethical. Wolf salvia freegan, sartorial keffiyeh echo park vegan.</p>
+ <p>Trust fund seitan letterpress, keytar raw denim keffiyeh etsy art party before they sold out master cleanse gluten-free squid scenester freegan cosby sweater. Fanny pack portland seitan DIY, art party locavore wolf cliche high life echo park Austin. Cred vinyl keffiyeh DIY salvia PBR, banh mi before they sold out farm-to-table VHS viral locavore cosby sweater. Lomo wolf viral, mustache readymade thundercats keffiyeh craft beer marfa ethical. Wolf salvia freegan, sartorial keffiyeh echo park vegan.</p>
+ </div>
+ </div>
+
+ <h4>Tabs without fade (no initially active pane)</h4>
+
+ <ul class="nav nav-tabs" role="tablist">
+ <li class="nav-item" role="presentation">
+ <button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#home3" role="tab">Home</button>
+ </li>
+ <li class="nav-item" role="presentation">
+ <button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#profile3" role="tab">Profile</button>
+ </li>
+ <li class="nav-item" role="presentation">
+ <button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#fat3" role="tab">@fat</button>
+ </li>
+ <li class="nav-item" role="presentation">
+ <button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#mdo3" role="tab">@mdo</button>
+ </li>
+ </ul>
+
+ <div class="tab-content" role="tablist">
+ <div class="tab-pane" id="home3" role="tabpanel">
+ <p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
+ <p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
+ </div>
+ <div class="tab-pane" id="profile3" role="tabpanel">
+ <p>Food truck fixie locavore, accusamus mcsweeney's marfa nulla single-origin coffee squid. Exercitation +1 labore velit, blog sartorial PBR leggings next level wes anderson artisan four loko farm-to-table craft beer twee. Qui photo booth letterpress, commodo enim craft beer mlkshk aliquip jean shorts ullamco ad vinyl cillum PBR. Homo nostrud organic, assumenda labore aesthetic magna delectus mollit. Keytar helvetica VHS salvia yr, vero magna velit sapiente labore stumptown. Vegan fanny pack odio cillum wes anderson 8-bit, sustainable jean shorts beard ut DIY ethical culpa terry richardson biodiesel. Art party scenester stumptown, tumblr butcher vero sint qui sapiente accusamus tattooed echo park.</p>
+ <p>Food truck fixie locavore, accusamus mcsweeney's marfa nulla single-origin coffee squid. Exercitation +1 labore velit, blog sartorial PBR leggings next level wes anderson artisan four loko farm-to-table craft beer twee. Qui photo booth letterpress, commodo enim craft beer mlkshk aliquip jean shorts ullamco ad vinyl cillum PBR. Homo nostrud organic, assumenda labore aesthetic magna delectus mollit. Keytar helvetica VHS salvia yr, vero magna velit sapiente labore stumptown. Vegan fanny pack odio cillum wes anderson 8-bit, sustainable jean shorts beard ut DIY ethical culpa terry richardson biodiesel. Art party scenester stumptown, tumblr butcher vero sint qui sapiente accusamus tattooed echo park.</p>
+ </div>
+ <div class="tab-pane" id="fat3" role="tabpanel">
+ <p>Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable tofu synth chambray yr.</p>
+ <p>Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable tofu synth chambray yr.</p>
+ </div>
+ <div class="tab-pane" id="mdo3" role="tabpanel">
+ <p>Trust fund seitan letterpress, keytar raw denim keffiyeh etsy art party before they sold out master cleanse gluten-free squid scenester freegan cosby sweater. Fanny pack portland seitan DIY, art party locavore wolf cliche high life echo park Austin. Cred vinyl keffiyeh DIY salvia PBR, banh mi before they sold out farm-to-table VHS viral locavore cosby sweater. Lomo wolf viral, mustache readymade thundercats keffiyeh craft beer marfa ethical. Wolf salvia freegan, sartorial keffiyeh echo park vegan.</p>
+ <p>Trust fund seitan letterpress, keytar raw denim keffiyeh etsy art party before they sold out master cleanse gluten-free squid scenester freegan cosby sweater. Fanny pack portland seitan DIY, art party locavore wolf cliche high life echo park Austin. Cred vinyl keffiyeh DIY salvia PBR, banh mi before they sold out farm-to-table VHS viral locavore cosby sweater. Lomo wolf viral, mustache readymade thundercats keffiyeh craft beer marfa ethical. Wolf salvia freegan, sartorial keffiyeh echo park vegan.</p>
+ </div>
+ </div>
+
+ <h4>Tabs with fade (no initially active pane)</h4>
+
+ <ul class="nav nav-tabs" role="tablist">
+ <li class="nav-item" role="presentation">
+ <button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#home4" role="tab">Home</button>
+ </li>
+ <li class="nav-item" role="presentation">
+ <button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#profile4" role="tab">Profile</button>
+ </li>
+ <li class="nav-item" role="presentation">
+ <button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#fat4" role="tab">@fat</button>
+ </li>
+ <li class="nav-item" role="presentation">
+ <button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#mdo4" role="tab">@mdo</button>
+ </li>
+ </ul>
+
+ <div class="tab-content">
+ <div class="tab-pane fade" id="home4" role="tabpanel">
+ <p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
+ <p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
+ </div>
+ <div class="tab-pane fade" id="profile4" role="tabpanel">
+ <p>Food truck fixie locavore, accusamus mcsweeney's marfa nulla single-origin coffee squid. Exercitation +1 labore velit, blog sartorial PBR leggings next level wes anderson artisan four loko farm-to-table craft beer twee. Qui photo booth letterpress, commodo enim craft beer mlkshk aliquip jean shorts ullamco ad vinyl cillum PBR. Homo nostrud organic, assumenda labore aesthetic magna delectus mollit. Keytar helvetica VHS salvia yr, vero magna velit sapiente labore stumptown. Vegan fanny pack odio cillum wes anderson 8-bit, sustainable jean shorts beard ut DIY ethical culpa terry richardson biodiesel. Art party scenester stumptown, tumblr butcher vero sint qui sapiente accusamus tattooed echo park.</p>
+ <p>Food truck fixie locavore, accusamus mcsweeney's marfa nulla single-origin coffee squid. Exercitation +1 labore velit, blog sartorial PBR leggings next level wes anderson artisan four loko farm-to-table craft beer twee. Qui photo booth letterpress, commodo enim craft beer mlkshk aliquip jean shorts ullamco ad vinyl cillum PBR. Homo nostrud organic, assumenda labore aesthetic magna delectus mollit. Keytar helvetica VHS salvia yr, vero magna velit sapiente labore stumptown. Vegan fanny pack odio cillum wes anderson 8-bit, sustainable jean shorts beard ut DIY ethical culpa terry richardson biodiesel. Art party scenester stumptown, tumblr butcher vero sint qui sapiente accusamus tattooed echo park.</p>
+ </div>
+ <div class="tab-pane fade" id="fat4" role="tabpanel">
+ <p>Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable tofu synth chambray yr.</p>
+ <p>Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable tofu synth chambray yr.</p>
+ </div>
+ <div class="tab-pane fade" id="mdo4" role="tabpanel">
+ <p>Trust fund seitan letterpress, keytar raw denim keffiyeh etsy art party before they sold out master cleanse gluten-free squid scenester freegan cosby sweater. Fanny pack portland seitan DIY, art party locavore wolf cliche high life echo park Austin. Cred vinyl keffiyeh DIY salvia PBR, banh mi before they sold out farm-to-table VHS viral locavore cosby sweater. Lomo wolf viral, mustache readymade thundercats keffiyeh craft beer marfa ethical. Wolf salvia freegan, sartorial keffiyeh echo park vegan.</p>
+ <p>Trust fund seitan letterpress, keytar raw denim keffiyeh etsy art party before they sold out master cleanse gluten-free squid scenester freegan cosby sweater. Fanny pack portland seitan DIY, art party locavore wolf cliche high life echo park Austin. Cred vinyl keffiyeh DIY salvia PBR, banh mi before they sold out farm-to-table VHS viral locavore cosby sweater. Lomo wolf viral, mustache readymade thundercats keffiyeh craft beer marfa ethical. Wolf salvia freegan, sartorial keffiyeh echo park vegan.</p>
+ </div>
+ </div>
+
+ <h4>Tabs with nav and using links (with fade)</h4>
+ <nav>
+ <div class="nav nav-pills" id="nav-tab" role="tablist">
+ <a class="nav-link nav-item active" role="tab" data-bs-toggle="tab" href="#home5">Home</a>
+ <a class="nav-link nav-item" role="tab" data-bs-toggle="tab" href="#profile5">Profile</a>
+ <a class="nav-link nav-item" role="tab" data-bs-toggle="tab" href="#fat5">@fat</a>
+ <a class="nav-link nav-item" role="tab" data-bs-toggle="tab" href="#mdo5">@mdo</a>
+ <a class="nav-link nav-item disabled" role="tab" href="#">Disabled</a>
+ </div>
+ </nav>
+
+ <div class="tab-content">
+ <div class="tab-pane fade show active" id="home5" role="tabpanel">
+ <p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
+ <p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
+ </div>
+ <div class="tab-pane fade" id="profile5" role="tabpanel">
+ <p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
+ <p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
+ </div>
+ <div class="tab-pane fade" id="fat5" role="tabpanel">
+ <p>Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable tofu synth chambray yr.</p>
+ <p>Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable tofu synth chambray yr.</p>
+ </div>
+ <div class="tab-pane fade" id="mdo5" role="tabpanel">
+ <p>Trust fund seitan letterpress, keytar raw denim keffiyeh etsy art party before they sold out master cleanse gluten-free squid scenester freegan cosby sweater. Fanny pack portland seitan DIY, art party locavore wolf cliche high life echo park Austin. Cred vinyl keffiyeh DIY salvia PBR, banh mi before they sold out farm-to-table VHS viral locavore cosby sweater. Lomo wolf viral, mustache readymade thundercats keffiyeh craft beer marfa ethical. Wolf salvia freegan, sartorial keffiyeh echo park vegan.</p>
+ <p>Trust fund seitan letterpress, keytar raw denim keffiyeh etsy art party before they sold out master cleanse gluten-free squid scenester freegan cosby sweater. Fanny pack portland seitan DIY, art party locavore wolf cliche high life echo park Austin. Cred vinyl keffiyeh DIY salvia PBR, banh mi before they sold out farm-to-table VHS viral locavore cosby sweater. Lomo wolf viral, mustache readymade thundercats keffiyeh craft beer marfa ethical. Wolf salvia freegan, sartorial keffiyeh echo park vegan.</p>
+ </div>
+ </div>
+
+ <h4>Tabs with list-group (with fade)</h4>
+ <div class="row">
+ <div class="col-4">
+ <div class="list-group" id="list-tab" role="tablist">
+ <button type="button" class="list-group-item list-group-item-action active" id="list-home-list" data-bs-toggle="tab" data-bs-target="#list-home" role="tab" aria-controls="list-home" aria-selected="true">Home</button>
+ <button type="button" class="list-group-item list-group-item-action" id="list-profile-list" data-bs-toggle="tab" data-bs-target="#list-profile" role="tab" aria-controls="list-profile">Profile</button>
+ <button type="button" class="list-group-item list-group-item-action" id="list-messages-list" data-bs-toggle="tab" data-bs-target="#list-messages" role="tab" aria-controls="list-messages">Messages</button>
+ <button type="button" class="list-group-item list-group-item-action" id="list-settings-list" data-bs-toggle="tab" data-bs-target="#list-settings" role="tab" aria-controls="list-settings">Settings</button>
+ </div>
+ </div>
+ <div class="col-8">
+ <div class="tab-content" id="nav-tabContent">
+ <div class="tab-pane fade show active" id="list-home" role="tabpanel" aria-labelledby="list-home-list">
+ <p>Velit aute mollit ipsum ad dolor consectetur nulla officia culpa adipisicing exercitation fugiat tempor. Voluptate deserunt sit sunt nisi aliqua fugiat proident ea ut. Mollit voluptate reprehenderit occaecat nisi ad non minim tempor sunt voluptate consectetur exercitation id ut nulla. Ea et fugiat aliquip nostrud sunt incididunt consectetur culpa aliquip eiusmod dolor. Anim ad Lorem aliqua in cupidatat nisi enim eu nostrud do aliquip veniam minim.</p>
+ </div>
+ <div class="tab-pane fade" id="list-profile" role="tabpanel" aria-labelledby="list-profile-list">
+ <p>Cupidatat quis ad sint excepteur laborum in esse qui. Et excepteur consectetur ex nisi eu do cillum ad laborum. Mollit et eu officia dolore sunt Lorem culpa qui commodo velit ex amet id ex. Officia anim incididunt laboris deserunt anim aute dolor incididunt veniam aute dolore do exercitation. Dolor nisi culpa ex ad irure in elit eu dolore. Ad laboris ipsum reprehenderit irure non commodo enim culpa commodo veniam incididunt veniam ad.</p>
+ </div>
+ <div class="tab-pane fade" id="list-messages" role="tabpanel" aria-labelledby="list-messages-list">
+ <p>Ut ut do pariatur aliquip aliqua aliquip exercitation do nostrud commodo reprehenderit aute ipsum voluptate. Irure Lorem et laboris nostrud amet cupidatat cupidatat anim do ut velit mollit consequat enim tempor. Consectetur est minim nostrud nostrud consectetur irure labore voluptate irure. Ipsum id Lorem sit sint voluptate est pariatur eu ad cupidatat et deserunt culpa sit eiusmod deserunt. Consectetur et fugiat anim do eiusmod aliquip nulla laborum elit adipisicing pariatur cillum.</p>
+ </div>
+ <div class="tab-pane fade" id="list-settings" role="tabpanel" aria-labelledby="list-settings-list">
+ <p>Irure enim occaecat labore sit qui aliquip reprehenderit amet velit. Deserunt ullamco ex elit nostrud ut dolore nisi officia magna sit occaecat laboris sunt dolor. Nisi eu minim cillum occaecat aute est cupidatat aliqua labore aute occaecat ea aliquip sunt amet. Aute mollit dolor ut exercitation irure commodo non amet consectetur quis amet culpa. Quis ullamco nisi amet qui aute irure eu. Magna labore dolor quis ex labore id nostrud deserunt dolor eiusmod eu pariatur culpa mollit in irure.</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <script src="../../../dist/js/bootstrap.bundle.js"></script>
+ </body>
+</html>
diff --git a/js/tests/visual/toast.html b/js/tests/visual/toast.html
new file mode 100644
index 0000000..f86926d
--- /dev/null
+++ b/js/tests/visual/toast.html
@@ -0,0 +1,70 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link href="../../../dist/css/bootstrap.min.css" rel="stylesheet">
+ <title>Toast</title>
+ <style>
+ .notifications {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ }
+ </style>
+ </head>
+ <body>
+ <div class="container">
+ <h1>Toast <small>Bootstrap Visual Test</small></h1>
+
+ <div class="row mt-3">
+ <div class="col-md-12">
+ <button id="btnShowToast" class="btn btn-primary">Show toast</button>
+ <button id="btnHideToast" class="btn btn-primary">Hide toast</button>
+ </div>
+ </div>
+ </div>
+
+ <div class="notifications">
+ <div id="toastAutoHide" class="toast" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="2000">
+ <div class="toast-header">
+ <span class="d-block bg-primary rounded me-2" style="width: 20px; height: 20px;"></span>
+ <strong class="me-auto">Bootstrap</strong>
+ <small>11 mins ago</small>
+ </div>
+ <div class="toast-body">
+ Hello, world! This is a toast message with <strong>autohide</strong> in 2 seconds
+ </div>
+ </div>
+
+ <div class="toast" data-bs-autohide="false" role="alert" aria-live="assertive" aria-atomic="true">
+ <div class="toast-header">
+ <span class="d-block bg-primary rounded me-2" style="width: 20px; height: 20px;"></span>
+ <strong class="me-auto">Bootstrap</strong>
+ <small class="text-muted">2 seconds ago</small>
+ <button type="button" class="ms-2 mb-1 btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
+ </div>
+ <div class="toast-body">
+ Heads up, toasts will stack automatically
+ </div>
+ </div>
+ </div>
+
+ <script src="../../../dist/js/bootstrap.bundle.js"></script>
+ <script>
+ /* global bootstrap: false */
+
+ window.addEventListener('load', () => {
+ document.querySelectorAll('.toast').forEach(toastEl => new bootstrap.Toast(toastEl))
+
+ document.getElementById('btnShowToast').addEventListener('click', () => {
+ document.querySelectorAll('.toast').forEach(toastEl => bootstrap.Toast.getInstance(toastEl).show())
+ })
+
+ document.getElementById('btnHideToast').addEventListener('click', () => {
+ document.querySelectorAll('.toast').forEach(toastEl => bootstrap.Toast.getInstance(toastEl).hide())
+ })
+ })
+ </script>
+ </body>
+</html>
diff --git a/js/tests/visual/tooltip.html b/js/tests/visual/tooltip.html
new file mode 100644
index 0000000..1a3b9f2
--- /dev/null
+++ b/js/tests/visual/tooltip.html
@@ -0,0 +1,138 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link href="../../../dist/css/bootstrap.min.css" rel="stylesheet">
+ <title>Tooltip</title>
+ <style>
+ #target {
+ border: 1px solid;
+ width: 100px;
+ height: 50px;
+ margin-left: 50px;
+ transform: rotate(270deg);
+ margin-top: 100px;
+ }
+ </style>
+ </head>
+ <body>
+ <div class="container">
+ <h1>Tooltip <small>Bootstrap Visual Test</small></h1>
+
+ <p class="text-muted">Tight pants next level keffiyeh <a href="#" data-bs-toggle="tooltip" title="Default tooltip">you probably</a> haven't heard of them. Photo booth beard raw denim letterpress vegan messenger bag stumptown. Farm-to-table seitan, mcsweeney's fixie sustainable quinoa 8-bit american apparel <a href="#" data-bs-toggle="tooltip" title="Another tooltip">have a</a> terry richardson vinyl chambray. Beard stumptown, cardigans banh mi lomo thundercats. Tofu biodiesel williamsburg marfa, four loko mcsweeney's cleanse vegan chambray. A really ironic artisan <a href="#" data-bs-toggle="tooltip" title="Another one here too">whatever keytar</a>, scenester farm-to-table banksy Austin <a href="#" data-bs-toggle="tooltip" title="The last tip!">twitter handle</a> freegan cred raw denim single-origin coffee viral.</p>
+
+ <hr>
+
+ <div class="row">
+ <p>
+ <button type="button" class="btn btn-secondary" data-bs-toggle="tooltip" data-bs-placement="auto" title="Tooltip on auto">
+ Tooltip on auto
+ </button>
+ <button type="button" class="btn btn-secondary" data-bs-toggle="tooltip" data-bs-placement="top" title="Tooltip on top">
+ Tooltip on top
+ </button>
+ <button type="button" class="btn btn-secondary" data-bs-toggle="tooltip" data-bs-placement="right" title="Tooltip on right">
+ Tooltip on end
+ </button>
+ <button type="button" class="btn btn-secondary" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Tooltip on bottom">
+ Tooltip on bottom
+ </button>
+ <button type="button" class="btn btn-secondary" data-bs-toggle="tooltip" data-bs-placement="left" title="Tooltip on left">
+ Tooltip on start
+ </button>
+ </p>
+ </div>
+ <div class="row">
+ <p>
+ <button type="button" class="btn btn-secondary" data-bs-toggle="tooltip" data-bs-placement="left" title="Tooltip with container (selector)" data-bs-container="#customContainer">
+ Tooltip with container (selector)
+ </button>
+ <button id="tooltipElement" type="button" class="btn btn-secondary" data-bs-placement="left" title="Tooltip with container (element)">
+ Tooltip with container (element)
+ </button>
+ <button type="button" class="btn btn-secondary" data-bs-toggle="tooltip" data-bs-html="true" title="<em>Tooltip</em> <u>with</u> <b>HTML</b>">
+ Tooltip with HTML
+ </button>
+ <button type="button" class="btn btn-secondary" data-bs-toggle="tooltip" data-bs-placement="left" title="Tooltip with XSS" data-bs-container="<img src=1 onerror=alert(123)>">
+ Tooltip with XSS
+ </button>
+ </p>
+ </div>
+ <div class="row">
+ <div class="col-sm-3">
+ <div id="target" title="Test tooltip on transformed element"></div>
+ </div>
+ <div id="shadow" class="pt-5"></div>
+ </div>
+ <div id="customContainer"></div>
+
+ <div class="row mt-4 border-top">
+ <hr>
+ <div class="h4">Test Selector triggered tooltips</div>
+ <div id="wrapperTriggeredBySelector">
+ <div class="py-2 selectorButtonsBlock">
+ <button type="button" class="btn btn-secondary bs-dynamic-tooltip" title="random title">Using title</button>
+ <button type="button" class="btn btn-secondary bs-dynamic-tooltip" data-bs-title="random title">Using bs-title</button>
+ </div>
+
+ </div>
+ <div class="mt-3">
+ <button type="button" class="btn btn-primary" onclick="duplicateButtons()">Duplicate above two buttons</button>
+ </div>
+ </div>
+ </div>
+
+ <script src="../../../dist/js/bootstrap.bundle.js"></script>
+ <script>
+ /* global bootstrap: false */
+
+ if (typeof document.body.attachShadow === 'function') {
+ const shadowRoot = document.getElementById('shadow').attachShadow({ mode: 'open' })
+ shadowRoot.innerHTML =
+ '<button id="firstShadowTooltip" type="button" class="btn btn-secondary" data-bs-toggle="tooltip" data-bs-placement="top" title="Tooltip on top in a shadow dom">' +
+ ' Tooltip on top in a shadow dom' +
+ '</button>' +
+ '<button id="secondShadowTooltip" type="button" class="btn btn-secondary" data-bs-toggle="tooltip" data-bs-placement="top" title="Tooltip on top in a shadow dom with container option">' +
+ ' Tooltip on top in a shadow dom' +
+ '</button>'
+
+ new bootstrap.Tooltip(shadowRoot.firstChild)
+ new bootstrap.Tooltip(shadowRoot.getElementById('secondShadowTooltip'), {
+ container: shadowRoot
+ })
+ }
+
+ new bootstrap.Tooltip('#tooltipElement', {
+ container: '#customContainer'
+ })
+
+ const targetTooltip = new bootstrap.Tooltip('#target', {
+ placement: 'top',
+ trigger: 'manual'
+ })
+ targetTooltip.show()
+
+ document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(tooltipEl => new bootstrap.Tooltip(tooltipEl))
+ </script>
+
+ <script>
+ /* global bootstrap: false */
+
+ new bootstrap.Tooltip('#wrapperTriggeredBySelector', {
+ animation: false,
+ selector: '.bs-dynamic-tooltip'
+ })
+
+ /* eslint-disable no-unused-vars */
+ function duplicateButtons() {
+ const buttonsBlock = document.querySelector('.selectorButtonsBlock')// get first
+ const buttonsBlockClone = buttonsBlock.cloneNode(true)
+ buttonsBlockClone.innerHTML += new Date().toLocaleString()
+ document.querySelector('#wrapperTriggeredBySelector').append(buttonsBlockClone)
+ }
+ /* eslint-enable no-unused-vars */
+ </script>
+
+ </body>
+</html>