]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
Allow dismissing tooltips and popovers with the Escape key (#42472)
authorMark Otto <markd.otto@gmail.com>
Thu, 11 Jun 2026 18:57:03 +0000 (11:57 -0700)
committerGitHub <noreply@github.com>
Thu, 11 Jun 2026 18:57:03 +0000 (11:57 -0700)
* Allow dismissing tooltips and popovers with the Escape key

* update

.bundlewatch.config.json
js/src/tooltip.js
js/tests/unit/tooltip.spec.js
site/src/content/docs/components/popover.mdx
site/src/content/docs/components/tooltip.mdx

index c85ddbbe73d1a4c29ba0717cf7ee72cefa3601ae..cd2034c8f8fdda71cac454d0523974f2b747b24a 100644 (file)
     },
     {
       "path": "./dist/js/bootstrap.bundle.js",
-      "maxSize": "80.0 kB"
+      "maxSize": "80.25 kB"
     },
     {
       "path": "./dist/js/bootstrap.bundle.min.js",
-      "maxSize": "52.0 kB"
+      "maxSize": "52.25 kB"
     },
     {
       "path": "./dist/js/bootstrap.js",
-      "maxSize": "51.25 kB"
+      "maxSize": "51.5 kB"
     },
     {
       "path": "./dist/js/bootstrap.min.js",
-      "maxSize": "30.0 kB"
+      "maxSize": "30.25 kB"
     }
   ],
   "ci": {
index 106085906dc7f556899cb2bfa9a8d75b807dd5a9..cf14f7441ee8e5095e626af9acb2f79c73519b9c 100644 (file)
@@ -35,6 +35,8 @@ import {
 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'
@@ -60,6 +62,7 @@ const EVENT_FOCUSIN = 'focusin'
 const EVENT_FOCUSOUT = 'focusout'
 const EVENT_MOUSEENTER = 'mouseenter'
 const EVENT_MOUSELEAVE = 'mouseleave'
+const EVENT_KEYDOWN = 'keydown'
 
 const AttachmentMap = {
   AUTO: 'auto',
@@ -130,6 +133,7 @@ class Tooltip extends BaseComponent {
     this._isHovered = null
     this._activeTrigger = {}
     this._floatingCleanup = null
+    this._keydownHandler = null
     this._templateFactory = null
     this._newContent = null
     this._mediaQueryListeners = []
@@ -188,6 +192,8 @@ class Tooltip extends BaseComponent {
   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')) {
@@ -237,6 +243,9 @@ class Tooltip extends BaseComponent {
 
     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
@@ -270,6 +279,8 @@ class Tooltip extends BaseComponent {
       return
     }
 
+    this._removeEscapeListener()
+
     const tip = this._getTipElement()
     tip.classList.remove(CLASS_NAME_SHOW)
 
@@ -599,6 +610,41 @@ class Tooltip extends BaseComponent {
     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')
 
index dc6b72ae1ca6ed174fa327230d2117522aebf5b4..a20a80bb5e335a2c27758e8a4157515f07be7226 100644 (file)
@@ -1042,6 +1042,109 @@ describe('Tooltip', () => {
         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', () => {
index e95656f4fd86af09e5fb4a2abfca458dc42c7833..e0e3e3d8244be31dd84555b4d8d10b2c3f5b301c 100644 (file)
@@ -166,6 +166,8 @@ const popover = new bootstrap.Popover('.popover-dismiss', {
 })
 ```
 
+A shown popover can also be dismissed by pressing the <kbd>Escape</kbd> key. As with dropdown menus, a popover shown inside a dialog is dismissed on its own: the first <kbd>Escape</kbd> closes the popover and a subsequent one closes the dialog.
+
 ### Disabled elements
 
 Elements with the `disabled` attribute aren’t interactive, meaning users cannot hover or click them to trigger a popover (or tooltip). As a workaround, you’ll want to trigger the popover from a wrapper `<div>` or `<span>`, ideally made keyboard-focusable using `tabindex="0"`.
index 2dfb26fde30df78c55da09d91089ebf7d31c2bf1..2a5cc65d729d1068aaa51d15a659a1916e695a14 100644 (file)
@@ -191,6 +191,8 @@ The required markup for a tooltip is only a `data` attribute and `title` on the
 **Keep tooltips accessible to keyboard and assistive technology users** by only adding them to HTML elements that are traditionally keyboard-focusable and interactive (such as links or form controls). While other HTML elements can be made focusable by adding `tabindex="0"`, this can create annoying and confusing tab stops on non-interactive elements for keyboard users, and most assistive technologies currently do not announce tooltips in this situation. Additionally, do not rely solely on `hover` as the trigger for your tooltips as this will make them impossible to trigger for keyboard users.
 </Callout>
 
+A shown tooltip can be dismissed by pressing the <kbd>Escape</kbd> key, helping satisfy the [WCAG 1.4.13 “Content on Hover or Focus”](https://www.w3.org/WAI/WCAG21/Understanding/content-on-hover-or-focus.html) success criterion. As with dropdown menus, a tooltip shown inside a dialog is dismissed on its own: the first <kbd>Escape</kbd> closes the tooltip and a subsequent one closes the dialog.
+
 ```html
 <!-- HTML to write -->
 <a href="#" data-bs-toggle="tooltip" data-bs-title="Some tooltip text!">Hover over me</a>