diff --git a/src/popover/docs/demo.html b/src/popover/docs/demo.html index bb9042989c..948f0c9a3f 100644 --- a/src/popover/docs/demo.html +++ b/src/popover/docs/demo.html @@ -25,11 +25,12 @@

Dynamic


Positional

- - - - - +
+ + +
+ +

Triggers

diff --git a/src/popover/docs/demo.js b/src/popover/docs/demo.js index 0d94f28941..93f3a248ed 100644 --- a/src/popover/docs/demo.js +++ b/src/popover/docs/demo.js @@ -4,4 +4,22 @@ angular.module('ui.bootstrap.demo').controller('PopoverDemoCtrl', function ($sco templateUrl: 'myPopoverTemplate.html', title: 'Title' }; + + $scope.placement = { + options: [ + 'top', + 'top-left', + 'top-right', + 'bottom', + 'bottom-left', + 'bottom-right', + 'left', + 'left-top', + 'left-bottom', + 'right', + 'right-top', + 'right-bottom' + ], + selected: 'top' + }; }); diff --git a/src/popover/docs/readme.md b/src/popover/docs/readme.md index 5f917725d5..186395d7d5 100644 --- a/src/popover/docs/readme.md +++ b/src/popover/docs/readme.md @@ -18,8 +18,22 @@ The popover directives provides several optional attributes to control how it will display: - `popover-title`: A string to display as a fancy title. -- `popover-placement`: Where to place it? Defaults to "top", but also accepts - "bottom", "left", "right". +- `popover-placement`: Where to place it? Defaults to "top". Passing in 'auto' seperated by a space before the placement will + enable auto positioning, e.g: "auto bottom-left". The popover will attempt to position where it fits in + the closest scrollable ancestor. Accepts: + + - "top" - popover on top, horizontally centered on host element. + - "top-left" - popover on top, left edge aligned with host element left edge. + - "top-right" - popover on top, right edge aligned with host element right edge. + - "bottom" - popover on bottom, horizontally centered on host element. + - "bottom-left" - popover on bottom, left edge aligned with host element left edge. + - "bottom-right" - popover on bottom, right edge aligned with host element right edge. + - "left" - popover on left, vertically centered on host element. + - "left-top" - popover on left, top edge aligned with host element top edge. + - "left-bottom" - popover on left, bottom edge aligned with host element bottom edge. + - "right" - popover on right, vertically centered on host element. + - "right-top" - popover on right, top edge aligned with host element top edge. + - "right-bottom" - popover on right, bottom edge aligned with host element bottom edge. - `popover-animation`: Should it fade in and out? Defaults to "true". - `popover-popup-delay`: For how long should the user have to have the mouse over the element before the popover shows (in milliseconds)? Defaults to 0. diff --git a/src/position/docs/demo.html b/src/position/docs/demo.html new file mode 100644 index 0000000000..fcc063f433 --- /dev/null +++ b/src/position/docs/demo.html @@ -0,0 +1,34 @@ +

+

$uibPosition service

