From 8534e12523c7156b53e76393cc8d016ffdcf5b4f Mon Sep 17 00:00:00 2001
From: Ben Ogle <ogle.ben@gmail.com>
Date: Mon, 20 Jan 2014 11:54:22 -0800
Subject: [PATCH] Add tooltip `viewport` option, respect bounds of the viewport

---
 docs/_includes/js/tooltips.html |   8 +++
 examples/tooltips/viewport.html | 103 ++++++++++++++++++++++++++++++++
 js/tests/unit/tooltip.js        |  81 ++++++++++++++++++++++++-
 js/tooltip.js                   | 102 +++++++++++++++++++------------
 4 files changed, 253 insertions(+), 41 deletions(-)
 create mode 100644 examples/tooltips/viewport.html

diff --git a/docs/_includes/js/tooltips.html b/docs/_includes/js/tooltips.html
index 2656556bed..b952e85040 100644
--- a/docs/_includes/js/tooltips.html
+++ b/docs/_includes/js/tooltips.html
@@ -134,6 +134,14 @@ $('#example').tooltip(options)
           <p>Appends the tooltip to a specific element. Example: <code>container: 'body'</code></p>
          </td>
        </tr>
+       <tr>
+         <td>viewport</td>
+         <td>string | object</td>
+         <td>{ selector: 'body', padding: 0 }</td>
+         <td>
+          <p>Keeps the tooltip within the bounds of this element. Example: <code>viewport: '#viewport'</code> or <code>{ selector: '#viewport', padding: 0 }</code></p>
+         </td>
+       </tr>
       </tbody>
     </table>
   </div><!-- /.table-responsive -->
