diff options
| author | Patrick H. Lauke <[email protected]> | 2021-05-04 12:46:06 +0100 |
|---|---|---|
| committer | GitHub <[email protected]> | 2021-05-04 12:46:06 +0100 |
| commit | 8865a8ab1c7157ab81bf49afa62b75f36daee46d (patch) | |
| tree | 97ef78f2ea8e07aab50014176d061fe3c1d49134 /js/src | |
| parent | 018ee6a3b50b958ddb49657086cd9168abf5a485 (diff) | |
| parent | 7ea6578773cb1b7f5cfb8fb41321b3fa10349daf (diff) | |
| download | bootstrap-jo-docs-thanks-page.tar.xz bootstrap-jo-docs-thanks-page.zip | |
Merge branch 'main' into jo-docs-thanks-pagejo-docs-thanks-page
Diffstat (limited to 'js/src')
| -rw-r--r-- | js/src/alert.js | 37 | ||||
| -rw-r--r-- | js/src/base-component.js | 14 | ||||
| -rw-r--r-- | js/src/button.js | 23 | ||||
| -rw-r--r-- | js/src/carousel.js | 171 | ||||
| -rw-r--r-- | js/src/collapse.js | 40 | ||||
| -rw-r--r-- | js/src/dom/data.js | 82 | ||||
| -rw-r--r-- | js/src/dom/event-handler.js | 41 | ||||
| -rw-r--r-- | js/src/dom/manipulator.js | 2 | ||||
| -rw-r--r-- | js/src/dom/selector-engine.js | 15 | ||||
| -rw-r--r-- | js/src/dropdown.js | 359 | ||||
| -rw-r--r-- | js/src/modal.js | 286 | ||||
| -rw-r--r-- | js/src/offcanvas.js | 285 | ||||
| -rw-r--r-- | js/src/popover.js | 23 | ||||
| -rw-r--r-- | js/src/scrollspy.js | 44 | ||||
| -rw-r--r-- | js/src/tab.js | 58 | ||||
| -rw-r--r-- | js/src/toast.js | 28 | ||||
| -rw-r--r-- | js/src/tooltip.js | 328 | ||||
| -rw-r--r-- | js/src/util/backdrop.js | 133 | ||||
| -rw-r--r-- | js/src/util/index.js | 76 | ||||
| -rw-r--r-- | js/src/util/sanitizer.js | 8 | ||||
| -rw-r--r-- | js/src/util/scrollbar.js | 81 |
21 files changed, 1220 insertions, 914 deletions
diff --git a/js/src/alert.js b/js/src/alert.js index f1f612232..a25c44ec3 100644 --- a/js/src/alert.js +++ b/js/src/alert.js @@ -1,14 +1,12 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-alpha3): alert.js + * Bootstrap (v5.0.0-beta3): alert.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ import { - getjQuery, - onDOMContentLoaded, - TRANSITION_END, + defineJQueryPlugin, emulateTransitionEnd, getElementFromSelector, getTransitionDurationFromElement @@ -34,9 +32,9 @@ const EVENT_CLOSE = `close${EVENT_KEY}` const EVENT_CLOSED = `closed${EVENT_KEY}` const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` -const CLASSNAME_ALERT = 'alert' -const CLASSNAME_FADE = 'fade' -const CLASSNAME_SHOW = 'show' +const CLASS_NAME_ALERT = 'alert' +const CLASS_NAME_FADE = 'fade' +const CLASS_NAME_SHOW = 'show' /** * ------------------------------------------------------------------------ @@ -67,7 +65,7 @@ class Alert extends BaseComponent { // Private _getRootElement(element) { - return getElementFromSelector(element) || element.closest(`.${CLASSNAME_ALERT}`) + return getElementFromSelector(element) || element.closest(`.${CLASS_NAME_ALERT}`) } _triggerCloseEvent(element) { @@ -75,16 +73,16 @@ class Alert extends BaseComponent { } _removeElement(element) { - element.classList.remove(CLASSNAME_SHOW) + element.classList.remove(CLASS_NAME_SHOW) - if (!element.classList.contains(CLASSNAME_FADE)) { + if (!element.classList.contains(CLASS_NAME_FADE)) { this._destroyElement(element) return } const transitionDuration = getTransitionDurationFromElement(element) - EventHandler.one(element, TRANSITION_END, () => this._destroyElement(element)) + EventHandler.one(element, 'transitionend', () => this._destroyElement(element)) emulateTransitionEnd(element, transitionDuration) } @@ -100,7 +98,7 @@ class Alert extends BaseComponent { static jQueryInterface(config) { return this.each(function () { - let data = Data.getData(this, DATA_KEY) + let data = Data.get(this, DATA_KEY) if (!data) { data = new Alert(this) @@ -128,6 +126,7 @@ class Alert extends BaseComponent { * Data Api implementation * ------------------------------------------------------------------------ */ + EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DISMISS, Alert.handleDismiss(new Alert())) /** @@ -137,18 +136,6 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DISMISS, Alert.handleDi * add .Alert to jQuery only if jQuery is present */ -onDOMContentLoaded(() => { - const $ = getjQuery() - /* istanbul ignore if */ - if ($) { - const JQUERY_NO_CONFLICT = $.fn[NAME] - $.fn[NAME] = Alert.jQueryInterface - $.fn[NAME].Constructor = Alert - $.fn[NAME].noConflict = () => { - $.fn[NAME] = JQUERY_NO_CONFLICT - return Alert.jQueryInterface - } - } -}) +defineJQueryPlugin(NAME, Alert) export default Alert diff --git a/js/src/base-component.js b/js/src/base-component.js index 776a0052b..77d54faad 100644 --- a/js/src/base-component.js +++ b/js/src/base-component.js @@ -1,11 +1,12 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-alpha3): base-component.js + * Bootstrap (v5.0.0-beta3): base-component.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ import Data from './dom/data' +import EventHandler from './dom/event-handler' /** * ------------------------------------------------------------------------ @@ -13,27 +14,30 @@ import Data from './dom/data' * ------------------------------------------------------------------------ */ -const VERSION = '5.0.0-alpha3' +const VERSION = '5.0.0-beta3' class BaseComponent { constructor(element) { + element = typeof element === 'string' ? document.querySelector(element) : 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) + Data.remove(this._element, this.constructor.DATA_KEY) + EventHandler.off(this._element, `.${this.constructor.DATA_KEY}`) this._element = null } /** Static */ static getInstance(element) { - return Data.getData(element, this.DATA_KEY) + return Data.get(element, this.DATA_KEY) } static get VERSION() { diff --git a/js/src/button.js b/js/src/button.js index 240995564..093679e90 100644 --- a/js/src/button.js +++ b/js/src/button.js @@ -1,11 +1,11 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-alpha3): button.js + * Bootstrap (v5.0.0-beta3): button.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ -import { getjQuery, onDOMContentLoaded } from './util/index' +import { defineJQueryPlugin } from './util/index' import Data from './dom/data' import EventHandler from './dom/event-handler' import BaseComponent from './base-component' @@ -51,7 +51,7 @@ class Button extends BaseComponent { static jQueryInterface(config) { return this.each(function () { - let data = Data.getData(this, DATA_KEY) + let data = Data.get(this, DATA_KEY) if (!data) { data = new Button(this) @@ -75,7 +75,7 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, event => { const button = event.target.closest(SELECTOR_DATA_TOGGLE) - let data = Data.getData(button, DATA_KEY) + let data = Data.get(button, DATA_KEY) if (!data) { data = new Button(button) } @@ -90,19 +90,6 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, event => { * add .Button to jQuery only if jQuery is present */ -onDOMContentLoaded(() => { - const $ = getjQuery() - /* istanbul ignore if */ - if ($) { - const JQUERY_NO_CONFLICT = $.fn[NAME] - $.fn[NAME] = Button.jQueryInterface - $.fn[NAME].Constructor = Button - - $.fn[NAME].noConflict = () => { - $.fn[NAME] = JQUERY_NO_CONFLICT - return Button.jQueryInterface - } - } -}) +defineJQueryPlugin(NAME, Button) export default Button diff --git a/js/src/carousel.js b/js/src/carousel.js index d8ad3a135..ebb0b7b20 100644 --- a/js/src/carousel.js +++ b/js/src/carousel.js @@ -1,17 +1,16 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-alpha3): carousel.js + * Bootstrap (v5.0.0-beta3): carousel.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ import { - getjQuery, - onDOMContentLoaded, - TRANSITION_END, + defineJQueryPlugin, emulateTransitionEnd, getElementFromSelector, getTransitionDurationFromElement, + isRTL, isVisible, reflow, triggerTransitionEnd, @@ -57,8 +56,8 @@ 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' @@ -91,13 +90,12 @@ const SELECTOR_ITEM = '.carousel-item' const SELECTOR_ITEM_IMG = '.carousel-item img' const SELECTOR_NEXT_PREV = '.carousel-item-next, .carousel-item-prev' const SELECTOR_INDICATORS = '.carousel-indicators' +const SELECTOR_INDICATOR = '[data-bs-target]' const SELECTOR_DATA_SLIDE = '[data-bs-slide], [data-bs-slide-to]' const SELECTOR_DATA_RIDE = '[data-bs-ride="carousel"]' -const PointerType = { - TOUCH: 'touch', - PEN: 'pen' -} +const POINTER_TYPE_TOUCH = 'touch' +const POINTER_TYPE_PEN = 'pen' /** * ------------------------------------------------------------------------ @@ -139,7 +137,7 @@ class Carousel extends BaseComponent { next() { if (!this._isSliding) { - this._slide(DIRECTION_NEXT) + this._slide(ORDER_NEXT) } } @@ -153,7 +151,7 @@ class Carousel extends BaseComponent { prev() { if (!this._isSliding) { - this._slide(DIRECTION_PREV) + this._slide(ORDER_PREV) } } @@ -210,17 +208,14 @@ class Carousel extends BaseComponent { return } - const direction = index > activeIndex ? - DIRECTION_NEXT : - DIRECTION_PREV + const order = index > activeIndex ? + ORDER_NEXT : + ORDER_PREV - this._slide(direction, this._items[index]) + this._slide(order, this._items[index]) } dispose() { - super.dispose() - EventHandler.off(this._element, EVENT_KEY) - this._items = null this._config = null this._interval = null @@ -228,6 +223,8 @@ class Carousel extends BaseComponent { this._isSliding = null this._activeElement = null this._indicatorsElement = null + + super.dispose() } // Private @@ -252,15 +249,11 @@ class Carousel extends BaseComponent { this.touchDeltaX = 0 - // swipe left - if (direction > 0) { - this.prev() + if (!direction) { + return } - // swipe right - if (direction < 0) { - this.next() - } + this._slide(direction > 0 ? DIRECTION_RIGHT : DIRECTION_LEFT) } _addEventListeners() { @@ -280,7 +273,7 @@ class Carousel extends BaseComponent { _addTouchEventListeners() { const start = event => { - if (this._pointerEvent && PointerType[event.pointerType.toUpperCase()]) { + if (this._pointerEvent && (event.pointerType === POINTER_TYPE_PEN || event.pointerType === POINTER_TYPE_TOUCH)) { this.touchStartX = event.clientX } else if (!this._pointerEvent) { this.touchStartX = event.touches[0].clientX @@ -289,15 +282,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 && PointerType[event.pointerType.toUpperCase()]) { + if (this._pointerEvent && (event.pointerType === POINTER_TYPE_PEN || event.pointerType === POINTER_TYPE_TOUCH)) { this.touchDeltaX = event.clientX - this.touchStartX } @@ -341,16 +332,12 @@ class Carousel extends BaseComponent { return } - switch (event.key) { - case ARROW_LEFT_KEY: - event.preventDefault() - this.prev() - break - case ARROW_RIGHT_KEY: - event.preventDefault() - this.next() - break - default: + if (event.key === ARROW_LEFT_KEY) { + event.preventDefault() + this._slide(DIRECTION_RIGHT) + } else if (event.key === ARROW_RIGHT_KEY) { + event.preventDefault() + this._slide(DIRECTION_LEFT) } } @@ -362,19 +349,18 @@ class Carousel extends BaseComponent { return this._items.indexOf(element) } - _getItemByDirection(direction, activeElement) { - const isNextDirection = direction === DIRECTION_NEXT - const isPrevDirection = direction === DIRECTION_PREV + _getItemByOrder(order, activeElement) { + const isNext = order === ORDER_NEXT + const isPrev = order === ORDER_PREV const activeIndex = this._getItemIndex(activeElement) const lastItemIndex = this._items.length - 1 - const isGoingToWrap = (isPrevDirection && activeIndex === 0) || - (isNextDirection && activeIndex === lastItemIndex) + const isGoingToWrap = (isPrev && activeIndex === 0) || (isNext && activeIndex === lastItemIndex) if (isGoingToWrap && !this._config.wrap) { return activeElement } - const delta = direction === DIRECTION_PREV ? -1 : 1 + const delta = isPrev ? -1 : 1 const itemIndex = (activeIndex + delta) % this._items.length return itemIndex === -1 ? @@ -396,18 +382,19 @@ class Carousel extends BaseComponent { _setActiveIndicatorElement(element) { if (this._indicatorsElement) { - const indicators = SelectorEngine.find(SELECTOR_ACTIVE, this._indicatorsElement) + const activeIndicator = SelectorEngine.findOne(SELECTOR_ACTIVE, this._indicatorsElement) - for (let i = 0; i < indicators.length; i++) { - indicators[i].classList.remove(CLASS_NAME_ACTIVE) - } + activeIndicator.classList.remove(CLASS_NAME_ACTIVE) + activeIndicator.removeAttribute('aria-current') - const nextIndicator = this._indicatorsElement.children[ - this._getItemIndex(element) - ] + const indicators = SelectorEngine.find(SELECTOR_INDICATOR, this._indicatorsElement) - if (nextIndicator) { - nextIndicator.classList.add(CLASS_NAME_ACTIVE) + for (let i = 0; i < indicators.length; i++) { + if (Number.parseInt(indicators[i].getAttribute('data-bs-slide-to'), 10) === this._getItemIndex(element)) { + indicators[i].classList.add(CLASS_NAME_ACTIVE) + indicators[i].setAttribute('aria-current', 'true') + break + } } } } @@ -429,27 +416,19 @@ 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) - let directionalClassName - let orderClassName - let eventDirectionName - - if (direction === DIRECTION_NEXT) { - directionalClassName = CLASS_NAME_START - orderClassName = CLASS_NAME_NEXT - eventDirectionName = DIRECTION_LEFT - } else { - directionalClassName = CLASS_NAME_END - orderClassName = CLASS_NAME_PREV - eventDirectionName = 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 @@ -485,7 +464,7 @@ class Carousel extends BaseComponent { const transitionDuration = getTransitionDurationFromElement(activeElement) - EventHandler.one(activeElement, TRANSITION_END, () => { + EventHandler.one(activeElement, 'transitionend', () => { nextElement.classList.remove(directionalClassName, orderClassName) nextElement.classList.add(CLASS_NAME_ACTIVE) @@ -522,10 +501,34 @@ 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 data = Data.get(element, DATA_KEY) let _config = { ...Default, ...Manipulator.getDataAttributes(element) @@ -584,7 +587,7 @@ class Carousel extends BaseComponent { Carousel.carouselInterface(target, config) if (slideIndex) { - Data.getData(target, DATA_KEY).to(slideIndex) + Data.get(target, DATA_KEY).to(slideIndex) } event.preventDefault() @@ -603,7 +606,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], Data.get(carousels[i], DATA_KEY)) } }) @@ -614,18 +617,6 @@ EventHandler.on(window, EVENT_LOAD_DATA_API, () => { * add .Carousel to jQuery only if jQuery is present */ -onDOMContentLoaded(() => { - const $ = getjQuery() - /* istanbul ignore if */ - if ($) { - const JQUERY_NO_CONFLICT = $.fn[NAME] - $.fn[NAME] = Carousel.jQueryInterface - $.fn[NAME].Constructor = Carousel - $.fn[NAME].noConflict = () => { - $.fn[NAME] = JQUERY_NO_CONFLICT - return Carousel.jQueryInterface - } - } -}) +defineJQueryPlugin(NAME, Carousel) export default Carousel diff --git a/js/src/collapse.js b/js/src/collapse.js index 0ad2b198e..6cb14cdd2 100644 --- a/js/src/collapse.js +++ b/js/src/collapse.js @@ -1,14 +1,12 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-alpha3): collapse.js + * Bootstrap (v5.0.0-beta3): collapse.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ import { - getjQuery, - onDOMContentLoaded, - TRANSITION_END, + defineJQueryPlugin, emulateTransitionEnd, getSelectorFromElement, getElementFromSelector, @@ -74,8 +72,8 @@ 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}"]` + `${SELECTOR_DATA_TOGGLE}[href="#${this._element.id}"],` + + `${SELECTOR_DATA_TOGGLE}[data-bs-target="#${this._element.id}"]` ) const toggleList = SelectorEngine.find(SELECTOR_DATA_TOGGLE) @@ -84,7 +82,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 @@ -149,7 +147,7 @@ class Collapse extends BaseComponent { const container = SelectorEngine.findOne(this._selector) if (actives) { const tempActiveData = actives.find(elem => container !== elem) - activesData = tempActiveData ? Data.getData(tempActiveData, DATA_KEY) : null + activesData = tempActiveData ? Data.get(tempActiveData, DATA_KEY) : null if (activesData && activesData._isTransitioning) { return @@ -168,7 +166,7 @@ class Collapse extends BaseComponent { } if (!activesData) { - Data.setData(elemActive, DATA_KEY, null) + Data.set(elemActive, DATA_KEY, null) } }) } @@ -204,7 +202,7 @@ class Collapse extends BaseComponent { const scrollSize = `scroll${capitalizedDimension}` const transitionDuration = getTransitionDurationFromElement(this._element) - EventHandler.one(this._element, TRANSITION_END, complete) + EventHandler.one(this._element, 'transitionend', complete) emulateTransitionEnd(this._element, transitionDuration) this._element.style[dimension] = `${this._element[scrollSize]}px` @@ -254,7 +252,7 @@ class Collapse extends BaseComponent { this._element.style[dimension] = '' const transitionDuration = getTransitionDurationFromElement(this._element) - EventHandler.one(this._element, TRANSITION_END, complete) + EventHandler.one(this._element, 'transitionend', complete) emulateTransitionEnd(this._element, transitionDuration) } @@ -334,7 +332,7 @@ class Collapse extends BaseComponent { // Static static collapseInterface(element, config) { - let data = Data.getData(element, DATA_KEY) + let data = Data.get(element, DATA_KEY) const _config = { ...Default, ...Manipulator.getDataAttributes(element), @@ -373,7 +371,7 @@ class Collapse extends BaseComponent { EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) { // preventDefault only for <a> elements (which change the URL) not inside the collapsible element - if (event.target.tagName === 'A') { + if (event.target.tagName === 'A' || (event.delegateTarget && event.delegateTarget.tagName === 'A')) { event.preventDefault() } @@ -382,7 +380,7 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function ( const selectorElements = SelectorEngine.find(selector) selectorElements.forEach(element => { - const data = Data.getData(element, DATA_KEY) + const data = Data.get(element, DATA_KEY) let config if (data) { // update parent attribute @@ -407,18 +405,6 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function ( * add .Collapse to jQuery only if jQuery is present */ -onDOMContentLoaded(() => { - const $ = getjQuery() - /* istanbul ignore if */ - if ($) { - const JQUERY_NO_CONFLICT = $.fn[NAME] - $.fn[NAME] = Collapse.jQueryInterface - $.fn[NAME].Constructor = Collapse - $.fn[NAME].noConflict = () => { - $.fn[NAME] = JQUERY_NO_CONFLICT - return Collapse.jQueryInterface - } - } -}) +defineJQueryPlugin(NAME, Collapse) export default Collapse diff --git a/js/src/dom/data.js b/js/src/dom/data.js index 0a47a58d7..41ad08ab3 100644 --- a/js/src/dom/data.js +++ b/js/src/dom/data.js @@ -1,6 +1,6 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-alpha3): dom/data.js + * Bootstrap (v5.0.0-beta3): 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 ceb6a6e6e..3293f397d 100644 --- a/js/src/dom/event-handler.js +++ b/js/src/dom/event-handler.js @@ -1,6 +1,6 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-alpha3): dom/event-handler.js + * Bootstrap (v5.0.0-beta3): 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', @@ -112,7 +113,8 @@ function bootstrapDelegationHandler(element, selector, fn) { event.delegateTarget = target if (handler.oneOff) { - EventHandler.off(element, event.type, fn) + // eslint-disable-next-line unicorn/consistent-destructuring + EventHandler.off(element, event.type, selector, fn) } return fn.apply(target, [event]) @@ -143,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) { @@ -170,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] = {}) @@ -218,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) @@ -271,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 ed74e0ce2..73b409f7e 100644 --- a/js/src/dom/manipulator.js +++ b/js/src/dom/manipulator.js @@ -1,6 +1,6 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-alpha3): dom/manipulator.js + * Bootstrap (v5.0.0-beta3): dom/manipulator.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ diff --git a/js/src/dom/selector-engine.js b/js/src/dom/selector-engine.js index b42c30c3f..116b02741 100644 --- a/js/src/dom/selector-engine.js +++ b/js/src/dom/selector-engine.js @@ -1,6 +1,6 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-alpha3): dom/selector-engine.js + * Bootstrap (v5.0.0-beta3): dom/selector-engine.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ @@ -14,10 +14,6 @@ const NODE_TEXT = 3 const SelectorEngine = { - matches(element, selector) { - return element.matches(selector) - }, - find(selector, element = document.documentElement) { return [].concat(...Element.prototype.querySelectorAll.call(element, selector)) }, @@ -27,9 +23,8 @@ const SelectorEngine = { }, children(element, selector) { - const children = [].concat(...element.children) - - return children.filter(child => child.matches(selector)) + return [].concat(...element.children) + .filter(child => child.matches(selector)) }, parents(element, selector) { @@ -38,7 +33,7 @@ const SelectorEngine = { let ancestor = element.parentNode while (ancestor && ancestor.nodeType === Node.ELEMENT_NODE && ancestor.nodeType !== NODE_TEXT) { - if (this.matches(ancestor, selector)) { + if (ancestor.matches(selector)) { parents.push(ancestor) } @@ -66,7 +61,7 @@ const SelectorEngine = { let next = element.nextElementSibling while (next) { - if (this.matches(next, selector)) { + if (next.matches(selector)) { return [next] } diff --git a/js/src/dropdown.js b/js/src/dropdown.js index 0ac108ab8..d26fa96ca 100644 --- a/js/src/dropdown.js +++ b/js/src/dropdown.js @@ -1,14 +1,16 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-alpha3): dropdown.js + * Bootstrap (v5.0.0-beta3): dropdown.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ +import * as Popper from '@popperjs/core' + import { - getjQuery, - onDOMContentLoaded, + defineJQueryPlugin, getElementFromSelector, + isDisabled, isElement, isVisible, isRTL, @@ -18,7 +20,6 @@ import { import Data from './dom/data' import EventHandler from './dom/event-handler' import Manipulator from './dom/manipulator' -import Popper from 'popper.js' import SelectorEngine from './dom/selector-engine' import BaseComponent from './base-component' @@ -51,44 +52,40 @@ 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' const CLASS_NAME_DROPSTART = 'dropstart' -const CLASS_NAME_MENUEND = 'dropdown-menu-end' const CLASS_NAME_NAVBAR = 'navbar' -const CLASS_NAME_POSITION_STATIC = 'position-static' 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)' -const PLACEMENT_TOP = isRTL ? 'top-end' : 'top-start' -const PLACEMENT_TOPEND = isRTL ? 'top-start' : 'top-end' -const PLACEMENT_BOTTOM = isRTL ? 'bottom-end' : 'bottom-start' -const PLACEMENT_BOTTOMEND = isRTL ? 'bottom-start' : 'bottom-end' -const PLACEMENT_RIGHT = isRTL ? 'left-start' : 'right-start' -const PLACEMENT_LEFT = isRTL ? 'right-start' : 'left-start' +const PLACEMENT_TOP = isRTL() ? 'top-end' : 'top-start' +const PLACEMENT_TOPEND = isRTL() ? 'top-start' : 'top-end' +const PLACEMENT_BOTTOM = isRTL() ? 'bottom-end' : 'bottom-start' +const PLACEMENT_BOTTOMEND = isRTL() ? 'bottom-start' : 'bottom-end' +const PLACEMENT_RIGHT = isRTL() ? 'left-start' : 'right-start' +const PLACEMENT_LEFT = isRTL() ? 'right-start' : 'left-start' const Default = { - offset: 0, - flip: true, - boundary: 'scrollParent', + offset: [0, 2], + boundary: 'clippingParents', reference: 'toggle', display: 'dynamic', - popperConfig: null + popperConfig: null, + autoClose: true } const DefaultType = { - offset: '(number|string|function)', - flip: 'boolean', + offset: '(array|string|function)', boundary: '(string|element)', - reference: '(string|element)', + reference: '(string|element|object)', display: 'string', - popperConfig: '(null|object)' + popperConfig: '(null|object|function)', + autoClose: '(boolean|string)' } /** @@ -126,15 +123,14 @@ class Dropdown extends BaseComponent { // Public toggle() { - if (this._element.disabled || this._element.classList.contains(CLASS_NAME_DISABLED)) { + if (isDisabled(this._element)) { return } const isActive = this._element.classList.contains(CLASS_NAME_SHOW) - Dropdown.clearMenus() - if (isActive) { + this.hide() return } @@ -142,7 +138,7 @@ class Dropdown extends BaseComponent { } show() { - if (this._element.disabled || this._element.classList.contains(CLASS_NAME_DISABLED) || this._menu.classList.contains(CLASS_NAME_SHOW)) { + if (isDisabled(this._element) || this._menu.classList.contains(CLASS_NAME_SHOW)) { return } @@ -158,7 +154,9 @@ class Dropdown extends BaseComponent { } // Totally disable Popper for Dropdowns in Navbar - if (!this._inNavbar) { + 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)') } @@ -174,16 +172,18 @@ class Dropdown extends BaseComponent { if (typeof this._config.reference.jquery !== 'undefined') { referenceElement = this._config.reference[0] } + } else if (typeof this._config.reference === 'object') { + referenceElement = this._config.reference } - // If boundary is not `scrollParent`, then set position to `static` - // to allow the menu to "escape" the scroll parent's boundaries - // https://github.com/twbs/bootstrap/issues/24251 - if (this._config.boundary !== 'scrollParent') { - parent.classList.add(CLASS_NAME_POSITION_STATIC) - } + const popperConfig = this._getPopperConfig() + const isDisplayStatic = popperConfig.modifiers.find(modifier => modifier.name === 'applyStyles' && modifier.enabled === false) + + this._popper = Popper.createPopper(referenceElement, this._menu, popperConfig) - this._popper = new Popper(referenceElement, this._menu, this._getPopperConfig()) + if (isDisplayStatic) { + Manipulator.setDataAttribute(this._menu, 'popper', 'static') + } } // If this is a touch-enabled device we add extra @@ -193,7 +193,7 @@ 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() @@ -201,48 +201,36 @@ class Dropdown extends BaseComponent { this._menu.classList.toggle(CLASS_NAME_SHOW) this._element.classList.toggle(CLASS_NAME_SHOW) - EventHandler.trigger(parent, EVENT_SHOWN, relatedTarget) + 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._menu.classList.contains(CLASS_NAME_SHOW)) { return } - const parent = Dropdown.getParentFromElement(this._element) const relatedTarget = { relatedTarget: this._element } - const hideEvent = EventHandler.trigger(parent, 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) - EventHandler.trigger(parent, EVENT_HIDDEN, relatedTarget) + this._completeHide(relatedTarget) } dispose() { - super.dispose() - EventHandler.off(this._element, EVENT_KEY) this._menu = null + if (this._popper) { this._popper.destroy() this._popper = null } + + super.dispose() } update() { this._inNavbar = this._detectNavbar() if (this._popper) { - this._popper.scheduleUpdate() + this._popper.update() } } @@ -251,11 +239,34 @@ class Dropdown extends BaseComponent { _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) { config = { ...this.constructor.Default, @@ -265,6 +276,13 @@ class Dropdown extends BaseComponent { typeCheckConfig(NAME, config, this.constructor.DefaultType) + if (typeof config.reference === 'object' && !isElement(config.reference) && + typeof config.reference.getBoundingClientRect !== 'function' + ) { + // Popper virtual elements require a getBoundingClientRect method + throw new TypeError(`${NAME.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`) + } + return config } @@ -274,78 +292,103 @@ class Dropdown extends BaseComponent { _getPlacement() { const parentDropdown = this._element.parentNode - let placement = PLACEMENT_BOTTOM - // Handle dropup + if (parentDropdown.classList.contains(CLASS_NAME_DROPEND)) { + return PLACEMENT_RIGHT + } + + if (parentDropdown.classList.contains(CLASS_NAME_DROPSTART)) { + return PLACEMENT_LEFT + } + + // We need to trim the value because custom properties can also include spaces + const isEnd = getComputedStyle(this._menu).getPropertyValue('--bs-position').trim() === 'end' + if (parentDropdown.classList.contains(CLASS_NAME_DROPUP)) { - placement = this._menu.classList.contains(CLASS_NAME_MENUEND) ? - PLACEMENT_TOPEND : - PLACEMENT_TOP - } else if (parentDropdown.classList.contains(CLASS_NAME_DROPEND)) { - placement = PLACEMENT_RIGHT - } else if (parentDropdown.classList.contains(CLASS_NAME_DROPSTART)) { - placement = PLACEMENT_LEFT - } else if (this._menu.classList.contains(CLASS_NAME_MENUEND)) { - placement = PLACEMENT_BOTTOMEND - } - - return placement + return isEnd ? PLACEMENT_TOPEND : PLACEMENT_TOP + } + + return isEnd ? PLACEMENT_BOTTOMEND : PLACEMENT_BOTTOM } _detectNavbar() { - return Boolean(this._element.closest(`.${CLASS_NAME_NAVBAR}`)) + return this._element.closest(`.${CLASS_NAME_NAVBAR}`) !== null } _getOffset() { - const offset = {} + const { offset } = this._config - if (typeof this._config.offset === 'function') { - offset.fn = data => { - data.offsets = { - ...data.offsets, - ...(this._config.offset(data.offsets, this._element) || {}) - } + if (typeof offset === 'string') { + return offset.split(',').map(val => Number.parseInt(val, 10)) + } - return data - } - } else { - offset.offset = this._config.offset + if (typeof offset === 'function') { + return popperData => offset(popperData, this._element) } return offset } _getPopperConfig() { - const popperConfig = { + const defaultBsPopperConfig = { placement: this._getPlacement(), - modifiers: { - offset: this._getOffset(), - flip: { - enabled: this._config.flip - }, - preventOverflow: { - boundariesElement: this._config.boundary + modifiers: [{ + name: 'preventOverflow', + options: { + boundary: this._config.boundary } - } + }, + { + name: 'offset', + options: { + offset: this._getOffset() + } + }] } // Disable Popper if we have a static display if (this._config.display === 'static') { - popperConfig.modifiers.applyStyle = { + defaultBsPopperConfig.modifiers = [{ + name: 'applyStyles', enabled: false - } + }] } return { - ...popperConfig, - ...this._config.popperConfig + ...defaultBsPopperConfig, + ...(typeof this._config.popperConfig === 'function' ? this._config.popperConfig(defaultBsPopperConfig) : this._config.popperConfig) + } + } + + _selectMenuItem(event) { + const items = SelectorEngine.find(SELECTOR_VISIBLE_ITEMS, this._menu).filter(isVisible) + + if (!items.length) { + return + } + + let index = items.indexOf(event.target) + + // Up + if (event.key === ARROW_UP_KEY && index > 0) { + index-- + } + + // Down + if (event.key === ARROW_DOWN_KEY && index < items.length - 1) { + index++ } + + // index is -1 if the first keydown is an ArrowUp + index = index === -1 ? 0 : index + + items[index].focus() } // Static static dropdownInterface(element, config) { - let data = Data.getData(element, DATA_KEY) + let data = Data.get(element, DATA_KEY) const _config = typeof config === 'object' ? config : null if (!data) { @@ -368,60 +411,54 @@ class Dropdown extends BaseComponent { } static clearMenus(event) { - if (event && (event.button === RIGHT_MOUSE_BUTTON || (event.type === 'keyup' && event.key !== TAB_KEY))) { - return - } - - const toggles = SelectorEngine.find(SELECTOR_DATA_TOGGLE) - - for (let i = 0, len = toggles.length; i < len; i++) { - const parent = Dropdown.getParentFromElement(toggles[i]) - const context = Data.getData(toggles[i], DATA_KEY) - const relatedTarget = { - relatedTarget: toggles[i] + if (event) { + if (event.button === RIGHT_MOUSE_BUTTON || (event.type === 'keyup' && event.key !== TAB_KEY)) { + return } - if (event && event.type === 'click') { - relatedTarget.clickEvent = event + if (/input|select|option|textarea|form/i.test(event.target.tagName)) { + return } + } - if (!context) { - continue - } + const toggles = SelectorEngine.find(SELECTOR_DATA_TOGGLE) - const dropdownMenu = context._menu - if (!toggles[i].classList.contains(CLASS_NAME_SHOW)) { + for (let i = 0, len = toggles.length; i < len; i++) { + const context = Data.get(toggles[i], DATA_KEY) + 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._element.classList.contains(CLASS_NAME_SHOW)) { continue } - const hideEvent = EventHandler.trigger(parent, 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 shouldn't close the menu + if (event.type === 'keyup' && event.key === TAB_KEY && context._menu.contains(event.target)) { + 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) - EventHandler.trigger(parent, EVENT_HIDDEN, relatedTarget) + context._completeHide(relatedTarget) } } @@ -445,50 +482,38 @@ class Dropdown extends BaseComponent { return } + const isActive = this.classList.contains(CLASS_NAME_SHOW) + + if (!isActive && event.key === ESCAPE_KEY) { + return + } + event.preventDefault() event.stopPropagation() - if (this.disabled || this.classList.contains(CLASS_NAME_DISABLED)) { + if (isDisabled(this)) { return } - const parent = Dropdown.getParentFromElement(this) - const isActive = this.classList.contains(CLASS_NAME_SHOW) + const getToggleButton = () => this.matches(SELECTOR_DATA_TOGGLE) ? this : SelectorEngine.prev(this, SELECTOR_DATA_TOGGLE)[0] if (event.key === ESCAPE_KEY) { - const button = this.matches(SELECTOR_DATA_TOGGLE) ? this : SelectorEngine.prev(this, SELECTOR_DATA_TOGGLE)[0] - button.focus() + getToggleButton().focus() Dropdown.clearMenus() return } - if (!isActive || event.key === SPACE_KEY) { - Dropdown.clearMenus() + if (!isActive && (event.key === ARROW_UP_KEY || event.key === ARROW_DOWN_KEY)) { + getToggleButton().click() return } - const items = SelectorEngine.find(SELECTOR_VISIBLE_ITEMS, parent).filter(isVisible) - - if (!items.length) { + if (!isActive || event.key === SPACE_KEY) { + Dropdown.clearMenus() return } - let index = items.indexOf(event.target) - - // Up - if (event.key === ARROW_UP_KEY && index > 0) { - index-- - } - - // Down - if (event.key === ARROW_DOWN_KEY && index < items.length - 1) { - index++ - } - - // index is -1 if the first keydown is an ArrowUp - index = index === -1 ? 0 : index - - items[index].focus() + Dropdown.getInstance(getToggleButton())._selectMenuItem(event) } } @@ -504,10 +529,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.dropdownInterface(this) }) -EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_FORM_CHILD, e => e.stopPropagation()) /** * ------------------------------------------------------------------------ @@ -516,18 +539,6 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_FORM_CHILD, e => e.stop * add .Dropdown to jQuery only if jQuery is present */ -onDOMContentLoaded(() => { - const $ = getjQuery() - /* istanbul ignore if */ - if ($) { - const JQUERY_NO_CONFLICT = $.fn[NAME] - $.fn[NAME] = Dropdown.jQueryInterface - $.fn[NAME].Constructor = Dropdown - $.fn[NAME].noConflict = () => { - $.fn[NAME] = JQUERY_NO_CONFLICT - return Dropdown.jQueryInterface - } - } -}) +defineJQueryPlugin(NAME, Dropdown) export default Dropdown diff --git a/js/src/modal.js b/js/src/modal.js index 94bf95f8a..fabb151cb 100644 --- a/js/src/modal.js +++ b/js/src/modal.js @@ -1,27 +1,26 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-alpha3): modal.js + * Bootstrap (v5.0.0-beta3): modal.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ import { - getjQuery, - onDOMContentLoaded, - TRANSITION_END, + 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 { getWidth as getScrollBarWidth, hide as scrollBarHide, reset as scrollBarReset } from './util/scrollbar' import BaseComponent from './base-component' +import Backdrop from './util/backdrop' /** * ------------------------------------------------------------------------ @@ -60,8 +59,6 @@ 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' @@ -71,8 +68,6 @@ 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' /** * ------------------------------------------------------------------------ @@ -85,13 +80,11 @@ 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._isShown = false - this._isBodyOverflowing = false this._ignoreBackdropClick = false this._isTransitioning = false - this._scrollbarWidth = 0 } // Getters @@ -115,7 +108,7 @@ class Modal extends BaseComponent { return } - if (this._element.classList.contains(CLASS_NAME_FADE)) { + if (this._isAnimated()) { this._isTransitioning = true } @@ -129,8 +122,9 @@ class Modal extends BaseComponent { this._isShown = true - this._checkScrollbar() - this._setScrollbar() + scrollBarHide() + + document.body.classList.add(CLASS_NAME_OPEN) this._adjustDialog() @@ -166,9 +160,9 @@ 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 } @@ -182,10 +176,10 @@ class Modal extends BaseComponent { EventHandler.off(this._element, EVENT_CLICK_DISMISS) EventHandler.off(this._dialog, EVENT_MOUSEDOWN_DISMISS) - if (transition) { + if (isAnimated) { const transitionDuration = getTransitionDurationFromElement(this._element) - EventHandler.one(this._element, TRANSITION_END, event => this._hideModal(event)) + EventHandler.one(this._element, 'transitionend', event => this._hideModal(event)) emulateTransitionEnd(this._element, transitionDuration) } else { this._hideModal() @@ -193,7 +187,7 @@ class Modal extends BaseComponent { } dispose() { - [window, this._element, this._dialog] + [window, this._dialog] .forEach(htmlElement => EventHandler.off(htmlElement, EVENT_KEY)) super.dispose() @@ -207,12 +201,11 @@ class Modal extends BaseComponent { this._config = null this._dialog = null + this._backdrop.dispose() this._backdrop = null this._isShown = null - this._isBodyOverflowing = null this._ignoreBackdropClick = null this._isTransitioning = null - this._scrollbarWidth = null } handleUpdate() { @@ -221,9 +214,17 @@ 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() + }) + } + _getConfig(config) { config = { ...Default, + ...Manipulator.getDataAttributes(this._element), ...config } typeCheckConfig(NAME, config, DefaultType) @@ -231,7 +232,7 @@ class Modal extends BaseComponent { } _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) { @@ -249,7 +250,7 @@ class Modal extends BaseComponent { modalBody.scrollTop = 0 } - if (transition) { + if (isAnimated) { reflow(this._element) } @@ -270,10 +271,10 @@ class Modal extends BaseComponent { }) } - if (transition) { + if (isAnimated) { const transitionDuration = getTransitionDurationFromElement(this._dialog) - EventHandler.one(this._dialog, TRANSITION_END, transitionComplete) + EventHandler.one(this._dialog, 'transitionend', transitionComplete) emulateTransitionEnd(this._dialog, transitionDuration) } else { transitionComplete() @@ -320,84 +321,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() + scrollBarReset() 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, TRANSITION_END, 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, TRANSITION_END, callbackRemove) - emulateTransitionEnd(this._backdrop, backdropTransitionDuration) - } else { - callbackRemove() - } - } else { - callback() - } + this._backdrop.show(callback) + } + + _isAnimated() { + return this._element.classList.contains(CLASS_NAME_FADE) } _triggerBackdropTransition() { @@ -414,11 +368,11 @@ class Modal extends BaseComponent { this._element.classList.add(CLASS_NAME_STATIC) const modalTransitionDuration = getTransitionDurationFromElement(this._dialog) - EventHandler.off(this._element, TRANSITION_END) - EventHandler.one(this._element, TRANSITION_END, () => { + EventHandler.off(this._element, 'transitionend') + EventHandler.one(this._element, 'transitionend', () => { this._element.classList.remove(CLASS_NAME_STATIC) if (!isModalOverflowing) { - EventHandler.one(this._element, TRANSITION_END, () => { + EventHandler.one(this._element, 'transitionend', () => { this._element.style.overflowY = '' }) emulateTransitionEnd(this._element, modalTransitionDuration) @@ -433,15 +387,16 @@ class Modal extends BaseComponent { // ---------------------------------------------------------------------- _adjustDialog() { - const isModalOverflowing = - this._element.scrollHeight > document.documentElement.clientHeight + const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight + const scrollbarWidth = getScrollBarWidth() + 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` } } @@ -450,108 +405,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) { - // Note: DOMNode.style.paddingRight returns the actual value or '' if not set - // while $(DOMNode).css('padding-right') returns the calculated value or 0 if not set - - // Adjust fixed content padding - SelectorEngine.find(SELECTOR_FIXED_CONTENT) - .forEach(element => { - const actualPadding = element.style.paddingRight - const calculatedPadding = window.getComputedStyle(element)['padding-right'] - Manipulator.setDataAttribute(element, 'padding-right', actualPadding) - element.style.paddingRight = `${Number.parseFloat(calculatedPadding) + this._scrollbarWidth}px` - }) - - // Adjust sticky content margin - SelectorEngine.find(SELECTOR_STICKY_CONTENT) - .forEach(element => { - const actualMargin = element.style.marginRight - const calculatedMargin = window.getComputedStyle(element)['margin-right'] - Manipulator.setDataAttribute(element, 'margin-right', actualMargin) - element.style.marginRight = `${Number.parseFloat(calculatedMargin) - this._scrollbarWidth}px` - }) - - // Adjust body padding - const actualPadding = document.body.style.paddingRight - const calculatedPadding = window.getComputedStyle(document.body)['padding-right'] - - Manipulator.setDataAttribute(document.body, 'padding-right', actualPadding) - document.body.style.paddingRight = `${Number.parseFloat(calculatedPadding) + this._scrollbarWidth}px` - } - - document.body.classList.add(CLASS_NAME_OPEN) - } - - _resetScrollbar() { - // Restore fixed content padding - SelectorEngine.find(SELECTOR_FIXED_CONTENT) - .forEach(element => { - const padding = Manipulator.getDataAttribute(element, 'padding-right') - if (typeof padding !== 'undefined') { - Manipulator.removeDataAttribute(element, 'padding-right') - element.style.paddingRight = padding - } - }) - - // Restore sticky content and navbar-toggler margin - SelectorEngine.find(`${SELECTOR_STICKY_CONTENT}`) - .forEach(element => { - const margin = Manipulator.getDataAttribute(element, 'margin-right') - if (typeof margin !== 'undefined') { - Manipulator.removeDataAttribute(element, 'margin-right') - element.style.marginRight = margin - } - }) - - // Restore body padding - const padding = Manipulator.getDataAttribute(document.body, 'padding-right') - if (typeof padding === 'undefined') { - document.body.style.paddingRight = '' - } else { - Manipulator.removeDataAttribute(document.body, 'padding-right') - document.body.style.paddingRight = padding - } - } - - _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.getInstance(this) || new Modal(this, typeof config === 'object' ? 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) }) } } @@ -565,7 +433,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() } @@ -582,17 +450,9 @@ 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) - } + const data = Modal.getInstance(target) || new Modal(target) - data.show(this) + data.toggle(this) }) /** @@ -602,18 +462,6 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function ( * add .Modal to jQuery only if jQuery is present */ -onDOMContentLoaded(() => { - const $ = getjQuery() - /* istanbul ignore if */ - if ($) { - const JQUERY_NO_CONFLICT = $.fn[NAME] - $.fn[NAME] = Modal.jQueryInterface - $.fn[NAME].Constructor = Modal - $.fn[NAME].noConflict = () => { - $.fn[NAME] = JQUERY_NO_CONFLICT - return Modal.jQueryInterface - } - } -}) +defineJQueryPlugin(NAME, Modal) export default Modal diff --git a/js/src/offcanvas.js b/js/src/offcanvas.js new file mode 100644 index 000000000..7fcdfb48a --- /dev/null +++ b/js/src/offcanvas.js @@ -0,0 +1,285 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap (v5.0.0-beta3): offcanvas.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + +import { + defineJQueryPlugin, + emulateTransitionEnd, + getElementFromSelector, + getTransitionDurationFromElement, + isDisabled, + isVisible, + typeCheckConfig +} from './util/index' +import { hide as scrollBarHide, reset as scrollBarReset } from './util/scrollbar' +import Data from './dom/data' +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' + +/** + * ------------------------------------------------------------------------ + * 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 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_FOCUSIN = `focusin${EVENT_KEY}` +const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` +const EVENT_CLICK_DISMISS = `click.dismiss${EVENT_KEY}` +const EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY}` + +const SELECTOR_DATA_DISMISS = '[data-bs-dismiss="offcanvas"]' +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._addEventListeners() + } + + // Getters + + static get Default() { + return Default + } + + static get DATA_KEY() { + return DATA_KEY + } + + // 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) { + scrollBarHide() + this._enforceFocusOnElement(this._element) + } + + 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 = () => { + EventHandler.trigger(this._element, EVENT_SHOWN, { relatedTarget }) + } + + const transitionDuration = getTransitionDurationFromElement(this._element) + EventHandler.one(this._element, 'transitionend', completeCallBack) + emulateTransitionEnd(this._element, transitionDuration) + } + + hide() { + if (!this._isShown) { + return + } + + const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE) + + if (hideEvent.defaultPrevented) { + return + } + + EventHandler.off(document, EVENT_FOCUSIN) + 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) { + scrollBarReset() + } + + EventHandler.trigger(this._element, EVENT_HIDDEN) + } + + const transitionDuration = getTransitionDurationFromElement(this._element) + EventHandler.one(this._element, 'transitionend', completeCallback) + emulateTransitionEnd(this._element, transitionDuration) + } + + dispose() { + this._backdrop.dispose() + super.dispose() + EventHandler.off(document, EVENT_FOCUSIN) + + this._config = null + this._backdrop = null + } + + // Private + + _getConfig(config) { + config = { + ...Default, + ...Manipulator.getDataAttributes(this._element), + ...(typeof config === 'object' ? config : {}) + } + typeCheckConfig(NAME, config, DefaultType) + return config + } + + _initializeBackDrop() { + return new Backdrop({ + isVisible: this._config.backdrop, + isAnimated: true, + rootElement: this._element.parentNode, + clickCallback: () => this.hide() + }) + } + + _enforceFocusOnElement(element) { + EventHandler.off(document, EVENT_FOCUSIN) // guard against infinite focus loop + EventHandler.on(document, EVENT_FOCUSIN, event => { + if (document !== event.target && + element !== event.target && + !element.contains(event.target)) { + element.focus() + } + }) + element.focus() + } + + _addEventListeners() { + EventHandler.on(this._element, EVENT_CLICK_DISMISS, SELECTOR_DATA_DISMISS, () => this.hide()) + + 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 = Data.get(this, DATA_KEY) || new Offcanvas(this, typeof config === 'object' ? 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 = Data.get(target, DATA_KEY) || new Offcanvas(target) + + data.toggle(this) +}) + +EventHandler.on(window, EVENT_LOAD_DATA_API, () => { + SelectorEngine.find(OPEN_SELECTOR).forEach(el => (Data.get(el, DATA_KEY) || new Offcanvas(el)).show()) +}) + +/** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + */ + +defineJQueryPlugin(NAME, Offcanvas) + +export default Offcanvas diff --git a/js/src/popover.js b/js/src/popover.js index d8bd92eef..fa8d2c961 100644 --- a/js/src/popover.js +++ b/js/src/popover.js @@ -1,11 +1,11 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-alpha3): popover.js + * Bootstrap (v5.0.0-beta3): popover.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ -import { getjQuery, onDOMContentLoaded } from './util/index' +import { defineJQueryPlugin } from './util/index' import Data from './dom/data' import SelectorEngine from './dom/selector-engine' import Tooltip from './tooltip' @@ -25,6 +25,7 @@ const BSCLS_PREFIX_REGEX = new RegExp(`(^|\\s)${CLASS_PREFIX}\\S+`, 'g') const Default = { ...Tooltip.Default, placement: 'right', + offset: [0, 8], trigger: 'click', content: '', template: '<div class="popover" role="tooltip">' + @@ -135,7 +136,7 @@ class Popover extends Tooltip { static jQueryInterface(config) { return this.each(function () { - let data = Data.getData(this, DATA_KEY) + let data = Data.get(this, DATA_KEY) const _config = typeof config === 'object' ? config : null if (!data && /dispose|hide/.test(config)) { @@ -144,7 +145,7 @@ class Popover extends Tooltip { if (!data) { data = new Popover(this, _config) - Data.setData(this, DATA_KEY, data) + Data.set(this, DATA_KEY, data) } if (typeof config === 'string') { @@ -165,18 +166,6 @@ class Popover extends Tooltip { * add .Popover to jQuery only if jQuery is present */ -onDOMContentLoaded(() => { - const $ = getjQuery() - /* istanbul ignore if */ - if ($) { - const JQUERY_NO_CONFLICT = $.fn[NAME] - $.fn[NAME] = Popover.jQueryInterface - $.fn[NAME].Constructor = Popover - $.fn[NAME].noConflict = () => { - $.fn[NAME] = JQUERY_NO_CONFLICT - return Popover.jQueryInterface - } - } -}) +defineJQueryPlugin(NAME, Popover) export default Popover diff --git a/js/src/scrollspy.js b/js/src/scrollspy.js index a05e57d62..4e830b530 100644 --- a/js/src/scrollspy.js +++ b/js/src/scrollspy.js @@ -1,19 +1,17 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-alpha3): scrollspy.js + * Bootstrap (v5.0.0-beta3): scrollspy.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ import { - getjQuery, - onDOMContentLoaded, + defineJQueryPlugin, 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' @@ -69,7 +67,7 @@ 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 = [] @@ -77,7 +75,7 @@ class ScrollSpy extends BaseComponent { this._activeTarget = null this._scrollHeight = 0 - EventHandler.on(this._scrollElement, EVENT_SCROLL, event => this._process(event)) + EventHandler.on(this._scrollElement, EVENT_SCROLL, () => this._process()) this.refresh() this._process() @@ -156,6 +154,7 @@ class ScrollSpy extends BaseComponent { _getConfig(config) { config = { ...Default, + ...Manipulator.getDataAttributes(this._element), ...(typeof config === 'object' && config ? config : {}) } @@ -279,20 +278,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.getInstance(this) || new ScrollSpy(this, typeof config === 'object' ? 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]() }) } } @@ -305,7 +301,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)) }) /** @@ -315,18 +311,6 @@ EventHandler.on(window, EVENT_LOAD_DATA_API, () => { * add .ScrollSpy to jQuery only if jQuery is present */ -onDOMContentLoaded(() => { - const $ = getjQuery() - /* istanbul ignore if */ - if ($) { - const JQUERY_NO_CONFLICT = $.fn[NAME] - $.fn[NAME] = ScrollSpy.jQueryInterface - $.fn[NAME].Constructor = ScrollSpy - $.fn[NAME].noConflict = () => { - $.fn[NAME] = JQUERY_NO_CONFLICT - return ScrollSpy.jQueryInterface - } - } -}) +defineJQueryPlugin(NAME, ScrollSpy) export default ScrollSpy diff --git a/js/src/tab.js b/js/src/tab.js index c8aac3be7..7301779d6 100644 --- a/js/src/tab.js +++ b/js/src/tab.js @@ -1,17 +1,16 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-alpha3): tab.js + * Bootstrap (v5.0.0-beta3): tab.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ import { - getjQuery, - onDOMContentLoaded, - TRANSITION_END, + defineJQueryPlugin, emulateTransitionEnd, getElementFromSelector, getTransitionDurationFromElement, + isDisabled, reflow } from './util/index' import Data from './dom/data' @@ -38,7 +37,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' @@ -68,8 +66,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 } @@ -83,13 +80,11 @@ class Tab extends BaseComponent { previous = previous[previous.length - 1] } - let hideEvent = null - - if (previous) { - hideEvent = EventHandler.trigger(previous, EVENT_HIDE, { + const hideEvent = previous ? + EventHandler.trigger(previous, EVENT_HIDE, { relatedTarget: this._element - }) - } + }) : + null const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, { relatedTarget: previous @@ -133,7 +128,7 @@ class Tab extends BaseComponent { const transitionDuration = getTransitionDurationFromElement(active) active.classList.remove(CLASS_NAME_SHOW) - EventHandler.one(active, TRANSITION_END, complete) + EventHandler.one(active, 'transitionend', complete) emulateTransitionEnd(active, transitionDuration) } else { complete() @@ -166,11 +161,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)) } @@ -186,7 +186,7 @@ class Tab extends BaseComponent { static jQueryInterface(config) { return this.each(function () { - const data = Data.getData(this, DATA_KEY) || new Tab(this) + const data = Data.get(this, DATA_KEY) || new Tab(this) if (typeof config === 'string') { if (typeof data[config] === 'undefined') { @@ -206,9 +206,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() + } - const data = Data.getData(this, DATA_KEY) || new Tab(this) + if (isDisabled(this)) { + return + } + + const data = Data.get(this, DATA_KEY) || new Tab(this) data.show() }) @@ -219,18 +225,6 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function ( * add .Tab to jQuery only if jQuery is present */ -onDOMContentLoaded(() => { - const $ = getjQuery() - /* istanbul ignore if */ - if ($) { - const JQUERY_NO_CONFLICT = $.fn[NAME] - $.fn[NAME] = Tab.jQueryInterface - $.fn[NAME].Constructor = Tab - $.fn[NAME].noConflict = () => { - $.fn[NAME] = JQUERY_NO_CONFLICT - return Tab.jQueryInterface - } - } -}) +defineJQueryPlugin(NAME, Tab) export default Tab diff --git a/js/src/toast.js b/js/src/toast.js index 30df4606a..5d762b29d 100644 --- a/js/src/toast.js +++ b/js/src/toast.js @@ -1,14 +1,12 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-alpha3): toast.js + * Bootstrap (v5.0.0-beta3): toast.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ import { - getjQuery, - onDOMContentLoaded, - TRANSITION_END, + defineJQueryPlugin, emulateTransitionEnd, getTransitionDurationFromElement, reflow, @@ -117,7 +115,7 @@ class Toast extends BaseComponent { if (this._config.animation) { const transitionDuration = getTransitionDurationFromElement(this._element) - EventHandler.one(this._element, TRANSITION_END, complete) + EventHandler.one(this._element, 'transitionend', complete) emulateTransitionEnd(this._element, transitionDuration) } else { complete() @@ -144,7 +142,7 @@ class Toast extends BaseComponent { if (this._config.animation) { const transitionDuration = getTransitionDurationFromElement(this._element) - EventHandler.one(this._element, TRANSITION_END, complete) + EventHandler.one(this._element, 'transitionend', complete) emulateTransitionEnd(this._element, transitionDuration) } else { complete() @@ -158,8 +156,6 @@ class Toast extends BaseComponent { this._element.classList.remove(CLASS_NAME_SHOW) } - EventHandler.off(this._element, EVENT_CLICK_DISMISS) - super.dispose() this._config = null } @@ -191,7 +187,7 @@ class Toast extends BaseComponent { static jQueryInterface(config) { return this.each(function () { - let data = Data.getData(this, DATA_KEY) + let data = Data.get(this, DATA_KEY) const _config = typeof config === 'object' && config if (!data) { @@ -216,18 +212,6 @@ class Toast extends BaseComponent { * add .Toast to jQuery only if jQuery is present */ -onDOMContentLoaded(() => { - const $ = getjQuery() - /* istanbul ignore if */ - if ($) { - const JQUERY_NO_CONFLICT = $.fn[NAME] - $.fn[NAME] = Toast.jQueryInterface - $.fn[NAME].Constructor = Toast - $.fn[NAME].noConflict = () => { - $.fn[NAME] = JQUERY_NO_CONFLICT - return Toast.jQueryInterface - } - } -}) +defineJQueryPlugin(NAME, Toast) export default Toast diff --git a/js/src/tooltip.js b/js/src/tooltip.js index 17148ed9a..ecea04390 100644 --- a/js/src/tooltip.js +++ b/js/src/tooltip.js @@ -1,14 +1,14 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-alpha3): tooltip.js + * Bootstrap (v5.0.0-beta3): tooltip.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ +import * as Popper from '@popperjs/core' + import { - getjQuery, - onDOMContentLoaded, - TRANSITION_END, + defineJQueryPlugin, emulateTransitionEnd, findShadowRoot, getTransitionDurationFromElement, @@ -25,7 +25,6 @@ import { import Data from './dom/data' import EventHandler from './dom/event-handler' import Manipulator from './dom/manipulator' -import Popper from 'popper.js' import SelectorEngine from './dom/selector-engine' import BaseComponent from './base-component' @@ -51,23 +50,23 @@ const DefaultType = { html: 'boolean', selector: '(string|boolean)', placement: '(string|function)', - offset: '(number|string|function)', + offset: '(array|string|function)', container: '(string|element|boolean)', - fallbackPlacement: '(string|array)', + fallbackPlacements: 'array', boundary: '(string|element)', customClass: '(string|function)', sanitize: 'boolean', sanitizeFn: '(null|function)', allowList: 'object', - popperConfig: '(null|object)' + popperConfig: '(null|object|function)' } const AttachmentMap = { AUTO: 'auto', TOP: 'top', - RIGHT: isRTL ? 'left' : 'right', + RIGHT: isRTL() ? 'left' : 'right', BOTTOM: 'bottom', - LEFT: isRTL ? 'right' : 'left' + LEFT: isRTL() ? 'right' : 'left' } const Default = { @@ -82,10 +81,10 @@ const Default = { html: false, selector: false, placement: 'top', - offset: 0, + offset: [0, 0], container: false, - fallbackPlacement: 'flip', - boundary: 'scrollParent', + fallbackPlacements: ['top', 'right', 'bottom', 'left'], + boundary: 'clippingParents', customClass: '', sanitize: true, sanitizeFn: null, @@ -194,13 +193,7 @@ class Tooltip extends BaseComponent { } if (event) { - const dataKey = this.constructor.DATA_KEY - let context = Data.getData(event.delegateTarget, dataKey) - - if (!context) { - context = new this.constructor(event.delegateTarget, this._getDelegateConfig()) - Data.setData(event.delegateTarget, dataKey, context) - } + const context = this._initializeOnDelegatedTarget(event) context._activeTrigger.click = !context._activeTrigger.click @@ -222,10 +215,9 @@ 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) - if (this.tip) { + if (this.tip && this.tip.parentNode) { this.tip.parentNode.removeChild(this.tip) } @@ -248,86 +240,87 @@ class Tooltip extends BaseComponent { throw new Error('Please use show on visible elements') } - if (this.isWithContent() && this._isEnabled) { - const showEvent = EventHandler.trigger(this._element, this.constructor.Event.SHOW) - const shadowRoot = findShadowRoot(this._element) - const isInTheDom = shadowRoot === null ? - this._element.ownerDocument.documentElement.contains(this._element) : - shadowRoot.contains(this._element) + if (!(this.isWithContent() && this._isEnabled)) { + return + } - if (showEvent.defaultPrevented || !isInTheDom) { - return - } + const showEvent = EventHandler.trigger(this._element, this.constructor.Event.SHOW) + const shadowRoot = findShadowRoot(this._element) + const isInTheDom = shadowRoot === null ? + this._element.ownerDocument.documentElement.contains(this._element) : + shadowRoot.contains(this._element) - const tip = this.getTipElement() - const tipId = getUID(this.constructor.NAME) + if (showEvent.defaultPrevented || !isInTheDom) { + return + } - tip.setAttribute('id', tipId) - this._element.setAttribute('aria-describedby', tipId) + const tip = this.getTipElement() + const tipId = getUID(this.constructor.NAME) - this.setContent() + tip.setAttribute('id', tipId) + this._element.setAttribute('aria-describedby', tipId) - if (this.config.animation) { - tip.classList.add(CLASS_NAME_FADE) - } + this.setContent() - const placement = typeof this.config.placement === 'function' ? - this.config.placement.call(this, tip, this._element) : - this.config.placement + if (this.config.animation) { + tip.classList.add(CLASS_NAME_FADE) + } - const attachment = this._getAttachment(placement) - this._addAttachmentClass(attachment) + const placement = typeof this.config.placement === 'function' ? + this.config.placement.call(this, tip, this._element) : + this.config.placement - const container = this._getContainer() - Data.setData(tip, this.constructor.DATA_KEY, this) + const attachment = this._getAttachment(placement) + this._addAttachmentClass(attachment) - if (!this._element.ownerDocument.documentElement.contains(this.tip)) { - container.appendChild(tip) - } + const container = this._getContainer() + Data.set(tip, this.constructor.DATA_KEY, this) + if (!this._element.ownerDocument.documentElement.contains(this.tip)) { + container.appendChild(tip) EventHandler.trigger(this._element, this.constructor.Event.INSERTED) + } - this._popper = new Popper(this._element, tip, this._getPopperConfig(attachment)) - - tip.classList.add(CLASS_NAME_SHOW) + if (this._popper) { + this._popper.update() + } else { + this._popper = Popper.createPopper(this._element, tip, this._getPopperConfig(attachment)) + } - const customClass = typeof this.config.customClass === 'function' ? this.config.customClass() : this.config.customClass - if (customClass) { - tip.classList.add(...customClass.split(' ')) - } + tip.classList.add(CLASS_NAME_SHOW) - // If this is a touch-enabled device we add extra - // empty mouseover listeners to the body's immediate children; - // only needed because of broken event delegation on iOS - // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html - if ('ontouchstart' in document.documentElement) { - [].concat(...document.body.children).forEach(element => { - EventHandler.on(element, 'mouseover', noop()) - }) - } + const customClass = typeof this.config.customClass === 'function' ? this.config.customClass() : this.config.customClass + if (customClass) { + tip.classList.add(...customClass.split(' ')) + } - const complete = () => { - if (this.config.animation) { - this._fixTransition() - } + // If this is a touch-enabled device we add extra + // empty mouseover listeners to the body's immediate children; + // only needed because of broken event delegation on iOS + // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html + if ('ontouchstart' in document.documentElement) { + [].concat(...document.body.children).forEach(element => { + EventHandler.on(element, 'mouseover', noop) + }) + } - const prevHoverState = this._hoverState - this._hoverState = null + const complete = () => { + const prevHoverState = this._hoverState - EventHandler.trigger(this._element, this.constructor.Event.SHOWN) + this._hoverState = null + EventHandler.trigger(this._element, this.constructor.Event.SHOWN) - if (prevHoverState === HOVER_STATE_OUT) { - this._leave(null, this) - } + if (prevHoverState === HOVER_STATE_OUT) { + this._leave(null, this) } + } - if (this.tip.classList.contains(CLASS_NAME_FADE)) { - const transitionDuration = getTransitionDurationFromElement(this.tip) - EventHandler.one(this.tip, TRANSITION_END, complete) - emulateTransitionEnd(this.tip, transitionDuration) - } else { - complete() - } + 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() } } @@ -338,6 +331,10 @@ class Tooltip extends BaseComponent { const tip = this.getTipElement() const complete = () => { + if (this._isWithActiveTrigger()) { + return + } + if (this._hoverState !== HOVER_STATE_SHOW && tip.parentNode) { tip.parentNode.removeChild(tip) } @@ -345,7 +342,11 @@ class Tooltip extends BaseComponent { this._cleanTipClass() this._element.removeAttribute('aria-describedby') EventHandler.trigger(this._element, this.constructor.Event.HIDDEN) - this._popper.destroy() + + if (this._popper) { + this._popper.destroy() + this._popper = null + } } const hideEvent = EventHandler.trigger(this._element, this.constructor.Event.HIDE) @@ -369,7 +370,7 @@ class Tooltip extends BaseComponent { if (this.tip.classList.contains(CLASS_NAME_FADE)) { const transitionDuration = getTransitionDurationFromElement(tip) - EventHandler.one(tip, TRANSITION_END, complete) + EventHandler.one(tip, 'transitionend', complete) emulateTransitionEnd(tip, transitionDuration) } else { complete() @@ -380,7 +381,7 @@ class Tooltip extends BaseComponent { update() { if (this._popper !== null) { - this._popper.scheduleUpdate() + this._popper.update() } } @@ -468,32 +469,77 @@ class Tooltip extends BaseComponent { // Private + _initializeOnDelegatedTarget(event, context) { + const dataKey = this.constructor.DATA_KEY + context = context || Data.get(event.delegateTarget, dataKey) + + if (!context) { + context = new this.constructor(event.delegateTarget, this._getDelegateConfig()) + Data.set(event.delegateTarget, dataKey, context) + } + + return context + } + + _getOffset() { + const { offset } = this.config + + if (typeof offset === 'string') { + return offset.split(',').map(val => Number.parseInt(val, 10)) + } + + if (typeof offset === 'function') { + return popperData => offset(popperData, this._element) + } + + return offset + } + _getPopperConfig(attachment) { - const defaultBsConfig = { + const defaultBsPopperConfig = { placement: attachment, - modifiers: { - offset: this._getOffset(), - flip: { - behavior: this.config.fallbackPlacement + modifiers: [ + { + name: 'flip', + options: { + fallbackPlacements: this.config.fallbackPlacements + } + }, + { + name: 'offset', + options: { + offset: this._getOffset() + } + }, + { + name: 'preventOverflow', + options: { + boundary: this.config.boundary + } }, - arrow: { - element: `.${this.constructor.NAME}-arrow` + { + name: 'arrow', + options: { + element: `.${this.constructor.NAME}-arrow` + } }, - preventOverflow: { - boundariesElement: this.config.boundary + { + name: 'onChange', + enabled: true, + phase: 'afterWrite', + fn: data => this._handlePopperPlacementChange(data) } - }, - onCreate: data => { - if (data.originalPlacement !== data.placement) { + ], + onFirstUpdate: data => { + if (data.options.placement !== data.placement) { this._handlePopperPlacementChange(data) } - }, - onUpdate: data => this._handlePopperPlacementChange(data) + } } return { - ...defaultBsConfig, - ...this.config.popperConfig + ...defaultBsPopperConfig, + ...(typeof this.config.popperConfig === 'function' ? this.config.popperConfig(defaultBsPopperConfig) : this.config.popperConfig) } } @@ -501,25 +547,6 @@ class Tooltip extends BaseComponent { this.getTipElement().classList.add(`${CLASS_PREFIX}-${this.updateAttachment(attachment)}`) } - _getOffset() { - const offset = {} - - if (typeof this.config.offset === 'function') { - offset.fn = data => { - data.offsets = { - ...data.offsets, - ...(this.config.offset(data.offsets, this._element) || {}) - } - - return data - } - } else { - offset.offset = this.config.offset - } - - return offset - } - _getContainer() { if (this.config.container === false) { return document.body @@ -541,8 +568,7 @@ class Tooltip extends BaseComponent { 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 : @@ -590,16 +616,7 @@ class Tooltip extends BaseComponent { } _enter(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) - } + context = this._initializeOnDelegatedTarget(event, context) if (event) { context._activeTrigger[ @@ -629,21 +646,12 @@ class Tooltip extends BaseComponent { } _leave(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) - } + context = this._initializeOnDelegatedTarget(event, context) if (event) { context._activeTrigger[ event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER - ] = false + ] = context._element.contains(event.relatedTarget) } if (context._isWithActiveTrigger()) { @@ -743,30 +751,22 @@ class Tooltip extends BaseComponent { } _handlePopperPlacementChange(popperData) { - this.tip = popperData.instance.popper - this._cleanTipClass() - this._addAttachmentClass(this._getAttachment(popperData.placement)) - } + const { state } = popperData - _fixTransition() { - const tip = this.getTipElement() - const initConfigAnimation = this.config.animation - if (tip.getAttribute('x-placement') !== null) { + if (!state) { return } - tip.classList.remove(CLASS_NAME_FADE) - this.config.animation = false - this.hide() - this.show() - this.config.animation = initConfigAnimation + this.tip = state.elements.popper + this._cleanTipClass() + this._addAttachmentClass(this._getAttachment(state.placement)) } // Static static jQueryInterface(config) { return this.each(function () { - let data = Data.getData(this, DATA_KEY) + let data = Data.get(this, DATA_KEY) const _config = typeof config === 'object' && config if (!data && /dispose|hide/.test(config)) { @@ -795,18 +795,6 @@ class Tooltip extends BaseComponent { * add .Tooltip to jQuery only if jQuery is present */ -onDOMContentLoaded(() => { - const $ = getjQuery() - /* istanbul ignore if */ - if ($) { - const JQUERY_NO_CONFLICT = $.fn[NAME] - $.fn[NAME] = Tooltip.jQueryInterface - $.fn[NAME].Constructor = Tooltip - $.fn[NAME].noConflict = () => { - $.fn[NAME] = JQUERY_NO_CONFLICT - return Tooltip.jQueryInterface - } - } -}) +defineJQueryPlugin(NAME, Tooltip) export default Tooltip diff --git a/js/src/util/backdrop.js b/js/src/util/backdrop.js new file mode 100644 index 000000000..a9d28bd10 --- /dev/null +++ b/js/src/util/backdrop.js @@ -0,0 +1,133 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap (v5.0.0-beta3): util/backdrop.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + +import EventHandler from '../dom/event-handler' +import { emulateTransitionEnd, execute, getTransitionDurationFromElement, reflow, typeCheckConfig } from './index' + +const Default = { + isVisible: true, // if false, we use the backdrop helper without adding any element to the dom + isAnimated: false, + rootElement: document.body, // give the choice to place backdrop under different elements + clickCallback: null +} + +const DefaultType = { + isVisible: 'boolean', + isAnimated: 'boolean', + rootElement: 'element', + clickCallback: '(function|null)' +} +const NAME = 'backdrop' +const CLASS_NAME_BACKDROP = 'modal-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 = CLASS_NAME_BACKDROP + if (this._config.isAnimated) { + backdrop.classList.add(CLASS_NAME_FADE) + } + + this._element = backdrop + } + + return this._element + } + + _getConfig(config) { + config = { + ...Default, + ...(typeof config === 'object' ? config : {}) + } + typeCheckConfig(NAME, config, DefaultType) + return config + } + + _append() { + if (this._isAppended) { + return + } + + this._config.rootElement.appendChild(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._getElement().parentNode.removeChild(this._element) + this._isAppended = false + } + + _emulateAnimation(callback) { + if (!this._config.isAnimated) { + execute(callback) + return + } + + const backdropTransitionDuration = getTransitionDurationFromElement(this._getElement()) + EventHandler.one(this._getElement(), 'transitionend', () => execute(callback)) + emulateTransitionEnd(this._getElement(), backdropTransitionDuration) + } +} + +export default Backdrop diff --git a/js/src/util/index.js b/js/src/util/index.js index 96cadc65b..c27c470e9 100644 --- a/js/src/util/index.js +++ b/js/src/util/index.js @@ -1,6 +1,6 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-alpha3): util/index.js + * Bootstrap (v5.0.0-beta3): util/index.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ @@ -36,7 +36,20 @@ const getSelector = element => { let selector = element.getAttribute('data-bs-target') if (!selector || selector === '#') { - const hrefAttr = element.getAttribute('href') + let hrefAttr = element.getAttribute('href') + + // The only valid content that could double as a selector are IDs or classes, + // so everything starting with `#` or `.`. If a "real" URL is used as the selector, + // `document.querySelector` will rightfully complain it is invalid. + // See https://github.com/twbs/bootstrap/issues/32273 + if (!hrefAttr || (!hrefAttr.includes('#') && !hrefAttr.startsWith('.'))) { + return null + } + + // Just in case some CMS puts out a full URL with the anchor appended + if (hrefAttr.includes('#') && !hrefAttr.startsWith('#')) { + hrefAttr = `#${hrefAttr.split('#')[1]}` + } selector = hrefAttr && hrefAttr !== '#' ? hrefAttr.trim() : null } @@ -111,15 +124,12 @@ const typeCheckConfig = (componentName, config, configTypes) => { Object.keys(configTypes).forEach(property => { const expectedTypes = configTypes[property] const value = config[property] - const valueType = value && isElement(value) ? - 'element' : - toType(value) + const valueType = value && isElement(value) ? 'element' : toType(value) if (!new RegExp(expectedTypes).test(valueType)) { - throw new Error( - `${componentName.toUpperCase()}: ` + - `Option "${property}" provided type "${valueType}" ` + - `but expected type "${expectedTypes}".`) + throw new TypeError( + `${componentName.toUpperCase()}: Option "${property}" provided type "${valueType}" but expected type "${expectedTypes}".` + ) } }) } @@ -141,6 +151,22 @@ const isVisible = element => { return false } +const isDisabled = element => { + if (!element || element.nodeType !== Node.ELEMENT_NODE) { + return true + } + + if (element.classList.contains('disabled')) { + return true + } + + if (typeof element.disabled !== 'undefined') { + return element.disabled + } + + return element.hasAttribute('disabled') && element.getAttribute('disabled') !== 'false' +} + const findShadowRoot = element => { if (!document.documentElement.attachShadow) { return null @@ -164,7 +190,7 @@ const findShadowRoot = element => { return findShadowRoot(element.parentNode) } -const noop = () => function () {} +const noop = () => {} const reflow = element => element.offsetHeight @@ -186,10 +212,31 @@ const onDOMContentLoaded = callback => { } } -const isRTL = document.documentElement.dir === 'rtl' +const isRTL = () => document.documentElement.dir === 'rtl' + +const defineJQueryPlugin = (name, plugin) => { + onDOMContentLoaded(() => { + const $ = getjQuery() + /* istanbul ignore if */ + if ($) { + const JQUERY_NO_CONFLICT = $.fn[name] + $.fn[name] = plugin.jQueryInterface + $.fn[name].Constructor = plugin + $.fn[name].noConflict = () => { + $.fn[name] = JQUERY_NO_CONFLICT + return plugin.jQueryInterface + } + } + }) +} + +const execute = callback => { + if (typeof callback === 'function') { + callback() + } +} export { - TRANSITION_END, getUID, getSelectorFromElement, getElementFromSelector, @@ -199,10 +246,13 @@ export { emulateTransitionEnd, typeCheckConfig, isVisible, + isDisabled, findShadowRoot, noop, reflow, getjQuery, onDOMContentLoaded, - isRTL + isRTL, + defineJQueryPlugin, + execute } diff --git a/js/src/util/sanitizer.js b/js/src/util/sanitizer.js index 68469285a..232a55e6b 100644 --- a/js/src/util/sanitizer.js +++ b/js/src/util/sanitizer.js @@ -1,6 +1,6 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-alpha3): util/sanitizer.js + * Bootstrap (v5.0.0-beta3): 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. @@ -37,7 +37,7 @@ const allowedAttribute = (attr, allowedAttributeList) => { if (allowedAttributeList.includes(attrName)) { if (uriAttrs.has(attrName)) { - return Boolean(attr.nodeValue.match(SAFE_URL_PATTERN) || attr.nodeValue.match(DATA_URL_PATTERN)) + return Boolean(SAFE_URL_PATTERN.test(attr.nodeValue) || DATA_URL_PATTERN.test(attr.nodeValue)) } return true @@ -47,7 +47,7 @@ const allowedAttribute = (attr, allowedAttributeList) => { // Check if a regular expression validates the attribute. for (let i = 0, len = regExp.length; i < len; i++) { - if (attrName.match(regExp[i])) { + if (regExp[i].test(attrName)) { return true } } diff --git a/js/src/util/scrollbar.js b/js/src/util/scrollbar.js new file mode 100644 index 000000000..352e3e11d --- /dev/null +++ b/js/src/util/scrollbar.js @@ -0,0 +1,81 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap (v5.0.0-beta3): 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' + +const SELECTOR_FIXED_CONTENT = '.fixed-top, .fixed-bottom, .is-fixed, .sticky-top' +const SELECTOR_STICKY_CONTENT = '.sticky-top' + +const 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) +} + +const hide = (width = getWidth()) => { + _disableOverFlow() + // give padding to element to balances the hidden scrollbar width + _setElementAttributes('body', 'paddingRight', calculatedValue => calculatedValue + width) + // trick: We adjust positive paddingRight and negative marginRight to sticky-top elements, to keep shown fullwidth + _setElementAttributes(SELECTOR_FIXED_CONTENT, 'paddingRight', calculatedValue => calculatedValue + width) + _setElementAttributes(SELECTOR_STICKY_CONTENT, 'marginRight', calculatedValue => calculatedValue - width) +} + +const _disableOverFlow = () => { + const actualValue = document.body.style.overflow + if (actualValue) { + Manipulator.setDataAttribute(document.body, 'overflow', actualValue) + } + + document.body.style.overflow = 'hidden' +} + +const _setElementAttributes = (selector, styleProp, callback) => { + const scrollbarWidth = getWidth() + SelectorEngine.find(selector) + .forEach(element => { + if (element !== document.body && window.innerWidth > element.clientWidth + scrollbarWidth) { + return + } + + const actualValue = element.style[styleProp] + const calculatedValue = window.getComputedStyle(element)[styleProp] + Manipulator.setDataAttribute(element, styleProp, actualValue) + element.style[styleProp] = `${callback(Number.parseFloat(calculatedValue))}px` + }) +} + +const reset = () => { + _resetElementAttributes('body', 'overflow') + _resetElementAttributes('body', 'paddingRight') + _resetElementAttributes(SELECTOR_FIXED_CONTENT, 'paddingRight') + _resetElementAttributes(SELECTOR_STICKY_CONTENT, 'marginRight') +} + +const _resetElementAttributes = (selector, styleProp) => { + SelectorEngine.find(selector).forEach(element => { + const value = Manipulator.getDataAttribute(element, styleProp) + if (typeof value === 'undefined') { + element.style.removeProperty(styleProp) + } else { + Manipulator.removeDataAttribute(element, styleProp) + element.style[styleProp] = value + } + }) +} + +const isBodyOverflowing = () => { + return getWidth() > 0 +} + +export { + getWidth, + hide, + isBodyOverflowing, + reset +} |
