0
0
mirror of https://github.com/twbs/bootstrap.git synced 2025-02-26 23:54:23 +01:00

tooltip accessibility (squashed + refactored)

This commit is contained in:
Ryan Berliner 2022-12-30 09:26:55 -05:00
parent 7c392498fa
commit 53d64b595c
2 changed files with 127 additions and 12 deletions

View File

@ -35,6 +35,7 @@ const TRIGGER_HOVER = 'hover'
const TRIGGER_FOCUS = 'focus' const TRIGGER_FOCUS = 'focus'
const TRIGGER_CLICK = 'click' const TRIGGER_CLICK = 'click'
const TRIGGER_MANUAL = 'manual' const TRIGGER_MANUAL = 'manual'
const ESCAPE_KEY = 'Escape'
const EVENT_HIDE = 'hide' const EVENT_HIDE = 'hide'
const EVENT_HIDDEN = 'hidden' const EVENT_HIDDEN = 'hidden'
@ -46,6 +47,7 @@ const EVENT_FOCUSIN = 'focusin'
const EVENT_FOCUSOUT = 'focusout' const EVENT_FOCUSOUT = 'focusout'
const EVENT_MOUSEENTER = 'mouseenter' const EVENT_MOUSEENTER = 'mouseenter'
const EVENT_MOUSELEAVE = 'mouseleave' const EVENT_MOUSELEAVE = 'mouseleave'
const EVENT_KEYDOWN_DISMISS = 'keydown.dismiss'
const AttachmentMap = { const AttachmentMap = {
AUTO: 'auto', AUTO: 'auto',
@ -206,11 +208,38 @@ class Tooltip extends BaseComponent {
this._element.setAttribute('aria-describedby', tip.getAttribute('id')) this._element.setAttribute('aria-describedby', tip.getAttribute('id'))
const { container } = this._config const { container, _trigger } = this._config
if (!this._element.ownerDocument.documentElement.contains(this.tip)) { if (!this._element.ownerDocument.documentElement.contains(this.tip)) {
container.append(tip) container.append(tip)
EventHandler.trigger(this._element, this.constructor.eventName(EVENT_INSERTED)) EventHandler.trigger(this._element, this.constructor.eventName(EVENT_INSERTED))
if (_trigger !== TRIGGER_MANUAL) {
this._maybeDismissHandler = event => {
if (event.key !== ESCAPE_KEY || !this._isWithActiveTrigger()) {
return
}
event.preventDefault()
event.stopPropagation()
this.hide()
}
EventHandler.on(document, this.constructor.eventName(EVENT_KEYDOWN_DISMISS), '*', this._maybeDismissHandler)
if (_trigger !== TRIGGER_CLICK) {
this._tipEventOut = event => {
this._activeTrigger[event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER] = this._isInside(event.relatedTarget)
this._leave()
}
for (const trigger of _trigger.split(' ')) {
const [, eventOut] = this._getTriggerEvents(trigger)
EventHandler.on(tip, eventOut, this._tipEventOut)
}
}
}
} }
this._popper = this._createPopper(tip) this._popper = this._createPopper(tip)
@ -452,12 +481,7 @@ class Tooltip extends BaseComponent {
context.toggle() context.toggle()
}) })
} else if (trigger !== TRIGGER_MANUAL) { } else if (trigger !== TRIGGER_MANUAL) {
const eventIn = trigger === TRIGGER_HOVER ? const [eventIn, eventOut] = this._getTriggerEvents(trigger)
this.constructor.eventName(EVENT_MOUSEENTER) :
this.constructor.eventName(EVENT_FOCUSIN)
const eventOut = trigger === TRIGGER_HOVER ?
this.constructor.eventName(EVENT_MOUSELEAVE) :
this.constructor.eventName(EVENT_FOCUSOUT)
EventHandler.on(this._element, eventIn, this._config.selector, event => { EventHandler.on(this._element, eventIn, this._config.selector, event => {
const context = this._initializeOnDelegatedTarget(event) const context = this._initializeOnDelegatedTarget(event)
@ -466,8 +490,7 @@ class Tooltip extends BaseComponent {
}) })
EventHandler.on(this._element, eventOut, this._config.selector, event => { EventHandler.on(this._element, eventOut, this._config.selector, event => {
const context = this._initializeOnDelegatedTarget(event) const context = this._initializeOnDelegatedTarget(event)
context._activeTrigger[event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER] = context._activeTrigger[event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER] = this._isInside(event.relatedTarget)
context._element.contains(event.relatedTarget)
context._leave() context._leave()
}) })
@ -536,6 +559,23 @@ class Tooltip extends BaseComponent {
return Object.values(this._activeTrigger).includes(true) return Object.values(this._activeTrigger).includes(true)
} }
_isInside(el) {
return this._element.contains(el) || (this.tip && this.tip.contains(el))
}
_getTriggerEvents(trigger) {
return {
[TRIGGER_HOVER]: [
this.constructor.eventName(EVENT_MOUSEENTER),
this.constructor.eventName(EVENT_MOUSELEAVE)
],
[TRIGGER_FOCUS]: [
this.constructor.eventName(EVENT_FOCUSIN),
this.constructor.eventName(EVENT_FOCUSOUT)
]
}[trigger]
}
_getConfig(config) { _getConfig(config) {
const dataAttributes = Manipulator.getDataAttributes(this._element) const dataAttributes = Manipulator.getDataAttributes(this._element)
@ -558,6 +598,11 @@ class Tooltip extends BaseComponent {
_configAfterMerge(config) { _configAfterMerge(config) {
config.container = config.container === false ? document.body : getElement(config.container) config.container = config.container === false ? document.body : getElement(config.container)
// To support delegated tooltip events, tooltips created on the fly have their trigger config
// set to manual. This means that it's particularly difficult to check what triggers a delegated
// tooltip. This property stores the "original" trigger config for easy future reference.
config._trigger = config._trigger || config.trigger
if (typeof config.delay === 'number') { if (typeof config.delay === 'number') {
config.delay = { config.delay = {
show: config.delay, show: config.delay,
@ -600,11 +645,26 @@ class Tooltip extends BaseComponent {
this._popper = null this._popper = null
} }
if (this.tip) { if (!this.tip) {
return
}
const { _trigger } = this._config
if (_trigger !== TRIGGER_MANUAL) {
EventHandler.off(document, this.constructor.eventName(EVENT_KEYDOWN_DISMISS), '*', this._maybeDismissHandler)
if (_trigger !== TRIGGER_CLICK) {
for (const trigger of _trigger.split(' ')) {
const [, eventOut] = this._getTriggerEvents(trigger)
EventHandler.on(this.tip, eventOut, this._tipEventOut)
}
}
}
this.tip.remove() this.tip.remove()
this.tip = null this.tip = null
} }
}
// Static // Static
static jQueryInterface(config) { static jQueryInterface(config) {

View File

@ -778,6 +778,35 @@ describe('Tooltip', () => {
}) })
}) })
it('should not hide a tooltip if cursor moves to tip element', done => {
fixtureEl.innerHTML = [
'<a href="#" rel="tooltip" title="tooltip">',
'trigger',
'</a>'
]
const tooltipEl = fixtureEl.querySelector('a')
const tooltip = new Tooltip(tooltipEl)
spyOn(tooltip, 'hide').and.callThrough()
tooltipEl.addEventListener('shown.bs.tooltip', () => {
const moveMouseToTipElementEvent = createEvent('mouseout')
Object.defineProperty(moveMouseToTipElementEvent, 'relatedTarget', {
value: tooltip._getTipElement()
})
tooltipEl.dispatchEvent(moveMouseToTipElementEvent)
})
tooltipEl.addEventListener('mouseout', () => {
expect(tooltip.hide).not.toHaveBeenCalled()
done()
})
tooltipEl.dispatchEvent(createEvent('mouseover'))
})
it('should not hide tooltip if leave event occurs and interaction remains inside trigger', () => { it('should not hide tooltip if leave event occurs and interaction remains inside trigger', () => {
return new Promise(resolve => { return new Promise(resolve => {
fixtureEl.innerHTML = [ fixtureEl.innerHTML = [
@ -1018,6 +1047,32 @@ describe('Tooltip', () => {
}) })
}) })
it('should hide a tooltip when escape key is pressed and active trigger', done => {
fixtureEl.innerHTML = [
'<a href="#" rel="tooltip" title="tooltip">',
'trigger',
'</a>'
]
const tooltipEl = fixtureEl.querySelector('a')
const tooltip = new Tooltip(tooltipEl)
spyOn(tooltip, 'hide').and.callThrough()
tooltipEl.addEventListener('shown.bs.tooltip', () => {
const keydownEscape = createEvent('keydown')
keydownEscape.key = 'Escape'
tooltipEl.dispatchEvent(keydownEscape)
})
tooltipEl.addEventListener('hidden.bs.tooltip', () => {
expect(tooltip.hide).toHaveBeenCalled()
done()
})
tooltipEl.dispatchEvent(createEvent('mouseover'))
})
it('should not hide a tooltip if hide event is prevented', () => { it('should not hide a tooltip if hide event is prevented', () => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"></a>' fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"></a>'