|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import * as Popper from '@popperjs/core' |
|
|
|
import { |
|
defineJQueryPlugin, |
|
emulateTransitionEnd, |
|
findShadowRoot, |
|
getTransitionDurationFromElement, |
|
getUID, |
|
isElement, |
|
isRTL, |
|
noop, |
|
typeCheckConfig |
|
} from './util/index' |
|
import { |
|
DefaultAllowlist, |
|
sanitizeHtml |
|
} from './util/sanitizer' |
|
import Data from './dom/data' |
|
import EventHandler from './dom/event-handler' |
|
import Manipulator from './dom/manipulator' |
|
import SelectorEngine from './dom/selector-engine' |
|
import BaseComponent from './base-component' |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const NAME = 'tooltip' |
|
const DATA_KEY = 'bs.tooltip' |
|
const EVENT_KEY = `.${DATA_KEY}` |
|
const CLASS_PREFIX = 'bs-tooltip' |
|
const BSCLS_PREFIX_REGEX = new RegExp(`(^|\\s)${CLASS_PREFIX}\\S+`, 'g') |
|
const DISALLOWED_ATTRIBUTES = new Set(['sanitize', 'allowList', 'sanitizeFn']) |
|
|
|
const DefaultType = { |
|
animation: 'boolean', |
|
template: 'string', |
|
title: '(string|element|function)', |
|
trigger: 'string', |
|
delay: '(number|object)', |
|
html: 'boolean', |
|
selector: '(string|boolean)', |
|
placement: '(string|function)', |
|
offset: '(array|string|function)', |
|
container: '(string|element|boolean)', |
|
fallbackPlacements: 'array', |
|
boundary: '(string|element)', |
|
customClass: '(string|function)', |
|
sanitize: 'boolean', |
|
sanitizeFn: '(null|function)', |
|
allowList: 'object', |
|
popperConfig: '(null|object|function)' |
|
} |
|
|
|
const AttachmentMap = { |
|
AUTO: 'auto', |
|
TOP: 'top', |
|
RIGHT: isRTL ? 'left' : 'right', |
|
BOTTOM: 'bottom', |
|
LEFT: isRTL ? 'right' : 'left' |
|
} |
|
|
|
const Default = { |
|
animation: true, |
|
template: '<div class="tooltip" role="tooltip">' + |
|
'<div class="tooltip-arrow"></div>' + |
|
'<div class="tooltip-inner"></div>' + |
|
'</div>', |
|
trigger: 'hover focus', |
|
title: '', |
|
delay: 0, |
|
html: false, |
|
selector: false, |
|
placement: 'top', |
|
offset: [0, 0], |
|
container: false, |
|
fallbackPlacements: ['top', 'right', 'bottom', 'left'], |
|
boundary: 'clippingParents', |
|
customClass: '', |
|
sanitize: true, |
|
sanitizeFn: null, |
|
allowList: DefaultAllowlist, |
|
popperConfig: null |
|
} |
|
|
|
const Event = { |
|
HIDE: `hide${EVENT_KEY}`, |
|
HIDDEN: `hidden${EVENT_KEY}`, |
|
SHOW: `show${EVENT_KEY}`, |
|
SHOWN: `shown${EVENT_KEY}`, |
|
INSERTED: `inserted${EVENT_KEY}`, |
|
CLICK: `click${EVENT_KEY}`, |
|
FOCUSIN: `focusin${EVENT_KEY}`, |
|
FOCUSOUT: `focusout${EVENT_KEY}`, |
|
MOUSEENTER: `mouseenter${EVENT_KEY}`, |
|
MOUSELEAVE: `mouseleave${EVENT_KEY}` |
|
} |
|
|
|
const CLASS_NAME_FADE = 'fade' |
|
const CLASS_NAME_MODAL = 'modal' |
|
const CLASS_NAME_SHOW = 'show' |
|
|
|
const HOVER_STATE_SHOW = 'show' |
|
const HOVER_STATE_OUT = 'out' |
|
|
|
const SELECTOR_TOOLTIP_INNER = '.tooltip-inner' |
|
|
|
const TRIGGER_HOVER = 'hover' |
|
const TRIGGER_FOCUS = 'focus' |
|
const TRIGGER_CLICK = 'click' |
|
const TRIGGER_MANUAL = 'manual' |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Tooltip extends BaseComponent { |
|
constructor(element, config) { |
|
if (typeof Popper === 'undefined') { |
|
throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org)') |
|
} |
|
|
|
super(element) |
|
|
|
|
|
this._isEnabled = true |
|
this._timeout = 0 |
|
this._hoverState = '' |
|
this._activeTrigger = {} |
|
this._popper = null |
|
|
|
|
|
this.config = this._getConfig(config) |
|
this.tip = null |
|
|
|
this._setListeners() |
|
} |
|
|
|
|
|
|
|
static get Default() { |
|
return Default |
|
} |
|
|
|
static get NAME() { |
|
return NAME |
|
} |
|
|
|
static get DATA_KEY() { |
|
return DATA_KEY |
|
} |
|
|
|
static get Event() { |
|
return Event |
|
} |
|
|
|
static get EVENT_KEY() { |
|
return EVENT_KEY |
|
} |
|
|
|
static get DefaultType() { |
|
return DefaultType |
|
} |
|
|
|
|
|
|
|
enable() { |
|
this._isEnabled = true |
|
} |
|
|
|
disable() { |
|
this._isEnabled = false |
|
} |
|
|
|
toggleEnabled() { |
|
this._isEnabled = !this._isEnabled |
|
} |
|
|
|
toggle(event) { |
|
if (!this._isEnabled) { |
|
return |
|
} |
|
|
|
if (event) { |
|
const context = this._initializeOnDelegatedTarget(event) |
|
|
|
context._activeTrigger.click = !context._activeTrigger.click |
|
|
|
if (context._isWithActiveTrigger()) { |
|
context._enter(null, context) |
|
} else { |
|
context._leave(null, context) |
|
} |
|
} else { |
|
if (this.getTipElement().classList.contains(CLASS_NAME_SHOW)) { |
|
this._leave(null, this) |
|
return |
|
} |
|
|
|
this._enter(null, this) |
|
} |
|
} |
|
|
|
dispose() { |
|
clearTimeout(this._timeout) |
|
|
|
EventHandler.off(this._element, this.constructor.EVENT_KEY) |
|
EventHandler.off(this._element.closest(`.${CLASS_NAME_MODAL}`), 'hide.bs.modal', this._hideModalHandler) |
|
|
|
if (this.tip && this.tip.parentNode) { |
|
this.tip.parentNode.removeChild(this.tip) |
|
} |
|
|
|
this._isEnabled = null |
|
this._timeout = null |
|
this._hoverState = null |
|
this._activeTrigger = null |
|
if (this._popper) { |
|
this._popper.destroy() |
|
} |
|
|
|
this._popper = null |
|
this.config = null |
|
this.tip = null |
|
super.dispose() |
|
} |
|
|
|
show() { |
|
if (this._element.style.display === 'none') { |
|
throw new Error('Please use show on visible elements') |
|
} |
|
|
|
if (!(this.isWithContent() && this._isEnabled)) { |
|
return |
|
} |
|
|
|
const showEvent = EventHandler.trigger(this._element, this.constructor.Event.SHOW) |
|
const shadowRoot = findShadowRoot(this._element) |
|
const isInTheDom = shadowRoot === null ? |
|
this._element.ownerDocument.documentElement.contains(this._element) : |
|
shadowRoot.contains(this._element) |
|
|
|
if (showEvent.defaultPrevented || !isInTheDom) { |
|
return |
|
} |
|
|
|
const tip = this.getTipElement() |
|
const tipId = getUID(this.constructor.NAME) |
|
|
|
tip.setAttribute('id', tipId) |
|
this._element.setAttribute('aria-describedby', tipId) |
|
|
|
this.setContent() |
|
|
|
if (this.config.animation) { |
|
tip.classList.add(CLASS_NAME_FADE) |
|
} |
|
|
|
const placement = typeof this.config.placement === 'function' ? |
|
this.config.placement.call(this, tip, this._element) : |
|
this.config.placement |
|
|
|
const attachment = this._getAttachment(placement) |
|
this._addAttachmentClass(attachment) |
|
|
|
const container = this._getContainer() |
|
Data.setData(tip, this.constructor.DATA_KEY, this) |
|
|
|
if (!this._element.ownerDocument.documentElement.contains(this.tip)) { |
|
container.appendChild(tip) |
|
} |
|
|
|
EventHandler.trigger(this._element, this.constructor.Event.INSERTED) |
|
|
|
this._popper = Popper.createPopper(this._element, tip, this._getPopperConfig(attachment)) |
|
|
|
tip.classList.add(CLASS_NAME_SHOW) |
|
|
|
const customClass = typeof this.config.customClass === 'function' ? this.config.customClass() : this.config.customClass |
|
if (customClass) { |
|
tip.classList.add(...customClass.split(' ')) |
|
} |
|
|
|
|
|
|
|
|
|
|
|
if ('ontouchstart' in document.documentElement) { |
|
[].concat(...document.body.children).forEach(element => { |
|
EventHandler.on(element, 'mouseover', noop()) |
|
}) |
|
} |
|
|
|
const complete = () => { |
|
const prevHoverState = this._hoverState |
|
|
|
this._hoverState = null |
|
EventHandler.trigger(this._element, this.constructor.Event.SHOWN) |
|
|
|
if (prevHoverState === HOVER_STATE_OUT) { |
|
this._leave(null, this) |
|
} |
|
} |
|
|
|
if (this.tip.classList.contains(CLASS_NAME_FADE)) { |
|
const transitionDuration = getTransitionDurationFromElement(this.tip) |
|
EventHandler.one(this.tip, 'transitionend', complete) |
|
emulateTransitionEnd(this.tip, transitionDuration) |
|
} else { |
|
complete() |
|
} |
|
} |
|
|
|
hide() { |
|
if (!this._popper) { |
|
return |
|
} |
|
|
|
const tip = this.getTipElement() |
|
const complete = () => { |
|
if (this._hoverState !== HOVER_STATE_SHOW && tip.parentNode) { |
|
tip.parentNode.removeChild(tip) |
|
} |
|
|
|
this._cleanTipClass() |
|
this._element.removeAttribute('aria-describedby') |
|
EventHandler.trigger(this._element, this.constructor.Event.HIDDEN) |
|
|
|
if (this._popper) { |
|
this._popper.destroy() |
|
this._popper = null |
|
} |
|
} |
|
|
|
const hideEvent = EventHandler.trigger(this._element, this.constructor.Event.HIDE) |
|
if (hideEvent.defaultPrevented) { |
|
return |
|
} |
|
|
|
tip.classList.remove(CLASS_NAME_SHOW) |
|
|
|
|
|
|
|
if ('ontouchstart' in document.documentElement) { |
|
[].concat(...document.body.children) |
|
.forEach(element => EventHandler.off(element, 'mouseover', noop)) |
|
} |
|
|
|
this._activeTrigger[TRIGGER_CLICK] = false |
|
this._activeTrigger[TRIGGER_FOCUS] = false |
|
this._activeTrigger[TRIGGER_HOVER] = false |
|
|
|
if (this.tip.classList.contains(CLASS_NAME_FADE)) { |
|
const transitionDuration = getTransitionDurationFromElement(tip) |
|
|
|
EventHandler.one(tip, 'transitionend', complete) |
|
emulateTransitionEnd(tip, transitionDuration) |
|
} else { |
|
complete() |
|
} |
|
|
|
this._hoverState = '' |
|
} |
|
|
|
update() { |
|
if (this._popper !== null) { |
|
this._popper.update() |
|
} |
|
} |
|
|
|
|
|
|
|
isWithContent() { |
|
return Boolean(this.getTitle()) |
|
} |
|
|
|
getTipElement() { |
|
if (this.tip) { |
|
return this.tip |
|
} |
|
|
|
const element = document.createElement('div') |
|
element.innerHTML = this.config.template |
|
|
|
this.tip = element.children[0] |
|
return this.tip |
|
} |
|
|
|
setContent() { |
|
const tip = this.getTipElement() |
|
this.setElementContent(SelectorEngine.findOne(SELECTOR_TOOLTIP_INNER, tip), this.getTitle()) |
|
tip.classList.remove(CLASS_NAME_FADE, CLASS_NAME_SHOW) |
|
} |
|
|
|
setElementContent(element, content) { |
|
if (element === null) { |
|
return |
|
} |
|
|
|
if (typeof content === 'object' && isElement(content)) { |
|
if (content.jquery) { |
|
content = content[0] |
|
} |
|
|
|
|
|
if (this.config.html) { |
|
if (content.parentNode !== element) { |
|
element.innerHTML = '' |
|
element.appendChild(content) |
|
} |
|
} else { |
|
element.textContent = content.textContent |
|
} |
|
|
|
return |
|
} |
|
|
|
if (this.config.html) { |
|
if (this.config.sanitize) { |
|
content = sanitizeHtml(content, this.config.allowList, this.config.sanitizeFn) |
|
} |
|
|
|
element.innerHTML = content |
|
} else { |
|
element.textContent = content |
|
} |
|
} |
|
|
|
getTitle() { |
|
let title = this._element.getAttribute('data-bs-original-title') |
|
|
|
if (!title) { |
|
title = typeof this.config.title === 'function' ? |
|
this.config.title.call(this._element) : |
|
this.config.title |
|
} |
|
|
|
return title |
|
} |
|
|
|
updateAttachment(attachment) { |
|
if (attachment === 'right') { |
|
return 'end' |
|
} |
|
|
|
if (attachment === 'left') { |
|
return 'start' |
|
} |
|
|
|
return attachment |
|
} |
|
|
|
|
|
|
|
_initializeOnDelegatedTarget(event, context) { |
|
const dataKey = this.constructor.DATA_KEY |
|
context = context || Data.getData(event.delegateTarget, dataKey) |
|
|
|
if (!context) { |
|
context = new this.constructor(event.delegateTarget, this._getDelegateConfig()) |
|
Data.setData(event.delegateTarget, dataKey, context) |
|
} |
|
|
|
return context |
|
} |
|
|
|
_getOffset() { |
|
const { offset } = this.config |
|
|
|
if (typeof offset === 'string') { |
|
return offset.split(',').map(val => Number.parseInt(val, 10)) |
|
} |
|
|
|
if (typeof offset === 'function') { |
|
return popperData => offset(popperData, this._element) |
|
} |
|
|
|
return offset |
|
} |
|
|
|
_getPopperConfig(attachment) { |
|
const defaultBsPopperConfig = { |
|
placement: attachment, |
|
modifiers: [ |
|
{ |
|
name: 'flip', |
|
options: { |
|
altBoundary: true, |
|
fallbackPlacements: this.config.fallbackPlacements |
|
} |
|
}, |
|
{ |
|
name: 'offset', |
|
options: { |
|
offset: this._getOffset() |
|
} |
|
}, |
|
{ |
|
name: 'preventOverflow', |
|
options: { |
|
boundary: this.config.boundary |
|
} |
|
}, |
|
{ |
|
name: 'arrow', |
|
options: { |
|
element: `.${this.constructor.NAME}-arrow` |
|
} |
|
}, |
|
{ |
|
name: 'onChange', |
|
enabled: true, |
|
phase: 'afterWrite', |
|
fn: data => this._handlePopperPlacementChange(data) |
|
} |
|
], |
|
onFirstUpdate: data => { |
|
if (data.options.placement !== data.placement) { |
|
this._handlePopperPlacementChange(data) |
|
} |
|
} |
|
} |
|
|
|
return { |
|
...defaultBsPopperConfig, |
|
...(typeof this.config.popperConfig === 'function' ? this.config.popperConfig(defaultBsPopperConfig) : this.config.popperConfig) |
|
} |
|
} |
|
|
|
_addAttachmentClass(attachment) { |
|
this.getTipElement().classList.add(`${CLASS_PREFIX}-${this.updateAttachment(attachment)}`) |
|
} |
|
|
|
_getContainer() { |
|
if (this.config.container === false) { |
|
return document.body |
|
} |
|
|
|
if (isElement(this.config.container)) { |
|
return this.config.container |
|
} |
|
|
|
return SelectorEngine.findOne(this.config.container) |
|
} |
|
|
|
_getAttachment(placement) { |
|
return AttachmentMap[placement.toUpperCase()] |
|
} |
|
|
|
_setListeners() { |
|
const triggers = this.config.trigger.split(' ') |
|
|
|
triggers.forEach(trigger => { |
|
if (trigger === 'click') { |
|
EventHandler.on(this._element, this.constructor.Event.CLICK, this.config.selector, event => this.toggle(event) |
|
) |
|
} else if (trigger !== TRIGGER_MANUAL) { |
|
const eventIn = trigger === TRIGGER_HOVER ? |
|
this.constructor.Event.MOUSEENTER : |
|
this.constructor.Event.FOCUSIN |
|
const eventOut = trigger === TRIGGER_HOVER ? |
|
this.constructor.Event.MOUSELEAVE : |
|
this.constructor.Event.FOCUSOUT |
|
|
|
EventHandler.on(this._element, eventIn, this.config.selector, event => this._enter(event)) |
|
EventHandler.on(this._element, eventOut, this.config.selector, event => this._leave(event)) |
|
} |
|
}) |
|
|
|
this._hideModalHandler = () => { |
|
if (this._element) { |
|
this.hide() |
|
} |
|
} |
|
|
|
EventHandler.on(this._element.closest(`.${CLASS_NAME_MODAL}`), 'hide.bs.modal', this._hideModalHandler) |
|
|
|
if (this.config.selector) { |
|
this.config = { |
|
...this.config, |
|
trigger: 'manual', |
|
selector: '' |
|
} |
|
} else { |
|
this._fixTitle() |
|
} |
|
} |
|
|
|
_fixTitle() { |
|
const title = this._element.getAttribute('title') |
|
const originalTitleType = typeof this._element.getAttribute('data-bs-original-title') |
|
|
|
if (title || originalTitleType !== 'string') { |
|
this._element.setAttribute('data-bs-original-title', title || '') |
|
if (title && !this._element.getAttribute('aria-label') && !this._element.textContent) { |
|
this._element.setAttribute('aria-label', title) |
|
} |
|
|
|
this._element.setAttribute('title', '') |
|
} |
|
} |
|
|
|
_enter(event, context) { |
|
context = this._initializeOnDelegatedTarget(event, context) |
|
|
|
if (event) { |
|
context._activeTrigger[ |
|
event.type === 'focusin' ? TRIGGER_FOCUS : TRIGGER_HOVER |
|
] = true |
|
} |
|
|
|
if (context.getTipElement().classList.contains(CLASS_NAME_SHOW) || context._hoverState === HOVER_STATE_SHOW) { |
|
context._hoverState = HOVER_STATE_SHOW |
|
return |
|
} |
|
|
|
clearTimeout(context._timeout) |
|
|
|
context._hoverState = HOVER_STATE_SHOW |
|
|
|
if (!context.config.delay || !context.config.delay.show) { |
|
context.show() |
|
return |
|
} |
|
|
|
context._timeout = setTimeout(() => { |
|
if (context._hoverState === HOVER_STATE_SHOW) { |
|
context.show() |
|
} |
|
}, context.config.delay.show) |
|
} |
|
|
|
_leave(event, context) { |
|
context = this._initializeOnDelegatedTarget(event, context) |
|
|
|
if (event) { |
|
context._activeTrigger[ |
|
event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER |
|
] = false |
|
} |
|
|
|
if (context._isWithActiveTrigger()) { |
|
return |
|
} |
|
|
|
clearTimeout(context._timeout) |
|
|
|
context._hoverState = HOVER_STATE_OUT |
|
|
|
if (!context.config.delay || !context.config.delay.hide) { |
|
context.hide() |
|
return |
|
} |
|
|
|
context._timeout = setTimeout(() => { |
|
if (context._hoverState === HOVER_STATE_OUT) { |
|
context.hide() |
|
} |
|
}, context.config.delay.hide) |
|
} |
|
|
|
_isWithActiveTrigger() { |
|
for (const trigger in this._activeTrigger) { |
|
if (this._activeTrigger[trigger]) { |
|
return true |
|
} |
|
} |
|
|
|
return false |
|
} |
|
|
|
_getConfig(config) { |
|
const dataAttributes = Manipulator.getDataAttributes(this._element) |
|
|
|
Object.keys(dataAttributes).forEach(dataAttr => { |
|
if (DISALLOWED_ATTRIBUTES.has(dataAttr)) { |
|
delete dataAttributes[dataAttr] |
|
} |
|
}) |
|
|
|
if (config && typeof config.container === 'object' && config.container.jquery) { |
|
config.container = config.container[0] |
|
} |
|
|
|
config = { |
|
...this.constructor.Default, |
|
...dataAttributes, |
|
...(typeof config === 'object' && config ? config : {}) |
|
} |
|
|
|
if (typeof config.delay === 'number') { |
|
config.delay = { |
|
show: config.delay, |
|
hide: config.delay |
|
} |
|
} |
|
|
|
if (typeof config.title === 'number') { |
|
config.title = config.title.toString() |
|
} |
|
|
|
if (typeof config.content === 'number') { |
|
config.content = config.content.toString() |
|
} |
|
|
|
typeCheckConfig(NAME, config, this.constructor.DefaultType) |
|
|
|
if (config.sanitize) { |
|
config.template = sanitizeHtml(config.template, config.allowList, config.sanitizeFn) |
|
} |
|
|
|
return config |
|
} |
|
|
|
_getDelegateConfig() { |
|
const config = {} |
|
|
|
if (this.config) { |
|
for (const key in this.config) { |
|
if (this.constructor.Default[key] !== this.config[key]) { |
|
config[key] = this.config[key] |
|
} |
|
} |
|
} |
|
|
|
return config |
|
} |
|
|
|
_cleanTipClass() { |
|
const tip = this.getTipElement() |
|
const tabClass = tip.getAttribute('class').match(BSCLS_PREFIX_REGEX) |
|
if (tabClass !== null && tabClass.length > 0) { |
|
tabClass.map(token => token.trim()) |
|
.forEach(tClass => tip.classList.remove(tClass)) |
|
} |
|
} |
|
|
|
_handlePopperPlacementChange(popperData) { |
|
const { state } = popperData |
|
|
|
if (!state) { |
|
return |
|
} |
|
|
|
this.tip = state.elements.popper |
|
this._cleanTipClass() |
|
this._addAttachmentClass(this._getAttachment(state.placement)) |
|
} |
|
|
|
|
|
|
|
static jQueryInterface(config) { |
|
return this.each(function () { |
|
let data = Data.getData(this, DATA_KEY) |
|
const _config = typeof config === 'object' && config |
|
|
|
if (!data && /dispose|hide/.test(config)) { |
|
return |
|
} |
|
|
|
if (!data) { |
|
data = new Tooltip(this, _config) |
|
} |
|
|
|
if (typeof config === 'string') { |
|
if (typeof data[config] === 'undefined') { |
|
throw new TypeError(`No method named "${config}"`) |
|
} |
|
|
|
data[config]() |
|
} |
|
}) |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
defineJQueryPlugin(NAME, Tooltip) |
|
|
|
export default Tooltip |
|
|