86b07d347a84f52d332f33abd9beb4bfd08e8561
[vnfsdk/refrepo.git] /
1 /*!
2  * Angular Material Design
3  * https://github.com/angular/material
4  * @license MIT
5  * v1.1.3
6  */
7 goog.provide('ngmaterial.components.tooltip');
8 goog.require('ngmaterial.components.panel');
9 goog.require('ngmaterial.core');
10 /**
11  * @ngdoc module
12  * @name material.components.tooltip
13  */
14 MdTooltipDirective['$inject'] = ["$timeout", "$window", "$$rAF", "$document", "$interpolate", "$mdUtil", "$mdPanel", "$$mdTooltipRegistry"];
15 angular
16     .module('material.components.tooltip', [
17       'material.core',
18       'material.components.panel'
19     ])
20     .directive('mdTooltip', MdTooltipDirective)
21     .service('$$mdTooltipRegistry', MdTooltipRegistry);
22
23
24 /**
25  * @ngdoc directive
26  * @name mdTooltip
27  * @module material.components.tooltip
28  * @description
29  * Tooltips are used to describe elements that are interactive and primarily
30  * graphical (not textual).
31  *
32  * Place a `<md-tooltip>` as a child of the element it describes.
33  *
34  * A tooltip will activate when the user hovers over, focuses, or touches the
35  * parent element.
36  *
37  * @usage
38  * <hljs lang="html">
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>
42  *   </md-button>
43  * </hljs>
44  *
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
48  *     currently visible.
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.
56  *     Defaults to bottom.
57  */
58 function MdTooltipDirective($timeout, $window, $$rAF, $document, $interpolate,
59     $mdUtil, $mdPanel, $$mdTooltipRegistry) {
60
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 }
71   };
72
73   return {
74     restrict: 'E',
75     priority: 210, // Before ngAria
76     scope: {
77       mdZIndex: '=?mdZIndex',
78       mdDelay: '=?mdDelay',
79       mdVisible: '=?mdVisible',
80       mdAutohide: '=?mdAutohide',
81       mdDirection: '@?mdDirection' // Do not expect expressions.
82     },
83     link: linkFunc
84   };
85
86   function linkFunc(scope, element, attr) {
87     // Set constants.
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;
93
94     // Set defaults
95     setDefaults();
96
97     // Set parent aria-label.
98     addAriaLabel();
99
100     // Remove the element from its current DOM position.
101     element.detach();
102
103     updatePosition();
104     bindEvents();
105     configureWatchers();
106
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;
112       }
113     }
114
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);
121       }
122     }
123
124     function updatePosition() {
125       setDefaults();
126
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);
131       }
132
133       // Set the panel element origin class based off of the current
134       // mdDirection.
135       origin = 'md-origin-' + scope.mdDirection;
136
137       // Create the position of the panel based off of the mdDirection.
138       position = TOOLTIP_DIRECTIONS[scope.mdDirection];
139
140       // Using the newly created position object, use the MdPanel
141       // panelPosition API to build the panel's position.
142       panelPosition = $mdPanel.newPanelPosition()
143           .relativeTo(parent)
144           .addPanelPosition(position.x, position.y);
145
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);
151       }
152     }
153
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() {
162               setVisible(false);
163             });
164           }
165         });
166
167         attributeObserver.observe(parent[0], {
168           attributes: true
169         });
170       }
171
172       elementFocusedOnWindowBlur = false;
173
174       $$mdTooltipRegistry.register('scroll', windowScrollEventHandler, true);
175       $$mdTooltipRegistry.register('blur', windowBlurEventHandler);
176       $$mdTooltipRegistry.register('resize', debouncedOnResize);
177
178       scope.$on('$destroy', onDestroy);
179
180       // To avoid 'synthetic clicks', we listen to mousedown instead of
181       // 'click'.
182       parent.on('mousedown', mousedownEventHandler);
183       parent.on(ENTER_EVENTS, enterEventHandler);
184
185       function isDisabledMutation(mutations) {
186         mutations.some(function(mutation) {
187           return mutation.attributeName === 'disabled' && parent[0].disabled;
188         });
189         return false;
190       }
191
192       function windowScrollEventHandler() {
193         setVisible(false);
194       }
195
196       function windowBlurEventHandler() {
197         elementFocusedOnWindowBlur = document.activeElement === parent[0];
198       }
199
200       function enterEventHandler($event) {
201         // Prevent the tooltip from showing when the window is receiving
202         // focus.
203         if ($event.type === 'focus' && elementFocusedOnWindowBlur) {
204           elementFocusedOnWindowBlur = false;
205         } else if (!scope.mdVisible) {
206           parent.on(LEAVE_EVENTS, leaveEventHandler);
207           setVisible(true);
208
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);
216               }, false);
217             });
218           }
219         }
220       }
221
222       function leaveEventHandler() {
223         autohide = scope.hasOwnProperty('mdAutohide') ?
224             scope.mdAutohide :
225             attr.hasOwnProperty('mdAutohide');
226
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.
232           if (showTimeout) {
233             $timeout.cancel(showTimeout);
234             setVisible.queued = false;
235             showTimeout = null;
236           }
237
238           parent.off(LEAVE_EVENTS, leaveEventHandler);
239           parent.triggerHandler('blur');
240           setVisible(false);
241         }
242         mouseActive = false;
243       }
244
245       function mousedownEventHandler() {
246         mouseActive = true;
247       }
248
249       function onDestroy() {
250         $$mdTooltipRegistry.deregister('scroll', windowScrollEventHandler, true);
251         $$mdTooltipRegistry.deregister('blur', windowBlurEventHandler);
252         $$mdTooltipRegistry.deregister('resize', debouncedOnResize);
253
254         parent
255             .off(ENTER_EVENTS, enterEventHandler)
256             .off(LEAVE_EVENTS, leaveEventHandler)
257             .off('mousedown', mousedownEventHandler);
258
259         // Trigger the handler in case any of the tooltips are
260         // still visible.
261         leaveEventHandler();
262         attributeObserver && attributeObserver.disconnect();
263       }
264     }
265
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',
273                   onVisibleChanged);
274             }
275           });
276         });
277
278         attributeObserver.observe(element[0], {
279           attributes: true
280         });
281
282         // Build watcher only if mdVisible is being used.
283         if (attr.hasOwnProperty('mdVisible')) {
284           scope.visibleWatcher = scope.$watch('mdVisible',
285               onVisibleChanged);
286         }
287       } else {
288         // MutationObserver not supported
289         scope.visibleWatcher = scope.$watch('mdVisible', onVisibleChanged);
290       }
291
292       // Direction watcher
293       scope.$watch('mdDirection', updatePosition);
294
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
304       //     loop.
305       //   - This kicks off the scope's `$destroy` event which finishes the
306       //     cleanup.
307       element.one('$destroy', onElementDestroy);
308       parent.one('$destroy', onElementDestroy);
309       scope.$on('$destroy', function() {
310         setVisible(false);
311         panelRef && panelRef.destroy();
312         attributeObserver && attributeObserver.disconnect();
313         element.remove();
314       });
315
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
318       // bindings.
319       if (element.text().indexOf($interpolate.startSymbol()) > -1) {
320         scope.$watch(function() {
321           return element.text().trim();
322         }, addAriaLabel);
323       }
324
325       function onElementDestroy() {
326         scope.$destroy();
327       }
328     }
329
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) {
335         return;
336       }
337       setVisible.value = !!value;
338
339       if (!setVisible.queued) {
340         if (value) {
341           setVisible.queued = true;
342           showTimeout = $timeout(function() {
343             scope.mdVisible = setVisible.value;
344             setVisible.queued = false;
345             showTimeout = null;
346             if (!scope.visibleWatcher) {
347               onVisibleChanged(scope.mdVisible);
348             }
349           }, scope.mdDelay);
350         } else {
351           $mdUtil.nextTick(function() {
352             scope.mdVisible = false;
353             if (!scope.visibleWatcher) {
354               onVisibleChanged(false);
355             }
356           });
357         }
358       }
359     }
360
361     function onVisibleChanged(isVisible) {
362       isVisible ? showTooltip() : hideTooltip();
363     }
364
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.');
370       }
371
372       if (!panelRef) {
373         var id = 'tooltip-' + $mdUtil.nextUid();
374         var attachTo = angular.element(document.body);
375         var panelAnimation = $mdPanel.newPanelAnimation()
376             .openFrom(parent)
377             .closeTo(parent)
378             .withAnimation({
379               open: 'md-show',
380               close: 'md-hide'
381             });
382
383         var panelConfig = {
384           id: id,
385           attachTo: attachTo,
386           contentElement: element,
387           propagateContainerEvents: true,
388           panelClass: 'md-tooltip ' + origin,
389           animation: panelAnimation,
390           position: panelPosition,
391           zIndex: scope.mdZIndex,
392           focusOnOpen: false
393         };
394
395         panelRef = $mdPanel.create(panelConfig);
396       }
397
398       panelRef.open().then(function() {
399         panelRef.panelEl.attr('role', 'tooltip');
400       });
401     }
402
403     function hideTooltip() {
404       panelRef && panelRef.close();
405     }
406   }
407
408 }
409
410
411 /**
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.
415  *
416  * ngInject
417  */
418 function MdTooltipRegistry() {
419   var listeners = {};
420   var ngWindow = angular.element(window);
421
422   return {
423     register: register,
424     deregister: deregister
425   };
426
427   /**
428    * Global event handler that dispatches the registered handlers in the
429    * service.
430    * @param {!Event} event Event object passed in by the browser
431    */
432   function globalEventHandler(event) {
433     if (listeners[event.type]) {
434       listeners[event.type].forEach(function(currentHandler) {
435         currentHandler.call(this, event);
436       }, this);
437     }
438   }
439
440   /**
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.
445    */
446   function register(type, handler, useCapture) {
447     var handlers = listeners[type] = listeners[type] || [];
448
449     if (!handlers.length) {
450       useCapture ? window.addEventListener(type, globalEventHandler, true) :
451           ngWindow.on(type, globalEventHandler);
452     }
453
454     if (handlers.indexOf(handler) === -1) {
455       handlers.push(handler);
456     }
457   }
458
459   /**
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.
464    */
465   function deregister(type, handler, useCapture) {
466     var handlers = listeners[type];
467     var index = handlers ? handlers.indexOf(handler) : -1;
468
469     if (index > -1) {
470       handlers.splice(index, 1);
471
472       if (handlers.length === 0) {
473         useCapture ? window.removeEventListener(type, globalEventHandler, true) :
474             ngWindow.off(type, globalEventHandler);
475       }
476     }
477   }
478 }
479
480 ngmaterial.components.tooltip = angular.module("material.components.tooltip");