'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: '', 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;