Bug 1266456 - part5: HTMLTooltip setContent() support "auto" width parameter;r=bgrins draft
authorJulian Descottes <jdescottes@mozilla.com>
Thu, 23 Jun 2016 17:30:50 +0200
changeset 381152 3ddfdc6ca484e051c3e2291c62e0af2367d8df3a
parent 381151 3740bcf6a94faae214336f5dc0cdfd1eea0f346e
child 381153 e694a6ba60d0c7f068a84a27b3a5db6882f054bd
push id21409
push userjdescottes@mozilla.com
push dateFri, 24 Jun 2016 14:54:55 +0000
reviewersbgrins
bugs1266456
milestone50.0a1
Bug 1266456 - part5: HTMLTooltip setContent() support "auto" width parameter;r=bgrins The autocomplete popup defines its width by finding the longest label to display and then applying a "width:Xch" to its content, where X is the length of the longest label + 3. In order to support this, the HTMLTooltip setContent() methods allows to use width: "auto" (which also becomes the default value). In this case, the HTMLTooltip show() method will automatically compute the preferred width for the tooltip. It will first calculate the tooltip height, then measure the width of the tooltip for this computed height and use it as the preferred width. MozReview-Commit-ID: KDxZNB3KDdR
devtools/client/shared/test/browser.ini
devtools/client/shared/test/browser_html_tooltip_width-auto.js
devtools/client/shared/widgets/HTMLTooltip.js
devtools/client/themes/tooltips.css
--- a/devtools/client/shared/test/browser.ini
+++ b/devtools/client/shared/test/browser.ini
@@ -117,16 +117,17 @@ skip-if = e10s # Bug 1221911, bug 122228
 [browser_html_tooltip-02.js]
 [browser_html_tooltip-03.js]
 [browser_html_tooltip-04.js]
 [browser_html_tooltip-05.js]
 [browser_html_tooltip_arrow-01.js]
 [browser_html_tooltip_arrow-02.js]
 [browser_html_tooltip_consecutive-show.js]
 [browser_html_tooltip_variable-height.js]
