/** * -------------------------------------------------------------------------- * Bootstrap (v5.2.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 } 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 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 EVENT_HIDE = 'hide' const EVENT_HIDDEN = 'hidden' const EVENT_SHOW = 'show' const EVENT_SHOWN = 'shown' const EVENT_INSERTED = 'inserted' const EVENT_CLICK = 'click' const EVENT_FOCUSIN = 'focusin' const EVENT_FOCUSOUT = 'focusout' const EVENT_MOUSEENTER = 'mouseenter' const EVENT_MOUSELEAVE = 'mouseleave' const AttachmentMap = { AUTO: 'auto', TOP: 'top', RIGHT: isRTL() ? 'left' : 'right', BOTTOM: 'bottom', LEFT: isRTL() ? 'right' : 'left' } const Default = { allowList: DefaultAllowlist, animation: true, boundary: 'clippingParents', container: false, customClass: '', delay: 0, fallbackPlacements: ['top', 'right', 'bottom', 'left'], html: false, offset: [0, 0], placement: 'top', popperConfig: null, sanitize: true, sanitizeFn: null, selector: false, template: '', title: '', trigger: 'hover focus' } const DefaultType = { allowList: 'object', animation: 'boolean', boundary: '(string|element)', container: '(string|element|boolean)', customClass: '(string|function)', delay: '(number|object)', fallbackPlacements: 'array', html: 'boolean', offset: '(array|string|function)', placement: '(string|function)', popperConfig: '(null|object|function)', sanitize: 'boolean', sanitizeFn: '(null|function)', selector: '(string|boolean)', template: 'string', title: '(string|element|function)', trigger: 'string' } /** * 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, config) // Private this._isEnabled = true this._timeout = 0 this._isHovered = null this._activeTrigger = {} this._popper = null this._templateFactory = null this._newContent = null // Protected this.tip = null this._setListeners() if (!this._config.selector) { this._fixTitle() } } // Getters static get Default() { return Default } static get DefaultType() { return DefaultType } static get NAME() { return NAME } // Public enable() { this._isEnabled = true } disable() { this._isEnabled = false } toggleEnabled() { this._isEnabled = !this._isEnabled } toggle() { if (!this._isEnabled) { return } this._activeTrigger.click = !this._activeTrigger.click if (this._isShown()) { this._leave() return } this._enter() } dispose() { clearTimeout(this._timeout) EventHandler.off(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler) if (this._element.getAttribute('data-bs-original-title')) { this._element.setAttribute('title', this._element.getAttribute('data-bs-original-title')) } 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.eventName(EVENT_SHOW)) const shadowRoot = findShadowRoot(this._element) const isInTheDom = (shadowRoot || this._element.ownerDocument.documentElement).contains(this._element) if (showEvent.defaultPrevented || !isInTheDom) { return } // todo v6 remove this OR make it optional this._disposePopper() 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.eventName(EVENT_INSERTED)) } this._popper = this._createPopper(tip) 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 = () => { EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOWN)) if (this._isHovered === false) { this._leave() } this._isHovered = false } this._queueCallback(complete, this.tip, this._isAnimated()) } hide() { if (!this._isShown()) { return } const hideEvent = EventHandler.trigger(this._element, this.constructor.eventName(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 this._isHovered = null // it is a trick to support manual triggering const complete = () => { if (this._isWithActiveTrigger()) { return } if (!this._isHovered) { this._disposePopper() } this._element.removeAttribute('aria-describedby') EventHandler.trigger(this._element, this.constructor.eventName(EVENT_HIDDEN)) } this._queueCallback(complete, this.tip, this._isAnimated()) } update() { if (this._popper) { this._popper.update() } } // Protected _isWithContent() { return Boolean(this._getTitle()) } _getTipElement() { if (!this.tip) { this.tip = this._createTipElement(this._newContent || 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) { this._newContent = content if (this._isShown()) { this._disposePopper() 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('data-bs-original-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)) } _isShown() { return this.tip && this.tip.classList.contains(CLASS_NAME_SHOW) } _createPopper(tip) { const placement = typeof this._config.placement === 'function' ? this._config.placement.call(this, tip, this._element) : this._config.placement const attachment = AttachmentMap[placement.toUpperCase()] return Popper.createPopper(this._element, tip, this._getPopperConfig(attachment)) } _getOffset() { const { offset } = this._config if (typeof offset === 'string') { return offset.split(',').map(value => Number.parseInt(value, 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` } }, { name: 'preSetPlacement', enabled: true, phase: 'beforeMain', fn: data => { // Pre-set Popper's placement attribute in order to read the arrow sizes properly. // Otherwise, Popper mixes up the width and height dimensions since the initial arrow style is for top placement this._getTipElement().setAttribute('data-popper-placement', data.state.placement) } } ] } 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.eventName(EVENT_CLICK), this._config.selector, event => { const context = this._initializeOnDelegatedTarget(event) 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) 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) } _fixTitle() { const title = this._element.getAttribute('title') if (!title) { return } if (!this._element.getAttribute('aria-label') && !this._element.textContent.trim()) { this._element.setAttribute('aria-label', title) } this._element.setAttribute('data-bs-original-title', title) // DO NOT USE IT. Is only for backwards compatibility this._element.removeAttribute('title') } _enter() { if (this._isShown() || 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 dataAttribute of Object.keys(dataAttributes)) { if (DISALLOWED_ATTRIBUTES.has(dataAttribute)) { delete dataAttributes[dataAttribute] } } config = { ...dataAttributes, ...(typeof config === 'object' && config ? config : {}) } config = this._mergeConfigObj(config) config = this._configAfterMerge(config) this._typeCheckConfig(config) return config } _configAfterMerge(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() } return config } _getDelegateConfig() { const config = {} for (const key in this._config) { if (this.constructor.Default[key] !== this._config[key]) { config[key] = this._config[key] } } config.selector = false config.trigger = 'manual' // 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 } if (this.tip) { this.tip.remove() this.tip = 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