‘use strict’;
angular.module(‘mgcrea.ngStrap.tooltip’, [‘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', template: 'tooltip/tooltip.tpl.html', contentTemplate: false, trigger: 'hover focus', keyboard: false, html: false, show: false, title: '', type: '', delay: 0, autoClose: false, bsEnabled: true, viewport: { selector: 'body', padding: 0 } }; this.$get = function($window, $rootScope, $compile, $q, $templateCache, $http, $animate, $sce, dimensions, $$rAF, $timeout) { var trim = String.prototype.trim; var isTouch = 'createTouch' in $window.document; var htmlReplaceRegExp = /ng-bind="/ig; var $body = angular.element($window.document); function TooltipFactory(element, config) { var $tooltip = {}; // Common vars var nodeName = element[0].nodeName.toLowerCase(); var options = $tooltip.$options = angular.extend({}, defaults, config); $tooltip.$promise = fetchTemplate(options.template); var scope = $tooltip.$scope = options.scope && options.scope.$new() || $rootScope.$new(); 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, hoverState; // Support contentTemplate option if(options.contentTemplate) { $tooltip.$promise = $tooltip.$promise.then(function(template) { var templateEl = angular.element(template); return fetchTemplate(options.contentTemplate) .then(function(contentTemplate) { var contentEl = findElement('[ng-bind="content"]', templateEl[0]); if(!contentEl.length) contentEl = findElement('[ng-bind="title"]', templateEl[0]); contentEl.removeAttr('ng-bind').html(contentTemplate); return templateEl[0].outerHTML; }); }); } // Fetch, compile then initialize tooltip var tipLinker, tipElement, tipTemplate, tipContainer, tipScope; $tooltip.$promise.then(function(template) { if(angular.isObject(template)) template = template.data; if(options.html) template = template.replace(htmlReplaceRegExp, 'ng-bind-html="'); template = trim.apply(template); tipTemplate = template; tipLinker = $compile(template); $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() { options.trigger === 'focus' ? element[0].focus() : $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); var parent, 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 = tipLinker(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', 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. after ? after.after(tipElement) : parent.prepend(tipElement); $tooltip.$isShown = scope.$isShown = true; safeDigest(scope); // Now, apply placement $tooltip.$applyPlacement(); // Once placed, animate it. // Support v1.3+ $animate // https://github.com/angular/angular.js/commit/bf0f5502b1bbfddc5cdd2f138efd9188b8c652a9 var promise = $animate.enter(tipElement, parent, after, enterAnimateCallback); if(promise && promise.then) promise.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); } $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); // store blur value for leaveAnimateCallback to use _blur = blur; // store current tipElement reference to use // in leaveAnimateCallback _tipToHide = tipElement; // Support v1.3+ $animate // https://github.com/angular/angular.js/commit/bf0f5502b1bbfddc5cdd2f138efd9188b8c652a9 var promise = $animate.leave(tipElement, leaveAnimateCallback); if(promise && promise.then) promise.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); // 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() { $tooltip.$isShown ? $tooltip.leave() : $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, autoToken = /\s?auto?\s?/i, 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(), tipWidth = tipElement.prop('offsetWidth'), tipHeight = tipElement.prop('offsetHeight'); // If we're auto placing, we need to check the positioning if (autoPlace) { var originalPlacement = placement; var container = options.container ? findElement(options.container) : element.parent(); var containerPosition = getPosition(container); // Determine if the vertical placement if (originalPlacement.indexOf('bottom') >= 0 && elementPosition.bottom + tipHeight > containerPosition.bottom) { placement = originalPlacement.replace('bottom', 'top'); } else if (originalPlacement.indexOf('top') >= 0 && elementPosition.top - tipHeight < containerPosition.top) { placement = originalPlacement.replace('top', 'bottom'); } // Determine the horizontal placement // The exotic placements of left and right are opposite of the standard placements. Their arrows are put on the left/right // and flow in the opposite direction of their placement. if ((originalPlacement === 'right' || originalPlacement === 'bottom-left' || originalPlacement === 'top-left') && elementPosition.right + tipWidth > containerPosition.width) { placement = originalPlacement === 'right' ? 'left' : placement.replace('left', 'right'); } else if ((originalPlacement === 'left' || originalPlacement === 'bottom-right' || originalPlacement === 'top-right') && elementPosition.left - tipWidth < containerPosition.left) { placement = originalPlacement === 'left' ? 'right' : 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) { evt.preventDefault(); evt.stopPropagation(); // Some browsers do not auto-focus buttons (eg. Safari) $tooltip.$isShown ? element[0].blur() : element[0].focus(); }; // bind/unbind events function bindTriggerEvents() { var triggers = options.trigger.split(' '); angular.forEach(triggers, function(trigger) { if(trigger === 'click') { element.on('click', $tooltip.toggle); } else if(trigger !== 'manual') { element.on(trigger === 'hover' ? 'mouseenter' : 'focus', $tooltip.enter); element.on(trigger === 'hover' ? 'mouseleave' : 'blur', $tooltip.leave); 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') { element.off('click', $tooltip.toggle); } else if(trigger !== 'manual') { element.off(trigger === 'hover' ? 'mouseenter' : 'focus', $tooltip.enter); element.off(trigger === 'hover' ? 'mouseleave' : 'blur', $tooltip.leave); 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 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], 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 for (var p in elRect) { if (elRect.hasOwnProperty(p)) { rect[p] = elRect[p]; } } 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), scroll = { scroll: isBody ? document.documentElement.scrollTop || document.body.scrollTop : $element.prop('scrollTop') || 0 }, 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; } } else if(split[0] === 'left' || split[0] === 'right') { switch (split[1]) { case 'top': offset.top = position.top - actualHeight; break; case 'bottom': offset.top = position.top + position.height; } } return offset; } function applyPlacement(offset, placement) { var tip = tipElement[0], width = tip.offsetWidth, height = tip.offsetHeight; // manually read margins because getBoundingClientRect includes difference var marginTop = parseInt(dimensions.css(tip, 'margin-top'), 10), 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' }); } }, offset), 0); // check to see if placing tip in new offset caused the tip to resize itself var actualWidth = tip.offsetWidth, 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), arrowDelta = isVertical ? delta.left * 2 - width + actualWidth : delta.top * 2 - height + actualHeight, arrowOffsetPosition = isVertical ? 'offsetWidth' : 'offsetHeight'; replaceArrow(arrowDelta, tip[arrowOffsetPosition], isVertical); } } function getViewportAdjustedDelta(placement, position, actualWidth, actualHeight) { var delta = { top: 0, left: 0 }, $viewport = options.viewport && findElement(options.viewport.selector || options.viewport); if (!$viewport) { return delta; } var viewportPadding = options.viewport && options.viewport.padding || 0, viewportDimensions = getPosition($viewport); if (/right|left/.test(placement)) { var topEdgeOffset = position.top - viewportPadding - viewportDimensions.scroll, 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, rightEdgeOffset = position.left + viewportPadding + actualWidth; if (leftEdgeOffset < viewportDimensions.left) { // left overflow delta.left = viewportDimensions.left - leftEdgeOffset; } else if (rightEdgeOffset > viewportDimensions.width) { // 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) { scope.$$phase || (scope.$root && scope.$root.$$phase) || scope.$digest(); } function findElement(query, element) { return angular.element((element || document).querySelectorAll(query)); } var fetchPromises = {}; function fetchTemplate(template) { if(fetchPromises[template]) return fetchPromises[template]; return (fetchPromises[template] = $http.get(template, {cache: $templateCache}).then(function(res) { return res.data; })); } return TooltipFactory; }; }) .directive('bsTooltip', function($window, $location, $sce, $tooltip, $$rAF) { return { restrict: 'EAC', scope: true, link: function postLink(scope, element, attr, transclusion) { // Directive options var options = {scope: scope}; angular.forEach(['template', 'contentTemplate', 'placement', 'container', 'delay', 'trigger', 'keyboard', 'html', 'animation', 'backdropAnimation', 'type', 'customClass', 'id'], function(key) { if(angular.isDefined(attr[key])) options[key] = attr[key]; }); // should not parse target attribute, only data-target if(element.attr('data-target')) { options.target = element.attr('data-target'); } // 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); angular.isDefined(oldValue) && $$rAF(function() { tooltip && tooltip.$applyPlacement(); }); } }); // Support scope as an object attr.bsTooltip && scope.$watch(attr.bsTooltip, function(newValue, oldValue) { if(angular.isObject(newValue)) { angular.extend(scope, newValue); } else { scope.title = newValue; } angular.isDefined(oldValue) && $$rAF(function() { tooltip && tooltip.$applyPlacement(); }); }, true); // Visibility binding support 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); newValue === true ? tooltip.show() : tooltip.hide(); }); // Enabled binding support 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); newValue === false ? tooltip.setEnabled(false) : tooltip.setEnabled(true); }); // Viewport support attr.viewport && scope.$watch(attr.viewport, function (newValue) { if(!tooltip || !angular.isDefined(newValue)) return; tooltip.setViewport(newValue); }); // Initialize popover var tooltip = $tooltip(element, options); // Garbage collection scope.$on('$destroy', function() { if(tooltip) tooltip.destroy(); options = null; tooltip = null; }); } }; });