diff options
| author | Bobby <[email protected]> | 2024-08-16 20:47:33 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2024-08-16 20:47:33 -0400 |
| commit | 6b28433d9cfde435be8ec2bd6cf91e6324d08865 (patch) | |
| tree | 8343c27b8b95ff5639233e81cf157f92e5688466 /js/src/tooltip.js | |
| parent | d53094ec16ba385faae2973ddee648698b32ab24 (diff) | |
| parent | 048f56f51460df75e92a2f7b472e1c56baeb68f7 (diff) | |
| download | bootstrap-main.tar.xz bootstrap-main.zip | |
Diffstat (limited to 'js/src/tooltip.js')
| -rw-r--r-- | js/src/tooltip.js | 482 |
1 files changed, 226 insertions, 256 deletions
diff --git a/js/src/tooltip.js b/js/src/tooltip.js index 29be4d8d2..92d455349 100644 --- a/js/src/tooltip.js +++ b/js/src/tooltip.js @@ -1,44 +1,31 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.1.3): tooltip.js + * Bootstrap tooltip.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ import * as Popper from '@popperjs/core' +import BaseComponent from './base-component.js' +import EventHandler from './dom/event-handler.js' +import Manipulator from './dom/manipulator.js' import { - defineJQueryPlugin, - findShadowRoot, - getElement, - getUID, - isRTL, - noop, - typeCheckConfig -} from './util/index' -import { DefaultAllowlist } from './util/sanitizer' -import Data from './dom/data' -import EventHandler from './dom/event-handler' -import Manipulator from './dom/manipulator' -import BaseComponent from './base-component' -import TemplateFactory from './util/template-factory' + defineJQueryPlugin, execute, findShadowRoot, getElement, getUID, isRTL, noop +} from './util/index.js' +import { DefaultAllowlist } from './util/sanitizer.js' +import TemplateFactory from './util/template-factory.js' /** * 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 HOVER_STATE_SHOW = 'show' -const HOVER_STATE_OUT = 'out' - -const SELECTOR_TOOLTIP_ARROW = '.tooltip-arrow' const SELECTOR_TOOLTIP_INNER = '.tooltip-inner' const SELECTOR_MODAL = `.${CLASS_NAME_MODAL}` @@ -49,6 +36,17 @@ 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', @@ -58,59 +56,46 @@ const AttachmentMap = { } const Default = { + allowList: DefaultAllowlist, animation: true, - template: '<div class="tooltip" role="tooltip">' + - '<div class="tooltip-arrow"></div>' + - '<div class="tooltip-inner"></div>' + - '</div>', - trigger: 'hover focus', - title: '', + boundary: 'clippingParents', + container: false, + customClass: '', delay: 0, + fallbackPlacements: ['top', 'right', 'bottom', 'left'], html: false, - selector: false, + offset: [0, 6], placement: 'top', - offset: [0, 0], - container: false, - fallbackPlacements: ['top', 'right', 'bottom', 'left'], - boundary: 'clippingParents', - customClass: '', + popperConfig: null, sanitize: true, sanitizeFn: null, - allowList: DefaultAllowlist, - popperConfig: 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', - template: 'string', - title: '(string|element|function)', - trigger: 'string', + boundary: '(string|element)', + container: '(string|element|boolean)', + customClass: '(string|function)', delay: '(number|object)', + fallbackPlacements: 'array', html: 'boolean', - selector: '(string|boolean)', - placement: '(string|function)', offset: '(array|string|function)', - container: '(string|element|boolean)', - fallbackPlacements: 'array', - boundary: '(string|element)', - customClass: '(string|function)', + placement: '(string|function)', + popperConfig: '(null|object|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}` + selector: '(string|boolean)', + template: 'string', + title: '(string|element|function)', + trigger: 'string' } /** @@ -120,24 +105,28 @@ const Event = { class Tooltip extends BaseComponent { constructor(element, config) { if (typeof Popper === 'undefined') { - throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org)') + throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org/docs/v2/)') } - super(element) + super(element, config) // Private this._isEnabled = true this._timeout = 0 - this._hoverState = '' + this._isHovered = null this._activeTrigger = {} this._popper = null this._templateFactory = null + this._newContent = null // Protected - this._config = this._getConfig(config) this.tip = null this._setListeners() + + if (!this._config.selector) { + this._fixTitle() + } } // Getters @@ -145,18 +134,14 @@ class Tooltip extends BaseComponent { return Default } - static get NAME() { - return NAME - } - - static get Event() { - return Event - } - static get DefaultType() { return DefaultType } + static get NAME() { + return NAME + } + // Public enable() { this._isEnabled = true @@ -170,29 +155,18 @@ class Tooltip extends BaseComponent { this._isEnabled = !this._isEnabled } - toggle(event) { + toggle() { 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) + this._activeTrigger.click = !this._activeTrigger.click + if (this._isShown()) { + this._leave() + return } + + this._enter() } dispose() { @@ -200,8 +174,8 @@ class Tooltip extends BaseComponent { EventHandler.off(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler) - if (this.tip) { - this.tip.remove() + if (this._element.getAttribute('data-bs-original-title')) { + this._element.setAttribute('title', this._element.getAttribute('data-bs-original-title')) } this._disposePopper() @@ -213,41 +187,33 @@ class Tooltip extends BaseComponent { throw new Error('Please use show on visible elements') } - if (!(this.isWithContent() && this._isEnabled)) { + if (!(this._isWithContent() && this._isEnabled)) { return } - const showEvent = EventHandler.trigger(this._element, this.constructor.Event.SHOW) + const showEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOW)) const shadowRoot = findShadowRoot(this._element) - const isInTheDom = shadowRoot === null ? - this._element.ownerDocument.documentElement.contains(this._element) : - shadowRoot.contains(this._element) + const isInTheDom = (shadowRoot || this._element.ownerDocument.documentElement).contains(this._element) if (showEvent.defaultPrevented || !isInTheDom) { return } - const tip = this.getTipElement() + // 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 - Data.set(tip, this.constructor.DATA_KEY, this) if (!this._element.ownerDocument.documentElement.contains(this.tip)) { container.append(tip) - EventHandler.trigger(this._element, this.constructor.Event.INSERTED) + EventHandler.trigger(this._element, this.constructor.eventName(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)) - } + this._popper = this._createPopper(tip) tip.classList.add(CLASS_NAME_SHOW) @@ -262,46 +228,29 @@ class Tooltip extends BaseComponent { } const complete = () => { - const prevHoverState = this._hoverState + EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOWN)) - this._hoverState = null - EventHandler.trigger(this._element, this.constructor.Event.SHOWN) - - if (prevHoverState === HOVER_STATE_OUT) { - this._leave(null, this) + if (this._isHovered === false) { + this._leave() } + + this._isHovered = false } - const isAnimated = this.tip.classList.contains(CLASS_NAME_FADE) - this._queueCallback(complete, this.tip, isAnimated) + this._queueCallback(complete, this.tip, this._isAnimated()) } hide() { - if (!this._popper) { + if (!this._isShown()) { return } - const tip = this.getTipElement() - const complete = () => { - if (this._isWithActiveTrigger()) { - return - } - - if (this._hoverState !== HOVER_STATE_SHOW) { - tip.remove() - } - - 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) + 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 @@ -315,59 +264,70 @@ class Tooltip extends BaseComponent { 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() + } - const isAnimated = this.tip.classList.contains(CLASS_NAME_FADE) - this._queueCallback(complete, this.tip, isAnimated) - this._hoverState = '' + 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 !== 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._newContent || this._getContentForTemplate()) } - const templateFactory = this._getTemplateFactory(this._getContentForTemplate()) + return this.tip + } + + _createTipElement(content) { + const tip = this._getTemplateFactory(content).toHtml() + + // TODO: remove this check in v6 + if (!tip) { + return null + } - const tip = templateFactory.toHtml() tip.classList.remove(CLASS_NAME_FADE, CLASS_NAME_SHOW) - // todo on v6 the following can be done on css only + // TODO: 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._config.animation) { + if (this._isAnimated()) { tip.classList.add(CLASS_NAME_FADE) } - this.tip = tip - return this.tip + return tip } setContent(content) { - let isShown = false - if (this.tip) { - isShown = this.tip.classList.contains(CLASS_NAME_SHOW) - this.tip.remove() - } - - this._disposePopper() - - this.tip = this._getTemplateFactory(content).toHtml() - - if (isShown) { + this._newContent = content + if (this._isShown()) { + this._disposePopper() this.show() } } @@ -390,36 +350,38 @@ class Tooltip extends BaseComponent { _getContentForTemplate() { return { - [SELECTOR_TOOLTIP_INNER]: this.getTitle() + [SELECTOR_TOOLTIP_INNER]: this._getTitle() } } - getTitle() { - return this._resolvePossibleFunction(this._config.title) || this._element.getAttribute('title') + _getTitle() { + return this._resolvePossibleFunction(this._config.title) || this._element.getAttribute('data-bs-original-title') } - updateAttachment(attachment) { - if (attachment === 'right') { - return 'end' - } + // Private + _initializeOnDelegatedTarget(event) { + return this.constructor.getOrCreateInstance(event.delegateTarget, this._getDelegateConfig()) + } - if (attachment === 'left') { - return 'start' - } + _isAnimated() { + return this._config.animation || (this.tip && this.tip.classList.contains(CLASS_NAME_FADE)) + } - return attachment + _isShown() { + return this.tip && this.tip.classList.contains(CLASS_NAME_SHOW) } - // Private - _initializeOnDelegatedTarget(event, context) { - return context || this.constructor.getOrCreateInstance(event.delegateTarget, this._getDelegateConfig()) + _createPopper(tip) { + const placement = execute(this._config.placement, [this, tip, this._element]) + 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(val => Number.parseInt(val, 10)) + return offset.split(',').map(value => Number.parseInt(value, 10)) } if (typeof offset === 'function') { @@ -430,7 +392,7 @@ class Tooltip extends BaseComponent { } _resolvePossibleFunction(arg) { - return typeof arg === 'function' ? arg.call(this._element) : arg + return execute(arg, [this._element, this._element]) } _getPopperConfig(attachment) { @@ -458,7 +420,17 @@ class Tooltip extends BaseComponent { { name: 'arrow', options: { - element: SELECTOR_TOOLTIP_ARROW + 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) } } ] @@ -466,7 +438,7 @@ class Tooltip extends BaseComponent { return { ...defaultBsPopperConfig, - ...(typeof this._config.popperConfig === 'function' ? this._config.popperConfig(defaultBsPopperConfig) : this._config.popperConfig) + ...execute(this._config.popperConfig, [undefined, defaultBsPopperConfig]) } } @@ -475,17 +447,30 @@ class Tooltip extends BaseComponent { for (const trigger of triggers) { if (trigger === 'click') { - EventHandler.on(this._element, this.constructor.Event.CLICK, this._config.selector, event => this.toggle(event)) + 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.Event.MOUSEENTER : - this.constructor.Event.FOCUSIN + this.constructor.eventName(EVENT_MOUSEENTER) : + this.constructor.eventName(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.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() + }) } } @@ -496,83 +481,55 @@ class Tooltip extends BaseComponent { } 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(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.trim()) { + this._element.setAttribute('aria-label', title) } - context._timeout = setTimeout(() => { - if (context._hoverState === HOVER_STATE_SHOW) { - context.show() - } - }, context._config.delay.show) + this._element.setAttribute('data-bs-original-title', title) // DO NOT USE IT. Is only for backwards compatibility + 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._isShown() || 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) + } + + _setTimeout(handler, timeout) { + clearTimeout(this._timeout) + this._timeout = setTimeout(handler, timeout) } _isWithActiveTrigger() { @@ -582,18 +539,23 @@ class Tooltip extends BaseComponent { _getConfig(config) { const dataAttributes = Manipulator.getDataAttributes(this._element) - for (const dataAttr of Object.keys(dataAttributes)) { - if (DISALLOWED_ATTRIBUTES.has(dataAttr)) { - delete dataAttributes[dataAttr] + for (const dataAttribute of Object.keys(dataAttributes)) { + if (DISALLOWED_ATTRIBUTES.has(dataAttribute)) { + delete dataAttributes[dataAttribute] } } config = { - ...this.constructor.Default, ...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') { @@ -611,19 +573,21 @@ class Tooltip extends BaseComponent { 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] + for (const [key, value] of Object.entries(this._config)) { + if (this.constructor.Default[key] !== value) { + config[key] = value } } + 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)` @@ -635,21 +599,27 @@ class Tooltip extends BaseComponent { 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') { - 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]() }) } } |
