diff options
Diffstat (limited to 'js/src/tooltip.js')
-rw-r--r-- | js/src/tooltip.js | 633 |
1 files changed, 633 insertions, 0 deletions
diff --git a/js/src/tooltip.js b/js/src/tooltip.js new file mode 100644 index 0000000..748a0e1 --- /dev/null +++ b/js/src/tooltip.js @@ -0,0 +1,633 @@ +/** + * -------------------------------------------------------------------------- + * 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: '<div class="tooltip" role="tooltip">' + + '<div class="tooltip-arrow"></div>' + + '<div class="tooltip-inner"></div>' + + '</div>', + 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 |