/** * -------------------------------------------------------------------------- * Bootstrap (v5.1.3): tooltip.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ import * as Popper from '@popperjs/core' import { defineJQueryPlugin, findShadowRoot, getElement, getUID, isRTL, noop, typeCheckConfig } from './util/index' import { DefaultAllowlist } from './util/sanitizer' import EventHandler from './dom/event-handler' import Manipulator from './dom/manipulator' import BaseComponent from './base-component' import TemplateFactory from './util/template-factory' /** * Constants */ const NAME = 'tooltip' const DATA_KEY = 'bs.tooltip' const EVENT_KEY = `.${DATA_KEY}` const DISALLOWED_ATTRIBUTES = new Set(['sanitize', 'allowList', 'sanitizeFn']) const CLASS_NAME_FADE = 'fade' const CLASS_NAME_MODAL = 'modal' const CLASS_NAME_SHOW = 'show' const SELECTOR_TOOLTIP_INNER = '.tooltip-inner' const SELECTOR_MODAL = `.${CLASS_NAME_MODAL}` const EVENT_MODAL_HIDE = 'hide.bs.modal' const TRIGGER_HOVER = 'hover' const TRIGGER_FOCUS = 'focus' const TRIGGER_CLICK = 'click' const TRIGGER_MANUAL = 'manual' const AttachmentMap = { AUTO: 'auto', TOP: 'top', RIGHT: isRTL() ? 'left' : 'right', BOTTOM: 'bottom', LEFT: isRTL() ? 'right' : 'left' } const Default = { animation: true, template: '', 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 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 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}` } /** * Class definition */ 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) // Private this._isEnabled = true this._timeout = 0 this._isHovered = false this._activeTrigger = {} this._popper = null this._templateFactory = null // Protected this._config = this._getConfig(config) this.tip = null this._setListeners() } // Getters static get Default() { return Default } static get NAME() { return NAME } static get Event() { return Event } static get DefaultType() { return DefaultType } // Public 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() } else { context._leave() } } else { if (this._getTipElement().classList.contains(CLASS_NAME_SHOW)) { this._leave() return } this._enter() } } dispose() { clearTimeout(this._timeout) EventHandler.off(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler) if (this.tip) { this.tip.remove() } this._disposePopper() 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() this._element.setAttribute('aria-describedby', tip.getAttribute('id')) const { container } = this._config if (!this._element.ownerDocument.documentElement.contains(this.tip)) { container.append(tip) EventHandler.trigger(this._element, this.constructor.Event.INSERTED) } if (this._popper) { this._popper.update() } else { const placement = typeof this._config.placement === 'function' ? this._config.placement.call(this, tip, this._element) : this._config.placement const attachment = AttachmentMap[placement.toUpperCase()] this._popper = Popper.createPopper(this._element, tip, this._getPopperConfig(attachment)) } tip.classList.add(CLASS_NAME_SHOW) // 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 // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html if ('ontouchstart' in document.documentElement) { for (const element of [].concat(...document.body.children)) { EventHandler.on(element, 'mouseover', noop) } } const complete = () => { const prevHoverState = this._isHovered this._isHovered = false EventHandler.trigger(this._element, this.constructor.Event.SHOWN) if (prevHoverState) { this._leave() } } this._queueCallback(complete, this.tip, this._isAnimated()) } hide() { if (!this._popper) { return } const hideEvent = EventHandler.trigger(this._element, this.constructor.Event.HIDE) if (hideEvent.defaultPrevented) { return } const tip = this._getTipElement() tip.classList.remove(CLASS_NAME_SHOW) // If this is a touch-enabled device we remove the extra // empty mouseover listeners we added for iOS support if ('ontouchstart' in document.documentElement) { for (const element of [].concat(...document.body.children)) { EventHandler.off(element, 'mouseover', noop) } } this._activeTrigger[TRIGGER_CLICK] = false this._activeTrigger[TRIGGER_FOCUS] = false this._activeTrigger[TRIGGER_HOVER] = false const complete = () => { if (this._isWithActiveTrigger()) { return } if (!this._isHovered) { tip.remove() } this._element.removeAttribute('aria-describedby') EventHandler.trigger(this._element, this.constructor.Event.HIDDEN) this._disposePopper() } this._queueCallback(complete, this.tip, this._isAnimated()) this._isHovered = false } update() { if (this._popper) { this._popper.update() } } // Protected _isWithContent() { return Boolean(this._getTitle()) } _getTipElement() { if (!this.tip) { this.tip = this._createTipElement(this._getContentForTemplate()) } return this.tip } _createTipElement(content) { const tip = this._getTemplateFactory(content).toHtml() // todo: remove this check on v6 if (!tip) { return null } tip.classList.remove(CLASS_NAME_FADE, CLASS_NAME_SHOW) // todo: on v6 the following can be achieved with CSS only tip.classList.add(`bs-${this.constructor.NAME}-auto`) const tipId = getUID(this.constructor.NAME).toString() tip.setAttribute('id', tipId) if (this._isAnimated()) { tip.classList.add(CLASS_NAME_FADE) } return tip } setContent(content) { let isShown = false if (this.tip) { isShown = this.tip.classList.contains(CLASS_NAME_SHOW) this.tip.remove() this.tip = null } this._disposePopper() this.tip = this._createTipElement(content) if (isShown) { this.show() } } _getTemplateFactory(content) { if (this._templateFactory) { this._templateFactory.changeContent(content) } else { this._templateFactory = new TemplateFactory({ ...this._config, // the `content` var has to be after `this._config` // to override config.content in case of popover content, extraClass: this._resolvePossibleFunction(this._config.customClass) }) } return this._templateFactory } _getContentForTemplate() { return { [SELECTOR_TOOLTIP_INNER]: this._getTitle() } } _getTitle() { return this._resolvePossibleFunction(this._config.title) || this._element.getAttribute('title') } // Private _initializeOnDelegatedTarget(event) { return this.constructor.getOrCreateInstance(event.delegateTarget, this._getDelegateConfig()) } _isAnimated() { return this._config.animation || (this.tip && this.tip.classList.contains(CLASS_NAME_FADE)) } _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 } _resolvePossibleFunction(arg) { return typeof arg === 'function' ? arg.call(this._element) : arg } _getPopperConfig(attachment) { const defaultBsPopperConfig = { placement: attachment, modifiers: [ { name: 'flip', options: { 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` } } ] } return { ...defaultBsPopperConfig, ...(typeof this._config.popperConfig === 'function' ? this._config.popperConfig(defaultBsPopperConfig) : this._config.popperConfig) } } _setListeners() { const triggers = this._config.trigger.split(' ') for (const trigger of triggers) { 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 => { const context = this._initializeOnDelegatedTarget(event) context._activeTrigger[event.type === 'focusin' ? TRIGGER_FOCUS : TRIGGER_HOVER] = true context._enter() }) 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._leave() }) } } this._hideModalHandler = () => { if (this._element) { this.hide() } } EventHandler.on(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler) if (this._config.selector) { this._config = { ...this._config, trigger: 'manual', selector: '' } } else { this._fixTitle() } } _fixTitle() { const title = this._element.getAttribute('title') if (title && !this._element.getAttribute('aria-label') && !this._element.textContent) { this._element.setAttribute('aria-label', title) } } _enter() { if (this._getTipElement().classList.contains(CLASS_NAME_SHOW) || this._isHovered) { this._isHovered = true return } this._isHovered = true this._setTimeout(() => { if (this._isHovered) { this.show() } }, this._config.delay.show) } _leave() { if (this._isWithActiveTrigger()) { return } this._isHovered = false this._setTimeout(() => { if (!this._isHovered) { this.hide() } }, this._config.delay.hide) } _setTimeout(handler, timeout) { clearTimeout(this._timeout) this._timeout = setTimeout(handler, timeout) } _isWithActiveTrigger() { return Object.values(this._activeTrigger).includes(true) } _getConfig(config) { const dataAttributes = Manipulator.getDataAttributes(this._element) for (const dataAttr of Object.keys(dataAttributes)) { if (DISALLOWED_ATTRIBUTES.has(dataAttr)) { delete dataAttributes[dataAttr] } } config = { ...this.constructor.Default, ...dataAttributes, ...(typeof config === 'object' && config ? config : {}) } config.container = config.container === false ? document.body : getElement(config.container) 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) return config } _getDelegateConfig() { const config = {} for (const key in this._config) { if (this.constructor.Default[key] !== this._config[key]) { config[key] = this._config[key] } } // In the future can be replaced with: // const keysWithDifferentValues = Object.entries(this._config).filter(entry => this.constructor.Default[entry[0]] !== this._config[entry[0]]) // `Object.fromEntries(keysWithDifferentValues)` return config } _disposePopper() { if (this._popper) { this._popper.destroy() this._popper = null } } // Static static jQueryInterface(config) { return this.each(function () { const data = Tooltip.getOrCreateInstance(this, config) if (typeof config !== 'string') { return } if (typeof data[config] === 'undefined') { throw new TypeError(`No method named "${config}"`) } data[config]() }) } } /** * jQuery */ defineJQueryPlugin(Tooltip) export default Tooltip