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