diff --git a/js/src/toast.ts b/js/src/toast.ts index d8c166a312c1..40937bd6adf5 100644 --- a/js/src/toast.ts +++ b/js/src/toast.ts @@ -8,7 +8,6 @@ import BaseComponent from './base-component.js' import EventHandler, { type BootstrapEvent } from './dom/event-handler.js' import { enableDismissTrigger } from './util/component-functions.js' -import { reflow } from './util/index.js' /** * Constants @@ -27,25 +26,20 @@ const EVENT_HIDDEN = `hidden${EVENT_KEY}` const EVENT_SHOW = `show${EVENT_KEY}` const EVENT_SHOWN = `shown${EVENT_KEY}` -const CLASS_NAME_FADE = 'fade' -const CLASS_NAME_HIDE = 'hide' // @deprecated - kept here only for backwards compatibility +const CLASS_NAME_INSTANT = 'toast-instant' const CLASS_NAME_SHOW = 'show' -const CLASS_NAME_SHOWING = 'showing' type ToastConfig = { - animation: boolean autohide: boolean delay: number } const DefaultType = { - animation: 'boolean', autohide: 'boolean', delay: 'number' } const Default: ToastConfig = { - animation: true, autohide: true, delay: 5000 } @@ -92,22 +86,15 @@ class Toast extends BaseComponent { this._clearTimeout() - if (this._config.animation) { - this._element.classList.add(CLASS_NAME_FADE) - } - const complete = () => { - this._element.classList.remove(CLASS_NAME_SHOWING) EventHandler.trigger(this._element, EVENT_SHOWN) this._maybeScheduleHide() } - this._element.classList.remove(CLASS_NAME_HIDE) // @deprecated - reflow(this._element) - this._element.classList.add(CLASS_NAME_SHOW, CLASS_NAME_SHOWING) + this._element.classList.add(CLASS_NAME_SHOW) - await this._queueCallback(complete, this._element, this._config.animation) + await this._queueCallback(complete, this._element, this._isAnimated()) } async hide(): Promise { @@ -122,13 +109,13 @@ class Toast extends BaseComponent { } const complete = () => { - this._element.classList.add(CLASS_NAME_HIDE) // @deprecated - this._element.classList.remove(CLASS_NAME_SHOWING, CLASS_NAME_SHOW) EventHandler.trigger(this._element, EVENT_HIDDEN) } - this._element.classList.add(CLASS_NAME_SHOWING) - await this._queueCallback(complete, this._element, this._config.animation) + // Removing .show starts the fade-out. The discrete `display` transition + // keeps the toast laid out until the fade finishes. + this._element.classList.remove(CLASS_NAME_SHOW) + await this._queueCallback(complete, this._element, this._isAnimated()) } override dispose(): void { @@ -146,6 +133,10 @@ class Toast extends BaseComponent { } // Private + protected _isAnimated(): boolean { + return !this._element.classList.contains(CLASS_NAME_INSTANT) + } + protected _maybeScheduleHide(): void { if (!this._config.autohide) { return diff --git a/js/tests/unit/toast.spec.js b/js/tests/unit/toast.spec.js index 078b3fd7a604..a39e7dece929 100644 --- a/js/tests/unit/toast.spec.js +++ b/js/tests/unit/toast.spec.js @@ -65,7 +65,7 @@ describe('Toast', () => { it('should close toast when close element with data-bs-dismiss attribute is set', () => { return new Promise(resolve => { fixtureEl.innerHTML = [ - '
', + '
', ' ', '
' ].join('') @@ -98,7 +98,7 @@ describe('Toast', () => { Toast.Default.delay = defaultDelay fixtureEl.innerHTML = [ - '
', + '
', ' ', '
' ].join('') @@ -139,32 +139,31 @@ describe('Toast', () => { }) }) - it('should not add fade class', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '
', - '
', - ' a simple toast', - '
', - '
' - ].join('') + it('should trigger shown synchronously when the toast is instant', () => { + fixtureEl.innerHTML = [ + '
', + '
', + ' a simple toast', + '
', + '
' + ].join('') - const toastEl = fixtureEl.querySelector('.toast') - const toast = new Toast(toastEl) + const toastEl = fixtureEl.querySelector('.toast') + const toast = new Toast(toastEl) + const spy = jasmine.createSpy('shown') - toastEl.addEventListener('shown.bs.toast', () => { - expect(toastEl).not.toHaveClass('fade') - resolve() - }) + toastEl.addEventListener('shown.bs.toast', spy) - toast.show() - }) + toast.show() + + expect(spy).toHaveBeenCalled() + expect(toastEl).toHaveClass('show') }) it('should not trigger shown if show is prevented', () => { return new Promise((resolve, reject) => { fixtureEl.innerHTML = [ - '
', + '
', '
', ' a simple toast', '
', @@ -437,6 +436,27 @@ describe('Toast', () => { }) }) + it('should trigger hidden synchronously when the toast is instant', () => { + fixtureEl.innerHTML = [ + '
', + '
', + ' a simple toast', + '
', + '
' + ].join('') + + const toastEl = fixtureEl.querySelector('.toast') + const toast = new Toast(toastEl) + const spy = jasmine.createSpy('hidden') + + toastEl.addEventListener('hidden.bs.toast', spy) + + toast.hide() + + expect(spy).toHaveBeenCalled() + expect(toastEl).not.toHaveClass('show') + }) + it('should do nothing when we call hide on a non shown toast', () => { fixtureEl.innerHTML = '
' @@ -453,7 +473,7 @@ describe('Toast', () => { it('should not trigger hidden if hide is prevented', () => { return new Promise((resolve, reject) => { fixtureEl.innerHTML = [ - '
', + '
', '
', ' a simple toast', '
', diff --git a/scss/_toasts.scss b/scss/_toasts.scss index 435b1270880c..efc9f884f72c 100644 --- a/scss/_toasts.scss +++ b/scss/_toasts.scss @@ -2,6 +2,7 @@ @use "functions" as *; @use "mixins/border-radius" as *; @use "mixins/tokens" as *; +@use "mixins/transition" as *; $toast-tokens: () !default; @@ -25,6 +26,8 @@ $toast-tokens: defaults( --toast-header-color: var(--fg-3), --toast-header-bg: var(--bg-1), --toast-header-border-color: var(--border-color-translucent), + --toast-transition-duration: .15s, + --toast-transition-timing: linear, ), $toast-tokens ); @@ -35,7 +38,7 @@ $toast-tokens: defaults( .toast { @include tokens($toast-tokens); - display: flex; + display: none; flex-direction: column; width: var(--toast-max-width); max-width: 100%; @@ -49,12 +52,27 @@ $toast-tokens: defaults( box-shadow: var(--toast-box-shadow); @include border-radius(var(--toast-border-radius, var(--radius-7))); - &.showing { + // Animated variant (default). `display` transitions discretely so the toast + // stays laid out until the fade-out finishes. Add .toast-instant to skip it. + &:not(.toast-instant) { opacity: 0; + @include transition( + opacity var(--toast-transition-duration) var(--toast-transition-timing), + display var(--toast-transition-duration) allow-discrete + ); } - &:not(.show) { - display: none; + &.show { + display: flex; + opacity: 1; + } + } + + // The toast is not rendered before .show lands, so the fade-in needs an + // explicit starting state — the base opacity above cannot serve as one. + @starting-style { + .toast:not(.toast-instant).show { + opacity: 0; } } diff --git a/site/src/content/docs/components/toasts.mdx b/site/src/content/docs/components/toasts.mdx index 2ffb1e40af83..d966fbdf1c75 100644 --- a/site/src/content/docs/components/toasts.mdx +++ b/site/src/content/docs/components/toasts.mdx @@ -227,6 +227,22 @@ Alternatively, you can also add additional controls and components to toasts.
`} /> +### Instant + +By default, toasts fade in and out. To disable the animation, add `.toast-instant` to the toast. The `show` and `hide` methods then finish immediately, so `shown.bs.toast` and `hidden.bs.toast` fire right away. + + +
+ + Bootstrap + 11 mins ago + +
+
+ This toast appears and disappears instantly. +
+
`} /> + ## 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`. @@ -375,7 +391,6 @@ const toastList = [...toastElList].map(toastEl => new bootstrap.Toast(toastEl, o | `data-bs-dismiss="toast"` | On a close control, dismisses the toast when activated. | | `data-bs-autohide` | Whether the toast hides automatically after the delay. | | `data-bs-delay` | Time in milliseconds before hiding (when autohide is enabled). | -| `data-bs-animation` | Whether to use the fade transition when showing and hiding. | ### Triggers @@ -393,7 +408,6 @@ const toastList = [...toastElList].map(toastEl => new bootstrap.Toast(toastEl, o | Name | Type | Default | Description | | --- | --- | --- | --- | -| `animation` | boolean | `true` | Apply a CSS fade transition to the toast. | | `autohide` | boolean | `true` | Automatically hide the toast after the delay. | | `delay` | number | `5000` | Delay in milliseconds before hiding the toast. | diff --git a/site/src/content/docs/customize/optimize.mdx b/site/src/content/docs/customize/optimize.mdx index 87a801bff7e7..50aa4a22db9d 100644 --- a/site/src/content/docs/customize/optimize.mdx +++ b/site/src/content/docs/customize/optimize.mdx @@ -103,7 +103,7 @@ export default { defaultExtractor: content => content.match(/[\w-/:]+(?