From 80085a12f6936bef11aa72631392e3e9b2646f17 Mon Sep 17 00:00:00 2001 From: GeoSot Date: Wed, 14 Apr 2021 23:28:50 +0300 Subject: [PATCH] Decouple BackDrop from modal (#32439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Create backdrop.js util * revert breaking changes remove PromiseTimout usage revert class name * one more test | change bundlewatch.config * add config obj to backdrop helper | tests for rootElement | use transitionend helper * Minor tweaks — Renaming Co-authored-by: Rohit Sharma --- .bundlewatch.config.json | 8 +- build/build-plugins.js | 6 +- js/src/modal.js | 86 +++-------- js/src/util/backdrop.js | 123 ++++++++++++++++ js/src/util/index.js | 9 +- js/tests/unit/util/backdrop.spec.js | 216 ++++++++++++++++++++++++++++ js/tests/unit/util/index.spec.js | 8 ++ 7 files changed, 386 insertions(+), 70 deletions(-) create mode 100644 js/src/util/backdrop.js create mode 100644 js/tests/unit/util/backdrop.spec.js diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index 32826198c3..81badf254c 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -34,11 +34,11 @@ }, { "path": "./dist/js/bootstrap.bundle.js", - "maxSize": "41 kB" + "maxSize": "41.25 kB" }, { "path": "./dist/js/bootstrap.bundle.min.js", - "maxSize": "22 kB" + "maxSize": "22.25 kB" }, { "path": "./dist/js/bootstrap.esm.js", @@ -46,11 +46,11 @@ }, { "path": "./dist/js/bootstrap.esm.min.js", - "maxSize": "18 kB" + "maxSize": "18.25 kB" }, { "path": "./dist/js/bootstrap.js", - "maxSize": "27 kB" + "maxSize": "27.25 kB" }, { "path": "./dist/js/bootstrap.min.js", diff --git a/build/build-plugins.js b/build/build-plugins.js index 7fd58bcb64..53093dc416 100644 --- a/build/build-plugins.js +++ b/build/build-plugins.js @@ -65,7 +65,8 @@ const getConfigByPluginKey = pluginKey => { pluginKey === 'EventHandler' || pluginKey === 'SelectorEngine' || pluginKey === 'Util' || - pluginKey === 'Sanitizer' + pluginKey === 'Sanitizer' || + pluginKey === 'Backdrop' ) { return { external: [] @@ -133,7 +134,8 @@ const getConfigByPluginKey = pluginKey => { const utilObjects = new Set([ 'Util', - 'Sanitizer' + 'Sanitizer', + 'Backdrop' ]) const domObjects = new Set([ diff --git a/js/src/modal.js b/js/src/modal.js index c6d67ac951..fabb151cb4 100644 --- a/js/src/modal.js +++ b/js/src/modal.js @@ -20,6 +20,7 @@ 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' /** * ------------------------------------------------------------------------ @@ -58,7 +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_BACKDROP = 'modal-backdrop' const CLASS_NAME_OPEN = 'modal-open' const CLASS_NAME_FADE = 'fade' const CLASS_NAME_SHOW = 'show' @@ -81,7 +81,7 @@ class Modal extends BaseComponent { this._config = this._getConfig(config) this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, this._element) - this._backdrop = null + this._backdrop = this._initializeBackDrop() this._isShown = false this._ignoreBackdropClick = false this._isTransitioning = false @@ -201,6 +201,7 @@ class Modal extends BaseComponent { this._config = null this._dialog = null + this._backdrop.dispose() this._backdrop = null this._isShown = null this._ignoreBackdropClick = null @@ -213,6 +214,13 @@ 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, @@ -313,7 +321,7 @@ 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() scrollBarReset() @@ -321,73 +329,25 @@ class Modal extends BaseComponent { }) } - _removeBackdrop() { - this._backdrop.parentNode.removeChild(this._backdrop) - this._backdrop = null - } - _showBackdrop(callback) { - const isAnimated = this._isAnimated() - if (this._isShown && this._config.backdrop) { - this._backdrop = document.createElement('div') - this._backdrop.className = CLASS_NAME_BACKDROP - - if (isAnimated) { - this._backdrop.classList.add(CLASS_NAME_FADE) - } - - 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 (isAnimated) { - reflow(this._backdrop) - } - - this._backdrop.classList.add(CLASS_NAME_SHOW) - - if (!isAnimated) { - callback() + EventHandler.on(this._element, EVENT_CLICK_DISMISS, event => { + if (this._ignoreBackdropClick) { + this._ignoreBackdropClick = false return } - const backdropTransitionDuration = getTransitionDurationFromElement(this._backdrop) - - EventHandler.one(this._backdrop, 'transitionend', callback) - emulateTransitionEnd(this._backdrop, backdropTransitionDuration) - } else if (!this._isShown && this._backdrop) { - this._backdrop.classList.remove(CLASS_NAME_SHOW) - - const callbackRemove = () => { - this._removeBackdrop() - callback() + if (event.target !== event.currentTarget) { + return } - if (isAnimated) { - const backdropTransitionDuration = getTransitionDurationFromElement(this._backdrop) - EventHandler.one(this._backdrop, 'transitionend', callbackRemove) - emulateTransitionEnd(this._backdrop, backdropTransitionDuration) - } else { - callbackRemove() + if (this._config.backdrop === true) { + this.hide() + } else if (this._config.backdrop === 'static') { + this._triggerBackdropTransition() } - } else { - callback() - } + }) + + this._backdrop.show(callback) } _isAnimated() { diff --git a/js/src/util/backdrop.js b/js/src/util/backdrop.js new file mode 100644 index 0000000000..ab14c23fe4 --- /dev/null +++ b/js/src/util/backdrop.js @@ -0,0 +1,123 @@ +/** + * -------------------------------------------------------------------------- + * 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 +} + +const DefaultType = { + isVisible: 'boolean', + isAnimated: 'boolean', + rootElement: 'element' +} +const NAME = 'backdrop' +const CLASS_NAME_BACKDROP = 'modal-backdrop' +const CLASS_NAME_FADE = 'fade' +const CLASS_NAME_SHOW = 'show' + +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()) + + this._isAppended = true + } + + dispose() { + if (!this._isAppended) { + return + } + + 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 f19d76e036..c27c470e95 100644 --- a/js/src/util/index.js +++ b/js/src/util/index.js @@ -230,6 +230,12 @@ const defineJQueryPlugin = (name, plugin) => { }) } +const execute = callback => { + if (typeof callback === 'function') { + callback() + } +} + export { getUID, getSelectorFromElement, @@ -247,5 +253,6 @@ export { getjQuery, onDOMContentLoaded, isRTL, - defineJQueryPlugin + defineJQueryPlugin, + execute } diff --git a/js/tests/unit/util/backdrop.spec.js b/js/tests/unit/util/backdrop.spec.js new file mode 100644 index 0000000000..c8570f1861 --- /dev/null +++ b/js/tests/unit/util/backdrop.spec.js @@ -0,0 +1,216 @@ +import Backdrop from '../../../src/util/backdrop' +import { getTransitionDurationFromElement } from '../../../src/util/index' +import { clearFixture, getFixture } from '../../helpers/fixture' + +const CLASS_BACKDROP = '.modal-backdrop' +const CLASS_NAME_FADE = 'fade' +const CLASS_NAME_SHOW = 'show' + +describe('Backdrop', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + const list = document.querySelectorAll(CLASS_BACKDROP) + + list.forEach(el => { + document.body.removeChild(el) + }) + }) + + describe('show', () => { + it('if it is "shown", should append the backdrop html once, on show, and contain "show" class', done => { + const instance = new Backdrop({ + isVisible: true, + isAnimated: false + }) + const getElements = () => document.querySelectorAll(CLASS_BACKDROP) + + expect(getElements().length).toEqual(0) + + instance.show() + instance.show(() => { + expect(getElements().length).toEqual(1) + getElements().forEach(el => { + expect(el.classList.contains(CLASS_NAME_SHOW)).toEqual(true) + }) + done() + }) + }) + + it('if it is not "shown", should not append the backdrop html', done => { + const instance = new Backdrop({ + isVisible: false, + isAnimated: true + }) + const getElements = () => document.querySelectorAll(CLASS_BACKDROP) + + expect(getElements().length).toEqual(0) + instance.show(() => { + expect(getElements().length).toEqual(0) + done() + }) + }) + + it('if it is "shown" and "animated", should append the backdrop html once, and contain "fade" class', done => { + const instance = new Backdrop({ + isVisible: true, + isAnimated: true + }) + const getElements = () => document.querySelectorAll(CLASS_BACKDROP) + + expect(getElements().length).toEqual(0) + + instance.show(() => { + expect(getElements().length).toEqual(1) + getElements().forEach(el => { + expect(el.classList.contains(CLASS_NAME_FADE)).toEqual(true) + }) + done() + }) + }) + + it('Should be appended on "document.body" by default', done => { + const instance = new Backdrop({ + isVisible: true + }) + const getElement = () => document.querySelector(CLASS_BACKDROP) + instance.show(() => { + expect(getElement().parentElement).toEqual(document.body) + done() + }) + }) + + it('Should appended on any element given by the proper config', done => { + fixtureEl.innerHTML = [ + '
', + '
' + ].join('') + + const wrapper = fixtureEl.querySelector('#wrapper') + const instance = new Backdrop({ + isVisible: true, + rootElement: wrapper + }) + const getElement = () => document.querySelector(CLASS_BACKDROP) + instance.show(() => { + expect(getElement().parentElement).toEqual(wrapper) + done() + }) + }) + }) + + describe('hide', () => { + it('should remove the backdrop html', done => { + const instance = new Backdrop({ + isVisible: true, + isAnimated: true + }) + + const getElements = () => document.body.querySelectorAll(CLASS_BACKDROP) + + expect(getElements().length).toEqual(0) + instance.show(() => { + expect(getElements().length).toEqual(1) + instance.hide(() => { + expect(getElements().length).toEqual(0) + done() + }) + }) + }) + + it('should remove "show" class', done => { + const instance = new Backdrop({ + isVisible: true, + isAnimated: true + }) + const elem = instance._getElement() + + instance.show() + instance.hide(() => { + expect(elem.classList.contains(CLASS_NAME_SHOW)).toEqual(false) + done() + }) + }) + + it('if it is not "shown", should not try to remove Node on remove method', done => { + const instance = new Backdrop({ + isVisible: false, + isAnimated: true + }) + const getElements = () => document.querySelectorAll(CLASS_BACKDROP) + const spy = spyOn(instance, 'dispose').and.callThrough() + + expect(getElements().length).toEqual(0) + expect(instance._isAppended).toEqual(false) + instance.show(() => { + instance.hide(() => { + expect(getElements().length).toEqual(0) + expect(spy).not.toHaveBeenCalled() + expect(instance._isAppended).toEqual(false) + done() + }) + }) + }) + }) + + describe('animation callbacks', () => { + it('if it is animated, should show and hide backdrop after counting transition duration', done => { + const instance = new Backdrop({ + isVisible: true, + isAnimated: true + }) + const spy2 = jasmine.createSpy('spy2') + + const execDone = () => { + setTimeout(() => { + expect(spy2).toHaveBeenCalledTimes(2) + done() + }, 10) + } + + instance.show(spy2) + instance.hide(() => { + spy2() + execDone() + }) + expect(spy2).not.toHaveBeenCalled() + }) + + it('if it is not animated, should show and hide backdrop without delay', done => { + const spy = jasmine.createSpy('spy', getTransitionDurationFromElement) + const instance = new Backdrop({ + isVisible: true, + isAnimated: false + }) + const spy2 = jasmine.createSpy('spy2') + + instance.show(spy2) + instance.hide(spy2) + + setTimeout(() => { + expect(spy2).toHaveBeenCalled() + expect(spy).not.toHaveBeenCalled() + done() + }, 10) + }) + + it('if it is not "shown", should not call delay callbacks', done => { + const instance = new Backdrop({ + isVisible: false, + isAnimated: true + }) + const spy = jasmine.createSpy('spy', getTransitionDurationFromElement) + + instance.show() + instance.hide(() => { + expect(spy).not.toHaveBeenCalled() + done() + }) + }) + }) +}) diff --git a/js/tests/unit/util/index.spec.js b/js/tests/unit/util/index.spec.js index 5d144348e4..11b6f7fa49 100644 --- a/js/tests/unit/util/index.spec.js +++ b/js/tests/unit/util/index.spec.js @@ -568,4 +568,12 @@ describe('Util', () => { expect(typeof fakejQuery.fn.test.noConflict).toEqual('function') }) }) + + describe('execute', () => { + it('should execute if arg is function', () => { + const spy = jasmine.createSpy('spy') + Util.execute(spy) + expect(spy).toHaveBeenCalled() + }) + }) })