]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
Tooltip/Popover: fix prevented-show lockout and boolean title/content (#42548)
authorMark Otto <markd.otto@gmail.com>
Sat, 27 Jun 2026 03:15:56 +0000 (20:15 -0700)
committerGitHub <noreply@github.com>
Sat, 27 Jun 2026 03:15:56 +0000 (20:15 -0700)
* Tooltip/Popover: reset hover state when show is prevented

A prevented (or not-in-DOM) show() returned early without clearing
_isHovered, leaving it stuck true. For click-triggered tips that made
every later click hit the _enter() early-return, so the tip never
reopened after one preventDefault(). Reset _isHovered on the early
return. Fixes #39861.

* Tooltip/Popover: coerce boolean title/content to strings

data-bs-title="true" / data-bs-content="false" are auto-converted to
booleans by the data-API, which failed the (null|string|element|function)
type check and threw. Extend the existing number-to-string coercion in
_configAfterMerge to booleans. Fixes #41925.

* Bump bundlewatch size thresholds

js/src/tooltip.js
js/tests/unit/tooltip.spec.js

index cf14f7441ee8e5095e626af9acb2f79c73519b9c..f664882774c194f2cb2cf632d90cc9035fcecf81 100644 (file)
@@ -219,6 +219,11 @@ class Tooltip extends BaseComponent {
     const isInTheDom = (shadowRoot || this._element.ownerDocument.documentElement).contains(this._element)
 
     if (showEvent.defaultPrevented || !isInTheDom) {
+      // Reset the transient hover/active state so a prevented (or not-in-DOM)
+      // show doesn't leave `_isHovered` stuck true — otherwise a click-triggered
+      // tip would hit the `_enter()` early-return on every later click and never
+      // reopen.
+      this._isHovered = false
       return
     }
 
@@ -727,11 +732,14 @@ class Tooltip extends BaseComponent {
       }
     }
 
-    if (typeof config.title === 'number') {
+    // Coerce number/boolean title and content to strings. `data-bs-title="true"`
+    // / `data-bs-content="false"` are auto-converted to booleans by the data-API,
+    // which would otherwise fail the (null|string|element|function) type check.
+    if (typeof config.title === 'number' || typeof config.title === 'boolean') {
       config.title = config.title.toString()
     }
 
-    if (typeof config.content === 'number') {
+    if (typeof config.content === 'number' || typeof config.content === 'boolean') {
       config.content = config.content.toString()
     }
 
index 3f1650b79cab15651aacb0f9bd9fb338997e8f8b..5ad7c939ad76a0bdb426745e06b9a39bfc05b16d 100644 (file)
@@ -90,6 +90,16 @@ describe('Tooltip', () => {
       expect(tooltip._config.content).toEqual('7')
     })
 
+    it('should convert title and content to string if booleans (e.g. data-bs-content="true")', () => {
+      fixtureEl.innerHTML = '<a href="#" rel="tooltip" data-bs-title="true" data-bs-content="false"></a>'
+
+      const tooltipEl = fixtureEl.querySelector('a')
+      const tooltip = new Tooltip(tooltipEl)
+
+      expect(tooltip._config.title).toEqual('true')
+      expect(tooltip._config.content).toEqual('false')
+    })
+
     it('should enable selector delegation', () => {
       return new Promise(resolve => {
         fixtureEl.innerHTML = '<div></div>'
@@ -678,6 +688,34 @@ describe('Tooltip', () => {
       })
     })
 
+    it('should reset hover state when show is prevented so the tip can reopen', () => {
+      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 preventOnce = ev => {
+          ev.preventDefault()
+          tooltipEl.removeEventListener('show.bs.tooltip', preventOnce)
+        }
+
+        tooltipEl.addEventListener('show.bs.tooltip', preventOnce)
+
+        tooltipEl.addEventListener('shown.bs.tooltip', () => {
+          expect(document.querySelector('.tooltip')).not.toBeNull()
+          resolve()
+        })
+
+        // First show is prevented — `_isHovered` must not stay stuck true,
+        // otherwise the `_enter()` retry below would early-return and never reopen.
+        tooltip.show()
+        expect(tooltip._isHovered).toBeFalse()
+
+        tooltip._enter()
+      })
+    })
+
     it('should show tooltip if leave event hasn\'t occurred before delay expires', () => {
       return new Promise(resolve => {
         fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"></a>'