+
+
+ +
+
+ +
+ + +
+ Demo element +
+
+
+ offsetParent: {{elemVals.offsetParent}} +
+ scrollParent: {{elemVals.scrollParent}} +
+ scrollbarWidth: {{scrollbarWidth}} +
+ position: {{elemVals.position}} +
+ offset: {{elemVals.offset}} +
+ viewportOffset: {{elemVals.viewportOffset}} +
+ positionElements: {{elemVals.positionElements}} +
\ No newline at end of file diff --git a/src/position/docs/demo.js b/src/position/docs/demo.js new file mode 100644 index 0000000000..8be1343de4 --- /dev/null +++ b/src/position/docs/demo.js @@ -0,0 +1,27 @@ +angular.module('ui.bootstrap.demo').controller('PositionDemoCtrl', function ($scope, $window, $uibPosition) { + + $scope.elemVals = {}; + $scope.parentScrollable = true; + $scope.parentRelative = true; + + $scope.getValues = function() { + var divEl = $window.document.querySelector('#posdemodiv'); + var btnEl = $window.document.querySelector('#posdemobtn'); + + var offsetParent = $uibPosition.offsetParent(divEl); + $scope.elemVals.offsetParent = 'type: ' + offsetParent.tagName + ', id: ' + offsetParent.id; + + var scrollParent = $uibPosition.scrollParent(divEl); + $scope.elemVals.scrollParent = 'type: ' + scrollParent.tagName + ', id: ' + scrollParent.id; + + $scope.scrollbarWidth = $uibPosition.scrollbarWidth(); + + $scope.elemVals.position = $uibPosition.position(divEl); + + $scope.elemVals.offset = $uibPosition.offset(divEl); + + $scope.elemVals.viewportOffset = $uibPosition.viewportOffset(divEl); + + $scope.elemVals.positionElements = $uibPosition.positionElements(btnEl, divEl, 'auto bottom-left'); + }; +}); \ No newline at end of file diff --git a/src/position/docs/readme.md b/src/position/docs/readme.md new file mode 100644 index 0000000000..c6b3b6dc5f --- /dev/null +++ b/src/position/docs/readme.md @@ -0,0 +1,167 @@ +The `$uibPosition` service provides a set of DOM utilities used internally to absolute-position an element in relation to another element (tooltips, popovers, typeaheads etc...). + +### getRawNode(element) + Takes a jQuery/jqLite element and converts it to a raw DOM element. + +##### parameters + +* `element` _(Type 'object')_ - The element to convert. + +##### returns + + _(Type 'element')_ - A raw DOM element. + +### parseSyle(element) + Parses a numeric style value to a number. Strips units and will return 0 for invalid (NaN) numbers. + +##### parameters + +* `value` _(Type 'string')_ - The style value to parse. + +##### returns + + _(Type 'number')_ - The numeric value of the style property. + +### offsetParent(element) + Gets the closest positioned ancestor. + +##### parameters + +* `element` _(Type 'element')_ - The element to get the offset parent for. + +##### returns + + _(Type 'element')_ - The closest positioned ancestor. + +### scrollbarWidth() + Calculates the browser scrollbar width and caches the result for future calls. Concept from the TWBS measureScrollbar() function in [modal.js](https://github.com/twbs/bootstrap/blob/master/js/modal.js). + +##### returns + + _(Type 'number')_ - The width of the browser scrollbar. + +### scrollParent(element, includeHidden) + Gets the closest scrollable ancestor. Concept from the jQueryUI [scrollParent.js](https://github.com/jquery/jquery-ui/blob/master/ui/scroll-parent.js). + +##### parameters + +* `element` _(Type 'element')_ - The element to get the closest scrollable ancestor for. + +* `includeHidden` _(Type 'boolean', optional - default is false)_ - Should scroll style of 'hidden' be considered. + +##### returns + + _(Type 'element')_ - The closest scrollable ancestor. + +### position(element, includeMargins) + A read-only equivalent of jQuery's [position](http://api.jquery.com/position/) function, distance to closest positioned ancestor. Does not account for margins by default like jQuery's position. + +##### parameters + +* `element` _(Type 'element')_ - The element to get the position for. + +* `includeMagins` _(Type 'boolean', optional - default is false)_ - Should margins be accounted for. + +##### returns + + _(Type 'object')_ - An object with the following properties: + +* width: _(Type 'number')_ The width of the element. +* height: _(Type 'number')_ The height of the element. +* top: _(Type 'number')_ Distance to top edge of offset parent. +* left: _(Type 'number')_ Distance to left edge of offset parent. + +### offset(element) + A read-only equivalent of jQuery's [offset](http://api.jquery.com/offset/) function, distance to viewport. + +##### parameters + +* `element` _(Type 'element')_ - The element to get the offset for. + +##### returns + + _(Type 'object')_ - An object with the following properties: + +* width: _(Type 'number')_ The width of the element. +* height: _(Type 'number')_ The height of the element. +* top: _(Type 'number')_ Distance to top edge of the viewport. +* left: _(Type 'number')_ Distance to left edge of the viewport. + +### viewportOffset(element, useDocument, includePadding) + Gets the elements available space relative to the closest scrollable ancestor. Accounts for padding, border, and scrollbar width. + Right and bottom dimensions represent the distance to the respective edge of the viewport element, not the top and left edge. + If the element edge extends beyond the viewport, a negative value will be reported. + +##### parameters + +* `element` _(Type 'element')_ - The element to get the viewport offset for. +* `useDocument` _(Type 'boolean', optional - default is false)_ - Should the viewport be the document element instead of the first scrollable element. +* `includePadding` _(Type 'boolean', optional - default is true)_ - Should the padding on the viewport element be accounted for, default is true. + +##### returns + + _(Type 'object')_ - An object with the following properties: + +* top: _(Type 'number')_ Distance to top content edge of the viewport. +* bottom: _(Type 'number')_ Distance to bottom content edge of the viewport. +* left: _(Type 'number')_ Distance to left content edge of the viewport. +* right: _(Type 'number')_ Distance to right content edge of the viewport. + +### parsePlacement(placement) + Gets an array of placement values parsed from a placement string. Along with the 'auto' indicator, supported placement strings are: + +* top: element on top, horizontally centered on host element. +* top-left: element on top, left edge aligned with host element left edge. +* top-right: element on top, right edge aligned with host element right edge. +* bottom: element on bottom, horizontally centered on host element. +* bottom-left: element on bottom, left edge aligned with host element left edge. +* bottom-right: element on bottom, right edge aligned with host element right edge. +* left: element on left, vertically centered on host element. +* left-top: element on left, top edge aligned with host element top edge. +* left-bottom: element on left, bottom edge aligned with host element bottom edge. +* right: element on right, vertically centered on host element. +* right-top: element on right, top edge aligned with host element top edge. +* right-bottom: element on right, bottom edge aligned with host element bottom edge. + +A placement string with an 'auto' indicator is expected to be space separated from the placement, i.e: 'auto bottom-left'. +If the primary and secondary placement values do not match 'top, bottom, left, right' then 'top' will be the primary placement and +'center' will be the secondary placement. If 'auto' is passed, true will be returned as the 3rd value of the array. + +##### parameters + +* `placement` _(Type 'string', e.g. 'auto top-left')_ - The placement string to parse. + +##### returns + + _(Type 'array')_ - An array with the following values: + +* [0]: _(Type 'string')_ - The primary placement. +* [1]: _(Type 'string')_ - The secondary placement. +* [2]: _(Type 'boolean')_ - Is auto place enabled. + +### positionElements(hostElement, targetElement, placement, appendToBody) + Gets gets coordinates for an element to be positioned relative to another element. + +##### parameters + + * `hostElement` _(Type 'element')_ - The element to position against. + * `targetElement` _(Type 'element')_ - The element to position. + * `placement` _(Type 'string', optional - default is top)_ - The placement for the target element. See the parsePlacement() function for + available options. If 'auto' placement is used, the viewportOffset() function is used to decide where the targetElement will fit. + * `appendToBody` _(Type 'boolean', optional - default is false)_ - Should the coordinates be cacluated from the body element. + +##### returns + + _(Type 'object')_ - An object with the following properties: + + * top: _(Type 'number')_ The targetElement top value. + * left: _(Type 'number')_ The targetElement left value. + * right: _(Type 'number')_ The resolved placement with 'auto' removed. + +### positionArrow(element, placement) + Positions the tooltip and popover arrow elements when using placement options beyond the standard top, left, bottom, or right. + +##### parameters + + * `element` _(Type 'element')_ - The element to position the arrow element for. + * `placement` _(Type 'string')_ - The placement for the element. \ No newline at end of file diff --git a/src/position/position.js b/src/position/position.js index 89f6a7bb38..2a9d368c3b 100644 --- a/src/position/position.js +++ b/src/position/position.js @@ -1,149 +1,530 @@ angular.module('ui.bootstrap.position', []) /** - * A set of utility methods that can be use to retrieve position of DOM elements. - * It is meant to be used where we need to absolute-position DOM elements in - * relation to other, existing elements (this is the case for tooltips, popovers, + * A set of utility methods for working with the DOM. + * It is meant to be used where we need to absolute-position elements in + * relation to another element (this is the case for tooltips, popovers, * typeahead suggestions etc.). */ .factory('$uibPosition', ['$document', '$window', function($document, $window) { - function getStyle(el, cssprop) { - if (el.currentStyle) { //IE - return el.currentStyle[cssprop]; - } else if ($window.getComputedStyle) { - return $window.getComputedStyle(el)[cssprop]; - } - // finally try and get inline style - return el.style[cssprop]; - } - - /** - * Checks if a given element is statically positioned - * @param element - raw DOM element - */ - function isStaticPositioned(element) { - return (getStyle(element, 'position') || 'static' ) === 'static'; - } - /** - * returns the closest, non-statically positioned parentOffset of a given element - * @param element + * Used by scrollbarWidth() function to cache scrollbar's width. + * Do not access this variable directly, use scrollbarWidth() instead. */ - var parentOffsetEl = function(element) { - var docDomEl = $document[0]; - var offsetParent = element.offsetParent || docDomEl; - while (offsetParent && offsetParent !== docDomEl && isStaticPositioned(offsetParent) ) { - offsetParent = offsetParent.offsetParent; - } - return offsetParent || docDomEl; + var SCROLLBAR_WIDTH; + var OVERFLOW_REGEX = { + normal: /(auto|scroll)/, + hidden: /(auto|scroll|hidden)/ + }; + var PLACEMENT_REGEX = { + auto: /\s?auto?\s?/i, + primary: /^(top|bottom|left|right)$/, + secondary: /^(top|bottom|left|right|center)$/, + vertical: /^(top|bottom)$/ }; return { + + /** + * Provides a raw DOM element from a jQuery/jQLite element. + * + * @param {element} elem - The element to convert. + * + * @returns {element} A HTML element. + */ + getRawNode: function(elem) { + return elem[0] || elem; + }, + + /** + * Provides a parsed number for a style property. Strips + * units and casts invalid numbers to 0. + * + * @param {string} value - The style value to parse. + * + * @returns {number} A valid number. + */ + parseStyle: function(value) { + value = parseFloat(value); + return isFinite(value) ? value : 0; + }, + + /** + * Provides the closest positioned ancestor. + * + * @param {element} element - The element to get the offest parent for. + * + * @returns {element} The closest positioned ancestor. + */ + offsetParent: function(elem) { + elem = this.getRawNode(elem); + + var offsetParent = elem.offsetParent || $document[0].documentElement; + + function isStaticPositioned(el) { + return ($window.getComputedStyle(el).position || 'static') === 'static'; + } + + while (offsetParent && offsetParent !== $document[0].documentElement && isStaticPositioned(offsetParent)) { + offsetParent = offsetParent.offsetParent; + } + + return offsetParent || $document[0].documentElement; + }, + + /** + * Provides the scrollbar width, concept from TWBS measureScrollbar() + * function in https://github.com/twbs/bootstrap/blob/master/js/modal.js + * + * @returns {number} The width of the browser scollbar. + */ + scrollbarWidth: function() { + if (angular.isUndefined(SCROLLBAR_WIDTH)) { + var scrollElem = angular.element('
'); + $document.find('body').append(scrollElem); + SCROLLBAR_WIDTH = scrollElem[0].offsetWidth - scrollElem[0].clientWidth; + SCROLLBAR_WIDTH = isFinite(SCROLLBAR_WIDTH) ? SCROLLBAR_WIDTH : 0; + scrollElem.remove(); + } + + return SCROLLBAR_WIDTH; + }, + + /** + * Provides the closest scrollable ancestor. + * A port of the jQuery UI scrollParent method: + * https://github.com/jquery/jquery-ui/blob/master/ui/scroll-parent.js + * + * @param {element} elem - The element to find the scroll parent of. + * @param {boolean=} [includeHidden=false] - Should scroll style of 'hidden' be considered, + * default is false. + * + * @returns {element} A HTML element. + */ + scrollParent: function(elem, includeHidden) { + elem = this.getRawNode(elem); + + var overflowRegex = includeHidden ? OVERFLOW_REGEX.hidden : OVERFLOW_REGEX.normal; + var documentEl = $document[0].documentElement; + var elemStyle = $window.getComputedStyle(elem); + var excludeStatic = elemStyle.position === 'absolute'; + var scrollParent = elem.parentElement || documentEl; + + if (scrollParent === documentEl || elemStyle.position === 'fixed') { + return documentEl; + } + + while (scrollParent.parentElement && scrollParent !== documentEl) { + var spStyle = $window.getComputedStyle(scrollParent); + if (excludeStatic && spStyle.position !== 'static') { + excludeStatic = false; + } + + if (!excludeStatic && overflowRegex.test(spStyle.overflow + spStyle.overflowY + spStyle.overflowX)) { + break; + } + scrollParent = scrollParent.parentElement; + } + + return scrollParent; + }, + /** * Provides read-only equivalent of jQuery's position function: - * http://api.jquery.com/position/ + * http://api.jquery.com/position/ - distance to closest positioned + * ancestor. Does not account for margins by default like jQuery position. + * + * @param {element} elem - The element to caclulate the position on. + * @param {boolean=} [includeMargins=false] - Should margins be accounted + * for, default is false. + * + * @returns {object} An object with the following properties: + * */ - position: function(element) { - var elBCR = this.offset(element); - var offsetParentBCR = { top: 0, left: 0 }; - var offsetParentEl = parentOffsetEl(element[0]); - if (offsetParentEl !== $document[0]) { - offsetParentBCR = this.offset(angular.element(offsetParentEl)); - offsetParentBCR.top += offsetParentEl.clientTop - offsetParentEl.scrollTop; - offsetParentBCR.left += offsetParentEl.clientLeft - offsetParentEl.scrollLeft; + position: function(elem, includeMagins) { + elem = this.getRawNode(elem); + + var elemOffset = this.offset(elem); + if (includeMagins) { + var elemStyle = $window.getComputedStyle(elem); + elemOffset.top -= this.parseStyle(elemStyle.marginTop); + elemOffset.left -= this.parseStyle(elemStyle.marginLeft); + } + var parent = this.offsetParent(elem); + var parentOffset = {top: 0, left: 0}; + + if (parent !== $document[0].documentElement) { + parentOffset = this.offset(parent); + parentOffset.top += parent.clientTop - parent.scrollTop; + parentOffset.left += parent.clientLeft - parent.scrollLeft; } - var boundingClientRect = element[0].getBoundingClientRect(); return { - width: boundingClientRect.width || element.prop('offsetWidth'), - height: boundingClientRect.height || element.prop('offsetHeight'), - top: elBCR.top - offsetParentBCR.top, - left: elBCR.left - offsetParentBCR.left + width: Math.round(angular.isNumber(elemOffset.width) ? elemOffset.width : elem.offsetWidth), + height: Math.round(angular.isNumber(elemOffset.height) ? elemOffset.height : elem.offsetHeight), + top: Math.round(elemOffset.top - parentOffset.top), + left: Math.round(elemOffset.left - parentOffset.left) }; }, /** * Provides read-only equivalent of jQuery's offset function: - * http://api.jquery.com/offset/ + * http://api.jquery.com/offset/ - distance to viewport. Does + * not account for borders, margins, or padding on the body + * element. + * + * @param {element} elem - The element to calculate the offset on. + * + * @returns {object} An object with the following properties: + * */ - offset: function(element) { - var boundingClientRect = element[0].getBoundingClientRect(); + offset: function(elem) { + elem = this.getRawNode(elem); + + var elemBCR = elem.getBoundingClientRect(); return { - width: boundingClientRect.width || element.prop('offsetWidth'), - height: boundingClientRect.height || element.prop('offsetHeight'), - top: boundingClientRect.top + ($window.pageYOffset || $document[0].documentElement.scrollTop), - left: boundingClientRect.left + ($window.pageXOffset || $document[0].documentElement.scrollLeft) + width: Math.round(angular.isNumber(elemBCR.width) ? elemBCR.width : elem.offsetWidth), + height: Math.round(angular.isNumber(elemBCR.height) ? elemBCR.height : elem.offsetHeight), + top: Math.round(elemBCR.top + ($window.pageYOffset || $document[0].documentElement.scrollTop)), + left: Math.round(elemBCR.left + ($window.pageXOffset || $document[0].documentElement.scrollLeft)) }; }, /** - * Provides coordinates for the targetEl in relation to hostEl + * Provides offset distance to the closest scrollable ancestor + * or viewport. Accounts for border and scrollbar width. + * + * Right and bottom dimensions represent the distance to the + * respective edge of the viewport element. If the element + * edge extends beyond the viewport, a negative value will be + * reported. + * + * @param {element} elem - The element to get the viewport offset for. + * @param {boolean=} [useDocument=false] - Should the viewport be the document element instead + * of the first scrollable element, default is false. + * @param {boolean=} [includePadding=true] - Should the padding on the offset parent element + * be accounted for, default is true. + * + * @returns {object} An object with the following properties: + * */ - positionElements: function(hostEl, targetEl, positionStr, appendToBody) { - var positionStrParts = positionStr.split('-'); - var pos0 = positionStrParts[0], pos1 = positionStrParts[1] || 'center'; - - var hostElPos, - targetElWidth, - targetElHeight, - targetElPos; - - hostElPos = appendToBody ? this.offset(hostEl) : this.position(hostEl); - - targetElWidth = targetEl.prop('offsetWidth'); - targetElHeight = targetEl.prop('offsetHeight'); - - var shiftWidth = { - center: function() { - return hostElPos.left + hostElPos.width / 2 - targetElWidth / 2; - }, - left: function() { - return hostElPos.left; - }, - right: function() { - return hostElPos.left + hostElPos.width; - } + viewportOffset: function(elem, useDocument, includePadding) { + elem = this.getRawNode(elem); + includePadding = includePadding !== false ? true : false; + + var elemBCR = elem.getBoundingClientRect(); + var offsetBCR = {top: 0, left: 0, bottom: 0, right: 0}; + + var offsetParent = useDocument ? $document[0].documentElement : this.scrollParent(elem); + var offsetParentBCR = offsetParent.getBoundingClientRect(); + + offsetBCR.top = offsetParentBCR.top + offsetParent.clientTop; + offsetBCR.left = offsetParentBCR.left + offsetParent.clientLeft; + if (offsetParent === $document[0].documentElement) { + offsetBCR.top += $window.pageYOffset; + offsetBCR.left += $window.pageXOffset; + } + offsetBCR.bottom = offsetBCR.top + offsetParent.clientHeight; + offsetBCR.right = offsetBCR.left + offsetParent.clientWidth; + + if (includePadding) { + var offsetParentStyle = $window.getComputedStyle(offsetParent); + offsetBCR.top += this.parseStyle(offsetParentStyle.paddingTop); + offsetBCR.bottom -= this.parseStyle(offsetParentStyle.paddingBottom); + offsetBCR.left += this.parseStyle(offsetParentStyle.paddingLeft); + offsetBCR.right -= this.parseStyle(offsetParentStyle.paddingRight); + } + + return { + top: Math.round(elemBCR.top - offsetBCR.top), + bottom: Math.round(offsetBCR.bottom - elemBCR.bottom), + left: Math.round(elemBCR.left - offsetBCR.left), + right: Math.round(offsetBCR.right - elemBCR.right) }; + }, + + /** + * Provides an array of placement values parsed from a placement string. + * Along with the 'auto' indicator, supported placement strings are: + * + * A placement string with an 'auto' indicator is expected to be + * space separated from the placement, i.e: 'auto bottom-left' If + * the primary and secondary placement values do not match 'top, + * bottom, left, right' then 'top' will be the primary placement and + * 'center' will be the secondary placement. If 'auto' is passed, true + * will be returned as the 3rd value of the array. + * + * @param {string} placement - The placement string to parse. + * + * @returns {array} An array with the following values + * + */ + parsePlacement: function(placement) { + var autoPlace = PLACEMENT_REGEX.auto.test(placement); + if (autoPlace) { + placement = placement.replace(PLACEMENT_REGEX.auto, ''); + } + + placement = placement.split('-'); + + placement[0] = placement[0] || 'top'; + if (!PLACEMENT_REGEX.primary.test(placement[0])) { + placement[0] = 'top'; + } + + placement[1] = placement[1] || 'center'; + if (!PLACEMENT_REGEX.secondary.test(placement[1])) { + placement[1] = 'center'; + } + + if (autoPlace) { + placement[2] = true; + } else { + placement[2] = false; + } + + return placement; + }, + + /** + * Provides coordinates for an element to be positioned relative to + * another element. Passing 'auto' as part of the placement parameter + * will enable smart placement - where the element fits. i.e: + * 'auto left-top' will check to see if there is enough space to the left + * of the hostElem to fit the targetElem, if not place right (same for secondary + * top placement). Available space is calculated using the viewportOffset + * function. + * + * @param {element} hostElem - The element to position against. + * @param {element} targetElem - The element to position. + * @param {string=} [placement=top] - The placement for the targetElem, + * default is 'top'. 'center' is assumed as secondary placement for + * 'top', 'left', 'right', and 'bottom' placements. Available placements are: + * + * @param {boolean=} [appendToBody=false] - Should the top and left values returned + * be calculated from the body element, default is false. + * + * @returns {object} An object with the following properties: + * + */ + positionElements: function(hostElem, targetElem, placement, appendToBody) { + hostElem = this.getRawNode(hostElem); + targetElem = this.getRawNode(targetElem); + + // need to read from prop to support tests. + var targetWidth = angular.isDefined(targetElem.offsetWidth) ? targetElem.offsetWidth : targetElem.prop('offsetWidth'); + var targetHeight = angular.isDefined(targetElem.offsetHeight) ? targetElem.offsetHeight : targetElem.prop('offsetHeight'); + + placement = this.parsePlacement(placement); - var shiftHeight = { - center: function() { - return hostElPos.top + hostElPos.height / 2 - targetElHeight / 2; - }, - top: function() { - return hostElPos.top; - }, - bottom: function() { - return hostElPos.top + hostElPos.height; + var hostElemPos = appendToBody ? this.offset(hostElem) : this.position(hostElem); + var targetElemPos = {top: 0, left: 0, placement: ''}; + + if (placement[2]) { + var viewportOffset = this.viewportOffset(hostElem); + + var targetElemStyle = $window.getComputedStyle(targetElem); + var adjustedSize = { + width: targetWidth + Math.round(Math.abs(this.parseStyle(targetElemStyle.marginLeft) + this.parseStyle(targetElemStyle.marginRight))), + height: targetHeight + Math.round(Math.abs(this.parseStyle(targetElemStyle.marginTop) + this.parseStyle(targetElemStyle.marginBottom))) + }; + + placement[0] = placement[0] === 'top' && adjustedSize.height > viewportOffset.top && adjustedSize.height <= viewportOffset.bottom ? 'bottom' : + placement[0] === 'bottom' && adjustedSize.height > viewportOffset.bottom && adjustedSize.height <= viewportOffset.top ? 'top' : + placement[0] === 'left' && adjustedSize.width > viewportOffset.left && adjustedSize.width <= viewportOffset.right ? 'right' : + placement[0] === 'right' && adjustedSize.width > viewportOffset.right && adjustedSize.width <= viewportOffset.left ? 'left' : + placement[0]; + + placement[1] = placement[1] === 'top' && adjustedSize.height - hostElemPos.height > viewportOffset.bottom && adjustedSize.height - hostElemPos.height <= viewportOffset.top ? 'bottom' : + placement[1] === 'bottom' && adjustedSize.height - hostElemPos.height > viewportOffset.top && adjustedSize.height - hostElemPos.height <= viewportOffset.bottom ? 'top' : + placement[1] === 'left' && adjustedSize.width - hostElemPos.width > viewportOffset.right && adjustedSize.width - hostElemPos.width <= viewportOffset.left ? 'right' : + placement[1] === 'right' && adjustedSize.width - hostElemPos.width > viewportOffset.left && adjustedSize.width - hostElemPos.width <= viewportOffset.right ? 'left' : + placement[1]; + + if (placement[1] === 'center') { + if (PLACEMENT_REGEX.vertical.test(placement[0])) { + var xOverflow = hostElemPos.width / 2 - targetWidth / 2; + if (viewportOffset.left + xOverflow < 0 && adjustedSize.width - hostElemPos.width <= viewportOffset.right) { + placement[1] = 'left'; + } else if (viewportOffset.right + xOverflow < 0 && adjustedSize.width - hostElemPos.width <= viewportOffset.left) { + placement[1] = 'right'; + } + } else { + var yOverflow = hostElemPos.height / 2 - adjustedSize.height / 2; + if (viewportOffset.top + yOverflow < 0 && adjustedSize.height - hostElemPos.height <= viewportOffset.bottom) { + placement[1] = 'top'; + } else if (viewportOffset.bottom + yOverflow < 0 && adjustedSize.height - hostElemPos.height <= viewportOffset.top) { + placement[1] = 'bottom'; + } + } } - }; + } - switch (pos0) { + switch (placement[0]) { + case 'top': + targetElemPos.top = hostElemPos.top - targetHeight; + break; + case 'bottom': + targetElemPos.top = hostElemPos.top + hostElemPos.height; + break; + case 'left': + targetElemPos.left = hostElemPos.left - targetWidth; + break; case 'right': - targetElPos = { - top: shiftHeight[pos1](), - left: shiftWidth[pos0]() - }; + targetElemPos.left = hostElemPos.left + hostElemPos.width; + break; + } + + switch (placement[1]) { + case 'top': + targetElemPos.top = hostElemPos.top; + break; + case 'bottom': + targetElemPos.top = hostElemPos.top + hostElemPos.height - targetHeight; break; case 'left': - targetElPos = { - top: shiftHeight[pos1](), - left: hostElPos.left - targetElWidth - }; + targetElemPos.left = hostElemPos.left; + break; + case 'right': + targetElemPos.left = hostElemPos.left + hostElemPos.width - targetWidth; + break; + case 'center': + if (PLACEMENT_REGEX.vertical.test(placement[0])) { + targetElemPos.left = hostElemPos.left + hostElemPos.width / 2 - targetWidth / 2; + } else { + targetElemPos.top = hostElemPos.top + hostElemPos.height / 2 - targetHeight / 2; + } + break; + } + + targetElemPos.top = Math.round(targetElemPos.top); + targetElemPos.left = Math.round(targetElemPos.left); + targetElemPos.placement = placement[1] === 'center' ? placement[0] : placement[0] + '-' + placement[1]; + + return targetElemPos; + }, + + /** + * Provides a way for positioning tooltip & dropdown + * arrows when using placement options beyond the standard + * left, right, top, or bottom. + * + * @param {element} elem - The tooltip/dropdown element. + * @param {string} placement - The placement for the elem. + */ + positionArrow: function(elem, placement) { + elem = this.getRawNode(elem); + + var isTooltip = true; + + var innerElem = elem.querySelector('.tooltip-inner'); + if (!innerElem) { + isTooltip = false; + innerElem = elem.querySelector('.popover-inner'); + } + if (!innerElem) { + return; + } + + var arrowElem = isTooltip ? elem.querySelector('.tooltip-arrow') : elem.querySelector('.arrow'); + if (!arrowElem) { + return; + } + + placement = this.parsePlacement(placement); + if (placement[1] === 'center') { + // no adjustment necessary - just reset styles + angular.element(arrowElem).css({top: '', bottom: '', right: '', left: '', margin: ''}); + return; + } + + var borderProp = 'border-' + placement[0] + '-width'; + var borderWidth = $window.getComputedStyle(arrowElem)[borderProp]; + + var borderRadiusProp = 'border-'; + if (PLACEMENT_REGEX.vertical.test(placement[0])) { + borderRadiusProp += placement[0] + '-' + placement[1]; + } else { + borderRadiusProp += placement[1] + '-' + placement[0]; + } + borderRadiusProp += '-radius'; + var borderRadius = $window.getComputedStyle(isTooltip ? innerElem : elem)[borderRadiusProp]; + + var arrowCss = { + top: 'auto', + bottom: 'auto', + left: 'auto', + right: 'auto', + margin: 0 + }; + + switch (placement[0]) { + case 'top': + arrowCss.bottom = isTooltip ? '0' : '-' + borderWidth; break; case 'bottom': - targetElPos = { - top: shiftHeight[pos0](), - left: shiftWidth[pos1]() - }; + arrowCss.top = isTooltip ? '0' : '-' + borderWidth; + break; + case 'left': + arrowCss.right = isTooltip ? '0' : '-' + borderWidth; + break; + case 'right': + arrowCss.left = isTooltip ? '0' : '-' + borderWidth; break; - default: - targetElPos = { - top: hostElPos.top - targetElHeight, - left: shiftWidth[pos1]() - }; } - return targetElPos; + arrowCss[placement[1]] = borderRadius; + + angular.element(arrowElem).css(arrowCss); } }; }]); diff --git a/src/position/test/position.spec.js b/src/position/test/position.spec.js index 432ceff3ab..07e38f95d3 100644 --- a/src/position/test/position.spec.js +++ b/src/position/test/position.spec.js @@ -1,4 +1,4 @@ -describe('position elements', function () { +describe('$uibPosition service', function () { var TargetElMock = function(width, height) { this.width = width; this.height = height; @@ -8,12 +8,16 @@ describe('position elements', function () { }; }; - var $position; + var $document; + var $uibPosition; beforeEach(module('ui.bootstrap.position')); - beforeEach(inject(function($uibPosition) { - $position = $uibPosition; + + beforeEach(inject(function(_$document_, _$uibPosition_) { + $document = _$document_; + $uibPosition = _$uibPosition_; })); + beforeEach(function () { jasmine.addMatchers({ toBePositionedAt: function(util, customEqualityTesters) { @@ -38,12 +42,6 @@ describe('position elements', function () { }); describe('offset', function() { - var $document; - - beforeEach(inject(function(_$document_) { - $document = _$document_; - })); - it('returns getBoundingClientRect by default', function() { var el = angular.element('
Foo
'); @@ -57,7 +55,7 @@ describe('position elements', function () { }); $document.find('body').append(el); - var offset = $position.offset(el); + var offset = $uibPosition.offset(el); expect(offset).toEqual({ width: 100, @@ -70,12 +68,96 @@ describe('position elements', function () { }); }); - describe('position', function() { - var $document, el; + describe('viewportOffset', function() { + var el; + + beforeEach(function() { + el = angular.element('
'); + $document.find('body').append(el); + }); + + afterEach(function() { + el.remove(); + }); + + it('measures the offset', function() { + var vpOffset = $uibPosition.viewportOffset(document.getElementById('inner')); + expect(vpOffset).toEqual({ + top: 20, + bottom: 30, + left: 20, + right: 30 + }); + }); + + it('measures the offset without padding', function() { + var outerEl = document.getElementById('outer'); + outerEl.style.paddingTop = '0px'; + outerEl.style.paddingBottom = '0px'; + outerEl.style.paddingLeft = '0px'; + outerEl.style.paddingRight = '0px'; + + var vpOffset = $uibPosition.viewportOffset(document.getElementById('inner')); + expect(vpOffset).toEqual({ + top: 20, + bottom: 80, + left: 20, + right: 80 + }); + }); + + it('measures the offset with borders', function() { + var outerEl = document.getElementById('outer'); + outerEl.style.width = '220px'; + outerEl.style.height = '220px'; + outerEl.style.border = '10px solid black'; + + var vpOffset = $uibPosition.viewportOffset(document.getElementById('inner')); + expect(vpOffset).toEqual({ + top: 20, + bottom: 30, + left: 20, + right: 30 + }); + }); + + it('measures the offset excluding padding', function() { + var vpOffset = $uibPosition.viewportOffset(document.getElementById('inner'), false, false); + expect(vpOffset).toEqual({ + top: 45, + bottom: 55, + left: 45, + right: 55 + }); + }); - beforeEach(inject(function(_$document_) { - $document = _$document_; - })); + it('measures the offset when scrolled', function() { + var innerEl = document.getElementById('inner'); + innerEl.style.width = '300px'; + innerEl.style.height = '300px'; + var outerEl = document.getElementById('outer'); + outerEl.scrollTop = 25; + outerEl.scrollLeft = 25; + + var vpOffset = $uibPosition.viewportOffset(document.getElementById('inner')); + expect(vpOffset.top).toEqual(-5); + expect(vpOffset.bottom).toBeGreaterThan(-180); + expect(vpOffset.left).toEqual(-5); + expect(vpOffset.right).toBeGreaterThan(-180); + + //brittle + // expect(vpOffset).toEqual({ + // top: -5, + // bottom: -162, + // left: -5, + // right: -162 + // }); + }); + + }); + + describe('position', function() { + var el; afterEach(function() { el.remove(); @@ -93,7 +175,7 @@ describe('position elements', function () { $document.find('body').append(el); - var position = $position.position(el); + var position = $uibPosition.position(el); expect(position).toEqual({ width: 100, @@ -103,7 +185,7 @@ describe('position elements', function () { }); }); - it('gets position with another DOM as the relative parent', function() { + it('gets position with an element as the relative parent', function() { el = angular.element('
Foo
'); $document.find('body').append(el); @@ -124,7 +206,7 @@ describe('position elements', function () { left: 5 }); - var position = $position.position(innerEl); + var position = $uibPosition.position(innerEl); expect(position).toEqual({ width: 20, @@ -135,10 +217,79 @@ describe('position elements', function () { }); }); - describe('append-to-body: false', function() { + describe('scrollParent', function() { + var el; + + afterEach(function() { + el.remove(); + }); + + it('gets the closest scrollable ancestor', function() { + el = angular.element('
Foo
Bar
'); + + $document.find('body').css({overflow: 'auto'}).append(el); + + var outerEl = document.getElementById('outer'); + var innerEl = document.getElementById('inner'); + + var scrollParent = $uibPosition.scrollParent(innerEl); + expect(scrollParent).toEqual(outerEl); + }); + + it('gets the closest scrollable ancestor with overflow-x: scroll', function() { + el = angular.element('
Foo
Bar
'); + + $document.find('body').css({overflow: 'auto'}).append(el); + + var outerEl = document.getElementById('outer'); + var innerEl = document.getElementById('inner'); + + var scrollParent = $uibPosition.scrollParent(innerEl); + expect(scrollParent).toEqual(outerEl); + }); + + it('gets the closest scrollable ancestor with overflow-y: hidden', function() { + el = angular.element('
Foo
Bar
'); + + $document.find('body').css({overflow: 'auto'}).append(el); + + var outerEl = document.getElementById('outer'); + var innerEl = document.getElementById('inner'); + + var scrollParent = $uibPosition.scrollParent(innerEl, true); + expect(scrollParent).toEqual(outerEl); + }); + + it('gets the document element if no scrollable ancestor exists', function() { + el = angular.element('
Foo
Bar
'); + + $document.find('body').css({overflow: ''}).append(el); + + var innerEl = document.getElementById('inner'); + + var scrollParent = $uibPosition.scrollParent(innerEl); + expect(scrollParent).toEqual($document[0].documentElement); + }); + + it('gets the closest scrollable ancestor after a positioned ancestor when positioned absolute', function() { + el = angular.element('
Foo
Bar
'); + + $document.find('body').css({overflow: 'auto'}).append(el); + + var outerEl = document.getElementById('outer'); + var innerEl = document.getElementById('inner'); + + var scrollParent = $uibPosition.scrollParent(innerEl); + expect(scrollParent).toEqual(outerEl); + }); + }); + + describe('positionElements - append-to-body: false', function() { + var el; + beforeEach(function() { //mock position info normally queried from the DOM - $position.position = function() { + $uibPosition.position = function() { return { width: 20, height: 20, @@ -149,63 +300,63 @@ describe('position elements', function () { }); it('should position element on top-center by default', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'other')).toBePositionedAt(90, 105); - expect($position.positionElements({}, new TargetElMock(10, 10), 'top')).toBePositionedAt(90, 105); - expect($position.positionElements({}, new TargetElMock(10, 10), 'top-center')).toBePositionedAt(90, 105); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'other')).toBePositionedAt(90, 105); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'top')).toBePositionedAt(90, 105); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'top-center')).toBePositionedAt(90, 105); }); it('should position on top-left', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'top-left')).toBePositionedAt(90, 100); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'top-left')).toBePositionedAt(90, 100); }); it('should position on top-right', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'top-right')).toBePositionedAt(90, 120); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'top-right')).toBePositionedAt(90, 110); }); it('should position elements on bottom-center when "bottom" specified', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'bottom')).toBePositionedAt(120, 105); - expect($position.positionElements({}, new TargetElMock(10, 10), 'bottom-center')).toBePositionedAt(120, 105); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'bottom')).toBePositionedAt(120, 105); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'bottom-center')).toBePositionedAt(120, 105); }); it('should position elements on bottom-left', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'bottom-left')).toBePositionedAt(120, 100); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'bottom-left')).toBePositionedAt(120, 100); }); it('should position elements on bottom-right', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'bottom-right')).toBePositionedAt(120, 120); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'bottom-right')).toBePositionedAt(120, 110); }); it('should position elements on left-center when "left" specified', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'left')).toBePositionedAt(105, 90); - expect($position.positionElements({}, new TargetElMock(10, 10), 'left-center')).toBePositionedAt(105, 90); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'left')).toBePositionedAt(105, 90); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'left-center')).toBePositionedAt(105, 90); }); it('should position elements on left-top when "left-top" specified', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'left-top')).toBePositionedAt(100, 90); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'left-top')).toBePositionedAt(100, 90); }); it('should position elements on left-bottom when "left-bottom" specified', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'left-bottom')).toBePositionedAt(120, 90); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'left-bottom')).toBePositionedAt(110, 90); }); it('should position elements on right-center when "right" specified', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'right')).toBePositionedAt(105, 120); - expect($position.positionElements({}, new TargetElMock(10, 10), 'right-center')).toBePositionedAt(105, 120); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'right')).toBePositionedAt(105, 120); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'right-center')).toBePositionedAt(105, 120); }); it('should position elements on right-top when "right-top" specified', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'right-top')).toBePositionedAt(100, 120); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'right-top')).toBePositionedAt(100, 120); }); it('should position elements on right-top when "right-top" specified', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'right-bottom')).toBePositionedAt(120, 120); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'right-bottom')).toBePositionedAt(110, 120); }); }); - describe('append-to-body: true', function() { + describe('positionElements - append-to-body: true', function() { beforeEach(function() { //mock offset info normally queried from the DOM - $position.offset = function() { + $uibPosition.offset = function() { return { width: 20, height: 20, @@ -216,56 +367,156 @@ describe('position elements', function () { }); it('should position element on top-center by default', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'other', true)).toBePositionedAt(90, 105); - expect($position.positionElements({}, new TargetElMock(10, 10), 'top', true)).toBePositionedAt(90, 105); - expect($position.positionElements({}, new TargetElMock(10, 10), 'top-center', true)).toBePositionedAt(90, 105); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'other', true)).toBePositionedAt(90, 105); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'top', true)).toBePositionedAt(90, 105); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'top-center', true)).toBePositionedAt(90, 105); }); it('should position on top-left', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'top-left', true)).toBePositionedAt(90, 100); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'top-left', true)).toBePositionedAt(90, 100); }); it('should position on top-right', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'top-right', true)).toBePositionedAt(90, 120); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'top-right', true)).toBePositionedAt(90, 110); }); it('should position elements on bottom-center when "bottom" specified', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'bottom', true)).toBePositionedAt(120, 105); - expect($position.positionElements({}, new TargetElMock(10, 10), 'bottom-center', true)).toBePositionedAt(120, 105); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'bottom', true)).toBePositionedAt(120, 105); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'bottom-center', true)).toBePositionedAt(120, 105); }); it('should position elements on bottom-left', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'bottom-left', true)).toBePositionedAt(120, 100); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'bottom-left', true)).toBePositionedAt(120, 100); }); it('should position elements on bottom-right', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'bottom-right', true)).toBePositionedAt(120, 120); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'bottom-right', true)).toBePositionedAt(120, 110); }); it('should position elements on left-center when "left" specified', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'left', true)).toBePositionedAt(105, 90); - expect($position.positionElements({}, new TargetElMock(10, 10), 'left-center', true)).toBePositionedAt(105, 90); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'left', true)).toBePositionedAt(105, 90); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'left-center', true)).toBePositionedAt(105, 90); }); it('should position elements on left-top when "left-top" specified', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'left-top', true)).toBePositionedAt(100, 90); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'left-top', true)).toBePositionedAt(100, 90); }); it('should position elements on left-bottom when "left-bottom" specified', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'left-bottom', true)).toBePositionedAt(120, 90); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'left-bottom', true)).toBePositionedAt(110, 90); }); it('should position elements on right-center when "right" specified', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'right', true)).toBePositionedAt(105, 120); - expect($position.positionElements({}, new TargetElMock(10, 10), 'right-center', true)).toBePositionedAt(105, 120); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'right', true)).toBePositionedAt(105, 120); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'right-center', true)).toBePositionedAt(105, 120); }); it('should position elements on right-top when "right-top" specified', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'right-top', true)).toBePositionedAt(100, 120); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'right-top', true)).toBePositionedAt(100, 120); }); - it('should position elements on right-top when "right-top" specified', function() { - expect($position.positionElements({}, new TargetElMock(10, 10), 'right-bottom', true)).toBePositionedAt(120, 120); + it('should position elements on right-bottom when "right-bottom" specified', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'right-bottom', true)).toBePositionedAt(110, 120); + }); + }); + + describe('smart positioning', function() { + var viewportOffset, el; + + beforeEach(function() { + el = angular.element('
'); + $document.find('body').append(el); + + //mock position info normally queried from the DOM + $uibPosition.position = function() { + return { + width: 40, + height: 40, + top: 100, + left: 100 + }; + }; + + viewportOffset = { + width: 10, + height: 10, + top: 10, + bottom: 10, + left: 10, + right: 10 + }; + + $uibPosition.viewportOffset = function() { + return viewportOffset; + }; + }); + + afterEach(function() { + el.remove(); + }); + + // tests primary top -> bottom + // tests secondary left -> right + it('should position element on bottom-right when top-left does not fit', function() { + viewportOffset.bottom = 20; + viewportOffset.left = 20; + el.css({ width: '60px', height: '20px' }); + expect($uibPosition.positionElements({}, el, 'auto top-left')).toBePositionedAt(140, 80); + }); + + // tests primary bottom -> top + // tests secondary right -> left + it('should position element on top-left when bottom-right does not fit', function() { + viewportOffset.top = 20; + viewportOffset.right = 20; + el.css({ width: '60px', height: '20px' }); + expect($uibPosition.positionElements({}, el, 'auto bottom-right')).toBePositionedAt(80, 100); + }); + + // tests primary left -> right + // tests secondary top -> bottom + it('should position element on right-bottom when left-top does not fit', function() { + viewportOffset.top = 20; + viewportOffset.right = 20; + el.css({ width: '20px', height: '60px' }); + expect($uibPosition.positionElements({}, el, 'auto left-top')).toBePositionedAt(80, 140); + }); + + // tests primary right -> left + // tests secondary bottom -> top + it('should position element on left-top when right-bottom does not fit', function() { + viewportOffset.bottom = 20; + viewportOffset.left = 20; + el.css({ width: '20px', height: '60px' }); + expect($uibPosition.positionElements({}, el, 'auto right-bottom')).toBePositionedAt(100, 80); + }); + + // tests vertical center -> top + it('should position element on left-top when left-center does not fit vetically', function() { + viewportOffset.bottom = 100; + el.css({ width: '20px', height: '120px' }); + expect($uibPosition.positionElements({}, el, 'auto left')).toBePositionedAt(100, 80); + }); + + // tests vertical center -> bottom + it('should position element on left-bottom when left-center does not fit vertically', function() { + viewportOffset.top = 100; + el.css({ width: '20px', height: '120px' }); + expect($uibPosition.positionElements({}, el, 'auto left')).toBePositionedAt(20, 80); + }); + + // tests horizontal center -> left + it('should position element on top-left when top-center does not fit horizontally', function() { + viewportOffset.right = 100; + el.css({ width: '120px', height: '20px' }); + expect($uibPosition.positionElements({}, el, 'auto top')).toBePositionedAt(80, 100); + }); + + // tests horizontal center -> right + it('should position element on top-right when top-center does not fit horizontally', function() { + viewportOffset.left = 100; + el.css({ width: '120px', height: '20px' }); + expect($uibPosition.positionElements({}, el, 'auto top')).toBePositionedAt(80, 20); }); }); }); diff --git a/src/tooltip/docs/demo.html b/src/tooltip/docs/demo.html index 795f3657b0..c9be614fdb 100644 --- a/src/tooltip/docs/demo.html +++ b/src/tooltip/docs/demo.html @@ -1,4 +1,12 @@
+

