diff --git a/js/src/modal.js b/js/src/modal.js index 8dac75265cc5..58be63faa18f 100644 --- a/js/src/modal.js +++ b/js/src/modal.js @@ -7,6 +7,7 @@ import { defineJQueryPlugin, + getElement, getElementFromSelector, isRTL, isVisible, @@ -35,13 +36,15 @@ const ESCAPE_KEY = 'Escape' const Default = { backdrop: true, keyboard: true, - focus: true + focus: true, + rootElement: 'body' } const DefaultType = { backdrop: '(boolean|string)', keyboard: 'boolean', - focus: 'boolean' + focus: 'boolean', + rootElement: 'element' } const EVENT_HIDE = `hide${EVENT_KEY}` @@ -83,7 +86,7 @@ class Modal extends BaseComponent { this._isShown = false this._ignoreBackdropClick = false this._isTransitioning = false - this._scrollBar = new ScrollBarHelper() + this._scrollBar = new ScrollBarHelper(this._config.rootElement) } // Getters @@ -123,7 +126,7 @@ class Modal extends BaseComponent { this._scrollBar.hide() - document.body.classList.add(CLASS_NAME_OPEN) + this._config.rootElement.classList.add(CLASS_NAME_OPEN) this._adjustDialog() @@ -202,7 +205,8 @@ class Modal extends BaseComponent { _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() + isAnimated: this._isAnimated(), + rootElement: this._config.rootElement }) } @@ -212,6 +216,8 @@ class Modal extends BaseComponent { ...Manipulator.getDataAttributes(this._element), ...(typeof config === 'object' ? config : {}) } + + config.rootElement = getElement(config.rootElement) typeCheckConfig(NAME, config, DefaultType) return config } @@ -222,7 +228,7 @@ class Modal extends BaseComponent { if (!this._element.parentNode || this._element.parentNode.nodeType !== Node.ELEMENT_NODE) { // Don't move modal's DOM position - document.body.appendChild(this._element) + this._config.rootElement.appendChild(this._element) } this._element.style.display = 'block' @@ -300,7 +306,7 @@ class Modal extends BaseComponent { this._element.removeAttribute('role') this._isTransitioning = false this._backdrop.hide(() => { - document.body.classList.remove(CLASS_NAME_OPEN) + this._config.rootElement.classList.remove(CLASS_NAME_OPEN) this._resetAdjustments() this._scrollBar.reset() EventHandler.trigger(this._element, EVENT_HIDDEN) diff --git a/js/src/util/backdrop.js b/js/src/util/backdrop.js index fbe32445eabd..b6e38066d2fe 100644 --- a/js/src/util/backdrop.js +++ b/js/src/util/backdrop.js @@ -20,7 +20,7 @@ const DefaultType = { className: 'string', isVisible: 'boolean', isAnimated: 'boolean', - rootElement: '(element|string)', + rootElement: 'element', clickCallback: '(function|null)' } const NAME = 'backdrop' diff --git a/js/src/util/scrollbar.js b/js/src/util/scrollbar.js index fad9766ac037..0a534ebb97a3 100644 --- a/js/src/util/scrollbar.js +++ b/js/src/util/scrollbar.js @@ -13,14 +13,20 @@ const SELECTOR_FIXED_CONTENT = '.fixed-top, .fixed-bottom, .is-fixed, .sticky-to const SELECTOR_STICKY_CONTENT = '.sticky-top' class ScrollBarHelper { - constructor() { - this._element = document.body + constructor(element = document.body) { + this._isBody = [document.body, document.documentElement].includes(element) + + this._element = this._isBody ? document.body : element } 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) + if (this._isBody) { + // https://developer.mozilla.org/en-US/docs/Web/API/Window/innerWidth#usage_notes + const documentWidth = document.documentElement.clientWidth + return Math.abs(window.innerWidth - documentWidth) + } + + return Math.abs(this._element.offsetWidth - this._element.clientWidth) } hide() { @@ -88,10 +94,6 @@ class ScrollBarHelper { SelectorEngine.find(selector, this._element).forEach(callBack) } } - - isOverflowing() { - return this.getWidth() > 0 - } } export default ScrollBarHelper diff --git a/js/tests/integration/bundle.js b/js/tests/integration/bundle.js index 452088a7d811..1ad004db830e 100644 --- a/js/tests/integration/bundle.js +++ b/js/tests/integration/bundle.js @@ -1,4 +1,4 @@ -import { Tooltip } from '../../../dist/js/bootstrap.esm.js' +import { Tooltip } from './../../../dist/js/bootstrap.esm.js' window.addEventListener('load', () => { [].concat(...document.querySelectorAll('[data-bs-toggle="tooltip"]')) diff --git a/js/tests/unit/modal.spec.js b/js/tests/unit/modal.spec.js index e6ef555e7007..24056e72eb89 100644 --- a/js/tests/unit/modal.spec.js +++ b/js/tests/unit/modal.spec.js @@ -714,6 +714,28 @@ describe('Modal', () => { }) }) + describe('config rootElement', () => { + it('should append backdrop to root element', done => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const modalEl = fixtureEl.querySelector('.modal') + const rootElement = fixtureEl.querySelector('#modal-root') + const modal = new Modal(modalEl, { rootElement }) + + modalEl.addEventListener('shown.bs.modal', () => { + expect(fixtureEl.querySelector('.modal-open')).toEqual(rootElement) + expect(rootElement.querySelector('.modal-backdrop')).not.toBeNull() + done() + }) + + modal.show() + }) + }) + describe('handleUpdate', () => { it('should call adjust dialog', () => { fixtureEl.innerHTML = '' diff --git a/js/tests/unit/util/scrollbar.spec.js b/js/tests/unit/util/scrollbar.spec.js index 280adb8e5a60..0f8f23024a38 100644 --- a/js/tests/unit/util/scrollbar.spec.js +++ b/js/tests/unit/util/scrollbar.spec.js @@ -48,61 +48,64 @@ describe('ScrollBar', () => { clearBodyAndDocument() }) - describe('isBodyOverflowing', () => { - it('should return true if body is overflowing', () => { - document.documentElement.style.overflowY = 'scroll' - document.body.style.overflowY = 'scroll' - fixtureEl.innerHTML = [ - '
' - ].join('') - const result = new ScrollBarHelper().isOverflowing() - - if (isScrollBarHidden()) { - expect(result).toEqual(false) - } else { - expect(result).toEqual(true) - } - }) + describe('getWidth', () => { + describe('Body', () => { + it('should return an integer greater than zero, if body is overflowing', () => { + doc.style.overflowY = 'scroll' + document.body.style.overflowY = 'scroll' + fixtureEl.innerHTML = [ + '
' + ].join('') + const result = new ScrollBarHelper().getWidth() - it('should return false if body is not overflowing', () => { - doc.style.overflowY = 'hidden' - document.body.style.overflowY = 'hidden' - fixtureEl.innerHTML = [ - '
' - ].join('') - const scrollBar = new ScrollBarHelper() - const result = scrollBar.isOverflowing() + if (isScrollBarHidden()) { + expect(result).toBe(0) + } else { + expect(result).toBeGreaterThan(1) + } + }) - expect(result).toEqual(false) - }) - }) + it('should return 0 if body is not overflowing', () => { + document.documentElement.style.overflowY = 'hidden' + document.body.style.overflowY = 'hidden' + fixtureEl.innerHTML = [ + '
' + ].join('') - describe('getWidth', () => { - it('should return an integer greater than zero, if body is overflowing', () => { - doc.style.overflowY = 'scroll' - document.body.style.overflowY = 'scroll' - fixtureEl.innerHTML = [ - '
' - ].join('') - const result = new ScrollBarHelper().getWidth() + const result = new ScrollBarHelper().getWidth() - if (isScrollBarHidden()) { - expect(result).toBe(0) - } else { - expect(result).toBeGreaterThan(1) - } + expect(result).toEqual(0) + }) }) - it('should return 0 if body is not overflowing', () => { - document.documentElement.style.overflowY = 'hidden' - document.body.style.overflowY = 'hidden' - fixtureEl.innerHTML = [ - '
' - ].join('') + describe('Element', () => { + it('should return an integer greater than zero, if wrapper element is overflowing', () => { + fixtureEl.innerHTML = [ + '
' + + '
' + + '
' + ].join('') + const wrapper = fixtureEl.querySelector('.wrapper') + const result = new ScrollBarHelper(wrapper).getWidth() + + if (isScrollBarHidden()) { + expect(result).toBe(0) + } else { + expect(result).toBeGreaterThan(1) + } + }) - const result = new ScrollBarHelper().getWidth() + it('should return 0 if wrapper element is not overflowing', () => { + fixtureEl.innerHTML = [ + '
' + + '
' + + '
' + ].join('') + const wrapper = fixtureEl.querySelector('.wrapper') + const result = new ScrollBarHelper(wrapper).getWidth() - expect(result).toEqual(0) + expect(result).toEqual(0) + }) }) }) diff --git a/scss/_modal.scss b/scss/_modal.scss index 21e1258f55f6..ac42f72cfaa1 100644 --- a/scss/_modal.scss +++ b/scss/_modal.scss @@ -88,6 +88,16 @@ @include overlay-backdrop($zindex-modal-backdrop, $modal-backdrop-bg, $modal-backdrop-opacity); } +// custom root specified +.modal-open:not(body) { + .modal, + .modal-backdrop { + position: absolute; + width: 100%; + height: 100%; + } +} + // Modal header // Top section of the modal w/ title and dismiss .modal-header { diff --git a/site/content/docs/5.0/components/modal.md b/site/content/docs/5.0/components/modal.md index e6a838aac284..084e4c2beb2a 100644 --- a/site/content/docs/5.0/components/modal.md +++ b/site/content/docs/5.0/components/modal.md @@ -564,6 +564,44 @@ Be sure to add `aria-labelledby="..."`, referencing the modal title, to `.modal` Embedding YouTube videos in modals requires additional JavaScript not in Bootstrap to automatically stop playback and more. [See this helpful Stack Overflow post](https://stackoverflow.com/questions/18622508/bootstrap-3-and-youtube-in-modal) for more information. +### Configuring root element + +Specify `data-bs-root-element` attribute +```html +
+