]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
Prevent toast autohiding if focusing or hovering (#33221)
authorRyan Berliner <22206986+RyanBerliner@users.noreply.github.com>
Tue, 11 May 2021 05:37:57 +0000 (01:37 -0400)
committerGitHub <noreply@github.com>
Tue, 11 May 2021 05:37:57 +0000 (08:37 +0300)
js/src/toast.js
js/tests/unit/toast.spec.js

index c8539b3a962b3440b99fbe462c98313bfc4cd275..94a9084ce5273ea3aab141fadeb53f15c6f83edf 100644 (file)
@@ -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() {
index d298dc993113d7eb67df6603799ce8d26db0c100..ea71e2cdb56be9f9b731ea663b1d43fc3fb878c3 100644 (file)
@@ -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 = [
+        '<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', () => {