Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@
},
{
"path": "./dist/js/bootstrap.bundle.js",
"maxSize": "72.75 kB"
"maxSize": "73.25 kB"
},
{
"path": "./dist/js/bootstrap.bundle.min.js",
"maxSize": "51.0 kB"
},
{
"path": "./dist/js/bootstrap.js",
"maxSize": "44.0 kB"
"maxSize": "44.25 kB"
},
{
"path": "./dist/js/bootstrap.min.js",
Expand Down
46 changes: 46 additions & 0 deletions js/src/tooltip.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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',
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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')) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -270,6 +279,8 @@ class Tooltip extends BaseComponent {
return
}

this._removeEscapeListener()

const tip = this._getTipElement()
tip.classList.remove(CLASS_NAME_SHOW)

Expand Down Expand Up @@ -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')

Expand Down
103 changes: 103 additions & 0 deletions js/tests/unit/tooltip.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
2 changes: 2 additions & 0 deletions site/src/content/docs/components/popover.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"`.
Expand Down
2 changes: 2 additions & 0 deletions site/src/content/docs/components/tooltip.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down