diff options
Diffstat (limited to 'js/src/toast')
-rw-r--r-- | js/src/toast/toast.js | 240 | ||||
-rw-r--r-- | js/src/toast/toast.spec.js | 374 |
2 files changed, 614 insertions, 0 deletions
diff --git a/js/src/toast/toast.js b/js/src/toast/toast.js new file mode 100644 index 0000000000..3ed02561a8 --- /dev/null +++ b/js/src/toast/toast.js @@ -0,0 +1,240 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap (v4.3.1): toast.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + +import { + jQuery as $, + TRANSITION_END, + emulateTransitionEnd, + getTransitionDurationFromElement, + typeCheckConfig +} from '../util/index' +import Data from '../dom/data' +import EventHandler from '../dom/event-handler' +import Manipulator from '../dom/manipulator' + +/** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + +const NAME = 'toast' +const VERSION = '4.3.1' +const DATA_KEY = 'bs.toast' +const EVENT_KEY = `.${DATA_KEY}` + +const Event = { + CLICK_DISMISS: `click.dismiss${EVENT_KEY}`, + HIDE: `hide${EVENT_KEY}`, + HIDDEN: `hidden${EVENT_KEY}`, + SHOW: `show${EVENT_KEY}`, + SHOWN: `shown${EVENT_KEY}` +} + +const ClassName = { + FADE: 'fade', + HIDE: 'hide', + SHOW: 'show', + SHOWING: 'showing' +} + +const DefaultType = { + animation: 'boolean', + autohide: 'boolean', + delay: 'number' +} + +const Default = { + animation: true, + autohide: true, + delay: 500 +} + +const Selector = { + DATA_DISMISS: '[data-dismiss="toast"]' +} + +/** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + +class Toast { + constructor(element, config) { + this._element = element + this._config = this._getConfig(config) + this._timeout = null + this._setListeners() + Data.setData(element, DATA_KEY, this) + } + + // Getters + + static get VERSION() { + return VERSION + } + + static get DefaultType() { + return DefaultType + } + + static get Default() { + return Default + } + + // Public + + show() { + const showEvent = EventHandler.trigger(this._element, Event.SHOW) + + if (showEvent.defaultPrevented) { + return + } + + if (this._config.animation) { + this._element.classList.add(ClassName.FADE) + } + + const complete = () => { + this._element.classList.remove(ClassName.SHOWING) + this._element.classList.add(ClassName.SHOW) + + EventHandler.trigger(this._element, Event.SHOWN) + + if (this._config.autohide) { + this._timeout = setTimeout(() => { + this.hide() + }, this._config.delay) + } + } + + this._element.classList.remove(ClassName.HIDE) + this._element.classList.add(ClassName.SHOWING) + if (this._config.animation) { + const transitionDuration = getTransitionDurationFromElement(this._element) + + EventHandler.one(this._element, TRANSITION_END, complete) + emulateTransitionEnd(this._element, transitionDuration) + } else { + complete() + } + } + + hide() { + if (!this._element.classList.contains(ClassName.SHOW)) { + return + } + + const hideEvent = EventHandler.trigger(this._element, Event.HIDE) + + if (hideEvent.defaultPrevented) { + return + } + + const complete = () => { + this._element.classList.add(ClassName.HIDE) + EventHandler.trigger(this._element, Event.HIDDEN) + } + + this._element.classList.remove(ClassName.SHOW) + if (this._config.animation) { + const transitionDuration = getTransitionDurationFromElement(this._element) + + EventHandler.one(this._element, TRANSITION_END, complete) + emulateTransitionEnd(this._element, transitionDuration) + } else { + complete() + } + } + + dispose() { + clearTimeout(this._timeout) + this._timeout = null + + if (this._element.classList.contains(ClassName.SHOW)) { + this._element.classList.remove(ClassName.SHOW) + } + + EventHandler.off(this._element, Event.CLICK_DISMISS) + Data.removeData(this._element, DATA_KEY) + + this._element = null + this._config = null + } + + // Private + + _getConfig(config) { + config = { + ...Default, + ...Manipulator.getDataAttributes(this._element), + ...typeof config === 'object' && config ? config : {} + } + + typeCheckConfig( + NAME, + config, + this.constructor.DefaultType + ) + + return config + } + + _setListeners() { + EventHandler.on( + this._element, + Event.CLICK_DISMISS, + Selector.DATA_DISMISS, + () => this.hide() + ) + } + + // Static + + static _jQueryInterface(config) { + return this.each(function () { + let data = Data.getData(this, DATA_KEY) + const _config = typeof config === 'object' && config + + if (!data) { + data = new Toast(this, _config) + } + + if (typeof config === 'string') { + if (typeof data[config] === 'undefined') { + throw new TypeError(`No method named "${config}"`) + } + + data[config](this) + } + }) + } + + static _getInstance(element) { + return Data.getData(element, DATA_KEY) + } +} + +/** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + * add .toast to jQuery only if jQuery is present + */ +/* istanbul ignore if */ +if (typeof $ !== 'undefined') { + const JQUERY_NO_CONFLICT = $.fn[NAME] + $.fn[NAME] = Toast._jQueryInterface + $.fn[NAME].Constructor = Toast + $.fn[NAME].noConflict = () => { + $.fn[NAME] = JQUERY_NO_CONFLICT + return Toast._jQueryInterface + } +} + +export default Toast diff --git a/js/src/toast/toast.spec.js b/js/src/toast/toast.spec.js new file mode 100644 index 0000000000..dec6313b2d --- /dev/null +++ b/js/src/toast/toast.spec.js @@ -0,0 +1,374 @@ +import Toast from './toast' + +/** Test helpers */ +import { getFixture, clearFixture, jQueryMock } from '../../tests/helpers/fixture' + +describe('Toast', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + }) + + describe('VERSION', () => { + it('should return plugin version', () => { + expect(Toast.VERSION).toEqual(jasmine.any(String)) + }) + }) + + describe('constructor', () => { + it('should allow to config in js', done => { + fixtureEl.innerHTML = [ + '<div class="toast">', + ' <div class="toast-body">', + ' a simple toast', + ' </div>', + '</div>' + ].join('') + + const toastEl = fixtureEl.querySelector('div') + const toast = new Toast(toastEl, { + delay: 1 + }) + + toastEl.addEventListener('shown.bs.toast', () => { + expect(toastEl.classList.contains('show')).toEqual(true) + done() + }) + + toast.show() + }) + + it('should close toast when close element with data-dismiss attribute is set', done => { + fixtureEl.innerHTML = [ + '<div class="toast" data-delay="1" data-autohide="false" data-animation="false">', + ' <button type="button" class="ml-2 mb-1 close" data-dismiss="toast">', + ' close', + ' </button>', + '</div>' + ].join('') + + const toastEl = fixtureEl.querySelector('div') + const toast = new Toast(toastEl) + + toastEl.addEventListener('shown.bs.toast', () => { + expect(toastEl.classList.contains('show')).toEqual(true) + + const button = toastEl.querySelector('.close') + + button.click() + }) + + toastEl.addEventListener('hidden.bs.toast', () => { + expect(toastEl.classList.contains('show')).toEqual(false) + done() + }) + + toast.show() + }) + }) + + describe('Default', () => { + it('should expose default setting to allow to override them', () => { + const defaultDelay = 1000 + + Toast.Default.delay = defaultDelay + + fixtureEl.innerHTML = [ + '<div class="toast" data-autohide="false" data-animation="false">', + ' <button type="button" class="ml-2 mb-1 close" data-dismiss="toast">', + ' close', + ' </button>', + '</div>' + ].join('') + + const toastEl = fixtureEl.querySelector('div') + const toast = new Toast(toastEl) + + expect(toast._config.delay).toEqual(defaultDelay) + }) + }) + + describe('DefaultType', () => { + it('should expose default setting types for read', () => { + expect(Toast.DefaultType).toEqual(jasmine.any(Object)) + }) + }) + + describe('show', () => { + it('should auto hide', done => { + fixtureEl.innerHTML = [ + '<div class="toast" data-delay="1">', + ' <div class="toast-body">', + ' a simple toast', + ' </div>', + '</div>' + ].join('') + + const toastEl = fixtureEl.querySelector('.toast') + const toast = new Toast(toastEl) + + toastEl.addEventListener('hidden.bs.toast', () => { + expect(toastEl.classList.contains('show')).toEqual(false) + done() + }) + + toast.show() + }) + + it('should not add fade class', done => { + fixtureEl.innerHTML = [ + '<div class="toast" data-delay="1" data-animation="false">', + ' <div class="toast-body">', + ' a simple toast', + ' </div>', + '</div>' + ].join('') + + const toastEl = fixtureEl.querySelector('.toast') + const toast = new Toast(toastEl) + + toastEl.addEventListener('shown.bs.toast', () => { + expect(toastEl.classList.contains('fade')).toEqual(false) + done() + }) + + toast.show() + }) + + it('should not trigger shown if show is prevented', done => { + fixtureEl.innerHTML = [ + '<div class="toast" data-delay="1" data-animation="false">', + ' <div class="toast-body">', + ' a simple toast', + ' </div>', + '</div>' + ].join('') + + const toastEl = fixtureEl.querySelector('.toast') + const toast = new Toast(toastEl) + + const assertDone = () => { + setTimeout(() => { + expect(toastEl.classList.contains('show')).toEqual(false) + done() + }, 20) + } + + toastEl.addEventListener('show.bs.toast', event => { + event.preventDefault() + assertDone() + }) + + toastEl.addEventListener('shown.bs.toast', () => { + throw new Error('shown event should not be triggered if show is prevented') + }) + + toast.show() + }) + }) + + describe('hide', () => { + it('should allow to hide toast manually', done => { + fixtureEl.innerHTML = [ + '<div class="toast" data-delay="1" data-autohide="false">', + ' <div class="toast-body">', + ' a simple toast', + ' </div>', + ' </div>' + ].join('') + + const toastEl = fixtureEl.querySelector('.toast') + const toast = new Toast(toastEl) + + toastEl.addEventListener('shown.bs.toast', () => { + toast.hide() + }) + + toastEl.addEventListener('hidden.bs.toast', () => { + expect(toastEl.classList.contains('show')).toEqual(false) + done() + }) + + toast.show() + }) + + it('should do nothing when we call hide on a non shown toast', () => { + fixtureEl.innerHTML = '<div></div>' + + const toastEl = fixtureEl.querySelector('div') + const toast = new Toast(toastEl) + + spyOn(toastEl.classList, 'contains') + + toast.hide() + + expect(toastEl.classList.contains).toHaveBeenCalled() + }) + + it('should not trigger hidden if hide is prevented', done => { + fixtureEl.innerHTML = [ + '<div class="toast" data-delay="1" data-animation="false">', + ' <div class="toast-body">', + ' a simple toast', + ' </div>', + '</div>' + ].join('') + + const toastEl = fixtureEl.querySelector('.toast') + const toast = new Toast(toastEl) + + const assertDone = () => { + setTimeout(() => { + expect(toastEl.classList.contains('show')).toEqual(true) + done() + }, 20) + } + + toastEl.addEventListener('shown.bs.toast', () => { + toast.hide() + }) + + toastEl.addEventListener('hide.bs.toast', event => { + event.preventDefault() + assertDone() + }) + + toastEl.addEventListener('hidden.bs.toast', () => { + throw new Error('hidden event should not be triggered if hide is prevented') + }) + + toast.show() + }) + }) + + describe('dispose', () => { + it('should allow to destroy toast', () => { + fixtureEl.innerHTML = '<div></div>' + + const toastEl = fixtureEl.querySelector('div') + const toast = new Toast(toastEl) + + expect(Toast._getInstance(toastEl)).toBeDefined() + + toast.dispose() + + expect(Toast._getInstance(toastEl)).toBeNull() + }) + + it('should allow to destroy toast and hide it before that', done => { + fixtureEl.innerHTML = [ + '<div class="toast" data-delay="0" data-autohide="false">', + ' <div class="toast-body">', + ' a simple toast', + ' </div>', + '</div>' + ].join('') + + const toastEl = fixtureEl.querySelector('div') + const toast = new Toast(toastEl) + const expected = () => { + expect(toastEl.classList.contains('show')).toEqual(true) + expect(Toast._getInstance(toastEl)).toBeDefined() + + toast.dispose() + + expect(Toast._getInstance(toastEl)).toBeNull() + expect(toastEl.classList.contains('show')).toEqual(false) + + done() + } + + toastEl.addEventListener('shown.bs.toast', () => { + setTimeout(expected, 1) + }) + + toast.show() + }) + }) + + describe('_jQueryInterface', () => { + it('should create a toast', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + jQueryMock.fn.toast = Toast._jQueryInterface + jQueryMock.elements = [div] + + jQueryMock.fn.toast.call(jQueryMock) + + expect(Toast._getInstance(div)).toBeDefined() + }) + + it('should not re create a toast', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const toast = new Toast(div) + + jQueryMock.fn.toast = Toast._jQueryInterface + jQueryMock.elements = [div] + + jQueryMock.fn.toast.call(jQueryMock) + + expect(Toast._getInstance(div)).toEqual(toast) + }) + + it('should call a toast method', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const toast = new Toast(div) + + spyOn(toast, 'show') + + jQueryMock.fn.toast = Toast._jQueryInterface + jQueryMock.elements = [div] + + jQueryMock.fn.toast.call(jQueryMock, 'show') + + expect(Toast._getInstance(div)).toEqual(toast) + expect(toast.show).toHaveBeenCalled() + }) + + it('should throw error on undefined method', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const action = 'undefinedMethod' + + jQueryMock.fn.toast = Toast._jQueryInterface + jQueryMock.elements = [div] + + try { + jQueryMock.fn.toast.call(jQueryMock, action) + } catch (error) { + expect(error.message).toEqual(`No method named "${action}"`) + } + }) + }) + + describe('_getInstance', () => { + it('should return collapse instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const toast = new Toast(div) + + expect(Toast._getInstance(div)).toEqual(toast) + }) + + it('should return null when there is no collapse instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Toast._getInstance(div)).toEqual(null) + }) + }) +}) |