0
0
mirror of https://github.com/twbs/bootstrap.git synced 2024-11-28 10:24:19 +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_CLICK = 'click'
const TRIGGER_MANUAL = 'manual'
const ESCAPE_KEY = 'Escape'
const EVENT_HIDE = 'hide'
const EVENT_HIDDEN = 'hidden'
@ -46,6 +47,7 @@ const EVENT_FOCUSIN = 'focusin'
const EVENT_FOCUSOUT = 'focusout'
const EVENT_MOUSEENTER = 'mouseenter'
const EVENT_MOUSELEAVE = 'mouseleave'
const EVENT_KEYDOWN_DISMISS = 'keydown.dismiss'
const AttachmentMap = {
AUTO: 'auto',
@ -206,11 +208,38 @@ class Tooltip extends BaseComponent {
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)) {
container.append(tip)
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)
@ -452,12 +481,7 @@ class Tooltip extends BaseComponent {
context.toggle()
})
} else if (trigger !== TRIGGER_MANUAL) {
const eventIn = trigger === TRIGGER_HOVER ?
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)
const [eventIn, eventOut] = this._getTriggerEvents(trigger)
EventHandler.on(this._element, eventIn, this._config.selector, event => {
const context = this._initializeOnDelegatedTarget(event)
@ -466,8 +490,7 @@ class Tooltip extends BaseComponent {
})
EventHandler.on(this._element, eventOut, this._config.selector, event => {
const context = this._initializeOnDelegatedTarget(event)
context._activeTrigger[event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER] =
context._element.contains(event.relatedTarget)
context._activeTrigger[event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER] = this._isInside(event.relatedTarget)
context._leave()
})
@ -536,6 +559,23 @@ class Tooltip extends BaseComponent {
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) {
const dataAttributes = Manipulator.getDataAttributes(this._element)
@ -558,6 +598,11 @@ class Tooltip extends BaseComponent {
_configAfterMerge(config) {
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') {
config.delay = {
show: config.delay,
@ -600,10 +645,25 @@ class Tooltip extends BaseComponent {
this._popper = null
}
if (this.tip) {
this.tip.remove()
this.tip = null
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 = null
}
// Static

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', () => {
return new Promise(resolve => {
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', () => {
return new Promise((resolve, reject) => {
fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"></a>'