From bb50510ef733da8f731de5c0c2cbd3b79320ea39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20M=C3=BCller?= Date: Wed, 27 Jan 2021 15:41:07 +0100 Subject: [PATCH] fix(b-toast): `show` and `hide` handling during active transitions [WIP] --- src/components/modal/helpers/bv-modal.js | 107 ++++--- src/components/modal/modal.js | 87 +++--- src/components/toast/helpers/bv-toast.js | 53 ++-- src/components/toast/helpers/bv-toast.spec.js | 6 +- src/components/toast/toast.js | 295 +++++++++++------- src/components/toast/toast.spec.js | 10 +- src/utils/props.js | 6 +- 7 files changed, 334 insertions(+), 230 deletions(-) diff --git a/src/components/modal/helpers/bv-modal.js b/src/components/modal/helpers/bv-modal.js index 31d052b339a..0fd5bc93efb 100644 --- a/src/components/modal/helpers/bv-modal.js +++ b/src/components/modal/helpers/bv-modal.js @@ -3,6 +3,8 @@ import { NAME_MODAL, NAME_MSG_BOX } from '../../../constants/components' import { EVENT_NAME_HIDDEN, EVENT_NAME_HIDE, + EVENT_NAME_SHOW, + EVENT_NAME_TOGGLE, HOOK_EVENT_NAME_BEFORE_DESTROY, HOOK_EVENT_NAME_DESTROYED } from '../../../constants/events' @@ -21,6 +23,7 @@ import { readonlyDescriptor } from '../../../utils/object' import { pluginFactory } from '../../../utils/plugins' +import { pluckProps } from '../../../utils/props' import { warn, warnNotClient, warnNoPromiseSupport } from '../../../utils/warn' import { BModal, props as modalProps } from '../modal' @@ -51,16 +54,6 @@ const propsToSlots = { // --- Helper methods --- -// Method to filter only recognized props that are not undefined -const filterOptions = options => { - return BASE_PROPS.reduce((memo, key) => { - if (!isUndefined(options[key])) { - memo[key] = options[key] - } - return memo - }, {}) -} - // Method to install `$bvModal` VM injection const plugin = Vue => { // Create a private sub-component that extends BModal @@ -116,7 +109,7 @@ const plugin = Vue => { parent: $parent, // Preset the prop values propsData: { - ...filterOptions(getComponentConfig(NAME_MODAL)), + ...pluckProps(BASE_PROPS, getComponentConfig(NAME_MODAL)), // Defaults that user can override hideHeaderClose: true, hideHeader: !(props.title || props.titleHtml), @@ -166,7 +159,7 @@ const plugin = Vue => { // Private utility method to open a user defined message box and returns a promise. // Not to be used directly by consumers, as this method may change calling syntax - const makeMsgBox = ($parent, content, options = {}, resolver = null) => { + const makeMsgBox = ($parent, content, props = {}, resolver = null) => { if ( !content || warnNoPromiseSupport(PROP_NAME) || @@ -176,7 +169,14 @@ const plugin = Vue => { /* istanbul ignore next */ return } - return asyncMsgBox($parent, { ...filterOptions(options), msgBoxContent: content }, resolver) + return asyncMsgBox( + $parent, + { + ...pluckProps(BASE_PROPS, props), + msgBoxContent: content + }, + resolver + ) } // BvModal instance class @@ -193,17 +193,24 @@ const plugin = Vue => { // --- Instance methods --- - // Show modal with the specified ID args are for future use + // Show modal with the specified ID show(id, ...args) { - if (id && this._root) { - this._root.$emit(getRootActionEventName(NAME_MODAL, 'show'), id, ...args) + if (id) { + this._root.$emit(getRootActionEventName(NAME_MODAL, EVENT_NAME_SHOW), id, ...args) } } - // Hide modal with the specified ID args are for future use + // Hide modal with the specified ID hide(id, ...args) { - if (id && this._root) { - this._root.$emit(getRootActionEventName(NAME_MODAL, 'hide'), id, ...args) + if (id) { + this._root.$emit(getRootActionEventName(NAME_MODAL, EVENT_NAME_HIDE), id, ...args) + } + } + + // Toggle modal with the specified ID + toggle(id, ...args) { + if (id) { + this._root.$emit(getRootActionEventName(NAME_MODAL, EVENT_NAME_TOGGLE), id, ...args) } } @@ -212,38 +219,44 @@ const plugin = Vue => { // should have a Polyfill loaded (which they need anyways for IE 11 support) // Open a message box with OK button only and returns a promise - msgBoxOk(message, options = {}) { - // Pick the modal props we support from options - const props = { - ...options, - // Add in overrides and our content prop - okOnly: true, - okDisabled: false, - hideFooter: false, - msgBoxContent: message - } - return makeMsgBox(this._vm, message, props, () => { - // Always resolve to true for OK - return true - }) + msgBoxOk(message, props = {}) { + return makeMsgBox( + this._vm, + message, + { + ...props, + // Add in overrides and our content prop + okOnly: true, + okDisabled: false, + hideFooter: false, + msgBoxContent: message + }, + () => { + // Always resolve to true for OK + return true + } + ) } // Open a message box modal with OK and CANCEL buttons // and returns a promise - msgBoxConfirm(message, options = {}) { - // Set the modal props we support from options - const props = { - ...options, - // Add in overrides and our content prop - okOnly: false, - okDisabled: false, - cancelDisabled: false, - hideFooter: false - } - return makeMsgBox(this._vm, message, props, bvModalEvent => { - const trigger = bvModalEvent.trigger - return trigger === 'ok' ? true : trigger === 'cancel' ? false : null - }) + msgBoxConfirm(message, props = {}) { + return makeMsgBox( + this._vm, + message, + { + ...props, + // Add in overrides and our content prop + okOnly: false, + okDisabled: false, + cancelDisabled: false, + hideFooter: false + }, + bvModalEvent => { + const trigger = bvModalEvent.trigger + return trigger === 'ok' ? true : trigger === 'cancel' ? false : null + } + ) } } diff --git a/src/components/modal/modal.js b/src/components/modal/modal.js index fa8a860ac19..28d57e519a7 100644 --- a/src/components/modal/modal.js +++ b/src/components/modal/modal.js @@ -369,8 +369,8 @@ export const BModal = /*#__PURE__*/ Vue.extend({ // Listen for `bv:modal::show events`, and close ourselves if the // opening modal not us this.listenOnRoot(getRootEventName(NAME_MODAL, EVENT_NAME_SHOW), this.modalListener) - // Initially show modal? - if (this[MODEL_PROP_NAME] === true) { + // Initially show modal + if (this[MODEL_PROP_NAME]) { this.$nextTick(this.show) } }, @@ -384,6 +384,41 @@ export const BModal = /*#__PURE__*/ Vue.extend({ } }, methods: { + // Private method to get the current document active element + getActiveElement() { + // Returning focus to `document.body` may cause unwanted scrolls, + // so we exclude setting focus on body + const activeElement = getActiveElement(IS_BROWSER ? [document.body] : []) + // Preset the fallback return focus value if it is not set + // `document.activeElement` should be the trigger element that was clicked or + // in the case of using the v-model, which ever element has current focus + // Will be overridden by some commands such as toggle, etc. + // Note: On IE 11, `document.activeElement` may be `null` + // So we test it for truthiness first + // https://github.com/bootstrap-vue/bootstrap-vue/issues/3206 + return activeElement && activeElement.focus ? activeElement : null + }, + buildEvent(type, options = {}) { + return new BvModalEvent(type, { + // Default options + cancelable: false, + target: this.$refs.modal || this.$el || null, + relatedTarget: null, + trigger: null, + // Supplied options + ...options, + // Options that can't be overridden + vueTarget: this, + componentId: this.modalId + }) + }, + emitEvent(bvEvent) { + const { type } = bvEvent + // We emit on `$root` first in case a global listener wants to cancel + // the event first before the instance emits its event + this.emitOnRoot(getRootEventName(NAME_MODAL, type), bvEvent, bvEvent.componentId) + this.$emit(type, bvEvent) + }, setObserver(on = false) { this.$_observer && this.$_observer.disconnect() this.$_observer = null @@ -395,32 +430,16 @@ export const BModal = /*#__PURE__*/ Vue.extend({ ) } }, - // Private method to update the v-model + // Private method to update the `v-model` updateModel(value) { if (value !== this[MODEL_PROP_NAME]) { this.$emit(MODEL_EVENT_NAME, value) } }, - // Private method to create a BvModalEvent object - buildEvent(type, options = {}) { - return new BvModalEvent(type, { - // Default options - cancelable: false, - target: this.$refs.modal || this.$el || null, - relatedTarget: null, - trigger: null, - // Supplied options - ...options, - // Options that can't be overridden - vueTarget: this, - componentId: this.modalId - }) - }, - // Public method to show modal show() { + // If already open, or in the process of opening, do nothing + /* istanbul ignore next */ if (this.isVisible || this.isOpening) { - // If already open, or in the process of opening, do nothing - /* istanbul ignore next */ return } /* istanbul ignore next */ @@ -448,10 +467,10 @@ export const BModal = /*#__PURE__*/ Vue.extend({ // Show the modal this.doShow() }, - // Public method to hide modal hide(trigger = '') { + // If already closed, or in the process of closing, do nothing + /* istanbul ignore next */ if (!this.isVisible || this.isClosing) { - /* istanbul ignore next */ return } this.isClosing = true @@ -482,7 +501,6 @@ export const BModal = /*#__PURE__*/ Vue.extend({ // Update the v-model this.updateModel(false) }, - // Public method to toggle modal visibility toggle(triggerEl) { if (triggerEl) { this.$_returnFocus = triggerEl @@ -493,20 +511,6 @@ export const BModal = /*#__PURE__*/ Vue.extend({ this.show() } }, - // Private method to get the current document active element - getActiveElement() { - // Returning focus to `document.body` may cause unwanted scrolls, - // so we exclude setting focus on body - const activeElement = getActiveElement(IS_BROWSER ? [document.body] : []) - // Preset the fallback return focus value if it is not set - // `document.activeElement` should be the trigger element that was clicked or - // in the case of using the v-model, which ever element has current focus - // Will be overridden by some commands such as toggle, etc. - // Note: On IE 11, `document.activeElement` may be `null` - // So we test it for truthiness first - // https://github.com/bootstrap-vue/bootstrap-vue/issues/3206 - return activeElement && activeElement.focus ? activeElement : null - }, // Private method to finish showing modal doShow() { /* istanbul ignore next: commenting out for now until we can test stacking */ @@ -588,13 +592,6 @@ export const BModal = /*#__PURE__*/ Vue.extend({ this.emitEvent(this.buildEvent(EVENT_NAME_HIDDEN)) }) }, - emitEvent(bvEvent) { - const { type } = bvEvent - // We emit on `$root` first in case a global listener wants to cancel - // the event first before the instance emits its event - this.emitOnRoot(getRootEventName(NAME_MODAL, type), bvEvent, bvEvent.componentId) - this.$emit(type, bvEvent) - }, // UI event handlers onDialogMousedown() { // Watch to see if the matching mouseup event occurs outside the dialog diff --git a/src/components/toast/helpers/bv-toast.js b/src/components/toast/helpers/bv-toast.js index 6de91803ae0..9c03e8fcc7f 100644 --- a/src/components/toast/helpers/bv-toast.js +++ b/src/components/toast/helpers/bv-toast.js @@ -8,6 +8,7 @@ import { EVENT_NAME_HIDDEN, EVENT_NAME_HIDE, EVENT_NAME_SHOW, + EVENT_NAME_TOGGLE, HOOK_EVENT_NAME_DESTROYED } from '../../../constants/events' import { concat } from '../../../utils/array' @@ -25,6 +26,7 @@ import { readonlyDescriptor } from '../../../utils/object' import { pluginFactory } from '../../../utils/plugins' +import { pluckProps } from '../../../utils/props' import { warn, warnNotClient } from '../../../utils/warn' import { BToast, props as toastProps } from '../toast' @@ -47,16 +49,6 @@ const propsToSlots = { // --- Helper methods --- -// Method to filter only recognized props that are not undefined -const filterOptions = options => { - return BASE_PROPS.reduce((memo, key) => { - if (!isUndefined(options[key])) { - memo[key] = options[key] - } - return memo - }, {}) -} - // Method to install `$bvToast` VM injection const plugin = Vue => { // Create a private sub-component constructor that @@ -75,16 +67,10 @@ const plugin = Vue => { mounted() { // Self destruct handler const handleDestroy = () => { - // Ensure the toast has been force hidden - this.localShow = false - this.doRender = false this.$nextTick(() => { - this.$nextTick(() => { - // In a `requestAF()` to release control back to application - // and to allow the portal-target time to remove the content - requestAF(() => { - this.$destroy() - }) + // In a `requestAF()` to release control back to application + requestAF(() => { + this.$destroy() }) }) } @@ -95,7 +81,7 @@ const plugin = Vue => { // Self destruct when toaster is destroyed this.listenOnRoot(getRootEventName(NAME_TOASTER, EVENT_NAME_DESTROYED), toaster => { /* istanbul ignore next: hard to test */ - if (toaster === this.toaster) { + if (toaster === this.computedToaster) { handleDestroy() } }) @@ -114,7 +100,7 @@ const plugin = Vue => { // app `$root`, and it ensures `BToast` is destroyed when parent is destroyed parent: $parent, propsData: { - ...filterOptions(getComponentConfig(NAME_TOAST)), + ...pluckProps(BASE_PROPS, getComponentConfig(NAME_TOAST)), // Add in (filtered) user supplied props ...omit(props, keys(propsToSlots)), // Props that can't be overridden @@ -154,26 +140,39 @@ const plugin = Vue => { // --- Public Instance methods --- - // Opens a user defined toast and returns immediately - toast(content, options = {}) { + // Shows a user defined toast and returns immediately + toast(content, props = {}) { + /* istanbul ignore next */ if (!content || warnNotClient(PROP_NAME)) { - /* istanbul ignore next */ return } - makeToast({ ...filterOptions(options), toastContent: content }, this._vm) + makeToast( + { + ...pluckProps(BASE_PROPS, props), + toastContent: content + }, + this._vm + ) } - // shows a `` component with the specified ID + // Show a toast with the specified ID show(id) { if (id) { this._root.$emit(getRootActionEventName(NAME_TOAST, EVENT_NAME_SHOW), id) } } - // Hide a toast with specified ID, or if not ID all toasts + // Hide a toast with specified ID, or if no ID all toasts hide(id = null) { this._root.$emit(getRootActionEventName(NAME_TOAST, EVENT_NAME_HIDE), id) } + + // Toggle a toast with the specified ID + toggle(id) { + if (id) { + this._root.$emit(getRootActionEventName(NAME_TOAST, EVENT_NAME_TOGGLE), id) + } + } } // Add our instance mixin diff --git a/src/components/toast/helpers/bv-toast.spec.js b/src/components/toast/helpers/bv-toast.spec.js index d0a44b23cd5..d480da6af34 100644 --- a/src/components/toast/helpers/bv-toast.spec.js +++ b/src/components/toast/helpers/bv-toast.spec.js @@ -6,7 +6,7 @@ const localVue = createLocalVue() localVue.use(ToastPlugin) describe('$bvToast', () => { - it('$bvToast.show() and $bvToast.hide() works', async () => { + it('`show()` and `hide()` methods work', async () => { const App = { render(h) { return h( @@ -34,6 +34,8 @@ describe('$bvToast', () => { await waitRAF() await waitNT(wrapper.vm) await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() expect(wrapper.vm.$bvToast).toBeDefined() expect(wrapper.vm.$bvToast.show).toBeDefined() @@ -68,7 +70,7 @@ describe('$bvToast', () => { wrapper.destroy() }) - it('$bvModal.toast() works', async () => { + it('`toast()` method works', async () => { const App = { render(h) { return h('div', 'app') diff --git a/src/components/toast/toast.js b/src/components/toast/toast.js index 542a23530e2..951d146f7ac 100644 --- a/src/components/toast/toast.js +++ b/src/components/toast/toast.js @@ -8,6 +8,7 @@ import { EVENT_NAME_HIDE, EVENT_NAME_SHOW, EVENT_NAME_SHOWN, + EVENT_NAME_TOGGLE, EVENT_OPTIONS_NO_CAPTURE } from '../../constants/events' import { @@ -99,16 +100,20 @@ export const BToast = /*#__PURE__*/ Vue.extend({ data() { return { isMounted: false, - doRender: false, - localShow: false, - isTransitioning: false, - isHiding: false, + isHidden: true, // If toast should not be in document + isVisible: false, // Controls toast visible state + isTransitioning: false, // Used for style control + isShowing: false, // To signal that the toast is in the process of showing + isHiding: false, // To signal that the toast is in the process of hiding order: 0, dismissStarted: 0, resumeDismiss: 0 } }, computed: { + toastId() { + return this.safeId() + }, toastClasses() { const { appendToast, variant } = this @@ -120,8 +125,8 @@ export const BToast = /*#__PURE__*/ Vue.extend({ } }, slotScope() { - const { hide } = this - return { hide } + const { hide, isVisible: visible } = this + return { hide, visible } }, computedDuration() { // Minimum supported duration is 1 second @@ -141,18 +146,15 @@ export const BToast = /*#__PURE__*/ Vue.extend({ computedAttrs() { return { ...this.bvAttrs, - id: this.safeId(), + id: this.toastId, tabindex: '0' } } }, watch: { - [MODEL_PROP_NAME](newValue) { - this[newValue ? 'show' : 'hide']() - }, - localShow(newValue) { - if (newValue !== this[MODEL_PROP_NAME]) { - this.$emit(MODEL_EVENT_NAME, newValue) + [MODEL_PROP_NAME](newValue, oldValue) { + if (newValue !== oldValue) { + this[newValue ? 'show' : 'hide']() } }, /* istanbul ignore next */ @@ -162,9 +164,9 @@ export const BToast = /*#__PURE__*/ Vue.extend({ }, /* istanbul ignore next */ static(newValue) { - // If static changes to true, and the toast is showing, + // If static changes to `true`, and the toast is showing, // ensure the toaster target exists - if (newValue && this.localShow) { + if (newValue && this.isVisible) { this.ensureToaster() } } @@ -175,81 +177,46 @@ export const BToast = /*#__PURE__*/ Vue.extend({ }, mounted() { this.isMounted = true - this.$nextTick(() => { - if (this[MODEL_PROP_NAME]) { - requestAF(() => { - this.show() - }) - } - }) - // Listen for global $root show events - this.listenOnRoot(getRootActionEventName(NAME_TOAST, EVENT_NAME_SHOW), id => { - if (id === this.safeId()) { - this.show() - } - }) - // Listen for global $root hide events - this.listenOnRoot(getRootActionEventName(NAME_TOAST, EVENT_NAME_HIDE), id => { - if (!id || id === this.safeId()) { - this.hide() - } - }) + // Listen for events from others to either show or hide ourselves + this.listenOnRoot(getRootActionEventName(NAME_TOAST, EVENT_NAME_SHOW), this.showHandler) + this.listenOnRoot(getRootActionEventName(NAME_TOAST, EVENT_NAME_HIDE), this.hideHandler) + this.listenOnRoot(getRootActionEventName(NAME_TOAST, EVENT_NAME_TOGGLE), this.toggleHandler) // Make sure we hide when toaster is destroyed /* istanbul ignore next: difficult to test */ - this.listenOnRoot(getRootEventName(NAME_TOASTER, EVENT_NAME_DESTROYED), toaster => { - /* istanbul ignore next */ - if (toaster === this.computedToaster) { - this.hide() - } - }) + this.listenOnRoot( + getRootEventName(NAME_TOASTER, EVENT_NAME_DESTROYED), + this.toasterDestroyedHandler + ) + // Initially show toast + if (this[MODEL_PROP_NAME]) { + this.$nextTick(this.show) + } }, beforeDestroy() { this.clearDismissTimer() + if (this.isVisible) { + this.isVisible = false + this.isTransitioning = false + } }, methods: { - show() { - if (!this.localShow) { - this.ensureToaster() - const showEvent = this.buildEvent(EVENT_NAME_SHOW) - this.emitEvent(showEvent) - this.dismissStarted = this.resumeDismiss = 0 - this.order = Date.now() * (this.appendToast ? 1 : -1) - this.isHiding = false - this.doRender = true - this.$nextTick(() => { - // We show the toast after we have rendered the portal and b-toast wrapper - // so that screen readers will properly announce the toast - requestAF(() => { - this.localShow = true - }) - }) - } - }, - hide() { - if (this.localShow) { - const hideEvent = this.buildEvent(EVENT_NAME_HIDE) - this.emitEvent(hideEvent) - this.setHoverHandler(false) - this.dismissStarted = this.resumeDismiss = 0 - this.clearDismissTimer() - this.isHiding = true - requestAF(() => { - this.localShow = false - }) - } - }, buildEvent(type, options = {}) { return new BvEvent(type, { + // Default options cancelable: false, target: this.$el || null, relatedTarget: null, + // Supplied options ...options, + // Options that can't be overridden vueTarget: this, - componentId: this.safeId() + componentId: this.toastId }) }, emitEvent(bvEvent) { const { type } = bvEvent + // We emit on `$root` first in case a global listener wants to cancel + // the event first before the instance emits its event this.emitOnRoot(getRootEventName(NAME_TOAST, type), bvEvent) this.$emit(type, bvEvent) }, @@ -284,9 +251,143 @@ export const BToast = /*#__PURE__*/ Vue.extend({ this.$_dismissTimer = null }, setHoverHandler(on) { - const el = this.$refs['b-toast'] - eventOnOff(on, el, 'mouseenter', this.onPause, EVENT_OPTIONS_NO_CAPTURE) - eventOnOff(on, el, 'mouseleave', this.onUnPause, EVENT_OPTIONS_NO_CAPTURE) + const $el = this.$refs['b-toast'] + eventOnOff(on, $el, 'mouseenter', this.onPause, EVENT_OPTIONS_NO_CAPTURE) + eventOnOff(on, $el, 'mouseleave', this.onUnpause, EVENT_OPTIONS_NO_CAPTURE) + }, + // Private method to update the `v-model` + updateModel(value) { + if (value !== this[MODEL_PROP_NAME]) { + this.$emit(MODEL_EVENT_NAME, value) + } + }, + show() { + console.log('show', { + id: this.toastId, + isVisible: this.isVisible, + isShowing: this.isShowing + }) + // If already shown, or in the process of showing, do nothing + /* istanbul ignore next */ + if (this.isVisible || this.isShowing) { + return + } + // If we are in the process of hiding, wait until hidden before showing + /* istanbul ignore next */ + if (this.isHiding) { + this.$once(EVENT_NAME_HIDDEN, this.show) + return + } + this.isShowing = true + const showEvent = this.buildEvent(EVENT_NAME_SHOW, { cancelable: true }) + this.emitEvent(showEvent) + // Don't show if canceled + if (showEvent.defaultPrevented || this.isVisible) { + this.isShowing = false + // Ensure the `v-model` reflects the current state + this.updateModel(false) + return + } + this.ensureToaster() + this.dismissStarted = this.resumeDismiss = 0 + this.order = Date.now() * (this.appendToast ? 1 : -1) + // Place toast in DOM + this.isHidden = false + console.log('show', { id: this.toastId, isHidden: this.isHidden }) + // We do this in `$nextTick()` to ensure the toast is in DOM first, + // before we show it + this.$nextTick(() => { + requestAF(() => { + this.isVisible = true + this.isShowing = false + // Update the `v-model` + this.updateModel(true) + }) + }) + }, + hide() { + // If already hidden, or in the process of hiding, do nothing + /* istanbul ignore next */ + if (!this.isVisible || this.isHiding) { + return + } + this.isHiding = true + const hideEvent = this.buildEvent(EVENT_NAME_HIDE, { cancelable: true }) + this.emitEvent(hideEvent) + // Hide if not canceled + if (hideEvent.defaultPrevented || !this.isVisible) { + this.isHiding = false + // Ensure the `v-model` reflects the current state + this.updateModel(true) + return + } + this.setHoverHandler(false) + this.dismissStarted = this.resumeDismiss = 0 + this.clearDismissTimer() + // Trigger the hide transition + this.isVisible = false + // Update the v-model + this.updateModel(false) + }, + toggle() { + if (this.isVisible) { + this.hide() + } else { + this.show() + } + }, + // Transition handlers + onBeforeEnter() { + console.log('onBeforeEnter', { id: this.toastId, isVisible: this.isVisible }) + this.isTransitioning = true + }, + onAfterEnter() { + console.log('onAfterEnter', { id: this.toastId, isVisible: this.isVisible }) + this.isTransitioning = false + // We use `requestAF()` to allow transition hooks to complete + // before passing control over to the other handlers + requestAF(() => { + this.emitEvent(this.buildEvent(EVENT_NAME_SHOWN)) + + this.startDismissTimer() + this.setHoverHandler(true) + }) + }, + onBeforeLeave() { + console.log('onBeforeLeave', { id: this.toastId, isVisible: this.isVisible }) + this.isTransitioning = true + }, + onAfterLeave() { + console.log('onAfterLeave', { id: this.toastId, isVisible: this.isVisible }) + this.isTransitioning = false + this.order = 0 + this.resumeDismiss = this.dismissStarted = 0 + this.isHidden = true + this.$nextTick(() => { + this.isHiding = false + this.emitEvent(this.buildEvent(EVENT_NAME_HIDDEN)) + }) + }, + // Root listener handlers + showHandler(id) { + if (id === this.toastId) { + this.show() + } + }, + hideHandler(id) { + if (!id || id === this.toastId) { + this.hide() + } + }, + toggleHandler(id) { + if (id === this.toastId) { + this.toggle() + } + }, + toasterDestroyedHandler(toaster) { + if (toaster === this.computedToaster) { + this.hide() + } }, onPause() { // Determine time remaining, and then pause timer @@ -299,7 +400,7 @@ export const BToast = /*#__PURE__*/ Vue.extend({ this.resumeDismiss = mathMax(this.computedDuration - passed, MIN_DURATION) } }, - onUnPause() { + onUnpause() { // Restart timer with max of time remaining or 1 second if (this.noAutoHide || this.noHoverPause || !this.resumeDismiss) { this.resumeDismiss = this.dismissStarted = 0 @@ -308,37 +409,16 @@ export const BToast = /*#__PURE__*/ Vue.extend({ this.startDismissTimer() }, onLinkClick() { - // We delay the close to allow time for the - // browser to process the link click + // We delay hiding to give the browser time to process the link click this.$nextTick(() => { requestAF(() => { this.hide() }) }) }, - onBeforeEnter() { - this.isTransitioning = true - }, - onAfterEnter() { - this.isTransitioning = false - const hiddenEvent = this.buildEvent(EVENT_NAME_SHOWN) - this.emitEvent(hiddenEvent) - this.startDismissTimer() - this.setHoverHandler(true) - }, - onBeforeLeave() { - this.isTransitioning = true - }, - onAfterLeave() { - this.isTransitioning = false - this.order = 0 - this.resumeDismiss = this.dismissStarted = 0 - const hiddenEvent = this.buildEvent(EVENT_NAME_HIDDEN) - this.emitEvent(hiddenEvent) - this.doRender = false - }, // Render helper for generating the toast makeToast(h) { + console.log('makeToast', { id: this.toastId }) const { title, slotScope } = this const link = isLink(this) const $headerContent = [] @@ -354,6 +434,7 @@ export const BToast = /*#__PURE__*/ Vue.extend({ $headerContent.push( h(BButtonClose, { staticClass: 'ml-auto mb-1', + props: { disabled: this.isTransitioning }, on: { click: () => { this.hide() @@ -367,7 +448,10 @@ export const BToast = /*#__PURE__*/ Vue.extend({ if ($headerContent.length > 0) { $header = h( 'header', - { staticClass: 'toast-header', class: this.headerClass }, + { + staticClass: 'toast-header', + class: this.headerClass + }, $headerContent ) } @@ -397,11 +481,12 @@ export const BToast = /*#__PURE__*/ Vue.extend({ } }, render(h) { - if (!this.doRender || !this.isMounted) { + console.log('render', { id: this.toastId, isMounted: this.isMounted, isHidden: this.isHidden }) + if (!this.isMounted || this.isHidden) { return h() } - const { order, static: isStatic, isHiding, isStatus } = this + const { order, noFade, static: isStatic, isHiding, isStatus } = this const name = `b-toast-${this[COMPONENT_UID_KEY]}` const $toast = h( @@ -413,7 +498,7 @@ export const BToast = /*#__PURE__*/ Vue.extend({ // If scoped styles are applied and the toast is not static, // make sure the scoped style data attribute is applied ...(isStatic ? {} : this.scopedStyleAttrs), - id: this.safeId('_toast_outer'), + id: this.safeId('__BV_toast_outer_'), role: isHiding ? null : isStatus ? 'status' : 'alert', 'aria-live': isHiding ? null : isStatus ? 'polite' : 'assertive', 'aria-atomic': isHiding ? null : 'true' @@ -425,10 +510,10 @@ export const BToast = /*#__PURE__*/ Vue.extend({ h( BVTransition, { - props: { noFade: this.noFade }, + props: { noFade }, on: this.transitionHandlers }, - [this.localShow ? this.makeToast(h) : h()] + [this.isVisible ? this.makeToast(h) : h()] ) ] ) diff --git a/src/components/toast/toast.spec.js b/src/components/toast/toast.spec.js index 63fcaf746dc..63860df9f8a 100644 --- a/src/components/toast/toast.spec.js +++ b/src/components/toast/toast.spec.js @@ -31,6 +31,8 @@ describe('b-toast', () => { await waitRAF() await waitNT(wrapper.vm) await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() expect(wrapper.element.tagName).toBe('DIV') expect(wrapper.classes()).toContain('b-toast') @@ -40,14 +42,14 @@ describe('b-toast', () => { expect(wrapper.attributes('aria-live')).toEqual('assertive') expect(wrapper.attributes('aria-atomic')).toEqual('true') - expect(wrapper.find('.toast').exists()).toBe(true) const $toast = wrapper.find('.toast') + expect($toast.exists()).toBe(true) expect($toast.element.tagName).toBe('DIV') expect($toast.classes()).toContain('toast') expect($toast.attributes('tabindex')).toEqual('0') - expect($toast.find('.toast-header').exists()).toBe(true) const $header = $toast.find('.toast-header') + expect($header.exists()).toBe(true) expect($header.element.tagName).toBe('HEADER') expect($header.classes().length).toBe(1) expect($header.find('strong').exists()).toBe(true) @@ -58,8 +60,8 @@ describe('b-toast', () => { expect($header.find('button').classes()).toContain('ml-auto') expect($header.find('button').classes()).toContain('mb-1') - expect($toast.find('.toast-body').exists()).toBe(true) const $body = $toast.find('.toast-body') + expect($body.exists()).toBe(true) expect($body.element.tagName).toBe('DIV') expect($body.classes().length).toBe(1) expect($body.text()).toEqual('content') @@ -103,6 +105,8 @@ describe('b-toast', () => { await waitRAF() await waitNT(wrapper.vm) await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() expect(wrapper.element.tagName).toBe('DIV') diff --git a/src/utils/props.js b/src/utils/props.js index 26f15df833a..e0ab491b5df 100644 --- a/src/utils/props.js +++ b/src/utils/props.js @@ -63,7 +63,11 @@ export const copyProps = (props, transformFn = identity) => { // that has props that reference the original prop values export const pluckProps = (keysToPluck, objToPluck, transformFn = identity) => (isArray(keysToPluck) ? keysToPluck.slice() : keys(keysToPluck)).reduce((memo, prop) => { - memo[transformFn(prop)] = objToPluck[prop] + const value = objToPluck[prop] + if (!isUndefined(value)) { + memo[transformFn(prop)] = value + } + return memo }, {})