From: Mark Otto Date: Sat, 27 Jun 2026 03:15:56 +0000 (-0700) Subject: Tooltip/Popover: fix prevented-show lockout and boolean title/content (#42548) X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=180a522930b6e55b45579342e928540d74b3d5b7;p=thirdparty%2Fbootstrap.git Tooltip/Popover: fix prevented-show lockout and boolean title/content (#42548) * 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 --- diff --git a/js/src/tooltip.js b/js/src/tooltip.js index cf14f7441e..f664882774 100644 --- a/js/src/tooltip.js +++ b/js/src/tooltip.js @@ -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() } diff --git a/js/tests/unit/tooltip.spec.js b/js/tests/unit/tooltip.spec.js index 3f1650b79c..5ad7c939ad 100644 --- a/js/tests/unit/tooltip.spec.js +++ b/js/tests/unit/tooltip.spec.js @@ -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 = '' + + 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 = '
' @@ -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 = '' + + 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 = ''