/*!
|
|
* Angular Material Design
|
|
* https://github.com/angular/material
|
|
* @license MIT
|
|
* v1.1.3
|
|
*/
|
|
(function( window, angular, undefined ){
|
|
"use strict";
|
|
|
|
/**
|
|
* @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 `<md-tooltip>` as a child of the element it describes.
|
|
*
|
|
* A tooltip will activate when the user hovers over, focuses, or touches the
|
|
* parent element.
|
|
*
|
|
* @usage
|
|
* <hljs lang="html">
|
|
* <md-button class="md-fab md-accent" aria-label="Play">
|
|
* <md-tooltip>Play Music</md-tooltip>
|
|
* <md-icon md-svg-src="img/icons/ic_play_arrow_24px.svg"></md-icon>
|
|
* </md-button>
|
|
* </hljs>
|
|
*
|
|
* @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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
})(window, window.angular);
|