vendor/assets/javascripts/jquery.powertip.js in enju_leaf-1.1.0.pre3 vs vendor/assets/javascripts/jquery.powertip.js in enju_leaf-1.1.0.rc1

- old
+ new

@@ -1,121 +1,161 @@ -/** - * PowerTip - * - * @fileoverview jQuery plugin that creates hover tooltips. - * @link http://stevenbenner.github.com/jquery-powertip/ - * @author Steven Benner (http://stevenbenner.com/) - * @version 1.1.0 - * @requires jQuery 1.7+ - * - * @license jQuery PowerTip Plugin v1.1.0 - * http://stevenbenner.github.com/jquery-powertip/ - * Copyright 2012 Steven Benner (http://stevenbenner.com/) - * Released under the MIT license. - * <https://raw.github.com/stevenbenner/jquery-powertip/master/LICENSE.txt> - */ +/*! + PowerTip - v1.2.0 - 2013-04-03 + http://stevenbenner.github.com/jquery-powertip/ + Copyright (c) 2013 Steven Benner (http://stevenbenner.com/). + Released under MIT license. + https://raw.github.com/stevenbenner/jquery-powertip/master/LICENSE.txt +*/ +(function(factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['jquery'], factory); + } else { + // Browser globals + factory(jQuery); + } +}(function($) { -(function($) { - 'use strict'; - // useful private variables var $document = $(document), $window = $(window), $body = $('body'); + // constants + var DATA_DISPLAYCONTROLLER = 'displayController', + DATA_HASACTIVEHOVER = 'hasActiveHover', + DATA_FORCEDOPEN = 'forcedOpen', + DATA_HASMOUSEMOVE = 'hasMouseMove', + DATA_MOUSEONTOTIP = 'mouseOnToPopup', + DATA_ORIGINALTITLE = 'originalTitle', + DATA_POWERTIP = 'powertip', + DATA_POWERTIPJQ = 'powertipjq', + DATA_POWERTIPTARGET = 'powertiptarget', + RAD2DEG = 180 / Math.PI; + /** * Session data * Private properties global to all powerTip instances - * @type Object */ var session = { - isPopOpen: false, - isFixedPopOpen: false, + isTipOpen: false, + isFixedTipOpen: false, isClosing: false, - popOpenImminent: false, + tipOpenImminent: false, activeHover: null, currentX: 0, currentY: 0, previousX: 0, previousY: 0, desyncTimeout: null, - mouseTrackingActive: false + mouseTrackingActive: false, + delayInProgress: false, + windowWidth: 0, + windowHeight: 0, + scrollTop: 0, + scrollLeft: 0 }; /** - * Display hover tooltips on the matched elements. - * @param {Object} opts The options object to use for the plugin. - * @return {Object} jQuery object for the matched selectors. + * Collision enumeration + * @enum {number} */ - $.fn.powerTip = function(opts) { + var Collision = { + none: 0, + top: 1, + bottom: 2, + left: 4, + right: 8 + }; + /** + * Display hover tooltips on the matched elements. + * @param {(Object|string)} opts The options object to use for the plugin, or + * the name of a method to invoke on the first matched element. + * @param {*=} [arg] Argument for an invoked method (optional). + * @return {jQuery} jQuery object for the matched selectors. + */ + $.fn.powerTip = function(opts, arg) { // don't do any work if there were no matched elements if (!this.length) { return this; } - // extend options + // handle api method calls on the plugin, e.g. powerTip('hide') + if ($.type(opts) === 'string' && $.powerTip[opts]) { + return $.powerTip[opts].call(this, this, arg); + } + + // extend options and instantiate TooltipController var options = $.extend({}, $.fn.powerTip.defaults, opts), tipController = new TooltipController(options); - // hook mouse tracking - initMouseTracking(); + // hook mouse and viewport dimension tracking + initTracking(); // setup the elements - this.each(function() { + this.each(function elementSetup() { var $this = $(this), - dataPowertip = $this.data('powertip'), - dataElem = $this.data('powertipjq'), - dataTarget = $this.data('powertiptarget'), - title = $this.attr('title'); + dataPowertip = $this.data(DATA_POWERTIP), + dataElem = $this.data(DATA_POWERTIPJQ), + dataTarget = $this.data(DATA_POWERTIPTARGET), + title; + // handle repeated powerTip calls on the same element by destroying the + // original instance hooked to it and replacing it with this call + if ($this.data(DATA_DISPLAYCONTROLLER)) { + $.powerTip.destroy($this); + } // attempt to use title attribute text if there is no data-powertip, // data-powertipjq or data-powertiptarget. If we do use the title // attribute, delete the attribute so the browser will not show it + title = $this.attr('title'); if (!dataPowertip && !dataTarget && !dataElem && title) { - $this.data('powertip', title); + $this.data(DATA_POWERTIP, title); + $this.data(DATA_ORIGINALTITLE, title); $this.removeAttr('title'); } // create hover controllers for each element $this.data( - 'displayController', + DATA_DISPLAYCONTROLLER, new DisplayController($this, options, tipController) ); }); - // attach hover events to all matched elements - return this.on({ - // mouse events - mouseenter: function(event) { - trackMouse(event); - session.previousX = event.pageX; - session.previousY = event.pageY; - $(this).data('displayController').show(); - }, - mouseleave: function() { - $(this).data('displayController').hide(); - }, - - // keyboard events - focus: function() { - var element = $(this); - if (!isMouseOver(element)) { - element.data('displayController').show(true); + // attach events to matched elements if the manual options is not enabled + if (!options.manual) { + this.on({ + // mouse events + 'mouseenter.powertip': function elementMouseEnter(event) { + $.powerTip.show(this, event); + }, + 'mouseleave.powertip': function elementMouseLeave() { + $.powerTip.hide(this); + }, + // keyboard events + 'focus.powertip': function elementFocus() { + $.powerTip.show(this); + }, + 'blur.powertip': function elementBlur() { + $.powerTip.hide(this, true); + }, + 'keydown.powertip': function elementKeyDown(event) { + // close tooltip when the escape key is pressed + if (event.keyCode === 27) { + $.powerTip.hide(this, true); + } } - }, - blur: function() { - $(this).data('displayController').hide(true); - } - }); + }); + } + return this; }; /** * Default options for the powerTip plugin. - * @type Object */ $.fn.powerTip.defaults = { fadeInTime: 200, fadeOutTime: 100, followMouse: false, @@ -124,127 +164,208 @@ intentPollInterval: 100, closeDelay: 100, placement: 'n', smartPlacement: false, offset: 10, - mouseOnToPopup: false + mouseOnToPopup: false, + manual: false }; /** * Default smart placement priority lists. - * The first item in the array is the highest priority, the last is the - * lowest. The last item is also the default, which will be used if all - * previous options do not fit. - * @type Object + * The first item in the array is the highest priority, the last is the lowest. + * The last item is also the default, which will be used if all previous options + * do not fit. */ $.fn.powerTip.smartPlacementLists = { n: ['n', 'ne', 'nw', 's'], e: ['e', 'ne', 'se', 'w', 'nw', 'sw', 'n', 's', 'e'], s: ['s', 'se', 'sw', 'n'], w: ['w', 'nw', 'sw', 'e', 'ne', 'se', 'n', 's', 'w'], nw: ['nw', 'w', 'sw', 'n', 's', 'se', 'nw'], ne: ['ne', 'e', 'se', 'n', 's', 'sw', 'ne'], sw: ['sw', 'w', 'nw', 's', 'n', 'ne', 'sw'], - se: ['se', 'e', 'ne', 's', 'n', 'nw', 'se'] + se: ['se', 'e', 'ne', 's', 'n', 'nw', 'se'], + 'nw-alt': ['nw-alt', 'n', 'ne-alt', 'sw-alt', 's', 'se-alt', 'w', 'e'], + 'ne-alt': ['ne-alt', 'n', 'nw-alt', 'se-alt', 's', 'sw-alt', 'e', 'w'], + 'sw-alt': ['sw-alt', 's', 'se-alt', 'nw-alt', 'n', 'ne-alt', 'w', 'e'], + 'se-alt': ['se-alt', 's', 'sw-alt', 'ne-alt', 'n', 'nw-alt', 'e', 'w'] }; /** * Public API - * @type Object */ $.powerTip = { - /** * Attempts to show the tooltip for the specified element. - * @public - * @param {Object} element The element that the tooltip should for. + * @param {jQuery|Element} element The element to open the tooltip for. + * @param {jQuery.Event=} event jQuery event for hover intent and mouse + * tracking (optional). */ - showTip: function(element) { - // close any open tooltip - $.powerTip.closeTip(); - // grab only the first matched element and ask it to show its tip - element = element.first(); - if (!isMouseOver(element)) { - element.data('displayController').show(true, true); + show: function apiShowTip(element, event) { + if (event) { + trackMouse(event); + session.previousX = event.pageX; + session.previousY = event.pageY; + $(element).data(DATA_DISPLAYCONTROLLER).show(); + } else { + $(element).first().data(DATA_DISPLAYCONTROLLER).show(true, true); } + return element; }, /** + * Repositions the tooltip on the element. + * @param {jQuery|Element} element The element the tooltip is shown for. + */ + reposition: function apiResetPosition(element) { + $(element).first().data(DATA_DISPLAYCONTROLLER).resetPosition(); + return element; + }, + + /** * Attempts to close any open tooltips. - * @public + * @param {(jQuery|Element)=} element The element with the tooltip that + * should be closed (optional). + * @param {boolean=} immediate Disable close delay (optional). */ - closeTip: function() { - $document.triggerHandler('closePowerTip'); - } + hide: function apiCloseTip(element, immediate) { + if (element) { + $(element).first().data(DATA_DISPLAYCONTROLLER).hide(immediate); + } else { + if (session.activeHover) { + session.activeHover.data(DATA_DISPLAYCONTROLLER).hide(true); + } + } + return element; + }, + /** + * Destroy and roll back any powerTip() instance on the specified element. + * @param {jQuery|Element} element The element with the powerTip instance. + */ + destroy: function apiDestroy(element) { + $(element).off('.powertip').each(function destroy() { + var $this = $(this), + dataAttributes = [ + DATA_ORIGINALTITLE, + DATA_DISPLAYCONTROLLER, + DATA_HASACTIVEHOVER, + DATA_FORCEDOPEN + ]; + + if ($this.data(DATA_ORIGINALTITLE)) { + $this.attr('title', $this.data(DATA_ORIGINALTITLE)); + dataAttributes.push(DATA_POWERTIP); + } + + $this.removeData(dataAttributes); + }); + return element; + } }; + // API aliasing + $.powerTip.showTip = $.powerTip.show; + $.powerTip.closeTip = $.powerTip.hide; + /** + * Creates a new CSSCoordinates object. + * @private + * @constructor + */ + function CSSCoordinates() { + var me = this; + + // initialize object properties + me.top = 'auto'; + me.left = 'auto'; + me.right = 'auto'; + me.bottom = 'auto'; + + /** + * Set a property to a value. + * @private + * @param {string} property The name of the property. + * @param {number} value The value of the property. + */ + me.set = function(property, value) { + if ($.isNumeric(value)) { + me[property] = Math.round(value); + } + }; + } + + /** * Creates a new tooltip display controller. * @private * @constructor - * @param {Object} element The element that this controller will handle. + * @param {jQuery} element The element that this controller will handle. * @param {Object} options Options object containing settings. - * @param {TooltipController} tipController The TooltipController for this instance. + * @param {TooltipController} tipController The TooltipController object for + * this instance. */ function DisplayController(element, options, tipController) { var hoverTimer = null; /** * Begins the process of showing a tooltip. * @private - * @param {Boolean=} immediate Skip intent testing (optional). - * @param {Boolean=} forceOpen Ignore cursor position and force tooltip to open (optional). + * @param {boolean=} immediate Skip intent testing (optional). + * @param {boolean=} forceOpen Ignore cursor position and force tooltip to + * open (optional). */ function openTooltip(immediate, forceOpen) { cancelTimer(); - if (!element.data('hasActiveHover')) { + if (!element.data(DATA_HASACTIVEHOVER)) { if (!immediate) { - session.popOpenImminent = true; + session.tipOpenImminent = true; hoverTimer = setTimeout( - function() { + function intentDelay() { hoverTimer = null; - checkForIntent(element); + checkForIntent(); }, options.intentPollInterval ); } else { if (forceOpen) { - element.data('forcedOpen', true); + element.data(DATA_FORCEDOPEN, true); } tipController.showTip(element); } } } /** * Begins the process of closing a tooltip. * @private - * @param {Boolean=} disableDelay Disable close delay (optional). + * @param {boolean=} disableDelay Disable close delay (optional). */ function closeTooltip(disableDelay) { cancelTimer(); - if (element.data('hasActiveHover')) { - session.popOpenImminent = false; - element.data('forcedOpen', false); + session.tipOpenImminent = false; + if (element.data(DATA_HASACTIVEHOVER)) { + element.data(DATA_FORCEDOPEN, false); if (!disableDelay) { + session.delayInProgress = true; hoverTimer = setTimeout( - function() { + function closeDelay() { hoverTimer = null; tipController.hideTip(element); + session.delayInProgress = false; }, options.closeDelay ); } else { tipController.hideTip(element); } } } /** - * Checks mouse position to make sure that the user intended to hover - * on the specified element before showing the tooltip. + * Checks mouse position to make sure that the user intended to hover on the + * specified element before showing the tooltip. * @private */ function checkForIntent() { // calculate mouse position difference var xDifference = Math.abs(session.previousX - session.currentX), @@ -266,166 +387,376 @@ * Cancels active hover timer. * @private */ function cancelTimer() { hoverTimer = clearTimeout(hoverTimer); + session.delayInProgress = false; } + /** + * Repositions the tooltip on this element. + * @private + */ + function repositionTooltip() { + tipController.resetPosition(element); + } + // expose the methods - return { - show: openTooltip, - hide: closeTooltip, - cancel: cancelTimer - }; + this.show = openTooltip; + this.hide = closeTooltip; + this.cancel = cancelTimer; + this.resetPosition = repositionTooltip; } /** + * Creates a new Placement Calculator. + * @private + * @constructor + */ + function PlacementCalculator() { + /** + * Compute the CSS position to display a tooltip at the specified placement + * relative to the specified element. + * @private + * @param {jQuery} element The element that the tooltip should target. + * @param {string} placement The placement for the tooltip. + * @param {number} tipWidth Width of the tooltip element in pixels. + * @param {number} tipHeight Height of the tooltip element in pixels. + * @param {number} offset Distance to offset tooltips in pixels. + * @return {CSSCoordinates} A CSSCoordinates object with the position. + */ + function computePlacementCoords(element, placement, tipWidth, tipHeight, offset) { + var placementBase = placement.split('-')[0], // ignore 'alt' for corners + coords = new CSSCoordinates(), + position; + + if (isSvgElement(element)) { + position = getSvgPlacement(element, placementBase); + } else { + position = getHtmlPlacement(element, placementBase); + } + + // calculate the appropriate x and y position in the document + switch (placement) { + case 'n': + coords.set('left', position.left - (tipWidth / 2)); + coords.set('bottom', session.windowHeight - position.top + offset); + break; + case 'e': + coords.set('left', position.left + offset); + coords.set('top', position.top - (tipHeight / 2)); + break; + case 's': + coords.set('left', position.left - (tipWidth / 2)); + coords.set('top', position.top + offset); + break; + case 'w': + coords.set('top', position.top - (tipHeight / 2)); + coords.set('right', session.windowWidth - position.left + offset); + break; + case 'nw': + coords.set('bottom', session.windowHeight - position.top + offset); + coords.set('right', session.windowWidth - position.left - 20); + break; + case 'nw-alt': + coords.set('left', position.left); + coords.set('bottom', session.windowHeight - position.top + offset); + break; + case 'ne': + coords.set('left', position.left - 20); + coords.set('bottom', session.windowHeight - position.top + offset); + break; + case 'ne-alt': + coords.set('bottom', session.windowHeight - position.top + offset); + coords.set('right', session.windowWidth - position.left); + break; + case 'sw': + coords.set('top', position.top + offset); + coords.set('right', session.windowWidth - position.left - 20); + break; + case 'sw-alt': + coords.set('left', position.left); + coords.set('top', position.top + offset); + break; + case 'se': + coords.set('left', position.left - 20); + coords.set('top', position.top + offset); + break; + case 'se-alt': + coords.set('top', position.top + offset); + coords.set('right', session.windowWidth - position.left); + break; + } + + return coords; + } + + /** + * Finds the tooltip attachment point in the document for a HTML DOM element + * for the specified placement. + * @private + * @param {jQuery} element The element that the tooltip should target. + * @param {string} placement The placement for the tooltip. + * @return {Object} An object with the top,left position values. + */ + function getHtmlPlacement(element, placement) { + var objectOffset = element.offset(), + objectWidth = element.outerWidth(), + objectHeight = element.outerHeight(), + left, + top; + + // calculate the appropriate x and y position in the document + switch (placement) { + case 'n': + left = objectOffset.left + objectWidth / 2; + top = objectOffset.top; + break; + case 'e': + left = objectOffset.left + objectWidth; + top = objectOffset.top + objectHeight / 2; + break; + case 's': + left = objectOffset.left + objectWidth / 2; + top = objectOffset.top + objectHeight; + break; + case 'w': + left = objectOffset.left; + top = objectOffset.top + objectHeight / 2; + break; + case 'nw': + left = objectOffset.left; + top = objectOffset.top; + break; + case 'ne': + left = objectOffset.left + objectWidth; + top = objectOffset.top; + break; + case 'sw': + left = objectOffset.left; + top = objectOffset.top + objectHeight; + break; + case 'se': + left = objectOffset.left + objectWidth; + top = objectOffset.top + objectHeight; + break; + } + + return { + top: top, + left: left + }; + } + + /** + * Finds the tooltip attachment point in the document for a SVG element for + * the specified placement. + * @private + * @param {jQuery} element The element that the tooltip should target. + * @param {string} placement The placement for the tooltip. + * @return {Object} An object with the top,left position values. + */ + function getSvgPlacement(element, placement) { + var svgElement = element.closest('svg')[0], + domElement = element[0], + point = svgElement.createSVGPoint(), + boundingBox = domElement.getBBox(), + matrix = domElement.getScreenCTM(), + halfWidth = boundingBox.width / 2, + halfHeight = boundingBox.height / 2, + placements = [], + placementKeys = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'], + coords, + rotation, + steps, + x; + + function pushPlacement() { + placements.push(point.matrixTransform(matrix)); + } + + // get bounding box corners and midpoints + point.x = boundingBox.x; + point.y = boundingBox.y; + pushPlacement(); + point.x += halfWidth; + pushPlacement(); + point.x += halfWidth; + pushPlacement(); + point.y += halfHeight; + pushPlacement(); + point.y += halfHeight; + pushPlacement(); + point.x -= halfWidth; + pushPlacement(); + point.x -= halfWidth; + pushPlacement(); + point.y -= halfHeight; + pushPlacement(); + + // determine rotation + if (placements[0].y !== placements[1].y || placements[0].x !== placements[7].x) { + rotation = Math.atan2(matrix.b, matrix.a) * RAD2DEG; + steps = Math.ceil(((rotation % 360) - 22.5) / 45); + if (steps < 1) { + steps += 8; + } + while (steps--) { + placementKeys.push(placementKeys.shift()); + } + } + + // find placement + for (x = 0; x < placements.length; x++) { + if (placementKeys[x] === placement) { + coords = placements[x]; + break; + } + } + + return { + top: coords.y + session.scrollTop, + left: coords.x + session.scrollLeft + }; + } + + // expose methods + this.compute = computePlacementCoords; + } + + /** * Creates a new tooltip controller. * @private * @constructor * @param {Object} options Options object containing settings. */ function TooltipController(options) { + var placementCalculator = new PlacementCalculator(), + tipElement = $('#' + options.popupId); - // build and append popup div if it does not already exist - var tipElement = $('#' + options.popupId); + // build and append tooltip div if it does not already exist if (tipElement.length === 0) { - tipElement = $('<div></div>', { id: options.popupId }); + tipElement = $('<div/>', { id: options.popupId }); // grab body element if it was not populated when the script loaded - // this hack exists solely for jsfiddle support + // note: this hack exists solely for jsfiddle support if ($body.length === 0) { $body = $('body'); } $body.append(tipElement); } // hook mousemove for cursor follow tooltips if (options.followMouse) { - // only one positionTipOnCursor hook per popup element, please - if (!tipElement.data('hasMouseMove')) { - $document.on({ - mousemove: positionTipOnCursor, - scroll: positionTipOnCursor - }); + // only one positionTipOnCursor hook per tooltip element, please + if (!tipElement.data(DATA_HASMOUSEMOVE)) { + $document.on('mousemove', positionTipOnCursor); + $window.on('scroll', positionTipOnCursor); + tipElement.data(DATA_HASMOUSEMOVE, true); } - tipElement.data('hasMouseMove', true); } - // if we want to be able to mouse onto the popup then we need to attach - // hover events to the popup that will cancel a close request on hover - // and start a new close request on mouseleave - if (options.followMouse || options.mouseOnToPopup) { + // if we want to be able to mouse onto the tooltip then we need to attach + // hover events to the tooltip that will cancel a close request on hover and + // start a new close request on mouseleave + if (options.mouseOnToPopup) { tipElement.on({ - mouseenter: function() { - if (tipElement.data('followMouse') || tipElement.data('mouseOnToPopup')) { - // check activeHover in case the mouse cursor entered - // the tooltip during the fadeOut and close cycle + mouseenter: function tipMouseEnter() { + // we only let the mouse stay on the tooltip if it is set to let + // users interact with it + if (tipElement.data(DATA_MOUSEONTOTIP)) { + // check activeHover in case the mouse cursor entered the + // tooltip during the fadeOut and close cycle if (session.activeHover) { - session.activeHover.data('displayController').cancel(); + session.activeHover.data(DATA_DISPLAYCONTROLLER).cancel(); } } }, - mouseleave: function() { - if (tipElement.data('mouseOnToPopup')) { - // check activeHover in case the mouse cursor entered - // the tooltip during the fadeOut and close cycle - if (session.activeHover) { - session.activeHover.data('displayController').hide(); - } + mouseleave: function tipMouseLeave() { + // check activeHover in case the mouse cursor entered the + // tooltip during the fadeOut and close cycle + if (session.activeHover) { + session.activeHover.data(DATA_DISPLAYCONTROLLER).hide(); } } }); } /** - * Gives the specified element the active-hover state and queues up - * the showTip function. + * Gives the specified element the active-hover state and queues up the + * showTip function. * @private - * @param {Object} element The element that the tooltip should target. + * @param {jQuery} element The element that the tooltip should target. */ function beginShowTip(element) { - element.data('hasActiveHover', true); - // show popup, asap - tipElement.queue(function(next) { + element.data(DATA_HASACTIVEHOVER, true); + // show tooltip, asap + tipElement.queue(function queueTipInit(next) { showTip(element); next(); }); } /** - * Shows the tooltip popup, as soon as possible. + * Shows the tooltip, as soon as possible. * @private - * @param {Object} element The element that the popup should target. + * @param {jQuery} element The element that the tooltip should target. */ function showTip(element) { - // it is possible, especially with keyboard navigation, to move on - // to another element with a tooltip during the queue to get to - // this point in the code. if that happens then we need to not - // proceed or we may have the fadeout callback for the last tooltip - // execute immediately after this code runs, causing bugs. - if (!element.data('hasActiveHover')) { + var tipContent; + + // it is possible, especially with keyboard navigation, to move on to + // another element with a tooltip during the queue to get to this point + // in the code. if that happens then we need to not proceed or we may + // have the fadeout callback for the last tooltip execute immediately + // after this code runs, causing bugs. + if (!element.data(DATA_HASACTIVEHOVER)) { return; } - // if the popup is open and we got asked to open another one then - // the old one is still in its fadeOut cycle, so wait and try again - if (session.isPopOpen) { + // if the tooltip is open and we got asked to open another one then the + // old one is still in its fadeOut cycle, so wait and try again + if (session.isTipOpen) { if (!session.isClosing) { hideTip(session.activeHover); } - tipElement.delay(100).queue(function(next) { + tipElement.delay(100).queue(function queueTipAgain(next) { showTip(element); next(); }); return; } // trigger powerTipPreRender event element.trigger('powerTipPreRender'); - var tipText = element.data('powertip'), - tipTarget = element.data('powertiptarget'), - tipElem = element.data('powertipjq'), - tipContent = tipTarget ? $('#' + tipTarget) : []; - - // set popup content - if (tipText) { - tipElement.html(tipText); - } else if (tipElem && tipElem.length > 0) { - tipElement.empty(); - tipElem.clone(true, true).appendTo(tipElement); - } else if (tipContent && tipContent.length > 0) { - tipElement.html($('#' + tipTarget).html()); + // set tooltip content + tipContent = getTooltipContent(element); + if (tipContent) { + tipElement.empty().append(tipContent); } else { // we have no content to display, give up return; } // trigger powerTipRender event element.trigger('powerTipRender'); - // hook close event for triggering from the api - $document.on('closePowerTip', function() { - element.data('displayController').hide(true); - }); - session.activeHover = element; - session.isPopOpen = true; + session.isTipOpen = true; - tipElement.data('followMouse', options.followMouse); - tipElement.data('mouseOnToPopup', options.mouseOnToPopup); + tipElement.data(DATA_MOUSEONTOTIP, options.mouseOnToPopup); - // set popup position + // set tooltip position if (!options.followMouse) { positionTipOnElement(element); - session.isFixedPopOpen = true; + session.isFixedTipOpen = true; } else { positionTipOnCursor(); } // fadein - tipElement.fadeIn(options.fadeInTime, function() { + tipElement.fadeIn(options.fadeInTime, function fadeInCallback() { // start desync polling if (!session.desyncTimeout) { session.desyncTimeout = setInterval(closeDesyncedTip, 500); } @@ -433,364 +764,403 @@ element.trigger('powerTipOpen'); }); } /** - * Hides the tooltip popup, immediately. + * Hides the tooltip. * @private - * @param {Object} element The element that the popup should target. + * @param {jQuery} element The element that the tooltip should target. */ function hideTip(element) { - session.isClosing = true; - element.data('hasActiveHover', false); - element.data('forcedOpen', false); // reset session + session.isClosing = true; session.activeHover = null; - session.isPopOpen = false; + session.isTipOpen = false; + // stop desync polling session.desyncTimeout = clearInterval(session.desyncTimeout); - // unhook close event api listener - $document.off('closePowerTip'); + + // reset element state + element.data(DATA_HASACTIVEHOVER, false); + element.data(DATA_FORCEDOPEN, false); + // fade out - tipElement.fadeOut(options.fadeOutTime, function() { + tipElement.fadeOut(options.fadeOutTime, function fadeOutCallback() { + var coords = new CSSCoordinates(); + + // reset session and tooltip element session.isClosing = false; - session.isFixedPopOpen = false; + session.isFixedTipOpen = false; tipElement.removeClass(); - // support mouse-follow and fixed position pops at the same - // time by moving the popup to the last known cursor location - // after it is hidden - setTipPosition( - session.currentX + options.offset, - session.currentY + options.offset - ); + // support mouse-follow and fixed position tips at the same time by + // moving the tooltip to the last cursor location after it is hidden + coords.set('top', session.currentY + options.offset); + coords.set('left', session.currentX + options.offset); + tipElement.css(coords); + // trigger powerTipClose event element.trigger('powerTipClose'); }); } /** - * Checks for a tooltip desync and closes the tooltip if one occurs. + * Moves the tooltip to the users mouse cursor. * @private */ - function closeDesyncedTip() { - // It is possible for the mouse cursor to leave an element without - // firing the mouseleave event. This seems to happen (in FF) if the - // element is disabled under mouse cursor, the element is moved out - // from under the mouse cursor (such as a slideDown() occurring - // above it), or if the browser is resized by code moving the - // element from under the mouse cursor. If this happens it will - // result in a desynced tooltip because we wait for any exiting - // open tooltips to close before opening a new one. So we should - // periodically check for a desync situation and close the tip if - // such a situation arises. - if (session.isPopOpen && !session.isClosing) { - var isDesynced = false; + function positionTipOnCursor() { + // to support having fixed tooltips on the same page as cursor tooltips, + // where both instances are referencing the same tooltip element, we + // need to keep track of the mouse position constantly, but we should + // only set the tip location if a fixed tip is not currently open, a tip + // open is imminent or active, and the tooltip element in question does + // have a mouse-follow using it. + if (!session.isFixedTipOpen && (session.isTipOpen || (session.tipOpenImminent && tipElement.data(DATA_HASMOUSEMOVE)))) { + // grab measurements + var tipWidth = tipElement.outerWidth(), + tipHeight = tipElement.outerHeight(), + coords = new CSSCoordinates(), + collisions, + collisionCount; - // case 1: user already moused onto another tip - easy test - if (session.activeHover.data('hasActiveHover') === false) { - isDesynced = true; - } else { - // case 2: hanging tip - have to test if mouse position is - // not over the active hover and not over a tooltip set to - // let the user interact with it. - // for keyboard navigation, this only counts if the element - // does not have focus. - // for tooltips opened via the api we need to check if it - // has the forcedOpen flag. - if (!isMouseOver(session.activeHover) && !session.activeHover.is(":focus") && !session.activeHover.data('forcedOpen')) { - if (tipElement.data('mouseOnToPopup')) { - if (!isMouseOver(tipElement)) { - isDesynced = true; - } - } else { - isDesynced = true; + // grab collisions + coords.set('top', session.currentY + options.offset); + coords.set('left', session.currentX + options.offset); + collisions = getViewportCollisions( + coords, + tipWidth, + tipHeight + ); + + // handle tooltip view port collisions + if (collisions !== Collision.none) { + collisionCount = countFlags(collisions); + if (collisionCount === 1) { + // if there is only one collision (bottom or right) then + // simply constrain the tooltip to the view port + if (collisions === Collision.right) { + coords.set('left', session.windowWidth - tipWidth); + } else if (collisions === Collision.bottom) { + coords.set('top', session.scrollTop + session.windowHeight - tipHeight); } + } else { + // if the tooltip has more than one collision then it is + // trapped in the corner and should be flipped to get it out + // of the users way + coords.set('left', session.currentX - tipWidth - options.offset); + coords.set('top', session.currentY - tipHeight - options.offset); } } - if (isDesynced) { - // close the desynced tip - hideTip(session.activeHover); - } - } - } - - /** - * Moves the tooltip popup to the users mouse cursor. - * @private - */ - function positionTipOnCursor() { - // to support having fixed powertips on the same page as cursor - // powertips, where both instances are referencing the same popup - // element, we need to keep track of the mouse position constantly, - // but we should only set the pop location if a fixed pop is not - // currently open, a pop open is imminent or active, and the popup - // element in question does have a mouse-follow using it. - if ((session.isPopOpen && !session.isFixedPopOpen) || (session.popOpenImminent && !session.isFixedPopOpen && tipElement.data('hasMouseMove'))) { - // grab measurements - var scrollTop = $window.scrollTop(), - windowWidth = $window.width(), - windowHeight = $window.height(), - popWidth = tipElement.outerWidth(), - popHeight = tipElement.outerHeight(), - x = 0, - y = 0; - - // constrain pop to browser viewport - if ((popWidth + session.currentX + options.offset) < windowWidth) { - x = session.currentX + options.offset; - } else { - x = windowWidth - popWidth; - } - if ((popHeight + session.currentY + options.offset) < (scrollTop + windowHeight)) { - y = session.currentY + options.offset; - } else { - y = scrollTop + windowHeight - popHeight; - } - // position the tooltip - setTipPosition(x, y); + tipElement.css(coords); } } /** - * Sets the tooltip popup too the correct position relative to the - * specified target element. Based on options settings. + * Sets the tooltip to the correct position relative to the specified target + * element. Based on options settings. * @private - * @param {Object} element The element that the popup should target. + * @param {jQuery} element The element that the tooltip should target. */ function positionTipOnElement(element) { - var tipWidth = tipElement.outerWidth(), - tipHeight = tipElement.outerHeight(), - priorityList, - placementCoords, - finalPlacement, - collisions; + var priorityList, + finalPlacement; - // with smart placement we will try a series of placement - // options and use the first one that does not collide with the - // browser view port boundaries. if (options.smartPlacement) { - - // grab the placement priority list priorityList = $.fn.powerTip.smartPlacementLists[options.placement]; - // iterate over the priority list and use the first placement - // option that does not collide with the viewport. if they all - // collide then the last placement in the list will be used. + // iterate over the priority list and use the first placement option + // that does not collide with the view port. if they all collide + // then the last placement in the list will be used. $.each(priorityList, function(idx, pos) { - // get placement coordinates - placementCoords = computePlacementCoords( - element, - pos, - tipWidth, - tipHeight + // place tooltip and find collisions + var collisions = getViewportCollisions( + placeTooltip(element, pos), + tipElement.outerWidth(), + tipElement.outerHeight() ); + + // update the final placement variable finalPlacement = pos; - // find collisions - collisions = getViewportCollisions( - placementCoords, - tipWidth, - tipHeight - ); - // break if there were no collisions - if (collisions.length === 0) { + if (collisions === Collision.none) { return false; } }); - } else { - - // if we're not going to use the smart placement feature then - // just compute the coordinates and do it - placementCoords = computePlacementCoords( - element, - options.placement, - tipWidth, - tipHeight - ); + // if we're not going to use the smart placement feature then just + // compute the coordinates and do it + placeTooltip(element, options.placement); finalPlacement = options.placement; - } // add placement as class for CSS arrows tipElement.addClass(finalPlacement); - - // position the tooltip - setTipPosition(placementCoords.x, placementCoords.y); } /** - * Compute the top/left coordinates to display the tooltip at the - * specified placement relative to the specified element. + * Sets the tooltip position to the appropriate values to show the tip at + * the specified placement. This function will iterate and test the tooltip + * to support elastic tooltips. * @private - * @param {Object} element The element that the tooltip should target. - * @param {String} placement The placement for the tooltip. - * @param {Number} popWidth Width of the tooltip element in pixels. - * @param {Number} popHeight Height of the tooltip element in pixels. - * @retun {Object} An object with the x and y coordinates. + * @param {jQuery} element The element that the tooltip should target. + * @param {string} placement The placement for the tooltip. + * @return {CSSCoordinates} A CSSCoordinates object with the top, left, and + * right position values. */ - function computePlacementCoords(element, placement, popWidth, popHeight) { - // grab measurements - var objectOffset = element.offset(), - objectWidth = element.outerWidth(), - objectHeight = element.outerHeight(), - x = 0, - y = 0; + function placeTooltip(element, placement) { + var iterationCount = 0, + tipWidth, + tipHeight, + coords = new CSSCoordinates(); - // calculate the appropriate x and y position in the document - switch (placement) { - case 'n': - x = (objectOffset.left + (objectWidth / 2)) - (popWidth / 2); - y = objectOffset.top - popHeight - options.offset; - break; - case 'e': - x = objectOffset.left + objectWidth + options.offset; - y = (objectOffset.top + (objectHeight / 2)) - (popHeight / 2); - break; - case 's': - x = (objectOffset.left + (objectWidth / 2)) - (popWidth / 2); - y = objectOffset.top + objectHeight + options.offset; - break; - case 'w': - x = objectOffset.left - popWidth - options.offset; - y = (objectOffset.top + (objectHeight / 2)) - (popHeight / 2); - break; - case 'nw': - x = (objectOffset.left - popWidth) + 20; - y = objectOffset.top - popHeight - options.offset; - break; - case 'ne': - x = (objectOffset.left + objectWidth) - 20; - y = objectOffset.top - popHeight - options.offset; - break; - case 'sw': - x = (objectOffset.left - popWidth) + 20; - y = objectOffset.top + objectHeight + options.offset; - break; - case 'se': - x = (objectOffset.left + objectWidth) - 20; - y = objectOffset.top + objectHeight + options.offset; - break; - } + // set the tip to 0,0 to get the full expanded width + coords.set('top', 0); + coords.set('left', 0); + tipElement.css(coords); - return { - x: Math.round(x), - y: Math.round(y) - }; + // to support elastic tooltips we need to check for a change in the + // rendered dimensions after the tooltip has been positioned + do { + // grab the current tip dimensions + tipWidth = tipElement.outerWidth(); + tipHeight = tipElement.outerHeight(); + + // get placement coordinates + coords = placementCalculator.compute( + element, + placement, + tipWidth, + tipHeight, + options.offset + ); + + // place the tooltip + tipElement.css(coords); + } while ( + // sanity check: limit to 5 iterations, and... + ++iterationCount <= 5 && + // try again if the dimensions changed after placement + (tipWidth !== tipElement.outerWidth() || tipHeight !== tipElement.outerHeight()) + ); + + return coords; } /** - * Sets the tooltip CSS position on the document. + * Checks for a tooltip desync and closes the tooltip if one occurs. * @private - * @param {Number} x Left position in pixels. - * @param {Number} y Top position in pixels. */ - function setTipPosition(x, y) { - tipElement.css('left', x + 'px'); - tipElement.css('top', y + 'px'); + function closeDesyncedTip() { + var isDesynced = false; + // It is possible for the mouse cursor to leave an element without + // firing the mouseleave or blur event. This most commonly happens when + // the element is disabled under mouse cursor. If this happens it will + // result in a desynced tooltip because the tooltip was never asked to + // close. So we should periodically check for a desync situation and + // close the tip if such a situation arises. + if (session.isTipOpen && !session.isClosing && !session.delayInProgress) { + // user moused onto another tip or active hover is disabled + if (session.activeHover.data(DATA_HASACTIVEHOVER) === false || session.activeHover.is(':disabled')) { + isDesynced = true; + } else { + // hanging tip - have to test if mouse position is not over the + // active hover and not over a tooltip set to let the user + // interact with it. + // for keyboard navigation: this only counts if the element does + // not have focus. + // for tooltips opened via the api: we need to check if it has + // the forcedOpen flag. + if (!isMouseOver(session.activeHover) && !session.activeHover.is(':focus') && !session.activeHover.data(DATA_FORCEDOPEN)) { + if (tipElement.data(DATA_MOUSEONTOTIP)) { + if (!isMouseOver(tipElement)) { + isDesynced = true; + } + } else { + isDesynced = true; + } + } + } + + if (isDesynced) { + // close the desynced tip + hideTip(session.activeHover); + } + } } // expose methods - return { - showTip: beginShowTip, - hideTip: hideTip - }; + this.showTip = beginShowTip; + this.hideTip = hideTip; + this.resetPosition = positionTipOnElement; } /** - * Hooks mouse position tracking to mousemove and scroll events. - * Prevents attaching the events more than once. + * Determine whether a jQuery object is an SVG element * @private + * @param {jQuery} element The element to check + * @return {boolean} Whether this is an SVG element */ - function initMouseTracking() { - var lastScrollX = 0, - lastScrollY = 0; + function isSvgElement(element) { + return window.SVGElement && element[0] instanceof SVGElement; + } + /** + * Initializes the viewport dimension cache and hooks up the mouse position + * tracking and viewport dimension tracking events. + * Prevents attaching the events more than once. + * @private + */ + function initTracking() { if (!session.mouseTrackingActive) { session.mouseTrackingActive = true; - // grab the current scroll position on load - $(function() { - lastScrollX = $document.scrollLeft(); - lastScrollY = $document.scrollTop(); + // grab the current viewport dimensions on load + $(function getViewportDimensions() { + session.scrollLeft = $window.scrollLeft(); + session.scrollTop = $window.scrollTop(); + session.windowWidth = $window.width(); + session.windowHeight = $window.height(); }); - // hook mouse position tracking - $document.on({ - mousemove: trackMouse, - scroll: function() { - var x = $document.scrollLeft(), - y = $document.scrollTop(); - if (x !== lastScrollX) { - session.currentX += x - lastScrollX; - lastScrollX = x; + // hook mouse move tracking + $document.on('mousemove', trackMouse); + + // hook viewport dimensions tracking + $window.on({ + resize: function trackResize() { + session.windowWidth = $window.width(); + session.windowHeight = $window.height(); + }, + scroll: function trackScroll() { + var x = $window.scrollLeft(), + y = $window.scrollTop(); + if (x !== session.scrollLeft) { + session.currentX += x - session.scrollLeft; + session.scrollLeft = x; } - if (y !== lastScrollY) { - session.currentY += y - lastScrollY; - lastScrollY = y; + if (y !== session.scrollTop) { + session.currentY += y - session.scrollTop; + session.scrollTop = y; } } }); } } /** - * Saves the current mouse coordinates to the powerTip session object. + * Saves the current mouse coordinates to the session object. * @private - * @param {Object} event The mousemove event for the document. + * @param {jQuery.Event} event The mousemove event for the document. */ function trackMouse(event) { session.currentX = event.pageX; session.currentY = event.pageY; } /** * Tests if the mouse is currently over the specified element. * @private - * @param {Object} element The element to check for hover. - * @return {Boolean} + * @param {jQuery} element The element to check for hover. + * @return {boolean} */ function isMouseOver(element) { - var elementPosition = element.offset(); + // use getBoundingClientRect() because jQuery's width() and height() + // methods do not work with SVG elements + // compute width/height because those properties do not exist on the object + // returned by getBoundingClientRect() in older versions of IE + var elementPosition = element.offset(), + elementBox = element[0].getBoundingClientRect(), + elementWidth = elementBox.right - elementBox.left, + elementHeight = elementBox.bottom - elementBox.top; + return session.currentX >= elementPosition.left && - session.currentX <= elementPosition.left + element.outerWidth() && + session.currentX <= elementPosition.left + elementWidth && session.currentY >= elementPosition.top && - session.currentY <= elementPosition.top + element.outerHeight(); + session.currentY <= elementPosition.top + elementHeight; } /** - * Finds any viewport collisions that an element (the tooltip) would have - * if it were absolutely positioned at the specified coordinates. + * Fetches the tooltip content from the specified element's data attributes. * @private - * @param {Object} coords Coordinates for the element. (e.g. {x: 123, y: 123}) - * @param {Number} elementWidth Width of the element in pixels. - * @param {Number} elementHeight Height of the element in pixels. - * @return {Array} Array of words representing directional collisions. + * @param {jQuery} element The element to get the tooltip content for. + * @return {(string|jQuery|undefined)} The text/HTML string, jQuery object, or + * undefined if there was no tooltip content for the element. */ + function getTooltipContent(element) { + var tipText = element.data(DATA_POWERTIP), + tipObject = element.data(DATA_POWERTIPJQ), + tipTarget = element.data(DATA_POWERTIPTARGET), + targetElement, + content; + + if (tipText) { + if ($.isFunction(tipText)) { + tipText = tipText.call(element[0]); + } + content = tipText; + } else if (tipObject) { + if ($.isFunction(tipObject)) { + tipObject = tipObject.call(element[0]); + } + if (tipObject.length > 0) { + content = tipObject.clone(true, true); + } + } else if (tipTarget) { + targetElement = $('#' + tipTarget); + if (targetElement.length > 0) { + content = targetElement.html(); + } + } + + return content; + } + + /** + * Finds any viewport collisions that an element (the tooltip) would have if it + * were absolutely positioned at the specified coordinates. + * @private + * @param {CSSCoordinates} coords Coordinates for the element. + * @param {number} elementWidth Width of the element in pixels. + * @param {number} elementHeight Height of the element in pixels. + * @return {number} Value with the collision flags. + */ function getViewportCollisions(coords, elementWidth, elementHeight) { - var scrollLeft = $window.scrollLeft(), - scrollTop = $window.scrollTop(), - windowWidth = $window.width(), - windowHeight = $window.height(), - collisions = []; + var viewportTop = session.scrollTop, + viewportLeft = session.scrollLeft, + viewportBottom = viewportTop + session.windowHeight, + viewportRight = viewportLeft + session.windowWidth, + collisions = Collision.none; - if (coords.y < scrollTop) { - collisions.push('top'); + if (coords.top < viewportTop || Math.abs(coords.bottom - session.windowHeight) - elementHeight < viewportTop) { + collisions |= Collision.top; } - if (coords.y + elementHeight > scrollTop + windowHeight) { - collisions.push('bottom'); + if (coords.top + elementHeight > viewportBottom || Math.abs(coords.bottom - session.windowHeight) > viewportBottom) { + collisions |= Collision.bottom; } - if (coords.x < scrollLeft) { - collisions.push('left'); + if (coords.left < viewportLeft || coords.right + elementWidth > viewportRight) { + collisions |= Collision.left; } - if (coords.x + elementWidth > scrollLeft + windowWidth) { - collisions.push('right'); + if (coords.left + elementWidth > viewportRight || coords.right < viewportLeft) { + collisions |= Collision.right; } return collisions; } -}(jQuery)); + /** + * Counts the number of bits set on a flags value. + * @param {number} value The flags value. + * @return {number} The number of bits that have been set. + */ + function countFlags(value) { + var count = 0; + while (value) { + value &= value - 1; + count++; + } + return count; + } + +}));