/*! * Angular Material Design * https://github.com/angular/material * @license MIT * v1.1.3 */ goog.provide('ngmaterial.components.tooltip'); goog.require('ngmaterial.components.panel'); goog.require('ngmaterial.core'); /** * @ngdoc module * @name material.components.tooltip */ MdTooltipDirective['$inject'] = ["$timeout", "$window", "$$rAF", "$document", "$interpolate", "$mdUtil", "$mdPanel", "$$mdTooltipRegistry"]; angular .module('material.components.tooltip', [ 'material.core', 'material.components.panel' ]) .directive('mdTooltip', MdTooltipDirective) .service('$$mdTooltipRegistry', MdTooltipRegistry); /** * @ngdoc directive * @name mdTooltip * @module material.components.tooltip * @description * Tooltips are used to describe elements that are interactive and primarily * graphical (not textual). * * Place a `` as a child of the element it describes. * * A tooltip will activate when the user hovers over, focuses, or touches the * parent element. * * @usage * * * Play Music * * * * * @param {number=} md-z-index The visual level that the tooltip will appear * in comparison with the rest of the elements of the application. * @param {expression=} md-visible Boolean bound to whether the tooltip is * currently visible. * @param {number=} md-delay How many milliseconds to wait to show the tooltip * after the user hovers over, focuses, or touches the parent element. * Defaults to 0ms on non-touch devices and 75ms on touch. * @param {boolean=} md-autohide If present or provided with a boolean value, * the tooltip will hide on mouse leave, regardless of focus. * @param {string=} md-direction The direction that the tooltip is shown, * relative to the parent element. Supports top, right, bottom, and left. * Defaults to bottom. */ function MdTooltipDirective($timeout, $window, $$rAF, $document, $interpolate, $mdUtil, $mdPanel, $$mdTooltipRegistry) { var ENTER_EVENTS = 'focus touchstart mouseenter'; var LEAVE_EVENTS = 'blur touchcancel mouseleave'; var TOOLTIP_DEFAULT_Z_INDEX = 100; var TOOLTIP_DEFAULT_SHOW_DELAY = 0; var TOOLTIP_DEFAULT_DIRECTION = 'bottom'; var TOOLTIP_DIRECTIONS = { top: { x: $mdPanel.xPosition.CENTER, y: $mdPanel.yPosition.ABOVE }, right: { x: $mdPanel.xPosition.OFFSET_END, y: $mdPanel.yPosition.CENTER }, bottom: { x: $mdPanel.xPosition.CENTER, y: $mdPanel.yPosition.BELOW }, left: { x: $mdPanel.xPosition.OFFSET_START, y: $mdPanel.yPosition.CENTER } }; return { restrict: 'E', priority: 210, // Before ngAria scope: { mdZIndex: '=?mdZIndex', mdDelay: '=?mdDelay', mdVisible: '=?mdVisible', mdAutohide: '=?mdAutohide', mdDirection: '@?mdDirection' // Do not expect expressions. }, link: linkFunc }; function linkFunc(scope, element, attr) { // Set constants. var parent = $mdUtil.getParentWithPointerEvents(element); var debouncedOnResize = $$rAF.throttle(updatePosition); var mouseActive = false; var origin, position, panelPosition, panelRef, autohide, showTimeout, elementFocusedOnWindowBlur = null; // Set defaults setDefaults(); // Set parent aria-label. addAriaLabel(); // Remove the element from its current DOM position. element.detach(); updatePosition(); bindEvents(); configureWatchers(); function setDefaults() { scope.mdZIndex = scope.mdZIndex || TOOLTIP_DEFAULT_Z_INDEX; scope.mdDelay = scope.mdDelay || TOOLTIP_DEFAULT_SHOW_DELAY; if (!TOOLTIP_DIRECTIONS[scope.mdDirection]) { scope.mdDirection = TOOLTIP_DEFAULT_DIRECTION; } } function addAriaLabel(override) { if (override || !parent.attr('aria-label')) { // Only interpolate the text from the HTML element because otherwise the custom text // could be interpolated twice and cause XSS violations. var interpolatedText = override || $interpolate(element.text().trim())(scope.$parent); parent.attr('aria-label', interpolatedText); } } function updatePosition() { setDefaults(); // If the panel has already been created, remove the current origin // class from the panel element. if (panelRef && panelRef.panelEl) { panelRef.panelEl.removeClass(origin); } // Set the panel element origin class based off of the current // mdDirection. origin = 'md-origin-' + scope.mdDirection; // Create the position of the panel based off of the mdDirection. position = TOOLTIP_DIRECTIONS[scope.mdDirection]; // Using the newly created position object, use the MdPanel // panelPosition API to build the panel's position. panelPosition = $mdPanel.newPanelPosition() .relativeTo(parent) .addPanelPosition(position.x, position.y); // If the panel has already been created, add the new origin class to // the panel element and update it's position with the panelPosition. if (panelRef && panelRef.panelEl) { panelRef.panelEl.addClass(origin); panelRef.updatePosition(panelPosition); } } function bindEvents() { // Add a mutationObserver where there is support for it and the need // for it in the form of viable host(parent[0]). if (parent[0] && 'MutationObserver' in $window) { // Use a mutationObserver to tackle #2602. var attributeObserver = new MutationObserver(function(mutations) { if (isDisabledMutation(mutations)) { $mdUtil.nextTick(function() { setVisible(false); }); } }); attributeObserver.observe(parent[0], { attributes: true }); } elementFocusedOnWindowBlur = false; $$mdTooltipRegistry.register('scroll', windowScrollEventHandler, true); $$mdTooltipRegistry.register('blur', windowBlurEventHandler); $$mdTooltipRegistry.register('resize', debouncedOnResize); scope.$on('$destroy', onDestroy); // To avoid 'synthetic clicks', we listen to mousedown instead of // 'click'. parent.on('mousedown', mousedownEventHandler); parent.on(ENTER_EVENTS, enterEventHandler); function isDisabledMutation(mutations) { mutations.some(function(mutation) { return mutation.attributeName === 'disabled' && parent[0].disabled; }); return false; } function windowScrollEventHandler() { setVisible(false); } function windowBlurEventHandler() { elementFocusedOnWindowBlur = document.activeElement === parent[0]; } function enterEventHandler($event) { // Prevent the tooltip from showing when the window is receiving // focus. if ($event.type === 'focus' && elementFocusedOnWindowBlur) { elementFocusedOnWindowBlur = false; } else if (!scope.mdVisible) { parent.on(LEAVE_EVENTS, leaveEventHandler); setVisible(true); // If the user is on a touch device, we should bind the tap away // after the 'touched' in order to prevent the tooltip being // removed immediately. if ($event.type === 'touchstart') { parent.one('touchend', function() { $mdUtil.nextTick(function() { $document.one('touchend', leaveEventHandler); }, false); }); } } } function leaveEventHandler() { autohide = scope.hasOwnProperty('mdAutohide') ? scope.mdAutohide : attr.hasOwnProperty('mdAutohide'); if (autohide || mouseActive || $document[0].activeElement !== parent[0]) { // When a show timeout is currently in progress, then we have // to cancel it, otherwise the tooltip will remain showing // without focus or hover. if (showTimeout) { $timeout.cancel(showTimeout); setVisible.queued = false; showTimeout = null; } parent.off(LEAVE_EVENTS, leaveEventHandler); parent.triggerHandler('blur'); setVisible(false); } mouseActive = false; } function mousedownEventHandler() { mouseActive = true; } function onDestroy() { $$mdTooltipRegistry.deregister('scroll', windowScrollEventHandler, true); $$mdTooltipRegistry.deregister('blur', windowBlurEventHandler); $$mdTooltipRegistry.deregister('resize', debouncedOnResize); parent .off(ENTER_EVENTS, enterEventHandler) .off(LEAVE_EVENTS, leaveEventHandler) .off('mousedown', mousedownEventHandler); // Trigger the handler in case any of the tooltips are // still visible. leaveEventHandler(); attributeObserver && attributeObserver.disconnect(); } } function configureWatchers() { if (element[0] && 'MutationObserver' in $window) { var attributeObserver = new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { if (mutation.attributeName === 'md-visible' && !scope.visibleWatcher ) { scope.visibleWatcher = scope.$watch('mdVisible', onVisibleChanged); } }); }); attributeObserver.observe(element[0], { attributes: true }); // Build watcher only if mdVisible is being used. if (attr.hasOwnProperty('mdVisible')) { scope.visibleWatcher = scope.$watch('mdVisible', onVisibleChanged); } } else { // MutationObserver not supported scope.visibleWatcher = scope.$watch('mdVisible', onVisibleChanged); } // Direction watcher scope.$watch('mdDirection', updatePosition); // Clean up if the element or parent was removed via jqLite's .remove. // A couple of notes: // - In these cases the scope might not have been destroyed, which // is why we destroy it manually. An example of this can be having // `md-visible="false"` and adding tooltips while they're // invisible. If `md-visible` becomes true, at some point, you'd // usually get a lot of tooltips. // - We use `.one`, not `.on`, because this only needs to fire once. // If we were using `.on`, it would get thrown into an infinite // loop. // - This kicks off the scope's `$destroy` event which finishes the // cleanup. element.one('$destroy', onElementDestroy); parent.one('$destroy', onElementDestroy); scope.$on('$destroy', function() { setVisible(false); panelRef && panelRef.destroy(); attributeObserver && attributeObserver.disconnect(); element.remove(); }); // Updates the aria-label when the element text changes. This watch // doesn't need to be set up if the element doesn't have any data // bindings. if (element.text().indexOf($interpolate.startSymbol()) > -1) { scope.$watch(function() { return element.text().trim(); }, addAriaLabel); } function onElementDestroy() { scope.$destroy(); } } function setVisible(value) { // Break if passed value is already in queue or there is no queue and // passed value is current in the controller. if (setVisible.queued && setVisible.value === !!value || !setVisible.queued && scope.mdVisible === !!value) { return; } setVisible.value = !!value; if (!setVisible.queued) { if (value) { setVisible.queued = true; showTimeout = $timeout(function() { scope.mdVisible = setVisible.value; setVisible.queued = false; showTimeout = null; if (!scope.visibleWatcher) { onVisibleChanged(scope.mdVisible); } }, scope.mdDelay); } else { $mdUtil.nextTick(function() { scope.mdVisible = false; if (!scope.visibleWatcher) { onVisibleChanged(false); } }); } } } function onVisibleChanged(isVisible) { isVisible ? showTooltip() : hideTooltip(); } function showTooltip() { // Do not show the tooltip if the text is empty. if (!element[0].textContent.trim()) { throw new Error('Text for the tooltip has not been provided. ' + 'Please include text within the mdTooltip element.'); } if (!panelRef) { var id = 'tooltip-' + $mdUtil.nextUid(); var attachTo = angular.element(document.body); var panelAnimation = $mdPanel.newPanelAnimation() .openFrom(parent) .closeTo(parent) .withAnimation({ open: 'md-show', close: 'md-hide' }); var panelConfig = { id: id, attachTo: attachTo, contentElement: element, propagateContainerEvents: true, panelClass: 'md-tooltip ' + origin, animation: panelAnimation, position: panelPosition, zIndex: scope.mdZIndex, focusOnOpen: false }; panelRef = $mdPanel.create(panelConfig); } panelRef.open().then(function() { panelRef.panelEl.attr('role', 'tooltip'); }); } function hideTooltip() { panelRef && panelRef.close(); } } } /** * Service that is used to reduce the amount of listeners that are being * registered on the `window` by the tooltip component. Works by collecting * the individual event handlers and dispatching them from a global handler. * * ngInject */ function MdTooltipRegistry() { var listeners = {}; var ngWindow = angular.element(window); return { register: register, deregister: deregister }; /** * Global event handler that dispatches the registered handlers in the * service. * @param {!Event} event Event object passed in by the browser */ function globalEventHandler(event) { if (listeners[event.type]) { listeners[event.type].forEach(function(currentHandler) { currentHandler.call(this, event); }, this); } } /** * Registers a new handler with the service. * @param {string} type Type of event to be registered. * @param {!Function} handler Event handler. * @param {boolean} useCapture Whether to use event capturing. */ function register(type, handler, useCapture) { var handlers = listeners[type] = listeners[type] || []; if (!handlers.length) { useCapture ? window.addEventListener(type, globalEventHandler, true) : ngWindow.on(type, globalEventHandler); } if (handlers.indexOf(handler) === -1) { handlers.push(handler); } } /** * Removes an event handler from the service. * @param {string} type Type of event handler. * @param {!Function} handler The event handler itself. * @param {boolean} useCapture Whether the event handler used event capturing. */ function deregister(type, handler, useCapture) { var handlers = listeners[type]; var index = handlers ? handlers.indexOf(handler) : -1; if (index > -1) { handlers.splice(index, 1); if (handlers.length === 0) { useCapture ? window.removeEventListener(type, globalEventHandler, true) : ngWindow.off(type, globalEventHandler); } } } } ngmaterial.components.tooltip = angular.module("material.components.tooltip");