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.

479 lines
16 KiB

7 years ago
  1. /*!
  2. * Angular Material Design
  3. * https://github.com/angular/material
  4. * @license MIT
  5. * v1.1.3
  6. */
  7. (function( window, angular, undefined ){
  8. "use strict";
  9. /**
  10. * @ngdoc module
  11. * @name material.components.tooltip
  12. */
  13. MdTooltipDirective['$inject'] = ["$timeout", "$window", "$$rAF", "$document", "$interpolate", "$mdUtil", "$mdPanel", "$$mdTooltipRegistry"];
  14. angular
  15. .module('material.components.tooltip', [
  16. 'material.core',
  17. 'material.components.panel'
  18. ])
  19. .directive('mdTooltip', MdTooltipDirective)
  20. .service('$$mdTooltipRegistry', MdTooltipRegistry);
  21. /**
  22. * @ngdoc directive
  23. * @name mdTooltip
  24. * @module material.components.tooltip
  25. * @description
  26. * Tooltips are used to describe elements that are interactive and primarily
  27. * graphical (not textual).
  28. *
  29. * Place a `<md-tooltip>` as a child of the element it describes.
  30. *
  31. * A tooltip will activate when the user hovers over, focuses, or touches the
  32. * parent element.
  33. *
  34. * @usage
  35. * <hljs lang="html">
  36. * <md-button class="md-fab md-accent" aria-label="Play">
  37. * <md-tooltip>Play Music</md-tooltip>
  38. * <md-icon md-svg-src="img/icons/ic_play_arrow_24px.svg"></md-icon>
  39. * </md-button>
  40. * </hljs>
  41. *
  42. * @param {number=} md-z-index The visual level that the tooltip will appear
  43. * in comparison with the rest of the elements of the application.
  44. * @param {expression=} md-visible Boolean bound to whether the tooltip is
  45. * currently visible.
  46. * @param {number=} md-delay How many milliseconds to wait to show the tooltip
  47. * after the user hovers over, focuses, or touches the parent element.
  48. * Defaults to 0ms on non-touch devices and 75ms on touch.
  49. * @param {boolean=} md-autohide If present or provided with a boolean value,
  50. * the tooltip will hide on mouse leave, regardless of focus.
  51. * @param {string=} md-direction The direction that the tooltip is shown,
  52. * relative to the parent element. Supports top, right, bottom, and left.
  53. * Defaults to bottom.
  54. */
  55. function MdTooltipDirective($timeout, $window, $$rAF, $document, $interpolate,
  56. $mdUtil, $mdPanel, $$mdTooltipRegistry) {
  57. var ENTER_EVENTS = 'focus touchstart mouseenter';
  58. var LEAVE_EVENTS = 'blur touchcancel mouseleave';
  59. var TOOLTIP_DEFAULT_Z_INDEX = 100;
  60. var TOOLTIP_DEFAULT_SHOW_DELAY = 0;
  61. var TOOLTIP_DEFAULT_DIRECTION = 'bottom';
  62. var TOOLTIP_DIRECTIONS = {
  63. top: { x: $mdPanel.xPosition.CENTER, y: $mdPanel.yPosition.ABOVE },
  64. right: { x: $mdPanel.xPosition.OFFSET_END, y: $mdPanel.yPosition.CENTER },
  65. bottom: { x: $mdPanel.xPosition.CENTER, y: $mdPanel.yPosition.BELOW },
  66. left: { x: $mdPanel.xPosition.OFFSET_START, y: $mdPanel.yPosition.CENTER }
  67. };
  68. return {
  69. restrict: 'E',
  70. priority: 210, // Before ngAria
  71. scope: {
  72. mdZIndex: '=?mdZIndex',
  73. mdDelay: '=?mdDelay',
  74. mdVisible: '=?mdVisible',
  75. mdAutohide: '=?mdAutohide',
  76. mdDirection: '@?mdDirection' // Do not expect expressions.
  77. },
  78. link: linkFunc
  79. };
  80. function linkFunc(scope, element, attr) {
  81. // Set constants.
  82. var parent = $mdUtil.getParentWithPointerEvents(element);
  83. var debouncedOnResize = $$rAF.throttle(updatePosition);
  84. var mouseActive = false;
  85. var origin, position, panelPosition, panelRef, autohide, showTimeout,
  86. elementFocusedOnWindowBlur = null;
  87. // Set defaults
  88. setDefaults();
  89. // Set parent aria-label.
  90. addAriaLabel();
  91. // Remove the element from its current DOM position.
  92. element.detach();
  93. updatePosition();
  94. bindEvents();
  95. configureWatchers();
  96. function setDefaults() {
  97. scope.mdZIndex = scope.mdZIndex || TOOLTIP_DEFAULT_Z_INDEX;
  98. scope.mdDelay = scope.mdDelay || TOOLTIP_DEFAULT_SHOW_DELAY;
  99. if (!TOOLTIP_DIRECTIONS[scope.mdDirection]) {
  100. scope.mdDirection = TOOLTIP_DEFAULT_DIRECTION;
  101. }
  102. }
  103. function addAriaLabel(override) {
  104. if (override || !parent.attr('aria-label')) {
  105. // Only interpolate the text from the HTML element because otherwise the custom text
  106. // could be interpolated twice and cause XSS violations.
  107. var interpolatedText = override || $interpolate(element.text().trim())(scope.$parent);
  108. parent.attr('aria-label', interpolatedText);
  109. }
  110. }
  111. function updatePosition() {
  112. setDefaults();
  113. // If the panel has already been created, remove the current origin
  114. // class from the panel element.
  115. if (panelRef && panelRef.panelEl) {
  116. panelRef.panelEl.removeClass(origin);
  117. }
  118. // Set the panel element origin class based off of the current
  119. // mdDirection.
  120. origin = 'md-origin-' + scope.mdDirection;
  121. // Create the position of the panel based off of the mdDirection.
  122. position = TOOLTIP_DIRECTIONS[scope.mdDirection];
  123. // Using the newly created position object, use the MdPanel
  124. // panelPosition API to build the panel's position.
  125. panelPosition = $mdPanel.newPanelPosition()
  126. .relativeTo(parent)
  127. .addPanelPosition(position.x, position.y);
  128. // If the panel has already been created, add the new origin class to
  129. // the panel element and update it's position with the panelPosition.
  130. if (panelRef && panelRef.panelEl) {
  131. panelRef.panelEl.addClass(origin);
  132. panelRef.updatePosition(panelPosition);
  133. }
  134. }
  135. function bindEvents() {
  136. // Add a mutationObserver where there is support for it and the need
  137. // for it in the form of viable host(parent[0]).
  138. if (parent[0] && 'MutationObserver' in $window) {
  139. // Use a mutationObserver to tackle #2602.
  140. var attributeObserver = new MutationObserver(function(mutations) {
  141. if (isDisabledMutation(mutations)) {
  142. $mdUtil.nextTick(function() {
  143. setVisible(false);
  144. });
  145. }
  146. });
  147. attributeObserver.observe(parent[0], {
  148. attributes: true
  149. });
  150. }
  151. elementFocusedOnWindowBlur = false;
  152. $$mdTooltipRegistry.register('scroll', windowScrollEventHandler, true);
  153. $$mdTooltipRegistry.register('blur', windowBlurEventHandler);
  154. $$mdTooltipRegistry.register('resize', debouncedOnResize);
  155. scope.$on('$destroy', onDestroy);
  156. // To avoid 'synthetic clicks', we listen to mousedown instead of
  157. // 'click'.
  158. parent.on('mousedown', mousedownEventHandler);
  159. parent.on(ENTER_EVENTS, enterEventHandler);
  160. function isDisabledMutation(mutations) {
  161. mutations.some(function(mutation) {
  162. return mutation.attributeName === 'disabled' && parent[0].disabled;
  163. });
  164. return false;
  165. }
  166. function windowScrollEventHandler() {
  167. setVisible(false);
  168. }
  169. function windowBlurEventHandler() {
  170. elementFocusedOnWindowBlur = document.activeElement === parent[0];
  171. }
  172. function enterEventHandler($event) {
  173. // Prevent the tooltip from showing when the window is receiving
  174. // focus.
  175. if ($event.type === 'focus' && elementFocusedOnWindowBlur) {
  176. elementFocusedOnWindowBlur = false;
  177. } else if (!scope.mdVisible) {
  178. parent.on(LEAVE_EVENTS, leaveEventHandler);
  179. setVisible(true);
  180. // If the user is on a touch device, we should bind the tap away
  181. // after the 'touched' in order to prevent the tooltip being
  182. // removed immediately.
  183. if ($event.type === 'touchstart') {
  184. parent.one('touchend', function() {
  185. $mdUtil.nextTick(function() {
  186. $document.one('touchend', leaveEventHandler);
  187. }, false);
  188. });
  189. }
  190. }
  191. }
  192. function leaveEventHandler() {
  193. autohide = scope.hasOwnProperty('mdAutohide') ?
  194. scope.mdAutohide :
  195. attr.hasOwnProperty('mdAutohide');
  196. if (autohide || mouseActive ||
  197. $document[0].activeElement !== parent[0]) {
  198. // When a show timeout is currently in progress, then we have
  199. // to cancel it, otherwise the tooltip will remain showing
  200. // without focus or hover.
  201. if (showTimeout) {
  202. $timeout.cancel(showTimeout);
  203. setVisible.queued = false;
  204. showTimeout = null;
  205. }
  206. parent.off(LEAVE_EVENTS, leaveEventHandler);
  207. parent.triggerHandler('blur');
  208. setVisible(false);
  209. }
  210. mouseActive = false;
  211. }
  212. function mousedownEventHandler() {
  213. mouseActive = true;
  214. }
  215. function onDestroy() {
  216. $$mdTooltipRegistry.deregister('scroll', windowScrollEventHandler, true);
  217. $$mdTooltipRegistry.deregister('blur', windowBlurEventHandler);
  218. $$mdTooltipRegistry.deregister('resize', debouncedOnResize);
  219. parent
  220. .off(ENTER_EVENTS, enterEventHandler)
  221. .off(LEAVE_EVENTS, leaveEventHandler)
  222. .off('mousedown', mousedownEventHandler);
  223. // Trigger the handler in case any of the tooltips are
  224. // still visible.
  225. leaveEventHandler();
  226. attributeObserver && attributeObserver.disconnect();
  227. }
  228. }
  229. function configureWatchers() {
  230. if (element[0] && 'MutationObserver' in $window) {
  231. var attributeObserver = new MutationObserver(function(mutations) {
  232. mutations.forEach(function(mutation) {
  233. if (mutation.attributeName === 'md-visible' &&
  234. !scope.visibleWatcher ) {
  235. scope.visibleWatcher = scope.$watch('mdVisible',
  236. onVisibleChanged);
  237. }
  238. });
  239. });
  240. attributeObserver.observe(element[0], {
  241. attributes: true
  242. });
  243. // Build watcher only if mdVisible is being used.
  244. if (attr.hasOwnProperty('mdVisible')) {
  245. scope.visibleWatcher = scope.$watch('mdVisible',
  246. onVisibleChanged);
  247. }
  248. } else {
  249. // MutationObserver not supported
  250. scope.visibleWatcher = scope.$watch('mdVisible', onVisibleChanged);
  251. }
  252. // Direction watcher
  253. scope.$watch('mdDirection', updatePosition);
  254. // Clean up if the element or parent was removed via jqLite's .remove.
  255. // A couple of notes:
  256. // - In these cases the scope might not have been destroyed, which
  257. // is why we destroy it manually. An example of this can be having
  258. // `md-visible="false"` and adding tooltips while they're
  259. // invisible. If `md-visible` becomes true, at some point, you'd
  260. // usually get a lot of tooltips.
  261. // - We use `.one`, not `.on`, because this only needs to fire once.
  262. // If we were using `.on`, it would get thrown into an infinite
  263. // loop.
  264. // - This kicks off the scope's `$destroy` event which finishes the
  265. // cleanup.
  266. element.one('$destroy', onElementDestroy);
  267. parent.one('$destroy', onElementDestroy);
  268. scope.$on('$destroy', function() {
  269. setVisible(false);
  270. panelRef && panelRef.destroy();
  271. attributeObserver && attributeObserver.disconnect();
  272. element.remove();
  273. });
  274. // Updates the aria-label when the element text changes. This watch
  275. // doesn't need to be set up if the element doesn't have any data
  276. // bindings.
  277. if (element.text().indexOf($interpolate.startSymbol()) > -1) {
  278. scope.$watch(function() {
  279. return element.text().trim();
  280. }, addAriaLabel);
  281. }
  282. function onElementDestroy() {
  283. scope.$destroy();
  284. }
  285. }
  286. function setVisible(value) {
  287. // Break if passed value is already in queue or there is no queue and
  288. // passed value is current in the controller.
  289. if (setVisible.queued && setVisible.value === !!value ||
  290. !setVisible.queued && scope.mdVisible === !!value) {
  291. return;
  292. }
  293. setVisible.value = !!value;
  294. if (!setVisible.queued) {
  295. if (value) {
  296. setVisible.queued = true;
  297. showTimeout = $timeout(function() {
  298. scope.mdVisible = setVisible.value;
  299. setVisible.queued = false;
  300. showTimeout = null;
  301. if (!scope.visibleWatcher) {
  302. onVisibleChanged(scope.mdVisible);
  303. }
  304. }, scope.mdDelay);
  305. } else {
  306. $mdUtil.nextTick(function() {
  307. scope.mdVisible = false;
  308. if (!scope.visibleWatcher) {
  309. onVisibleChanged(false);
  310. }
  311. });
  312. }
  313. }
  314. }
  315. function onVisibleChanged(isVisible) {
  316. isVisible ? showTooltip() : hideTooltip();
  317. }
  318. function showTooltip() {
  319. // Do not show the tooltip if the text is empty.
  320. if (!element[0].textContent.trim()) {
  321. throw new Error('Text for the tooltip has not been provided. ' +
  322. 'Please include text within the mdTooltip element.');
  323. }
  324. if (!panelRef) {
  325. var id = 'tooltip-' + $mdUtil.nextUid();
  326. var attachTo = angular.element(document.body);
  327. var panelAnimation = $mdPanel.newPanelAnimation()
  328. .openFrom(parent)
  329. .closeTo(parent)
  330. .withAnimation({
  331. open: 'md-show',
  332. close: 'md-hide'
  333. });
  334. var panelConfig = {
  335. id: id,
  336. attachTo: attachTo,
  337. contentElement: element,
  338. propagateContainerEvents: true,
  339. panelClass: 'md-tooltip ' + origin,
  340. animation: panelAnimation,
  341. position: panelPosition,
  342. zIndex: scope.mdZIndex,
  343. focusOnOpen: false
  344. };
  345. panelRef = $mdPanel.create(panelConfig);
  346. }
  347. panelRef.open().then(function() {
  348. panelRef.panelEl.attr('role', 'tooltip');
  349. });
  350. }
  351. function hideTooltip() {
  352. panelRef && panelRef.close();
  353. }
  354. }
  355. }
  356. /**
  357. * Service that is used to reduce the amount of listeners that are being
  358. * registered on the `window` by the tooltip component. Works by collecting
  359. * the individual event handlers and dispatching them from a global handler.
  360. *
  361. * ngInject
  362. */
  363. function MdTooltipRegistry() {
  364. var listeners = {};
  365. var ngWindow = angular.element(window);
  366. return {
  367. register: register,
  368. deregister: deregister
  369. };
  370. /**
  371. * Global event handler that dispatches the registered handlers in the
  372. * service.
  373. * @param {!Event} event Event object passed in by the browser
  374. */
  375. function globalEventHandler(event) {
  376. if (listeners[event.type]) {
  377. listeners[event.type].forEach(function(currentHandler) {
  378. currentHandler.call(this, event);
  379. }, this);
  380. }
  381. }
  382. /**
  383. * Registers a new handler with the service.
  384. * @param {string} type Type of event to be registered.
  385. * @param {!Function} handler Event handler.
  386. * @param {boolean} useCapture Whether to use event capturing.
  387. */
  388. function register(type, handler, useCapture) {
  389. var handlers = listeners[type] = listeners[type] || [];
  390. if (!handlers.length) {
  391. useCapture ? window.addEventListener(type, globalEventHandler, true) :
  392. ngWindow.on(type, globalEventHandler);
  393. }
  394. if (handlers.indexOf(handler) === -1) {
  395. handlers.push(handler);
  396. }
  397. }
  398. /**
  399. * Removes an event handler from the service.
  400. * @param {string} type Type of event handler.
  401. * @param {!Function} handler The event handler itself.
  402. * @param {boolean} useCapture Whether the event handler used event capturing.
  403. */
  404. function deregister(type, handler, useCapture) {
  405. var handlers = listeners[type];
  406. var index = handlers ? handlers.indexOf(handler) : -1;
  407. if (index > -1) {
  408. handlers.splice(index, 1);
  409. if (handlers.length === 0) {
  410. useCapture ? window.removeEventListener(type, globalEventHandler, true) :
  411. ngWindow.off(type, globalEventHandler);
  412. }
  413. }
  414. }
  415. }
  416. })(window, window.angular);