const NAME = 'tooltip'
const DISALLOWED_ATTRIBUTES = new Set(['sanitize', 'allowList', 'sanitizeFn'])
+const ESCAPE_KEY = 'Escape'
+
const CLASS_NAME_FADE = 'fade'
const CLASS_NAME_MODAL = 'modal'
const CLASS_NAME_SHOW = 'show'
const EVENT_FOCUSOUT = 'focusout'
const EVENT_MOUSEENTER = 'mouseenter'
const EVENT_MOUSELEAVE = 'mouseleave'
+const EVENT_KEYDOWN = 'keydown'
const AttachmentMap = {
AUTO: 'auto',
this._isHovered = null
this._activeTrigger = {}
this._floatingCleanup = null
+ this._keydownHandler = null
this._templateFactory = null
this._newContent = null
this._mediaQueryListeners = []
dispose() {
clearTimeout(this._timeout)
+ this._removeEscapeListener()
+
EventHandler.off(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler)
if (this._element.getAttribute('data-bs-original-title')) {
tip.classList.add(CLASS_NAME_SHOW)
+ // Allow dismissing the tooltip with the Escape key (WCAG 1.4.13)
+ this._setEscapeListener()
+
// If this is a touch-enabled device we add extra
// empty mouseover listeners to the body's immediate children;
// only needed because of broken event delegation on iOS
return
}
+ this._removeEscapeListener()
+
const tip = this._getTipElement()
tip.classList.remove(CLASS_NAME_SHOW)
EventHandler.on(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler)
}
+ _setEscapeListener() {
+ if (this._keydownHandler) {
+ return
+ }
+
+ this._keydownHandler = event => {
+ if (event.key !== ESCAPE_KEY || !this._isShown() || !this.tip.isConnected) {
+ return
+ }
+
+ // Dismiss the tooltip and consume the keystroke so it doesn't reach
+ // ancestor components (e.g. a parent dialog). This way the first Escape
+ // only closes the tooltip, and a subsequent one can close the dialog —
+ // matching the behavior of the dropdown menu.
+ event.preventDefault()
+ event.stopPropagation()
+ this.hide()
+ }
+
+ // Listen in the capture phase so this runs before the dialog's own keydown
+ // handler, and on the document so it works regardless of where focus is
+ // (e.g. for hover-triggered tooltips). EventHandler only uses the capture
+ // phase for delegated listeners, so attach natively here.
+ this._element.ownerDocument.addEventListener(EVENT_KEYDOWN, this._keydownHandler, true)
+ }
+
+ _removeEscapeListener() {
+ if (!this._keydownHandler) {
+ return
+ }
+
+ this._element.ownerDocument.removeEventListener(EVENT_KEYDOWN, this._keydownHandler, true)
+ this._keydownHandler = null
+ }
+
_fixTitle() {
const title = this._element.getAttribute('title')
throw new Error('should not throw error')
}
})
+
+ it('should hide a tooltip when the Escape key is pressed', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"></a>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).not.toBeNull()
+
+ const keydownEscape = createEvent('keydown', { bubbles: true })
+ keydownEscape.key = 'Escape'
+ document.dispatchEvent(keydownEscape)
+ })
+
+ tooltipEl.addEventListener('hidden.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).toBeNull()
+ expect(tooltipEl.getAttribute('aria-describedby')).toBeNull()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should stop the Escape keystroke from reaching ancestor components (e.g. a dialog)', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"></a>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+ const ancestorSpy = jasmine.createSpy('ancestor keydown')
+
+ // A parent dialog handles Escape on the bubble phase; it should not run
+ // while a tooltip is open, so the first Escape only closes the tooltip.
+ fixtureEl.addEventListener('keydown', ancestorSpy)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const keydownEscape = createEvent('keydown', { bubbles: true, cancelable: true })
+ keydownEscape.key = 'Escape'
+ tooltipEl.dispatchEvent(keydownEscape)
+
+ expect(ancestorSpy).not.toHaveBeenCalled()
+ expect(keydownEscape.defaultPrevented).toBeTrue()
+ })
+
+ tooltipEl.addEventListener('hidden.bs.tooltip', () => {
+ fixtureEl.removeEventListener('keydown', ancestorSpy)
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should not hide a tooltip when a non-Escape key is pressed', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"></a>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const spy = spyOn(tooltip, 'hide').and.callThrough()
+
+ const keydownEnter = createEvent('keydown', { bubbles: true })
+ keydownEnter.key = 'Enter'
+ document.dispatchEvent(keydownEnter)
+
+ setTimeout(() => {
+ expect(spy).not.toHaveBeenCalled()
+ expect(document.querySelector('.tooltip')).not.toBeNull()
+ resolve()
+ }, 20)
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should remove the Escape keydown listener once the tooltip is hidden', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"></a>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => tooltip.hide())
+ tooltipEl.addEventListener('hidden.bs.tooltip', () => {
+ const spy = spyOn(tooltip, 'hide')
+
+ const keydownEscape = createEvent('keydown', { bubbles: true })
+ keydownEscape.key = 'Escape'
+ document.dispatchEvent(keydownEscape)
+
+ expect(spy).not.toHaveBeenCalled()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
})
describe('update', () => {