0
0
mirror of https://github.com/twbs/bootstrap.git synced 2024-12-10 22:24:19 +01:00
Bootstrap/js/tooltip.js
Quentin Spencer-Harper acc5f65756 Tooltip: Avoid creating an unnecessary tooltip when Tooltip#hide is called
Fixed performance bug where a tooltip element is created and then immediately destroyed when tooltip.hide() is called and there is no existing tooltip element

Resolves #15557 by merging it.
2015-02-26 06:53:58 +01:00

869 lines
21 KiB
JavaScript

/** =======================================================================
* Bootstrap: tooltip.js v4.0.0
* http://getbootstrap.com/javascript/#tooltip
* ========================================================================
* Copyright 2011-2015 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ========================================================================
* @fileoverview - Bootstrap's tooltip plugin.
* (Inspired by jQuery.tipsy by Jason Frame)
*
* Public Methods & Properties:
*
* + $.tooltip
* + $.tooltip.noConflict
* + $.tooltip.Constructor
* + $.tooltip.Constructor.VERSION
* + $.tooltip.Constructor.Defaults
* + $.tooltip.Constructor.Defaults.container
* + $.tooltip.Constructor.Defaults.animation
* + $.tooltip.Constructor.Defaults.placement
* + $.tooltip.Constructor.Defaults.selector
* + $.tooltip.Constructor.Defaults.template
* + $.tooltip.Constructor.Defaults.trigger
* + $.tooltip.Constructor.Defaults.title
* + $.tooltip.Constructor.Defaults.delay
* + $.tooltip.Constructor.Defaults.html
* + $.tooltip.Constructor.Defaults.viewport
* + $.tooltip.Constructor.Defaults.viewport.selector
* + $.tooltip.Constructor.Defaults.viewport.padding
* + $.tooltip.Constructor.prototype.enable
* + $.tooltip.Constructor.prototype.disable
* + $.tooltip.Constructor.prototype.destroy
* + $.tooltip.Constructor.prototype.toggleEnabled
* + $.tooltip.Constructor.prototype.toggle
* + $.tooltip.Constructor.prototype.show
* + $.tooltip.Constructor.prototype.hide
*
* ========================================================================
*/
'use strict';
/**
* Our tooltip class.
* @param {Element!} element
* @param {Object=} opt_config
* @constructor
*/
var Tooltip = function (element, opt_config) {
/** @private {boolean} */
this._isEnabled = true
/** @private {number} */
this._timeout = 0
/** @private {string} */
this._hoverState = ''
/** @protected {Element} */
this.element = element
/** @protected {Object} */
this.config = this._getConfig(opt_config)
/** @protected {Element} */
this.tip = null
/** @protected {Element} */
this.arrow = null
if (this.config['viewport']) {
/** @private {Element} */
this._viewport = $(this.config['viewport']['selector'] || this.config['viewport'])[0]
}
this._setListeners()
}
/**
* @const
* @type {string}
*/
Tooltip['VERSION'] = '4.0.0'
/**
* @const
* @type {Object}
*/
Tooltip['Defaults'] = {
'container' : false,
'animation' : true,
'placement' : 'top',
'selector' : false,
'template' : '<div class="tooltip" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>',
'trigger' : 'hover focus',
'title' : '',
'delay' : 0,
'html' : false,
'viewport': {
'selector': 'body',
'padding' : 0
}
}
/**
* @const
* @enum {string}
* @protected
*/
Tooltip.Direction = {
TOP: 'top',
LEFT: 'left',
RIGHT: 'right',
BOTTOM: 'bottom'
}
/**
* @const
* @type {string}
* @private
*/
Tooltip._NAME = 'tooltip'
/**
* @const
* @type {string}
* @private
*/
Tooltip._DATA_KEY = 'bs.tooltip'
/**
* @const
* @type {number}
* @private
*/
Tooltip._TRANSITION_DURATION = 150
/**
* @const
* @enum {string}
* @private
*/
Tooltip._HoverState = {
IN: 'in',
OUT: 'out'
}
/**
* @const
* @enum {string}
* @private
*/
Tooltip._Event = {
HIDE : 'hide.bs.tooltip',
HIDDEN : 'hidden.bs.tooltip',
SHOW : 'show.bs.tooltip',
SHOWN : 'shown.bs.tooltip'
}
/**
* @const
* @enum {string}
* @private
*/
Tooltip._ClassName = {
FADE : 'fade',
IN : 'in'
}
/**
* @const
* @enum {string}
* @private
*/
Tooltip._Selector = {
TOOLTIP : '.tooltip',
TOOLTIP_INNER : '.tooltip-inner',
TOOLTIP_ARROW : '.tooltip-arrow'
}
/**
* @const
* @type {Function}
* @private
*/
Tooltip._JQUERY_NO_CONFLICT = $.fn[Tooltip._NAME]
/**
* @param {Object=} opt_config
* @this {jQuery}
* @return {jQuery}
* @private
*/
Tooltip._jQueryInterface = function (opt_config) {
return this.each(function () {
var data = $(this).data(Tooltip._DATA_KEY)
var config = typeof opt_config == 'object' ? opt_config : null
if (!data && opt_config == 'destroy') {
return
}
if (!data) {
data = new Tooltip(this, config)
$(this).data(Tooltip._DATA_KEY, data)
}
if (typeof opt_config === 'string') {
data[opt_config]()
}
})
}
/**
* Enable tooltip
*/
Tooltip.prototype['enable'] = function () {
this._isEnabled = true
}
/**
* Disable tooltip
*/
Tooltip.prototype['disable'] = function () {
this._isEnabled = false
}
/**
* Toggle the tooltip enable state
*/
Tooltip.prototype['toggleEnabled'] = function () {
this._isEnabled = !this._isEnabled
}
/**
* Toggle the tooltips display
* @param {Event} opt_event
*/
Tooltip.prototype['toggle'] = function (opt_event) {
var context = this
var dataKey = this.getDataKey()
if (opt_event) {
context = $(opt_event.currentTarget).data(dataKey)
if (!context) {
context = new this.constructor(opt_event.currentTarget, this._getDelegateConfig())
$(opt_event.currentTarget).data(dataKey, context)
}
}
$(context.getTipElement()).hasClass(Tooltip._ClassName.IN) ?
context._leave(null, context) :
context._enter(null, context)
}
/**
* Remove tooltip functionality
*/
Tooltip.prototype['destroy'] = function () {
clearTimeout(this._timeout)
this['hide'](function () {
$(this.element)
.off(Tooltip._Selector.TOOLTIP)
.removeData(this.getDataKey())
}.bind(this))
}
/**
* Show the tooltip
* todo (fat): ~fuck~ this is a big function - refactor out all of positioning logic
* and replace with external lib
*/
Tooltip.prototype['show'] = function () {
var showEvent = $.Event(this.getEventObject().SHOW)
if (this.isWithContent() && this._isEnabled) {
$(this.element).trigger(showEvent)
var isInTheDom = $.contains(this.element.ownerDocument.documentElement, this.element)
if (showEvent.isDefaultPrevented() || !isInTheDom) {
return
}
var tip = this.getTipElement()
var tipId = Bootstrap.getUID(this.getName())
tip.setAttribute('id', tipId)
this.element.setAttribute('aria-describedby', tipId)
this.setContent()
if (this.config['animation']) {
$(tip).addClass(Tooltip._ClassName.FADE)
}
var placement = typeof this.config['placement'] == 'function' ?
this.config['placement'].call(this, tip, this.element) :
this.config['placement']
var autoToken = /\s?auto?\s?/i
var isWithAutoPlacement = autoToken.test(placement)
if (isWithAutoPlacement) {
placement = placement.replace(autoToken, '') || Tooltip.Direction.TOP
}
if (tip.parentNode && tip.parentNode.nodeType == Node.ELEMENT_NODE) {
tip.parentNode.removeChild(tip)
}
tip.style.top = 0
tip.style.left = 0
tip.style.display = 'block'
$(tip).addClass(Tooltip._NAME + '-' + placement)
$(tip).data(this.getDataKey(), this)
if (this.config['container']) {
$(this.config['container'])[0].appendChild(tip)
} else {
this.element.parentNode.insertBefore(tip, this.element.nextSibling)
}
var position = this._getPosition()
var actualWidth = tip.offsetWidth
var actualHeight = tip.offsetHeight
var calculatedPlacement = this._getCalculatedAutoPlacement(isWithAutoPlacement, placement, position, actualWidth, actualHeight)
var calculatedOffset = this._getCalculatedOffset(calculatedPlacement, position, actualWidth, actualHeight)
this._applyCalculatedPlacement(calculatedOffset, calculatedPlacement)
var complete = function () {
var prevHoverState = this.hoverState
$(this.element).trigger(this.getEventObject().SHOWN)
this.hoverState = null
if (prevHoverState == 'out') this._leave(null, this)
}.bind(this)
Bootstrap.transition && $(this._tip).hasClass(Tooltip._ClassName.FADE) ?
$(this._tip)
.one(Bootstrap.TRANSITION_END, complete)
.emulateTransitionEnd(Tooltip._TRANSITION_DURATION) :
complete()
}
}
/**
* Hide the tooltip breh
*/
Tooltip.prototype['hide'] = function (callback) {
var tip = this.getTipElement()
var hideEvent = $.Event(this.getEventObject().HIDE)
var complete = function () {
if (this._hoverState != Tooltip._HoverState.IN) {
tip.parentNode.removeChild(tip)
}
this.element.removeAttribute('aria-describedby')
$(this.element).trigger(this.getEventObject().HIDDEN)
if (callback) {
callback()
}
}.bind(this)
$(this.element).trigger(hideEvent)
if (hideEvent.isDefaultPrevented()) return
$(tip).removeClass(Tooltip._ClassName.IN)
if (Bootstrap.transition && $(this._tip).hasClass(Tooltip._ClassName.FADE)) {
$(tip)
.one(Bootstrap.TRANSITION_END, complete)
.emulateTransitionEnd(Tooltip._TRANSITION_DURATION)
} else {
complete()
}
this._hoverState = ''
}
/**
* @return {string}
*/
Tooltip.prototype['getHoverState'] = function (callback) {
return this._hoverState
}
/**
* @return {string}
* @protected
*/
Tooltip.prototype.getName = function () {
return Tooltip._NAME
}
/**
* @return {string}
* @protected
*/
Tooltip.prototype.getDataKey = function () {
return Tooltip._DATA_KEY
}
/**
* @return {Object}
* @protected
*/
Tooltip.prototype.getEventObject = function () {
return Tooltip._Event
}
/**
* @return {string}
* @protected
*/
Tooltip.prototype.getTitle = function () {
var title = this.element.getAttribute('data-original-title')
if (!title) {
title = typeof this.config['title'] === 'function' ?
this.config['title'].call(this.element) :
this.config['title']
}
return /** @type {string} */ (title)
}
/**
* @return {Element}
* @protected
*/
Tooltip.prototype.getTipElement = function () {
return (this._tip = this._tip || $(this.config['template'])[0])
}
/**
* @return {Element}
* @protected
*/
Tooltip.prototype.getArrowElement = function () {
return (this.arrow = this.arrow || $(this.getTipElement()).find(Tooltip._Selector.TOOLTIP_ARROW)[0])
}
/**
* @return {boolean}
* @protected
*/
Tooltip.prototype.isWithContent = function () {
return !!this.getTitle()
}
/**
* @protected
*/
Tooltip.prototype.setContent = function () {
var tip = this.getTipElement()
var title = this.getTitle()
$(tip).find(Tooltip._Selector.TOOLTIP_INNER)[0][this.config['html'] ? 'innerHTML' : 'innerText'] = title
$(tip)
.removeClass(Tooltip._ClassName.FADE)
.removeClass(Tooltip._ClassName.IN)
for (var direction in Tooltip.Direction) {
$(tip).removeClass(Tooltip._NAME + '-' + direction)
}
}
/**
* @private
*/
Tooltip.prototype._setListeners = function () {
var triggers = this.config['trigger'].split(' ')
triggers.forEach(function (trigger) {
if (trigger == 'click') {
$(this.element).on('click.bs.tooltip', this.config['selector'], this['toggle'].bind(this))
} else if (trigger != 'manual') {
var eventIn = trigger == 'hover' ? 'mouseenter' : 'focusin'
var eventOut = trigger == 'hover' ? 'mouseleave' : 'focusout'
$(this.element)
.on(eventIn + '.bs.tooltip', this.config['selector'], this._enter.bind(this))
.on(eventOut + '.bs.tooltip', this.config['selector'], this._leave.bind(this))
}
}.bind(this))
if (this.config['selector']) {
this.config = $.extend({}, this.config, { 'trigger': 'manual', 'selector': '' })
} else {
this._fixTitle()
}
}
/**
* @param {Object=} opt_config
* @return {Object}
* @private
*/
Tooltip.prototype._getConfig = function (opt_config) {
var config = $.extend({}, this.constructor['Defaults'], $(this.element).data(), opt_config)
if (config['delay'] && typeof config['delay'] == 'number') {
config['delay'] = {
'show': config['delay'],
'hide': config['delay']
}
}
return config
}
/**
* @return {Object}
* @private
*/
Tooltip.prototype._getDelegateConfig = function () {
var config = {}
var defaults = this.constructor['Defaults']
if (this.config) {
for (var key in this.config) {
var value = this.config[key]
if (defaults[key] != value) config[key] = value
}
}
return config
}
/**
* @param {boolean} isWithAutoPlacement
* @param {string} placement
* @param {Object} position
* @param {number} actualWidth
* @param {number} actualHeight
* @return {string}
* @private
*/
Tooltip.prototype._getCalculatedAutoPlacement = function (isWithAutoPlacement, placement, position, actualWidth, actualHeight) {
if (isWithAutoPlacement) {
var originalPlacement = placement
var container = this.config['container'] ? $(this.config['container'])[0] : this.element.parentNode
var containerDim = this._getPosition(/** @type {Element} */ (container))
placement = placement == Tooltip.Direction.BOTTOM && position.bottom + actualHeight > containerDim.bottom ? Tooltip.Direction.TOP :
placement == Tooltip.Direction.TOP && position.top - actualHeight < containerDim.top ? Tooltip.Direction.BOTTOM :
placement == Tooltip.Direction.RIGHT && position.right + actualWidth > containerDim.width ? Tooltip.Direction.LEFT :
placement == Tooltip.Direction.LEFT && position.left - actualWidth < containerDim.left ? Tooltip.Direction.RIGHT :
placement
$(this._tip)
.removeClass(Tooltip._NAME + '-' + originalPlacement)
.addClass(Tooltip._NAME + '-' + placement)
}
return placement
}
/**
* @param {string} placement
* @param {Object} position
* @param {number} actualWidth
* @param {number} actualHeight
* @return {{left: number, top: number}}
* @private
*/
Tooltip.prototype._getCalculatedOffset = function (placement, position, actualWidth, actualHeight) {
return placement == Tooltip.Direction.BOTTOM ? { top: position.top + position.height, left: position.left + position.width / 2 - actualWidth / 2 } :
placement == Tooltip.Direction.TOP ? { top: position.top - actualHeight, left: position.left + position.width / 2 - actualWidth / 2 } :
placement == Tooltip.Direction.LEFT ? { top: position.top + position.height / 2 - actualHeight / 2, left: position.left - actualWidth } :
/* placement == Tooltip.Direction.RIGHT */ { top: position.top + position.height / 2 - actualHeight / 2, left: position.left + position.width }
}
/**
* @param {string} placement
* @param {Object} position
* @param {number} actualWidth
* @param {number} actualHeight
* @return {Object}
* @private
*/
Tooltip.prototype._getViewportAdjustedDelta = function (placement, position, actualWidth, actualHeight) {
var delta = { top: 0, left: 0 }
if (!this._viewport) {
return delta
}
var viewportPadding = this.config['viewport'] && this.config['viewport']['padding'] || 0
var viewportDimensions = this._getPosition(this._viewport)
if (placement === Tooltip.Direction.RIGHT || placement === Tooltip.Direction.LEFT) {
var topEdgeOffset = position.top - viewportPadding - viewportDimensions.scroll
var bottomEdgeOffset = position.top + viewportPadding - viewportDimensions.scroll + actualHeight
if (topEdgeOffset < viewportDimensions.top) { // top overflow
delta.top = viewportDimensions.top - topEdgeOffset
} else if (bottomEdgeOffset > viewportDimensions.top + viewportDimensions.height) { // bottom overflow
delta.top = viewportDimensions.top + viewportDimensions.height - bottomEdgeOffset
}
} else {
var leftEdgeOffset = position.left - viewportPadding
var rightEdgeOffset = position.left + viewportPadding + actualWidth
if (leftEdgeOffset < viewportDimensions.left) { // left overflow
delta.left = viewportDimensions.left - leftEdgeOffset
} else if (rightEdgeOffset > viewportDimensions.width) { // right overflow
delta.left = viewportDimensions.left + viewportDimensions.width - rightEdgeOffset
}
}
return delta
}
/**
* @param {Element=} opt_element
* @return {Object}
* @private
*/
Tooltip.prototype._getPosition = function (opt_element) {
var element = opt_element || this.element
var isBody = element.tagName == 'BODY'
var rect = element.getBoundingClientRect()
var offset = isBody ? { top: 0, left: 0 } : $(element).offset()
var scroll = { scroll: isBody ? document.documentElement.scrollTop || document.body.scrollTop : this.element.scrollTop }
var outerDims = isBody ? { width: window.innerWidth, height: window.innerHeight } : null
return $.extend({}, rect, scroll, outerDims, offset)
}
/**
* @param {{left: number, top: number}} offset
* @param {string} placement
* @private
*/
Tooltip.prototype._applyCalculatedPlacement = function (offset, placement) {
var tip = this.getTipElement()
var width = tip.offsetWidth
var height = tip.offsetHeight
// manually read margins because getBoundingClientRect includes difference
var marginTop = parseInt(tip.style.marginTop, 10)
var marginLeft = parseInt(tip.style.marginLeft, 10)
// we must check for NaN for ie 8/9
if (isNaN(marginTop)) {
marginTop = 0
}
if (isNaN(marginLeft)) {
marginLeft = 0
}
offset.top = offset.top + marginTop
offset.left = offset.left + marginLeft
// $.fn.offset doesn't round pixel values
// so we use setOffset directly with our own function B-0
$.offset.setOffset(tip, $.extend({
using: function (props) {
tip.style.top = Math.round(props.top) + 'px'
tip.style.left = Math.round(props.left) + 'px'
}
}, offset), 0)
$(tip).addClass(Tooltip._ClassName.IN)
// check to see if placing tip in new offset caused the tip to resize itself
var actualWidth = tip.offsetWidth
var actualHeight = tip.offsetHeight
if (placement == Tooltip.Direction.TOP && actualHeight != height) {
offset.top = offset.top + height - actualHeight
}
var delta = this._getViewportAdjustedDelta(placement, offset, actualWidth, actualHeight)
if (delta.left) {
offset.left += delta.left
} else {
offset.top += delta.top
}
var isVertical = placement === Tooltip.Direction.TOP || placement === Tooltip.Direction.BOTTOM
var arrowDelta = isVertical ? delta.left * 2 - width + actualWidth : delta.top * 2 - height + actualHeight
var arrowOffsetPosition = isVertical ? 'offsetWidth' : 'offsetHeight'
$(tip).offset(offset)
this._replaceArrow(arrowDelta, tip[arrowOffsetPosition], isVertical)
}
/**
* @param {number} delta
* @param {number} dimension
* @param {boolean} isHorizontal
* @private
*/
Tooltip.prototype._replaceArrow = function (delta, dimension, isHorizontal) {
var arrow = this.getArrowElement()
arrow.style[isHorizontal ? 'left' : 'top'] = 50 * (1 - delta / dimension) + '%'
arrow.style[isHorizontal ? 'top' : 'left'] = ''
}
/**
* @private
*/
Tooltip.prototype._fixTitle = function () {
if (this.element.getAttribute('title') || typeof this.element.getAttribute('data-original-title') != 'string') {
this.element.setAttribute('data-original-title', this.element.getAttribute('title') || '')
this.element.setAttribute('title', '')
}
}
/**
* @param {Event=} opt_event
* @param {Object=} opt_context
* @private
*/
Tooltip.prototype._enter = function (opt_event, opt_context) {
var dataKey = this.getDataKey()
var context = opt_context || $(opt_event.currentTarget).data(dataKey)
if (context && context._tip && context._tip.offsetWidth) {
context._hoverState = Tooltip._HoverState.IN
return
}
if (!context) {
context = new this.constructor(opt_event.currentTarget, this._getDelegateConfig())
$(opt_event.currentTarget).data(dataKey, context)
}
clearTimeout(context._timeout)
context._hoverState = Tooltip._HoverState.IN
if (!context.config['delay'] || !context.config['delay']['show']) {
context['show']()
return
}
context._timeout = setTimeout(function () {
if (context._hoverState == Tooltip._HoverState.IN) {
context['show']()
}
}, context.config['delay']['show'])
}
/**
* @param {Event=} opt_event
* @param {Object=} opt_context
* @private
*/
Tooltip.prototype._leave = function (opt_event, opt_context) {
var dataKey = this.getDataKey()
var context = opt_context || $(opt_event.currentTarget).data(dataKey)
if (!context) {
context = new this.constructor(opt_event.currentTarget, this._getDelegateConfig())
$(opt_event.currentTarget).data(dataKey, context)
}
clearTimeout(context._timeout)
context._hoverState = Tooltip._HoverState.OUT
if (!context.config['delay'] || !context.config['delay']['hide']) {
context['hide']()
return
}
context._timeout = setTimeout(function () {
if (context._hoverState == Tooltip._HoverState.OUT) {
context['hide']()
}
}, context.config['delay']['hide'])
}
/**
* ------------------------------------------------------------------------
* jQuery Interface + noConflict implementaiton
* ------------------------------------------------------------------------
*/
/**
* @const
* @type {Function}
*/
$.fn[Tooltip._NAME] = Tooltip._jQueryInterface
/**
* @const
* @type {Function}
*/
$.fn[Tooltip._NAME]['Constructor'] = Tooltip
/**
* @const
* @type {Function}
*/
$.fn[Tooltip._NAME]['noConflict'] = function () {
$.fn[Tooltip._NAME] = Tooltip._JQUERY_NO_CONFLICT
return this
}