Permalink
Cannot retrieve contributors at this time
691 lines (573 sloc)
17.6 KB
| '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; |