Positional

+
+ + +
+ + +
@@ -11,19 +19,14 @@ Pellentesque {{dynamicTooltipText}}, sit amet venenatis urna cursus eget nunc scelerisque viverra mauris, in aliquam. Tincidunt lobortis feugiat vivamus at - left eget - arcu dictum varius duis at consectetur lorem. Vitae elementum curabitur - right - nunc sed velit dignissim sodales ut eu sem integer vitae. Turpis egestas - bottom - pharetra convallis posuere morbi leo urna, fading - at elementum eu, facilisis sed odio morbi quis commodo odio. In cursus + eget arcu dictum varius duis at consectetur lorem. Vitae elementum curabitur show delay - turpis massa tincidunt dui ut. In cursus + nunc sed velit dignissim sodales ut eu sem integer vitae. Turpis egestas hide delay + pharetra convallis posuere morbi leo urna, Custom template - nunc sed velit dignissim sodales ut eu sem integer vitae. Turpis egestas + at elementum eu, facilisis sed odio morbi quis commodo odio.

@@ -31,19 +34,19 @@

- - I can have a custom class. Check me out! + + I can have a custom class. Check me out!

diff --git a/src/tooltip/docs/demo.js b/src/tooltip/docs/demo.js index 18c2794b3e..d4a2cc02a8 100644 --- a/src/tooltip/docs/demo.js +++ b/src/tooltip/docs/demo.js @@ -2,4 +2,21 @@ angular.module('ui.bootstrap.demo').controller('TooltipDemoCtrl', function ($sco $scope.dynamicTooltip = 'Hello, World!'; $scope.dynamicTooltipText = 'dynamic'; $scope.htmlTooltip = $sce.trustAsHtml('I\'ve been made bold!'); + $scope.placement = { + options: [ + 'top', + 'top-left', + 'top-right', + 'bottom', + 'bottom-left', + 'bottom-right', + 'left', + 'left-top', + 'left-bottom', + 'right', + 'right-top', + 'right-bottom' + ], + selected: 'top' + }; }); diff --git a/src/tooltip/docs/readme.md b/src/tooltip/docs/readme.md index fa263e90f6..2bfdbe3bba 100644 --- a/src/tooltip/docs/readme.md +++ b/src/tooltip/docs/readme.md @@ -14,8 +14,22 @@ There are three versions of the tooltip: `uib-tooltip`, `uib-tooltip-template`, The tooltip directives provide several optional attributes to control how they will display: -- `tooltip-placement`: Where to place it? Defaults to "top", but also accepts - "bottom", "left", "right". +- `tooltip-placement`: Where to place it? Defaults to "top". Passing in 'auto' seperated by a space before the placement will + enable auto positioning, e.g: "auto bottom-left". The tooltip will attempt to position where it fits in + the closest scrollable ancestor. Accepts: + + - "top" - tooltip on top, horizontally centered on host element. + - "top-left" - tooltip on top, left edge aligned with host element left edge. + - "top-right" - tooltip on top, right edge aligned with host element right edge. + - "bottom" - tooltip on bottom, horizontally centered on host element. + - "bottom-left" - tooltip on bottom, left edge aligned with host element left edge. + - "bottom-right" - tooltip on bottom, right edge aligned with host element right edge. + - "left" - tooltip on left, vertically centered on host element. + - "left-top" - tooltip on left, top edge aligned with host element top edge. + - "left-bottom" - tooltip on left, bottom edge aligned with host element bottom edge. + - "right" - tooltip on right, vertically centered on host element. + - "right-top" - tooltip on right, top edge aligned with host element top edge. + - "right-bottom" - tooltip on right, bottom edge aligned with host element bottom edge. - `tooltip-animation`: Should it fade in and out? Defaults to "true". - `tooltip-popup-delay`: For how long should the user have to have the mouse over the element before the tooltip shows (in milliseconds)? Defaults to 0. @@ -60,11 +74,12 @@ methods are available: - `setTriggers(obj)`: Extends the default trigger mappings mentioned above with mappings of your own. E.g. `{ 'openTrigger': 'closeTrigger' }`. - `options(obj)`: Provide a set of defaults for certain tooltip and popover - attributes. Currently supports 'placement', 'animation', 'popupDelay', and + attributes. Currently supports 'placement', 'placementClassPrefix', 'animation', 'popupDelay', and `appendToBody`. Here are the defaults:
   placement: 'top',
