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;