2  * Angular Material Design
 
   3  * https://github.com/angular/material
 
   7 (function( window, angular, undefined ){
 
  12  * @name material.components.tooltip
 
  14 MdTooltipDirective['$inject'] = ["$timeout", "$window", "$$rAF", "$document", "$interpolate", "$mdUtil", "$mdPanel", "$$mdTooltipRegistry"];
 
  16     .module('material.components.tooltip', [
 
  18       'material.components.panel'
 
  20     .directive('mdTooltip', MdTooltipDirective)
 
  21     .service('$$mdTooltipRegistry', MdTooltipRegistry);
 
  27  * @module material.components.tooltip
 
  29  * Tooltips are used to describe elements that are interactive and primarily
 
  30  * graphical (not textual).
 
  32  * Place a `<md-tooltip>` as a child of the element it describes.
 
  34  * A tooltip will activate when the user hovers over, focuses, or touches the
 
  39  *   <md-button class="md-fab md-accent" aria-label="Play">
 
  40  *     <md-tooltip>Play Music</md-tooltip>
 
  41  *     <md-icon md-svg-src="img/icons/ic_play_arrow_24px.svg"></md-icon>
 
  45  * @param {number=} md-z-index The visual level that the tooltip will appear
 
  46  *     in comparison with the rest of the elements of the application.
 
  47  * @param {expression=} md-visible Boolean bound to whether the tooltip is
 
  49  * @param {number=} md-delay How many milliseconds to wait to show the tooltip
 
  50  *     after the user hovers over, focuses, or touches the parent element.
 
  51  *     Defaults to 0ms on non-touch devices and 75ms on touch.
 
  52  * @param {boolean=} md-autohide If present or provided with a boolean value,
 
  53  *     the tooltip will hide on mouse leave, regardless of focus.
 
  54  * @param {string=} md-direction The direction that the tooltip is shown,
 
  55  *     relative to the parent element. Supports top, right, bottom, and left.
 
  58 function MdTooltipDirective($timeout, $window, $$rAF, $document, $interpolate,
 
  59     $mdUtil, $mdPanel, $$mdTooltipRegistry) {
 
  61   var ENTER_EVENTS = 'focus touchstart mouseenter';
 
  62   var LEAVE_EVENTS = 'blur touchcancel mouseleave';
 
  63   var TOOLTIP_DEFAULT_Z_INDEX = 100;
 
  64   var TOOLTIP_DEFAULT_SHOW_DELAY = 0;
 
  65   var TOOLTIP_DEFAULT_DIRECTION = 'bottom';
 
  66   var TOOLTIP_DIRECTIONS = {
 
  67     top: { x: $mdPanel.xPosition.CENTER, y: $mdPanel.yPosition.ABOVE },
 
  68     right: { x: $mdPanel.xPosition.OFFSET_END, y: $mdPanel.yPosition.CENTER },
 
  69     bottom: { x: $mdPanel.xPosition.CENTER, y: $mdPanel.yPosition.BELOW },
 
  70     left: { x: $mdPanel.xPosition.OFFSET_START, y: $mdPanel.yPosition.CENTER }
 
  75     priority: 210, // Before ngAria
 
  77       mdZIndex: '=?mdZIndex',
 
  79       mdVisible: '=?mdVisible',
 
  80       mdAutohide: '=?mdAutohide',
 
  81       mdDirection: '@?mdDirection' // Do not expect expressions.
 
  86   function linkFunc(scope, element, attr) {
 
  88     var parent = $mdUtil.getParentWithPointerEvents(element);
 
  89     var debouncedOnResize = $$rAF.throttle(updatePosition);
 
  90     var mouseActive = false;
 
  91     var origin, position, panelPosition, panelRef, autohide, showTimeout,
 
  92         elementFocusedOnWindowBlur = null;
 
  97     // Set parent aria-label.
 
 100     // Remove the element from its current DOM position.
 
 107     function setDefaults() {
 
 108       scope.mdZIndex = scope.mdZIndex || TOOLTIP_DEFAULT_Z_INDEX;
 
 109       scope.mdDelay = scope.mdDelay || TOOLTIP_DEFAULT_SHOW_DELAY;
 
 110       if (!TOOLTIP_DIRECTIONS[scope.mdDirection]) {
 
 111         scope.mdDirection = TOOLTIP_DEFAULT_DIRECTION;
 
 115     function addAriaLabel(override) {
 
 116       if (override || !parent.attr('aria-label')) {
 
 117         // Only interpolate the text from the HTML element because otherwise the custom text
 
 118         // could be interpolated twice and cause XSS violations.
 
 119         var interpolatedText = override || $interpolate(element.text().trim())(scope.$parent);
 
 120         parent.attr('aria-label', interpolatedText);
 
 124     function updatePosition() {
 
 127       // If the panel has already been created, remove the current origin
 
 128       // class from the panel element.
 
 129       if (panelRef && panelRef.panelEl) {
 
 130         panelRef.panelEl.removeClass(origin);
 
 133       // Set the panel element origin class based off of the current
 
 135       origin = 'md-origin-' + scope.mdDirection;
 
 137       // Create the position of the panel based off of the mdDirection.
 
 138       position = TOOLTIP_DIRECTIONS[scope.mdDirection];
 
 140       // Using the newly created position object, use the MdPanel
 
 141       // panelPosition API to build the panel's position.
 
 142       panelPosition = $mdPanel.newPanelPosition()
 
 144           .addPanelPosition(position.x, position.y);
 
 146       // If the panel has already been created, add the new origin class to
 
 147       // the panel element and update it's position with the panelPosition.
 
 148       if (panelRef && panelRef.panelEl) {
 
 149         panelRef.panelEl.addClass(origin);
 
 150         panelRef.updatePosition(panelPosition);
 
 154     function bindEvents() {
 
 155       // Add a mutationObserver where there is support for it and the need
 
 156       // for it in the form of viable host(parent[0]).
 
 157       if (parent[0] && 'MutationObserver' in $window) {
 
 158         // Use a mutationObserver to tackle #2602.
 
 159         var attributeObserver = new MutationObserver(function(mutations) {
 
 160           if (isDisabledMutation(mutations)) {
 
 161             $mdUtil.nextTick(function() {
 
 167         attributeObserver.observe(parent[0], {
 
 172       elementFocusedOnWindowBlur = false;
 
 174       $$mdTooltipRegistry.register('scroll', windowScrollEventHandler, true);
 
 175       $$mdTooltipRegistry.register('blur', windowBlurEventHandler);
 
 176       $$mdTooltipRegistry.register('resize', debouncedOnResize);
 
 178       scope.$on('$destroy', onDestroy);
 
 180       // To avoid 'synthetic clicks', we listen to mousedown instead of
 
 182       parent.on('mousedown', mousedownEventHandler);
 
 183       parent.on(ENTER_EVENTS, enterEventHandler);
 
 185       function isDisabledMutation(mutations) {
 
 186         mutations.some(function(mutation) {
 
 187           return mutation.attributeName === 'disabled' && parent[0].disabled;
 
 192       function windowScrollEventHandler() {
 
 196       function windowBlurEventHandler() {
 
 197         elementFocusedOnWindowBlur = document.activeElement === parent[0];
 
 200       function enterEventHandler($event) {
 
 201         // Prevent the tooltip from showing when the window is receiving
 
 203         if ($event.type === 'focus' && elementFocusedOnWindowBlur) {
 
 204           elementFocusedOnWindowBlur = false;
 
 205         } else if (!scope.mdVisible) {
 
 206           parent.on(LEAVE_EVENTS, leaveEventHandler);
 
 209           // If the user is on a touch device, we should bind the tap away
 
 210           // after the 'touched' in order to prevent the tooltip being
 
 211           // removed immediately.
 
 212           if ($event.type === 'touchstart') {
 
 213             parent.one('touchend', function() {
 
 214               $mdUtil.nextTick(function() {
 
 215                 $document.one('touchend', leaveEventHandler);
 
 222       function leaveEventHandler() {
 
 223         autohide = scope.hasOwnProperty('mdAutohide') ?
 
 225             attr.hasOwnProperty('mdAutohide');
 
 227         if (autohide || mouseActive ||
 
 228             $document[0].activeElement !== parent[0]) {
 
 229           // When a show timeout is currently in progress, then we have
 
 230           // to cancel it, otherwise the tooltip will remain showing
 
 231           // without focus or hover.
 
 233             $timeout.cancel(showTimeout);
 
 234             setVisible.queued = false;
 
 238           parent.off(LEAVE_EVENTS, leaveEventHandler);
 
 239           parent.triggerHandler('blur');
 
 245       function mousedownEventHandler() {
 
 249       function onDestroy() {
 
 250         $$mdTooltipRegistry.deregister('scroll', windowScrollEventHandler, true);
 
 251         $$mdTooltipRegistry.deregister('blur', windowBlurEventHandler);
 
 252         $$mdTooltipRegistry.deregister('resize', debouncedOnResize);
 
 255             .off(ENTER_EVENTS, enterEventHandler)
 
 256             .off(LEAVE_EVENTS, leaveEventHandler)
 
 257             .off('mousedown', mousedownEventHandler);
 
 259         // Trigger the handler in case any of the tooltips are
 
 262         attributeObserver && attributeObserver.disconnect();
 
 266     function configureWatchers() {
 
 267       if (element[0] && 'MutationObserver' in $window) {
 
 268         var attributeObserver = new MutationObserver(function(mutations) {
 
 269           mutations.forEach(function(mutation) {
 
 270             if (mutation.attributeName === 'md-visible' &&
 
 271                 !scope.visibleWatcher ) {
 
 272               scope.visibleWatcher = scope.$watch('mdVisible',
 
 278         attributeObserver.observe(element[0], {
 
 282         // Build watcher only if mdVisible is being used.
 
 283         if (attr.hasOwnProperty('mdVisible')) {
 
 284           scope.visibleWatcher = scope.$watch('mdVisible',
 
 288         // MutationObserver not supported
 
 289         scope.visibleWatcher = scope.$watch('mdVisible', onVisibleChanged);
 
 293       scope.$watch('mdDirection', updatePosition);
 
 295       // Clean up if the element or parent was removed via jqLite's .remove.
 
 296       // A couple of notes:
 
 297       //   - In these cases the scope might not have been destroyed, which
 
 298       //     is why we destroy it manually. An example of this can be having
 
 299       //     `md-visible="false"` and adding tooltips while they're
 
 300       //     invisible. If `md-visible` becomes true, at some point, you'd
 
 301       //     usually get a lot of tooltips.
 
 302       //   - We use `.one`, not `.on`, because this only needs to fire once.
 
 303       //     If we were using `.on`, it would get thrown into an infinite
 
 305       //   - This kicks off the scope's `$destroy` event which finishes the
 
 307       element.one('$destroy', onElementDestroy);
 
 308       parent.one('$destroy', onElementDestroy);
 
 309       scope.$on('$destroy', function() {
 
 311         panelRef && panelRef.destroy();
 
 312         attributeObserver && attributeObserver.disconnect();
 
 316       // Updates the aria-label when the element text changes. This watch
 
 317       // doesn't need to be set up if the element doesn't have any data
 
 319       if (element.text().indexOf($interpolate.startSymbol()) > -1) {
 
 320         scope.$watch(function() {
 
 321           return element.text().trim();
 
 325       function onElementDestroy() {
 
 330     function setVisible(value) {
 
 331       // Break if passed value is already in queue or there is no queue and
 
 332       // passed value is current in the controller.
 
 333       if (setVisible.queued && setVisible.value === !!value ||
 
 334           !setVisible.queued && scope.mdVisible === !!value) {
 
 337       setVisible.value = !!value;
 
 339       if (!setVisible.queued) {
 
 341           setVisible.queued = true;
 
 342           showTimeout = $timeout(function() {
 
 343             scope.mdVisible = setVisible.value;
 
 344             setVisible.queued = false;
 
 346             if (!scope.visibleWatcher) {
 
 347               onVisibleChanged(scope.mdVisible);
 
 351           $mdUtil.nextTick(function() {
 
 352             scope.mdVisible = false;
 
 353             if (!scope.visibleWatcher) {
 
 354               onVisibleChanged(false);
 
 361     function onVisibleChanged(isVisible) {
 
 362       isVisible ? showTooltip() : hideTooltip();
 
 365     function showTooltip() {
 
 366       // Do not show the tooltip if the text is empty.
 
 367       if (!element[0].textContent.trim()) {
 
 368         throw new Error('Text for the tooltip has not been provided. ' +
 
 369             'Please include text within the mdTooltip element.');
 
 373         var id = 'tooltip-' + $mdUtil.nextUid();
 
 374         var attachTo = angular.element(document.body);
 
 375         var panelAnimation = $mdPanel.newPanelAnimation()
 
 386           contentElement: element,
 
 387           propagateContainerEvents: true,
 
 388           panelClass: 'md-tooltip ' + origin,
 
 389           animation: panelAnimation,
 
 390           position: panelPosition,
 
 391           zIndex: scope.mdZIndex,
 
 395         panelRef = $mdPanel.create(panelConfig);
 
 398       panelRef.open().then(function() {
 
 399         panelRef.panelEl.attr('role', 'tooltip');
 
 403     function hideTooltip() {
 
 404       panelRef && panelRef.close();
 
 412  * Service that is used to reduce the amount of listeners that are being
 
 413  * registered on the `window` by the tooltip component. Works by collecting
 
 414  * the individual event handlers and dispatching them from a global handler.
 
 418 function MdTooltipRegistry() {
 
 420   var ngWindow = angular.element(window);
 
 424     deregister: deregister
 
 428    * Global event handler that dispatches the registered handlers in the
 
 430    * @param {!Event} event Event object passed in by the browser
 
 432   function globalEventHandler(event) {
 
 433     if (listeners[event.type]) {
 
 434       listeners[event.type].forEach(function(currentHandler) {
 
 435         currentHandler.call(this, event);
 
 441    * Registers a new handler with the service.
 
 442    * @param {string} type Type of event to be registered.
 
 443    * @param {!Function} handler Event handler.
 
 444    * @param {boolean} useCapture Whether to use event capturing.
 
 446   function register(type, handler, useCapture) {
 
 447     var handlers = listeners[type] = listeners[type] || [];
 
 449     if (!handlers.length) {
 
 450       useCapture ? window.addEventListener(type, globalEventHandler, true) :
 
 451           ngWindow.on(type, globalEventHandler);
 
 454     if (handlers.indexOf(handler) === -1) {
 
 455       handlers.push(handler);
 
 460    * Removes an event handler from the service.
 
 461    * @param {string} type Type of event handler.
 
 462    * @param {!Function} handler The event handler itself.
 
 463    * @param {boolean} useCapture Whether the event handler used event capturing.
 
 465   function deregister(type, handler, useCapture) {
 
 466     var handlers = listeners[type];
 
 467     var index = handlers ? handlers.indexOf(handler) : -1;
 
 470       handlers.splice(index, 1);
 
 472       if (handlers.length === 0) {
 
 473         useCapture ? window.removeEventListener(type, globalEventHandler, true) :
 
 474             ngWindow.off(type, globalEventHandler);
 
 480 })(window, window.angular);