/ src / tooltip.js
tooltip.js
  1  'use strict';
  2  
  3  const EventKit = require('event-kit');
  4  const tooltipComponentsByElement = new WeakMap();
  5  const listen = require('./delegated-listener');
  6  
  7  // This tooltip class is derived from Bootstrap 3, but modified to not require
  8  // jQuery, which is an expensive dependency we want to eliminate.
  9  
 10  var followThroughTimer = null;
 11  
 12  var Tooltip = function(element, options, viewRegistry) {
 13    this.options = null;
 14    this.enabled = null;
 15    this.timeout = null;
 16    this.hoverState = null;
 17    this.element = null;
 18    this.inState = null;
 19    this.viewRegistry = viewRegistry;
 20  
 21    this.init(element, options);
 22  };
 23  
 24  Tooltip.VERSION = '3.3.5';
 25  
 26  Tooltip.FOLLOW_THROUGH_DURATION = 300;
 27  
 28  Tooltip.DEFAULTS = {
 29    animation: true,
 30    placement: 'top',
 31    selector: false,
 32    template:
 33      '<div class="tooltip" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>',
 34    trigger: 'hover focus',
 35    title: '',
 36    delay: 0,
 37    html: false,
 38    container: false,
 39    viewport: {
 40      selector: 'body',
 41      padding: 0
 42    }
 43  };
 44  
 45  Tooltip.prototype.init = function(element, options) {
 46    this.enabled = true;
 47    this.element = element;
 48    this.options = this.getOptions(options);
 49    this.disposables = new EventKit.CompositeDisposable();
 50    this.mutationObserver = new MutationObserver(this.handleMutations.bind(this));
 51  
 52    if (this.options.viewport) {
 53      if (typeof this.options.viewport === 'function') {
 54        this.viewport = this.options.viewport.call(this, this.element);
 55      } else {
 56        this.viewport = document.querySelector(
 57          this.options.viewport.selector || this.options.viewport
 58        );
 59      }
 60    }
 61    this.inState = { click: false, hover: false, focus: false };
 62  
 63    if (this.element instanceof document.constructor && !this.options.selector) {
 64      throw new Error(
 65        '`selector` option must be specified when initializing tooltip on the window.document object!'
 66      );
 67    }
 68  
 69    var triggers = this.options.trigger.split(' ');
 70  
 71    for (var i = triggers.length; i--; ) {
 72      var trigger = triggers[i];
 73  
 74      if (trigger === 'click') {
 75        this.disposables.add(
 76          listen(
 77            this.element,
 78            'click',
 79            this.options.selector,
 80            this.toggle.bind(this)
 81          )
 82        );
 83        this.hideOnClickOutsideOfTooltip = event => {
 84          const tooltipElement = this.getTooltipElement();
 85          if (tooltipElement === event.target) return;
 86          if (tooltipElement.contains(event.target)) return;
 87          if (this.element === event.target) return;
 88          if (this.element.contains(event.target)) return;
 89          this.hide();
 90        };
 91      } else if (trigger === 'manual') {
 92        this.show();
 93      } else {
 94        var eventIn, eventOut;
 95  
 96        if (trigger === 'hover') {
 97          this.hideOnKeydownOutsideOfTooltip = () => this.hide();
 98          if (this.options.selector) {
 99            eventIn = 'mouseover';
100            eventOut = 'mouseout';
101          } else {
102            eventIn = 'mouseenter';
103            eventOut = 'mouseleave';
104          }
105        } else {
106          eventIn = 'focusin';
107          eventOut = 'focusout';
108        }
109  
110        this.disposables.add(
111          listen(
112            this.element,
113            eventIn,
114            this.options.selector,
115            this.enter.bind(this)
116          )
117        );
118        this.disposables.add(
119          listen(
120            this.element,
121            eventOut,
122            this.options.selector,
123            this.leave.bind(this)
124          )
125        );
126      }
127    }
128  
129    this.options.selector
130      ? (this._options = extend({}, this.options, {
131          trigger: 'manual',
132          selector: ''
133        }))
134      : this.fixTitle();
135  };
136  
137  Tooltip.prototype.startObservingMutations = function() {
138    this.mutationObserver.observe(this.getTooltipElement(), {
139      attributes: true,
140      childList: true,
141      characterData: true,
142      subtree: true
143    });
144  };
145  
146  Tooltip.prototype.stopObservingMutations = function() {
147    this.mutationObserver.disconnect();
148  };
149  
150  Tooltip.prototype.handleMutations = function() {
151    window.requestAnimationFrame(
152      function() {
153        this.stopObservingMutations();
154        this.recalculatePosition();
155        this.startObservingMutations();
156      }.bind(this)
157    );
158  };
159  
160  Tooltip.prototype.getDefaults = function() {
161    return Tooltip.DEFAULTS;
162  };
163  
164  Tooltip.prototype.getOptions = function(options) {
165    options = extend({}, this.getDefaults(), options);
166  
167    if (options.delay && typeof options.delay === 'number') {
168      options.delay = {
169        show: options.delay,
170        hide: options.delay
171      };
172    }
173  
174    return options;
175  };
176  
177  Tooltip.prototype.getDelegateOptions = function() {
178    var options = {};
179    var defaults = this.getDefaults();
180  
181    if (this._options) {
182      for (var key of Object.getOwnPropertyNames(this._options)) {
183        var value = this._options[key];
184        if (defaults[key] !== value) options[key] = value;
185      }
186    }
187  
188    return options;
189  };
190  
191  Tooltip.prototype.enter = function(event) {
192    if (event) {
193      if (event.currentTarget !== this.element) {
194        this.getDelegateComponent(event.currentTarget).enter(event);
195        return;
196      }
197  
198      this.inState[event.type === 'focusin' ? 'focus' : 'hover'] = true;
199    }
200  
201    if (
202      this.getTooltipElement().classList.contains('in') ||
203      this.hoverState === 'in'
204    ) {
205      this.hoverState = 'in';
206      return;
207    }
208  
209    clearTimeout(this.timeout);
210  
211    this.hoverState = 'in';
212  
213    if (!this.options.delay || !this.options.delay.show || followThroughTimer) {
214      return this.show();
215    }
216  
217    this.timeout = setTimeout(
218      function() {
219        if (this.hoverState === 'in') this.show();
220      }.bind(this),
221      this.options.delay.show
222    );
223  };
224  
225  Tooltip.prototype.isInStateTrue = function() {
226    for (var key in this.inState) {
227      if (this.inState[key]) return true;
228    }
229  
230    return false;
231  };
232  
233  Tooltip.prototype.leave = function(event) {
234    if (event) {
235      if (event.currentTarget !== this.element) {
236        this.getDelegateComponent(event.currentTarget).leave(event);
237        return;
238      }
239  
240      this.inState[event.type === 'focusout' ? 'focus' : 'hover'] = false;
241    }
242  
243    if (this.isInStateTrue()) return;
244  
245    clearTimeout(this.timeout);
246  
247    this.hoverState = 'out';
248  
249    if (!this.options.delay || !this.options.delay.hide) return this.hide();
250  
251    this.timeout = setTimeout(
252      function() {
253        if (this.hoverState === 'out') this.hide();
254      }.bind(this),
255      this.options.delay.hide
256    );
257  };
258  
259  Tooltip.prototype.show = function() {
260    if (this.hasContent() && this.enabled) {
261      if (this.hideOnClickOutsideOfTooltip) {
262        window.addEventListener('click', this.hideOnClickOutsideOfTooltip, true);
263      }
264  
265      if (this.hideOnKeydownOutsideOfTooltip) {
266        window.addEventListener(
267          'keydown',
268          this.hideOnKeydownOutsideOfTooltip,
269          true
270        );
271      }
272  
273      var tip = this.getTooltipElement();
274      this.startObservingMutations();
275      var tipId = this.getUID('tooltip');
276  
277      this.setContent();
278      tip.setAttribute('id', tipId);
279      this.element.setAttribute('aria-describedby', tipId);
280  
281      if (this.options.animation) tip.classList.add('fade');
282  
283      var placement =
284        typeof this.options.placement === 'function'
285          ? this.options.placement.call(this, tip, this.element)
286          : this.options.placement;
287  
288      var autoToken = /\s?auto?\s?/i;
289      var autoPlace = autoToken.test(placement);
290      if (autoPlace) placement = placement.replace(autoToken, '') || 'top';
291  
292      tip.remove();
293      tip.style.top = '0px';
294      tip.style.left = '0px';
295      tip.style.display = 'block';
296      tip.classList.add(placement);
297  
298      document.body.appendChild(tip);
299  
300      var pos = this.element.getBoundingClientRect();
301      var actualWidth = tip.offsetWidth;
302      var actualHeight = tip.offsetHeight;
303  
304      if (autoPlace) {
305        var orgPlacement = placement;
306        var viewportDim = this.viewport.getBoundingClientRect();
307  
308        placement =
309          placement === 'bottom' && pos.bottom + actualHeight > viewportDim.bottom
310            ? 'top'
311            : placement === 'top' && pos.top - actualHeight < viewportDim.top
312            ? 'bottom'
313            : placement === 'right' && pos.right + actualWidth > viewportDim.width
314            ? 'left'
315            : placement === 'left' && pos.left - actualWidth < viewportDim.left
316            ? 'right'
317            : placement;
318  
319        tip.classList.remove(orgPlacement);
320        tip.classList.add(placement);
321      }
322  
323      var calculatedOffset = this.getCalculatedOffset(
324        placement,
325        pos,
326        actualWidth,
327        actualHeight
328      );
329  
330      this.applyPlacement(calculatedOffset, placement);
331  
332      var prevHoverState = this.hoverState;
333      this.hoverState = null;
334  
335      if (prevHoverState === 'out') this.leave();
336    }
337  };
338  
339  Tooltip.prototype.applyPlacement = function(offset, placement) {
340    var tip = this.getTooltipElement();
341  
342    var width = tip.offsetWidth;
343    var height = tip.offsetHeight;
344  
345    // manually read margins because getBoundingClientRect includes difference
346    var computedStyle = window.getComputedStyle(tip);
347    var marginTop = parseInt(computedStyle.marginTop, 10);
348    var marginLeft = parseInt(computedStyle.marginLeft, 10);
349  
350    offset.top += marginTop;
351    offset.left += marginLeft;
352  
353    tip.style.top = offset.top + 'px';
354    tip.style.left = offset.left + 'px';
355  
356    tip.classList.add('in');
357  
358    // check to see if placing tip in new offset caused the tip to resize itself
359    var actualWidth = tip.offsetWidth;
360    var actualHeight = tip.offsetHeight;
361  
362    if (placement === 'top' && actualHeight !== height) {
363      offset.top = offset.top + height - actualHeight;
364    }
365  
366    var delta = this.getViewportAdjustedDelta(
367      placement,
368      offset,
369      actualWidth,
370      actualHeight
371    );
372  
373    if (delta.left) offset.left += delta.left;
374    else offset.top += delta.top;
375  
376    var isVertical = /top|bottom/.test(placement);
377    var arrowDelta = isVertical
378      ? delta.left * 2 - width + actualWidth
379      : delta.top * 2 - height + actualHeight;
380    var arrowOffsetPosition = isVertical ? 'offsetWidth' : 'offsetHeight';
381  
382    tip.style.top = offset.top + 'px';
383    tip.style.left = offset.left + 'px';
384  
385    this.replaceArrow(arrowDelta, tip[arrowOffsetPosition], isVertical);
386  };
387  
388  Tooltip.prototype.replaceArrow = function(delta, dimension, isVertical) {
389    var arrow = this.getArrowElement();
390    var amount = 50 * (1 - delta / dimension) + '%';
391  
392    if (isVertical) {
393      arrow.style.left = amount;
394      arrow.style.top = '';
395    } else {
396      arrow.style.top = amount;
397      arrow.style.left = '';
398    }
399  };
400  
401  Tooltip.prototype.setContent = function() {
402    var tip = this.getTooltipElement();
403  
404    if (this.options.class) {
405      tip.classList.add(this.options.class);
406    }
407  
408    var inner = tip.querySelector('.tooltip-inner');
409    if (this.options.item) {
410      inner.appendChild(this.viewRegistry.getView(this.options.item));
411    } else {
412      var title = this.getTitle();
413      if (this.options.html) {
414        inner.innerHTML = title;
415      } else {
416        inner.textContent = title;
417      }
418    }
419  
420    tip.classList.remove('fade', 'in', 'top', 'bottom', 'left', 'right');
421  };
422  
423  Tooltip.prototype.hide = function(callback) {
424    this.inState = {};
425  
426    if (this.hideOnClickOutsideOfTooltip) {
427      window.removeEventListener('click', this.hideOnClickOutsideOfTooltip, true);
428    }
429  
430    if (this.hideOnKeydownOutsideOfTooltip) {
431      window.removeEventListener(
432        'keydown',
433        this.hideOnKeydownOutsideOfTooltip,
434        true
435      );
436    }
437  
438    this.tip && this.tip.classList.remove('in');
439    this.stopObservingMutations();
440  
441    if (this.hoverState !== 'in') this.tip && this.tip.remove();
442  
443    this.element.removeAttribute('aria-describedby');
444  
445    callback && callback();
446  
447    this.hoverState = null;
448  
449    clearTimeout(followThroughTimer);
450    followThroughTimer = setTimeout(function() {
451      followThroughTimer = null;
452    }, Tooltip.FOLLOW_THROUGH_DURATION);
453  
454    return this;
455  };
456  
457  Tooltip.prototype.fixTitle = function() {
458    if (
459      this.element.getAttribute('title') ||
460      typeof this.element.getAttribute('data-original-title') !== 'string'
461    ) {
462      this.element.setAttribute(
463        'data-original-title',
464        this.element.getAttribute('title') || ''
465      );
466      this.element.setAttribute('title', '');
467    }
468  };
469  
470  Tooltip.prototype.hasContent = function() {
471    return this.getTitle() || this.options.item;
472  };
473  
474  Tooltip.prototype.getCalculatedOffset = function(
475    placement,
476    pos,
477    actualWidth,
478    actualHeight
479  ) {
480    return placement === 'bottom'
481      ? {
482          top: pos.top + pos.height,
483          left: pos.left + pos.width / 2 - actualWidth / 2
484        }
485      : placement === 'top'
486      ? {
487          top: pos.top - actualHeight,
488          left: pos.left + pos.width / 2 - actualWidth / 2
489        }
490      : placement === 'left'
491      ? {
492          top: pos.top + pos.height / 2 - actualHeight / 2,
493          left: pos.left - actualWidth
494        }
495      : /* placement === 'right' */ {
496          top: pos.top + pos.height / 2 - actualHeight / 2,
497          left: pos.left + pos.width
498        };
499  };
500  
501  Tooltip.prototype.getViewportAdjustedDelta = function(
502    placement,
503    pos,
504    actualWidth,
505    actualHeight
506  ) {
507    var delta = { top: 0, left: 0 };
508    if (!this.viewport) return delta;
509  
510    var viewportPadding =
511      (this.options.viewport && this.options.viewport.padding) || 0;
512    var viewportDimensions = this.viewport.getBoundingClientRect();
513  
514    if (/right|left/.test(placement)) {
515      var topEdgeOffset = pos.top - viewportPadding - viewportDimensions.scroll;
516      var bottomEdgeOffset =
517        pos.top + viewportPadding - viewportDimensions.scroll + actualHeight;
518      if (topEdgeOffset < viewportDimensions.top) {
519        // top overflow
520        delta.top = viewportDimensions.top - topEdgeOffset;
521      } else if (
522        bottomEdgeOffset >
523        viewportDimensions.top + viewportDimensions.height
524      ) {
525        // bottom overflow
526        delta.top =
527          viewportDimensions.top + viewportDimensions.height - bottomEdgeOffset;
528      }
529    } else {
530      var leftEdgeOffset = pos.left - viewportPadding;
531      var rightEdgeOffset = pos.left + viewportPadding + actualWidth;
532      if (leftEdgeOffset < viewportDimensions.left) {
533        // left overflow
534        delta.left = viewportDimensions.left - leftEdgeOffset;
535      } else if (rightEdgeOffset > viewportDimensions.right) {
536        // right overflow
537        delta.left =
538          viewportDimensions.left + viewportDimensions.width - rightEdgeOffset;
539      }
540    }
541  
542    return delta;
543  };
544  
545  Tooltip.prototype.getTitle = function() {
546    var title = this.element.getAttribute('data-original-title');
547    if (title) {
548      return title;
549    } else {
550      return typeof this.options.title === 'function'
551        ? this.options.title.call(this.element)
552        : this.options.title;
553    }
554  };
555  
556  Tooltip.prototype.getUID = function(prefix) {
557    do prefix += ~~(Math.random() * 1000000);
558    while (document.getElementById(prefix));
559    return prefix;
560  };
561  
562  Tooltip.prototype.getTooltipElement = function() {
563    if (!this.tip) {
564      let div = document.createElement('div');
565      div.innerHTML = this.options.template;
566      if (div.children.length !== 1) {
567        throw new Error(
568          'Tooltip `template` option must consist of exactly 1 top-level element!'
569        );
570      }
571      this.tip = div.firstChild;
572    }
573    return this.tip;
574  };
575  
576  Tooltip.prototype.getArrowElement = function() {
577    this.arrow =
578      this.arrow || this.getTooltipElement().querySelector('.tooltip-arrow');
579    return this.arrow;
580  };
581  
582  Tooltip.prototype.enable = function() {
583    this.enabled = true;
584  };
585  
586  Tooltip.prototype.disable = function() {
587    this.enabled = false;
588  };
589  
590  Tooltip.prototype.toggleEnabled = function() {
591    this.enabled = !this.enabled;
592  };
593  
594  Tooltip.prototype.toggle = function(event) {
595    if (event) {
596      if (event.currentTarget !== this.element) {
597        this.getDelegateComponent(event.currentTarget).toggle(event);
598        return;
599      }
600  
601      this.inState.click = !this.inState.click;
602      if (this.isInStateTrue()) this.enter();
603      else this.leave();
604    } else {
605      this.getTooltipElement().classList.contains('in')
606        ? this.leave()
607        : this.enter();
608    }
609  };
610  
611  Tooltip.prototype.destroy = function() {
612    clearTimeout(this.timeout);
613    this.tip && this.tip.remove();
614    this.disposables.dispose();
615  };
616  
617  Tooltip.prototype.getDelegateComponent = function(element) {
618    var component = tooltipComponentsByElement.get(element);
619    if (!component) {
620      component = new Tooltip(
621        element,
622        this.getDelegateOptions(),
623        this.viewRegistry
624      );
625      tooltipComponentsByElement.set(element, component);
626    }
627    return component;
628  };
629  
630  Tooltip.prototype.recalculatePosition = function() {
631    var tip = this.getTooltipElement();
632  
633    var placement =
634      typeof this.options.placement === 'function'
635        ? this.options.placement.call(this, tip, this.element)
636        : this.options.placement;
637  
638    var autoToken = /\s?auto?\s?/i;
639    var autoPlace = autoToken.test(placement);
640    if (autoPlace) placement = placement.replace(autoToken, '') || 'top';
641  
642    tip.classList.add(placement);
643  
644    var pos = this.element.getBoundingClientRect();
645    var actualWidth = tip.offsetWidth;
646    var actualHeight = tip.offsetHeight;
647  
648    if (autoPlace) {
649      var orgPlacement = placement;
650      var viewportDim = this.viewport.getBoundingClientRect();
651  
652      placement =
653        placement === 'bottom' && pos.bottom + actualHeight > viewportDim.bottom
654          ? 'top'
655          : placement === 'top' && pos.top - actualHeight < viewportDim.top
656          ? 'bottom'
657          : placement === 'right' && pos.right + actualWidth > viewportDim.width
658          ? 'left'
659          : placement === 'left' && pos.left - actualWidth < viewportDim.left
660          ? 'right'
661          : placement;
662  
663      tip.classList.remove(orgPlacement);
664      tip.classList.add(placement);
665    }
666  
667    var calculatedOffset = this.getCalculatedOffset(
668      placement,
669      pos,
670      actualWidth,
671      actualHeight
672    );
673    this.applyPlacement(calculatedOffset, placement);
674  };
675  
676  function extend() {
677    var args = Array.prototype.slice.apply(arguments);
678    var target = args.shift();
679    var source = args.shift();
680    while (source) {
681      for (var key of Object.getOwnPropertyNames(source)) {
682        target[key] = source[key];
683      }
684      source = args.shift();
685    }
686    return target;
687  }
688  
689  module.exports = Tooltip;