diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index 5960b3bef571..811ae553efbd 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -2,7 +2,7 @@ "files": [ { "path": "./dist/css/bootstrap-grid.css", - "maxSize": "7.25 kB" + "maxSize": "7.5 kB" }, { "path": "./dist/css/bootstrap-grid.min.css", @@ -14,7 +14,7 @@ }, { "path": "./dist/css/bootstrap-reboot.min.css", - "maxSize": "2.35 kB" + "maxSize": "2.5 kB" }, { "path": "./dist/css/bootstrap-utilities.css", @@ -22,15 +22,15 @@ }, { "path": "./dist/css/bootstrap-utilities.min.css", - "maxSize": "7 kB" + "maxSize": "7.0 kB" }, { "path": "./dist/css/bootstrap.css", - "maxSize": "26 kB" + "maxSize": "26.4 kB" }, { "path": "./dist/css/bootstrap.min.css", - "maxSize": "23.5 kB" + "maxSize": "24.4 kB" }, { "path": "./dist/js/bootstrap.bundle.js", diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 5d0f45de88d1..0289984bec06 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -7,6 +7,9 @@ categories: - title: '❗ Breaking Changes' labels: - 'breaking-change' + - title: '🚀 Highlights' + labels: + - 'release-highlight' - title: '🚀 Features' labels: - 'new-feature' diff --git a/.github/workflows/browserstack.yml b/.github/workflows/browserstack.yml index 0a3426e80439..425c5668449a 100644 --- a/.github/workflows/browserstack.yml +++ b/.github/workflows/browserstack.yml @@ -16,10 +16,10 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Set up Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: "${{ env.NODE }}" cache: npm diff --git a/.github/workflows/bundlewatch.yml b/.github/workflows/bundlewatch.yml index 14deaa97f8ff..d1a174784d27 100644 --- a/.github/workflows/bundlewatch.yml +++ b/.github/workflows/bundlewatch.yml @@ -17,10 +17,10 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Set up Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: "${{ env.NODE }}" cache: npm diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2aa437417e96..3936ebd330df 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Initialize CodeQL uses: github/codeql-action/init@v1 diff --git a/.github/workflows/cspell.yml b/.github/workflows/cspell.yml index 6787888fec89..7fd988dbb2be 100644 --- a/.github/workflows/cspell.yml +++ b/.github/workflows/cspell.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Run cspell uses: streetsidesoftware/cspell-action@v1 diff --git a/.github/workflows/css.yml b/.github/workflows/css.yml index 0059dc44d267..857a5672cb35 100644 --- a/.github/workflows/css.yml +++ b/.github/workflows/css.yml @@ -17,10 +17,10 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Set up Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: "${{ env.NODE }}" cache: npm diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d06105061591..f33413eb4b93 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -17,10 +17,10 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Set up Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: "${{ env.NODE }}" cache: npm diff --git a/.github/workflows/issue-close-require.yml b/.github/workflows/issue-close-require.yml new file mode 100644 index 000000000000..b251cd75ee0d --- /dev/null +++ b/.github/workflows/issue-close-require.yml @@ -0,0 +1,19 @@ +name: Close Issue Awaiting Reply + +on: + schedule: + - cron: "0 0 * * *" + +jobs: + issue-close-require: + runs-on: ubuntu-latest + if: github.repository == 'twbs/bootstrap' + steps: + - name: awaiting reply + uses: actions-cool/issues-helper@v3 + with: + actions: "close-issues" + labels: "awaiting-reply" + inactive-day: 14 + body: | + As the issue was labeled with `awaiting-reply`, but there has been no response in 14 days, this issue will be closed. If you have any questions, you can comment/reply. diff --git a/.github/workflows/issue-labeled.yml b/.github/workflows/issue-labeled.yml new file mode 100644 index 000000000000..74c194679f43 --- /dev/null +++ b/.github/workflows/issue-labeled.yml @@ -0,0 +1,19 @@ +name: Issue Labeled + +on: + issues: + types: [labeled] + +jobs: + issue-labeled: + if: github.repository == 'twbs/bootstrap' + runs-on: ubuntu-latest + steps: + - name: awaiting reply + if: github.event.label.name == 'awaiting-reply' + uses: actions-cool/issues-helper@v3 + with: + actions: "create-comment" + token: ${{ secrets.GITHUB_TOKEN }} + body: | + Hello @${{ github.event.issue.user.login }}. Bug reports must include a **live demo** of the issue. Per our [contributing guidelines](https://github.com/twbs/bootstrap/blob/main/.github/CONTRIBUTING.md), please create a reduced test case on [CodePen](https://codepen.io/) or [JS Bin](https://jsbin.com/) and report back with your link, Bootstrap version, and specific browser and Operating System details. diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index e9719000e3fd..82616c5743dd 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -18,10 +18,10 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Set up Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: ${{ env.NODE }} cache: npm diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 418232a66d9a..816694ec2864 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,10 +17,10 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Set up Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: "${{ env.NODE }}" cache: npm diff --git a/.github/workflows/node-sass.yml b/.github/workflows/node-sass.yml index 8a958a7a9ed4..465cee48501d 100644 --- a/.github/workflows/node-sass.yml +++ b/.github/workflows/node-sass.yml @@ -17,10 +17,10 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Set up Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: "${{ env.NODE }}" diff --git a/README.md b/README.md index cf2e31111f31..32a9608ff1b5 100644 --- a/README.md +++ b/README.md @@ -79,57 +79,58 @@ Read the [Getting started page](https://getbootstrap.com/docs/5.1/getting-starte Within the download you'll find the following directories and files, logically grouping common assets and providing both compiled and minified variations. -
Download contents - -```text -bootstrap/ -├── css/ -│ ├── bootstrap-grid.css -│ ├── bootstrap-grid.css.map -│ ├── bootstrap-grid.min.css -│ ├── bootstrap-grid.min.css.map -│ ├── bootstrap-grid.rtl.css -│ ├── bootstrap-grid.rtl.css.map -│ ├── bootstrap-grid.rtl.min.css -│ ├── bootstrap-grid.rtl.min.css.map -│ ├── bootstrap-reboot.css -│ ├── bootstrap-reboot.css.map -│ ├── bootstrap-reboot.min.css -│ ├── bootstrap-reboot.min.css.map -│ ├── bootstrap-reboot.rtl.css -│ ├── bootstrap-reboot.rtl.css.map -│ ├── bootstrap-reboot.rtl.min.css -│ ├── bootstrap-reboot.rtl.min.css.map -│ ├── bootstrap-utilities.css -│ ├── bootstrap-utilities.css.map -│ ├── bootstrap-utilities.min.css -│ ├── bootstrap-utilities.min.css.map -│ ├── bootstrap-utilities.rtl.css -│ ├── bootstrap-utilities.rtl.css.map -│ ├── bootstrap-utilities.rtl.min.css -│ ├── bootstrap-utilities.rtl.min.css.map -│ ├── bootstrap.css -│ ├── bootstrap.css.map -│ ├── bootstrap.min.css -│ ├── bootstrap.min.css.map -│ ├── bootstrap.rtl.css -│ ├── bootstrap.rtl.css.map -│ ├── bootstrap.rtl.min.css -│ └── bootstrap.rtl.min.css.map -└── js/ - ├── bootstrap.bundle.js - ├── bootstrap.bundle.js.map - ├── bootstrap.bundle.min.js - ├── bootstrap.bundle.min.js.map - ├── bootstrap.esm.js - ├── bootstrap.esm.js.map - ├── bootstrap.esm.min.js - ├── bootstrap.esm.min.js.map - ├── bootstrap.js - ├── bootstrap.js.map - ├── bootstrap.min.js - └── bootstrap.min.js.map -``` +
+ Download contents + + ```text + bootstrap/ + ├── css/ + │ ├── bootstrap-grid.css + │ ├── bootstrap-grid.css.map + │ ├── bootstrap-grid.min.css + │ ├── bootstrap-grid.min.css.map + │ ├── bootstrap-grid.rtl.css + │ ├── bootstrap-grid.rtl.css.map + │ ├── bootstrap-grid.rtl.min.css + │ ├── bootstrap-grid.rtl.min.css.map + │ ├── bootstrap-reboot.css + │ ├── bootstrap-reboot.css.map + │ ├── bootstrap-reboot.min.css + │ ├── bootstrap-reboot.min.css.map + │ ├── bootstrap-reboot.rtl.css + │ ├── bootstrap-reboot.rtl.css.map + │ ├── bootstrap-reboot.rtl.min.css + │ ├── bootstrap-reboot.rtl.min.css.map + │ ├── bootstrap-utilities.css + │ ├── bootstrap-utilities.css.map + │ ├── bootstrap-utilities.min.css + │ ├── bootstrap-utilities.min.css.map + │ ├── bootstrap-utilities.rtl.css + │ ├── bootstrap-utilities.rtl.css.map + │ ├── bootstrap-utilities.rtl.min.css + │ ├── bootstrap-utilities.rtl.min.css.map + │ ├── bootstrap.css + │ ├── bootstrap.css.map + │ ├── bootstrap.min.css + │ ├── bootstrap.min.css.map + │ ├── bootstrap.rtl.css + │ ├── bootstrap.rtl.css.map + │ ├── bootstrap.rtl.min.css + │ └── bootstrap.rtl.min.css.map + └── js/ + ├── bootstrap.bundle.js + ├── bootstrap.bundle.js.map + ├── bootstrap.bundle.min.js + ├── bootstrap.bundle.min.js.map + ├── bootstrap.esm.js + ├── bootstrap.esm.js.map + ├── bootstrap.esm.min.js + ├── bootstrap.esm.min.js.map + ├── bootstrap.js + ├── bootstrap.js.map + ├── bootstrap.min.js + └── bootstrap.min.js.map + ```
We provide compiled CSS and JS (`bootstrap.*`), as well as compiled and minified CSS and JS (`bootstrap.min.*`). [Source maps](https://developers.google.com/web/tools/chrome-devtools/javascript/source-maps) (`bootstrap.*.map`) are available for use with certain browsers' developer tools. Bundled JS files (`bootstrap.bundle.js` and minified `bootstrap.bundle.min.js`) include [Popper](https://popper.js.org/). diff --git a/build/build-plugins.js b/build/build-plugins.js index 8fa0e0b1f395..4c68edcd1f85 100644 --- a/build/build-plugins.js +++ b/build/build-plugins.js @@ -48,7 +48,7 @@ const build = async plugin => { babelHelpers: 'bundled' }) ], - external: source => { + external(source) { // Pattern to identify local files const pattern = /^(\.{1,2})\// diff --git a/config.yml b/config.yml index f06fa2bf1c04..82e7082b8fb7 100644 --- a/config.yml +++ b/config.yml @@ -83,3 +83,7 @@ params: js_bundle_hash: "sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" popper: "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.2/dist/umd/popper.min.js" popper_hash: "sha384-q9CRHqZndzlxGLOj+xrdLDJa9ittGte1NksRmgJKeCV9DrM7Kz868XYqsKWPpAmn" + + anchors: + min: 2 + max: 5 diff --git a/js/src/base-component.js b/js/src/base-component.js index 4140bf19475b..75bb90c32697 100644 --- a/js/src/base-component.js +++ b/js/src/base-component.js @@ -76,6 +76,10 @@ class BaseComponent extends Config { static get EVENT_KEY() { return `.${this.DATA_KEY}` } + + static eventName(name) { + return `${name}${this.EVENT_KEY}` + } } export default BaseComponent diff --git a/js/src/carousel.js b/js/src/carousel.js index 5a0cbc208de7..7a30beb10e3d 100644 --- a/js/src/carousel.js +++ b/js/src/carousel.js @@ -56,10 +56,9 @@ const CLASS_NAME_NEXT = 'carousel-item-next' const CLASS_NAME_PREV = 'carousel-item-prev' const SELECTOR_ACTIVE = '.active' -const SELECTOR_ACTIVE_ITEM = '.active.carousel-item' const SELECTOR_ITEM = '.carousel-item' +const SELECTOR_ACTIVE_ITEM = SELECTOR_ACTIVE + SELECTOR_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_DATA_SLIDE = '[data-bs-slide], [data-bs-slide-to]' const SELECTOR_DATA_RIDE = '[data-bs-ride="carousel"]' @@ -95,10 +94,9 @@ class Carousel extends BaseComponent { constructor(element, config) { super(element, config) - this._items = null this._interval = null this._activeElement = null - this._isPaused = false + this._stayPaused = false this._isSliding = false this.touchTimeout = null this._swipeHelper = null @@ -140,10 +138,10 @@ class Carousel extends BaseComponent { pause(event) { if (!event) { - this._isPaused = true + this._stayPaused = true } - if (SelectorEngine.findOne(SELECTOR_NEXT_PREV, this._element)) { + if (this._isSliding) { triggerTransitionEnd(this._element) this.cycle(true) } @@ -153,11 +151,11 @@ class Carousel extends BaseComponent { cycle(event) { if (!event) { - this._isPaused = false + this._stayPaused = false } this._clearInterval() - if (this._config.interval && !this._isPaused) { + if (this._config.interval && !this._stayPaused) { this._updateInterval() this._interval = setInterval(() => this.nextWhenVisible(), this._config.interval) @@ -165,10 +163,8 @@ class Carousel extends BaseComponent { } to(index) { - this._activeElement = this._getActive() - const activeIndex = this._getItemIndex(this._activeElement) - - if (index > this._items.length - 1 || index < 0) { + const items = this._getItems() + if (index > items.length - 1 || index < 0) { return } @@ -177,17 +173,16 @@ class Carousel extends BaseComponent { return } + const activeIndex = this._getItemIndex(this._getActive()) if (activeIndex === index) { this.pause() this.cycle() return } - const order = index > activeIndex ? - ORDER_NEXT : - ORDER_PREV + const order = index > activeIndex ? ORDER_NEXT : ORDER_PREV - this._slide(order, this._items[index]) + this._slide(order, items[index]) } dispose() { @@ -246,8 +241,8 @@ class Carousel extends BaseComponent { } const swipeConfig = { - leftCallback: () => this._slide(DIRECTION_LEFT), - rightCallback: () => this._slide(DIRECTION_RIGHT), + leftCallback: () => this._slide(this._directionToOrder(DIRECTION_LEFT)), + rightCallback: () => this._slide(this._directionToOrder(DIRECTION_RIGHT)), endCallback: endCallBack } @@ -262,36 +257,15 @@ class Carousel extends BaseComponent { const direction = KEY_TO_DIRECTION[event.key] if (direction) { event.preventDefault() - this._slide(direction) + this._slide(this._directionToOrder(direction)) } } _getItemIndex(element) { - this._items = element && element.parentNode ? - SelectorEngine.find(SELECTOR_ITEM, element.parentNode) : - [] - - return this._items.indexOf(element) - } - - _getItemByOrder(order, activeElement) { - const isNext = order === ORDER_NEXT - return getNextActiveElement(this._items, activeElement, isNext, this._config.wrap) + return this._getItems().indexOf(element) } - _triggerSlideEvent(relatedTarget, eventDirectionName) { - const targetIndex = this._getItemIndex(relatedTarget) - const fromIndex = this._getItemIndex(this._getActive()) - - return EventHandler.trigger(this._element, EVENT_SLIDE, { - relatedTarget, - direction: eventDirectionName, - from: fromIndex, - to: targetIndex - }) - } - - _setActiveIndicatorElement(element) { + _setActiveIndicatorElement(index) { if (!this._indicatorsElement) { return } @@ -301,7 +275,7 @@ class Carousel extends BaseComponent { activeIndicator.classList.remove(CLASS_NAME_ACTIVE) activeIndicator.removeAttribute('aria-current') - const newActiveIndicator = SelectorEngine.findOne(`[data-bs-slide-to="${this._getItemIndex(element)}"]`, this._indicatorsElement) + const newActiveIndicator = SelectorEngine.findOne(`[data-bs-slide-to="${index}"]`, this._indicatorsElement) if (newActiveIndicator) { newActiveIndicator.classList.add(CLASS_NAME_ACTIVE) @@ -321,94 +295,92 @@ class Carousel extends BaseComponent { this._config.interval = elementInterval || this._config.defaultInterval } - _slide(directionOrOrder, element) { - const order = this._directionToOrder(directionOrOrder) - const activeElement = this._getActive() - const activeElementIndex = this._getItemIndex(activeElement) - const nextElement = element || this._getItemByOrder(order, activeElement) - - const nextElementIndex = this._getItemIndex(nextElement) - const isCycling = Boolean(this._interval) + _slide(order, element = null) { + if (this._isSliding) { + return + } + const activeElement = this._getActive() 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) + const nextElement = element || getNextActiveElement(this._getItems(), activeElement, isNext, this._config.wrap) - if (nextElement && nextElement.classList.contains(CLASS_NAME_ACTIVE)) { - this._isSliding = false + if (nextElement === activeElement) { return } - if (this._isSliding) { - return + const nextElementIndex = this._getItemIndex(nextElement) + + const triggerEvent = eventName => { + return EventHandler.trigger(this._element, eventName, { + relatedTarget: nextElement, + direction: this._orderToDirection(order), + from: this._getItemIndex(activeElement), + to: nextElementIndex + }) } - const slideEvent = this._triggerSlideEvent(nextElement, eventDirectionName) + const slideEvent = triggerEvent(EVENT_SLIDE) + if (slideEvent.defaultPrevented) { return } if (!activeElement || !nextElement) { // Some weirdness is happening, so we bail + // todo: change tests that use empty divs to avoid this check return } this._isSliding = true + const isCycling = Boolean(this._interval) if (isCycling) { this.pause() } - this._setActiveIndicatorElement(nextElement) + this._setActiveIndicatorElement(nextElementIndex) this._activeElement = nextElement - const triggerSlidEvent = () => { - EventHandler.trigger(this._element, EVENT_SLID, { - relatedTarget: nextElement, - direction: eventDirectionName, - from: activeElementIndex, - to: nextElementIndex - }) - } - - if (this._element.classList.contains(CLASS_NAME_SLIDE)) { - nextElement.classList.add(orderClassName) - - reflow(nextElement) - - activeElement.classList.add(directionalClassName) - nextElement.classList.add(directionalClassName) - - const completeCallBack = () => { - nextElement.classList.remove(directionalClassName, orderClassName) - nextElement.classList.add(CLASS_NAME_ACTIVE) + const directionalClassName = isNext ? CLASS_NAME_START : CLASS_NAME_END + const orderClassName = isNext ? CLASS_NAME_NEXT : CLASS_NAME_PREV - activeElement.classList.remove(CLASS_NAME_ACTIVE, orderClassName, directionalClassName) + nextElement.classList.add(orderClassName) - this._isSliding = false + reflow(nextElement) - setTimeout(triggerSlidEvent, 0) - } + activeElement.classList.add(directionalClassName) + nextElement.classList.add(directionalClassName) - this._queueCallback(completeCallBack, activeElement, true) - } else { - activeElement.classList.remove(CLASS_NAME_ACTIVE) + const completeCallBack = () => { + nextElement.classList.remove(directionalClassName, orderClassName) nextElement.classList.add(CLASS_NAME_ACTIVE) + activeElement.classList.remove(CLASS_NAME_ACTIVE, orderClassName, directionalClassName) + this._isSliding = false - triggerSlidEvent() + + triggerEvent(EVENT_SLID) } + this._queueCallback(completeCallBack, activeElement, this._isAnimated()) + if (isCycling) { this.cycle() } } + _isAnimated() { + return this._element.classList.contains(CLASS_NAME_SLIDE) + } + _getActive() { return SelectorEngine.findOne(SELECTOR_ACTIVE_ITEM, this._element) } + _getItems() { + return SelectorEngine.find(SELECTOR_ITEM, this._element) + } + _clearInterval() { if (this._interval) { clearInterval(this._interval) @@ -417,10 +389,6 @@ 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 } @@ -429,10 +397,6 @@ class Carousel extends BaseComponent { } _orderToDirection(order) { - if (![ORDER_NEXT, ORDER_PREV].includes(order)) { - return order - } - if (isRTL()) { return order === ORDER_PREV ? DIRECTION_LEFT : DIRECTION_RIGHT } @@ -441,77 +405,66 @@ class Carousel extends BaseComponent { } // Static - static carouselInterface(element, config) { - const data = Carousel.getOrCreateInstance(element, config) - - let { _config } = data - if (typeof config === 'object') { - _config = { - ..._config, - ...config + static jQueryInterface(config) { + return this.each(function () { + const data = Carousel.getOrCreateInstance(this, config) + + if (typeof config === 'number') { + data.to(config) + return } - } - const action = typeof config === 'string' ? config : _config.slide + if (typeof config === 'string') { + if (data[config] === undefined || config.startsWith('_') || config === 'constructor') { + throw new TypeError(`No method named "${config}"`) + } - if (typeof config === 'number') { - data.to(config) - } else if (typeof action === 'string') { - if (typeof data[action] === 'undefined') { - throw new TypeError(`No method named "${action}"`) + data[config]() + return } - data[action]() - } else if (_config.interval && _config.ride) { - data.pause() - data.cycle() - } - } - - static jQueryInterface(config) { - return this.each(function () { - Carousel.carouselInterface(this, config) + if (data._config.interval && data._config.ride) { + data.pause() + data.cycle() + } }) } +} - static dataApiClickHandler(event) { - const target = getElementFromSelector(this) - - if (!target || !target.classList.contains(CLASS_NAME_CAROUSEL)) { - return - } +/** + * Data API implementation + */ - const config = { - ...Manipulator.getDataAttributes(target), - ...Manipulator.getDataAttributes(this) - } - const slideIndex = this.getAttribute('data-bs-slide-to') +EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_SLIDE, function (event) { + const target = getElementFromSelector(this) - if (slideIndex) { - config.interval = false - } + if (!target || !target.classList.contains(CLASS_NAME_CAROUSEL)) { + return + } - Carousel.carouselInterface(target, config) + event.preventDefault() - if (slideIndex) { - Carousel.getInstance(target).to(slideIndex) - } + const carousel = Carousel.getOrCreateInstance(target) + const slideIndex = this.getAttribute('data-bs-slide-to') - event.preventDefault() + if (slideIndex) { + carousel.to(slideIndex) + return } -} -/** - * Data API implementation - */ + if (Manipulator.getDataAttribute(this, 'slide') === 'next') { + carousel.next() + return + } -EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_SLIDE, Carousel.dataApiClickHandler) + carousel.prev() +}) EventHandler.on(window, EVENT_LOAD_DATA_API, () => { const carousels = SelectorEngine.find(SELECTOR_DATA_RIDE) for (const carousel of carousels) { - Carousel.carouselInterface(carousel, Carousel.getInstance(carousel)) + Carousel.getOrCreateInstance(carousel) } }) diff --git a/js/src/collapse.js b/js/src/collapse.js index 8894342dfc29..b1088e106fb7 100644 --- a/js/src/collapse.js +++ b/js/src/collapse.js @@ -256,12 +256,12 @@ class Collapse extends BaseComponent { // Static static jQueryInterface(config) { - return this.each(function () { - const _config = {} - if (typeof config === 'string' && /show|hide/.test(config)) { - _config.toggle = false - } + const _config = {} + if (typeof config === 'string' && /show|hide/.test(config)) { + _config.toggle = false + } + return this.each(function () { const data = Collapse.getOrCreateInstance(this, _config) if (typeof config === 'string') { diff --git a/js/src/dropdown.js b/js/src/dropdown.js index 5635ec96ec6c..65b3aa372ab7 100644 --- a/js/src/dropdown.js +++ b/js/src/dropdown.js @@ -48,8 +48,11 @@ const CLASS_NAME_SHOW = 'show' const CLASS_NAME_DROPUP = 'dropup' const CLASS_NAME_DROPEND = 'dropend' const CLASS_NAME_DROPSTART = 'dropstart' +const CLASS_NAME_DROPUP_CENTER = 'dropup-center' +const CLASS_NAME_DROPDOWN_CENTER = 'dropdown-center' -const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="dropdown"]' +const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="dropdown"]:not(.disabled):not(:disabled)' +const SELECTOR_DATA_TOGGLE_SHOWN = `${SELECTOR_DATA_TOGGLE}.${CLASS_NAME_SHOW}` const SELECTOR_MENU = '.dropdown-menu' const SELECTOR_NAVBAR = '.navbar' const SELECTOR_NAVBAR_NAV = '.navbar-nav' @@ -61,6 +64,8 @@ const PLACEMENT_BOTTOM = isRTL() ? 'bottom-end' : 'bottom-start' const PLACEMENT_BOTTOMEND = isRTL() ? 'bottom-start' : 'bottom-end' const PLACEMENT_RIGHT = isRTL() ? 'left-start' : 'right-start' const PLACEMENT_LEFT = isRTL() ? 'right-start' : 'left-start' +const PLACEMENT_TOPCENTER = 'top' +const PLACEMENT_BOTTOMCENTER = 'bottom' const Default = { offset: [0, 2], @@ -247,6 +252,14 @@ class Dropdown extends BaseComponent { return PLACEMENT_LEFT } + if (parentDropdown.classList.contains(CLASS_NAME_DROPUP_CENTER)) { + return PLACEMENT_TOPCENTER + } + + if (parentDropdown.classList.contains(CLASS_NAME_DROPDOWN_CENTER)) { + return PLACEMENT_BOTTOMCENTER + } + // We need to trim the value because custom properties can also include spaces const isEnd = getComputedStyle(this._menu).getPropertyValue('--bs-position').trim() === 'end' @@ -341,18 +354,14 @@ class Dropdown extends BaseComponent { return } - const toggles = SelectorEngine.find(SELECTOR_DATA_TOGGLE) + const openToggles = SelectorEngine.find(SELECTOR_DATA_TOGGLE_SHOWN) - for (const toggle of toggles) { + for (const toggle of openToggles) { const context = Dropdown.getInstance(toggle) if (!context || context._config.autoClose === false) { continue } - if (!context._isShown()) { - continue - } - const composedPath = event.composedPath() const isMenuTarget = composedPath.includes(context._menu) if ( @@ -379,54 +388,41 @@ class Dropdown extends BaseComponent { } static dataApiKeydownHandler(event) { - // If not input/textarea: - // - And not a key in UP | DOWN | ESCAPE => not a dropdown command - // If input/textarea && If key is other than ESCAPE - // - If key is not UP or DOWN => not a dropdown command - // - If trigger inside the menu => not a dropdown command - - const { target, key, delegateTarget } = event - const isInput = /input|textarea/i.test(target.tagName) - const isEscapeEvent = key === ESCAPE_KEY - const isUpOrDownEvent = [ARROW_UP_KEY, ARROW_DOWN_KEY].includes(key) - - if (!isInput && !(isUpOrDownEvent || isEscapeEvent)) { + // If not an UP | DOWN | ESCAPE key => not a dropdown command + // If input/textarea && if key is other than ESCAPE => not a dropdown command + + const isInput = /input|textarea/i.test(event.target.tagName) + const isEscapeEvent = event.key === ESCAPE_KEY + const isUpOrDownEvent = [ARROW_UP_KEY, ARROW_DOWN_KEY].includes(event.key) + + if (!isUpOrDownEvent && !isEscapeEvent) { return } if (isInput && !isEscapeEvent) { - // eslint-disable-next-line unicorn/no-lonely-if - if (!isUpOrDownEvent || target.closest(SELECTOR_MENU)) { - return - } - } - - const isActive = delegateTarget.classList.contains(CLASS_NAME_SHOW) - - if (!isActive && isEscapeEvent) { return } event.preventDefault() - event.stopPropagation() - - if (isDisabled(this)) { - return + if (!isEscapeEvent) { + event.stopPropagation() } - const getToggleButton = SelectorEngine.findOne(SELECTOR_DATA_TOGGLE, delegateTarget.parentNode) + const getToggleButton = SelectorEngine.findOne(SELECTOR_DATA_TOGGLE, event.delegateTarget.parentNode) const instance = Dropdown.getOrCreateInstance(getToggleButton) if (isEscapeEvent) { - instance.hide() - getToggleButton.focus() + if (getToggleButton.classList.contains(CLASS_NAME_SHOW)) { + instance.hide() + getToggleButton.focus() + event.stopPropagation() + } + return } - if (isUpOrDownEvent) { - instance.show() - instance._selectMenuItem(event) - } + instance.show() + instance._selectMenuItem(event) } } diff --git a/js/src/modal.js b/js/src/modal.js index 054750c5f7fd..ea8e0a0463b7 100644 --- a/js/src/modal.js +++ b/js/src/modal.js @@ -69,6 +69,8 @@ class Modal extends BaseComponent { this._isShown = false this._isTransitioning = false this._scrollBar = new ScrollBarHelper() + + this._addEventListeners() } // Getters @@ -111,9 +113,6 @@ class Modal extends BaseComponent { this._adjustDialog() - this._toggleEscapeEventListener(true) - this._toggleResizeEventListener(true) - this._backdrop.show(() => this._showElement(relatedTarget)) } @@ -130,10 +129,6 @@ class Modal extends BaseComponent { this._isShown = false this._isTransitioning = true - - this._toggleEscapeEventListener(false) - this._toggleResizeEventListener(false) - this._focustrap.deactivate() this._element.classList.remove(CLASS_NAME_SHOW) @@ -217,12 +212,7 @@ class Modal extends BaseComponent { this._queueCallback(transitionComplete, this._dialog, this._isAnimated()) } - _toggleEscapeEventListener(enable) { - if (!enable) { - EventHandler.off(this._element, EVENT_KEYDOWN_DISMISS) - return - } - + _addEventListeners() { EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, event => { if (event.key !== ESCAPE_KEY) { return @@ -236,15 +226,12 @@ class Modal extends BaseComponent { this._triggerBackdropTransition() }) - } - - _toggleResizeEventListener(enable) { - if (enable) { - EventHandler.on(window, EVENT_RESIZE, () => this._adjustDialog()) - return - } - EventHandler.off(window, EVENT_RESIZE) + EventHandler.on(window, EVENT_RESIZE, () => { + if (this._isShown && !this._isTransitioning) { + this._adjustDialog() + } + }) } _hideModal() { diff --git a/js/src/offcanvas.js b/js/src/offcanvas.js index 2735a9c2aeac..b5afc0c87b49 100644 --- a/js/src/offcanvas.js +++ b/js/src/offcanvas.js @@ -39,6 +39,7 @@ const OPEN_SELECTOR = '.offcanvas.show' const EVENT_SHOW = `show${EVENT_KEY}` const EVENT_SHOWN = `shown${EVENT_KEY}` const EVENT_HIDE = `hide${EVENT_KEY}` +const EVENT_HIDE_PREVENTED = `hidePrevented${EVENT_KEY}` const EVENT_HIDDEN = `hidden${EVENT_KEY}` const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` const EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY}` @@ -52,7 +53,7 @@ const Default = { } const DefaultType = { - backdrop: 'boolean', + backdrop: '(boolean|string)', keyboard: 'boolean', scroll: 'boolean' } @@ -164,12 +165,24 @@ class Offcanvas extends BaseComponent { // Private _initializeBackDrop() { + const clickCallback = () => { + if (this._config.backdrop === 'static') { + EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED) + return + } + + this.hide() + } + + // 'static' option will be translated to true, and booleans will keep their value + const isVisible = Boolean(this._config.backdrop) + return new Backdrop({ className: CLASS_NAME_BACKDROP, - isVisible: this._config.backdrop, + isVisible, isAnimated: true, rootElement: this._element.parentNode, - clickCallback: () => this.hide() + clickCallback: isVisible ? clickCallback : null }) } @@ -181,9 +194,16 @@ class Offcanvas extends BaseComponent { _addEventListeners() { EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, event => { - if (this._config.keyboard && event.key === ESCAPE_KEY) { - this.hide() + if (event.key !== ESCAPE_KEY) { + return } + + if (!this._config.keyboard) { + EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED) + return + } + + this.hide() }) } diff --git a/js/src/popover.js b/js/src/popover.js index b62b6a212323..b6d1e2010e82 100644 --- a/js/src/popover.js +++ b/js/src/popover.js @@ -13,8 +13,6 @@ import Tooltip from './tooltip' */ const NAME = 'popover' -const DATA_KEY = 'bs.popover' -const EVENT_KEY = `.${DATA_KEY}` const SELECTOR_TITLE = '.popover-header' const SELECTOR_CONTENT = '.popover-body' @@ -37,19 +35,6 @@ const DefaultType = { content: '(null|string|element|function)' } -const Event = { - HIDE: `hide${EVENT_KEY}`, - HIDDEN: `hidden${EVENT_KEY}`, - SHOW: `show${EVENT_KEY}`, - SHOWN: `shown${EVENT_KEY}`, - INSERTED: `inserted${EVENT_KEY}`, - CLICK: `click${EVENT_KEY}`, - FOCUSIN: `focusin${EVENT_KEY}`, - FOCUSOUT: `focusout${EVENT_KEY}`, - MOUSEENTER: `mouseenter${EVENT_KEY}`, - MOUSELEAVE: `mouseleave${EVENT_KEY}` -} - /** * Class definition */ @@ -68,10 +53,6 @@ class Popover extends Tooltip { return NAME } - static get Event() { - return Event - } - // Overrides _isWithContent() { return this._getTitle() || this._getContent() diff --git a/js/src/tooltip.js b/js/src/tooltip.js index 82f32ca7c1b1..2bf625575d57 100644 --- a/js/src/tooltip.js +++ b/js/src/tooltip.js @@ -6,14 +6,7 @@ */ import * as Popper from '@popperjs/core' -import { - defineJQueryPlugin, - findShadowRoot, - getElement, - getUID, - isRTL, - noop -} from './util/index' +import { defineJQueryPlugin, findShadowRoot, getElement, getUID, isRTL, noop } from './util/index' import { DefaultAllowlist } from './util/sanitizer' import EventHandler from './dom/event-handler' import Manipulator from './dom/manipulator' @@ -25,8 +18,6 @@ import TemplateFactory from './util/template-factory' */ const NAME = 'tooltip' -const DATA_KEY = 'bs.tooltip' -const EVENT_KEY = `.${DATA_KEY}` const DISALLOWED_ATTRIBUTES = new Set(['sanitize', 'allowList', 'sanitizeFn']) const CLASS_NAME_FADE = 'fade' @@ -43,6 +34,17 @@ const TRIGGER_FOCUS = 'focus' const TRIGGER_CLICK = 'click' const TRIGGER_MANUAL = 'manual' +const EVENT_HIDE = 'hide' +const EVENT_HIDDEN = 'hidden' +const EVENT_SHOW = 'show' +const EVENT_SHOWN = 'shown' +const EVENT_INSERTED = 'inserted' +const EVENT_CLICK = 'click' +const EVENT_FOCUSIN = 'focusin' +const EVENT_FOCUSOUT = 'focusout' +const EVENT_MOUSEENTER = 'mouseenter' +const EVENT_MOUSELEAVE = 'mouseleave' + const AttachmentMap = { AUTO: 'auto', TOP: 'top', @@ -94,19 +96,6 @@ const DefaultType = { popperConfig: '(null|object|function)' } -const Event = { - HIDE: `hide${EVENT_KEY}`, - HIDDEN: `hidden${EVENT_KEY}`, - SHOW: `show${EVENT_KEY}`, - SHOWN: `shown${EVENT_KEY}`, - INSERTED: `inserted${EVENT_KEY}`, - CLICK: `click${EVENT_KEY}`, - FOCUSIN: `focusin${EVENT_KEY}`, - FOCUSOUT: `focusout${EVENT_KEY}`, - MOUSEENTER: `mouseenter${EVENT_KEY}`, - MOUSELEAVE: `mouseleave${EVENT_KEY}` -} - /** * Class definition */ @@ -146,10 +135,6 @@ class Tooltip extends BaseComponent { return NAME } - static get Event() { - return Event - } - // Public enable() { this._isEnabled = true @@ -212,11 +197,9 @@ class Tooltip extends BaseComponent { return } - const showEvent = EventHandler.trigger(this._element, this.constructor.Event.SHOW) + const showEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOW)) const shadowRoot = findShadowRoot(this._element) - const isInTheDom = shadowRoot === null ? - this._element.ownerDocument.documentElement.contains(this._element) : - shadowRoot.contains(this._element) + const isInTheDom = (shadowRoot || this._element.ownerDocument.documentElement).contains(this._element) if (showEvent.defaultPrevented || !isInTheDom) { return @@ -236,7 +219,7 @@ class Tooltip extends BaseComponent { if (!this._element.ownerDocument.documentElement.contains(this.tip)) { container.append(tip) - EventHandler.trigger(this._element, this.constructor.Event.INSERTED) + EventHandler.trigger(this._element, this.constructor.eventName(EVENT_INSERTED)) } if (this._popper) { @@ -261,7 +244,7 @@ class Tooltip extends BaseComponent { const previousHoverState = this._isHovered this._isHovered = false - EventHandler.trigger(this._element, this.constructor.Event.SHOWN) + EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOWN)) if (previousHoverState) { this._leave() @@ -276,7 +259,7 @@ class Tooltip extends BaseComponent { return } - const hideEvent = EventHandler.trigger(this._element, this.constructor.Event.HIDE) + const hideEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_HIDE)) if (hideEvent.defaultPrevented) { return } @@ -307,7 +290,7 @@ class Tooltip extends BaseComponent { } this._element.removeAttribute('aria-describedby') - EventHandler.trigger(this._element, this.constructor.Event.HIDDEN) + EventHandler.trigger(this._element, this.constructor.eventName(EVENT_HIDDEN)) this._disposePopper() } @@ -490,14 +473,14 @@ class Tooltip extends BaseComponent { for (const trigger of triggers) { if (trigger === 'click') { - EventHandler.on(this._element, this.constructor.Event.CLICK, this._config.selector, event => this.toggle(event)) + EventHandler.on(this._element, this.constructor.eventName(EVENT_CLICK), this._config.selector, event => this.toggle(event)) } else if (trigger !== TRIGGER_MANUAL) { const eventIn = trigger === TRIGGER_HOVER ? - this.constructor.Event.MOUSEENTER : - this.constructor.Event.FOCUSIN + this.constructor.eventName(EVENT_MOUSEENTER) : + this.constructor.eventName(EVENT_FOCUSIN) const eventOut = trigger === TRIGGER_HOVER ? - this.constructor.Event.MOUSELEAVE : - this.constructor.Event.FOCUSOUT + this.constructor.eventName(EVENT_MOUSELEAVE) : + this.constructor.eventName(EVENT_FOCUSOUT) EventHandler.on(this._element, eventIn, this._config.selector, event => { const context = this._initializeOnDelegatedTarget(event) diff --git a/js/tests/unit/alert.spec.js b/js/tests/unit/alert.spec.js index e2fe49246a89..bfe6f9a2927d 100644 --- a/js/tests/unit/alert.spec.js +++ b/js/tests/unit/alert.spec.js @@ -102,7 +102,7 @@ describe('Alert', () => { }) it('should not remove alert if close event is prevented', () => { - return new Promise(resolve => { + return new Promise((resolve, reject) => { fixtureEl.innerHTML = '
' const getAlert = () => document.querySelector('.alert') @@ -118,7 +118,7 @@ describe('Alert', () => { }) alertEl.addEventListener('closed.bs.alert', () => { - throw new Error('should not fire closed event') + reject(new Error('should not fire closed event')) }) alert.close() diff --git a/js/tests/unit/carousel.spec.js b/js/tests/unit/carousel.spec.js index 1c91cebec4c9..8875f3f00382 100644 --- a/js/tests/unit/carousel.spec.js +++ b/js/tests/unit/carousel.spec.js @@ -214,7 +214,7 @@ describe('Carousel', () => { const carouselEl = fixtureEl.querySelector('div') const carousel = new Carousel(carouselEl, {}) - spyOn(carousel, '_triggerSlideEvent') + spyOn(EventHandler, 'trigger') carousel._isSliding = true @@ -225,7 +225,7 @@ describe('Carousel', () => { carouselEl.dispatchEvent(keydown) } - expect(carousel._triggerSlideEvent).not.toHaveBeenCalled() + expect(EventHandler.trigger).not.toHaveBeenCalled() }) it('should wrap around from end to start when wrap option is true', () => { @@ -270,7 +270,7 @@ describe('Carousel', () => { }) it('should stay at the start when the prev method is called and wrap is false', () => { - return new Promise(resolve => { + return new Promise((resolve, reject) => { fixtureEl.innerHTML = [ '