src/tooltip/tooltip.js
'use strict';
angular.module('mgcrea.ngStrap.tooltip', ['mgcrea.ngStrap.core', 'mgcrea.ngStrap.helpers.dimensions'])
.provider('$tooltip', function () {
var defaults = this.defaults = {
animation: 'am-fade',
customClass: '',
prefixClass: 'tooltip',
prefixEvent: 'tooltip',
container: false,
target: false,
placement: 'top',
templateUrl: 'tooltip/tooltip.tpl.html',
template: '',
titleTemplate: false,
trigger: 'hover focus',
keyboard: false,
html: false,
show: false,
title: '',
type: '',
delay: 0,
autoClose: false,
bsEnabled: true,
mouseDownPreventDefault: true,
mouseDownStopPropagation: true,
viewport: {
selector: 'body',
padding: 0
}
};
this.$get = function ($window, $rootScope, $bsCompiler, $q, $templateCache, $http, $animate, $sce, dimensions, $$rAF, $timeout) {
var isNative = /(ip[ao]d|iphone|android)/ig.test($window.navigator.userAgent);
var isTouch = ('createTouch' in $window.document) && isNative;
var $body = angular.element($window.document);
function TooltipFactory (element, config) {
var $tooltip = {};
// Common vars
var options = $tooltip.$options = angular.extend({}, defaults, config);
var promise = $tooltip.$promise = $bsCompiler.compile(options);
var scope = $tooltip.$scope = options.scope && options.scope.$new() || $rootScope.$new();
var nodeName = element[0].nodeName.toLowerCase();
if (options.delay && angular.isString(options.delay)) {
var split = options.delay.split(',').map(parseFloat);
options.delay = split.length > 1 ? {show: split[0], hide: split[1]} : split[0];
}
// Store $id to identify the triggering element in events
// give priority to options.id, otherwise, try to use
// element id if defined
$tooltip.$id = options.id || element.attr('id') || '';
// Support scope as string options
if (options.title) {
scope.title = $sce.trustAsHtml(options.title);
}
// Provide scope helpers
scope.$setEnabled = function (isEnabled) {
scope.$$postDigest(function () {
$tooltip.setEnabled(isEnabled);
});
};
scope.$hide = function () {
scope.$$postDigest(function () {
$tooltip.hide();
});
};
scope.$show = function () {
scope.$$postDigest(function () {
$tooltip.show();
});
};
scope.$toggle = function () {
scope.$$postDigest(function () {
$tooltip.toggle();
});
};
// Publish isShown as a protected var on scope
$tooltip.$isShown = scope.$isShown = false;
// Private vars
var timeout;
var hoverState;
// Fetch, compile then initialize tooltip
var compileData;
var tipElement;
var tipContainer;
var tipScope;
promise.then(function (data) {
compileData = data;
$tooltip.init();
});
$tooltip.init = function () {
// Options: delay
if (options.delay && angular.isNumber(options.delay)) {
options.delay = {
show: options.delay,
hide: options.delay
};
}
// Replace trigger on touch devices ?
// if(isTouch && options.trigger === defaults.trigger) {
// options.trigger.replace(/hover/g, 'click');
// }
// Options : container
if (options.container === 'self') {
tipContainer = element;
} else if (angular.isElement(options.container)) {
tipContainer = options.container;
} else if (options.container) {
tipContainer = findElement(options.container);
}
// Options: trigger
bindTriggerEvents();
// Options: target
if (options.target) {
options.target = angular.isElement(options.target) ? options.target : findElement(options.target);
}
// Options: show
if (options.show) {
scope.$$postDigest(function () {
if (options.trigger === 'focus') {
element[0].focus();
} else {
$tooltip.show();
}
});
}
};
$tooltip.destroy = function () {
// Unbind events
unbindTriggerEvents();
// Remove element
destroyTipElement();
// Destroy scope
scope.$destroy();
};
$tooltip.enter = function () {
clearTimeout(timeout);
hoverState = 'in';
if (!options.delay || !options.delay.show) {
return $tooltip.show();
}
timeout = setTimeout(function () {
if (hoverState === 'in') $tooltip.show();
}, options.delay.show);
};
$tooltip.show = function () {
if (!options.bsEnabled || $tooltip.$isShown) return;
scope.$emit(options.prefixEvent + '.show.before', $tooltip);
if (angular.isDefined(options.onBeforeShow) && angular.isFunction(options.onBeforeShow)) {
options.onBeforeShow($tooltip);
}
var parent;
var after;
if (options.container) {
parent = tipContainer;
if (tipContainer[0].lastChild) {
after = angular.element(tipContainer[0].lastChild);
} else {
after = null;
}
} else {
parent = null;
after = element;
}
// Hide any existing tipElement
if (tipElement) destroyTipElement();
// Fetch a cloned element linked from template
tipScope = $tooltip.$scope.$new();
tipElement = $tooltip.$element = compileData.link(tipScope, function (clonedElement, scope) {});
// Set the initial positioning. Make the tooltip invisible
// so IE doesn't try to focus on it off screen.
tipElement.css({top: '-9999px', left: '-9999px', right: 'auto', display: 'block', visibility: 'hidden'});
// Options: animation
if (options.animation) tipElement.addClass(options.animation);
// Options: type
if (options.type) tipElement.addClass(options.prefixClass + '-' + options.type);
// Options: custom classes
if (options.customClass) tipElement.addClass(options.customClass);
// Append the element, without any animations. If we append
// using $animate.enter, some of the animations cause the placement
// to be off due to the transforms.
if (after) {
after.after(tipElement);
} else {
parent.prepend(tipElement);
}
$tooltip.$isShown = scope.$isShown = true;
safeDigest(scope);
// Now, apply placement
$tooltip.$applyPlacement();
// Once placed, animate it.
// Support v1.2+ $animate
// https://github.com/angular/angular.js/issues/11713
if (angular.version.minor <= 2) {
$animate.enter(tipElement, parent, after, enterAnimateCallback);
} else {
$animate.enter(tipElement, parent, after).then(enterAnimateCallback);
}
safeDigest(scope);
$$rAF(function () {
// Once the tooltip is placed and the animation starts, make the tooltip visible
if (tipElement) tipElement.css({visibility: 'visible'});
// Bind events
if (options.keyboard) {
if (options.trigger !== 'focus') {
$tooltip.focus();
}
bindKeyboardEvents();
}
});
if (options.autoClose) {
bindAutoCloseEvents();
}
};
function enterAnimateCallback () {
scope.$emit(options.prefixEvent + '.show', $tooltip);
if (angular.isDefined(options.onShow) && angular.isFunction(options.onShow)) {
options.onShow($tooltip);
}
}
$tooltip.leave = function () {
clearTimeout(timeout);
hoverState = 'out';
if (!options.delay || !options.delay.hide) {
return $tooltip.hide();
}
timeout = setTimeout(function () {
if (hoverState === 'out') {
$tooltip.hide();
}
}, options.delay.hide);
};
var _blur;
var _tipToHide;
$tooltip.hide = function (blur) {
if (!$tooltip.$isShown) return;
scope.$emit(options.prefixEvent + '.hide.before', $tooltip);
if (angular.isDefined(options.onBeforeHide) && angular.isFunction(options.onBeforeHide)) {
options.onBeforeHide($tooltip);
}
// store blur value for leaveAnimateCallback to use
_blur = blur;
// store current tipElement reference to use
// in leaveAnimateCallback
_tipToHide = tipElement;
if (tipElement !== null) {
// Support v1.2+ $animate
// https://github.com/angular/angular.js/issues/11713
if (angular.version.minor <= 2) {
$animate.leave(tipElement, leaveAnimateCallback);
} else {
$animate.leave(tipElement).then(leaveAnimateCallback);
}
}
$tooltip.$isShown = scope.$isShown = false;
safeDigest(scope);
// Unbind events
if (options.keyboard && tipElement !== null) {
unbindKeyboardEvents();
}
if (options.autoClose && tipElement !== null) {
unbindAutoCloseEvents();
}
};
function leaveAnimateCallback () {
scope.$emit(options.prefixEvent + '.hide', $tooltip);
if (angular.isDefined(options.onHide) && angular.isFunction(options.onHide)) {
options.onHide($tooltip);
}
// check if current tipElement still references
// the same element when hide was called
if (tipElement === _tipToHide) {
// Allow to blur the input when hidden, like when pressing enter key
if (_blur && options.trigger === 'focus') {
return element[0].blur();
}
// clean up child scopes
destroyTipElement();
}
}
$tooltip.toggle = function (evt) {
if (evt) { evt.preventDefault(); }
if ($tooltip.$isShown) {
$tooltip.leave();
} else {
$tooltip.enter();
}
};
$tooltip.focus = function () {
tipElement[0].focus();
};
$tooltip.setEnabled = function (isEnabled) {
options.bsEnabled = isEnabled;
};
$tooltip.setViewport = function (viewport) {
options.viewport = viewport;
};
// Protected methods
$tooltip.$applyPlacement = function () {
if (!tipElement) return;
// Determine if we're doing an auto or normal placement
var placement = options.placement;
var autoToken = /\s?auto?\s?/i;
var autoPlace = autoToken.test(placement);
if (autoPlace) {
placement = placement.replace(autoToken, '') || defaults.placement;
}
// Need to add the position class before we get
// the offsets
tipElement.addClass(options.placement);
// Get the position of the target element
// and the height and width of the tooltip so we can center it.
var elementPosition = getPosition();
var tipWidth = tipElement.prop('offsetWidth');
var tipHeight = tipElement.prop('offsetHeight');
// Refresh viewport position
$tooltip.$viewport = options.viewport && findElement(options.viewport.selector || options.viewport);
// If we're auto placing, we need to check the positioning
if (autoPlace) {
var originalPlacement = placement;
var viewportPosition = getPosition($tooltip.$viewport);
if (/bottom/.test(originalPlacement) && elementPosition.bottom + tipHeight > viewportPosition.bottom) {
placement = originalPlacement.replace('bottom', 'top');
} else if (/top/.test(originalPlacement) && elementPosition.top - tipHeight < viewportPosition.top) {
placement = originalPlacement.replace('top', 'bottom');
}
if (/left/.test(originalPlacement) && elementPosition.left - tipWidth < viewportPosition.left) {
placement = placement.replace('left', 'right');
} else if (/right/.test(originalPlacement) && elementPosition.right + tipWidth > viewportPosition.width) {
placement = placement.replace('right', 'left');
}
tipElement.removeClass(originalPlacement).addClass(placement);
}
// Get the tooltip's top and left coordinates to center it with this directive.
var tipPosition = getCalculatedOffset(placement, elementPosition, tipWidth, tipHeight);
applyPlacement(tipPosition, placement);
};
$tooltip.$onKeyUp = function (evt) {
if (evt.which === 27 && $tooltip.$isShown) {
$tooltip.hide();
evt.stopPropagation();
}
};
$tooltip.$onFocusKeyUp = function (evt) {
if (evt.which === 27) {
element[0].blur();
evt.stopPropagation();
}
};
$tooltip.$onFocusElementMouseDown = function (evt) {
if (options.mouseDownPreventDefault) { evt.preventDefault(); }
if (options.mouseDownStopPropagation) { evt.stopPropagation(); }
// Some browsers do not auto-focus buttons (eg. Safari)
if ($tooltip.$isShown) {
element[0].blur();
} else {
element[0].focus();
}
};
// bind/unbind events
function bindTriggerEvents () {
var triggers = options.trigger.split(' ');
angular.forEach(triggers, function (trigger) {
if (trigger === 'click' || trigger === 'contextmenu') {
element.on(trigger, $tooltip.toggle);
} else if (trigger !== 'manual') {
element.on(trigger === 'hover' ? 'mouseenter' : 'focus', $tooltip.enter);
element.on(trigger === 'hover' ? 'mouseleave' : 'blur', $tooltip.leave);
if (nodeName === 'button' && trigger !== 'hover') {
element.on(isTouch ? 'touchstart' : 'mousedown', $tooltip.$onFocusElementMouseDown);
}
}
});
}
function unbindTriggerEvents () {
var triggers = options.trigger.split(' ');
for (var i = triggers.length; i--;) {
var trigger = triggers[i];
if (trigger === 'click' || trigger === 'contextmenu') {
element.off(trigger, $tooltip.toggle);
} else if (trigger !== 'manual') {
element.off(trigger === 'hover' ? 'mouseenter' : 'focus', $tooltip.enter);
element.off(trigger === 'hover' ? 'mouseleave' : 'blur', $tooltip.leave);
if (nodeName === 'button' && trigger !== 'hover') {
element.off(isTouch ? 'touchstart' : 'mousedown', $tooltip.$onFocusElementMouseDown);
}
}
}
}
function bindKeyboardEvents () {
if (options.trigger !== 'focus') {
tipElement.on('keyup', $tooltip.$onKeyUp);
} else {
element.on('keyup', $tooltip.$onFocusKeyUp);
}
}
function unbindKeyboardEvents () {
if (options.trigger !== 'focus') {
tipElement.off('keyup', $tooltip.$onKeyUp);
} else {
element.off('keyup', $tooltip.$onFocusKeyUp);
}
}
var _autoCloseEventsBinded = false;
function bindAutoCloseEvents () {
// use timeout to hookup the events to prevent
// event bubbling from being processed imediately.
$timeout(function () {
// Stop propagation when clicking inside tooltip
if (tipElement !== null) {
tipElement.on('click', stopEventPropagation);
}
// Hide when clicking outside tooltip
$body.on('click', $tooltip.hide);
_autoCloseEventsBinded = true;
}, 0, false);
}
function unbindAutoCloseEvents () {
if (_autoCloseEventsBinded) {
tipElement.off('click', stopEventPropagation);
$body.off('click', $tooltip.hide);
_autoCloseEventsBinded = false;
}
}
function stopEventPropagation (event) {
event.stopPropagation();
}
// Private methods
function getPosition ($element) {
$element = $element || (options.target || element);
var el = $element[0];
var isBody = el.tagName === 'BODY';
var elRect = el.getBoundingClientRect();
var rect = {};
// IE8 has issues with angular.extend and using elRect directly.
// By coping the values of elRect into a new object, we can continue to use extend
/* eslint-disable guard-for-in */
for (var p in elRect) { // eslint-disable-line
// DO NOT use hasOwnProperty when inspecting the return of getBoundingClientRect.
rect[p] = elRect[p];
}
/* eslint-enable guard-for-in */
if (rect.width === null) {
// width and height are missing in IE8, so compute them manually; see https://github.com/twbs/bootstrap/issues/14093
rect = angular.extend({}, rect, {width: elRect.right - elRect.left, height: elRect.bottom - elRect.top});
}
var elOffset = isBody ? {top: 0, left: 0} : dimensions.offset(el);
var scroll = {scroll: isBody ? document.documentElement.scrollTop || document.body.scrollTop : $element.prop('scrollTop') || 0};
var outerDims = isBody ? {width: document.documentElement.clientWidth, height: $window.innerHeight} : null;
return angular.extend({}, rect, scroll, outerDims, elOffset);
}
function getCalculatedOffset (placement, position, actualWidth, actualHeight) {
var offset;
var split = placement.split('-');
switch (split[0]) {
case 'right':
offset = {
top: position.top + position.height / 2 - actualHeight / 2,
left: position.left + position.width
};
break;
case 'bottom':
offset = {
top: position.top + position.height,
left: position.left + position.width / 2 - actualWidth / 2
};
break;
case 'left':
offset = {
top: position.top + position.height / 2 - actualHeight / 2,
left: position.left - actualWidth
};
break;
default:
offset = {
top: position.top - actualHeight,
left: position.left + position.width / 2 - actualWidth / 2
};
break;
}
if (!split[1]) {
return offset;
}
// Add support for corners @todo css
if (split[0] === 'top' || split[0] === 'bottom') {
switch (split[1]) {
case 'left':
offset.left = position.left;
break;
case 'right':
offset.left = position.left + position.width - actualWidth;
break;
default:
break;
}
} else if (split[0] === 'left' || split[0] === 'right') {
switch (split[1]) {
case 'top':
offset.top = position.top - actualHeight + position.height;
break;
case 'bottom':
offset.top = position.top;
break;
default:
break;
}
}
return offset;
}
function applyPlacement (offset, placement) {
var tip = tipElement[0];
var width = tip.offsetWidth;
var height = tip.offsetHeight;
// manually read margins because getBoundingClientRect includes difference
var marginTop = parseInt(dimensions.css(tip, 'margin-top'), 10);
var marginLeft = parseInt(dimensions.css(tip, 'margin-left'), 10);
// we must check for NaN for ie 8/9
if (isNaN(marginTop)) marginTop = 0;
if (isNaN(marginLeft)) marginLeft = 0;
offset.top = offset.top + marginTop;
offset.left = offset.left + marginLeft;
// dimensions setOffset doesn't round pixel values
// so we use setOffset directly with our own function
dimensions.setOffset(tip, angular.extend({
using: function (props) {
tipElement.css({
top: Math.round(props.top) + 'px',
left: Math.round(props.left) + 'px',
right: ''
});
}
}, offset), 0);
// check to see if placing tip in new offset caused the tip to resize itself
var actualWidth = tip.offsetWidth;
var actualHeight = tip.offsetHeight;
if (placement === 'top' && actualHeight !== height) {
offset.top = offset.top + height - actualHeight;
}
// If it's an exotic placement, exit now instead of
// applying a delta and changing the arrow
if (/top-left|top-right|bottom-left|bottom-right/.test(placement)) return;
var delta = getViewportAdjustedDelta(placement, offset, actualWidth, actualHeight);
if (delta.left) {
offset.left += delta.left;
} else {
offset.top += delta.top;
}
dimensions.setOffset(tip, offset);
if (/top|right|bottom|left/.test(placement)) {
var isVertical = /top|bottom/.test(placement);
var arrowDelta = isVertical ? delta.left * 2 - width + actualWidth : delta.top * 2 - height + actualHeight;
var arrowOffsetPosition = isVertical ? 'offsetWidth' : 'offsetHeight';
replaceArrow(arrowDelta, tip[arrowOffsetPosition], isVertical);
}
}
// @source https://github.com/twbs/bootstrap/blob/v3.3.5/js/tooltip.js#L380
function getViewportAdjustedDelta (placement, position, actualWidth, actualHeight) {
var delta = {top: 0, left: 0};
if (!$tooltip.$viewport) return delta;
var viewportPadding = options.viewport && options.viewport.padding || 0;
var viewportDimensions = getPosition($tooltip.$viewport);
if (/right|left/.test(placement)) {
var topEdgeOffset = position.top - viewportPadding - viewportDimensions.scroll;
var bottomEdgeOffset = position.top + viewportPadding - viewportDimensions.scroll + actualHeight;
if (topEdgeOffset < viewportDimensions.top) { // top overflow
delta.top = viewportDimensions.top - topEdgeOffset;
} else if (bottomEdgeOffset > viewportDimensions.top + viewportDimensions.height) { // bottom overflow
delta.top = viewportDimensions.top + viewportDimensions.height - bottomEdgeOffset;
}
} else {
var leftEdgeOffset = position.left - viewportPadding;
var rightEdgeOffset = position.left + viewportPadding + actualWidth;
if (leftEdgeOffset < viewportDimensions.left) { // left overflow
delta.left = viewportDimensions.left - leftEdgeOffset;
} else if (rightEdgeOffset > viewportDimensions.right) { // right overflow
delta.left = viewportDimensions.left + viewportDimensions.width - rightEdgeOffset;
}
}
return delta;
}
function replaceArrow (delta, dimension, isHorizontal) {
var $arrow = findElement('.tooltip-arrow, .arrow', tipElement[0]);
$arrow.css(isHorizontal ? 'left' : 'top', 50 * (1 - delta / dimension) + '%')
.css(isHorizontal ? 'top' : 'left', '');
}
function destroyTipElement () {
// Cancel pending callbacks
clearTimeout(timeout);
if ($tooltip.$isShown && tipElement !== null) {
if (options.autoClose) {
unbindAutoCloseEvents();
}
if (options.keyboard) {
unbindKeyboardEvents();
}
}
if (tipScope) {
tipScope.$destroy();
tipScope = null;
}
if (tipElement) {
tipElement.remove();
tipElement = $tooltip.$element = null;
}
}
return $tooltip;
}
// Helper functions
function safeDigest (scope) {
/* eslint-disable no-unused-expressions */
scope.$$phase || (scope.$root && scope.$root.$$phase) || scope.$digest();
/* eslint-enable no-unused-expressions */
}
function findElement (query, element) {
return angular.element((element || document).querySelectorAll(query));
}
return TooltipFactory;
};
})
.directive('bsTooltip', function ($window, $location, $sce, $parse, $tooltip, $$rAF) {
return {
restrict: 'EAC',
scope: true,
link: function postLink (scope, element, attr, transclusion) {
var tooltip;
// Directive options
var options = {scope: scope};
angular.forEach(['template', 'templateUrl', 'controller', 'controllerAs', 'titleTemplate', 'placement', 'container', 'delay', 'trigger', 'html', 'animation', 'backdropAnimation', 'type', 'customClass', 'id'], function (key) {
if (angular.isDefined(attr[key])) options[key] = attr[key];
});
// use string regex match boolean attr falsy values, leave truthy values be
var falseValueRegExp = /^(false|0|)$/i;
angular.forEach(['html', 'container'], function (key) {
if (angular.isDefined(attr[key]) && falseValueRegExp.test(attr[key])) {
options[key] = false;
}
});
// bind functions from the attrs to the show and hide events
angular.forEach(['onBeforeShow', 'onShow', 'onBeforeHide', 'onHide'], function (key) {
var bsKey = 'bs' + key.charAt(0).toUpperCase() + key.slice(1);
if (angular.isDefined(attr[bsKey])) {
options[key] = scope.$eval(attr[bsKey]);
}
});
// should not parse target attribute (anchor tag), only data-target #1454
var dataTarget = element.attr('data-target');
if (angular.isDefined(dataTarget)) {
if (falseValueRegExp.test(dataTarget)) {
options.target = false;
} else {
options.target = dataTarget;
}
}
// overwrite inherited title value when no value specified
// fix for angular 1.3.1 531a8de72c439d8ddd064874bf364c00cedabb11
if (!scope.hasOwnProperty('title')) {
scope.title = '';
}
// Observe scope attributes for change
attr.$observe('title', function (newValue) {
if (angular.isDefined(newValue) || !scope.hasOwnProperty('title')) {
var oldValue = scope.title;
scope.title = $sce.trustAsHtml(newValue);
if (angular.isDefined(oldValue)) {
$$rAF(function () {
if (tooltip) tooltip.$applyPlacement();
});
}
}
});
attr.$observe('disabled', function (newValue) {
if (newValue && tooltip.$isShown) {
tooltip.hide();
}
});
// Support scope as an object
if (attr.bsTooltip) {
scope.$watch(attr.bsTooltip, function (newValue, oldValue) {
if (angular.isObject(newValue)) {
angular.extend(scope, newValue);
} else {
scope.title = newValue;
}
if (angular.isDefined(oldValue)) {
$$rAF(function () {
if (tooltip) tooltip.$applyPlacement();
});
}
}, true);
}
// Visibility binding support
if (attr.bsShow) {
scope.$watch(attr.bsShow, function (newValue, oldValue) {
if (!tooltip || !angular.isDefined(newValue)) return;
if (angular.isString(newValue)) newValue = !!newValue.match(/true|,?(tooltip),?/i);
if (newValue === true) {
tooltip.show();
} else {
tooltip.hide();
}
});
}
// Enabled binding support
if (attr.bsEnabled) {
scope.$watch(attr.bsEnabled, function (newValue, oldValue) {
// console.warn('scope.$watch(%s)', attr.bsEnabled, newValue, oldValue);
if (!tooltip || !angular.isDefined(newValue)) return;
if (angular.isString(newValue)) newValue = !!newValue.match(/true|1|,?(tooltip),?/i);
if (newValue === false) {
tooltip.setEnabled(false);
} else {
tooltip.setEnabled(true);
}
});
}
// Viewport support
if (attr.viewport) {
scope.$watch(attr.viewport, function (newValue) {
if (!tooltip || !angular.isDefined(newValue)) return;
tooltip.setViewport(newValue);
});
}
// Initialize popover
tooltip = $tooltip(element, options);
// Garbage collection
scope.$on('$destroy', function () {
if (tooltip) tooltip.destroy();
options = null;
tooltip = null;
});
}
};
});