diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index 25c981393a04..ebe30bb22ec4 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -34,7 +34,7 @@ }, { "path": "./dist/js/bootstrap.bundle.js", - "maxSize": "72.75 kB" + "maxSize": "73.25 kB" }, { "path": "./dist/js/bootstrap.bundle.min.js", @@ -42,7 +42,7 @@ }, { "path": "./dist/js/bootstrap.js", - "maxSize": "44.0 kB" + "maxSize": "44.25 kB" }, { "path": "./dist/js/bootstrap.min.js", diff --git a/js/src/tooltip.js b/js/src/tooltip.js index 106085906dc7..cf14f7441ee8 100644 --- a/js/src/tooltip.js +++ b/js/src/tooltip.js @@ -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') diff --git a/js/tests/unit/tooltip.spec.js b/js/tests/unit/tooltip.spec.js index dc6b72ae1ca6..a20a80bb5e33 100644 --- a/js/tests/unit/tooltip.spec.js +++ b/js/tests/unit/tooltip.spec.js @@ -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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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', () => { diff --git a/site/src/content/docs/components/popover.mdx b/site/src/content/docs/components/popover.mdx index e95656f4fd86..e0e3e3d8244b 100644 --- a/site/src/content/docs/components/popover.mdx +++ b/site/src/content/docs/components/popover.mdx @@ -166,6 +166,8 @@ const popover = new bootstrap.Popover('.popover-dismiss', { }) ``` +A shown popover can also be dismissed by pressing the Escape key. As with dropdown menus, a popover shown inside a dialog is dismissed on its own: the first Escape 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 `