+[browser_html_tooltip_width-auto.js]
 [browser_inplace-editor-01.js]
 [browser_inplace-editor-02.js]
 [browser_inplace-editor_autocomplete_01.js]
 [browser_inplace-editor_autocomplete_02.js]
 [browser_inplace-editor_autocomplete_offset.js]
 [browser_inplace-editor_maxwidth.js]
 [browser_key_shortcuts.js]
 [browser_layoutHelpers.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/test/browser_html_tooltip_width-auto.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+/* import-globals-from helper_html_tooltip.js */
+
+"use strict";
+
+/**
+ * Test the HTMLTooltip content can automatically calculate its width based on content.
+ */
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+const TEST_URI = `data:text/xml;charset=UTF-8,<?xml version="1.0"?>
+  <?xml-stylesheet href="chrome://global/skin/global.css"?>
+  <?xml-stylesheet href="chrome://devtools/skin/tooltips.css"?>
+  <window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+   title="Tooltip test">
+    <vbox flex="1">
+      <hbox id="box1" flex="1">test1</hbox>
+      <hbox id="box2" flex="1">test2</hbox>
+      <hbox id="box3" flex="1">test3</hbox>
+      <hbox id="box4" flex="1">test4</hbox>
+    </vbox>
+  </window>`;
+
+const {HTMLTooltip} = require("devtools/client/shared/widgets/HTMLTooltip");
+loadHelperScript("helper_html_tooltip.js");
+
+add_task(function* () {
+  yield addTab("about:blank");
+  let [,, doc] = yield createHost("bottom", TEST_URI);
+
+  let tooltip = new HTMLTooltip({doc}, {});
+  info("Create tooltip content width to 150px");
+  let tooltipContent = doc.createElementNS(HTML_NS, "div");
+  tooltipContent.style.cssText = "height: 100%; width: 150px; background: red;";
+
+  info("Set tooltip content using width:auto");
+  tooltip.setContent(tooltipContent, {width: "auto", height: 50});
+
+  info("Show the tooltip and check the tooltip panel width.");
+  yield showTooltip(tooltip, doc.getElementById("box1"));
+
+  let panelRect = tooltip.panel.getBoundingClientRect();
+  is(panelRect.width, 150, "Tooltip panel has the expected width.");
+
+  yield hideTooltip(tooltip);
+});
--- a/devtools/client/shared/widgets/HTMLTooltip.js
+++ b/devtools/client/shared/widgets/HTMLTooltip.js
@@ -38,16 +38,138 @@ const EXTRA_HEIGHT = {
 };
 
 const EXTRA_BORDER = {
   "normal": 0,
   "arrow": 3,
 };
 
 /**
+ * Calculate the vertical position & offsets to use for the tooltip. Will attempt to
+ * respect the provided height and position preferences, unless the available height
+ * prevents this.
+ *
+ * @param {DOMRect} anchorRect
+ *        Bounding rectangle for the anchor, relative to the tooltip document.
+ * @param {DOMRect} docRect
+ *        Bounding rectange for the tooltip document owner.
+ * @param {Number} preferredHeight
+ *        Preferred height for the tooltip.
+ * @param {String} pos
+ *        Preferred position for the tooltip. Possible values: "top" or "bottom".
+ * @return {Object}
+ *         - {Number} top: the top offset for the tooltip.
+ *         - {Number} height: the height to use for the tooltip container.
+ *         - {String} computedPosition: Can differ from the preferred position depending
+ *           on the available height). "top" or "bottom"
+ */
+const calculateVerticalPosition = function (anchorRect, docRect, preferredHeight, pos) {
+  let {TOP, BOTTOM} = POSITION;
+
+  let {top: anchorTop, height: anchorHeight} = anchorRect;
+  let {bottom: docBottom} = docRect;
+
+  // Calculate available space for the tooltip.
+  let availableTop = anchorTop;
+  let availableBottom = docBottom - (anchorTop + anchorHeight);
+
+  // Find POSITION
+  let keepPosition = false;
+  if (pos === TOP) {
+    keepPosition = availableTop >= preferredHeight;
+  } else if (pos === BOTTOM) {
+    keepPosition = availableBottom >= preferredHeight;
+  }
+  if (!keepPosition) {
+    pos = availableTop > availableBottom ? TOP : BOTTOM;
+  }
+
+  // Calculate HEIGHT.
+  let availableHeight = pos === TOP ? availableTop : availableBottom;
+  let height = Math.min(preferredHeight, availableHeight);
+  height = Math.floor(height);
+
+  // Calculate TOP.
+  let top = pos === TOP ? anchorTop - height : anchorTop + anchorHeight;
+
+  return {top, height, computedPosition: pos};
+};
+
+/**
+ * Calculate the vertical position & offsets to use for the tooltip. Will attempt to
+ * respect the provided height and position preferences, unless the available height
+ * prevents this.
+ *
+ * @param {DOMRect} anchorRect
+ *        Bounding rectangle for the anchor, relative to the tooltip document.
+ * @param {DOMRect} docRect
+ *        Bounding rectange for the tooltip document owner.
+ * @param {Number} preferredWidth
+ *        Preferred width for the tooltip.
+ * @return {Object}
+ *         - {Number} left: the left offset for the tooltip.
+ *         - {Number} width: the width to use for the tooltip container.
+ *         - {Number} arrowLeft: the left offset to use for the arrow element.
+ */
+const calculateHorizontalPosition = function (anchorRect, docRect, preferredWidth, type) {
+  let {left: anchorLeft, width: anchorWidth} = anchorRect;
+  let {right: docRight} = docRect;
+
+  // Calculate WIDTH.
+  let availableWidth = docRight;
+  let width = Math.min(preferredWidth, availableWidth);
+
+  // Calculate LEFT.
+  // By default the tooltip is aligned with the anchor left edge. Unless this
+  // makes it overflow the viewport, in which case is shifts to the left.
+  let left = Math.min(anchorLeft, docRight - width);
+
+  // Calculate ARROW LEFT (tooltip's LEFT might be updated)
+  let arrowLeft;
+  // Arrow style tooltips may need to be shifted to the left
+  if (type === TYPE.ARROW) {
+    let arrowCenter = left + ARROW_OFFSET + ARROW_WIDTH / 2;
+    let anchorCenter = anchorLeft + anchorWidth / 2;
+    // If the anchor is too narrow, align the arrow and the anchor center.
+    if (arrowCenter > anchorCenter) {
+      left = Math.max(0, left - (arrowCenter - anchorCenter));
+    }
+    // Arrow's left offset relative to the anchor.
+    arrowLeft = Math.min(ARROW_OFFSET, (anchorWidth - ARROW_WIDTH) / 2) | 0;
+    // Translate the coordinate to tooltip container
+    arrowLeft += anchorLeft - left;
+    // Make sure the arrow remains in the tooltip container.
+    arrowLeft = Math.min(arrowLeft, width - ARROW_WIDTH);
+    arrowLeft = Math.max(arrowLeft, 0);
+  }
+
+  return {left, width, arrowLeft};
+};
+
+/**
+ * Get the bounding client rectangle for a given node, relative to a custom
+ * reference element (instead of the default for getBoundingClientRect which
+ * is always the element's ownerDocument).
+ */
+const getRelativeRect = function (node, relativeTo) {
+  // Width and Height can be taken from the rect.
+  let {width, height} = node.getBoundingClientRect();
+
+  let quads = node.getBoxQuads({relativeTo});
+  let top = quads[0].bounds.top;
+  let left = quads[0].bounds.left;
+
+  // Compute right and bottom coordinates using the rest of the data.
+  let right = left + width;
+  let bottom = top + height;
+
+  return {top, right, bottom, left, width, height};
+};
+
+/**
  * The HTMLTooltip can display HTML content in a tooltip popup.
  *
  * @param {Toolbox} toolbox
  *        The devtools toolbox, needed to get the devtools main window.
  * @param {Object}
  *        - {String} type
  *          Display type of the tooltip. Possible values: "normal", "arrow"
  *        - {Boolean} autofocus
@@ -104,27 +226,26 @@ HTMLTooltip.prototype = {
 
   /**
    * Set the tooltip content element. The preferred width/height should also be
    * specified here.
    *
    * @param {Element} content
    *        The tooltip content, should be a HTML element.
    * @param {Object}
-   *        - {Number} width: preferred width for the tooltip container
+   *        - {Number} width: preferred width for the tooltip container. If not specified
+   *          the tooltip container will be measured before being displayed, and the
+   *          measured width will be used as preferred width.
    *        - {Number} height: optional, preferred height for the tooltip container. This
    *          parameter acts as a max-height for the tooltip content. If not specified,
    *          the tooltip will be able to use all the height available.
    */
-  setContent: function (content, {width, height = Infinity}) {
-    let themeHeight = EXTRA_HEIGHT[this.type] + 2 * EXTRA_BORDER[this.type];
-    let themeWidth = 2 * EXTRA_BORDER[this.type];
-
-    this.preferredWidth = width + themeWidth;
-    this.preferredHeight = height + themeHeight;
+  setContent: function (content, {width = "auto", height = Infinity} = {}) {
+    this.preferredWidth = width;
+    this.preferredHeight = height;
 
     this.panel.innerHTML = "";
     this.panel.appendChild(content);
   },
 
   /**
    * Show the tooltip next to the provided anchor element. A preferred position
    * can be set. The event "shown" will be fired after the tooltip is displayed.
@@ -133,44 +254,68 @@ HTMLTooltip.prototype = {
    *        The reference element with which the tooltip should be aligned
    * @param {Object}
    *        - {String} position: optional, possible values: top|bottom
    *          If layout permits, the tooltip will be displayed on top/bottom
    *          of the anchor. If ommitted, the tooltip will be displayed where
    *          more space is available.
    */
   show: function (anchor, {position} = {}) {
-    let computedPosition = this._findBestPosition(anchor, position);
+    // Get anchor geometry
+    let anchorRect = getRelativeRect(anchor, this.doc);
+    // Get document geometry
+    let docRect = this.doc.documentElement.getBoundingClientRect();
 
-    let isTop = computedPosition.position === POSITION.TOP;
+    let themeHeight = EXTRA_HEIGHT[this.type] + 2 * EXTRA_BORDER[this.type];
+    let preferredHeight = this.preferredHeight + themeHeight;
+
+    let {top, height, computedPosition} =
+      calculateVerticalPosition(anchorRect, docRect, preferredHeight, position);
+
+    // Apply height and top information before measuring the content width (if "auto").
+    let isTop = computedPosition === POSITION.TOP;
     this.container.classList.toggle("tooltip-top", isTop);
     this.container.classList.toggle("tooltip-bottom", !isTop);
+    this.container.style.height = height + "px";
+    this.container.style.top = top + "px";
 
-    this.container.style.width = computedPosition.width + "px";
-    this.container.style.height = computedPosition.height + "px";
-    this.container.style.top = computedPosition.top + "px";
-    this.container.style.left = computedPosition.left + "px";
+    let themeWidth = 2 * EXTRA_BORDER[this.type];
+    let preferredWidth = this.preferredWidth === "auto" ?
+      this._measureContainerWidth() : this.preferredWidth + themeWidth;
+
+    let {left, width, arrowLeft} =
+      calculateHorizontalPosition(anchorRect, docRect, preferredWidth, this.type);
+
+    this.container.style.width = width + "px";
+    this.container.style.left = left + "px";
 
     if (this.type === TYPE.ARROW) {
-      this.arrow.style.left = computedPosition.arrowLeft + "px";
+      this.arrow.style.left = arrowLeft + "px";
     }
 
     this.container.classList.add("tooltip-visible");
 
     // Keep a pointer on the focused element to refocus it when hiding the tooltip.
     this._focusedElement = this.doc.activeElement;
 
     this.doc.defaultView.clearTimeout(this.attachEventsTimer);
     this.attachEventsTimer = this.doc.defaultView.setTimeout(() => {
       this._maybeFocusTooltip();
       this.topWindow.addEventListener("click", this._onClick, true);
       this.emit("shown");
     }, 0);
   },
 
+  _measureContainerWidth: function () {
+    this.container.classList.add("tooltip-hidden");
+    let width = this.container.getBoundingClientRect().width;
+    this.container.classList.remove("tooltip-hidden");
+    return width;
+  },
+
   /**
    * Hide the current tooltip. The event "hidden" will be fired when the tooltip
    * is hidden.
    */
   hide: function () {
     this.doc.defaultView.clearTimeout(this.attachEventsTimer);
     if (!this.isVisible()) {
       return;
@@ -254,112 +399,16 @@ HTMLTooltip.prototype = {
       }
       win = win.parent;
     }
 
     return false;
   },
 
   /**
-   * Calculates the best possible position to display the tooltip near the
-   * provided anchor. An optional position can be provided, but will be
-   * respected only if it doesn't force the tooltip to be resized.
-   *
-   * If the tooltip has to be resized, the position will be wherever the most
-   * space is available.
-   *
-   */
-  _findBestPosition: function (anchor, position) {
-    let {TOP, BOTTOM} = POSITION;
-
-    // Get anchor geometry
-    let {
-      left: anchorLeft, top: anchorTop,
-      height: anchorHeight, width: anchorWidth
-    } = this._getRelativeRect(anchor, this.doc);
-
-    // Get document geometry
-    let {bottom: docBottom, right: docRight} =
-      this.doc.documentElement.getBoundingClientRect();
-
-    // Calculate available space for the tooltip.
-    let availableTop = anchorTop;
-    let availableBottom = docBottom - (anchorTop + anchorHeight);
-
-    // Find POSITION
-    let keepPosition = false;
-    if (position === TOP) {
-      keepPosition = availableTop >= this.preferredHeight;
-    } else if (position === BOTTOM) {
-      keepPosition = availableBottom >= this.preferredHeight;
-    }
-    if (!keepPosition) {
-      position = availableTop > availableBottom ? TOP : BOTTOM;
-    }
-
-    // Calculate HEIGHT.
-    let availableHeight = position === TOP ? availableTop : availableBottom;
-    let height = Math.min(this.preferredHeight, availableHeight);
-    height = Math.floor(height);
-
-    // Calculate TOP.
-    let top = position === TOP ? anchorTop - height : anchorTop + anchorHeight;
-
-    // Calculate WIDTH.
-    let availableWidth = docRight;
-    let width = Math.min(this.preferredWidth, availableWidth);
-
-    // Calculate LEFT.
-    // By default the tooltip is aligned with the anchor left edge. Unless this
-    // makes it overflow the viewport, in which case is shifts to the left.
-    let left = Math.min(anchorLeft, docRight - width);
-
-    // Calculate ARROW LEFT (tooltip's LEFT might be updated)
-    let arrowLeft;
-    // Arrow style tooltips may need to be shifted to the left
-    if (this.type === TYPE.ARROW) {
-      let arrowCenter = left + ARROW_OFFSET + ARROW_WIDTH / 2;
-      let anchorCenter = anchorLeft + anchorWidth / 2;
-      // If the anchor is too narrow, align the arrow and the anchor center.
-      if (arrowCenter > anchorCenter) {
-        left = Math.max(0, left - (arrowCenter - anchorCenter));
-      }
-      // Arrow's left offset relative to the anchor.
-      arrowLeft = Math.min(ARROW_OFFSET, (anchorWidth - ARROW_WIDTH) / 2) | 0;
-      // Translate the coordinate to tooltip container
-      arrowLeft += anchorLeft - left;
-      // Make sure the arrow remains in the tooltip container.
-      arrowLeft = Math.min(arrowLeft, width - ARROW_WIDTH);
-      arrowLeft = Math.max(arrowLeft, 0);
-    }
-
-    return {top, left, width, height, position, arrowLeft};
-  },
-
-  /**
-   * Get the bounding client rectangle for a given node, relative to a custom
-   * reference element (instead of the default for getBoundingClientRect which
-   * is always the element's ownerDocument).
-   */
-  _getRelativeRect: function (node, relativeTo) {
-    // Width and Height can be taken from the rect.
-    let {width, height} = node.getBoundingClientRect();
-
-    let quads = node.getBoxQuads({relativeTo});
-    let top = quads[0].bounds.top;
-    let left = quads[0].bounds.left;
-
-    // Compute right and bottom coordinates using the rest of the data.
-    let right = left + width;
-    let bottom = top + height;
-
-    return {top, right, bottom, left, width, height};
-  },
-
-  /**
    * Check if the tooltip's owner document is a XUL document.
    */
   _isXUL: function () {
     return this.doc.documentElement.namespaceURI === XUL_NS;
   },
 
   /**
    * If the tootlip is configured to autofocus and a focusable element can be found,
--- a/devtools/client/themes/tooltips.css
+++ b/devtools/client/themes/tooltips.css
@@ -121,16 +121,21 @@
   background-color: var(--theme-tooltip-background);
   pointer-events: all;
 }
 
 .tooltip-visible {
   display: flex;
 }
 
+.tooltip-hidden {
+  display: flex;
+  visibility: hidden;
+}
+
 /* Tooltip : arrow style */
 
 .tooltip-container[type="arrow"] {
   filter: drop-shadow(0 3px 4px var(--theme-tooltip-shadow));
 }
 
 .tooltip-container[type="arrow"] > .tooltip-panel {
   position: relative;