diff options
Diffstat (limited to 'js/src/tooltip.js')
-rw-r--r-- | js/src/tooltip.js | 402 |
1 files changed, 149 insertions, 253 deletions
diff --git a/js/src/tooltip.js b/js/src/tooltip.js index f069dc7515..19a9b31685 100644 --- a/js/src/tooltip.js +++ b/js/src/tooltip.js @@ -11,17 +11,15 @@ import { findShadowRoot, getElement, getUID, - isElement, isRTL, noop, typeCheckConfig } from './util/index' -import { DefaultAllowlist, sanitizeHtml } from './util/sanitizer' -import Data from './dom/data' +import { DefaultAllowlist } from './util/sanitizer' import EventHandler from './dom/event-handler' import Manipulator from './dom/manipulator' -import SelectorEngine from './dom/selector-engine' import BaseComponent from './base-component' +import TemplateFactory from './util/template-factory' /** * Constants @@ -30,16 +28,12 @@ import BaseComponent from './base-component' const NAME = 'tooltip' const DATA_KEY = 'bs.tooltip' const EVENT_KEY = `.${DATA_KEY}` -const CLASS_PREFIX = 'bs-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 HOVER_STATE_SHOW = 'show' -const HOVER_STATE_OUT = 'out' - const SELECTOR_TOOLTIP_INNER = '.tooltip-inner' const SELECTOR_MODAL = `.${CLASS_NAME_MODAL}` @@ -129,9 +123,10 @@ class Tooltip extends BaseComponent { // Private this._isEnabled = true this._timeout = 0 - this._hoverState = '' + this._isHovered = false this._activeTrigger = {} this._popper = null + this._templateFactory = null // Protected this._config = this._getConfig(config) @@ -181,17 +176,17 @@ class Tooltip extends BaseComponent { context._activeTrigger.click = !context._activeTrigger.click if (context._isWithActiveTrigger()) { - context._enter(null, context) + context._enter() } else { - context._leave(null, context) + context._leave() } } else { - if (this.getTipElement().classList.contains(CLASS_NAME_SHOW)) { - this._leave(null, this) + if (this._getTipElement().classList.contains(CLASS_NAME_SHOW)) { + this._leave() return } - this._enter(null, this) + this._enter() } } @@ -213,7 +208,7 @@ class Tooltip extends BaseComponent { throw new Error('Please use show on visible elements') } - if (!(this.isWithContent() && this._isEnabled)) { + if (!(this._isWithContent() && this._isEnabled)) { return } @@ -227,33 +222,11 @@ class Tooltip extends BaseComponent { return } - // A trick to recreate a tooltip in case a new title is given by using the NOT documented `data-bs-original-title` - // This will be removed later in favor of a `setContent` method - if (this.constructor.NAME === 'tooltip' && this.tip && this.getTitle() !== this.tip.querySelector(SELECTOR_TOOLTIP_INNER).innerHTML) { - this._disposePopper() - this.tip.remove() - this.tip = null - } + const tip = this._getTipElement() - const tip = this.getTipElement() - const tipId = getUID(this.constructor.NAME) - - tip.setAttribute('id', tipId) - this._element.setAttribute('aria-describedby', tipId) - - 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) + this._element.setAttribute('aria-describedby', tip.getAttribute('id')) const { container } = this._config - Data.set(tip, this.constructor.DATA_KEY, this) if (!this._element.ownerDocument.documentElement.contains(this.tip)) { container.append(tip) @@ -263,16 +236,15 @@ class Tooltip extends BaseComponent { 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) - const customClass = this._resolvePossibleFunction(this._config.customClass) - if (customClass) { - tip.classList.add(...customClass.split(' ')) - } - // 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 @@ -284,18 +256,17 @@ class Tooltip extends BaseComponent { } const complete = () => { - const prevHoverState = this._hoverState + const prevHoverState = this._isHovered - this._hoverState = null + this._isHovered = false EventHandler.trigger(this._element, this.constructor.Event.SHOWN) - if (prevHoverState === HOVER_STATE_OUT) { - this._leave(null, this) + if (prevHoverState) { + this._leave() } } - const isAnimated = this.tip.classList.contains(CLASS_NAME_FADE) - this._queueCallback(complete, this.tip, isAnimated) + this._queueCallback(complete, this.tip, this._isAnimated()) } hide() { @@ -303,28 +274,12 @@ class Tooltip extends BaseComponent { return } - const tip = this.getTipElement() - const complete = () => { - if (this._isWithActiveTrigger()) { - return - } - - if (this._hoverState !== HOVER_STATE_SHOW) { - tip.remove() - } - - this._cleanTipClass() - this._element.removeAttribute('aria-describedby') - EventHandler.trigger(this._element, this.constructor.Event.HIDDEN) - - this._disposePopper() - } - 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 @@ -339,107 +294,116 @@ class Tooltip extends BaseComponent { this._activeTrigger[TRIGGER_FOCUS] = false this._activeTrigger[TRIGGER_HOVER] = false - const isAnimated = this.tip.classList.contains(CLASS_NAME_FADE) - this._queueCallback(complete, this.tip, isAnimated) - this._hoverState = '' + 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 !== null) { + if (this._popper) { this._popper.update() } } // Protected - isWithContent() { - return Boolean(this.getTitle()) + _isWithContent() { + return Boolean(this._getTitle()) } - getTipElement() { - if (this.tip) { - return this.tip + _getTipElement() { + if (!this.tip) { + this.tip = this._createTipElement(this._getContentForTemplate()) } - const element = document.createElement('div') - element.innerHTML = this._config.template - - const tip = element.children[0] - this.setContent(tip) - tip.classList.remove(CLASS_NAME_FADE, CLASS_NAME_SHOW) - - this.tip = tip return this.tip } - setContent(tip) { - this._sanitizeAndSetContent(tip, this.getTitle(), SELECTOR_TOOLTIP_INNER) - } - - _sanitizeAndSetContent(template, content, selector) { - const templateElement = SelectorEngine.findOne(selector, template) + _createTipElement(content) { + const tip = this._getTemplateFactory(content).toHtml() - if (!content && templateElement) { - templateElement.remove() - return + // todo: remove this check on v6 + if (!tip) { + return null } - // we use append for html objects to maintain js events - this.setElementContent(templateElement, content) - } - - setElementContent(element, content) { - if (element === null) { - return - } + 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`) - if (isElement(content)) { - content = getElement(content) + const tipId = getUID(this.constructor.NAME).toString() - // content is a DOM node or a jQuery - if (this._config.html) { - if (content.parentNode !== element) { - element.innerHTML = '' - element.append(content) - } - } else { - element.textContent = content.textContent - } + tip.setAttribute('id', tipId) - return + if (this._isAnimated()) { + tip.classList.add(CLASS_NAME_FADE) } - if (this._config.html) { - if (this._config.sanitize) { - content = sanitizeHtml(content, this._config.allowList, this._config.sanitizeFn) - } + return tip + } - element.innerHTML = content // lgtm [js/xss-through-dom] - } else { - element.textContent = content + setContent(content) { + let isShown = false + if (this.tip) { + isShown = this.tip.classList.contains(CLASS_NAME_SHOW) + this.tip.remove() + this.tip = null } - } - getTitle() { - const title = this._element.getAttribute('data-bs-original-title') || this._config.title + this._disposePopper() + this.tip = this._createTipElement(content) - return this._resolvePossibleFunction(title) + if (isShown) { + this.show() + } } - updateAttachment(attachment) { - if (attachment === 'right') { - return 'end' + _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) + }) } - if (attachment === 'left') { - return 'start' + return this._templateFactory + } + + _getContentForTemplate() { + return { + [SELECTOR_TOOLTIP_INNER]: this._getTitle() } + } - return attachment + _getTitle() { + return this._config.title } // Private - _initializeOnDelegatedTarget(event, context) { - return context || this.constructor.getOrCreateInstance(event.delegateTarget, this._getDelegateConfig()) + _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() { @@ -456,8 +420,8 @@ class Tooltip extends BaseComponent { return offset } - _resolvePossibleFunction(content) { - return typeof content === 'function' ? content.call(this._element) : content + _resolvePossibleFunction(arg) { + return typeof arg === 'function' ? arg.call(this._element) : arg } _getPopperConfig(attachment) { @@ -487,19 +451,8 @@ class Tooltip extends BaseComponent { 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 { @@ -508,14 +461,6 @@ class Tooltip extends BaseComponent { } } - _addAttachmentClass(attachment) { - this.getTipElement().classList.add(`${this._getBasicClassPrefix()}-${this.updateAttachment(attachment)}`) - } - - _getAttachment(placement) { - return AttachmentMap[placement.toUpperCase()] - } - _setListeners() { const triggers = this._config.trigger.split(' ') @@ -530,8 +475,18 @@ class Tooltip extends BaseComponent { 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)) + 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() + }) } } @@ -555,86 +510,55 @@ class Tooltip extends BaseComponent { } _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', '') - } - } + const title = this._config.originalTitle - _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 + if (!title) { return } - clearTimeout(context._timeout) - - context._hoverState = HOVER_STATE_SHOW - - if (!context._config.delay || !context._config.delay.show) { - context.show() - return + if (!this._element.getAttribute('aria-label') && !this._element.textContent) { + this._element.setAttribute('aria-label', title) } - context._timeout = setTimeout(() => { - if (context._hoverState === HOVER_STATE_SHOW) { - context.show() - } - }, context._config.delay.show) + this._element.removeAttribute('title') } - _leave(event, context) { - context = this._initializeOnDelegatedTarget(event, context) - - if (event) { - context._activeTrigger[ - event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER - ] = context._element.contains(event.relatedTarget) - } - - if (context._isWithActiveTrigger()) { + _enter() { + if (this._getTipElement().classList.contains(CLASS_NAME_SHOW) || this._isHovered) { + this._isHovered = true return } - clearTimeout(context._timeout) + this._isHovered = true - context._hoverState = HOVER_STATE_OUT + this._setTimeout(() => { + if (this._isHovered) { + this.show() + } + }, this._config.delay.show) + } - if (!context._config.delay || !context._config.delay.hide) { - context.hide() + _leave() { + if (this._isWithActiveTrigger()) { return } - context._timeout = setTimeout(() => { - if (context._hoverState === HOVER_STATE_OUT) { - context.hide() + this._isHovered = false + + this._setTimeout(() => { + if (!this._isHovered) { + this.hide() } - }, context._config.delay.hide) + }, this._config.delay.hide) } - _isWithActiveTrigger() { - for (const trigger in this._activeTrigger) { - if (this._activeTrigger[trigger]) { - return true - } - } + _setTimeout(handler, timeout) { + clearTimeout(this._timeout) + this._timeout = setTimeout(handler, timeout) + } - return false + _isWithActiveTrigger() { + return Object.values(this._activeTrigger).includes(true) } _getConfig(config) { @@ -661,6 +585,8 @@ class Tooltip extends BaseComponent { } } + config.originalTitle = this._element.getAttribute('title') || '' + config.title = this._resolvePossibleFunction(config.title) || config.originalTitle if (typeof config.title === 'number') { config.title = config.title.toString() } @@ -670,11 +596,6 @@ class Tooltip extends BaseComponent { } typeCheckConfig(NAME, config, this.constructor.DefaultType) - - if (config.sanitize) { - config.template = sanitizeHtml(config.template, config.allowList, config.sanitizeFn) - } - return config } @@ -693,33 +614,6 @@ class Tooltip extends BaseComponent { return config } - _cleanTipClass() { - const tip = this.getTipElement() - const basicClassPrefixRegex = new RegExp(`(^|\\s)${this._getBasicClassPrefix()}\\S+`, 'g') - const tabClass = tip.getAttribute('class').match(basicClassPrefixRegex) - if (tabClass !== null && tabClass.length > 0) { - for (const tClass of tabClass.map(token => token.trim())) { - tip.classList.remove(tClass) - } - } - } - - _getBasicClassPrefix() { - return CLASS_PREFIX - } - - _handlePopperPlacementChange(popperData) { - const { state } = popperData - - if (!state) { - return - } - - this.tip = state.elements.popper - this._cleanTipClass() - this._addAttachmentClass(this._getAttachment(state.placement)) - } - _disposePopper() { if (this._popper) { this._popper.destroy() @@ -733,13 +627,15 @@ class Tooltip extends BaseComponent { return this.each(function () { const data = Tooltip.getOrCreateInstance(this, config) - if (typeof config === 'string') { - if (typeof data[config] === 'undefined') { - throw new TypeError(`No method named "${config}"`) - } + if (typeof config !== 'string') { + return + } - data[config]() + if (typeof data[config] === 'undefined') { + throw new TypeError(`No method named "${config}"`) } + + data[config]() }) } } |