+  placementClassPrefix: '',
   animation: true,
   popupDelay: 0,
   popupCloseDelay: 500,
diff --git a/src/tooltip/tooltip.js b/src/tooltip/tooltip.js
index 2f363ab652..318efb4552 100644
--- a/src/tooltip/tooltip.js
+++ b/src/tooltip/tooltip.js
@@ -13,6 +13,7 @@ angular.module('ui.bootstrap.tooltip', ['ui.bootstrap.position', 'ui.bootstrap.s
   // The default options tooltip and popover.
   var defaultOptions = {
     placement: 'top',
+    placementClassPrefix: '',
     animation: true,
     popupDelay: 0,
     popupCloseDelay: 0,
@@ -158,11 +159,32 @@ angular.module('ui.bootstrap.tooltip', ['ui.bootstrap.position', 'ui.bootstrap.s
                   tooltip.css({ top: 0, left: 0 });
 
                   // Now set the calculated positioning.
-                  var ttCss = $position.positionElements(element, tooltip, ttScope.placement, appendToBody);
-                  ttCss.top += 'px';
-                  ttCss.left += 'px';
-                  ttCss.visibility = 'visible';
-                  tooltip.css(ttCss);
+                  var ttPosition = $position.positionElements(element, tooltip, ttScope.placement, appendToBody);
+                  tooltip.css({ top: ttPosition.top + 'px', left: ttPosition.left + 'px', visibility: 'visible' });
+
+                  // If the placement class is prefixed, still need
+                  // to remove the TWBS standard class.
+                  if (options.placementClassPrefix) {
+                    tooltip.removeClass('top bottom left right');
+                  }
+
+                  tooltip.removeClass(
+                    options.placementClassPrefix + 'top ' +
+                    options.placementClassPrefix + 'top-left ' +
+                    options.placementClassPrefix + 'top-right ' +
+                    options.placementClassPrefix + 'bottom ' +
+                    options.placementClassPrefix + 'bottom-left ' +
+                    options.placementClassPrefix + 'bottom-right ' +
+                    options.placementClassPrefix + 'left ' +
+                    options.placementClassPrefix + 'left-top ' +
+                    options.placementClassPrefix + 'left-bottom ' +
+                    options.placementClassPrefix + 'right ' +
+                    options.placementClassPrefix + 'right-top ' +
+                    options.placementClassPrefix + 'right-bottom');
+
+                  var placement = ttPosition.placement.split('-');
+                  tooltip.addClass(placement[0], options.placementClassPrefix + ttPosition.placement);
+                  $position.positionArrow(tooltip, ttPosition.placement);
 
                   positionTimeout = null;
                 }, 0, false);
@@ -608,12 +630,20 @@ function ($animate, $sce, $compile, $templateRequest) {
  * They must not be animated as they're expected to be present on the tooltip on
  * initialization.
  */
-.directive('uibTooltipClasses', function() {
+.directive('uibTooltipClasses', ['$uibPosition', function($uibPosition) {
   return {
     restrict: 'A',
     link: function(scope, element, attrs) {
+      // need to set the primary position so the
+      // arrow has space during position measure.
+      // tooltip.positionTooltip()
       if (scope.placement) {
-        element.addClass(scope.placement);
+        // // There are no top-left etc... classes
+        // // in TWBS, so we need the primary position.
+        var position = $uibPosition.parsePlacement(scope.placement);
+        element.addClass(position[0]);
+      } else {
+        element.addClass('top');
       }
 
       if (scope.popupClass) {
@@ -625,7 +655,7 @@ function ($animate, $sce, $compile, $templateRequest) {
       }
     }
   };
-})
+}])
 
 .directive('uibTooltipPopup', function() {
   return {