From 16cf76ff1aa20f5b10d782f1170f6a1c36bcae0a Mon Sep 17 00:00:00 2001 From: Johann-S Date: Thu, 23 Aug 2018 19:31:25 +0300 Subject: [PATCH] Create toast JS plugin, add unit tests. --- build/build-plugins.js | 1 + js/src/index.js | 2 + js/src/toast.js | 211 +++++++++++++++++++++++++++++++ js/tests/index.html | 2 + js/tests/unit/.eslintrc.json | 3 +- js/tests/unit/toast.js | 235 +++++++++++++++++++++++++++++++++++ js/tests/visual/toast.html | 69 ++++++++++ package.json | 2 +- scss/_toasts.scss | 5 + scss/_variables.scss | 2 +- 10 files changed, 529 insertions(+), 3 deletions(-) create mode 100644 js/src/toast.js create mode 100644 js/tests/unit/toast.js create mode 100644 js/tests/visual/toast.html diff --git a/build/build-plugins.js b/build/build-plugins.js index 1de65b426d..ec337f03ee 100644 --- a/build/build-plugins.js +++ b/build/build-plugins.js @@ -33,6 +33,7 @@ const bsPlugins = { Popover: path.resolve(__dirname, '../js/src/popover.js'), ScrollSpy: path.resolve(__dirname, '../js/src/scrollspy.js'), Tab: path.resolve(__dirname, '../js/src/tab.js'), + Toast: path.resolve(__dirname, '../js/src/toast.js'), Tooltip: path.resolve(__dirname, '../js/src/tooltip.js'), Util: path.resolve(__dirname, '../js/src/util.js') } diff --git a/js/src/index.js b/js/src/index.js index 580562907f..6d99ff3918 100644 --- a/js/src/index.js +++ b/js/src/index.js @@ -8,6 +8,7 @@ import Modal from './modal' import Popover from './popover' import Scrollspy from './scrollspy' import Tab from './tab' +import Toast from './toast' import Tooltip from './tooltip' import Util from './util' @@ -46,5 +47,6 @@ export { Popover, Scrollspy, Tab, + Toast, Tooltip } diff --git a/js/src/toast.js b/js/src/toast.js new file mode 100644 index 0000000000..cb6de974b5 --- /dev/null +++ b/js/src/toast.js @@ -0,0 +1,211 @@ +import $ from 'jquery' +import Util from './util' + +/** + * -------------------------------------------------------------------------- + * Bootstrap (v4.1.3): toast.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + +const Toast = (($) => { + /** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + + const NAME = 'toast' + const VERSION = '4.1.3' + const DATA_KEY = 'bs.toast' + const EVENT_KEY = `.${DATA_KEY}` + const JQUERY_NO_CONFLICT = $.fn[NAME] + + const Event = { + HIDE : `hide${EVENT_KEY}`, + HIDDEN : `hidden${EVENT_KEY}`, + SHOW : `show${EVENT_KEY}`, + SHOWN : `shown${EVENT_KEY}` + } + + const ClassName = { + FADE : 'fade', + HIDE : 'hide', + SHOW : 'show' + } + + const DefaultType = { + animation : 'boolean', + autohide : 'boolean', + delay : '(number|object)' + } + + const Default = { + animation : true, + autohide : true, + delay : { + show: 0, + hide: 500 + } + } + + /** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + + class Toast { + constructor(element, config) { + this._element = element + this._config = this._getConfig(config) + this._timeout = null + } + + // Getters + + static get VERSION() { + return VERSION + } + + static get DefaultType() { + return DefaultType + } + + // Public + + show() { + $(this._element).trigger(Event.SHOW) + + if (this._config.animation) { + this._element.classList.add(ClassName.FADE) + } + + const complete = () => { + $(this._element).trigger(Event.SHOWN) + + if (this._config.autohide) { + this.hide() + } + } + + this._timeout = setTimeout(() => { + this._element.classList.add(ClassName.SHOW) + + if (this._config.animation) { + const transitionDuration = Util.getTransitionDurationFromElement(this._element) + + $(this._element) + .one(Util.TRANSITION_END, complete) + .emulateTransitionEnd(transitionDuration) + } else { + complete() + } + }, this._config.delay.show) + } + + hide() { + if (!this._element.classList.contains(ClassName.SHOW)) { + return + } + + $(this._element).trigger(Event.HIDE) + + const complete = () => { + $(this._element).trigger(Event.HIDDEN) + } + + this._timeout = setTimeout(() => { + this._element.classList.remove(ClassName.SHOW) + + if (this._config.animation) { + const transitionDuration = Util.getTransitionDurationFromElement(this._element) + + $(this._element) + .one(Util.TRANSITION_END, complete) + .emulateTransitionEnd(transitionDuration) + } else { + complete() + } + }, this._config.delay.hide) + } + + dispose() { + clearTimeout(this._timeout) + this._timeout = null + + if (this._element.classList.contains(ClassName.SHOW)) { + this._element.classList.remove(ClassName.SHOW) + } + + $.removeData(this._element, DATA_KEY) + this._element = null + this._config = null + } + + // Private + + _getConfig(config) { + config = { + ...Default, + ...$(this._element).data(), + ...typeof config === 'object' && config ? config : {} + } + + if (typeof config.delay === 'number') { + config.delay = { + show: config.delay, + hide: config.delay + } + } + + Util.typeCheckConfig( + NAME, + config, + this.constructor.DefaultType + ) + + return config + } + + // Static + + static _jQueryInterface(config) { + return this.each(function () { + const $element = $(this) + let data = $element.data(DATA_KEY) + const _config = typeof config === 'object' && config + + if (!data) { + data = new Toast(this, _config) + $element.data(DATA_KEY, data) + } + + if (typeof config === 'string') { + if (typeof data[config] === 'undefined') { + throw new TypeError(`No method named "${config}"`) + } + + data[config](this) + } + }) + } + } + + /** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + */ + + $.fn[NAME] = Toast._jQueryInterface + $.fn[NAME].Constructor = Toast + $.fn[NAME].noConflict = () => { + $.fn[NAME] = JQUERY_NO_CONFLICT + return Toast._jQueryInterface + } + + return Toast +})($) + +export default Toast diff --git a/js/tests/index.html b/js/tests/index.html index 1bcdc5380e..06bfa2c434 100644 --- a/js/tests/index.html +++ b/js/tests/index.html @@ -120,6 +120,7 @@ + @@ -133,6 +134,7 @@ +
diff --git a/js/tests/unit/.eslintrc.json b/js/tests/unit/.eslintrc.json index a7fa64af0e..7a3b99ead0 100644 --- a/js/tests/unit/.eslintrc.json +++ b/js/tests/unit/.eslintrc.json @@ -11,7 +11,8 @@ "Alert": false, "Button": false, "Carousel": false, - "Simulator": false + "Simulator": false, + "Toast": false }, "parserOptions": { "ecmaVersion": 5, diff --git a/js/tests/unit/toast.js b/js/tests/unit/toast.js new file mode 100644 index 0000000000..873661c76f --- /dev/null +++ b/js/tests/unit/toast.js @@ -0,0 +1,235 @@ +$(function () { + 'use strict' + + if (typeof bootstrap !== 'undefined') { + window.Toast = bootstrap.Toast + } + + QUnit.module('toast plugin') + + QUnit.test('should be defined on jquery object', function (assert) { + assert.expect(1) + assert.ok($(document.body).toast, 'toast method is defined') + }) + + QUnit.module('toast', { + beforeEach: function () { + // Run all tests in noConflict mode -- it's the only way to ensure that the plugin works in noConflict mode + $.fn.bootstrapToast = $.fn.toast.noConflict() + }, + afterEach: function () { + $.fn.toast = $.fn.bootstrapToast + delete $.fn.bootstrapToast + $('#qunit-fixture').html('') + } + }) + + QUnit.test('should provide no conflict', function (assert) { + assert.expect(1) + assert.strictEqual(typeof $.fn.toast, 'undefined', 'toast was set back to undefined (org value)') + }) + + QUnit.test('should return the current version', function (assert) { + assert.expect(1) + assert.strictEqual(typeof Toast.VERSION, 'string') + }) + + QUnit.test('should throw explicit error on undefined method', function (assert) { + assert.expect(1) + var $el = $('
') + $el.bootstrapToast() + + try { + $el.bootstrapToast('noMethod') + } catch (err) { + assert.strictEqual(err.message, 'No method named "noMethod"') + } + }) + + QUnit.test('should return jquery collection containing the element', function (assert) { + assert.expect(2) + + var $el = $('
') + var $toast = $el.bootstrapToast() + assert.ok($toast instanceof $, 'returns jquery collection') + assert.strictEqual($toast[0], $el[0], 'collection contains element') + }) + + QUnit.test('should auto hide', function (assert) { + assert.expect(1) + var done = assert.async() + + var toastHtml = + '
' + + '
' + + 'a simple toast' + + '
' + + '
' + + var $toast = $(toastHtml) + .bootstrapToast() + .appendTo($('#qunit-fixture')) + + $toast.on('hidden.bs.toast', function () { + assert.strictEqual($toast.hasClass('show'), false) + done() + }) + .bootstrapToast('show') + }) + + QUnit.test('should not add fade class', function (assert) { + assert.expect(1) + var done = assert.async() + + var toastHtml = + '
' + + '
' + + 'a simple toast' + + '
' + + '
' + + var $toast = $(toastHtml) + .bootstrapToast() + .appendTo($('#qunit-fixture')) + + $toast.on('shown.bs.toast', function () { + assert.strictEqual($toast.hasClass('fade'), false) + done() + }) + .bootstrapToast('show') + }) + + QUnit.test('should allow to hide toast manually', function (assert) { + assert.expect(1) + var done = assert.async() + + var toastHtml = + '
' + + '
' + + 'a simple toast' + + '
' + + '
' + + var $toast = $(toastHtml) + .bootstrapToast() + .appendTo($('#qunit-fixture')) + + $toast + .on('shown.bs.toast', function () { + $toast.bootstrapToast('hide') + }) + .on('hidden.bs.toast', function () { + assert.strictEqual($toast.hasClass('show'), false) + done() + }) + .bootstrapToast('show') + }) + + QUnit.test('should do nothing when we call hide on a non shown toast', function (assert) { + assert.expect(1) + + var $toast = $('
') + .bootstrapToast() + .appendTo($('#qunit-fixture')) + + var spy = sinon.spy($toast[0].classList, 'contains') + + $toast.bootstrapToast('hide') + + assert.strictEqual(spy.called, true) + }) + + QUnit.test('should allow to destroy toast', function (assert) { + assert.expect(2) + + var $toast = $('
') + .bootstrapToast() + .appendTo($('#qunit-fixture')) + + assert.ok(typeof $toast.data('bs.toast') !== 'undefined') + + $toast.bootstrapToast('dispose') + + assert.ok(typeof $toast.data('bs.toast') === 'undefined') + }) + + QUnit.test('should allow to destroy toast and hide it before that', function (assert) { + assert.expect(4) + var done = assert.async() + + var toastHtml = + '
' + + '
' + + 'a simple toast' + + '
' + + '
' + + var $toast = $(toastHtml) + .bootstrapToast() + .appendTo($('#qunit-fixture')) + + $toast.one('shown.bs.toast', function () { + setTimeout(function () { + assert.ok($toast.hasClass('show')) + assert.ok(typeof $toast.data('bs.toast') !== 'undefined') + + $toast.bootstrapToast('dispose') + + assert.ok(typeof $toast.data('bs.toast') === 'undefined') + assert.ok($toast.hasClass('show') === false) + + done() + }, 1) + }) + .bootstrapToast('show') + }) + + QUnit.test('should allow to pass delay object in html', function (assert) { + assert.expect(1) + var done = assert.async() + + var toastHtml = + '
' + + '
' + + 'a simple toast' + + '
' + + '
' + + var $toast = $(toastHtml) + .bootstrapToast() + .appendTo($('#qunit-fixture')) + + $toast.on('shown.bs.toast', function () { + assert.strictEqual($toast.hasClass('show'), true) + done() + }) + .bootstrapToast('show') + }) + + QUnit.test('should allow to config in js', function (assert) { + assert.expect(1) + var done = assert.async() + + var toastHtml = + '
' + + '
' + + 'a simple toast' + + '
' + + '
' + + var $toast = $(toastHtml) + .bootstrapToast({ + delay: { + show: 0, + hide: 1 + } + }) + .appendTo($('#qunit-fixture')) + + $toast.on('shown.bs.toast', function () { + assert.strictEqual($toast.hasClass('show'), true) + done() + }) + .bootstrapToast('show') + }) +}) diff --git a/js/tests/visual/toast.html b/js/tests/visual/toast.html new file mode 100644 index 0000000000..0daf8b521c --- /dev/null +++ b/js/tests/visual/toast.html @@ -0,0 +1,69 @@ + + + + + + + Toast + + + +
+

Toast Bootstrap Visual Test

+ +
+
+ + +
+
+
+ +
+
+
+ + Bootstrap + 11 mins ago +
+
+ Hello, world! This is a toast message with autohide in 2 seconds +
+
+ +
+
+ + Bootstrap + 2 seconds ago +
+
+ Heads up, toasts will stack automatically +
+
+
+ + + + + + + diff --git a/package.json b/package.json index a91e04f9fd..9e3a7bbf0f 100644 --- a/package.json +++ b/package.json @@ -190,7 +190,7 @@ }, { "path": "./dist/js/bootstrap.js", - "maxSize": "22 kB" + "maxSize": "23 kB" }, { "path": "./dist/js/bootstrap.min.js", diff --git a/scss/_toasts.scss b/scss/_toasts.scss index 248a212989..5ec9cab43a 100644 --- a/scss/_toasts.scss +++ b/scss/_toasts.scss @@ -1,4 +1,5 @@ .toast { + display: none; max-width: $toast-max-width; overflow: hidden; // cheap rounded corners on nested items font-size: $toast-font-size; // knock it down to 14px @@ -14,6 +15,10 @@ } } +.toast.show { + display: inherit; +} + .toast-header { display: flex; align-items: center; diff --git a/scss/_variables.scss b/scss/_variables.scss index ce958b3c42..86d55c8f3b 100644 --- a/scss/_variables.scss +++ b/scss/_variables.scss @@ -866,7 +866,7 @@ $toast-padding-y: .25rem !default; $toast-font-size: .875rem !default; $toast-background-color: rgba($white, .85) !default; $toast-border-width: 1px !default; -$toast-border-color: rgba(0,0,0,.1) !default; +$toast-border-color: rgba(0, 0, 0, .1) !default; $toast-border-radius: .25rem !default; $toast-box-shadow: 0 .25rem .75rem rgba($black, .1) !default;