You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

398 lines
14 KiB

  1. /*!
  2. * Angular Material Design
  3. * https://github.com/angular/material
  4. * @license MIT
  5. * v1.1.1
  6. */
  7. goog.provide('ngmaterial.components.tooltip');
  8. goog.require('ngmaterial.core');
  9. /**
  10. * @ngdoc module
  11. * @name material.components.tooltip
  12. */
  13. MdTooltipDirective.$inject = ["$timeout", "$window", "$$rAF", "$document", "$mdUtil", "$mdTheming", "$rootElement", "$animate", "$q", "$interpolate"];
  14. angular
  15. .module('material.components.tooltip', [ 'material.core' ])
  16. .directive('mdTooltip', MdTooltipDirective);
  17. /**
  18. * @ngdoc directive
  19. * @name mdTooltip
  20. * @module material.components.tooltip
  21. * @description
  22. * Tooltips are used to describe elements that are interactive and primarily graphical (not textual).
  23. *
  24. * Place a `<md-tooltip>` as a child of the element it describes.
  25. *
  26. * A tooltip will activate when the user focuses, hovers over, or touches the parent.
  27. *
  28. * @usage
  29. * <hljs lang="html">
  30. * <md-button class="md-fab md-accent" aria-label="Play">
  31. * <md-tooltip>
  32. * Play Music
  33. * </md-tooltip>
  34. * <md-icon icon="img/icons/ic_play_arrow_24px.svg"></md-icon>
  35. * </md-button>
  36. * </hljs>
  37. *
  38. * @param {expression=} md-visible Boolean bound to whether the tooltip is currently visible.
  39. * @param {number=} md-delay How many milliseconds to wait to show the tooltip after the user focuses, hovers, or touches the
  40. * parent. Defaults to 0ms on non-touch devices and 75ms on touch.
  41. * @param {boolean=} md-autohide If present or provided with a boolean value, the tooltip will hide on mouse leave, regardless of focus
  42. * @param {string=} md-direction Which direction would you like the tooltip to go? Supports left, right, top, and bottom. Defaults to bottom.
  43. */
  44. function MdTooltipDirective($timeout, $window, $$rAF, $document, $mdUtil, $mdTheming, $rootElement,
  45. $animate, $q, $interpolate) {
  46. var ENTER_EVENTS = 'focus touchstart mouseenter';
  47. var LEAVE_EVENTS = 'blur touchcancel mouseleave';
  48. var SHOW_CLASS = 'md-show';
  49. var TOOLTIP_SHOW_DELAY = 0;
  50. var TOOLTIP_WINDOW_EDGE_SPACE = 8;
  51. return {
  52. restrict: 'E',
  53. transclude: true,
  54. priority: 210, // Before ngAria
  55. template: '<div class="md-content _md" ng-transclude></div>',
  56. scope: {
  57. delay: '=?mdDelay',
  58. visible: '=?mdVisible',
  59. autohide: '=?mdAutohide',
  60. direction: '@?mdDirection' // only expect raw or interpolated string value; not expression
  61. },
  62. compile: function(tElement, tAttr) {
  63. if (!tAttr.mdDirection) {
  64. tAttr.$set('mdDirection', 'bottom');
  65. }
  66. return postLink;
  67. }
  68. };
  69. function postLink(scope, element, attr) {
  70. $mdTheming(element);
  71. var parent = $mdUtil.getParentWithPointerEvents(element),
  72. content = angular.element(element[0].getElementsByClassName('md-content')[0]),
  73. tooltipParent = angular.element(document.body),
  74. showTimeout = null,
  75. debouncedOnResize = $$rAF.throttle(function () { updatePosition(); });
  76. if ($animate.pin) $animate.pin(element, parent);
  77. // Initialize element
  78. setDefaults();
  79. manipulateElement();
  80. bindEvents();
  81. // Default origin transform point is 'center top'
  82. // positionTooltip() is always relative to center top
  83. updateContentOrigin();
  84. configureWatchers();
  85. addAriaLabel();
  86. function setDefaults () {
  87. scope.delay = scope.delay || TOOLTIP_SHOW_DELAY;
  88. }
  89. function updateContentOrigin() {
  90. var origin = 'center top';
  91. switch (scope.direction) {
  92. case 'left' : origin = 'right center'; break;
  93. case 'right' : origin = 'left center'; break;
  94. case 'top' : origin = 'center bottom'; break;
  95. case 'bottom': origin = 'center top'; break;
  96. }
  97. content.css('transform-origin', origin);
  98. }
  99. function onVisibleChanged (isVisible) {
  100. if (isVisible) showTooltip();
  101. else hideTooltip();
  102. }
  103. function configureWatchers () {
  104. if (element[0] && 'MutationObserver' in $window) {
  105. var attributeObserver = new MutationObserver(function(mutations) {
  106. mutations
  107. .forEach(function (mutation) {
  108. if (mutation.attributeName === 'md-visible') {
  109. if (!scope.visibleWatcher)
  110. scope.visibleWatcher = scope.$watch('visible', onVisibleChanged );
  111. }
  112. if (mutation.attributeName === 'md-direction') {
  113. updatePosition(scope.direction);
  114. }
  115. });
  116. });
  117. attributeObserver.observe(element[0], { attributes: true });
  118. // build watcher only if mdVisible is being used
  119. if (attr.hasOwnProperty('mdVisible')) {
  120. scope.visibleWatcher = scope.$watch('visible', onVisibleChanged );
  121. }
  122. } else { // MutationObserver not supported
  123. scope.visibleWatcher = scope.$watch('visible', onVisibleChanged );
  124. scope.$watch('direction', updatePosition );
  125. }
  126. var onElementDestroy = function() {
  127. scope.$destroy();
  128. };
  129. // Clean up if the element or parent was removed via jqLite's .remove.
  130. // A couple of notes:
  131. // - In these cases the scope might not have been destroyed, which is why we
  132. // destroy it manually. An example of this can be having `md-visible="false"` and
  133. // adding tooltips while they're invisible. If `md-visible` becomes true, at some
  134. // point, you'd usually get a lot of inputs.
  135. // - We use `.one`, not `.on`, because this only needs to fire once. If we were
  136. // using `.on`, it would get thrown into an infinite loop.
  137. // - This kicks off the scope's `$destroy` event which finishes the cleanup.
  138. element.one('$destroy', onElementDestroy);
  139. parent.one('$destroy', onElementDestroy);
  140. scope.$on('$destroy', function() {
  141. setVisible(false);
  142. element.remove();
  143. attributeObserver && attributeObserver.disconnect();
  144. });
  145. // Updates the aria-label when the element text changes. This watch
  146. // doesn't need to be set up if the element doesn't have any data
  147. // bindings.
  148. if (element.text().indexOf($interpolate.startSymbol()) > -1) {
  149. scope.$watch(function() {
  150. return element.text().trim();
  151. }, addAriaLabel);
  152. }
  153. }
  154. function addAriaLabel (override) {
  155. if ((override || !parent.attr('aria-label')) && !parent.text().trim()) {
  156. var rawText = override || element.text().trim();
  157. var interpolatedText = $interpolate(rawText)(parent.scope());
  158. parent.attr('aria-label', interpolatedText);
  159. }
  160. }
  161. function manipulateElement () {
  162. element.detach();
  163. element.attr('role', 'tooltip');
  164. }
  165. function bindEvents () {
  166. var mouseActive = false;
  167. // add an mutationObserver when there is support for it
  168. // and the need for it in the form of viable host(parent[0])
  169. if (parent[0] && 'MutationObserver' in $window) {
  170. // use an mutationObserver to tackle #2602
  171. var attributeObserver = new MutationObserver(function(mutations) {
  172. if (mutations.some(function (mutation) {
  173. return (mutation.attributeName === 'disabled' && parent[0].disabled);
  174. })) {
  175. $mdUtil.nextTick(function() {
  176. setVisible(false);
  177. });
  178. }
  179. });
  180. attributeObserver.observe(parent[0], { attributes: true});
  181. }
  182. // Store whether the element was focused when the window loses focus.
  183. var windowBlurHandler = function() {
  184. elementFocusedOnWindowBlur = document.activeElement === parent[0];
  185. };
  186. var elementFocusedOnWindowBlur = false;
  187. function windowScrollHandler() {
  188. setVisible(false);
  189. }
  190. angular.element($window)
  191. .on('blur', windowBlurHandler)
  192. .on('resize', debouncedOnResize);
  193. document.addEventListener('scroll', windowScrollHandler, true);
  194. scope.$on('$destroy', function() {
  195. angular.element($window)
  196. .off('blur', windowBlurHandler)
  197. .off('resize', debouncedOnResize);
  198. parent
  199. .off(ENTER_EVENTS, enterHandler)
  200. .off(LEAVE_EVENTS, leaveHandler)
  201. .off('mousedown', mousedownHandler);
  202. // Trigger the handler in case any the tooltip was still visible.
  203. leaveHandler();
  204. document.removeEventListener('scroll', windowScrollHandler, true);
  205. attributeObserver && attributeObserver.disconnect();
  206. });
  207. var enterHandler = function(e) {
  208. // Prevent the tooltip from showing when the window is receiving focus.
  209. if (e.type === 'focus' && elementFocusedOnWindowBlur) {
  210. elementFocusedOnWindowBlur = false;
  211. } else if (!scope.visible) {
  212. parent.on(LEAVE_EVENTS, leaveHandler);
  213. setVisible(true);
  214. // If the user is on a touch device, we should bind the tap away after
  215. // the `touched` in order to prevent the tooltip being removed immediately.
  216. if (e.type === 'touchstart') {
  217. parent.one('touchend', function() {
  218. $mdUtil.nextTick(function() {
  219. $document.one('touchend', leaveHandler);
  220. }, false);
  221. });
  222. }
  223. }
  224. };
  225. var leaveHandler = function () {
  226. var autohide = scope.hasOwnProperty('autohide') ? scope.autohide : attr.hasOwnProperty('mdAutohide');
  227. if (autohide || mouseActive || $document[0].activeElement !== parent[0]) {
  228. // When a show timeout is currently in progress, then we have to cancel it.
  229. // Otherwise the tooltip will remain showing without focus or hover.
  230. if (showTimeout) {
  231. $timeout.cancel(showTimeout);
  232. setVisible.queued = false;
  233. showTimeout = null;
  234. }
  235. parent.off(LEAVE_EVENTS, leaveHandler);
  236. parent.triggerHandler('blur');
  237. setVisible(false);
  238. }
  239. mouseActive = false;
  240. };
  241. var mousedownHandler = function() {
  242. mouseActive = true;
  243. };
  244. // to avoid `synthetic clicks` we listen to mousedown instead of `click`
  245. parent.on('mousedown', mousedownHandler);
  246. parent.on(ENTER_EVENTS, enterHandler);
  247. }
  248. function setVisible (value) {
  249. // break if passed value is already in queue or there is no queue and passed value is current in the scope
  250. if (setVisible.queued && setVisible.value === !!value || !setVisible.queued && scope.visible === !!value) return;
  251. setVisible.value = !!value;
  252. if (!setVisible.queued) {
  253. if (value) {
  254. setVisible.queued = true;
  255. showTimeout = $timeout(function() {
  256. scope.visible = setVisible.value;
  257. setVisible.queued = false;
  258. showTimeout = null;
  259. if (!scope.visibleWatcher) {
  260. onVisibleChanged(scope.visible);
  261. }
  262. }, scope.delay);
  263. } else {
  264. $mdUtil.nextTick(function() {
  265. scope.visible = false;
  266. if (!scope.visibleWatcher)
  267. onVisibleChanged(false);
  268. });
  269. }
  270. }
  271. }
  272. function showTooltip() {
  273. // Do not show the tooltip if the text is empty.
  274. if (!element[0].textContent.trim()) return;
  275. // Insert the element and position at top left, so we can get the position
  276. // and check if we should display it
  277. element.css({top: 0, left: 0});
  278. tooltipParent.append(element);
  279. // Check if we should display it or not.
  280. // This handles hide-* and show-* along with any user defined css
  281. if ( $mdUtil.hasComputedStyle(element, 'display', 'none')) {
  282. scope.visible = false;
  283. element.detach();
  284. return;
  285. }
  286. updatePosition();
  287. $animate.addClass(content, SHOW_CLASS).then(function() {
  288. element.addClass(SHOW_CLASS);
  289. });
  290. }
  291. function hideTooltip() {
  292. $animate.removeClass(content, SHOW_CLASS).then(function(){
  293. element.removeClass(SHOW_CLASS);
  294. if (!scope.visible) element.detach();
  295. });
  296. }
  297. function updatePosition() {
  298. if ( !scope.visible ) return;
  299. updateContentOrigin();
  300. positionTooltip();
  301. }
  302. function positionTooltip() {
  303. var tipRect = $mdUtil.offsetRect(element, tooltipParent);
  304. var parentRect = $mdUtil.offsetRect(parent, tooltipParent);
  305. var newPosition = getPosition(scope.direction);
  306. var offsetParent = element.prop('offsetParent');
  307. // If the user provided a direction, just nudge the tooltip onto the screen
  308. // Otherwise, recalculate based on 'top' since default is 'bottom'
  309. if (scope.direction) {
  310. newPosition = fitInParent(newPosition);
  311. } else if (offsetParent && newPosition.top > offsetParent.scrollHeight - tipRect.height - TOOLTIP_WINDOW_EDGE_SPACE) {
  312. newPosition = fitInParent(getPosition('top'));
  313. }
  314. element.css({
  315. left: newPosition.left + 'px',
  316. top: newPosition.top + 'px'
  317. });
  318. function fitInParent (pos) {
  319. var newPosition = { left: pos.left, top: pos.top };
  320. newPosition.left = Math.min( newPosition.left, tooltipParent.prop('scrollWidth') - tipRect.width - TOOLTIP_WINDOW_EDGE_SPACE );
  321. newPosition.left = Math.max( newPosition.left, TOOLTIP_WINDOW_EDGE_SPACE );
  322. newPosition.top = Math.min( newPosition.top, tooltipParent.prop('scrollHeight') - tipRect.height - TOOLTIP_WINDOW_EDGE_SPACE );
  323. newPosition.top = Math.max( newPosition.top, TOOLTIP_WINDOW_EDGE_SPACE );
  324. return newPosition;
  325. }
  326. function getPosition (dir) {
  327. return dir === 'left'
  328. ? { left: parentRect.left - tipRect.width - TOOLTIP_WINDOW_EDGE_SPACE,
  329. top: parentRect.top + parentRect.height / 2 - tipRect.height / 2 }
  330. : dir === 'right'
  331. ? { left: parentRect.left + parentRect.width + TOOLTIP_WINDOW_EDGE_SPACE,
  332. top: parentRect.top + parentRect.height / 2 - tipRect.height / 2 }
  333. : dir === 'top'
  334. ? { left: parentRect.left + parentRect.width / 2 - tipRect.width / 2,
  335. top: parentRect.top - tipRect.height - TOOLTIP_WINDOW_EDGE_SPACE }
  336. : { left: parentRect.left + parentRect.width / 2 - tipRect.width / 2,
  337. top: parentRect.top + parentRect.height + TOOLTIP_WINDOW_EDGE_SPACE };
  338. }
  339. }
  340. }
  341. }
  342. ngmaterial.components.tooltip = angular.module("material.components.tooltip");