diff --git a/examples/tooltips/viewport.html b/examples/tooltips/viewport.html
new file mode 100644
index 0000000000..6efcd0495c
--- /dev/null
+++ b/examples/tooltips/viewport.html
@@ -0,0 +1,103 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta name="description" content="">
+    <meta name="author" content="">
+    <link rel="shortcut icon" href="../../docs-assets/ico/favicon.png">
+
+    <title>Tooltip Viewport Example for Bootstrap</title>
+
+    <!-- Bootstrap core CSS -->
+    <link href="../../dist/css/bootstrap.css" rel="stylesheet">
+
+    <!-- Just for debugging purposes. Don't actually copy this line! -->
+    <!--[if lt IE 9]><script src="../../docs-assets/js/ie8-responsive-file-warning.js"></script><![endif]-->
+
+    <!-- HTML5 shim and Respond.js IE8 support of HTML5 elements and media queries -->
+    <!--[if lt IE 9]>
+      <script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
+      <script src="https://oss.maxcdn.com/libs/respond.js/1.3.0/respond.min.js"></script>
+    <![endif]-->
+
+    <style>
+    body {
+      height: 1200px;
+    }
+    .tooltip {
+      min-width: 250px;
+      max-width: 500px;
+    }
+    .tooltip .tooltip-inner {
+      min-height: 200px;
+      min-width: 250px;
+      max-width: 500px;
+    }
+    .placeholder {
+      height: 900px;
+    }
+    .container-viewport {
+      position: absolute;
+      left: 200px;
+      top: 600px;
+      width: 600px;
+      height: 400px;
+      background: #c00;
+    }
+    .btn-bottom {
+      position: absolute;
+      left: 0;
+      bottom: 0;
+    }
+    </style>
+  </head>
+
+  <body>
+
+    <button class="btn pull-right tooltip-bottom", title="This should be shifted to the left">Shift Left</button>
+    <button class="btn tooltip-bottom", title="This should be shifted to the right">Shift Right</button>
+    <button class="btn tooltip-right", title="This should be shifted down">Shift Down</button>
+
+    <div class="placeholder">There is a button down there ↓</div>
+
+    <button class="btn tooltip-right", title="This should be shifted up">Shift Up</button>
+
+    <div class="container-viewport">
+      <button class="btn tooltip-viewport-bottom", title="This should be shifted Left">Shift Left</button>
+      <button class="btn tooltip-viewport-right", title="This should be shifted Down">Shift Down</button>
+
+      <button class="btn pull-right tooltip-viewport-bottom", title="This should be shifted Right">Shift Right</button>
+
+      <button class="btn tooltip-viewport-right btn-bottom", title="This should be shifted up">Shift Up</button>
+    </div>
+
+    <!-- Bootstrap core JavaScript
+    ================================================== -->
+    <!-- Placed at the end of the document so the pages load faster -->
+    <script src="https://code.jquery.com/jquery-1.10.2.min.js"></script>
+    <script src="../../js/tooltip.js"></script>
+
+    <script>
+    $(function(){
+      $('.tooltip-right').tooltip({
+        placement: 'right',
+        viewport: {selector: 'body', padding: 2}
+      });
+      $('.tooltip-bottom').tooltip({
+        placement: 'bottom',
+        viewport: {selector: 'body', padding: 2}
+      });
+      $('.tooltip-viewport-right').tooltip({
+        placement: 'right',
+        viewport: {selector: '.container-viewport', padding: 2}
+      });
+      $('.tooltip-viewport-bottom').tooltip({
+        placement: 'bottom',
+        viewport: {selector: '.container-viewport', padding: 2}
+      });
+    });
+    </script>
+  </body>
+</html>
diff --git a/js/tests/unit/tooltip.js b/js/tests/unit/tooltip.js
index 9df234236c..e670883e35 100644
--- a/js/tests/unit/tooltip.js
+++ b/js/tests/unit/tooltip.js
@@ -337,12 +337,12 @@ $(function () {
   })
 
   test('should add position class before positioning so that position-specific styles are taken into account', function () {
-    $('head').append('<style> .tooltip.right { white-space: nowrap; } .tooltip.right .tooltip-inner { max-width: none; } </style>')
+    $('head').append('<style id="test"> .tooltip.right { white-space: nowrap; } .tooltip.right .tooltip-inner { max-width: none; } </style>')
 
     var container = $('<div />').appendTo('body'),
         target = $('<a href="#" rel="tooltip" title="very very very very very very very very long tooltip in one line"></a>')
           .appendTo(container)
-          .tooltip({placement: 'right'})
+          .tooltip({placement: 'right', viewport: null})
           .tooltip('show'),
         tooltip = container.find('.tooltip')
 
@@ -352,6 +352,7 @@ $(function () {
     var topDiff =  top - top2
     ok(topDiff <= 1 && topDiff >= -1)
     target.tooltip('hide')
+    $('head #test').remove()
   })
 
   test('tooltip title test #1', function () {
@@ -428,4 +429,80 @@ $(function () {
     ttContainer.remove()
   })
 
+  test('should adjust the tip\'s top when up against the top of the viewport', function () {
+    $('head').append('<style id="test"> .tooltip .tooltip-inner { width: 200px; height: 200px; max-width: none; } </style>')
+
+    var container = $('<div />').appendTo('body'),
+      target = $('<a href="#" rel="tooltip" title="tip" style="position: fixed; top: 0px; left: 0px;"></a>')
+          .appendTo(container)
+          .tooltip({placement: 'right', viewport: {selector: 'body', padding: 12}})
+          .tooltip('show'),
+      tooltip = container.find('.tooltip')
+
+    ok( Math.round(tooltip.offset().top) === 12 )
+    target.tooltip('hide')
+    $('head #test').remove()
+  })
+
+  test('should adjust the tip\'s top when up against the bottom of the viewport', function () {
+    $('head').append('<style id="test"> .tooltip .tooltip-inner { width: 200px; height: 200px; max-width: none; } </style>')
+
+    var container = $('<div />').appendTo('body'),
+      target = $('<a href="#" rel="tooltip" title="tip" style="position: fixed; bottom: 0px; left: 0px;"></a>')
+          .appendTo(container)
+          .tooltip({placement: 'right', viewport: {selector: 'body', padding: 12}})
+          .tooltip('show'),
+      tooltip = container.find('.tooltip')
+
+    ok( Math.round(tooltip.offset().top) === Math.round($(window).height() - 12 - tooltip[0].offsetHeight) )
+    target.tooltip('hide')
+    $('head #test').remove()
+  })
+
+  test('should adjust the tip\'s left when up against the left of the viewport', function () {
+    $('head').append('<style id="test"> .tooltip .tooltip-inner { width: 200px; height: 200px; max-width: none; } </style>')
+
+    var container = $('<div />').appendTo('body'),
+      target = $('<a href="#" rel="tooltip" title="tip" style="position: fixed; top: 0px; left: 0px;"></a>')
+          .appendTo(container)
+          .tooltip({placement: 'bottom', viewport: {selector: 'body', padding: 12}})
+          .tooltip('show'),
+      tooltip = container.find('.tooltip')
+
+    ok( Math.round(tooltip.offset().left) === 12 )
+    target.tooltip('hide')
+    $('head #test').remove()
+  })
+
+  test('should adjust the tip\'s left when up against the right of the viewport', function () {
+    $('head').append('<style id="test"> .tooltip .tooltip-inner { width: 200px; height: 200px; max-width: none; } </style>')
+
+    var container = $('<div />').appendTo('body'),
+      target = $('<a href="#" rel="tooltip" title="tip" style="position: fixed; top: 0px; right: 0px;"></a>')
+          .appendTo(container)
+          .tooltip({placement: 'bottom', viewport: {selector: 'body', padding: 12}})
+          .tooltip('show'),
+      tooltip = container.find('.tooltip')
+
+    ok( Math.round(tooltip.offset().left) === Math.round($(window).width() - 12 - tooltip[0].offsetWidth) )
+    target.tooltip('hide')
+    $('head #test').remove()
+  })
+
+  test('should adjust the tip when up against the right of an arbitrary viewport', function () {
+    $('head').append('<style id="test"> .tooltip, .tooltip .tooltip-inner { width: 200px; height: 200px; max-width: none; } </style>')
+    $('head').append('<style id="viewport-style"> .container-viewport { position: absolute; top: 50px; left: 60px; width: 300px; height: 300px; } </style>')
+
+    var container = $('<div />', {class: 'container-viewport'}).appendTo('body'),
+      target = $('<a href="#" rel="tooltip" title="tip" style="position: fixed; top: 50px; left: 350px;"></a>')
+          .appendTo(container)
+          .tooltip({placement: 'bottom', viewport: '.container-viewport'})
+          .tooltip('show'),
+      tooltip = container.find('.tooltip')
+
+    ok( Math.round(tooltip.offset().left) === Math.round(60 + container.width() - tooltip[0].offsetWidth) )
+    target.tooltip('hide')
+    $('head #test').remove()
+    $('head #viewport-style').remove()
+  })
 })
diff --git a/js/tooltip.js b/js/tooltip.js
index eb7875c9fe..f27beacc6d 100644
--- a/js/tooltip.js
+++ b/js/tooltip.js
@@ -34,14 +34,19 @@
     title: '',
     delay: 0,
     html: false,
-    container: false
+    container: false,
+    viewport: {
+      selector: 'body',
+      padding: 0
+    }
   }
 
   Tooltip.prototype.init = function (type, element, options) {
-    this.enabled  = true
-    this.type     = type
-    this.$element = $(element)
-    this.options  = this.getOptions(options)
+    this.enabled   = true
+    this.type      = type
+    this.$element  = $(element)
+    this.options   = this.getOptions(options)
+    this.$viewport = this.options.viewport && $(this.options.viewport.selector || this.options.viewport)
 
     var triggers = this.options.trigger.split(' ')
 
@@ -157,18 +162,14 @@
       var actualHeight = $tip[0].offsetHeight
 
       if (autoPlace) {
-        var $parent = this.$element.parent()
-
         var orgPlacement = placement
-        var docScroll    = document.documentElement.scrollTop
-        var parentWidth  = this.options.container == 'body' ? window.innerWidth  : $parent.outerWidth()
-        var parentHeight = this.options.container == 'body' ? window.innerHeight : $parent.outerHeight()
-        var parentLeft   = this.options.container == 'body' ? 0 : $parent.offset().left
-
-        placement = placement == 'bottom' && pos.top   + pos.height  + actualHeight - docScroll > parentHeight  ? 'top'    :
-                    placement == 'top'    && pos.top   - docScroll   - actualHeight < 0                         ? 'bottom' :
-                    placement == 'right'  && pos.right + actualWidth > parentWidth                              ? 'left'   :
-                    placement == 'left'   && pos.left  - actualWidth < parentLeft                               ? 'right'  :
+        var $parent      = this.$element.parent()
+        var parentDim    = this.getPosition($parent)
+
+        placement = placement == 'bottom' && pos.top   + pos.height       + actualHeight - parentDim.scroll > parentDim.height ? 'top'    :
+                    placement == 'top'    && pos.top   - parentDim.scroll - actualHeight < 0                                   ? 'bottom' :
+                    placement == 'right'  && pos.right + actualWidth      > parentDim.width                                    ? 'left'   :
+                    placement == 'left'   && pos.left  - actualWidth      < parentDim.left                                     ? 'right'  :
                     placement
 
         $tip
@@ -228,29 +229,20 @@
     var actualHeight = $tip[0].offsetHeight
 
     if (placement == 'top' && actualHeight != height) {
-      replace = true
       offset.top = offset.top + height - actualHeight
     }
 
-    if (/bottom|top/.test(placement)) {
-      var delta = 0
+    var delta = this.getViewportAdjustedDelta(placement, offset, actualWidth, actualHeight)
 
-      if (offset.left < 0) {
-        delta       = offset.left * -2
-        offset.left = 0
+    if (delta.left) offset.left += delta.left
+    else offset.top += delta.top
 
-        $tip.offset(offset)
-
-        actualWidth  = $tip[0].offsetWidth
-        actualHeight = $tip[0].offsetHeight
-      }
+    var arrowDelta          = delta.left ? delta.left * 2 - width + actualWidth : delta.top * 2 - height + actualHeight
+    var arrowPosition       = delta.left ? 'left'        : 'top'
+    var arrowOffsetPosition = delta.left ? 'offsetWidth' : 'offsetHeight'
 
-      this.replaceArrow(delta - width + actualWidth, actualWidth, 'left')
-    } else {
-      this.replaceArrow(actualHeight - height, actualHeight, 'top')
-    }
-
-    if (replace) $tip.offset(offset)
+    $tip.offset(offset)
+    this.replaceArrow(arrowDelta, $tip[0][arrowOffsetPosition], arrowPosition)
   }
 
   Tooltip.prototype.replaceArrow = function (delta, dimension, position) {
@@ -303,12 +295,15 @@
     return this.getTitle()
   }
 
-  Tooltip.prototype.getPosition = function () {
-    var el = this.$element[0]
-    return $.extend({}, (typeof el.getBoundingClientRect == 'function') ? el.getBoundingClientRect() : {
-      width: el.offsetWidth,
-      height: el.offsetHeight
-    }, this.$element.offset())
+  Tooltip.prototype.getPosition = function ($element) {
+    $element   = $element || this.$element
+    var el     = $element[0]
+    var isBody = el.tagName == 'BODY'
+    return $.extend({}, (typeof el.getBoundingClientRect == 'function') ? el.getBoundingClientRect() : null, {
+      scroll: isBody ? document.documentElement.scrollTop || document.body.scrollTop : $element.scrollTop(),
+      width:  isBody ? $(window).width()  : $element.outerWidth(),
+      height: isBody ? $(window).height() : $element.outerHeight()
+    }, isBody ? {top: 0, left: 0} : $element.offset())
   }
 
   Tooltip.prototype.getCalculatedOffset = function (placement, pos, actualWidth, actualHeight) {
@@ -316,6 +311,35 @@
            placement == 'top'    ? { top: pos.top - actualHeight, left: pos.left + pos.width / 2 - actualWidth / 2  } :
            placement == 'left'   ? { top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left - actualWidth } :
         /* placement == 'right' */ { top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left + pos.width   }
+
+  }
+
+  Tooltip.prototype.getViewportAdjustedDelta = function (placement, pos, actualWidth, actualHeight) {
+    var delta = { top: 0, left: 0 }
+    if (!this.$viewport) return delta
+
+    var viewportPadding = this.options.viewport && this.options.viewport.padding || 0
+    var viewportDimensions = this.getPosition(this.$viewport)
+
+    if (/right|left/.test(placement)) {
+      var topEdgeOffset    = pos.top - viewportPadding - viewportDimensions.scroll
+      var bottomEdgeOffset = pos.top + viewportPadding - viewportDimensions.scroll + actualHeight
+      if (topEdgeOffset < viewportDimensions.top) { // top overflow
+        delta.top = viewportDimensions.top - topEdgeOffset
+      } else if (bottomEdgeOffset > viewportDimensions.top + viewportDimensions.height) { // bottom overflow
+        delta.top = viewportDimensions.top + viewportDimensions.height - bottomEdgeOffset
+      }
+    } else {
+      var leftEdgeOffset  = pos.left - viewportPadding
+      var rightEdgeOffset = pos.left + viewportPadding + actualWidth
+      if (leftEdgeOffset < viewportDimensions.left) { // left overflow
+        delta.left = viewportDimensions.left - leftEdgeOffset
+      } else if (rightEdgeOffset > viewportDimensions.width) { // right overflow
+        delta.left = viewportDimensions.left + viewportDimensions.width - rightEdgeOffset
+      }
+    }
+
+    return delta
   }
 
   Tooltip.prototype.getTitle = function () {
-- 
GitLab