diff options
| author | Johann-S <[email protected]> | 2019-05-01 15:43:40 +0200 |
|---|---|---|
| committer | Johann-S <[email protected]> | 2019-07-23 14:23:50 +0200 |
| commit | 1ac07a66ce56e6ee3d2b48a649e957417f56c83e (patch) | |
| tree | c255b2c904d40986adaf799c0ad87e986936c9f5 /js/src/modal | |
| parent | e916a9bc03ba916d10f9deef630f810fee918bef (diff) | |
| download | bootstrap-1ac07a66ce56e6ee3d2b48a649e957417f56c83e.tar.xz bootstrap-1ac07a66ce56e6ee3d2b48a649e957417f56c83e.zip | |
rewrite modal unit tests
Diffstat (limited to 'js/src/modal')
| -rw-r--r-- | js/src/modal/modal.js | 602 | ||||
| -rw-r--r-- | js/src/modal/modal.spec.js | 974 |
2 files changed, 1576 insertions, 0 deletions
diff --git a/js/src/modal/modal.js b/js/src/modal/modal.js new file mode 100644 index 000000000..4c430a01f --- /dev/null +++ b/js/src/modal/modal.js @@ -0,0 +1,602 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap (v4.3.1): modal.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + +import { + jQuery as $, + TRANSITION_END, + emulateTransitionEnd, + getSelectorFromElement, + getTransitionDurationFromElement, + isVisible, + makeArray, + reflow, + typeCheckConfig +} from '../util/index' +import Data from '../dom/data' +import EventHandler from '../dom/event-handler' +import Manipulator from '../dom/manipulator' +import SelectorEngine from '../dom/selector-engine' + +/** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + +const NAME = 'modal' +const VERSION = '4.3.1' +const DATA_KEY = 'bs.modal' +const EVENT_KEY = `.${DATA_KEY}` +const DATA_API_KEY = '.data-api' +const ESCAPE_KEYCODE = 27 // KeyboardEvent.which value for Escape (Esc) key + +const Default = { + backdrop: true, + keyboard: true, + focus: true, + show: true +} + +const DefaultType = { + backdrop: '(boolean|string)', + keyboard: 'boolean', + focus: 'boolean', + show: 'boolean' +} + +const Event = { + HIDE: `hide${EVENT_KEY}`, + HIDDEN: `hidden${EVENT_KEY}`, + SHOW: `show${EVENT_KEY}`, + SHOWN: `shown${EVENT_KEY}`, + FOCUSIN: `focusin${EVENT_KEY}`, + RESIZE: `resize${EVENT_KEY}`, + CLICK_DISMISS: `click.dismiss${EVENT_KEY}`, + KEYDOWN_DISMISS: `keydown.dismiss${EVENT_KEY}`, + MOUSEUP_DISMISS: `mouseup.dismiss${EVENT_KEY}`, + MOUSEDOWN_DISMISS: `mousedown.dismiss${EVENT_KEY}`, + CLICK_DATA_API: `click${EVENT_KEY}${DATA_API_KEY}` +} + +const ClassName = { + SCROLLABLE: 'modal-dialog-scrollable', + SCROLLBAR_MEASURER: 'modal-scrollbar-measure', + BACKDROP: 'modal-backdrop', + OPEN: 'modal-open', + FADE: 'fade', + SHOW: 'show' +} + +const Selector = { + DIALOG: '.modal-dialog', + MODAL_BODY: '.modal-body', + DATA_TOGGLE: '[data-toggle="modal"]', + DATA_DISMISS: '[data-dismiss="modal"]', + FIXED_CONTENT: '.fixed-top, .fixed-bottom, .is-fixed, .sticky-top', + STICKY_CONTENT: '.sticky-top' +} + +/** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + +class Modal { + constructor(element, config) { + this._config = this._getConfig(config) + this._element = element + this._dialog = SelectorEngine.findOne(Selector.DIALOG, element) + this._backdrop = null + this._isShown = false + this._isBodyOverflowing = false + this._ignoreBackdropClick = false + this._isTransitioning = false + this._scrollbarWidth = 0 + Data.setData(element, DATA_KEY, this) + } + + // Getters + + static get VERSION() { + return VERSION + } + + static get Default() { + return Default + } + + // Public + + toggle(relatedTarget) { + return this._isShown ? this.hide() : this.show(relatedTarget) + } + + show(relatedTarget) { + if (this._isShown || this._isTransitioning) { + return + } + + if (this._element.classList.contains(ClassName.FADE)) { + this._isTransitioning = true + } + + const showEvent = EventHandler.trigger(this._element, Event.SHOW, { + relatedTarget + }) + + if (this._isShown || showEvent.defaultPrevented) { + return + } + + this._isShown = true + + this._checkScrollbar() + this._setScrollbar() + + this._adjustDialog() + + this._setEscapeEvent() + this._setResizeEvent() + + EventHandler.on(this._element, + Event.CLICK_DISMISS, + Selector.DATA_DISMISS, + event => this.hide(event) + ) + + EventHandler.on(this._dialog, Event.MOUSEDOWN_DISMISS, () => { + EventHandler.one(this._element, Event.MOUSEUP_DISMISS, event => { + if (event.target === this._element) { + this._ignoreBackdropClick = true + } + }) + }) + + this._showBackdrop(() => this._showElement(relatedTarget)) + } + + hide(event) { + if (event) { + event.preventDefault() + } + + if (!this._isShown || this._isTransitioning) { + return + } + + const hideEvent = EventHandler.trigger(this._element, Event.HIDE) + + if (hideEvent.defaultPrevented) { + return + } + + this._isShown = false + const transition = this._element.classList.contains(ClassName.FADE) + + if (transition) { + this._isTransitioning = true + } + + this._setEscapeEvent() + this._setResizeEvent() + + EventHandler.off(document, Event.FOCUSIN) + + this._element.classList.remove(ClassName.SHOW) + + EventHandler.off(this._element, Event.CLICK_DISMISS) + EventHandler.off(this._dialog, Event.MOUSEDOWN_DISMISS) + + if (transition) { + const transitionDuration = getTransitionDurationFromElement(this._element) + + EventHandler.one(this._element, TRANSITION_END, event => this._hideModal(event)) + emulateTransitionEnd(this._element, transitionDuration) + } else { + this._hideModal() + } + } + + dispose() { + [window, this._element, this._dialog] + .forEach(htmlElement => EventHandler.off(htmlElement, EVENT_KEY)) + + /** + * `document` has 2 events `Event.FOCUSIN` and `Event.CLICK_DATA_API` + * Do not move `document` in `htmlElements` array + * It will remove `Event.CLICK_DATA_API` event that should remain + */ + EventHandler.off(document, Event.FOCUSIN) + + Data.removeData(this._element, DATA_KEY) + + this._config = null + this._element = null + this._dialog = null + this._backdrop = null + this._isShown = null + this._isBodyOverflowing = null + this._ignoreBackdropClick = null + this._isTransitioning = null + this._scrollbarWidth = null + } + + handleUpdate() { + this._adjustDialog() + } + + // Private + + _getConfig(config) { + config = { + ...Default, + ...config + } + typeCheckConfig(NAME, config, DefaultType) + return config + } + + _showElement(relatedTarget) { + const transition = this._element.classList.contains(ClassName.FADE) + + if (!this._element.parentNode || + this._element.parentNode.nodeType !== Node.ELEMENT_NODE) { + // Don't move modal's DOM position + document.body.appendChild(this._element) + } + + this._element.style.display = 'block' + this._element.removeAttribute('aria-hidden') + this._element.setAttribute('aria-modal', true) + + if (this._dialog.classList.contains(ClassName.SCROLLABLE)) { + SelectorEngine.findOne(Selector.MODAL_BODY, this._dialog).scrollTop = 0 + } else { + this._element.scrollTop = 0 + } + + if (transition) { + reflow(this._element) + } + + this._element.classList.add(ClassName.SHOW) + + if (this._config.focus) { + this._enforceFocus() + } + + const transitionComplete = () => { + if (this._config.focus) { + this._element.focus() + } + + this._isTransitioning = false + EventHandler.trigger(this._element, Event.SHOWN, { + relatedTarget + }) + } + + if (transition) { + const transitionDuration = getTransitionDurationFromElement(this._dialog) + + EventHandler.one(this._dialog, TRANSITION_END, transitionComplete) + emulateTransitionEnd(this._dialog, transitionDuration) + } else { + transitionComplete() + } + } + + _enforceFocus() { + EventHandler.off(document, Event.FOCUSIN) // guard against infinite focus loop + EventHandler.on(document, Event.FOCUSIN, event => { + if (document !== event.target && + this._element !== event.target && + !this._element.contains(event.target)) { + this._element.focus() + } + }) + } + + _setEscapeEvent() { + if (this._isShown && this._config.keyboard) { + EventHandler.on(this._element, Event.KEYDOWN_DISMISS, event => { + if (event.which === ESCAPE_KEYCODE) { + event.preventDefault() + this.hide() + } + }) + } else { + EventHandler.off(this._element, Event.KEYDOWN_DISMISS) + } + } + + _setResizeEvent() { + if (this._isShown) { + EventHandler.on(window, Event.RESIZE, () => this._adjustDialog()) + } else { + EventHandler.off(window, Event.RESIZE) + } + } + + _hideModal() { + this._element.style.display = 'none' + this._element.setAttribute('aria-hidden', true) + this._element.removeAttribute('aria-modal') + this._isTransitioning = false + this._showBackdrop(() => { + document.body.classList.remove(ClassName.OPEN) + this._resetAdjustments() + this._resetScrollbar() + EventHandler.trigger(this._element, Event.HIDDEN) + }) + } + + _removeBackdrop() { + this._backdrop.parentNode.removeChild(this._backdrop) + this._backdrop = null + } + + _showBackdrop(callback) { + const animate = this._element.classList.contains(ClassName.FADE) ? + ClassName.FADE : + '' + + if (this._isShown && this._config.backdrop) { + this._backdrop = document.createElement('div') + this._backdrop.className = ClassName.BACKDROP + + if (animate) { + this._backdrop.classList.add(animate) + } + + document.body.appendChild(this._backdrop) + + EventHandler.on(this._element, Event.CLICK_DISMISS, event => { + if (this._ignoreBackdropClick) { + this._ignoreBackdropClick = false + return + } + + if (event.target !== event.currentTarget) { + return + } + + if (this._config.backdrop === 'static') { + this._element.focus() + } else { + this.hide() + } + }) + + if (animate) { + reflow(this._backdrop) + } + + this._backdrop.classList.add(ClassName.SHOW) + + if (!animate) { + callback() + return + } + + const backdropTransitionDuration = getTransitionDurationFromElement(this._backdrop) + + EventHandler.one(this._backdrop, TRANSITION_END, callback) + emulateTransitionEnd(this._backdrop, backdropTransitionDuration) + } else if (!this._isShown && this._backdrop) { + this._backdrop.classList.remove(ClassName.SHOW) + + const callbackRemove = () => { + this._removeBackdrop() + callback() + } + + if (this._element.classList.contains(ClassName.FADE)) { + const backdropTransitionDuration = getTransitionDurationFromElement(this._backdrop) + EventHandler.one(this._backdrop, TRANSITION_END, callbackRemove) + emulateTransitionEnd(this._backdrop, backdropTransitionDuration) + } else { + callbackRemove() + } + } else { + callback() + } + } + + // ---------------------------------------------------------------------- + // the following methods are used to handle overflowing modals + // ---------------------------------------------------------------------- + + _adjustDialog() { + const isModalOverflowing = + this._element.scrollHeight > document.documentElement.clientHeight + + if (!this._isBodyOverflowing && isModalOverflowing) { + this._element.style.paddingLeft = `${this._scrollbarWidth}px` + } + + if (this._isBodyOverflowing && !isModalOverflowing) { + this._element.style.paddingRight = `${this._scrollbarWidth}px` + } + } + + _resetAdjustments() { + this._element.style.paddingLeft = '' + this._element.style.paddingRight = '' + } + + _checkScrollbar() { + const rect = document.body.getBoundingClientRect() + this._isBodyOverflowing = rect.left + rect.right < window.innerWidth + this._scrollbarWidth = this._getScrollbarWidth() + } + + _setScrollbar() { + if (this._isBodyOverflowing) { + // Note: DOMNode.style.paddingRight returns the actual value or '' if not set + // while $(DOMNode).css('padding-right') returns the calculated value or 0 if not set + + // Adjust fixed content padding + makeArray(SelectorEngine.find(Selector.FIXED_CONTENT)) + .forEach(element => { + const actualPadding = element.style.paddingRight + const calculatedPadding = window.getComputedStyle(element)['padding-right'] + Manipulator.setDataAttribute(element, 'padding-right', actualPadding) + element.style.paddingRight = `${parseFloat(calculatedPadding) + this._scrollbarWidth}px` + }) + + // Adjust sticky content margin + makeArray(SelectorEngine.find(Selector.STICKY_CONTENT)) + .forEach(element => { + const actualMargin = element.style.marginRight + const calculatedMargin = window.getComputedStyle(element)['margin-right'] + Manipulator.setDataAttribute(element, 'margin-right', actualMargin) + element.style.marginRight = `${parseFloat(calculatedMargin) - this._scrollbarWidth}px` + }) + + // Adjust body padding + const actualPadding = document.body.style.paddingRight + const calculatedPadding = window.getComputedStyle(document.body)['padding-right'] + + Manipulator.setDataAttribute(document.body, 'padding-right', actualPadding) + document.body.style.paddingRight = `${parseFloat(calculatedPadding) + this._scrollbarWidth}px` + } + + document.body.classList.add(ClassName.OPEN) + } + + _resetScrollbar() { + // Restore fixed content padding + makeArray(SelectorEngine.find(Selector.FIXED_CONTENT)) + .forEach(element => { + const padding = Manipulator.getDataAttribute(element, 'padding-right') + if (typeof padding !== 'undefined') { + Manipulator.removeDataAttribute(element, 'padding-right') + element.style.paddingRight = padding + } + }) + + // Restore sticky content and navbar-toggler margin + makeArray(SelectorEngine.find(`${Selector.STICKY_CONTENT}`)) + .forEach(element => { + const margin = Manipulator.getDataAttribute(element, 'margin-right') + if (typeof margin !== 'undefined') { + Manipulator.removeDataAttribute(element, 'margin-right') + element.style.marginRight = margin + } + }) + + // Restore body padding + const padding = Manipulator.getDataAttribute(document.body, 'padding-right') + if (typeof padding === 'undefined') { + document.body.style.paddingRight = '' + } else { + Manipulator.removeDataAttribute(document.body, 'padding-right') + document.body.style.paddingRight = padding + } + } + + _getScrollbarWidth() { // thx d.walsh + const scrollDiv = document.createElement('div') + scrollDiv.className = ClassName.SCROLLBAR_MEASURER + document.body.appendChild(scrollDiv) + const scrollbarWidth = scrollDiv.getBoundingClientRect().width - scrollDiv.clientWidth + document.body.removeChild(scrollDiv) + return scrollbarWidth + } + + // Static + + static _jQueryInterface(config, relatedTarget) { + return this.each(function () { + let data = Data.getData(this, DATA_KEY) + const _config = { + ...Default, + ...Manipulator.getDataAttributes(this), + ...typeof config === 'object' && config ? config : {} + } + + if (!data) { + data = new Modal(this, _config) + } + + if (typeof config === 'string') { + if (typeof data[config] === 'undefined') { + throw new TypeError(`No method named "${config}"`) + } + + data[config](relatedTarget) + } else if (_config.show) { + data.show(relatedTarget) + } + }) + } + + static _getInstance(element) { + return Data.getData(element, DATA_KEY) + } +} + +/** + * ------------------------------------------------------------------------ + * Data Api implementation + * ------------------------------------------------------------------------ + */ + +EventHandler.on(document, Event.CLICK_DATA_API, Selector.DATA_TOGGLE, function (event) { + const selector = getSelectorFromElement(this) + const target = SelectorEngine.findOne(selector) + + if (this.tagName === 'A' || this.tagName === 'AREA') { + event.preventDefault() + } + + EventHandler.one(target, Event.SHOW, showEvent => { + if (showEvent.defaultPrevented) { + // only register focus restorer if modal will actually get shown + return + } + + EventHandler.one(target, Event.HIDDEN, () => { + if (isVisible(this)) { + this.focus() + } + }) + }) + + let data = Data.getData(target, DATA_KEY) + if (!data) { + const config = { + ...Manipulator.getDataAttributes(target), + ...Manipulator.getDataAttributes(this) + } + + data = new Modal(target, config) + } + + data.show(this) +}) + +/** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + * add .modal to jQuery only if jQuery is present + */ +/* istanbul ignore if */ +if (typeof $ !== 'undefined') { + const JQUERY_NO_CONFLICT = $.fn[NAME] + $.fn[NAME] = Modal._jQueryInterface + $.fn[NAME].Constructor = Modal + $.fn[NAME].noConflict = () => { + $.fn[NAME] = JQUERY_NO_CONFLICT + return Modal._jQueryInterface + } +} + +export default Modal diff --git a/js/src/modal/modal.spec.js b/js/src/modal/modal.spec.js new file mode 100644 index 000000000..f1311c591 --- /dev/null +++ b/js/src/modal/modal.spec.js @@ -0,0 +1,974 @@ +import Modal from './modal' +import EventHandler from '../dom/event-handler' +import { makeArray } from '../util/index' + +/** Test helpers */ +import { getFixture, clearFixture, createEvent, jQueryMock } from '../../tests/helpers/fixture' + +describe('Modal', () => { + let fixtureEl + let style + + beforeAll(() => { + fixtureEl = getFixture() + + // Enable the scrollbar measurer + const css = '.modal-scrollbar-measure { position: absolute; top: -9999px; width: 50px; height: 50px; overflow: scroll; }' + + style = document.createElement('style') + style.type = 'text/css' + style.appendChild(document.createTextNode(css)) + + document.head.appendChild(style) + + // Simulate scrollbars + document.documentElement.style.paddingRight = '16px' + }) + + afterEach(() => { + clearFixture() + + document.body.classList.remove('modal-open') + document.body.removeAttribute('style') + document.body.removeAttribute('data-padding-right') + const backdropList = makeArray(document.querySelectorAll('.modal-backdrop')) + + backdropList.forEach(backdrop => { + document.body.removeChild(backdrop) + }) + + document.body.style.paddingRight = '0px' + }) + + afterAll(() => { + document.head.removeChild(style) + document.documentElement.style.paddingRight = '0px' + }) + + describe('VERSION', () => { + it('should return plugin version', () => { + expect(Modal.VERSION).toEqual(jasmine.any(String)) + }) + }) + + describe('Default', () => { + it('should return plugin default config', () => { + expect(Modal.Default).toEqual(jasmine.any(Object)) + }) + }) + + describe('toggle', () => { + it('should toggle a modal', done => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + const originalPadding = '0px' + + document.body.style.paddingRight = originalPadding + + modalEl.addEventListener('shown.bs.modal', () => { + expect(document.body.getAttribute('data-padding-right')).toEqual(originalPadding, 'original body padding should be stored in data-padding-right') + modal.toggle() + }) + + modalEl.addEventListener('hidden.bs.modal', () => { + expect(document.body.getAttribute('data-padding-right')).toBeNull() + expect().nothing() + done() + }) + + modal.toggle() + }) + + it('should adjust the inline padding of fixed elements when opening and restore when closing', done => { + fixtureEl.innerHTML = [ + '<div class="fixed-top" style="padding-right: 0px"></div>', + '<div class="modal"><div class="modal-dialog" /></div>' + ].join('') + + const fixedEl = fixtureEl.querySelector('.fixed-top') + const originalPadding = parseInt(window.getComputedStyle(fixedEl).paddingRight, 10) + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + modalEl.addEventListener('shown.bs.modal', () => { + const expectedPadding = originalPadding + modal._getScrollbarWidth() + const currentPadding = parseInt(window.getComputedStyle(modalEl).paddingRight, 10) + + expect(fixedEl.getAttribute('data-padding-right')).toEqual('0px', 'original fixed element padding should be stored in data-padding-right') + expect(currentPadding).toEqual(expectedPadding, 'fixed element padding should be adjusted while opening') + modal.toggle() + }) + + modalEl.addEventListener('hidden.bs.modal', () => { + const currentPadding = parseInt(window.getComputedStyle(modalEl).paddingRight, 10) + + expect(fixedEl.getAttribute('data-padding-right')).toEqual(null, 'data-padding-right should be cleared after closing') + expect(currentPadding).toEqual(originalPadding, 'fixed element padding should be reset after closing') + done() + }) + + modal.toggle() + }) + + it('should adjust the inline margin of sticky elements when opening and restore when closing', done => { + fixtureEl.innerHTML = [ + '<div class="sticky-top" style="margin-right: 0px;"></div>', + '<div class="modal"><div class="modal-dialog" /></div>' + ].join('') + + const stickyTopEl = fixtureEl.querySelector('.sticky-top') + const originalMargin = parseInt(window.getComputedStyle(stickyTopEl).marginRight, 10) + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + modalEl.addEventListener('shown.bs.modal', () => { + const expectedMargin = originalMargin - modal._getScrollbarWidth() + const currentMargin = parseInt(window.getComputedStyle(stickyTopEl).marginRight, 10) + + expect(stickyTopEl.getAttribute('data-margin-right')).toEqual('0px', 'original sticky element margin should be stored in data-margin-right') + expect(currentMargin).toEqual(expectedMargin, 'sticky element margin should be adjusted while opening') + modal.toggle() + }) + + modalEl.addEventListener('hidden.bs.modal', () => { + const currentMargin = parseInt(window.getComputedStyle(stickyTopEl).marginRight, 10) + + expect(stickyTopEl.getAttribute('data-margin-right')).toEqual(null, 'data-margin-right should be cleared after closing') + expect(currentMargin).toEqual(originalMargin, 'sticky element margin should be reset after closing') + done() + }) + + modal.toggle() + }) + + it('should ignore values set via CSS when trying to restore body padding after closing', done => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + const styleTest = document.createElement('style') + + styleTest.type = 'text/css' + styleTest.appendChild(document.createTextNode('body { padding-right: 7px; }')) + document.head.appendChild(styleTest) + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + modalEl.addEventListener('shown.bs.modal', () => { + modal.toggle() + }) + + modalEl.addEventListener('hidden.bs.modal', () => { + expect(window.getComputedStyle(document.body).paddingLeft).toEqual('0px', 'body does not have inline padding set') + document.head.removeChild(styleTest) + done() + }) + + modal.toggle() + }) + + it('should ignore other inline styles when trying to restore body padding after closing', done => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + const styleTest = document.createElement('style') + + styleTest.type = 'text/css' + styleTest.appendChild(document.createTextNode('body { padding-right: 7px; }')) + + document.head.appendChild(styleTest) + document.body.style.color = 'red' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + modalEl.addEventListener('shown.bs.modal', () => { + modal.toggle() + }) + + modalEl.addEventListener('hidden.bs.modal', () => { + const bodyPaddingRight = document.body.style.paddingRight + + expect(bodyPaddingRight === '0px' || bodyPaddingRight === '').toEqual(true, 'body does not have inline padding set') + expect(document.body.style.color).toEqual('red', 'body still has other inline styles set') + document.head.removeChild(styleTest) + document.body.removeAttribute('style') + done() + }) + + modal.toggle() + }) + + it('should properly restore non-pixel inline body padding after closing', done => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + document.body.style.paddingRight = '5%' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + modalEl.addEventListener('shown.bs.modal', () => { + modal.toggle() + }) + + modalEl.addEventListener('hidden.bs.modal', () => { + expect(document.body.style.paddingRight).toEqual('5%') + document.body.removeAttribute('style') + done() + }) + + modal.toggle() + }) + }) + + describe('show', () => { + it('should show a modal', done => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + modalEl.addEventListener('show.bs.modal', e => { + expect(e).toBeDefined() + }) + + modalEl.addEventListener('shown.bs.modal', () => { + expect(modalEl.getAttribute('aria-modal')).toEqual('true') + expect(modalEl.getAttribute('aria-hidden')).toEqual(null) + expect(modalEl.style.display).toEqual('block') + expect(document.querySelector('.modal-backdrop')).toBeDefined() + done() + }) + + modal.show() + }) + + it('should show a modal without backdrop', done => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl, { + backdrop: false + }) + + modalEl.addEventListener('show.bs.modal', e => { + expect(e).toBeDefined() + }) + + modalEl.addEventListener('shown.bs.modal', () => { + expect(modalEl.getAttribute('aria-modal')).toEqual('true') + expect(modalEl.getAttribute('aria-hidden')).toEqual(null) + expect(modalEl.style.display).toEqual('block') + expect(document.querySelector('.modal-backdrop')).toBeNull() + done() + }) + + modal.show() + }) + + it('should show a modal and append the element', done => { + const modalEl = document.createElement('div') + const id = 'dynamicModal' + + modalEl.setAttribute('id', id) + modalEl.classList.add('modal') + modalEl.innerHTML = '<div class="modal-dialog"></div>' + + const modal = new Modal(modalEl) + + modalEl.addEventListener('shown.bs.modal', () => { + const dynamicModal = document.getElementById(id) + expect(dynamicModal).toBeDefined() + dynamicModal.parentNode.removeChild(dynamicModal) + done() + }) + + modal.show() + }) + + it('should do nothing if a modal is shown', () => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + spyOn(EventHandler, 'trigger') + modal._isShown = true + + modal.show() + + expect(EventHandler.trigger).not.toHaveBeenCalled() + }) + + it('should do nothing if a modal is transitioning', () => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + spyOn(EventHandler, 'trigger') + modal._isTransitioning = true + + modal.show() + + expect(EventHandler.trigger).not.toHaveBeenCalled() + }) + + it('should not fire shown event when show is prevented', done => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + modalEl.addEventListener('show.bs.modal', e => { + e.preventDefault() + + const expectedDone = () => { + expect().nothing() + done() + } + + setTimeout(expectedDone, 10) + }) + + modalEl.addEventListener('shown.bs.modal', () => { + throw new Error('shown event triggered') + }) + + modal.show() + }) + + it('should set is transitioning if fade class is present', done => { + fixtureEl.innerHTML = '<div class="modal fade"><div class="modal-dialog" /></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + modalEl.addEventListener('show.bs.modal', () => { + expect(modal._isTransitioning).toEqual(true) + }) + + modalEl.addEventListener('shown.bs.modal', () => { + expect(modal._isTransitioning).toEqual(false) + done() + }) + + modal.show() + }) + + it('should close modal when a click occured on data-dismiss="modal"', done => { + fixtureEl.innerHTML = [ + '<div class="modal fade">', + ' <div class="modal-dialog">', + ' <div class="modal-header">', + ' <button type="button" data-dismiss="modal"></button>', + ' </div>', + ' </div>', + '</div>' + ].join('') + + const modalEl = fixtureEl.querySelector('.modal') + const btnClose = fixtureEl.querySelector('[data-dismiss="modal"]') + const modal = new Modal(modalEl) + + spyOn(modal, 'hide').and.callThrough() + + modalEl.addEventListener('shown.bs.modal', () => { + btnClose.click() + }) + + modalEl.addEventListener('hidden.bs.modal', () => { + expect(modal.hide).toHaveBeenCalled() + done() + }) + + modal.show() + }) + + it('should set modal body scroll top to 0 if .modal-dialog-scrollable', done => { + fixtureEl.innerHTML = [ + '<div class="modal fade">', + ' <div class="modal-dialog modal-dialog-scrollable">', + ' <div class="modal-body"></div>', + ' </div>', + '</div>' + ].join('') + + const modalEl = fixtureEl.querySelector('.modal') + const modalBody = modalEl.querySelector('.modal-body') + const modal = new Modal(modalEl) + + spyOn(modal, 'hide').and.callThrough() + + modalEl.addEventListener('shown.bs.modal', () => { + expect(modalBody.scrollTop).toEqual(0) + done() + }) + + modal.show() + }) + + it('should not enforce focus if focus equal to false', done => { + fixtureEl.innerHTML = '<div class="modal fade"><div class="modal-dialog" /></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl, { + focus: false + }) + + spyOn(modal, '_enforceFocus') + + modalEl.addEventListener('shown.bs.modal', () => { + expect(modal._enforceFocus).not.toHaveBeenCalled() + done() + }) + + modal.show() + }) + + it('should add listener when escape touch is pressed', done => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + spyOn(modal, 'hide').and.callThrough() + + modalEl.addEventListener('shown.bs.modal', () => { + const keydownEscape = createEvent('keydown') + keydownEscape.which = 27 + + modalEl.dispatchEvent(keydownEscape) + }) + + modalEl.addEventListener('hidden.bs.modal', () => { + expect(modal.hide).toHaveBeenCalled() + done() + }) + + modal.show() + }) + + it('should do nothing when the pressed key is not escape', done => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + spyOn(modal, 'hide') + + const expectDone = () => { + expect(modal.hide).not.toHaveBeenCalled() + + done() + } + + modalEl.addEventListener('shown.bs.modal', () => { + const keydownTab = createEvent('keydown') + keydownTab.which = 9 + + modalEl.dispatchEvent(keydownTab) + setTimeout(expectDone, 30) + }) + + modal.show() + }) + + it('should adjust dialog on resize', done => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + spyOn(modal, '_adjustDialog').and.callThrough() + + const expectDone = () => { + expect(modal._adjustDialog).toHaveBeenCalled() + + done() + } + + modalEl.addEventListener('shown.bs.modal', () => { + const resizeEvent = createEvent('resize') + + window.dispatchEvent(resizeEvent) + setTimeout(expectDone, 10) + }) + + modal.show() + }) + + it('should not close modal when clicking outside of modal-content if backdrop = false', done => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl, { + backdrop: false + }) + + const shownCallback = () => { + setTimeout(() => { + expect(modal._isShown).toEqual(true) + done() + }, 10) + } + + modalEl.addEventListener('shown.bs.modal', () => { + modalEl.click() + shownCallback() + }) + + modalEl.addEventListener('hidden.bs.modal', () => { + throw new Error('Should not hide a modal') + }) + + modal.show() + }) + + it('should not adjust the inline body padding when it does not overflow', done => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + const originalPadding = window.getComputedStyle(document.body).paddingRight + + // Hide scrollbars to prevent the body overflowing + document.body.style.overflow = 'hidden' + document.documentElement.style.paddingRight = '0px' + + modalEl.addEventListener('shown.bs.modal', () => { + const currentPadding = window.getComputedStyle(document.body).paddingRight + + expect(currentPadding).toEqual(originalPadding, 'body padding should not be adjusted') + + // Restore scrollbars + document.body.style.overflow = 'auto' + document.documentElement.style.paddingRight = '16px' + done() + }) + + modal.show() + }) + + it('should enforce focus', done => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const isIE11 = Boolean(window.MSInputMethodContext) && Boolean(document.documentMode) + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + spyOn(modal, '_enforceFocus').and.callThrough() + + const focusInListener = () => { + expect(modal._element.focus).toHaveBeenCalled() + document.removeEventListener('focusin', focusInListener) + done() + } + + modalEl.addEventListener('shown.bs.modal', () => { + expect(modal._enforceFocus).toHaveBeenCalled() + + if (isIE11) { + done() + return + } + + spyOn(modal._element, 'focus') + + document.addEventListener('focusin', focusInListener) + + const focusInEvent = createEvent('focusin', { bubbles: true }) + Object.defineProperty(focusInEvent, 'target', { + value: fixtureEl + }) + + document.dispatchEvent(focusInEvent) + }) + + modal.show() + }) + }) + + describe('hide', () => { + it('should hide a modal', done => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + modalEl.addEventListener('shown.bs.modal', () => { + modal.hide() + }) + + modalEl.addEventListener('hide.bs.modal', e => { + expect(e).toBeDefined() + }) + + modalEl.addEventListener('hidden.bs.modal', () => { + expect(modalEl.getAttribute('aria-modal')).toEqual(null) + expect(modalEl.getAttribute('aria-hidden')).toEqual('true') + expect(modalEl.style.display).toEqual('none') + expect(document.querySelector('.modal-backdrop')).toBeNull() + done() + }) + + modal.show() + }) + + it('should close modal when clicking outside of modal-content', done => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + modalEl.addEventListener('shown.bs.modal', () => { + modalEl.click() + }) + + modalEl.addEventListener('hidden.bs.modal', () => { + expect(modalEl.getAttribute('aria-modal')).toEqual(null) + expect(modalEl.getAttribute('aria-hidden')).toEqual('true') + expect(modalEl.style.display).toEqual('none') + expect(document.querySelector('.modal-backdrop')).toBeNull() + done() + }) + + modal.show() + }) + + it('should do nothing is the modal is not shown', () => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + modal.hide() + + expect().nothing() + }) + + it('should do nothing is the modal is transitioning', () => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + modal._isTransitioning = true + modal.hide() + + expect().nothing() + }) + + it('should not hide a modal if hide is prevented', done => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + modalEl.addEventListener('shown.bs.modal', () => { + modal.hide() + }) + + const hideCallback = () => { + setTimeout(() => { + expect(modal._isShown).toEqual(true) + done() + }, 10) + } + + modalEl.addEventListener('hide.bs.modal', e => { + e.preventDefault() + hideCallback() + }) + + modalEl.addEventListener('hidden.bs.modal', () => { + throw new Error('should not trigger hidden') + }) + + modal.show() + }) + }) + + describe('dispose', () => { + it('should dispose a modal', () => { + fixtureEl.innerHTML = '<div id="exampleModal" class="modal"><div class="modal-dialog" /></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + expect(Modal._getInstance(modalEl)).toEqual(modal) + + spyOn(EventHandler, 'off') + + modal.dispose() + + expect(Modal._getInstance(modalEl)).toEqual(null) + expect(EventHandler.off).toHaveBeenCalledTimes(4) + }) + }) + + describe('handleUpdate', () => { + it('should call adjust dialog', () => { + fixtureEl.innerHTML = '<div id="exampleModal" class="modal"><div class="modal-dialog" /></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + spyOn(modal, '_adjustDialog') + + modal.handleUpdate() + + expect(modal._adjustDialog).toHaveBeenCalled() + }) + }) + + describe('data-api', () => { + it('should open modal', done => { + fixtureEl.innerHTML = [ + '<button type="button" data-toggle="modal" data-target="#exampleModal"></button>', + '<div id="exampleModal" class="modal"><div class="modal-dialog" /></div>' + ].join('') + + const modalEl = fixtureEl.querySelector('.modal') + const trigger = fixtureEl.querySelector('[data-toggle="modal"]') + + modalEl.addEventListener('shown.bs.modal', () => { + expect(modalEl.getAttribute('aria-modal')).toEqual('true') + expect(modalEl.getAttribute('aria-hidden')).toEqual(null) + expect(modalEl.style.display).toEqual('block') + expect(document.querySelector('.modal-backdrop')).toBeDefined() + done() + }) + + trigger.click() + }) + + it('should not recreate a new modal', done => { + fixtureEl.innerHTML = [ + '<button type="button" data-toggle="modal" data-target="#exampleModal"></button>', + '<div id="exampleModal" class="modal"><div class="modal-dialog" /></div>' + ].join('') + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + const trigger = fixtureEl.querySelector('[data-toggle="modal"]') + + spyOn(modal, 'show').and.callThrough() + + modalEl.addEventListener('shown.bs.modal', () => { + expect(modal.show).toHaveBeenCalled() + done() + }) + + trigger.click() + }) + + it('should prevent default when the trigger is <a> or <area>', done => { + fixtureEl.innerHTML = [ + '<a data-toggle="modal" href="#" data-target="#exampleModal"></a>', + '<div id="exampleModal" class="modal"><div class="modal-dialog" /></div>' + ].join('') + + const modalEl = fixtureEl.querySelector('.modal') + const trigger = fixtureEl.querySelector('[data-toggle="modal"]') + + spyOn(Event.prototype, 'preventDefault').and.callThrough() + + modalEl.addEventListener('shown.bs.modal', () => { + expect(modalEl.getAttribute('aria-modal')).toEqual('true') + expect(modalEl.getAttribute('aria-hidden')).toEqual(null) + expect(modalEl.style.display).toEqual('block') + expect(document.querySelector('.modal-backdrop')).toBeDefined() + expect(Event.prototype.preventDefault).toHaveBeenCalled() + done() + }) + + trigger.click() + }) + + it('should focus the trigger on hide', done => { + fixtureEl.innerHTML = [ + '<a data-toggle="modal" href="#" data-target="#exampleModal"></a>', + '<div id="exampleModal" class="modal"><div class="modal-dialog" /></div>' + ].join('') + + // the element must be displayed, without that activeElement won't change + fixtureEl.style.display = 'block' + + const modalEl = fixtureEl.querySelector('.modal') + const trigger = fixtureEl.querySelector('[data-toggle="modal"]') + + spyOn(trigger, 'focus') + + modalEl.addEventListener('shown.bs.modal', () => { + const modal = Modal._getInstance(modalEl) + + modal.hide() + }) + + const hideListener = () => { + setTimeout(() => { + expect(trigger.focus).toHaveBeenCalled() + fixtureEl.style.display = 'none' + done() + }, 20) + } + + modalEl.addEventListener('hidden.bs.modal', () => { + hideListener() + }) + + trigger.click() + }) + + it('should not focus the trigger if the modal is not visible', done => { + fixtureEl.innerHTML = [ + '<a data-toggle="modal" href="#" data-target="#exampleModal"></a>', + '<div id="exampleModal" class="modal"><div class="modal-dialog" /></div>' + ].join('') + + const modalEl = fixtureEl.querySelector('.modal') + const trigger = fixtureEl.querySelector('[data-toggle="modal"]') + + spyOn(trigger, 'focus') + + modalEl.addEventListener('shown.bs.modal', () => { + const modal = Modal._getInstance(modalEl) + + modal.hide() + }) + + const hideListener = () => { + setTimeout(() => { + expect(trigger.focus).not.toHaveBeenCalled() + done() + }, 20) + } + + modalEl.addEventListener('hidden.bs.modal', () => { + hideListener() + }) + + trigger.click() + }) + + it('should not focus the trigger if the modal is not shown', done => { + fixtureEl.innerHTML = [ + '<a data-toggle="modal" href="#" data-target="#exampleModal"></a>', + '<div id="exampleModal" class="modal"><div class="modal-dialog" /></div>' + ].join('') + + const modalEl = fixtureEl.querySelector('.modal') + const trigger = fixtureEl.querySelector('[data-toggle="modal"]') + + spyOn(trigger, 'focus') + + const showListener = () => { + setTimeout(() => { + expect(trigger.focus).not.toHaveBeenCalled() + done() + }, 10) + } + + modalEl.addEventListener('show.bs.modal', e => { + e.preventDefault() + showListener() + }) + + trigger.click() + }) + }) + + describe('_jQueryInterface', () => { + it('should create a modal', () => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const div = fixtureEl.querySelector('div') + + jQueryMock.fn.modal = Modal._jQueryInterface + jQueryMock.elements = [div] + + jQueryMock.fn.modal.call(jQueryMock) + + expect(Modal._getInstance(div)).toBeDefined() + }) + + it('should not re create a modal', () => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const div = fixtureEl.querySelector('div') + const modal = new Modal(div) + + jQueryMock.fn.modal = Modal._jQueryInterface + jQueryMock.elements = [div] + + jQueryMock.fn.modal.call(jQueryMock) + + expect(Modal._getInstance(div)).toEqual(modal) + }) + + it('should throw error on undefined method', () => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const div = fixtureEl.querySelector('div') + const action = 'undefinedMethod' + + jQueryMock.fn.modal = Modal._jQueryInterface + jQueryMock.elements = [div] + + try { + jQueryMock.fn.modal.call(jQueryMock, action) + } catch (error) { + expect(error.message).toEqual(`No method named "${action}"`) + } + }) + + it('should should call show method', () => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const div = fixtureEl.querySelector('div') + const modal = new Modal(div) + + jQueryMock.fn.modal = Modal._jQueryInterface + jQueryMock.elements = [div] + + spyOn(modal, 'show') + + jQueryMock.fn.modal.call(jQueryMock, 'show') + + expect(modal.show).toHaveBeenCalled() + }) + + it('should should not call show method', () => { + fixtureEl.innerHTML = '<div class="modal" data-show="false"><div class="modal-dialog" /></div>' + + const div = fixtureEl.querySelector('div') + + jQueryMock.fn.modal = Modal._jQueryInterface + jQueryMock.elements = [div] + + spyOn(Modal.prototype, 'show') + + jQueryMock.fn.modal.call(jQueryMock) + + expect(Modal.prototype.show).not.toHaveBeenCalled() + }) + }) + + describe('_getInstance', () => { + it('should return modal instance', () => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const div = fixtureEl.querySelector('div') + const modal = new Modal(div) + + expect(Modal._getInstance(div)).toEqual(modal) + }) + + it('should return null when there is no modal instance', () => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>' + + const div = fixtureEl.querySelector('div') + + expect(Modal._getInstance(div)).toEqual(null) + }) + }) +}) |
