Skip to content

Commit

Permalink
prevent toast autohiding if focusing or hovering
Browse files Browse the repository at this point in the history
  • Loading branch information
RyanBerliner committed May 10, 2021
1 parent 90b1a69 commit e4d9a00
Show file tree
Hide file tree
Showing 2 changed files with 229 additions and 6 deletions.
57 changes: 52 additions & 5 deletions js/src/toast.js
Expand Up @@ -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}`
Expand Down Expand Up @@ -62,6 +66,8 @@ class Toast extends BaseComponent {

this._config = this._getConfig(config)
this._timeout = null
this._hasMouseInteraction = false
this._hasKeyboardInteraction = false
this._setListeners()
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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() {
Expand Down
178 changes: 177 additions & 1 deletion 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
Expand Down Expand Up @@ -210,6 +210,182 @@ describe('Toast', () => {

toast.show()
})

it('should clear timeout if toast is interacted with mouse', done => {
fixtureEl.innerHTML = [
'<div class="toast">',
' <div class="toast-body">',
' a simple toast',
' </div>',
'</div>'
].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 = [
'<button id="outside-focusable">outside focusable</button>',
'<div class="toast">',
' <div class="toast-body">',
' a simple toast',
' <button>with a button</button>',
' </div>',
'</div>'
].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 = [
'<button id="outside-focusable">outside focusable</button>',
'<div class="toast">',
' <div class="toast-body">',
' a simple toast',
' <button>with a button</button>',
' </div>',
'</div>'
].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 = [
'<button id="outside-focusable">outside focusable</button>',
'<div class="toast">',
' <div class="toast-body">',
' a simple toast',
' <button>with a button</button>',
' </div>',
'</div>'
].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 = [
'<button id="outside-focusable">outside focusable</button>',
'<div class="toast">',
' <div class="toast-body">',
' a simple toast',
' <button>with a button</button>',
' </div>',
'</div>'
].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', () => {
Expand Down

0 comments on commit e4d9a00

Please sign in to comment.