diff options
Diffstat (limited to 'js/src')
| -rw-r--r-- | js/src/alert.js | 92 | ||||
| -rw-r--r-- | js/src/base-component.js | 43 | ||||
| -rw-r--r-- | js/src/button.js | 21 | ||||
| -rw-r--r-- | js/src/carousel.js | 216 | ||||
| -rw-r--r-- | js/src/collapse.js | 218 | ||||
| -rw-r--r-- | js/src/dom/data.js | 82 | ||||
| -rw-r--r-- | js/src/dom/event-handler.js | 40 | ||||
| -rw-r--r-- | js/src/dom/manipulator.js | 6 | ||||
| -rw-r--r-- | js/src/dom/selector-engine.js | 19 | ||||
| -rw-r--r-- | js/src/dropdown.js | 300 | ||||
| -rw-r--r-- | js/src/modal.js | 350 | ||||
| -rw-r--r-- | js/src/offcanvas.js | 272 | ||||
| -rw-r--r-- | js/src/popover.js | 63 | ||||
| -rw-r--r-- | js/src/scrollspy.js | 70 | ||||
| -rw-r--r-- | js/src/tab.js | 42 | ||||
| -rw-r--r-- | js/src/toast.js | 115 | ||||
| -rw-r--r-- | js/src/tooltip.js | 247 | ||||
| -rw-r--r-- | js/src/util/backdrop.js | 130 | ||||
| -rw-r--r-- | js/src/util/component-functions.js | 34 | ||||
| -rw-r--r-- | js/src/util/focustrap.js | 109 | ||||
| -rw-r--r-- | js/src/util/index.js | 163 | ||||
| -rw-r--r-- | js/src/util/sanitizer.js | 6 | ||||
| -rw-r--r-- | js/src/util/scrollbar.js | 97 |
23 files changed, 1556 insertions, 1179 deletions
diff --git a/js/src/alert.js b/js/src/alert.js index 3a018a638..601078fc6 100644 --- a/js/src/alert.js +++ b/js/src/alert.js @@ -1,19 +1,14 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-beta2): alert.js + * Bootstrap (v5.1.0): alert.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ -import { - defineJQueryPlugin, - emulateTransitionEnd, - getElementFromSelector, - getTransitionDurationFromElement -} from './util/index' -import Data from './dom/data' +import { defineJQueryPlugin } from './util/index' import EventHandler from './dom/event-handler' import BaseComponent from './base-component' +import { enableDismissTrigger } from './util/component-functions' /** * ------------------------------------------------------------------------ @@ -24,15 +19,9 @@ import BaseComponent from './base-component' const NAME = 'alert' const DATA_KEY = 'bs.alert' const EVENT_KEY = `.${DATA_KEY}` -const DATA_API_KEY = '.data-api' - -const SELECTOR_DISMISS = '[data-bs-dismiss="alert"]' const EVENT_CLOSE = `close${EVENT_KEY}` const EVENT_CLOSED = `closed${EVENT_KEY}` -const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` - -const CLASS_NAME_ALERT = 'alert' const CLASS_NAME_FADE = 'fade' const CLASS_NAME_SHOW = 'show' @@ -45,79 +34,48 @@ const CLASS_NAME_SHOW = 'show' class Alert extends BaseComponent { // Getters - static get DATA_KEY() { - return DATA_KEY + static get NAME() { + return NAME } // Public - close(element) { - const rootElement = element ? this._getRootElement(element) : this._element - const customEvent = this._triggerCloseEvent(rootElement) + close() { + const closeEvent = EventHandler.trigger(this._element, EVENT_CLOSE) - if (customEvent === null || customEvent.defaultPrevented) { + if (closeEvent.defaultPrevented) { return } - this._removeElement(rootElement) - } - - // Private - - _getRootElement(element) { - return getElementFromSelector(element) || element.closest(`.${CLASS_NAME_ALERT}`) - } + this._element.classList.remove(CLASS_NAME_SHOW) - _triggerCloseEvent(element) { - return EventHandler.trigger(element, EVENT_CLOSE) + const isAnimated = this._element.classList.contains(CLASS_NAME_FADE) + this._queueCallback(() => this._destroyElement(), this._element, isAnimated) } - _removeElement(element) { - element.classList.remove(CLASS_NAME_SHOW) - - if (!element.classList.contains(CLASS_NAME_FADE)) { - this._destroyElement(element) - return - } - - const transitionDuration = getTransitionDurationFromElement(element) - - EventHandler.one(element, 'transitionend', () => this._destroyElement(element)) - emulateTransitionEnd(element, transitionDuration) - } - - _destroyElement(element) { - if (element.parentNode) { - element.parentNode.removeChild(element) - } - - EventHandler.trigger(element, EVENT_CLOSED) + // Private + _destroyElement() { + this._element.remove() + EventHandler.trigger(this._element, EVENT_CLOSED) + this.dispose() } // Static static jQueryInterface(config) { return this.each(function () { - let data = Data.getData(this, DATA_KEY) + const data = Alert.getOrCreateInstance(this) - if (!data) { - data = new Alert(this) + if (typeof config !== 'string') { + return } - if (config === 'close') { - data[config](this) - } - }) - } - - static handleDismiss(alertInstance) { - return function (event) { - if (event) { - event.preventDefault() + if (data[config] === undefined || config.startsWith('_') || config === 'constructor') { + throw new TypeError(`No method named "${config}"`) } - alertInstance.close(this) - } + data[config](this) + }) } } @@ -126,8 +84,8 @@ class Alert extends BaseComponent { * Data Api implementation * ------------------------------------------------------------------------ */ -EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DISMISS, Alert.handleDismiss(new Alert())) +enableDismissTrigger(Alert, 'close') /** * ------------------------------------------------------------------------ * jQuery @@ -135,6 +93,6 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DISMISS, Alert.handleDi * add .Alert to jQuery only if jQuery is present */ -defineJQueryPlugin(NAME, Alert) +defineJQueryPlugin(Alert) export default Alert diff --git a/js/src/base-component.js b/js/src/base-component.js index 9de274bd0..e7b4112a9 100644 --- a/js/src/base-component.js +++ b/js/src/base-component.js @@ -1,11 +1,16 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-beta2): base-component.js + * Bootstrap (v5.1.0): 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' /** * ------------------------------------------------------------------------ @@ -13,32 +18,58 @@ import Data from './dom/data' * ------------------------------------------------------------------------ */ -const VERSION = '5.0.0-beta2' +const VERSION = '5.1.0' class BaseComponent { constructor(element) { + element = getElement(element) + if (!element) { return } this._element = element - Data.setData(element, this.constructor.DATA_KEY, this) + Data.set(this._element, this.constructor.DATA_KEY, this) } dispose() { - Data.removeData(this._element, this.constructor.DATA_KEY) - this._element = null + Data.remove(this._element, this.constructor.DATA_KEY) + EventHandler.off(this._element, this.constructor.EVENT_KEY) + + Object.getOwnPropertyNames(this).forEach(propertyName => { + this[propertyName] = null + }) + } + + _queueCallback(callback, element, isAnimated = true) { + executeAfterTransition(callback, element, isAnimated) } /** Static */ static getInstance(element) { - return Data.getData(element, this.DATA_KEY) + 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 NAME() { + throw new Error('You have to implement the static method "NAME", for each component!') + } + + static get DATA_KEY() { + return `bs.${this.NAME}` + } + + static get EVENT_KEY() { + return `.${this.DATA_KEY}` + } } export default BaseComponent diff --git a/js/src/button.js b/js/src/button.js index 4ec48ca08..a145fd845 100644 --- a/js/src/button.js +++ b/js/src/button.js @@ -1,12 +1,11 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-beta2): button.js + * Bootstrap (v5.1.0): button.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ import { defineJQueryPlugin } from './util/index' -import Data from './dom/data' import EventHandler from './dom/event-handler' import BaseComponent from './base-component' @@ -36,8 +35,8 @@ const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` class Button extends BaseComponent { // Getters - static get DATA_KEY() { - return DATA_KEY + static get NAME() { + return NAME } // Public @@ -51,11 +50,7 @@ class Button extends BaseComponent { static jQueryInterface(config) { return this.each(function () { - let data = Data.getData(this, DATA_KEY) - - if (!data) { - data = new Button(this) - } + const data = Button.getOrCreateInstance(this) if (config === 'toggle') { data[config]() @@ -74,11 +69,7 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, event => { event.preventDefault() const button = event.target.closest(SELECTOR_DATA_TOGGLE) - - let data = Data.getData(button, DATA_KEY) - if (!data) { - data = new Button(button) - } + const data = Button.getOrCreateInstance(button) data.toggle() }) @@ -90,6 +81,6 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, event => { * add .Button to jQuery only if jQuery is present */ -defineJQueryPlugin(NAME, Button) +defineJQueryPlugin(Button) export default Button diff --git a/js/src/carousel.js b/js/src/carousel.js index 75f8a4da7..b0aed3872 100644 --- a/js/src/carousel.js +++ b/js/src/carousel.js @@ -1,22 +1,20 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-beta2): carousel.js + * Bootstrap (v5.1.0): carousel.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ import { defineJQueryPlugin, - emulateTransitionEnd, getElementFromSelector, - getTransitionDurationFromElement, - isVisible, isRTL, + isVisible, + getNextActiveElement, reflow, triggerTransitionEnd, typeCheckConfig } from './util/index' -import Data from './dom/data' import EventHandler from './dom/event-handler' import Manipulator from './dom/manipulator' import SelectorEngine from './dom/selector-engine' @@ -56,11 +54,16 @@ const DefaultType = { touch: 'boolean' } -const DIRECTION_NEXT = 'next' -const DIRECTION_PREV = 'prev' +const ORDER_NEXT = 'next' +const ORDER_PREV = 'prev' const DIRECTION_LEFT = 'left' const DIRECTION_RIGHT = 'right' +const KEY_TO_DIRECTION = { + [ARROW_LEFT_KEY]: DIRECTION_RIGHT, + [ARROW_RIGHT_KEY]: DIRECTION_LEFT +} + const EVENT_SLIDE = `slide${EVENT_KEY}` const EVENT_SLID = `slid${EVENT_KEY}` const EVENT_KEYDOWN = `keydown${EVENT_KEY}` @@ -129,16 +132,14 @@ class Carousel extends BaseComponent { return Default } - static get DATA_KEY() { - return DATA_KEY + static get NAME() { + return NAME } // Public next() { - if (!this._isSliding) { - this._slide(DIRECTION_NEXT) - } + this._slide(ORDER_NEXT) } nextWhenVisible() { @@ -150,9 +151,7 @@ class Carousel extends BaseComponent { } prev() { - if (!this._isSliding) { - this._slide(DIRECTION_PREV) - } + this._slide(ORDER_PREV) } pause(event) { @@ -208,25 +207,11 @@ class Carousel extends BaseComponent { return } - const direction = index > activeIndex ? - DIRECTION_NEXT : - DIRECTION_PREV - - this._slide(direction, this._items[index]) - } - - dispose() { - EventHandler.off(this._element, EVENT_KEY) - - this._items = null - this._config = null - this._interval = null - this._isPaused = null - this._isSliding = null - this._activeElement = null - this._indicatorsElement = null + const order = index > activeIndex ? + ORDER_NEXT : + ORDER_PREV - super.dispose() + this._slide(order, this._items[index]) } // Private @@ -234,7 +219,8 @@ class Carousel extends BaseComponent { _getConfig(config) { config = { ...Default, - ...config + ...Manipulator.getDataAttributes(this._element), + ...(typeof config === 'object' ? config : {}) } typeCheckConfig(NAME, config, DefaultType) return config @@ -251,23 +237,11 @@ class Carousel extends BaseComponent { this.touchDeltaX = 0 - // swipe left - if (direction > 0) { - if (isRTL()) { - this.next() - } else { - this.prev() - } + if (!direction) { + return } - // swipe right - if (direction < 0) { - if (isRTL()) { - this.prev() - } else { - this.next() - } - } + this._slide(direction > 0 ? DIRECTION_RIGHT : DIRECTION_LEFT) } _addEventListeners() { @@ -286,8 +260,13 @@ class Carousel extends BaseComponent { } _addTouchEventListeners() { + const hasPointerPenTouch = event => { + return this._pointerEvent && + (event.pointerType === POINTER_TYPE_PEN || event.pointerType === POINTER_TYPE_TOUCH) + } + const start = event => { - if (this._pointerEvent && (event.pointerType === POINTER_TYPE_PEN || event.pointerType === POINTER_TYPE_TOUCH)) { + if (hasPointerPenTouch(event)) { this.touchStartX = event.clientX } else if (!this._pointerEvent) { this.touchStartX = event.touches[0].clientX @@ -296,15 +275,13 @@ class Carousel extends BaseComponent { const move = event => { // ensure swiping with one touch and not pinching - if (event.touches && event.touches.length > 1) { - this.touchDeltaX = 0 - } else { - this.touchDeltaX = event.touches[0].clientX - this.touchStartX - } + this.touchDeltaX = event.touches && event.touches.length > 1 ? + 0 : + event.touches[0].clientX - this.touchStartX } const end = event => { - if (this._pointerEvent && (event.pointerType === POINTER_TYPE_PEN || event.pointerType === POINTER_TYPE_TOUCH)) { + if (hasPointerPenTouch(event)) { this.touchDeltaX = event.clientX - this.touchStartX } @@ -348,20 +325,10 @@ class Carousel extends BaseComponent { return } - if (event.key === ARROW_LEFT_KEY) { + const direction = KEY_TO_DIRECTION[event.key] + if (direction) { event.preventDefault() - if (isRTL()) { - this.next() - } else { - this.prev() - } - } else if (event.key === ARROW_RIGHT_KEY) { - event.preventDefault() - if (isRTL()) { - this.prev() - } else { - this.next() - } + this._slide(direction) } } @@ -373,24 +340,9 @@ class Carousel extends BaseComponent { return this._items.indexOf(element) } - _getItemByDirection(direction, activeElement) { - const isNextDirection = direction === DIRECTION_NEXT - const isPrevDirection = direction === DIRECTION_PREV - const activeIndex = this._getItemIndex(activeElement) - const lastItemIndex = this._items.length - 1 - const isGoingToWrap = (isPrevDirection && activeIndex === 0) || - (isNextDirection && activeIndex === lastItemIndex) - - if (isGoingToWrap && !this._config.wrap) { - return activeElement - } - - const delta = direction === DIRECTION_PREV ? -1 : 1 - const itemIndex = (activeIndex + delta) % this._items.length - - return itemIndex === -1 ? - this._items[this._items.length - 1] : - this._items[itemIndex] + _getItemByOrder(order, activeElement) { + const isNext = order === ORDER_NEXT + return getNextActiveElement(this._items, activeElement, isNext, this._config.wrap) } _triggerSlideEvent(relatedTarget, eventDirectionName) { @@ -441,23 +393,29 @@ class Carousel extends BaseComponent { } } - _slide(direction, element) { + _slide(directionOrOrder, element) { + const order = this._directionToOrder(directionOrOrder) const activeElement = SelectorEngine.findOne(SELECTOR_ACTIVE_ITEM, this._element) const activeElementIndex = this._getItemIndex(activeElement) - const nextElement = element || (activeElement && this._getItemByDirection(direction, activeElement)) + const nextElement = element || this._getItemByOrder(order, activeElement) const nextElementIndex = this._getItemIndex(nextElement) const isCycling = Boolean(this._interval) - const directionalClassName = direction === DIRECTION_NEXT ? CLASS_NAME_START : CLASS_NAME_END - const orderClassName = direction === DIRECTION_NEXT ? CLASS_NAME_NEXT : CLASS_NAME_PREV - const eventDirectionName = direction === DIRECTION_NEXT ? DIRECTION_LEFT : DIRECTION_RIGHT + const isNext = order === ORDER_NEXT + const directionalClassName = isNext ? CLASS_NAME_START : CLASS_NAME_END + const orderClassName = isNext ? CLASS_NAME_NEXT : CLASS_NAME_PREV + const eventDirectionName = this._orderToDirection(order) if (nextElement && nextElement.classList.contains(CLASS_NAME_ACTIVE)) { this._isSliding = false return } + if (this._isSliding) { + return + } + const slideEvent = this._triggerSlideEvent(nextElement, eventDirectionName) if (slideEvent.defaultPrevented) { return @@ -477,6 +435,15 @@ class Carousel extends BaseComponent { this._setActiveIndicatorElement(nextElement) this._activeElement = nextElement + const triggerSlidEvent = () => { + EventHandler.trigger(this._element, EVENT_SLID, { + relatedTarget: nextElement, + direction: eventDirectionName, + from: activeElementIndex, + to: nextElementIndex + }) + } + if (this._element.classList.contains(CLASS_NAME_SLIDE)) { nextElement.classList.add(orderClassName) @@ -485,9 +452,7 @@ class Carousel extends BaseComponent { activeElement.classList.add(directionalClassName) nextElement.classList.add(directionalClassName) - const transitionDuration = getTransitionDurationFromElement(activeElement) - - EventHandler.one(activeElement, 'transitionend', () => { + const completeCallBack = () => { nextElement.classList.remove(directionalClassName, orderClassName) nextElement.classList.add(CLASS_NAME_ACTIVE) @@ -495,28 +460,16 @@ class Carousel extends BaseComponent { this._isSliding = false - setTimeout(() => { - EventHandler.trigger(this._element, EVENT_SLID, { - relatedTarget: nextElement, - direction: eventDirectionName, - from: activeElementIndex, - to: nextElementIndex - }) - }, 0) - }) + setTimeout(triggerSlidEvent, 0) + } - emulateTransitionEnd(activeElement, transitionDuration) + this._queueCallback(completeCallBack, activeElement, true) } else { activeElement.classList.remove(CLASS_NAME_ACTIVE) nextElement.classList.add(CLASS_NAME_ACTIVE) this._isSliding = false - EventHandler.trigger(this._element, EVENT_SLID, { - relatedTarget: nextElement, - direction: eventDirectionName, - from: activeElementIndex, - to: nextElementIndex - }) + triggerSlidEvent() } if (isCycling) { @@ -524,15 +477,36 @@ class Carousel extends BaseComponent { } } + _directionToOrder(direction) { + if (![DIRECTION_RIGHT, DIRECTION_LEFT].includes(direction)) { + return direction + } + + if (isRTL()) { + return direction === DIRECTION_LEFT ? ORDER_PREV : ORDER_NEXT + } + + return direction === DIRECTION_LEFT ? ORDER_NEXT : ORDER_PREV + } + + _orderToDirection(order) { + if (![ORDER_NEXT, ORDER_PREV].includes(order)) { + return order + } + + if (isRTL()) { + return order === ORDER_PREV ? DIRECTION_LEFT : DIRECTION_RIGHT + } + + return order === ORDER_PREV ? DIRECTION_RIGHT : DIRECTION_LEFT + } + // Static static carouselInterface(element, config) { - let data = Data.getData(element, DATA_KEY) - let _config = { - ...Default, - ...Manipulator.getDataAttributes(element) - } + const data = Carousel.getOrCreateInstance(element, config) + let { _config } = data if (typeof config === 'object') { _config = { ..._config, @@ -542,10 +516,6 @@ class Carousel extends BaseComponent { const action = typeof config === 'string' ? config : _config.slide - if (!data) { - data = new Carousel(element, _config) - } - if (typeof config === 'number') { data.to(config) } else if (typeof action === 'string') { @@ -586,7 +556,7 @@ class Carousel extends BaseComponent { Carousel.carouselInterface(target, config) if (slideIndex) { - Data.getData(target, DATA_KEY).to(slideIndex) + Carousel.getInstance(target).to(slideIndex) } event.preventDefault() @@ -605,7 +575,7 @@ EventHandler.on(window, EVENT_LOAD_DATA_API, () => { const carousels = SelectorEngine.find(SELECTOR_DATA_RIDE) for (let i = 0, len = carousels.length; i < len; i++) { - Carousel.carouselInterface(carousels[i], Data.getData(carousels[i], DATA_KEY)) + Carousel.carouselInterface(carousels[i], Carousel.getInstance(carousels[i])) } }) @@ -616,6 +586,6 @@ EventHandler.on(window, EVENT_LOAD_DATA_API, () => { * add .Carousel to jQuery only if jQuery is present */ -defineJQueryPlugin(NAME, Carousel) +defineJQueryPlugin(Carousel) export default Carousel diff --git a/js/src/collapse.js b/js/src/collapse.js index 0a1b47547..f39c55b92 100644 --- a/js/src/collapse.js +++ b/js/src/collapse.js @@ -1,17 +1,15 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-beta2): collapse.js + * Bootstrap (v5.1.0): collapse.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ import { defineJQueryPlugin, - emulateTransitionEnd, + getElement, getSelectorFromElement, getElementFromSelector, - getTransitionDurationFromElement, - isElement, reflow, typeCheckConfig } from './util/index' @@ -34,12 +32,12 @@ const DATA_API_KEY = '.data-api' const Default = { toggle: true, - parent: '' + parent: null } const DefaultType = { toggle: 'boolean', - parent: '(string|element)' + parent: '(null|element)' } const EVENT_SHOW = `show${EVENT_KEY}` @@ -52,6 +50,7 @@ const CLASS_NAME_SHOW = 'show' const CLASS_NAME_COLLAPSE = 'collapse' const CLASS_NAME_COLLAPSING = 'collapsing' const CLASS_NAME_COLLAPSED = 'collapsed' +const CLASS_NAME_HORIZONTAL = 'collapse-horizontal' const WIDTH = 'width' const HEIGHT = 'height' @@ -71,10 +70,7 @@ class Collapse extends BaseComponent { this._isTransitioning = false this._config = this._getConfig(config) - this._triggerArray = SelectorEngine.find( - `${SELECTOR_DATA_TOGGLE}[href="#${element.id}"],` + - `${SELECTOR_DATA_TOGGLE}[data-bs-target="#${element.id}"]` - ) + this._triggerArray = [] const toggleList = SelectorEngine.find(SELECTOR_DATA_TOGGLE) @@ -82,7 +78,7 @@ class Collapse extends BaseComponent { const elem = toggleList[i] const selector = getSelectorFromElement(elem) const filterElement = SelectorEngine.find(selector) - .filter(foundElem => foundElem === element) + .filter(foundElem => foundElem === this._element) if (selector !== null && filterElement.length) { this._selector = selector @@ -90,10 +86,10 @@ class Collapse extends BaseComponent { } } - this._parent = this._config.parent ? this._getParent() : null + this._initializeChildren() if (!this._config.parent) { - this._addAriaAndCollapsedClass(this._element, this._triggerArray) + this._addAriaAndCollapsedClass(this._triggerArray, this._isShown()) } if (this._config.toggle) { @@ -107,14 +103,14 @@ class Collapse extends BaseComponent { return Default } - static get DATA_KEY() { - return DATA_KEY + static get NAME() { + return NAME } // Public toggle() { - if (this._element.classList.contains(CLASS_NAME_SHOW)) { + if (this._isShown()) { this.hide() } else { this.show() @@ -122,32 +118,22 @@ class Collapse extends BaseComponent { } show() { - if (this._isTransitioning || this._element.classList.contains(CLASS_NAME_SHOW)) { + if (this._isTransitioning || this._isShown()) { return } - let actives + let actives = [] let activesData - if (this._parent) { - actives = SelectorEngine.find(SELECTOR_ACTIVES, this._parent) - .filter(elem => { - if (typeof this._config.parent === 'string') { - return elem.getAttribute('data-bs-parent') === this._config.parent - } - - return elem.classList.contains(CLASS_NAME_COLLAPSE) - }) - - if (actives.length === 0) { - actives = null - } + if (this._config.parent) { + const children = SelectorEngine.find(`.${CLASS_NAME_COLLAPSE} .${CLASS_NAME_COLLAPSE}`, this._config.parent) + actives = SelectorEngine.find(SELECTOR_ACTIVES, this._config.parent).filter(elem => !children.includes(elem)) // remove children if greater depth } const container = SelectorEngine.findOne(this._selector) - if (actives) { + if (actives.length) { const tempActiveData = actives.find(elem => container !== elem) - activesData = tempActiveData ? Data.getData(tempActiveData, DATA_KEY) : null + activesData = tempActiveData ? Collapse.getInstance(tempActiveData) : null if (activesData && activesData._isTransitioning) { return @@ -159,17 +145,15 @@ class Collapse extends BaseComponent { return } - if (actives) { - actives.forEach(elemActive => { - if (container !== elemActive) { - Collapse.collapseInterface(elemActive, 'hide') - } + actives.forEach(elemActive => { + if (container !== elemActive) { + Collapse.getOrCreateInstance(elemActive, { toggle: false }).hide() + } - if (!activesData) { - Data.setData(elemActive, DATA_KEY, null) - } - }) - } + if (!activesData) { + Data.set(elemActive, DATA_KEY, null) + } + }) const dimension = this._getDimension() @@ -178,38 +162,29 @@ class Collapse extends BaseComponent { this._element.style[dimension] = 0 - if (this._triggerArray.length) { - this._triggerArray.forEach(element => { - element.classList.remove(CLASS_NAME_COLLAPSED) - element.setAttribute('aria-expanded', true) - }) - } - - this.setTransitioning(true) + 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] = '' - this.setTransitioning(false) - EventHandler.trigger(this._element, EVENT_SHOWN) } const capitalizedDimension = dimension[0].toUpperCase() + dimension.slice(1) const scrollSize = `scroll${capitalizedDimension}` - const transitionDuration = getTransitionDurationFromElement(this._element) - EventHandler.one(this._element, 'transitionend', complete) - - emulateTransitionEnd(this._element, transitionDuration) + this._queueCallback(complete, this._element, true) this._element.style[dimension] = `${this._element[scrollSize]}px` } hide() { - if (this._isTransitioning || !this._element.classList.contains(CLASS_NAME_SHOW)) { + if (this._isTransitioning || !this._isShown()) { return } @@ -228,44 +203,31 @@ class Collapse extends BaseComponent { this._element.classList.remove(CLASS_NAME_COLLAPSE, CLASS_NAME_SHOW) const triggerArrayLength = this._triggerArray.length - if (triggerArrayLength > 0) { - for (let i = 0; i < triggerArrayLength; i++) { - const trigger = this._triggerArray[i] - const elem = getElementFromSelector(trigger) - - if (elem && !elem.classList.contains(CLASS_NAME_SHOW)) { - trigger.classList.add(CLASS_NAME_COLLAPSED) - trigger.setAttribute('aria-expanded', false) - } + for (let i = 0; i < triggerArrayLength; i++) { + const trigger = this._triggerArray[i] + const elem = getElementFromSelector(trigger) + + if (elem && !this._isShown(elem)) { + this._addAriaAndCollapsedClass([trigger], false) } } - this.setTransitioning(true) + this._isTransitioning = true const complete = () => { - this.setTransitioning(false) + 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] = '' - const transitionDuration = getTransitionDurationFromElement(this._element) - EventHandler.one(this._element, 'transitionend', complete) - emulateTransitionEnd(this._element, transitionDuration) + this._queueCallback(complete, this._element, true) } - setTransitioning(isTransitioning) { - this._isTransitioning = isTransitioning - } - - dispose() { - super.dispose() - this._config = null - this._parent = null - this._triggerArray = null - this._isTransitioning = null + _isShown(element = this._element) { + return element.classList.contains(CLASS_NAME_SHOW) } // Private @@ -273,51 +235,40 @@ class Collapse extends BaseComponent { _getConfig(config) { config = { ...Default, + ...Manipulator.getDataAttributes(this._element), ...config } config.toggle = Boolean(config.toggle) // Coerce string values + config.parent = getElement(config.parent) typeCheckConfig(NAME, config, DefaultType) return config } _getDimension() { - return this._element.classList.contains(WIDTH) ? WIDTH : HEIGHT + return this._element.classList.contains(CLASS_NAME_HORIZONTAL) ? WIDTH : HEIGHT } - _getParent() { - let { parent } = this._config - - if (isElement(parent)) { - // it's a jQuery object - if (typeof parent.jquery !== 'undefined' || typeof parent[0] !== 'undefined') { - parent = parent[0] - } - } else { - parent = SelectorEngine.findOne(parent) + _initializeChildren() { + if (!this._config.parent) { + return } - const selector = `${SELECTOR_DATA_TOGGLE}[data-bs-parent="${parent}"]` - - SelectorEngine.find(selector, parent) + const children = SelectorEngine.find(`.${CLASS_NAME_COLLAPSE} .${CLASS_NAME_COLLAPSE}`, this._config.parent) + SelectorEngine.find(SELECTOR_DATA_TOGGLE, this._config.parent).filter(elem => !children.includes(elem)) .forEach(element => { const selected = getElementFromSelector(element) - this._addAriaAndCollapsedClass( - selected, - [element] - ) + if (selected) { + this._addAriaAndCollapsedClass([element], this._isShown(selected)) + } }) - - return parent } - _addAriaAndCollapsedClass(element, triggerArray) { - if (!element || !triggerArray.length) { + _addAriaAndCollapsedClass(triggerArray, isOpen) { + if (!triggerArray.length) { return } - const isOpen = element.classList.contains(CLASS_NAME_SHOW) - triggerArray.forEach(elem => { if (isOpen) { elem.classList.remove(CLASS_NAME_COLLAPSED) @@ -331,34 +282,22 @@ class Collapse extends BaseComponent { // Static - static collapseInterface(element, config) { - let data = Data.getData(element, DATA_KEY) - const _config = { - ...Default, - ...Manipulator.getDataAttributes(element), - ...(typeof config === 'object' && config ? config : {}) - } + static jQueryInterface(config) { + return this.each(function () { + const _config = {} + if (typeof config === 'string' && /show|hide/.test(config)) { + _config.toggle = false + } - if (!data && _config.toggle && typeof config === 'string' && /show|hide/.test(config)) { - _config.toggle = false - } + const data = Collapse.getOrCreateInstance(this, _config) - if (!data) { - data = new Collapse(element, _config) - } + if (typeof config === 'string') { + if (typeof data[config] === 'undefined') { + throw new TypeError(`No method named "${config}"`) + } - if (typeof config === 'string') { - if (typeof data[config] === 'undefined') { - throw new TypeError(`No method named "${config}"`) + data[config]() } - - data[config]() - } - } - - static jQueryInterface(config) { - return this.each(function () { - Collapse.collapseInterface(this, config) }) } } @@ -375,26 +314,11 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function ( event.preventDefault() } - const triggerData = Manipulator.getDataAttributes(this) const selector = getSelectorFromElement(this) const selectorElements = SelectorEngine.find(selector) selectorElements.forEach(element => { - const data = Data.getData(element, DATA_KEY) - let config - if (data) { - // update parent attribute - if (data._parent === null && typeof triggerData.parent === 'string') { - data._config.parent = triggerData.parent - data._parent = data._getParent() - } - - config = 'toggle' - } else { - config = triggerData - } - - Collapse.collapseInterface(element, config) + Collapse.getOrCreateInstance(element, { toggle: false }).toggle() }) }) @@ -405,6 +329,6 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function ( * add .Collapse to jQuery only if jQuery is present */ -defineJQueryPlugin(NAME, Collapse) +defineJQueryPlugin(Collapse) export default Collapse diff --git a/js/src/dom/data.js b/js/src/dom/data.js index c93a8dc7c..69488b510 100644 --- a/js/src/dom/data.js +++ b/js/src/dom/data.js @@ -1,6 +1,6 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-beta2): dom/data.js + * Bootstrap (v5.1.0): dom/data.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ @@ -11,57 +11,47 @@ * ------------------------------------------------------------------------ */ -const mapData = (() => { - const storeData = {} - let id = 1 - return { - set(element, key, data) { - if (typeof element.bsKey === 'undefined') { - element.bsKey = { - key, - id - } - id++ - } +const elementMap = new Map() - storeData[element.bsKey.id] = data - }, - get(element, key) { - if (!element || typeof element.bsKey === 'undefined') { - return null - } - - const keyProperties = element.bsKey - if (keyProperties.key === key) { - return storeData[keyProperties.id] - } +export default { + set(element, key, instance) { + if (!elementMap.has(element)) { + elementMap.set(element, new Map()) + } - return null - }, - delete(element, key) { - if (typeof element.bsKey === 'undefined') { - return - } + const instanceMap = elementMap.get(element) - const keyProperties = element.bsKey - if (keyProperties.key === key) { - delete storeData[keyProperties.id] - delete element.bsKey - } + // 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 } - } -})() -const Data = { - setData(instance, key, data) { - mapData.set(instance, key, data) + instanceMap.set(key, instance) }, - getData(instance, key) { - return mapData.get(instance, key) + + get(element, key) { + if (elementMap.has(element)) { + return elementMap.get(element).get(key) || null + } + + return null }, - removeData(instance, key) { - mapData.delete(instance, key) + + 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) + } } } - -export default Data diff --git a/js/src/dom/event-handler.js b/js/src/dom/event-handler.js index 5b11ae3d0..087afde07 100644 --- a/js/src/dom/event-handler.js +++ b/js/src/dom/event-handler.js @@ -1,6 +1,6 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-beta2): dom/event-handler.js + * Bootstrap (v5.1.0): dom/event-handler.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ @@ -22,6 +22,7 @@ const customEvents = { mouseenter: 'mouseover', mouseleave: 'mouseout' } +const customEventsRegex = /^(mouseenter|mouseleave)/i const nativeEvents = new Set([ 'click', 'dblclick', @@ -113,7 +114,7 @@ function bootstrapDelegationHandler(element, selector, fn) { if (handler.oneOff) { // eslint-disable-next-line unicorn/consistent-destructuring - EventHandler.off(element, event.type, fn) + EventHandler.off(element, event.type, selector, fn) } return fn.apply(target, [event]) @@ -144,14 +145,7 @@ function normalizeParams(originalTypeEvent, handler, delegationFn) { const delegation = typeof handler === 'string' const originalHandler = delegation ? delegationFn : handler - // allow to get the native events from namespaced events ('click.bs.button' --> 'click') - let typeEvent = originalTypeEvent.replace(stripNameRegex, '') - const custom = customEvents[typeEvent] - - if (custom) { - typeEvent = custom - } - + let typeEvent = getTypeEvent(originalTypeEvent) const isNative = nativeEvents.has(typeEvent) if (!isNative) { @@ -171,6 +165,24 @@ function addHandler(element, originalTypeEvent, handler, delegationFn, oneOff) { delegationFn = null } + // 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 (customEventsRegex.test(originalTypeEvent)) { + const wrapFn = fn => { + return function (event) { + if (!event.relatedTarget || (event.relatedTarget !== event.delegateTarget && !event.delegateTarget.contains(event.relatedTarget))) { + return fn.call(this, event) + } + } + } + + if (delegationFn) { + delegationFn = wrapFn(delegationFn) + } else { + handler = wrapFn(handler) + } + } + const [delegation, originalHandler, typeEvent] = normalizeParams(originalTypeEvent, handler, delegationFn) const events = getEvent(element) const handlers = events[typeEvent] || (events[typeEvent] = {}) @@ -219,6 +231,12 @@ function removeNamespacedHandlers(element, events, typeEvent, namespace) { }) } +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, delegationFn) { addHandler(element, event, handler, delegationFn, false) @@ -272,7 +290,7 @@ const EventHandler = { } const $ = getjQuery() - const typeEvent = event.replace(stripNameRegex, '') + const typeEvent = getTypeEvent(event) const inNamespace = event !== typeEvent const isNative = nativeEvents.has(typeEvent) diff --git a/js/src/dom/manipulator.js b/js/src/dom/manipulator.js index 509797bc0..fdeb69ed8 100644 --- a/js/src/dom/manipulator.js +++ b/js/src/dom/manipulator.js @@ -1,6 +1,6 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-beta2): dom/manipulator.js + * Bootstrap (v5.1.0): dom/manipulator.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ @@ -64,8 +64,8 @@ const Manipulator = { const rect = element.getBoundingClientRect() return { - top: rect.top + document.body.scrollTop, - left: rect.left + document.body.scrollLeft + top: rect.top + window.pageYOffset, + left: rect.left + window.pageXOffset } }, diff --git a/js/src/dom/selector-engine.js b/js/src/dom/selector-engine.js index b310098b5..e3988a986 100644 --- a/js/src/dom/selector-engine.js +++ b/js/src/dom/selector-engine.js @@ -1,6 +1,6 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-beta2): dom/selector-engine.js + * Bootstrap (v5.1.0): dom/selector-engine.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ @@ -11,6 +11,8 @@ * ------------------------------------------------------------------------ */ +import { isDisabled, isVisible } from '../util/index' + const NODE_TEXT = 3 const SelectorEngine = { @@ -69,6 +71,21 @@ const SelectorEngine = { } 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)) } } diff --git a/js/src/dropdown.js b/js/src/dropdown.js index 590c74801..d1f573fc8 100644 --- a/js/src/dropdown.js +++ b/js/src/dropdown.js @@ -1,6 +1,6 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-beta2): dropdown.js + * Bootstrap (v5.1.0): dropdown.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ @@ -9,14 +9,16 @@ import * as Popper from '@popperjs/core' import { defineJQueryPlugin, + getElement, getElementFromSelector, + getNextActiveElement, + isDisabled, isElement, - isVisible, isRTL, + isVisible, noop, typeCheckConfig } from './util/index' -import Data from './dom/data' import EventHandler from './dom/event-handler' import Manipulator from './dom/manipulator' import SelectorEngine from './dom/selector-engine' @@ -46,12 +48,10 @@ 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 = `click${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_DISABLED = 'disabled' const CLASS_NAME_SHOW = 'show' const CLASS_NAME_DROPUP = 'dropup' const CLASS_NAME_DROPEND = 'dropend' @@ -59,7 +59,6 @@ const CLASS_NAME_DROPSTART = 'dropstart' const CLASS_NAME_NAVBAR = 'navbar' const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="dropdown"]' -const SELECTOR_FORM_CHILD = '.dropdown form' const SELECTOR_MENU = '.dropdown-menu' const SELECTOR_NAVBAR_NAV = '.navbar-nav' const SELECTOR_VISIBLE_ITEMS = '.dropdown-menu .dropdown-item:not(.disabled):not(:disabled)' @@ -73,20 +72,20 @@ const PLACEMENT_LEFT = isRTL() ? 'right-start' : 'left-start' const Default = { offset: [0, 2], - flip: true, boundary: 'clippingParents', reference: 'toggle', display: 'dynamic', - popperConfig: null + popperConfig: null, + autoClose: true } const DefaultType = { offset: '(array|string|function)', - flip: 'boolean', boundary: '(string|element)', reference: '(string|element|object)', display: 'string', - popperConfig: '(null|object|function)' + popperConfig: '(null|object|function)', + autoClose: '(boolean|string)' } /** @@ -103,8 +102,6 @@ class Dropdown extends BaseComponent { this._config = this._getConfig(config) this._menu = this._getMenuElement() this._inNavbar = this._detectNavbar() - - this._addEventListeners() } // Getters @@ -117,34 +114,21 @@ class Dropdown extends BaseComponent { return DefaultType } - static get DATA_KEY() { - return DATA_KEY + static get NAME() { + return NAME } // Public toggle() { - if (this._element.disabled || this._element.classList.contains(CLASS_NAME_DISABLED)) { - return - } - - const isActive = this._element.classList.contains(CLASS_NAME_SHOW) - - Dropdown.clearMenus() - - if (isActive) { - return - } - - this.show() + return this._isShown() ? this.hide() : this.show() } show() { - if (this._element.disabled || this._element.classList.contains(CLASS_NAME_DISABLED) || this._menu.classList.contains(CLASS_NAME_SHOW)) { + if (isDisabled(this._element) || this._isShown(this._menu)) { return } - const parent = Dropdown.getParentFromElement(this._element) const relatedTarget = { relatedTarget: this._element } @@ -155,37 +139,12 @@ class Dropdown extends BaseComponent { return } + const parent = Dropdown.getParentFromElement(this._element) // Totally disable Popper for Dropdowns in Navbar if (this._inNavbar) { Manipulator.setDataAttribute(this._menu, 'popper', 'none') } else { - 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 = parent - } else if (isElement(this._config.reference)) { - referenceElement = this._config.reference - - // Check if it's jQuery element - if (typeof this._config.reference.jquery !== 'undefined') { - referenceElement = this._config.reference[0] - } - } else if (typeof this._config.reference === 'object') { - referenceElement = this._config.reference - } - - const popperConfig = this._getPopperConfig() - const isDisplayStatic = popperConfig.modifiers.find(modifier => modifier.name === 'applyStyles' && modifier.enabled === false) - - this._popper = Popper.createPopper(referenceElement, this._menu, popperConfig) - - if (isDisplayStatic) { - Manipulator.setDataAttribute(this._menu, 'popper', 'static') - } + this._createPopper(parent) } // If this is a touch-enabled device we add extra @@ -195,19 +154,19 @@ class Dropdown extends BaseComponent { if ('ontouchstart' in document.documentElement && !parent.closest(SELECTOR_NAVBAR_NAV)) { [].concat(...document.body.children) - .forEach(elem => EventHandler.on(elem, 'mouseover', null, noop())) + .forEach(elem => EventHandler.on(elem, 'mouseover', noop)) } this._element.focus() this._element.setAttribute('aria-expanded', true) - this._menu.classList.toggle(CLASS_NAME_SHOW) - this._element.classList.toggle(CLASS_NAME_SHOW) + this._menu.classList.add(CLASS_NAME_SHOW) + this._element.classList.add(CLASS_NAME_SHOW) EventHandler.trigger(this._element, EVENT_SHOWN, relatedTarget) } hide() { - if (this._element.disabled || this._element.classList.contains(CLASS_NAME_DISABLED) || !this._menu.classList.contains(CLASS_NAME_SHOW)) { + if (isDisabled(this._element) || !this._isShown(this._menu)) { return } @@ -215,29 +174,12 @@ class Dropdown extends BaseComponent { relatedTarget: this._element } - const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE, relatedTarget) - - if (hideEvent.defaultPrevented) { - return - } - - if (this._popper) { - this._popper.destroy() - } - - this._menu.classList.toggle(CLASS_NAME_SHOW) - this._element.classList.toggle(CLASS_NAME_SHOW) - Manipulator.removeDataAttribute(this._menu, 'popper') - EventHandler.trigger(this._element, EVENT_HIDDEN, relatedTarget) + this._completeHide(relatedTarget) } dispose() { - EventHandler.off(this._element, EVENT_KEY) - this._menu = null - if (this._popper) { this._popper.destroy() - this._popper = null } super.dispose() @@ -252,12 +194,28 @@ class Dropdown extends BaseComponent { // Private - _addEventListeners() { - EventHandler.on(this._element, EVENT_CLICK, event => { - event.preventDefault() - event.stopPropagation() - this.toggle() - }) + _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) { + [].concat(...document.body.children) + .forEach(elem => EventHandler.off(elem, '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) { @@ -279,6 +237,35 @@ class Dropdown extends BaseComponent { return config } + _createPopper(parent) { + 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 = 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() + const isDisplayStatic = popperConfig.modifiers.find(modifier => modifier.name === 'applyStyles' && modifier.enabled === false) + + this._popper = Popper.createPopper(referenceElement, this._menu, popperConfig) + + if (isDisplayStatic) { + Manipulator.setDataAttribute(this._menu, 'popper', 'static') + } + } + + _isShown(element = this._element) { + return element.classList.contains(CLASS_NAME_SHOW) + } + _getMenuElement() { return SelectorEngine.next(this._element, SELECTOR_MENU)[0] } @@ -328,7 +315,6 @@ class Dropdown extends BaseComponent { modifiers: [{ name: 'preventOverflow', options: { - altBoundary: this._config.flip, boundary: this._config.boundary } }, @@ -354,28 +340,33 @@ class Dropdown extends BaseComponent { } } + _selectMenuItem({ key, target }) { + const items = SelectorEngine.find(SELECTOR_VISIBLE_ITEMS, this._menu).filter(isVisible) + + 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 dropdownInterface(element, config) { - let data = Data.getData(element, DATA_KEY) - const _config = typeof config === 'object' ? config : null + static jQueryInterface(config) { + return this.each(function () { + const data = Dropdown.getOrCreateInstance(this, config) - if (!data) { - data = new Dropdown(element, _config) - } + if (typeof config !== 'string') { + return + } - if (typeof config === 'string') { if (typeof data[config] === 'undefined') { throw new TypeError(`No method named "${config}"`) } data[config]() - } - } - - static jQueryInterface(config) { - return this.each(function () { - Dropdown.dropdownInterface(this, config) }) } @@ -387,53 +378,41 @@ class Dropdown extends BaseComponent { const toggles = SelectorEngine.find(SELECTOR_DATA_TOGGLE) for (let i = 0, len = toggles.length; i < len; i++) { - const context = Data.getData(toggles[i], DATA_KEY) - const relatedTarget = { - relatedTarget: toggles[i] - } - - if (event && event.type === 'click') { - relatedTarget.clickEvent = event - } - - if (!context) { - continue - } - - const dropdownMenu = context._menu - if (!toggles[i].classList.contains(CLASS_NAME_SHOW)) { + const context = Dropdown.getInstance(toggles[i]) + if (!context || context._config.autoClose === false) { continue } - if (event && ((event.type === 'click' && - /input|textarea/i.test(event.target.tagName)) || - (event.type === 'keyup' && event.key === TAB_KEY)) && - dropdownMenu.contains(event.target)) { + if (!context._isShown()) { continue } - const hideEvent = EventHandler.trigger(toggles[i], EVENT_HIDE, relatedTarget) - if (hideEvent.defaultPrevented) { - continue + const relatedTarget = { + relatedTarget: context._element } - // If this is a touch-enabled device we remove the extra - // empty mouseover listeners we added for iOS support - if ('ontouchstart' in document.documentElement) { - [].concat(...document.body.children) - .forEach(elem => EventHandler.off(elem, 'mouseover', null, noop())) - } + if (event) { + 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 + } - toggles[i].setAttribute('aria-expanded', 'false') + // 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 + } - if (context._popper) { - context._popper.destroy() + if (event.type === 'click') { + relatedTarget.clickEvent = event + } } - dropdownMenu.classList.remove(CLASS_NAME_SHOW) - toggles[i].classList.remove(CLASS_NAME_SHOW) - Manipulator.removeDataAttribute(dropdownMenu, 'popper') - EventHandler.trigger(toggles[i], EVENT_HIDDEN, relatedTarget) + context._completeHide(relatedTarget) } } @@ -457,56 +436,39 @@ class Dropdown extends BaseComponent { return } - event.preventDefault() - event.stopPropagation() - - if (this.disabled || this.classList.contains(CLASS_NAME_DISABLED)) { - return - } - - const parent = Dropdown.getParentFromElement(this) const isActive = this.classList.contains(CLASS_NAME_SHOW) - if (event.key === ESCAPE_KEY) { - const button = this.matches(SELECTOR_DATA_TOGGLE) ? this : SelectorEngine.prev(this, SELECTOR_DATA_TOGGLE)[0] - button.focus() - Dropdown.clearMenus() + if (!isActive && event.key === ESCAPE_KEY) { return } - if (!isActive && (event.key === ARROW_UP_KEY || event.key === ARROW_DOWN_KEY)) { - const button = this.matches(SELECTOR_DATA_TOGGLE) ? this : SelectorEngine.prev(this, SELECTOR_DATA_TOGGLE)[0] - button.click() - return - } + event.preventDefault() + event.stopPropagation() - if (!isActive || event.key === SPACE_KEY) { - Dropdown.clearMenus() + if (isDisabled(this)) { return } - const items = SelectorEngine.find(SELECTOR_VISIBLE_ITEMS, parent).filter(isVisible) + const getToggleButton = this.matches(SELECTOR_DATA_TOGGLE) ? this : SelectorEngine.prev(this, SELECTOR_DATA_TOGGLE)[0] + const instance = Dropdown.getOrCreateInstance(getToggleButton) - if (!items.length) { + if (event.key === ESCAPE_KEY) { + instance.hide() return } - let index = items.indexOf(event.target) + if (event.key === ARROW_UP_KEY || event.key === ARROW_DOWN_KEY) { + if (!isActive) { + instance.show() + } - // Up - if (event.key === ARROW_UP_KEY && index > 0) { - index-- + instance._selectMenuItem(event) + return } - // Down - if (event.key === ARROW_DOWN_KEY && index < items.length - 1) { - index++ + if (!isActive || event.key === SPACE_KEY) { + Dropdown.clearMenus() } - - // index is -1 if the first keydown is an ArrowUp - index = index === -1 ? 0 : index - - items[index].focus() } } @@ -522,10 +484,8 @@ 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() - event.stopPropagation() - Dropdown.dropdownInterface(this, 'toggle') + Dropdown.getOrCreateInstance(this).toggle() }) -EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_FORM_CHILD, e => e.stopPropagation()) /** * ------------------------------------------------------------------------ @@ -534,6 +494,6 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_FORM_CHILD, e => e.stop * add .Dropdown to jQuery only if jQuery is present */ -defineJQueryPlugin(NAME, Dropdown) +defineJQueryPlugin(Dropdown) export default Dropdown diff --git a/js/src/modal.js b/js/src/modal.js index 79a2f143a..7d44c31e8 100644 --- a/js/src/modal.js +++ b/js/src/modal.js @@ -1,25 +1,26 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-beta2): modal.js + * Bootstrap (v5.1.0): modal.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ import { defineJQueryPlugin, - emulateTransitionEnd, getElementFromSelector, - getTransitionDurationFromElement, - isVisible, isRTL, + isVisible, reflow, typeCheckConfig } from './util/index' -import Data from './dom/data' import EventHandler from './dom/event-handler' import Manipulator from './dom/manipulator' 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' /** * ------------------------------------------------------------------------ @@ -50,7 +51,6 @@ 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_FOCUSIN = `focusin${EVENT_KEY}` const EVENT_RESIZE = `resize${EVENT_KEY}` const EVENT_CLICK_DISMISS = `click.dismiss${EVENT_KEY}` const EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY}` @@ -58,19 +58,15 @@ const EVENT_MOUSEUP_DISMISS = `mouseup.dismiss${EVENT_KEY}` const EVENT_MOUSEDOWN_DISMISS = `mousedown.dismiss${EVENT_KEY}` const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` -const CLASS_NAME_SCROLLBAR_MEASURER = 'modal-scrollbar-measure' -const CLASS_NAME_BACKDROP = 'modal-backdrop' 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 SELECTOR_DATA_DISMISS = '[data-bs-dismiss="modal"]' -const SELECTOR_FIXED_CONTENT = '.fixed-top, .fixed-bottom, .is-fixed, .sticky-top' -const SELECTOR_STICKY_CONTENT = '.sticky-top' /** * ------------------------------------------------------------------------ @@ -83,13 +79,13 @@ class Modal extends BaseComponent { super(element) this._config = this._getConfig(config) - this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, element) - this._backdrop = null + this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, this._element) + this._backdrop = this._initializeBackDrop() + this._focustrap = this._initializeFocusTrap() this._isShown = false - this._isBodyOverflowing = false this._ignoreBackdropClick = false this._isTransitioning = false - this._scrollbarWidth = 0 + this._scrollBar = new ScrollBarHelper() } // Getters @@ -98,8 +94,8 @@ class Modal extends BaseComponent { return Default } - static get DATA_KEY() { - return DATA_KEY + static get NAME() { + return NAME } // Public @@ -113,30 +109,29 @@ class Modal extends BaseComponent { return } - if (this._element.classList.contains(CLASS_NAME_FADE)) { - this._isTransitioning = true - } - const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, { relatedTarget }) - if (this._isShown || showEvent.defaultPrevented) { + if (showEvent.defaultPrevented) { return } this._isShown = true - this._checkScrollbar() - this._setScrollbar() + if (this._isAnimated()) { + this._isTransitioning = true + } + + this._scrollBar.hide() + + document.body.classList.add(CLASS_NAME_OPEN) this._adjustDialog() this._setEscapeEvent() this._setResizeEvent() - EventHandler.on(this._element, EVENT_CLICK_DISMISS, SELECTOR_DATA_DISMISS, event => this.hide(event)) - EventHandler.on(this._dialog, EVENT_MOUSEDOWN_DISMISS, () => { EventHandler.one(this._element, EVENT_MOUSEUP_DISMISS, event => { if (event.target === this._element) { @@ -148,11 +143,7 @@ class Modal extends BaseComponent { this._showBackdrop(() => this._showElement(relatedTarget)) } - hide(event) { - if (event) { - event.preventDefault() - } - + hide() { if (!this._isShown || this._isTransitioning) { return } @@ -164,53 +155,32 @@ class Modal extends BaseComponent { } this._isShown = false - const transition = this._element.classList.contains(CLASS_NAME_FADE) + const isAnimated = this._isAnimated() - if (transition) { + if (isAnimated) { this._isTransitioning = true } this._setEscapeEvent() this._setResizeEvent() - EventHandler.off(document, EVENT_FOCUSIN) + this._focustrap.deactivate() this._element.classList.remove(CLASS_NAME_SHOW) EventHandler.off(this._element, EVENT_CLICK_DISMISS) EventHandler.off(this._dialog, EVENT_MOUSEDOWN_DISMISS) - if (transition) { - const transitionDuration = getTransitionDurationFromElement(this._element) - - EventHandler.one(this._element, 'transitionend', event => this._hideModal(event)) - emulateTransitionEnd(this._element, transitionDuration) - } else { - this._hideModal() - } + this._queueCallback(() => this._hideModal(), this._element, isAnimated) } dispose() { - [window, this._element, this._dialog] + [window, this._dialog] .forEach(htmlElement => EventHandler.off(htmlElement, EVENT_KEY)) + this._backdrop.dispose() + this._focustrap.deactivate() super.dispose() - - /** - * `document` has 2 events `EVENT_FOCUSIN` and `EVENT_CLICK_DATA_API` - * Do not move `document` in `htmlElements` array - * It will remove `EVENT_CLICK_DATA_API` event that should remain - */ - EventHandler.off(document, EVENT_FOCUSIN) - - this._config = null - this._dialog = null - this._backdrop = null - this._isShown = null - this._isBodyOverflowing = null - this._ignoreBackdropClick = null - this._isTransitioning = null - this._scrollbarWidth = null } handleUpdate() { @@ -219,22 +189,36 @@ class Modal extends BaseComponent { // 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 + }) + } + _getConfig(config) { config = { ...Default, - ...config + ...Manipulator.getDataAttributes(this._element), + ...(typeof config === 'object' ? config : {}) } typeCheckConfig(NAME, config, DefaultType) return config } _showElement(relatedTarget) { - const transition = this._element.classList.contains(CLASS_NAME_FADE) + const isAnimated = this._isAnimated() const modalBody = SelectorEngine.findOne(SELECTOR_MODAL_BODY, this._dialog) if (!this._element.parentNode || this._element.parentNode.nodeType !== Node.ELEMENT_NODE) { // Don't move modal's DOM position - document.body.appendChild(this._element) + document.body.append(this._element) } this._element.style.display = 'block' @@ -247,19 +231,15 @@ class Modal extends BaseComponent { modalBody.scrollTop = 0 } - if (transition) { + if (isAnimated) { reflow(this._element) } this._element.classList.add(CLASS_NAME_SHOW) - if (this._config.focus) { - this._enforceFocus() - } - const transitionComplete = () => { if (this._config.focus) { - this._element.focus() + this._focustrap.activate() } this._isTransitioning = false @@ -268,25 +248,7 @@ class Modal extends BaseComponent { }) } - if (transition) { - const transitionDuration = getTransitionDurationFromElement(this._dialog) - - EventHandler.one(this._dialog, 'transitionend', transitionComplete) - emulateTransitionEnd(this._dialog, transitionDuration) - } else { - transitionComplete() - } - } - - _enforceFocus() { - EventHandler.off(document, EVENT_FOCUSIN) // guard against infinite focus loop - EventHandler.on(document, EVENT_FOCUSIN, event => { - if (document !== event.target && - this._element !== event.target && - !this._element.contains(event.target)) { - this._element.focus() - } - }) + this._queueCallback(transitionComplete, this._dialog, isAnimated) } _setEscapeEvent() { @@ -318,84 +280,37 @@ class Modal extends BaseComponent { this._element.removeAttribute('aria-modal') this._element.removeAttribute('role') this._isTransitioning = false - this._showBackdrop(() => { + this._backdrop.hide(() => { document.body.classList.remove(CLASS_NAME_OPEN) this._resetAdjustments() - this._resetScrollbar() + this._scrollBar.reset() EventHandler.trigger(this._element, EVENT_HIDDEN) }) } - _removeBackdrop() { - this._backdrop.parentNode.removeChild(this._backdrop) - this._backdrop = null - } - _showBackdrop(callback) { - const animate = this._element.classList.contains(CLASS_NAME_FADE) ? - CLASS_NAME_FADE : - '' - - if (this._isShown && this._config.backdrop) { - this._backdrop = document.createElement('div') - this._backdrop.className = CLASS_NAME_BACKDROP - - if (animate) { - this._backdrop.classList.add(animate) - } - - document.body.appendChild(this._backdrop) - - EventHandler.on(this._element, EVENT_CLICK_DISMISS, event => { - if (this._ignoreBackdropClick) { - this._ignoreBackdropClick = false - return - } - - if (event.target !== event.currentTarget) { - return - } - - if (this._config.backdrop === 'static') { - this._triggerBackdropTransition() - } else { - this.hide() - } - }) - - if (animate) { - reflow(this._backdrop) + EventHandler.on(this._element, EVENT_CLICK_DISMISS, event => { + if (this._ignoreBackdropClick) { + this._ignoreBackdropClick = false + return } - this._backdrop.classList.add(CLASS_NAME_SHOW) - - if (!animate) { - callback() + if (event.target !== event.currentTarget) { return } - const backdropTransitionDuration = getTransitionDurationFromElement(this._backdrop) - - EventHandler.one(this._backdrop, 'transitionend', callback) - emulateTransitionEnd(this._backdrop, backdropTransitionDuration) - } else if (!this._isShown && this._backdrop) { - this._backdrop.classList.remove(CLASS_NAME_SHOW) - - const callbackRemove = () => { - this._removeBackdrop() - callback() + if (this._config.backdrop === true) { + this.hide() + } else if (this._config.backdrop === 'static') { + this._triggerBackdropTransition() } + }) - if (this._element.classList.contains(CLASS_NAME_FADE)) { - const backdropTransitionDuration = getTransitionDurationFromElement(this._backdrop) - EventHandler.one(this._backdrop, 'transitionend', callbackRemove) - emulateTransitionEnd(this._backdrop, backdropTransitionDuration) - } else { - callbackRemove() - } - } else { - callback() - } + this._backdrop.show(callback) + } + + _isAnimated() { + return this._element.classList.contains(CLASS_NAME_FADE) } _triggerBackdropTransition() { @@ -404,25 +319,28 @@ class Modal extends BaseComponent { return } - const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight + const { classList, scrollHeight, style } = this._element + const isModalOverflowing = scrollHeight > document.documentElement.clientHeight + + // return if the following background transition hasn't yet completed + if ((!isModalOverflowing && style.overflowY === 'hidden') || classList.contains(CLASS_NAME_STATIC)) { + return + } if (!isModalOverflowing) { - this._element.style.overflowY = 'hidden' + style.overflowY = 'hidden' } - this._element.classList.add(CLASS_NAME_STATIC) - const modalTransitionDuration = getTransitionDurationFromElement(this._dialog) - EventHandler.off(this._element, 'transitionend') - EventHandler.one(this._element, 'transitionend', () => { - this._element.classList.remove(CLASS_NAME_STATIC) + classList.add(CLASS_NAME_STATIC) + this._queueCallback(() => { + classList.remove(CLASS_NAME_STATIC) if (!isModalOverflowing) { - EventHandler.one(this._element, 'transitionend', () => { - this._element.style.overflowY = '' - }) - emulateTransitionEnd(this._element, modalTransitionDuration) + this._queueCallback(() => { + style.overflowY = '' + }, this._dialog) } - }) - emulateTransitionEnd(this._element, modalTransitionDuration) + }, this._dialog) + this._element.focus() } @@ -432,13 +350,15 @@ class Modal extends BaseComponent { _adjustDialog() { const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight + const scrollbarWidth = this._scrollBar.getWidth() + const isBodyOverflowing = scrollbarWidth > 0 - if ((!this._isBodyOverflowing && isModalOverflowing && !isRTL()) || (this._isBodyOverflowing && !isModalOverflowing && isRTL())) { - this._element.style.paddingLeft = `${this._scrollbarWidth}px` + if ((!isBodyOverflowing && isModalOverflowing && !isRTL()) || (isBodyOverflowing && !isModalOverflowing && isRTL())) { + this._element.style.paddingLeft = `${scrollbarWidth}px` } - if ((this._isBodyOverflowing && !isModalOverflowing && !isRTL()) || (!this._isBodyOverflowing && isModalOverflowing && isRTL())) { - this._element.style.paddingRight = `${this._scrollbarWidth}px` + if ((isBodyOverflowing && !isModalOverflowing && !isRTL()) || (!isBodyOverflowing && isModalOverflowing && isRTL())) { + this._element.style.paddingRight = `${scrollbarWidth}px` } } @@ -447,81 +367,21 @@ class Modal extends BaseComponent { this._element.style.paddingRight = '' } - _checkScrollbar() { - const rect = document.body.getBoundingClientRect() - this._isBodyOverflowing = Math.round(rect.left + rect.right) < window.innerWidth - this._scrollbarWidth = this._getScrollbarWidth() - } - - _setScrollbar() { - if (this._isBodyOverflowing) { - this._setElementAttributes(SELECTOR_FIXED_CONTENT, 'paddingRight', calculatedValue => calculatedValue + this._scrollbarWidth) - this._setElementAttributes(SELECTOR_STICKY_CONTENT, 'marginRight', calculatedValue => calculatedValue - this._scrollbarWidth) - this._setElementAttributes('body', 'paddingRight', calculatedValue => calculatedValue + this._scrollbarWidth) - } - - document.body.classList.add(CLASS_NAME_OPEN) - } - - _setElementAttributes(selector, styleProp, callback) { - SelectorEngine.find(selector) - .forEach(element => { - const actualValue = element.style[styleProp] - const calculatedValue = window.getComputedStyle(element)[styleProp] - Manipulator.setDataAttribute(element, styleProp, actualValue) - element.style[styleProp] = callback(Number.parseFloat(calculatedValue)) + 'px' - }) - } - - _resetScrollbar() { - this._resetElementAttributes(SELECTOR_FIXED_CONTENT, 'paddingRight') - this._resetElementAttributes(SELECTOR_STICKY_CONTENT, 'marginRight') - this._resetElementAttributes('body', 'paddingRight') - } - - _resetElementAttributes(selector, styleProp) { - SelectorEngine.find(selector).forEach(element => { - const value = Manipulator.getDataAttribute(element, styleProp) - if (typeof value === 'undefined' && element === document.body) { - element.style[styleProp] = '' - } else { - Manipulator.removeDataAttribute(element, styleProp) - element.style[styleProp] = value - } - }) - } - - _getScrollbarWidth() { // thx d.walsh - const scrollDiv = document.createElement('div') - scrollDiv.className = CLASS_NAME_SCROLLBAR_MEASURER - document.body.appendChild(scrollDiv) - const scrollbarWidth = scrollDiv.getBoundingClientRect().width - scrollDiv.clientWidth - document.body.removeChild(scrollDiv) - return scrollbarWidth - } - // Static static jQueryInterface(config, relatedTarget) { return this.each(function () { - let data = Data.getData(this, DATA_KEY) - const _config = { - ...Default, - ...Manipulator.getDataAttributes(this), - ...(typeof config === 'object' && config ? config : {}) - } + const data = Modal.getOrCreateInstance(this, config) - if (!data) { - data = new Modal(this, _config) + if (typeof config !== 'string') { + return } - if (typeof config === 'string') { - if (typeof data[config] === 'undefined') { - throw new TypeError(`No method named "${config}"`) - } - - data[config](relatedTarget) + if (typeof data[config] === 'undefined') { + throw new TypeError(`No method named "${config}"`) } + + data[config](relatedTarget) }) } } @@ -535,7 +395,7 @@ class Modal extends BaseComponent { EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) { const target = getElementFromSelector(this) - if (this.tagName === 'A' || this.tagName === 'AREA') { + if (['A', 'AREA'].includes(this.tagName)) { event.preventDefault() } @@ -552,19 +412,19 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function ( }) }) - let data = Data.getData(target, DATA_KEY) - if (!data) { - const config = { - ...Manipulator.getDataAttributes(target), - ...Manipulator.getDataAttributes(this) - } - - data = new Modal(target, config) + // avoid conflict when clicking moddal toggler while another one is open + const allReadyOpen = SelectorEngine.findOne(OPEN_SELECTOR) + if (allReadyOpen) { + Modal.getInstance(allReadyOpen).hide() } + const data = Modal.getOrCreateInstance(target) + data.toggle(this) }) +enableDismissTrigger(Modal) + /** * ------------------------------------------------------------------------ * jQuery @@ -572,6 +432,6 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function ( * add .Modal to jQuery only if jQuery is present */ -defineJQueryPlugin(NAME, Modal) +defineJQueryPlugin(Modal) export default Modal diff --git a/js/src/offcanvas.js b/js/src/offcanvas.js new file mode 100644 index 000000000..60ce8a6c9 --- /dev/null +++ b/js/src/offcanvas.js @@ -0,0 +1,272 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap (v5.1.0): offcanvas.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + +import { + defineJQueryPlugin, + getElementFromSelector, + isDisabled, + isVisible, + typeCheckConfig +} 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 Manipulator from './dom/manipulator' +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 Default = { + backdrop: true, + keyboard: true, + scroll: false +} + +const DefaultType = { + backdrop: 'boolean', + keyboard: 'boolean', + scroll: 'boolean' +} + +const CLASS_NAME_SHOW = 'show' +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_HIDDEN = `hidden${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"]' + +/** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + +class Offcanvas extends BaseComponent { + constructor(element, config) { + super(element) + + this._config = this._getConfig(config) + this._isShown = false + this._backdrop = this._initializeBackDrop() + this._focustrap = this._initializeFocusTrap() + this._addEventListeners() + } + + // Getters + + static get NAME() { + return NAME + } + + static get Default() { + return Default + } + + // 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._element.style.visibility = 'visible' + + this._backdrop.show() + + if (!this._config.scroll) { + new ScrollBarHelper().hide() + } + + this._element.removeAttribute('aria-hidden') + this._element.setAttribute('aria-modal', true) + this._element.setAttribute('role', 'dialog') + this._element.classList.add(CLASS_NAME_SHOW) + + const completeCallBack = () => { + if (!this._config.scroll) { + this._focustrap.activate() + } + + 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.remove(CLASS_NAME_SHOW) + this._backdrop.hide() + + const completeCallback = () => { + this._element.setAttribute('aria-hidden', true) + this._element.removeAttribute('aria-modal') + this._element.removeAttribute('role') + this._element.style.visibility = 'hidden' + + 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 + + _getConfig(config) { + config = { + ...Default, + ...Manipulator.getDataAttributes(this._element), + ...(typeof config === 'object' ? config : {}) + } + typeCheckConfig(NAME, config, DefaultType) + return config + } + + _initializeBackDrop() { + return new Backdrop({ + className: CLASS_NAME_BACKDROP, + isVisible: this._config.backdrop, + isAnimated: true, + rootElement: this._element.parentNode, + clickCallback: () => this.hide() + }) + } + + _initializeFocusTrap() { + return new FocusTrap({ + trapElement: this._element + }) + } + + _addEventListeners() { + EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, event => { + if (this._config.keyboard && event.key === ESCAPE_KEY) { + 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 allReadyOpen = SelectorEngine.findOne(OPEN_SELECTOR) + if (allReadyOpen && allReadyOpen !== target) { + Offcanvas.getInstance(allReadyOpen).hide() + } + + const data = Offcanvas.getOrCreateInstance(target) + data.toggle(this) +}) + +EventHandler.on(window, EVENT_LOAD_DATA_API, () => + SelectorEngine.find(OPEN_SELECTOR).forEach(el => Offcanvas.getOrCreateInstance(el).show()) +) + +enableDismissTrigger(Offcanvas) +/** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + */ + +defineJQueryPlugin(Offcanvas) + +export default Offcanvas diff --git a/js/src/popover.js b/js/src/popover.js index 0677dafa0..a01e2d294 100644 --- a/js/src/popover.js +++ b/js/src/popover.js @@ -1,13 +1,11 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-beta2): popover.js + * Bootstrap (v5.1.0): popover.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ import { defineJQueryPlugin } from './util/index' -import Data from './dom/data' -import SelectorEngine from './dom/selector-engine' import Tooltip from './tooltip' /** @@ -20,7 +18,6 @@ const NAME = 'popover' const DATA_KEY = 'bs.popover' const EVENT_KEY = `.${DATA_KEY}` const CLASS_PREFIX = 'bs-popover' -const BSCLS_PREFIX_REGEX = new RegExp(`(^|\\s)${CLASS_PREFIX}\\S+`, 'g') const Default = { ...Tooltip.Default, @@ -30,7 +27,7 @@ const Default = { content: '', template: '<div class="popover" role="tooltip">' + '<div class="popover-arrow"></div>' + - '<h3 class="popover-header"></h3>' + + '<h3 class="popover-header"></h3>' + '<div class="popover-body"></div>' + '</div>' } @@ -53,9 +50,6 @@ const Event = { MOUSELEAVE: `mouseleave${EVENT_KEY}` } -const CLASS_NAME_FADE = 'fade' -const CLASS_NAME_SHOW = 'show' - const SELECTOR_TITLE = '.popover-header' const SELECTOR_CONTENT = '.popover-body' @@ -76,18 +70,10 @@ class Popover extends Tooltip { return NAME } - static get DATA_KEY() { - return DATA_KEY - } - static get Event() { return Event } - static get EVENT_KEY() { - return EVENT_KEY - } - static get DefaultType() { return DefaultType } @@ -98,55 +84,26 @@ class Popover extends Tooltip { return this.getTitle() || this._getContent() } - setContent() { - const tip = this.getTipElement() - - // we use append for html objects to maintain js events - this.setElementContent(SelectorEngine.findOne(SELECTOR_TITLE, tip), this.getTitle()) - let content = this._getContent() - if (typeof content === 'function') { - content = content.call(this._element) - } - - this.setElementContent(SelectorEngine.findOne(SELECTOR_CONTENT, tip), content) - - tip.classList.remove(CLASS_NAME_FADE, CLASS_NAME_SHOW) + setContent(tip) { + this._sanitizeAndSetContent(tip, this.getTitle(), SELECTOR_TITLE) + this._sanitizeAndSetContent(tip, this._getContent(), SELECTOR_CONTENT) } // Private - _addAttachmentClass(attachment) { - this.getTipElement().classList.add(`${CLASS_PREFIX}-${this.updateAttachment(attachment)}`) - } - _getContent() { - return this._element.getAttribute('data-bs-content') || this.config.content + return this._resolvePossibleFunction(this._config.content) } - _cleanTipClass() { - const tip = this.getTipElement() - const tabClass = tip.getAttribute('class').match(BSCLS_PREFIX_REGEX) - if (tabClass !== null && tabClass.length > 0) { - tabClass.map(token => token.trim()) - .forEach(tClass => tip.classList.remove(tClass)) - } + _getBasicClassPrefix() { + return CLASS_PREFIX } // Static static jQueryInterface(config) { return this.each(function () { - let data = Data.getData(this, DATA_KEY) - const _config = typeof config === 'object' ? config : null - - if (!data && /dispose|hide/.test(config)) { - return - } - - if (!data) { - data = new Popover(this, _config) - Data.setData(this, DATA_KEY, data) - } + const data = Popover.getOrCreateInstance(this, config) if (typeof config === 'string') { if (typeof data[config] === 'undefined') { @@ -166,6 +123,6 @@ class Popover extends Tooltip { * add .Popover to jQuery only if jQuery is present */ -defineJQueryPlugin(NAME, Popover) +defineJQueryPlugin(Popover) export default Popover diff --git a/js/src/scrollspy.js b/js/src/scrollspy.js index 43a91e5e9..ad170fcbd 100644 --- a/js/src/scrollspy.js +++ b/js/src/scrollspy.js @@ -1,18 +1,16 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-beta2): scrollspy.js + * Bootstrap (v5.1.0): scrollspy.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ import { defineJQueryPlugin, + getElement, getSelectorFromElement, - getUID, - isElement, typeCheckConfig } from './util/index' -import Data from './dom/data' import EventHandler from './dom/event-handler' import Manipulator from './dom/manipulator' import SelectorEngine from './dom/selector-engine' @@ -53,6 +51,7 @@ 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_LIST_ITEMS}, .${CLASS_NAME_DROPDOWN_ITEM}` const SELECTOR_DROPDOWN = '.dropdown' const SELECTOR_DROPDOWN_TOGGLE = '.dropdown-toggle' @@ -68,9 +67,8 @@ const METHOD_POSITION = 'position' class ScrollSpy extends BaseComponent { constructor(element, config) { super(element) - this._scrollElement = element.tagName === 'BODY' ? window : element + this._scrollElement = this._element.tagName === 'BODY' ? window : this._element this._config = this._getConfig(config) - this._selector = `${this._config.target} ${SELECTOR_NAV_LINKS}, ${this._config.target} ${SELECTOR_LIST_ITEMS}, ${this._config.target} .${CLASS_NAME_DROPDOWN_ITEM}` this._offsets = [] this._targets = [] this._activeTarget = null @@ -88,8 +86,8 @@ class ScrollSpy extends BaseComponent { return Default } - static get DATA_KEY() { - return DATA_KEY + static get NAME() { + return NAME } // Public @@ -111,7 +109,7 @@ class ScrollSpy extends BaseComponent { this._targets = [] this._scrollHeight = this._getScrollHeight() - const targets = SelectorEngine.find(this._selector) + const targets = SelectorEngine.find(SELECTOR_LINK_ITEMS, this._config.target) targets.map(element => { const targetSelector = getSelectorFromElement(element) @@ -138,16 +136,8 @@ class ScrollSpy extends BaseComponent { } dispose() { - super.dispose() EventHandler.off(this._scrollElement, EVENT_KEY) - - this._scrollElement = null - this._config = null - this._selector = null - this._offsets = null - this._targets = null - this._activeTarget = null - this._scrollHeight = null + super.dispose() } // Private @@ -155,18 +145,11 @@ class ScrollSpy extends BaseComponent { _getConfig(config) { config = { ...Default, + ...Manipulator.getDataAttributes(this._element), ...(typeof config === 'object' && config ? config : {}) } - if (typeof config.target !== 'string' && isElement(config.target)) { - let { id } = config.target - if (!id) { - id = getUID(NAME) - config.target.id = id - } - - config.target = `#${id}` - } + config.target = getElement(config.target) || document.documentElement typeCheckConfig(NAME, config, DefaultType) @@ -233,20 +216,16 @@ class ScrollSpy extends BaseComponent { this._clear() - const queries = this._selector.split(',') + const queries = SELECTOR_LINK_ITEMS.split(',') .map(selector => `${selector}[data-bs-target="${target}"],${selector}[href="${target}"]`) - const link = SelectorEngine.findOne(queries.join(',')) + const link = SelectorEngine.findOne(queries.join(','), this._config.target) + link.classList.add(CLASS_NAME_ACTIVE) if (link.classList.contains(CLASS_NAME_DROPDOWN_ITEM)) { SelectorEngine.findOne(SELECTOR_DROPDOWN_TOGGLE, link.closest(SELECTOR_DROPDOWN)) .classList.add(CLASS_NAME_ACTIVE) - - link.classList.add(CLASS_NAME_ACTIVE) } else { - // Set triggered link as active - link.classList.add(CLASS_NAME_ACTIVE) - SelectorEngine.parents(link, SELECTOR_NAV_LIST_GROUP) .forEach(listGroup => { // Set triggered links parents as active @@ -269,7 +248,7 @@ class ScrollSpy extends BaseComponent { } _clear() { - SelectorEngine.find(this._selector) + SelectorEngine.find(SELECTOR_LINK_ITEMS, this._config.target) .filter(node => node.classList.contains(CLASS_NAME_ACTIVE)) .forEach(node => node.classList.remove(CLASS_NAME_ACTIVE)) } @@ -278,20 +257,17 @@ class ScrollSpy extends BaseComponent { static jQueryInterface(config) { return this.each(function () { - let data = Data.getData(this, DATA_KEY) - const _config = typeof config === 'object' && config + const data = ScrollSpy.getOrCreateInstance(this, config) - if (!data) { - data = new ScrollSpy(this, _config) + if (typeof config !== 'string') { + return } - if (typeof config === 'string') { - if (typeof data[config] === 'undefined') { - throw new TypeError(`No method named "${config}"`) - } - - data[config]() + if (typeof data[config] === 'undefined') { + throw new TypeError(`No method named "${config}"`) } + + data[config]() }) } } @@ -304,7 +280,7 @@ class ScrollSpy extends BaseComponent { EventHandler.on(window, EVENT_LOAD_DATA_API, () => { SelectorEngine.find(SELECTOR_DATA_SPY) - .forEach(spy => new ScrollSpy(spy, Manipulator.getDataAttributes(spy))) + .forEach(spy => new ScrollSpy(spy)) }) /** @@ -314,6 +290,6 @@ EventHandler.on(window, EVENT_LOAD_DATA_API, () => { * add .ScrollSpy to jQuery only if jQuery is present */ -defineJQueryPlugin(NAME, ScrollSpy) +defineJQueryPlugin(ScrollSpy) export default ScrollSpy diff --git a/js/src/tab.js b/js/src/tab.js index e60ecddb5..e50f41456 100644 --- a/js/src/tab.js +++ b/js/src/tab.js @@ -1,18 +1,16 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-beta2): tab.js + * Bootstrap (v5.1.0): tab.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ import { defineJQueryPlugin, - emulateTransitionEnd, getElementFromSelector, - getTransitionDurationFromElement, + isDisabled, reflow } from './util/index' -import Data from './dom/data' import EventHandler from './dom/event-handler' import SelectorEngine from './dom/selector-engine' import BaseComponent from './base-component' @@ -36,7 +34,6 @@ const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` const CLASS_NAME_DROPDOWN_MENU = 'dropdown-menu' const CLASS_NAME_ACTIVE = 'active' -const CLASS_NAME_DISABLED = 'disabled' const CLASS_NAME_FADE = 'fade' const CLASS_NAME_SHOW = 'show' @@ -57,8 +54,8 @@ const SELECTOR_DROPDOWN_ACTIVE_CHILD = ':scope > .dropdown-menu .active' class Tab extends BaseComponent { // Getters - static get DATA_KEY() { - return DATA_KEY + static get NAME() { + return NAME } // Public @@ -66,8 +63,7 @@ class Tab extends BaseComponent { show() { if ((this._element.parentNode && this._element.parentNode.nodeType === Node.ELEMENT_NODE && - this._element.classList.contains(CLASS_NAME_ACTIVE)) || - this._element.classList.contains(CLASS_NAME_DISABLED)) { + this._element.classList.contains(CLASS_NAME_ACTIVE))) { return } @@ -126,11 +122,8 @@ class Tab extends BaseComponent { const complete = () => this._transitionComplete(element, active, callback) if (active && isTransitioning) { - const transitionDuration = getTransitionDurationFromElement(active) active.classList.remove(CLASS_NAME_SHOW) - - EventHandler.one(active, 'transitionend', complete) - emulateTransitionEnd(active, transitionDuration) + this._queueCallback(complete, element, true) } else { complete() } @@ -162,11 +155,16 @@ class Tab extends BaseComponent { element.classList.add(CLASS_NAME_SHOW) } - if (element.parentNode && element.parentNode.classList.contains(CLASS_NAME_DROPDOWN_MENU)) { + let parent = element.parentNode + if (parent && parent.nodeName === 'LI') { + parent = parent.parentNode + } + + if (parent && parent.classList.contains(CLASS_NAME_DROPDOWN_MENU)) { const dropdownElement = element.closest(SELECTOR_DROPDOWN) if (dropdownElement) { - SelectorEngine.find(SELECTOR_DROPDOWN_TOGGLE) + SelectorEngine.find(SELECTOR_DROPDOWN_TOGGLE, dropdownElement) .forEach(dropdown => dropdown.classList.add(CLASS_NAME_ACTIVE)) } @@ -182,7 +180,7 @@ class Tab extends BaseComponent { static jQueryInterface(config) { return this.each(function () { - const data = Data.getData(this, DATA_KEY) || new Tab(this) + const data = Tab.getOrCreateInstance(this) if (typeof config === 'string') { if (typeof data[config] === 'undefined') { @@ -202,9 +200,15 @@ class Tab extends BaseComponent { */ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) { - event.preventDefault() + if (['A', 'AREA'].includes(this.tagName)) { + event.preventDefault() + } + + if (isDisabled(this)) { + return + } - const data = Data.getData(this, DATA_KEY) || new Tab(this) + const data = Tab.getOrCreateInstance(this) data.show() }) @@ -215,6 +219,6 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function ( * add .Tab to jQuery only if jQuery is present */ -defineJQueryPlugin(NAME, Tab) +defineJQueryPlugin(Tab) export default Tab diff --git a/js/src/toast.js b/js/src/toast.js index 2f451aab7..442200738 100644 --- a/js/src/toast.js +++ b/js/src/toast.js @@ -1,21 +1,19 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-beta2): toast.js + * Bootstrap (v5.1.0): toast.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ import { defineJQueryPlugin, - emulateTransitionEnd, - getTransitionDurationFromElement, reflow, typeCheckConfig } from './util/index' -import Data from './dom/data' import EventHandler from './dom/event-handler' import Manipulator from './dom/manipulator' import BaseComponent from './base-component' +import { enableDismissTrigger } from './util/component-functions' /** * ------------------------------------------------------------------------ @@ -27,14 +25,17 @@ const NAME = 'toast' const DATA_KEY = 'bs.toast' const EVENT_KEY = `.${DATA_KEY}` -const EVENT_CLICK_DISMISS = `click.dismiss${EVENT_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' +const CLASS_NAME_HIDE = 'hide' // @deprecated - kept here only for backwards compatibility const CLASS_NAME_SHOW = 'show' const CLASS_NAME_SHOWING = 'showing' @@ -50,8 +51,6 @@ const Default = { delay: 5000 } -const SELECTOR_DATA_DISMISS = '[data-bs-dismiss="toast"]' - /** * ------------------------------------------------------------------------ * Class Definition @@ -64,6 +63,8 @@ class Toast extends BaseComponent { this._config = this._getConfig(config) this._timeout = null + this._hasMouseInteraction = false + this._hasKeyboardInteraction = false this._setListeners() } @@ -77,8 +78,8 @@ class Toast extends BaseComponent { return Default } - static get DATA_KEY() { - return DATA_KEY + static get NAME() { + return NAME } // Public @@ -98,28 +99,17 @@ class Toast extends BaseComponent { const complete = () => { this._element.classList.remove(CLASS_NAME_SHOWING) - this._element.classList.add(CLASS_NAME_SHOW) - EventHandler.trigger(this._element, EVENT_SHOWN) - if (this._config.autohide) { - this._timeout = setTimeout(() => { - this.hide() - }, this._config.delay) - } + this._maybeScheduleHide() } - this._element.classList.remove(CLASS_NAME_HIDE) + this._element.classList.remove(CLASS_NAME_HIDE) // @deprecated reflow(this._element) + this._element.classList.add(CLASS_NAME_SHOW) this._element.classList.add(CLASS_NAME_SHOWING) - if (this._config.animation) { - const transitionDuration = getTransitionDurationFromElement(this._element) - EventHandler.one(this._element, 'transitionend', complete) - emulateTransitionEnd(this._element, transitionDuration) - } else { - complete() - } + this._queueCallback(complete, this._element, this._config.animation) } hide() { @@ -134,19 +124,14 @@ class Toast extends BaseComponent { } const complete = () => { - this._element.classList.add(CLASS_NAME_HIDE) + this._element.classList.add(CLASS_NAME_HIDE) // @deprecated + this._element.classList.remove(CLASS_NAME_SHOWING) + this._element.classList.remove(CLASS_NAME_SHOW) EventHandler.trigger(this._element, EVENT_HIDDEN) } - this._element.classList.remove(CLASS_NAME_SHOW) - if (this._config.animation) { - const transitionDuration = getTransitionDurationFromElement(this._element) - - EventHandler.one(this._element, 'transitionend', complete) - emulateTransitionEnd(this._element, transitionDuration) - } else { - complete() - } + this._element.classList.add(CLASS_NAME_SHOWING) + this._queueCallback(complete, this._element, this._config.animation) } dispose() { @@ -156,10 +141,7 @@ class Toast extends BaseComponent { this._element.classList.remove(CLASS_NAME_SHOW) } - EventHandler.off(this._element, EVENT_CLICK_DISMISS) - super.dispose() - this._config = null } // Private @@ -176,8 +158,52 @@ class Toast extends BaseComponent { return config } + _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_CLICK_DISMISS, SELECTOR_DATA_DISMISS, () => this.hide()) + 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() { @@ -189,12 +215,7 @@ class Toast extends BaseComponent { static jQueryInterface(config) { return this.each(function () { - let data = Data.getData(this, DATA_KEY) - const _config = typeof config === 'object' && config - - if (!data) { - data = new Toast(this, _config) - } + const data = Toast.getOrCreateInstance(this, config) if (typeof config === 'string') { if (typeof data[config] === 'undefined') { @@ -207,6 +228,8 @@ class Toast extends BaseComponent { } } +enableDismissTrigger(Toast) + /** * ------------------------------------------------------------------------ * jQuery @@ -214,6 +237,6 @@ class Toast extends BaseComponent { * add .Toast to jQuery only if jQuery is present */ -defineJQueryPlugin(NAME, Toast) +defineJQueryPlugin(Toast) export default Toast diff --git a/js/src/tooltip.js b/js/src/tooltip.js index d35b5e0ab..288146472 100644 --- a/js/src/tooltip.js +++ b/js/src/tooltip.js @@ -1,6 +1,6 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-beta2): tooltip.js + * Bootstrap (v5.1.0): tooltip.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ @@ -9,19 +9,15 @@ import * as Popper from '@popperjs/core' import { defineJQueryPlugin, - emulateTransitionEnd, findShadowRoot, - getTransitionDurationFromElement, + getElement, getUID, isElement, isRTL, noop, typeCheckConfig } from './util/index' -import { - DefaultAllowlist, - sanitizeHtml -} from './util/sanitizer' +import { DefaultAllowlist, sanitizeHtml } from './util/sanitizer' import Data from './dom/data' import EventHandler from './dom/event-handler' import Manipulator from './dom/manipulator' @@ -38,7 +34,6 @@ const NAME = 'tooltip' const DATA_KEY = 'bs.tooltip' const EVENT_KEY = `.${DATA_KEY}` const CLASS_PREFIX = 'bs-tooltip' -const BSCLS_PREFIX_REGEX = new RegExp(`(^|\\s)${CLASS_PREFIX}\\S+`, 'g') const DISALLOWED_ATTRIBUTES = new Set(['sanitize', 'allowList', 'sanitizeFn']) const DefaultType = { @@ -113,6 +108,9 @@ const HOVER_STATE_SHOW = 'show' const HOVER_STATE_OUT = 'out' 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' @@ -141,7 +139,7 @@ class Tooltip extends BaseComponent { this._popper = null // Protected - this.config = this._getConfig(config) + this._config = this._getConfig(config) this.tip = null this._setListeners() @@ -157,18 +155,10 @@ class Tooltip extends BaseComponent { return NAME } - static get DATA_KEY() { - return DATA_KEY - } - static get Event() { return Event } - static get EVENT_KEY() { - return EVENT_KEY - } - static get DefaultType() { return DefaultType } @@ -215,24 +205,16 @@ class Tooltip extends BaseComponent { dispose() { clearTimeout(this._timeout) - EventHandler.off(this._element, this.constructor.EVENT_KEY) - EventHandler.off(this._element.closest(`.${CLASS_NAME_MODAL}`), 'hide.bs.modal', this._hideModalHandler) + EventHandler.off(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler) - if (this.tip && this.tip.parentNode) { - this.tip.parentNode.removeChild(this.tip) + if (this.tip) { + this.tip.remove() } - this._isEnabled = null - this._timeout = null - this._hoverState = null - this._activeTrigger = null if (this._popper) { this._popper.destroy() } - this._popper = null - this.config = null - this.tip = null super.dispose() } @@ -261,33 +243,34 @@ class Tooltip extends BaseComponent { tip.setAttribute('id', tipId) this._element.setAttribute('aria-describedby', tipId) - this.setContent() - - if (this.config.animation) { + if (this._config.animation) { tip.classList.add(CLASS_NAME_FADE) } - const placement = typeof this.config.placement === 'function' ? - this.config.placement.call(this, tip, this._element) : - this.config.placement + const placement = typeof this._config.placement === 'function' ? + this._config.placement.call(this, tip, this._element) : + this._config.placement const attachment = this._getAttachment(placement) this._addAttachmentClass(attachment) - const container = this._getContainer() - Data.setData(tip, this.constructor.DATA_KEY, this) + const { container } = this._config + Data.set(tip, this.constructor.DATA_KEY, this) if (!this._element.ownerDocument.documentElement.contains(this.tip)) { - container.appendChild(tip) + container.append(tip) + EventHandler.trigger(this._element, this.constructor.Event.INSERTED) } - EventHandler.trigger(this._element, this.constructor.Event.INSERTED) - - this._popper = Popper.createPopper(this._element, tip, this._getPopperConfig(attachment)) + if (this._popper) { + this._popper.update() + } else { + this._popper = Popper.createPopper(this._element, tip, this._getPopperConfig(attachment)) + } tip.classList.add(CLASS_NAME_SHOW) - const customClass = typeof this.config.customClass === 'function' ? this.config.customClass() : this.config.customClass + const customClass = this._resolvePossibleFunction(this._config.customClass) if (customClass) { tip.classList.add(...customClass.split(' ')) } @@ -298,7 +281,7 @@ class Tooltip extends BaseComponent { // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html if ('ontouchstart' in document.documentElement) { [].concat(...document.body.children).forEach(element => { - EventHandler.on(element, 'mouseover', noop()) + EventHandler.on(element, 'mouseover', noop) }) } @@ -313,13 +296,8 @@ class Tooltip extends BaseComponent { } } - if (this.tip.classList.contains(CLASS_NAME_FADE)) { - const transitionDuration = getTransitionDurationFromElement(this.tip) - EventHandler.one(this.tip, 'transitionend', complete) - emulateTransitionEnd(this.tip, transitionDuration) - } else { - complete() - } + const isAnimated = this.tip.classList.contains(CLASS_NAME_FADE) + this._queueCallback(complete, this.tip, isAnimated) } hide() { @@ -329,8 +307,12 @@ class Tooltip extends BaseComponent { const tip = this.getTipElement() const complete = () => { - if (this._hoverState !== HOVER_STATE_SHOW && tip.parentNode) { - tip.parentNode.removeChild(tip) + if (this._isWithActiveTrigger()) { + return + } + + if (this._hoverState !== HOVER_STATE_SHOW) { + tip.remove() } this._cleanTipClass() @@ -361,15 +343,8 @@ class Tooltip extends BaseComponent { this._activeTrigger[TRIGGER_FOCUS] = false this._activeTrigger[TRIGGER_HOVER] = false - if (this.tip.classList.contains(CLASS_NAME_FADE)) { - const transitionDuration = getTransitionDurationFromElement(tip) - - EventHandler.one(tip, 'transitionend', complete) - emulateTransitionEnd(tip, transitionDuration) - } else { - complete() - } - + const isAnimated = this.tip.classList.contains(CLASS_NAME_FADE) + this._queueCallback(complete, this.tip, isAnimated) this._hoverState = '' } @@ -391,16 +366,30 @@ class Tooltip extends BaseComponent { } const element = document.createElement('div') - element.innerHTML = this.config.template + element.innerHTML = this._config.template + + const tip = element.children[0] + this.setContent(tip) + tip.classList.remove(CLASS_NAME_FADE, CLASS_NAME_SHOW) - this.tip = element.children[0] + this.tip = tip return this.tip } - setContent() { - const tip = this.getTipElement() - this.setElementContent(SelectorEngine.findOne(SELECTOR_TOOLTIP_INNER, tip), this.getTitle()) - tip.classList.remove(CLASS_NAME_FADE, CLASS_NAME_SHOW) + setContent(tip) { + this._sanitizeAndSetContent(tip, this.getTitle(), SELECTOR_TOOLTIP_INNER) + } + + _sanitizeAndSetContent(template, content, selector) { + const templateElement = SelectorEngine.findOne(selector, template) + + if (!content && templateElement) { + templateElement.remove() + return + } + + // we use append for html objects to maintain js events + this.setElementContent(templateElement, content) } setElementContent(element, content) { @@ -408,16 +397,14 @@ class Tooltip extends BaseComponent { return } - if (typeof content === 'object' && isElement(content)) { - if (content.jquery) { - content = content[0] - } + if (isElement(content)) { + content = getElement(content) // content is a DOM node or a jQuery - if (this.config.html) { + if (this._config.html) { if (content.parentNode !== element) { element.innerHTML = '' - element.appendChild(content) + element.append(content) } } else { element.textContent = content.textContent @@ -426,9 +413,9 @@ class Tooltip extends BaseComponent { return } - if (this.config.html) { - if (this.config.sanitize) { - content = sanitizeHtml(content, this.config.allowList, this.config.sanitizeFn) + if (this._config.html) { + if (this._config.sanitize) { + content = sanitizeHtml(content, this._config.allowList, this._config.sanitizeFn) } element.innerHTML = content @@ -438,15 +425,9 @@ class Tooltip extends BaseComponent { } getTitle() { - let title = this._element.getAttribute('data-bs-original-title') - - if (!title) { - title = typeof this.config.title === 'function' ? - this.config.title.call(this._element) : - this.config.title - } + const title = this._element.getAttribute('data-bs-original-title') || this._config.title - return title + return this._resolvePossibleFunction(title) } updateAttachment(attachment) { @@ -464,19 +445,11 @@ class Tooltip extends BaseComponent { // Private _initializeOnDelegatedTarget(event, context) { - const dataKey = this.constructor.DATA_KEY - context = context || Data.getData(event.delegateTarget, dataKey) - - if (!context) { - context = new this.constructor(event.delegateTarget, this._getDelegateConfig()) - Data.setData(event.delegateTarget, dataKey, context) - } - - return context + return context || this.constructor.getOrCreateInstance(event.delegateTarget, this._getDelegateConfig()) } _getOffset() { - const { offset } = this.config + const { offset } = this._config if (typeof offset === 'string') { return offset.split(',').map(val => Number.parseInt(val, 10)) @@ -489,6 +462,10 @@ class Tooltip extends BaseComponent { return offset } + _resolvePossibleFunction(content) { + return typeof content === 'function' ? content.call(this._element) : content + } + _getPopperConfig(attachment) { const defaultBsPopperConfig = { placement: attachment, @@ -496,8 +473,7 @@ class Tooltip extends BaseComponent { { name: 'flip', options: { - altBoundary: true, - fallbackPlacements: this.config.fallbackPlacements + fallbackPlacements: this._config.fallbackPlacements } }, { @@ -509,7 +485,7 @@ class Tooltip extends BaseComponent { { name: 'preventOverflow', options: { - boundary: this.config.boundary + boundary: this._config.boundary } }, { @@ -534,24 +510,12 @@ class Tooltip extends BaseComponent { return { ...defaultBsPopperConfig, - ...(typeof this.config.popperConfig === 'function' ? this.config.popperConfig(defaultBsPopperConfig) : this.config.popperConfig) + ...(typeof this._config.popperConfig === 'function' ? this._config.popperConfig(defaultBsPopperConfig) : this._config.popperConfig) } } _addAttachmentClass(attachment) { - this.getTipElement().classList.add(`${CLASS_PREFIX}-${this.updateAttachment(attachment)}`) - } - - _getContainer() { - if (this.config.container === false) { - return document.body - } - - if (isElement(this.config.container)) { - return this.config.container - } - - return SelectorEngine.findOne(this.config.container) + this.getTipElement().classList.add(`${this._getBasicClassPrefix()}-${this.updateAttachment(attachment)}`) } _getAttachment(placement) { @@ -559,11 +523,11 @@ class Tooltip extends BaseComponent { } _setListeners() { - const triggers = this.config.trigger.split(' ') + const triggers = this._config.trigger.split(' ') triggers.forEach(trigger => { if (trigger === 'click') { - EventHandler.on(this._element, this.constructor.Event.CLICK, this.config.selector, event => this.toggle(event)) + EventHandler.on(this._element, this.constructor.Event.CLICK, this._config.selector, event => this.toggle(event)) } else if (trigger !== TRIGGER_MANUAL) { const eventIn = trigger === TRIGGER_HOVER ? this.constructor.Event.MOUSEENTER : @@ -572,8 +536,8 @@ class Tooltip extends BaseComponent { this.constructor.Event.MOUSELEAVE : this.constructor.Event.FOCUSOUT - EventHandler.on(this._element, eventIn, this.config.selector, event => this._enter(event)) - EventHandler.on(this._element, eventOut, this.config.selector, event => this._leave(event)) + EventHandler.on(this._element, eventIn, this._config.selector, event => this._enter(event)) + EventHandler.on(this._element, eventOut, this._config.selector, event => this._leave(event)) } }) @@ -583,11 +547,11 @@ class Tooltip extends BaseComponent { } } - EventHandler.on(this._element.closest(`.${CLASS_NAME_MODAL}`), 'hide.bs.modal', this._hideModalHandler) + EventHandler.on(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler) - if (this.config.selector) { - this.config = { - ...this.config, + if (this._config.selector) { + this._config = { + ...this._config, trigger: 'manual', selector: '' } @@ -628,7 +592,7 @@ class Tooltip extends BaseComponent { context._hoverState = HOVER_STATE_SHOW - if (!context.config.delay || !context.config.delay.show) { + if (!context._config.delay || !context._config.delay.show) { context.show() return } @@ -637,7 +601,7 @@ class Tooltip extends BaseComponent { if (context._hoverState === HOVER_STATE_SHOW) { context.show() } - }, context.config.delay.show) + }, context._config.delay.show) } _leave(event, context) { @@ -646,7 +610,7 @@ class Tooltip extends BaseComponent { if (event) { context._activeTrigger[ event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER - ] = false + ] = context._element.contains(event.relatedTarget) } if (context._isWithActiveTrigger()) { @@ -657,7 +621,7 @@ class Tooltip extends BaseComponent { context._hoverState = HOVER_STATE_OUT - if (!context.config.delay || !context.config.delay.hide) { + if (!context._config.delay || !context._config.delay.hide) { context.hide() return } @@ -666,7 +630,7 @@ class Tooltip extends BaseComponent { if (context._hoverState === HOVER_STATE_OUT) { context.hide() } - }, context.config.delay.hide) + }, context._config.delay.hide) } _isWithActiveTrigger() { @@ -688,16 +652,14 @@ class Tooltip extends BaseComponent { } }) - if (config && typeof config.container === 'object' && config.container.jquery) { - config.container = config.container[0] - } - config = { ...this.constructor.Default, ...dataAttributes, ...(typeof config === 'object' && config ? config : {}) } + config.container = config.container === false ? document.body : getElement(config.container) + if (typeof config.delay === 'number') { config.delay = { show: config.delay, @@ -725,26 +687,32 @@ class Tooltip extends BaseComponent { _getDelegateConfig() { const config = {} - if (this.config) { - for (const key in this.config) { - if (this.constructor.Default[key] !== this.config[key]) { - config[key] = this.config[key] - } + for (const key in this._config) { + if (this.constructor.Default[key] !== this._config[key]) { + config[key] = this._config[key] } } + // 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 } _cleanTipClass() { const tip = this.getTipElement() - const tabClass = tip.getAttribute('class').match(BSCLS_PREFIX_REGEX) + const basicClassPrefixRegex = new RegExp(`(^|\\s)${this._getBasicClassPrefix()}\\S+`, 'g') + const tabClass = tip.getAttribute('class').match(basicClassPrefixRegex) if (tabClass !== null && tabClass.length > 0) { tabClass.map(token => token.trim()) .forEach(tClass => tip.classList.remove(tClass)) } } + _getBasicClassPrefix() { + return CLASS_PREFIX + } + _handlePopperPlacementChange(popperData) { const { state } = popperData @@ -761,16 +729,7 @@ class Tooltip extends BaseComponent { static jQueryInterface(config) { return this.each(function () { - let data = Data.getData(this, DATA_KEY) - const _config = typeof config === 'object' && config - - if (!data && /dispose|hide/.test(config)) { - return - } - - if (!data) { - data = new Tooltip(this, _config) - } + const data = Tooltip.getOrCreateInstance(this, config) if (typeof config === 'string') { if (typeof data[config] === 'undefined') { @@ -790,6 +749,6 @@ class Tooltip extends BaseComponent { * add .Tooltip to jQuery only if jQuery is present */ -defineJQueryPlugin(NAME, Tooltip) +defineJQueryPlugin(Tooltip) export default Tooltip diff --git a/js/src/util/backdrop.js b/js/src/util/backdrop.js new file mode 100644 index 000000000..0f515b3d3 --- /dev/null +++ b/js/src/util/backdrop.js @@ -0,0 +1,130 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap (v5.1.0): util/backdrop.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + +import EventHandler from '../dom/event-handler' +import { execute, executeAfterTransition, getElement, reflow, typeCheckConfig } from './index' + +const Default = { + className: 'modal-backdrop', + isVisible: true, // if false, we use the backdrop helper without adding any element to the dom + isAnimated: false, + rootElement: 'body', // give the choice to place backdrop under different elements + clickCallback: null +} + +const DefaultType = { + className: 'string', + isVisible: 'boolean', + isAnimated: 'boolean', + rootElement: '(element|string)', + clickCallback: '(function|null)' +} +const NAME = 'backdrop' +const CLASS_NAME_FADE = 'fade' +const CLASS_NAME_SHOW = 'show' + +const EVENT_MOUSEDOWN = `mousedown.bs.${NAME}` + +class Backdrop { + constructor(config) { + this._config = this._getConfig(config) + this._isAppended = false + this._element = null + } + + show(callback) { + if (!this._config.isVisible) { + execute(callback) + return + } + + this._append() + + if (this._config.isAnimated) { + reflow(this._getElement()) + } + + this._getElement().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) + }) + } + + // 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 + } + + _getConfig(config) { + config = { + ...Default, + ...(typeof config === 'object' ? config : {}) + } + + // use getElement() with the default "body" to get a fresh Element on each instantiation + config.rootElement = getElement(config.rootElement) + typeCheckConfig(NAME, config, DefaultType) + return config + } + + _append() { + if (this._isAppended) { + return + } + + this._config.rootElement.append(this._getElement()) + + EventHandler.on(this._getElement(), EVENT_MOUSEDOWN, () => { + execute(this._config.clickCallback) + }) + + this._isAppended = true + } + + dispose() { + if (!this._isAppended) { + return + } + + EventHandler.off(this._element, EVENT_MOUSEDOWN) + + this._element.remove() + this._isAppended = false + } + + _emulateAnimation(callback) { + executeAfterTransition(callback, this._getElement(), this._config.isAnimated) + } +} + +export default Backdrop diff --git a/js/src/util/component-functions.js b/js/src/util/component-functions.js new file mode 100644 index 000000000..0737b6374 --- /dev/null +++ b/js/src/util/component-functions.js @@ -0,0 +1,34 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap (v5.1.0): util/component-functions.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import EventHandler from '../dom/event-handler' +import { getElementFromSelector, isDisabled } from './index' + +const enableDismissTrigger = (component, method = 'hide') => { + const clickEvent = `click.dismiss${component.EVENT_KEY}` + const name = component.NAME + + EventHandler.on(document, clickEvent, `[data-bs-dismiss="${name}"]`, function (event) { + if (['A', 'AREA'].includes(this.tagName)) { + event.preventDefault() + } + + if (isDisabled(this)) { + return + } + + const target = getElementFromSelector(this) || this.closest(`.${name}`) + const instance = component.getOrCreateInstance(target) + + // Method argument is left, for Alert and only, as it doesn't implement the 'hide' method + instance[method]() + }) +} + +export { + enableDismissTrigger +} diff --git a/js/src/util/focustrap.js b/js/src/util/focustrap.js new file mode 100644 index 000000000..e35bbe6ab --- /dev/null +++ b/js/src/util/focustrap.js @@ -0,0 +1,109 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap (v5.1.0): util/focustrap.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + +import EventHandler from '../dom/event-handler' +import SelectorEngine from '../dom/selector-engine' +import { typeCheckConfig } from './index' + +const Default = { + trapElement: null, // The element to trap focus inside of + autofocus: true +} + +const DefaultType = { + trapElement: 'element', + autofocus: 'boolean' +} + +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' + +class FocusTrap { + constructor(config) { + this._config = this._getConfig(config) + this._isActive = false + this._lastTabNavDirection = null + } + + activate() { + const { trapElement, autofocus } = this._config + + if (this._isActive) { + return + } + + if (autofocus) { + 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 { target } = event + const { trapElement } = this._config + + if ( + target === document || + target === trapElement || + trapElement.contains(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 + } + + _getConfig(config) { + config = { + ...Default, + ...(typeof config === 'object' ? config : {}) + } + typeCheckConfig(NAME, config, DefaultType) + return config + } +} + +export default FocusTrap diff --git a/js/src/util/index.js b/js/src/util/index.js index ae3cd2ac0..bed2534e5 100644 --- a/js/src/util/index.js +++ b/js/src/util/index.js @@ -1,6 +1,6 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-beta2): util/index.js + * Bootstrap (v5.1.0): util/index.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ @@ -48,7 +48,7 @@ const getSelector = element => { // Just in case some CMS puts out a full URL with the anchor appended if (hrefAttr.includes('#') && !hrefAttr.startsWith('#')) { - hrefAttr = '#' + hrefAttr.split('#')[1] + hrefAttr = `#${hrefAttr.split('#')[1]}` } selector = hrefAttr && hrefAttr !== '#' ? hrefAttr.trim() : null @@ -100,24 +100,28 @@ const triggerTransitionEnd = element => { element.dispatchEvent(new Event(TRANSITION_END)) } -const isElement = obj => (obj[0] || obj).nodeType +const isElement = obj => { + if (!obj || typeof obj !== 'object') { + return false + } -const emulateTransitionEnd = (element, duration) => { - let called = false - const durationPadding = 5 - const emulatedDuration = duration + durationPadding + if (typeof obj.jquery !== 'undefined') { + obj = obj[0] + } - function listener() { - called = true - element.removeEventListener(TRANSITION_END, listener) + return typeof obj.nodeType !== 'undefined' +} + +const getElement = obj => { + if (isElement(obj)) { // it's a jQuery object or a node element + return obj.jquery ? obj[0] : obj } - element.addEventListener(TRANSITION_END, listener) - setTimeout(() => { - if (!called) { - triggerTransitionEnd(element) - } - }, emulatedDuration) + if (typeof obj === 'string' && obj.length > 0) { + return document.querySelector(obj) + } + + return null } const typeCheckConfig = (componentName, config, configTypes) => { @@ -128,29 +132,34 @@ const typeCheckConfig = (componentName, config, configTypes) => { if (!new RegExp(expectedTypes).test(valueType)) { throw new TypeError( - `${componentName.toUpperCase()}: ` + - `Option "${property}" provided type "${valueType}" ` + - `but expected type "${expectedTypes}".` + `${componentName.toUpperCase()}: Option "${property}" provided type "${valueType}" but expected type "${expectedTypes}".` ) } }) } const isVisible = element => { - if (!element) { + if (!isElement(element) || element.getClientRects().length === 0) { return false } - if (element.style && element.parentNode && element.parentNode.style) { - const elementStyle = getComputedStyle(element) - const parentNodeStyle = getComputedStyle(element.parentNode) + return getComputedStyle(element).getPropertyValue('visibility') === 'visible' +} + +const isDisabled = element => { + if (!element || element.nodeType !== Node.ELEMENT_NODE) { + return true + } + + if (element.classList.contains('disabled')) { + return true + } - return elementStyle.display !== 'none' && - parentNodeStyle.display !== 'none' && - elementStyle.visibility !== 'hidden' + if (typeof element.disabled !== 'undefined') { + return element.disabled } - return false + return element.hasAttribute('disabled') && element.getAttribute('disabled') !== 'false' } const findShadowRoot = element => { @@ -176,9 +185,20 @@ const findShadowRoot = element => { return findShadowRoot(element.parentNode) } -const noop = () => function () {} +const noop = () => {} -const reflow = element => element.offsetHeight +/** + * 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 => { + // eslint-disable-next-line no-unused-expressions + element.offsetHeight +} const getjQuery = () => { const { jQuery } = window @@ -190,9 +210,18 @@ const getjQuery = () => { return null } +const DOMContentLoadedCallbacks = [] + const onDOMContentLoaded = callback => { if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', callback) + // add listener on the first call when the document is in loading state + if (!DOMContentLoadedCallbacks.length) { + document.addEventListener('DOMContentLoaded', () => { + DOMContentLoadedCallbacks.forEach(callback => callback()) + }) + } + + DOMContentLoadedCallbacks.push(callback) } else { callback() } @@ -200,11 +229,12 @@ const onDOMContentLoaded = callback => { const isRTL = () => document.documentElement.dir === 'rtl' -const defineJQueryPlugin = (name, plugin) => { +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 @@ -216,21 +246,88 @@ const defineJQueryPlugin = (name, plugin) => { }) } +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) => { + 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 list[!shouldGetNext && isCycleAllowed ? list.length - 1 : 0] + } + + const listLength = list.length + + index += shouldGetNext ? 1 : -1 + + if (isCycleAllowed) { + index = (index + listLength) % listLength + } + + return list[Math.max(0, Math.min(index, listLength - 1))] +} + export { + getElement, getUID, getSelectorFromElement, getElementFromSelector, getTransitionDurationFromElement, triggerTransitionEnd, isElement, - emulateTransitionEnd, typeCheckConfig, isVisible, + isDisabled, findShadowRoot, noop, + getNextActiveElement, reflow, getjQuery, onDOMContentLoaded, isRTL, - defineJQueryPlugin + defineJQueryPlugin, + execute, + executeAfterTransition } diff --git a/js/src/util/sanitizer.js b/js/src/util/sanitizer.js index 18ac6f943..d40655918 100644 --- a/js/src/util/sanitizer.js +++ b/js/src/util/sanitizer.js @@ -1,6 +1,6 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-beta2): util/sanitizer.js + * Bootstrap (v5.1.0): util/sanitizer.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ @@ -23,7 +23,7 @@ const ARIA_ATTRIBUTE_PATTERN = /^aria-[\w-]*$/i * * Shoutout to Angular 7 https://github.com/angular/angular/blob/7.2.4/packages/core/src/sanitization/url_sanitizer.ts */ -const SAFE_URL_PATTERN = /^(?:(?:https?|mailto|ftp|tel|file):|[^#&/:?]*(?:[#/?]|$))/gi +const SAFE_URL_PATTERN = /^(?:(?:https?|mailto|ftp|tel|file):|[^#&/:?]*(?:[#/?]|$))/i /** * A pattern that matches safe data URLs. Only matches image, video and audio types. @@ -108,7 +108,7 @@ export function sanitizeHtml(unsafeHtml, allowList, sanitizeFn) { const elName = el.nodeName.toLowerCase() if (!allowlistKeys.includes(elName)) { - el.parentNode.removeChild(el) + el.remove() continue } diff --git a/js/src/util/scrollbar.js b/js/src/util/scrollbar.js new file mode 100644 index 000000000..c90d82907 --- /dev/null +++ b/js/src/util/scrollbar.js @@ -0,0 +1,97 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap (v5.1.0): 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' + +const SELECTOR_FIXED_CONTENT = '.fixed-top, .fixed-bottom, .is-fixed, .sticky-top' +const SELECTOR_STICKY_CONTENT = '.sticky-top' + +class ScrollBarHelper { + constructor() { + this._element = document.body + } + + 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, 'paddingRight', calculatedValue => calculatedValue + width) + // trick: We adjust positive paddingRight and negative marginRight to sticky-top elements to keep showing fullwidth + this._setElementAttributes(SELECTOR_FIXED_CONTENT, 'paddingRight', calculatedValue => calculatedValue + width) + this._setElementAttributes(SELECTOR_STICKY_CONTENT, 'marginRight', calculatedValue => calculatedValue - width) + } + + _disableOverFlow() { + this._saveInitialAttribute(this._element, 'overflow') + this._element.style.overflow = 'hidden' + } + + _setElementAttributes(selector, styleProp, callback) { + const scrollbarWidth = this.getWidth() + const manipulationCallBack = element => { + if (element !== this._element && window.innerWidth > element.clientWidth + scrollbarWidth) { + return + } + + this._saveInitialAttribute(element, styleProp) + const calculatedValue = window.getComputedStyle(element)[styleProp] + element.style[styleProp] = `${callback(Number.parseFloat(calculatedValue))}px` + } + + this._applyManipulationCallback(selector, manipulationCallBack) + } + + reset() { + this._resetElementAttributes(this._element, 'overflow') + this._resetElementAttributes(this._element, 'paddingRight') + this._resetElementAttributes(SELECTOR_FIXED_CONTENT, 'paddingRight') + this._resetElementAttributes(SELECTOR_STICKY_CONTENT, 'marginRight') + } + + _saveInitialAttribute(element, styleProp) { + const actualValue = element.style[styleProp] + if (actualValue) { + Manipulator.setDataAttribute(element, styleProp, actualValue) + } + } + + _resetElementAttributes(selector, styleProp) { + const manipulationCallBack = element => { + const value = Manipulator.getDataAttribute(element, styleProp) + if (typeof value === 'undefined') { + element.style.removeProperty(styleProp) + } else { + Manipulator.removeDataAttribute(element, styleProp) + element.style[styleProp] = value + } + } + + this._applyManipulationCallback(selector, manipulationCallBack) + } + + _applyManipulationCallback(selector, callBack) { + if (isElement(selector)) { + callBack(selector) + } else { + SelectorEngine.find(selector, this._element).forEach(callBack) + } + } + + isOverflowing() { + return this.getWidth() > 0 + } +} + +export default ScrollBarHelper |
