From 7957d14de9bdac3bf1fec44bd7f1047879d43766 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Mon, 3 Jul 2017 16:09:28 -0700 Subject: [PATCH 1/9] Add new toasts component --- scss/_toasts.scss | 29 +++++ scss/_variables.scss | 17 ++- scss/bootstrap.scss | 1 + site/_data/nav.yml | 1 + site/docs/4.1/components/toasts.md | 169 +++++++++++++++++++++++++++++ 5 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 scss/_toasts.scss create mode 100644 site/docs/4.1/components/toasts.md diff --git a/scss/_toasts.scss b/scss/_toasts.scss new file mode 100644 index 000000000000..248a21298946 --- /dev/null +++ b/scss/_toasts.scss @@ -0,0 +1,29 @@ +.toast { + max-width: $toast-max-width; + overflow: hidden; // cheap rounded corners on nested items + font-size: $toast-font-size; // knock it down to 14px + background-color: $toast-background-color; + background-clip: padding-box; + border: $toast-border-width solid $toast-border-color; + border-radius: $toast-border-radius; + box-shadow: $toast-box-shadow; + backdrop-filter: blur(10px); + + + .toast { + margin-top: $toast-padding-x; + } +} + +.toast-header { + display: flex; + align-items: center; + padding: $toast-padding-y $toast-padding-x; + color: $toast-header-color; + background-color: $toast-header-background-color; + background-clip: padding-box; + border-bottom: $toast-border-width solid $toast-header-border-color; +} + +.toast-body { + padding: $toast-padding-x; // apply to both vertical and horizontal +} diff --git a/scss/_variables.scss b/scss/_variables.scss index c636860bdadc..ce958b3c42bc 100644 --- a/scss/_variables.scss +++ b/scss/_variables.scss @@ -3,7 +3,6 @@ // Variables should follow the `$component-state-property-size` formula for // consistent naming. Ex: $nav-link-disabled-color and $modal-content-box-shadow-xs. - // Color system $white: #fff !default; @@ -860,6 +859,22 @@ $popover-arrow-color: $popover-bg !default; $popover-arrow-outer-color: fade-in($popover-border-color, .05) !default; +// Toasts +$toast-max-width: 350px !default; +$toast-padding-x: .75rem !default; +$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-radius: .25rem !default; +$toast-box-shadow: 0 .25rem .75rem rgba($black, .1) !default; + +$toast-header-color: $gray-600 !default; +$toast-header-background-color: rgba($white, .85) !default; +$toast-header-border-color: rgba(0, 0, 0, .05) !default; + + // Badges $badge-font-size: 75% !default; diff --git a/scss/bootstrap.scss b/scss/bootstrap.scss index 6f7e4eef15bf..c9795108c695 100644 --- a/scss/bootstrap.scss +++ b/scss/bootstrap.scss @@ -34,6 +34,7 @@ @import "media"; @import "list-group"; @import "close"; +@import "toasts"; @import "modal"; @import "tooltip"; @import "popover"; diff --git a/site/_data/nav.yml b/site/_data/nav.yml index cb0defd890f4..7b6ff7cd977e 100644 --- a/site/_data/nav.yml +++ b/site/_data/nav.yml @@ -50,6 +50,7 @@ - title: Progress - title: Scrollspy - title: Spinners + - title: Toasts - title: Tooltips - title: Utilities diff --git a/site/docs/4.1/components/toasts.md b/site/docs/4.1/components/toasts.md new file mode 100644 index 000000000000..df5d2e827e91 --- /dev/null +++ b/site/docs/4.1/components/toasts.md @@ -0,0 +1,169 @@ +--- +layout: docs +title: Toasts +description: Push notifications to your visitors with a toast, a lightweight and easily customizable alert message. +group: components +toc: true +--- + +Toasts are lightweight notifications designed to mimic the push notifications that have been popularized by mobile and desktop operating systems. They're built with flexbox, so they're easy to align and position. + +## Examples + +A basic toast can include a header (though it doesn't strictly need one) with whatever contents you like. The header is also `display: flex`, so `.mr-auto` and `.ml-auto` can be used for easy pushing of content, as well as all our flexbox utilities. + +
+{% capture example %} +
+
+ + Bootstrap + 11 mins ago +
+
+ Hello, world! This is a toast message. +
+
+{% endcapture %} +{% include example.html content=example %} +
+ +They're slightly translucent, too, so they blend over whatever they might appear over. For browsers that support `backdrop-filter`, we'll also attempt to blur the elements under a toast. + +
+{% capture example %} +
+
+ + Bootstrap + 11 mins ago +
+
+ Hello, world! This is a toast message. +
+
+{% endcapture %} +{% include example.html content=example %} +
+ +Plus, they'll easily stack. + +
+{% capture example %} +
+
+ + Bootstrap + just now +
+
+ See? Just like this. +
+
+ +
+
+ + Bootstrap + 2 seconds ago +
+
+ Heads up, toasts will stack automatically +
+
+{% endcapture %} +{% include example.html content=example %} +
+ +## Accessibility + +Toasts are intended to be small interruptions to your visitors or users, so to help those on screen readers, you should wrap your toasts in an [`aria-live` region](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions). This allows screen readers the ability to see suggested interruptions without any visual cues. + +{% highlight html %} +
+
...
+
+{% endhighlight %} + +## Placement + +Place toasts with custom CSS as you need them. The top right is often used for notifications, as is the top middle. If you're only ever going to show one toast at a time, put the positioning styles right on the `.toast.` + +
+{% capture example %} +
+
+
+ + Bootstrap + 11 mins ago +
+
+ Hello, world! This is a toast message. +
+
+
+{% endcapture %} +{% include example.html content=example %} +
+ +For systems that generate more notifications, consider using a wrapping element so they can easily stack. + +
+{% capture example %} +
+ +
+ + +
+
+ + Bootstrap + just now +
+
+ See? Just like this. +
+
+ +
+
+ + Bootstrap + 2 seconds ago +
+
+ Heads up, toasts will stack automatically +
+
+
+
+{% endcapture %} +{% include example.html content=example %} +
+ +You can also get fancy with flexbox utilities. + +
+{% capture example html %} +
+ +
+ + +
+
+ + Bootstrap + 11 mins ago +
+
+ Hello, world! This is a toast message. +
+
+
+
+{% endcapture %} +{% include example.html content=example %} +
From 922ca0a053db3539610df889684d5860b32d5692 Mon Sep 17 00:00:00 2001 From: Johann-S Date: Thu, 23 Aug 2018 19:31:25 +0300 Subject: [PATCH 2/9] 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 1de65b426dc0..ec337f03ee12 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 580562907f1a..6d99ff3918f6 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 000000000000..cb6de974b5cc --- /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 1bcdc5380e2e..06bfa2c43439 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 a7fa64af0edf..7a3b99ead0fa 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 000000000000..873661c76f63 --- /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 000000000000..0daf8b521c16 --- /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 a91e04f9fdeb..9e3a7bbf0f07 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 248a21298946..5ec9cab43ae6 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 ce958b3c42bc..86d55c8f3b83 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; From 87642b4369183fb897471d68455d699d48095592 Mon Sep 17 00:00:00 2001 From: Johann-S Date: Thu, 23 Aug 2018 21:06:35 +0200 Subject: [PATCH 3/9] Fix toast documentation page. --- js/tests/visual/toast.html | 6 +- site/docs/4.1/assets/js/src/application.js | 6 + site/docs/4.1/components/toasts.md | 123 +++++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) diff --git a/js/tests/visual/toast.html b/js/tests/visual/toast.html index 0daf8b521c16..6897022c06ed 100644 --- a/js/tests/visual/toast.html +++ b/js/tests/visual/toast.html @@ -26,7 +26,7 @@

Toast Bootstrap Visual Test

-
+
Bootstrap @@ -54,6 +54,10 @@

Toast Bootstrap Visual Test