src/tooltip.js
'use strict';
const EventKit = require('event-kit');
const tooltipComponentsByElement = new WeakMap();
const listen = require('./delegated-listener');
// This tooltip class is derived from Bootstrap 3, but modified to not require
// jQuery, which is an expensive dependency we want to eliminate.
let followThroughTimer = null;
const Tooltip = function(element, options, viewRegistry) {
this.options = null;
this.enabled = null;
this.timeout = null;
this.hoverState = null;
this.element = null;
this.inState = null;
this.viewRegistry = viewRegistry;
this.init(element, options);
};
Tooltip.VERSION = '3.3.5';
Tooltip.FOLLOW_THROUGH_DURATION = 300;
Tooltip.DEFAULTS = {
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,
container: false,
viewport: {
selector: 'body',
padding: 0
}
};
Tooltip.prototype.init = function(element, options) {
this.enabled = true;
this.element = element;
this.options = this.getOptions(options);
this.disposables = new EventKit.CompositeDisposable();
this.mutationObserver = new MutationObserver(this.handleMutations.bind(this));
if (this.options.viewport) {
if (typeof this.options.viewport === 'function') {
this.viewport = this.options.viewport.call(this, this.element);
} else {
this.viewport = document.querySelector(
this.options.viewport.selector || this.options.viewport
);
}
}
this.inState = { click: false, hover: false, focus: false };
if (this.element instanceof document.constructor && !this.options.selector) {
throw new Error(
'`selector` option must be specified when initializing tooltip on the window.document object!'
);
}
const triggers = this.options.trigger.split(' ');
for (let i = triggers.length; i--; ) {
var trigger = triggers[i];
if (trigger === 'click') {
this.disposables.add(
listen(
this.element,
'click',
this.options.selector,
this.toggle.bind(this)
)
);
this.hideOnClickOutsideOfTooltip = event => {
const tooltipElement = this.getTooltipElement();
if (tooltipElement === event.target) return;
if (tooltipElement.contains(event.target)) return;
if (this.element === event.target) return;
if (this.element.contains(event.target)) return;
this.hide();
};
} else if (trigger === 'manual') {
this.show();
} else {
let eventIn, eventOut;
if (trigger === 'hover') {
this.hideOnKeydownOutsideOfTooltip = () => this.hide();
if (this.options.selector) {
eventIn = 'mouseover';
eventOut = 'mouseout';
} else {
eventIn = 'mouseenter';
eventOut = 'mouseleave';
}
} else {
eventIn = 'focusin';
eventOut = 'focusout';
}
this.disposables.add(
listen(
this.element,
eventIn,
this.options.selector,
this.enter.bind(this)
)
);
this.disposables.add(
listen(
this.element,
eventOut,
this.options.selector,
this.leave.bind(this)
)
);
}
}
this.options.selector
? (this._options = extend({}, this.options, {
trigger: 'manual',
selector: ''
}))
: this.fixTitle();
};
Tooltip.prototype.startObservingMutations = function() {
this.mutationObserver.observe(this.getTooltipElement(), {
attributes: true,
childList: true,
characterData: true,
subtree: true
});
};
Tooltip.prototype.stopObservingMutations = function() {
this.mutationObserver.disconnect();
};
Tooltip.prototype.handleMutations = function() {
window.requestAnimationFrame(
function() {
this.stopObservingMutations();
this.recalculatePosition();
this.startObservingMutations();
}.bind(this)
);
};
Tooltip.prototype.getDefaults = function() {
return Tooltip.DEFAULTS;
};
Tooltip.prototype.getOptions = function(options) {
options = extend({}, this.getDefaults(), options);
if (options.delay && typeof options.delay === 'number') {
options.delay = {
show: options.delay,
hide: options.delay
};
}
return options;
};
Tooltip.prototype.getDelegateOptions = function() {
const options = {};
const defaults = this.getDefaults();
if (this._options) {
for (const key of Object.getOwnPropertyNames(this._options)) {
const value = this._options[key];
if (defaults[key] !== value) options[key] = value;
}
}
return options;
};
Tooltip.prototype.enter = function(event) {
if (event) {
if (event.currentTarget !== this.element) {
this.getDelegateComponent(event.currentTarget).enter(event);
return;
}
this.inState[event.type === 'focusin' ? 'focus' : 'hover'] = true;
}
if (
this.getTooltipElement().classList.contains('in') ||
this.hoverState === 'in'
) {
this.hoverState = 'in';
return;
}
clearTimeout(this.timeout);
this.hoverState = 'in';
if (!this.options.delay || !this.options.delay.show || followThroughTimer) {
return this.show();
}
this.timeout = setTimeout(
function() {
if (this.hoverState === 'in') this.show();
}.bind(this),
this.options.delay.show
);
};
Tooltip.prototype.isInStateTrue = function() {
for (const key in this.inState) {
if (this.inState[key]) return true;
}
return false;
};
Tooltip.prototype.leave = function(event) {
if (event) {
if (event.currentTarget !== this.element) {
this.getDelegateComponent(event.currentTarget).leave(event);
return;
}
this.inState[event.type === 'focusout' ? 'focus' : 'hover'] = false;
}
if (this.isInStateTrue()) return;
clearTimeout(this.timeout);
this.hoverState = 'out';
if (!this.options.delay || !this.options.delay.hide) return this.hide();
this.timeout = setTimeout(
function() {
if (this.hoverState === 'out') this.hide();
}.bind(this),
this.options.delay.hide
);
};
Tooltip.prototype.show = function() {
if (this.hasContent() && this.enabled) {
if (this.hideOnClickOutsideOfTooltip) {
window.addEventListener('click', this.hideOnClickOutsideOfTooltip, {
capture: true
});
}
if (this.hideOnKeydownOutsideOfTooltip) {
window.addEventListener(
'keydown',
this.hideOnKeydownOutsideOfTooltip,
true
);
}
const tip = this.getTooltipElement();
this.startObservingMutations();
const tipId = this.getUID('tooltip');
this.setContent();
tip.setAttribute('id', tipId);
this.element.setAttribute('aria-describedby', tipId);
if (this.options.animation) tip.classList.add('fade');
let placement =
typeof this.options.placement === 'function'
? this.options.placement.call(this, tip, this.element)
: this.options.placement;
const autoToken = /\s?auto?\s?/i;
const autoPlace = autoToken.test(placement);
if (autoPlace) placement = placement.replace(autoToken, '') || 'top';
tip.remove();
tip.style.top = '0px';
tip.style.left = '0px';
tip.style.display = 'block';
tip.classList.add(placement);
document.body.appendChild(tip);
const pos = this.element.getBoundingClientRect();
const actualWidth = tip.offsetWidth;
const actualHeight = tip.offsetHeight;
if (autoPlace) {
const orgPlacement = placement;
const viewportDim = this.viewport.getBoundingClientRect();
placement =
placement === 'bottom' && pos.bottom + actualHeight > viewportDim.bottom
? 'top'
: placement === 'top' && pos.top - actualHeight < viewportDim.top
? 'bottom'
: placement === 'right' && pos.right + actualWidth > viewportDim.width
? 'left'
: placement === 'left' && pos.left - actualWidth < viewportDim.left
? 'right'
: placement;
tip.classList.remove(orgPlacement);
tip.classList.add(placement);
}
const calculatedOffset = this.getCalculatedOffset(
placement,
pos,
actualWidth,
actualHeight
);
this.applyPlacement(calculatedOffset, placement);
const prevHoverState = this.hoverState;
this.hoverState = null;
if (prevHoverState === 'out') this.leave();
}
};
Tooltip.prototype.applyPlacement = function(offset, placement) {
const tip = this.getTooltipElement();
const width = tip.offsetWidth;
const height = tip.offsetHeight;
// manually read margins because getBoundingClientRect includes difference
const computedStyle = window.getComputedStyle(tip);
const marginTop = parseInt(computedStyle.marginTop, 10);
const marginLeft = parseInt(computedStyle.marginLeft, 10);
offset.top += marginTop;
offset.left += marginLeft;
tip.style.top = offset.top + 'px';
tip.style.left = offset.left + 'px';
tip.classList.add('in');
// check to see if placing tip in new offset caused the tip to resize itself
const actualWidth = tip.offsetWidth;
const actualHeight = tip.offsetHeight;
if (placement === 'top' && actualHeight !== height) {
offset.top = offset.top + height - actualHeight;
}
const delta = this.getViewportAdjustedDelta(
placement,
offset,
actualWidth,
actualHeight
);
if (delta.left) offset.left += delta.left;
else offset.top += delta.top;
const isVertical = /top|bottom/.test(placement);
const arrowDelta = isVertical
? delta.left * 2 - width + actualWidth
: delta.top * 2 - height + actualHeight;
const arrowOffsetPosition = isVertical ? 'offsetWidth' : 'offsetHeight';
tip.style.top = offset.top + 'px';
tip.style.left = offset.left + 'px';
this.replaceArrow(arrowDelta, tip[arrowOffsetPosition], isVertical);
};
Tooltip.prototype.replaceArrow = function(delta, dimension, isVertical) {
const arrow = this.getArrowElement();
const amount = 50 * (1 - delta / dimension) + '%';
if (isVertical) {
arrow.style.left = amount;
arrow.style.top = '';
} else {
arrow.style.top = amount;
arrow.style.left = '';
}
};
Tooltip.prototype.setContent = function() {
const tip = this.getTooltipElement();
if (this.options.class) {
tip.classList.add(this.options.class);
}
const inner = tip.querySelector('.tooltip-inner');
if (this.options.item) {
inner.appendChild(this.viewRegistry.getView(this.options.item));
} else {
const title = this.getTitle();
if (this.options.html) {
inner.innerHTML = title;
} else {
inner.textContent = title;
}
}
tip.classList.remove('fade', 'in', 'top', 'bottom', 'left', 'right');
};
Tooltip.prototype.hide = function(callback) {
this.inState = {};
if (this.hideOnClickOutsideOfTooltip) {
window.removeEventListener('click', this.hideOnClickOutsideOfTooltip, true);
}
if (this.hideOnKeydownOutsideOfTooltip) {
window.removeEventListener(
'keydown',
this.hideOnKeydownOutsideOfTooltip,
true
);
}
this.tip && this.tip.classList.remove('in');
this.stopObservingMutations();
if (this.hoverState !== 'in') this.tip && this.tip.remove();
this.element.removeAttribute('aria-describedby');
callback && callback();
this.hoverState = null;
clearTimeout(followThroughTimer);
followThroughTimer = setTimeout(function() {
followThroughTimer = null;
}, Tooltip.FOLLOW_THROUGH_DURATION);
return this;
};
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', '');
}
};
Tooltip.prototype.hasContent = function() {
return this.getTitle() || this.options.item;
};
Tooltip.prototype.getCalculatedOffset = function(
placement,
pos,
actualWidth,
actualHeight
) {
return placement === 'bottom'
? {
top: pos.top + pos.height,
left: pos.left + pos.width / 2 - actualWidth / 2
}
: placement === 'top'
? {
top: pos.top - actualHeight,
left: pos.left + pos.width / 2 - actualWidth / 2
}
: placement === 'left'
? {
top: pos.top + pos.height / 2 - actualHeight / 2,
left: pos.left - actualWidth
}
: /* placement === 'right' */ {
top: pos.top + pos.height / 2 - actualHeight / 2,
left: pos.left + pos.width
};
};
Tooltip.prototype.getViewportAdjustedDelta = function(
placement,
pos,
actualWidth,
actualHeight
) {
const delta = { top: 0, left: 0 };
if (!this.viewport) return delta;
const viewportPadding =
(this.options.viewport && this.options.viewport.padding) || 0;
const viewportDimensions = this.viewport.getBoundingClientRect();
if (/right|left/.test(placement)) {
const topEdgeOffset = pos.top - viewportPadding - viewportDimensions.scroll;
const bottomEdgeOffset =
pos.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 {
const leftEdgeOffset = pos.left - viewportPadding;
const rightEdgeOffset = pos.left + viewportPadding + actualWidth;
if (leftEdgeOffset < viewportDimensions.left) {
// left overflow
delta.left = viewportDimensions.left - leftEdgeOffset;
} else if (rightEdgeOffset > viewportDimensions.right) {
// right overflow
delta.left =
viewportDimensions.left + viewportDimensions.width - rightEdgeOffset;
}
}
return delta;
};
Tooltip.prototype.getTitle = function() {
const title = this.element.getAttribute('data-original-title');
if (title) {
return title;
} else {
return typeof this.options.title === 'function'
? this.options.title.call(this.element)
: this.options.title;
}
};
Tooltip.prototype.getUID = function(prefix) {
do prefix += ~~(Math.random() * 1000000);
while (document.getElementById(prefix));
return prefix;
};
Tooltip.prototype.getTooltipElement = function() {
if (!this.tip) {
let div = document.createElement('div');
div.innerHTML = this.options.template;
if (div.children.length !== 1) {
throw new Error(
'Tooltip `template` option must consist of exactly 1 top-level element!'
);
}
this.tip = div.firstChild;
}
return this.tip;
};
Tooltip.prototype.getArrowElement = function() {
this.arrow =
this.arrow || this.getTooltipElement().querySelector('.tooltip-arrow');
return this.arrow;
};
Tooltip.prototype.enable = function() {
this.enabled = true;
};
Tooltip.prototype.disable = function() {
this.enabled = false;
};
Tooltip.prototype.toggleEnabled = function() {
this.enabled = !this.enabled;
};
Tooltip.prototype.toggle = function(event) {
if (event) {
if (event.currentTarget !== this.element) {
this.getDelegateComponent(event.currentTarget).toggle(event);
return;
}
this.inState.click = !this.inState.click;
if (this.isInStateTrue()) this.enter();
else this.leave();
} else {
this.getTooltipElement().classList.contains('in')
? this.leave()
: this.enter();
}
};
Tooltip.prototype.destroy = function() {
clearTimeout(this.timeout);
this.tip && this.tip.remove();
this.disposables.dispose();
};
Tooltip.prototype.getDelegateComponent = function(element) {
let component = tooltipComponentsByElement.get(element);
if (!component) {
component = new Tooltip(
element,
this.getDelegateOptions(),
this.viewRegistry
);
tooltipComponentsByElement.set(element, component);
}
return component;
};
Tooltip.prototype.recalculatePosition = function() {
const tip = this.getTooltipElement();
let placement =
typeof this.options.placement === 'function'
? this.options.placement.call(this, tip, this.element)
: this.options.placement;
const autoToken = /\s?auto?\s?/i;
const autoPlace = autoToken.test(placement);
if (autoPlace) placement = placement.replace(autoToken, '') || 'top';
tip.classList.add(placement);
const pos = this.element.getBoundingClientRect();
const actualWidth = tip.offsetWidth;
const actualHeight = tip.offsetHeight;
if (autoPlace) {
const orgPlacement = placement;
const viewportDim = this.viewport.getBoundingClientRect();
placement =
placement === 'bottom' && pos.bottom + actualHeight > viewportDim.bottom
? 'top'
: placement === 'top' && pos.top - actualHeight < viewportDim.top
? 'bottom'
: placement === 'right' && pos.right + actualWidth > viewportDim.width
? 'left'
: placement === 'left' && pos.left - actualWidth < viewportDim.left
? 'right'
: placement;
tip.classList.remove(orgPlacement);
tip.classList.add(placement);
}
const calculatedOffset = this.getCalculatedOffset(
placement,
pos,
actualWidth,
actualHeight
);
this.applyPlacement(calculatedOffset, placement);
};
function extend() {
const args = Array.prototype.slice.apply(arguments);
const target = args.shift();
let source = args.shift();
while (source) {
for (const key of Object.getOwnPropertyNames(source)) {
target[key] = source[key];
}
source = args.shift();
}
return target;
}
module.exports = Tooltip;