diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index bd9ce671d45e..4a28264b3a99 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -26,35 +26,35 @@ }, { "path": "./dist/css/bootstrap.css", - "maxSize": "24 kB" + "maxSize": "24.25 kB" }, { "path": "./dist/css/bootstrap.min.css", - "maxSize": "22 kB" + "maxSize": "22.25 kB" }, { "path": "./dist/js/bootstrap.bundle.js", - "maxSize": "42 kB" + "maxSize": "43 kB" }, { "path": "./dist/js/bootstrap.bundle.min.js", - "maxSize": "21.75 kB" + "maxSize": "22.5 kB" }, { "path": "./dist/js/bootstrap.esm.js", - "maxSize": "27 kB" + "maxSize": "28.5 kB" }, { "path": "./dist/js/bootstrap.esm.min.js", - "maxSize": "18 kB" + "maxSize": "19 kB" }, { "path": "./dist/js/bootstrap.js", - "maxSize": "28 kB" + "maxSize": "29 kB" }, { "path": "./dist/js/bootstrap.min.js", - "maxSize": "15.75 kB" + "maxSize": "16.25 kB" } ], "ci": { diff --git a/.eslintignore b/.eslintignore index ae6baae7e14d..a18b03a5df54 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,6 +1,6 @@ **/*.min.js **/dist/ **/vendor/ -/_gh_pages/ +/_site/ /js/coverage/ /site/static/sw.js diff --git a/.github/workflows/bundlewatch.yml b/.github/workflows/bundlewatch.yml index bebf013510ab..b2b1fa109346 100644 --- a/.github/workflows/bundlewatch.yml +++ b/.github/workflows/bundlewatch.yml @@ -2,8 +2,6 @@ name: Bundlewatch on: push: - branches-ignore: - - "dependabot/**" pull_request: env: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 469a5a4fc4e4..13e2eb598fd7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -11,6 +11,7 @@ on: branches: - main - v4-dev + - "!dependabot/**" schedule: - cron: "0 2 * * 5" diff --git a/.gitignore b/.gitignore index 0a0f88d1589d..9817c71118f8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Ignore docs files -/_gh_pages/ +/_site/ # Hugo resources folder /resources/ diff --git a/.stylelintignore b/.stylelintignore index e42e88938400..0759a69acead 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -1,5 +1,5 @@ **/*.min.css **/dist/ **/vendor/ -/_gh_pages/ +/_site/ /js/coverage/ diff --git a/build/build-plugins.js b/build/build-plugins.js index d1930b8556ed..7175df4dd562 100644 --- a/build/build-plugins.js +++ b/build/build-plugins.js @@ -35,6 +35,7 @@ const bsPlugins = { Collapse: path.resolve(__dirname, '../js/src/collapse.js'), Dropdown: path.resolve(__dirname, '../js/src/dropdown.js'), Modal: path.resolve(__dirname, '../js/src/modal.js'), + OffCanvas: path.resolve(__dirname, '../js/src/offcanvas.js'), Popover: path.resolve(__dirname, '../js/src/popover.js'), ScrollSpy: path.resolve(__dirname, '../js/src/scrollspy.js'), Tab: path.resolve(__dirname, '../js/src/tab.js'), @@ -71,7 +72,7 @@ const getConfigByPluginKey = pluginKey => { } } - if (pluginKey === 'Alert' || pluginKey === 'Tab') { + if (pluginKey === 'Alert' || pluginKey === 'Tab' || pluginKey === 'OffCanvas') { return defaultPluginConfig } diff --git a/build/change-version.js b/build/change-version.js index f41f253837a0..9543a4f463e6 100644 --- a/build/change-version.js +++ b/build/change-version.js @@ -90,7 +90,7 @@ function main(args) { const newVersion = args[1] const EXCLUDED_DIRS = new Set([ '.git', - '_gh_pages', + '_site', 'node_modules', 'resources' ]) diff --git a/build/ship.sh b/build/ship.sh index da5a03bcd15c..b1f0a415eeac 100644 --- a/build/ship.sh +++ b/build/ship.sh @@ -47,7 +47,7 @@ npm run release printf "\n%s=======================================================%s" "$magenta" "$end" printf "\n%sCopy it over...%s" "$magenta" "$end" printf "\n%s=======================================================\n%s" "$magenta" "$end" -cp -rf _gh_pages/. ../bs-docs/ +cp -rf _site/. ../bs-docs/ printf "\nDone!\n" printf "\n%s=======================================================%s" "$green" "$end" diff --git a/build/vnu-jar.js b/build/vnu-jar.js index 0296d140fcc2..7a912675e1f5 100644 --- a/build/vnu-jar.js +++ b/build/vnu-jar.js @@ -49,7 +49,7 @@ childProcess.exec('java -version', (error, stdout, stderr) => { '--no-langdetect', '--Werror', `--filterpattern "${ignores}"`, - '_gh_pages/', + '_site/', 'js/tests/' ] diff --git a/build/zip-examples.js b/build/zip-examples.js index b2f156502ff5..312548e8af17 100644 --- a/build/zip-examples.js +++ b/build/zip-examples.js @@ -16,7 +16,7 @@ const pkg = require('../package.json') const versionShort = pkg.config.version_short const distFolder = `bootstrap-${pkg.version}-examples` -const rootDocsDir = '_gh_pages' +const rootDocsDir = '_site' const docsDir = `${rootDocsDir}/docs/${versionShort}/` // these are the files we need in the examples diff --git a/config.yml b/config.yml index 12e2476dff26..c9c5a81b3978 100644 --- a/config.yml +++ b/config.yml @@ -20,7 +20,7 @@ enableRobotsTXT: true metaDataFormat: "yaml" disableKinds: ["404", "taxonomy", "taxonomyTerm", "RSS"] -publishDir: "_gh_pages" +publishDir: "_site" module: mounts: @@ -75,5 +75,5 @@ params: js_hash: "sha384-nsg8ua9HAw1y0W1btsyWgBklPnCUAFLuTMS2G72MMONqmOymq585AcH49TLBQObG" js_bundle: "https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/js/bootstrap.bundle.min.js" js_bundle_hash: "sha384-b5kHyXgcpbZJO/tY9Ul7kGkf1S0CWuKcCD38l8YkeH8z8QjE0GmW1gYU5S9FOnJ0" - popper: "https://cdn.jsdelivr.net/npm/@popperjs/core@2.6.0/dist/umd/popper.min.js" - popper_hash: "sha384-KsvD1yqQ1/1+IA7gi3P0tyJcT3vR+NdBTt13hSJ2lnve8agRGXTTyNaBYmCR/Nwi" + popper: "https://cdn.jsdelivr.net/npm/@popperjs/core@2.9.0/dist/umd/popper.min.js" + popper_hash: "sha384-EOlc5FUOsy7obkrpNXqD9DtrephzQpK7DXijDIKexeJT0qfn4dZ7GDid8suWuQHq" diff --git a/js/index.esm.js b/js/index.esm.js index b1bbf53a8f71..c041beffe255 100644 --- a/js/index.esm.js +++ b/js/index.esm.js @@ -11,6 +11,7 @@ import Carousel from './src/carousel' import Collapse from './src/collapse' import Dropdown from './src/dropdown' import Modal from './src/modal' +import OffCanvas from './src/offcanvas' import Popover from './src/popover' import ScrollSpy from './src/scrollspy' import Tab from './src/tab' @@ -24,6 +25,7 @@ export { Collapse, Dropdown, Modal, + OffCanvas, Popover, ScrollSpy, Tab, diff --git a/js/index.umd.js b/js/index.umd.js index 5c44ce8d2def..a99a3be896a3 100644 --- a/js/index.umd.js +++ b/js/index.umd.js @@ -11,6 +11,7 @@ import Carousel from './src/carousel' import Collapse from './src/collapse' import Dropdown from './src/dropdown' import Modal from './src/modal' +import OffCanvas from './src/offcanvas' import Popover from './src/popover' import ScrollSpy from './src/scrollspy' import Tab from './src/tab' @@ -24,6 +25,7 @@ export default { Collapse, Dropdown, Modal, + OffCanvas, Popover, ScrollSpy, Tab, diff --git a/js/src/alert.js b/js/src/alert.js index 3a018a6386b9..d10e6c8da3a3 100644 --- a/js/src/alert.js +++ b/js/src/alert.js @@ -98,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) @@ -126,6 +126,7 @@ class Alert extends BaseComponent { * Data Api implementation * ------------------------------------------------------------------------ */ + EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DISMISS, Alert.handleDismiss(new Alert())) /** diff --git a/js/src/base-component.js b/js/src/base-component.js index 9de274bd0978..2a9e29c2aa71 100644 --- a/js/src/base-component.js +++ b/js/src/base-component.js @@ -17,23 +17,25 @@ const VERSION = '5.0.0-beta2' 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) 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 4ec48ca0830f..7a9449f07510 100644 --- a/js/src/button.js +++ b/js/src/button.js @@ -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) } diff --git a/js/src/carousel.js b/js/src/carousel.js index 75f8a4da7983..a825aaef4812 100644 --- a/js/src/carousel.js +++ b/js/src/carousel.js @@ -527,7 +527,7 @@ class Carousel extends BaseComponent { // 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) @@ -586,7 +586,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() @@ -605,7 +605,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)) } }) diff --git a/js/src/collapse.js b/js/src/collapse.js index 0a1b475470cd..036ffcf24841 100644 --- a/js/src/collapse.js +++ b/js/src/collapse.js @@ -72,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) @@ -82,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 @@ -147,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 @@ -166,7 +166,7 @@ class Collapse extends BaseComponent { } if (!activesData) { - Data.setData(elemActive, DATA_KEY, null) + Data.set(elemActive, DATA_KEY, null) } }) } @@ -332,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), @@ -380,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 diff --git a/js/src/dom/data.js b/js/src/dom/data.js index c93a8dc7c5ef..1d283d68b8a2 100644 --- a/js/src/dom/data.js +++ b/js/src/dom/data.js @@ -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/dropdown.js b/js/src/dropdown.js index 590c748012c9..eeec9076d52a 100644 --- a/js/src/dropdown.js +++ b/js/src/dropdown.js @@ -73,7 +73,6 @@ const PLACEMENT_LEFT = isRTL() ? 'right-start' : 'left-start' const Default = { offset: [0, 2], - flip: true, boundary: 'clippingParents', reference: 'toggle', display: 'dynamic', @@ -82,7 +81,6 @@ const Default = { const DefaultType = { offset: '(array|string|function)', - flip: 'boolean', boundary: '(string|element)', reference: '(string|element|object)', display: 'string', @@ -328,7 +326,6 @@ class Dropdown extends BaseComponent { modifiers: [{ name: 'preventOverflow', options: { - altBoundary: this._config.flip, boundary: this._config.boundary } }, @@ -357,7 +354,7 @@ class Dropdown extends BaseComponent { // 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) { @@ -387,7 +384,7 @@ class Dropdown extends BaseComponent { const toggles = SelectorEngine.find(SELECTOR_DATA_TOGGLE) for (let i = 0, len = toggles.length; i < len; i++) { - const context = Data.getData(toggles[i], DATA_KEY) + const context = Data.get(toggles[i], DATA_KEY) const relatedTarget = { relatedTarget: toggles[i] } diff --git a/js/src/modal.js b/js/src/modal.js index 79a2f143a38b..332d636d0f05 100644 --- a/js/src/modal.js +++ b/js/src/modal.js @@ -83,7 +83,7 @@ class Modal extends BaseComponent { super(element) this._config = this._getConfig(config) - this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, element) + this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, this._element) this._backdrop = null this._isShown = false this._isBodyOverflowing = false @@ -466,6 +466,10 @@ class Modal extends BaseComponent { _setElementAttributes(selector, styleProp, callback) { SelectorEngine.find(selector) .forEach(element => { + if (element !== document.body && window.innerWidth > element.clientWidth + this._scrollbarWidth) { + return + } + const actualValue = element.style[styleProp] const calculatedValue = window.getComputedStyle(element)[styleProp] Manipulator.setDataAttribute(element, styleProp, actualValue) @@ -504,7 +508,7 @@ class Modal extends BaseComponent { static jQueryInterface(config, relatedTarget) { return this.each(function () { - let data = Data.getData(this, DATA_KEY) + let data = Data.get(this, DATA_KEY) const _config = { ...Default, ...Manipulator.getDataAttributes(this), @@ -552,7 +556,7 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function ( }) }) - let data = Data.getData(target, DATA_KEY) + let data = Data.get(target, DATA_KEY) if (!data) { const config = { ...Manipulator.getDataAttributes(target), diff --git a/js/src/offcanvas.js b/js/src/offcanvas.js new file mode 100644 index 000000000000..148f003e9634 --- /dev/null +++ b/js/src/offcanvas.js @@ -0,0 +1,239 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap (v5.0.0-beta2): offcanvas.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + +import { + defineJQueryPlugin, + getElementFromSelector, + getSelectorFromElement, + getTransitionDurationFromElement, + isVisible +} 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' + +/** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + +const NAME = 'offcanvas' +const DATA_KEY = 'bs.offcanvas' +const EVENT_KEY = `.${DATA_KEY}` +const DATA_API_KEY = '.data-api' +const ESCAPE_KEY = 'Escape' +const DATA_BODY_ACTIONS = 'data-bs-body' + +const CLASS_NAME_BACKDROP_BODY = 'offcanvas-backdrop' +const CLASS_NAME_DISABLED = 'disabled' +const CLASS_NAME_SHOW = 'show' +const CLASS_NAME_TOGGLING = 'offcanvas-toggling' +const ACTIVE_SELECTOR = `.offcanvas.show, .${CLASS_NAME_TOGGLING}` + +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 SELECTOR_DATA_DISMISS = '[data-bs-dismiss="offcanvas"]' +const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="offcanvas"]' + +/** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + +class OffCanvas extends BaseComponent { + constructor(element) { + super(element) + + this._isShown = element.classList.contains(CLASS_NAME_SHOW) + this._bodyOptions = element.getAttribute(DATA_BODY_ACTIONS) || '' + this._addEventListeners() + } + + // 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' + + if (this._bodyOptionsHas('backdrop') || !this._bodyOptions.length) { + document.body.classList.add(CLASS_NAME_BACKDROP_BODY) + } + + if (!this._bodyOptionsHas('scroll')) { + scrollBarHide() + } + + this._element.classList.add(CLASS_NAME_TOGGLING) + 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 = () => { + this._element.classList.remove(CLASS_NAME_TOGGLING) + EventHandler.trigger(this._element, EVENT_SHOWN, { relatedTarget }) + this._enforceFocusOnElement(this._element) + } + + setTimeout(completeCallBack, getTransitionDurationFromElement(this._element)) + } + + hide() { + if (!this._isShown) { + return + } + + const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE) + + if (hideEvent.defaultPrevented) { + return + } + + this._element.classList.add(CLASS_NAME_TOGGLING) + EventHandler.off(document, EVENT_FOCUSIN) + this._element.blur() + this._isShown = false + this._element.classList.remove(CLASS_NAME_SHOW) + + const completeCallback = () => { + this._element.setAttribute('aria-hidden', true) + this._element.removeAttribute('aria-modal') + this._element.removeAttribute('role') + this._element.style.visibility = 'hidden' + + if (this._bodyOptionsHas('backdrop') || !this._bodyOptions.length) { + document.body.classList.remove(CLASS_NAME_BACKDROP_BODY) + } + + if (!this._bodyOptionsHas('scroll')) { + scrollBarReset() + } + + EventHandler.trigger(this._element, EVENT_HIDDEN) + this._element.classList.remove(CLASS_NAME_TOGGLING) + } + + setTimeout(completeCallback, getTransitionDurationFromElement(this._element)) + } + + _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() + } + + _bodyOptionsHas(option) { + return this._bodyOptions.split(',').includes(option) + } + + _addEventListeners() { + EventHandler.on(this._element, EVENT_CLICK_DISMISS, SELECTOR_DATA_DISMISS, () => this.hide()) + + EventHandler.on(document, 'keydown', event => { + if (event.key === ESCAPE_KEY) { + this.hide() + } + }) + + EventHandler.on(document, EVENT_CLICK_DATA_API, event => { + const target = SelectorEngine.findOne(getSelectorFromElement(event.target)) + if (!this._element.contains(event.target) && target !== this._element) { + this.hide() + } + }) + } + + // Static + + static jQueryInterface(config) { + return this.each(function () { + const data = Data.get(this, DATA_KEY) || new OffCanvas(this) + + if (typeof config === 'string') { + if (typeof data[config] === 'undefined') { + 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 (this.disabled || this.classList.contains(CLASS_NAME_DISABLED)) { + 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(ACTIVE_SELECTOR) + if (allReadyOpen && allReadyOpen !== target) { + return + } + + const data = Data.get(target, DATA_KEY) || new OffCanvas(target) + data.toggle(this) +}) + +/** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + */ + +defineJQueryPlugin(NAME, OffCanvas) + +export default OffCanvas diff --git a/js/src/popover.js b/js/src/popover.js index 0677dafa0945..55354475477f 100644 --- a/js/src/popover.js +++ b/js/src/popover.js @@ -136,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)) { @@ -145,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') { diff --git a/js/src/scrollspy.js b/js/src/scrollspy.js index 43a91e5e9326..c7472439be3e 100644 --- a/js/src/scrollspy.js +++ b/js/src/scrollspy.js @@ -68,7 +68,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 = [] @@ -278,7 +278,7 @@ class ScrollSpy 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) { diff --git a/js/src/tab.js b/js/src/tab.js index e60ecddb56b1..95968f4f8ba8 100644 --- a/js/src/tab.js +++ b/js/src/tab.js @@ -182,7 +182,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') { @@ -204,7 +204,7 @@ class Tab extends BaseComponent { EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) { event.preventDefault() - const data = Data.getData(this, DATA_KEY) || new Tab(this) + const data = Data.get(this, DATA_KEY) || new Tab(this) data.show() }) diff --git a/js/src/toast.js b/js/src/toast.js index 2f451aab76c9..ea91163d8470 100644 --- a/js/src/toast.js +++ b/js/src/toast.js @@ -189,7 +189,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) { diff --git a/js/src/tooltip.js b/js/src/tooltip.js index d35b5e0ab102..6f33245f8ee0 100644 --- a/js/src/tooltip.js +++ b/js/src/tooltip.js @@ -275,7 +275,7 @@ class Tooltip extends BaseComponent { this._addAttachmentClass(attachment) const container = this._getContainer() - Data.setData(tip, this.constructor.DATA_KEY, this) + Data.set(tip, this.constructor.DATA_KEY, this) if (!this._element.ownerDocument.documentElement.contains(this.tip)) { container.appendChild(tip) @@ -465,11 +465,11 @@ class Tooltip extends BaseComponent { _initializeOnDelegatedTarget(event, context) { const dataKey = this.constructor.DATA_KEY - context = context || Data.getData(event.delegateTarget, dataKey) + context = context || Data.get(event.delegateTarget, dataKey) if (!context) { context = new this.constructor(event.delegateTarget, this._getDelegateConfig()) - Data.setData(event.delegateTarget, dataKey, context) + Data.set(event.delegateTarget, dataKey, context) } return context @@ -761,7 +761,7 @@ class Tooltip 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 && /dispose|hide/.test(config)) { diff --git a/js/src/util/sanitizer.js b/js/src/util/sanitizer.js index 18ac6f9438dd..57653a891fc9 100644 --- a/js/src/util/sanitizer.js +++ b/js/src/util/sanitizer.js @@ -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. diff --git a/js/src/util/scrollbar.js b/js/src/util/scrollbar.js new file mode 100644 index 000000000000..d2f3919e6c7a --- /dev/null +++ b/js/src/util/scrollbar.js @@ -0,0 +1,70 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap (v5.0.0-beta2): 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' +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()) => { + document.body.style.overflow = 'hidden' + _setElementAttributes(SELECTOR_FIXED_CONTENT, 'paddingRight', calculatedValue => calculatedValue + width) + _setElementAttributes(SELECTOR_STICKY_CONTENT, 'marginRight', calculatedValue => calculatedValue - width) + _setElementAttributes('body', 'paddingRight', calculatedValue => calculatedValue + width) +} + +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 = () => { + document.body.style.overflow = 'auto' + _resetElementAttributes(SELECTOR_FIXED_CONTENT, 'paddingRight') + _resetElementAttributes(SELECTOR_STICKY_CONTENT, 'marginRight') + _resetElementAttributes('body', 'paddingRight') +} + +const _resetElementAttributes = (selector, styleProp) => { + SelectorEngine.find(selector).forEach(element => { + const value = Manipulator.getDataAttribute(element, styleProp) + if (typeof value === 'undefined' && element === document.body) { + element.style.removeProperty(styleProp) + } else { + Manipulator.removeDataAttribute(element, styleProp) + element.style[styleProp] = value + } + }) +} + +const isBodyOverflowing = () => { + return getWidth() > 0 +} + +export { + getWidth, + hide, + isBodyOverflowing, + reset +} diff --git a/js/tests/unit/alert.spec.js b/js/tests/unit/alert.spec.js index 916c7fd07c6d..194da420e9b5 100644 --- a/js/tests/unit/alert.spec.js +++ b/js/tests/unit/alert.spec.js @@ -15,6 +15,17 @@ describe('Alert', () => { clearFixture() }) + it('should take care of element either passed as a CSS selector or DOM element', () => { + fixtureEl.innerHTML = '
' + + const alertEl = fixtureEl.querySelector('.alert') + const alertBySelector = new Alert('.alert') + const alertByElement = new Alert(alertEl) + + expect(alertBySelector._element).toEqual(alertEl) + expect(alertByElement._element).toEqual(alertEl) + }) + it('should return version', () => { expect(typeof Alert.VERSION).toEqual('string') }) diff --git a/js/tests/unit/button.spec.js b/js/tests/unit/button.spec.js index e442fd90d0a1..e7d92cb6d208 100644 --- a/js/tests/unit/button.spec.js +++ b/js/tests/unit/button.spec.js @@ -18,6 +18,16 @@ describe('Button', () => { clearFixture() }) + it('should take care of element either passed as a CSS selector or DOM element', () => { + fixtureEl.innerHTML = '' + const buttonEl = fixtureEl.querySelector('[data-bs-toggle="button"]') + const buttonBySelector = new Button('[data-bs-toggle="button"]') + const buttonByElement = new Button(buttonEl) + + expect(buttonBySelector._element).toEqual(buttonEl) + expect(buttonByElement._element).toEqual(buttonEl) + }) + describe('VERSION', () => { it('should return plugin version', () => { expect(Button.VERSION).toEqual(jasmine.any(String)) diff --git a/js/tests/unit/carousel.spec.js b/js/tests/unit/carousel.spec.js index 533e1ba7e253..c475489c0605 100644 --- a/js/tests/unit/carousel.spec.js +++ b/js/tests/unit/carousel.spec.js @@ -52,6 +52,17 @@ describe('Carousel', () => { }) describe('constructor', () => { + it('should take care of element either passed as a CSS selector or DOM element', () => { + fixtureEl.innerHTML = '' + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carouselBySelector = new Carousel('#myCarousel') + const carouselByElement = new Carousel(carouselEl) + + expect(carouselBySelector._element).toEqual(carouselEl) + expect(carouselByElement._element).toEqual(carouselEl) + }) + it('should go to next item if right arrow key is pressed', done => { fixtureEl.innerHTML = [ '