1
0
Fork 0

Adding upstream version 5.2.3+dfsg.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-02-17 07:02:47 +01:00
parent 8ae304677e
commit 5d8756ab77
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
617 changed files with 89471 additions and 0 deletions

19
js/index.esm.js Normal file
View file

@ -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'

34
js/index.umd.js Normal file
View file

@ -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
}

87
js/src/alert.js Normal file
View file

@ -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

85
js/src/base-component.js Normal file
View file

@ -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

72
js/src/button.js Normal file
View file

@ -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

475
js/src/carousel.js Normal file
View file

@ -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

302
js/src/collapse.js Normal file
View file

@ -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

55
js/src/dom/data.js Normal file
View file

@ -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)
}
}
}

320
js/src/dom/event-handler.js Normal file
View file

@ -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

71
js/src/dom/manipulator.js Normal file
View file

@ -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

View file

@ -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

454
js/src/dropdown.js Normal file
View file

@ -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

377
js/src/modal.js Normal file
View file

@ -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

283
js/src/offcanvas.js Normal file
View file

@ -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

97
js/src/popover.js Normal file
View file

@ -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

294
js/src/scrollspy.js Normal file
View file

@ -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

305
js/src/tab.js Normal file
View file

@ -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

225
js/src/toast.js Normal file
View file

@ -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

633
js/src/tooltip.js Normal file
View file

@ -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

149
js/src/util/backdrop.js Normal file
View file

@ -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

View file

@ -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
}

66
js/src/util/config.js Normal file
View file

@ -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

115
js/src/util/focustrap.js Normal file
View file

@ -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

336
js/src/util/index.js Normal file
View file

@ -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
}

118
js/src/util/sanitizer.js Normal file
View file

@ -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
}

114
js/src/util/scrollbar.js Normal file
View file

@ -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

146
js/src/util/swipe.js Normal file
View file

@ -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

View file

@ -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

73
js/tests/README.md Normal file
View file

@ -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()
})
})
```

79
js/tests/browsers.js Normal file
View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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))
})

View file

@ -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))
})

View file

@ -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>

View file

@ -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

View file

@ -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'
})
]
}

171
js/tests/karma.conf.js Normal file
View file

@ -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)
}

View file

@ -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"
}
}

259
js/tests/unit/alert.spec.js Normal file
View file

@ -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)
})
})
})

View file

@ -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)
})
})
})
})

View file

@ -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)
})
})
})

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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 */
})

View file

@ -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)
})
})
})
})

View file

@ -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)
})
})
})

View file

@ -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)
})
})
})

File diff suppressed because it is too large Load diff

View file

@ -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')
})
})
})

1298
js/tests/unit/modal.spec.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -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()
})
})
})

View file

@ -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')
})
})
})

View file

@ -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()
})
})
})

1101
js/tests/unit/tab.spec.js Normal file

File diff suppressed because it is too large Load diff

670
js/tests/unit/toast.spec.js Normal file
View file

@ -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)
})
})
})

File diff suppressed because it is too large Load diff

View file

@ -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()
})
})
})
})
})
})
})

View file

@ -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()
})
})
})

View file

@ -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()
})
})
})

View file

@ -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()
})
})
})

View file

@ -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')
})
})
})

View file

@ -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')
})
})
})

View file

@ -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()
})
})
})
})

View file

@ -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()
})
})
})

View file

@ -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')
})
})
})

View file

@ -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"
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

275
js/tests/visual/modal.html Normal file
View file

@ -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>

View file

@ -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>

View file

@ -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>

223
js/tests/visual/tab.html Normal file
View file

@ -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>

View file

@ -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>

View file

@ -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>