From e4d9a005f1b3689d243bcb954195a026a66edb10 Mon Sep 17 00:00:00 2001 From: Ryan Berliner Date: Sun, 28 Feb 2021 15:27:04 -0500 Subject: [PATCH] prevent toast autohiding if focusing or hovering --- js/src/toast.js | 57 +++++++++++- js/tests/unit/toast.spec.js | 178 +++++++++++++++++++++++++++++++++++- 2 files changed, 229 insertions(+), 6 deletions(-) diff --git a/js/src/toast.js b/js/src/toast.js index c8539b3a962b..94a9084ce527 100644 --- a/js/src/toast.js +++ b/js/src/toast.js @@ -26,6 +26,10 @@ const DATA_KEY = 'bs.toast' const EVENT_KEY = `.${DATA_KEY}` const EVENT_CLICK_DISMISS = `click.dismiss${EVENT_KEY}` +const EVENT_MOUSEOVER = `mouseover${EVENT_KEY}` +const EVENT_MOUSEOUT = `mouseout${EVENT_KEY}` +const EVENT_FOCUSIN = `focusin${EVENT_KEY}` +const EVENT_FOCUSOUT = `focusout${EVENT_KEY}` const EVENT_HIDE = `hide${EVENT_KEY}` const EVENT_HIDDEN = `hidden${EVENT_KEY}` const EVENT_SHOW = `show${EVENT_KEY}` @@ -62,6 +66,8 @@ class Toast extends BaseComponent { this._config = this._getConfig(config) this._timeout = null + this._hasMouseInteraction = false + this._hasKeyboardInteraction = false this._setListeners() } @@ -100,11 +106,7 @@ class Toast extends BaseComponent { EventHandler.trigger(this._element, EVENT_SHOWN) - if (this._config.autohide) { - this._timeout = setTimeout(() => { - this.hide() - }, this._config.delay) - } + this._maybeScheduleHide() } this._element.classList.remove(CLASS_NAME_HIDE) @@ -159,8 +161,53 @@ class Toast extends BaseComponent { return config } + _maybeScheduleHide() { + if (!this._config.autohide) { + return + } + + if (this._hasMouseInteraction || this._hasKeyboardInteraction) { + return + } + + this._timeout = setTimeout(() => { + this.hide() + }, this._config.delay) + } + + _onInteraction(event, isInteracting) { + switch (event.type) { + case 'mouseover': + case 'mouseout': + this._hasMouseInteraction = isInteracting + break + case 'focusin': + case 'focusout': + this._hasKeyboardInteraction = isInteracting + break + default: + break + } + + if (isInteracting) { + this._clearTimeout() + return + } + + const nextElement = event.relatedTarget + if (this._element === nextElement || this._element.contains(nextElement)) { + return + } + + this._maybeScheduleHide() + } + _setListeners() { EventHandler.on(this._element, EVENT_CLICK_DISMISS, SELECTOR_DATA_DISMISS, () => this.hide()) + EventHandler.on(this._element, EVENT_MOUSEOVER, event => this._onInteraction(event, true)) + EventHandler.on(this._element, EVENT_MOUSEOUT, event => this._onInteraction(event, false)) + EventHandler.on(this._element, EVENT_FOCUSIN, event => this._onInteraction(event, true)) + EventHandler.on(this._element, EVENT_FOCUSOUT, event => this._onInteraction(event, false)) } _clearTimeout() { diff --git a/js/tests/unit/toast.spec.js b/js/tests/unit/toast.spec.js index d298dc993113..ea71e2cdb56b 100644 --- a/js/tests/unit/toast.spec.js +++ b/js/tests/unit/toast.spec.js @@ -1,7 +1,7 @@ import Toast from '../../src/toast' /** Test helpers */ -import { getFixture, clearFixture, jQueryMock } from '../helpers/fixture' +import { getFixture, clearFixture, createEvent, jQueryMock } from '../helpers/fixture' describe('Toast', () => { let fixtureEl @@ -210,6 +210,182 @@ describe('Toast', () => { toast.show() }) + + it('should clear timeout if toast is interacted with mouse', done => { + fixtureEl.innerHTML = [ + '
', + '
', + ' a simple toast', + '
', + '
' + ].join('') + + const toastEl = fixtureEl.querySelector('.toast') + const toast = new Toast(toastEl) + const spy = spyOn(toast, '_clearTimeout').and.callThrough() + + setTimeout(() => { + spy.calls.reset() + + toastEl.addEventListener('mouseover', () => { + expect(toast._clearTimeout).toHaveBeenCalledTimes(1) + expect(toast._timeout).toBeNull() + done() + }) + + const mouseOverEvent = createEvent('mouseover') + toastEl.dispatchEvent(mouseOverEvent) + }, toast._config.delay / 2) + + toast.show() + }) + + it('should clear timeout if toast is interacted with keyboard', done => { + fixtureEl.innerHTML = [ + '', + '
', + '
', + ' a simple toast', + ' ', + '
', + '
' + ].join('') + + const toastEl = fixtureEl.querySelector('.toast') + const toast = new Toast(toastEl) + const spy = spyOn(toast, '_clearTimeout').and.callThrough() + + setTimeout(() => { + spy.calls.reset() + + toastEl.addEventListener('focusin', () => { + expect(toast._clearTimeout).toHaveBeenCalledTimes(1) + expect(toast._timeout).toBeNull() + done() + }) + + const insideFocusable = toastEl.querySelector('button') + insideFocusable.focus() + }, toast._config.delay / 2) + + toast.show() + }) + + it('should still auto hide after being interacted with mouse and keyboard', done => { + fixtureEl.innerHTML = [ + '', + '
', + '
', + ' a simple toast', + ' ', + '
', + '
' + ].join('') + + const toastEl = fixtureEl.querySelector('.toast') + const toast = new Toast(toastEl) + + setTimeout(() => { + toastEl.addEventListener('mouseover', () => { + const insideFocusable = toastEl.querySelector('button') + insideFocusable.focus() + }) + + toastEl.addEventListener('focusin', () => { + const mouseOutEvent = createEvent('mouseout') + toastEl.dispatchEvent(mouseOutEvent) + }) + + toastEl.addEventListener('mouseout', () => { + const outsideFocusable = document.getElementById('outside-focusable') + outsideFocusable.focus() + }) + + toastEl.addEventListener('focusout', () => { + expect(toast._timeout).not.toBeNull() + done() + }) + + const mouseOverEvent = createEvent('mouseover') + toastEl.dispatchEvent(mouseOverEvent) + }, toast._config.delay / 2) + + toast.show() + }) + + it('should not auto hide if focus leaves but mouse pointer remains inside', done => { + fixtureEl.innerHTML = [ + '', + '
', + '
', + ' a simple toast', + ' ', + '
', + '
' + ].join('') + + const toastEl = fixtureEl.querySelector('.toast') + const toast = new Toast(toastEl) + + setTimeout(() => { + toastEl.addEventListener('mouseover', () => { + const insideFocusable = toastEl.querySelector('button') + insideFocusable.focus() + }) + + toastEl.addEventListener('focusin', () => { + const outsideFocusable = document.getElementById('outside-focusable') + outsideFocusable.focus() + }) + + toastEl.addEventListener('focusout', () => { + expect(toast._timeout).toBeNull() + done() + }) + + const mouseOverEvent = createEvent('mouseover') + toastEl.dispatchEvent(mouseOverEvent) + }, toast._config.delay / 2) + + toast.show() + }) + + it('should not auto hide if mouse pointer leaves but focus remains inside', done => { + fixtureEl.innerHTML = [ + '', + '
', + '
', + ' a simple toast', + ' ', + '
', + '
' + ].join('') + + const toastEl = fixtureEl.querySelector('.toast') + const toast = new Toast(toastEl) + + setTimeout(() => { + toastEl.addEventListener('mouseover', () => { + const insideFocusable = toastEl.querySelector('button') + insideFocusable.focus() + }) + + toastEl.addEventListener('focusin', () => { + const mouseOutEvent = createEvent('mouseout') + toastEl.dispatchEvent(mouseOutEvent) + }) + + toastEl.addEventListener('mouseout', () => { + expect(toast._timeout).toBeNull() + done() + }) + + const mouseOverEvent = createEvent('mouseover') + toastEl.dispatchEvent(mouseOverEvent) + }, toast._config.delay / 2) + + toast.show() + }) }) describe('hide', () => {