Bug 1259834 - Create basic HTML tooltip API;r=bgrins
authorJulian Descottes <jdescottes@mozilla.com>
Wed, 04 May 2016 14:44:57 +0200
changeset 321083 ea89f71fc38e4f4627b3e3075159cddc31aae645
parent 321082 4dd60a8b9e2b229e8551c08ad4302433081ecced
child 321084 5d2a315b876ed33333450866c17a3214c83fc452
push id9671
push userraliiev@mozilla.com
push dateMon, 06 Jun 2016 20:27:52 +0000
treeherdermozilla-aurora@cea65ca3d0bd [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbgrins
bugs1259834
milestone49.0a1
Bug 1259834 - Create basic HTML tooltip API;r=bgrins First implementation of HTML based tooltip to be used in devtools instead of XUL panels. API is similar to the current API of Tooltip.js MozReview-Commit-ID: 8njiKBubLSj
devtools/client/framework/test/shared-head.js
devtools/client/jar.mn
devtools/client/shared/test/browser.ini
devtools/client/shared/test/browser_html_tooltip-01.js
devtools/client/shared/test/browser_html_tooltip-02.js
devtools/client/shared/test/browser_html_tooltip-03.js
devtools/client/shared/test/browser_html_tooltip-04.js
devtools/client/shared/test/browser_html_tooltip-05.js
devtools/client/shared/test/helper_html_tooltip.js
devtools/client/shared/widgets/HTMLTooltip.js
devtools/client/shared/widgets/moz.build
devtools/client/shared/widgets/tooltip-frame.xhtml
devtools/client/themes/common.css
--- a/devtools/client/framework/test/shared-head.js
+++ b/devtools/client/framework/test/shared-head.js
@@ -416,8 +416,26 @@ function waitForContextMenu(popup, butto
 
   info("wait for the context menu to open");
   button.scrollIntoView();
   let eventDetails = {type: "contextmenu", button: 2};
   EventUtils.synthesizeMouse(button, 5, 2, eventDetails,
                              button.ownerDocument.defaultView);
   return deferred.promise;
 }
+
+/**
+ * Simple helper to push a temporary preference. Wrapper on SpecialPowers
+ * pushPrefEnv that returns a promise resolving when the preferences have been
+ * updated.
+ *
+ * @param {String} preferenceName
+ *        The name of the preference to updated
+ * @param {} value
+ *        The preference value, type can vary
+ * @return {Promise} resolves when the preferences have been updated
+ */
+function pushPref(preferenceName, value) {
+  return new Promise(resolve => {
+    let options = {"set": [[preferenceName, value]]};
+    SpecialPowers.pushPrefEnv(options, resolve);
+  });
+}
--- a/devtools/client/jar.mn
+++ b/devtools/client/jar.mn
@@ -124,16 +124,17 @@ devtools.jar:
     content/inspector/inspector.css (inspector/inspector.css)
     content/framework/connect/connect.xhtml (framework/connect/connect.xhtml)
     content/framework/connect/connect.css (framework/connect/connect.css)
     content/framework/connect/connect.js (framework/connect/connect.js)
     content/shared/widgets/graphs-frame.xhtml (shared/widgets/graphs-frame.xhtml)
     content/shared/widgets/spectrum-frame.xhtml (shared/widgets/spectrum-frame.xhtml)
     content/shared/widgets/spectrum.css (shared/widgets/spectrum.css)
     content/shared/widgets/cubic-bezier-frame.xhtml (shared/widgets/cubic-bezier-frame.xhtml)
+    content/shared/widgets/tooltip-frame.xhtml (shared/widgets/tooltip-frame.xhtml)
     content/shared/widgets/cubic-bezier.css (shared/widgets/cubic-bezier.css)
     content/shared/widgets/mdn-docs-frame.xhtml (shared/widgets/mdn-docs-frame.xhtml)
     content/shared/widgets/mdn-docs.css (shared/widgets/mdn-docs.css)
     content/shared/widgets/filter-frame.xhtml (shared/widgets/filter-frame.xhtml)
     content/shared/widgets/filter-widget.css (shared/widgets/filter-widget.css)
     content/eyedropper/eyedropper.xul (eyedropper/eyedropper.xul)
     content/eyedropper/crosshairs.css (eyedropper/crosshairs.css)
     content/eyedropper/nocursor.css (eyedropper/nocursor.css)
--- a/devtools/client/shared/test/browser.ini
+++ b/devtools/client/shared/test/browser.ini
@@ -5,16 +5,17 @@ support-files =
   browser_layoutHelpers.html
   browser_layoutHelpers-getBoxQuads.html
   browser_templater_basic.html
   browser_toolbar_basic.html
   browser_toolbar_webconsole_errors_count.html
   browser_devices.json
   doc_options-view.xul
   head.js
+  helper_html_tooltip.js
   html-mdn-css-basic-testing.html
   html-mdn-css-no-summary.html
   html-mdn-css-no-summary-or-syntax.html
   html-mdn-css-no-syntax.html
   html-mdn-css-syntax-old-style.html
   leakhunt.js
   test-actor.js
   test-actor-registry.js
@@ -106,16 +107,21 @@ skip-if = e10s # Bug 1221911, bug 122228
 [browser_graphs-13.js]
 skip-if = e10s # Bug 1221911, bug 1222289, frequent e10s timeouts
 [browser_graphs-14.js]
 skip-if = e10s # Bug 1221911, bug 1222289, frequent e10s timeouts
 [browser_graphs-15.js]
 skip-if = e10s # Bug 1221911, bug 1222289, frequent e10s timeouts
 [browser_graphs-16.js]
 skip-if = e10s # Bug 1221911, bug 1222289, frequent e10s timeouts
+[browser_html_tooltip-01.js]
+[browser_html_tooltip-02.js]
+[browser_html_tooltip-03.js]
+[browser_html_tooltip-04.js]
+[browser_html_tooltip-05.js]
 [browser_inplace-editor-01.js]
 [browser_inplace-editor-02.js]
 [browser_inplace-editor_maxwidth.js]
 [browser_layoutHelpers.js]
 skip-if = e10s # Layouthelpers test should not run in a content page.
 [browser_layoutHelpers-getBoxQuads.js]
 skip-if = e10s # Layouthelpers test should not run in a content page.
 [browser_mdn-docs-01.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/test/browser_html_tooltip-01.js
@@ -0,0 +1,71 @@
+/* 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 show & hide methods.
+ */
+
+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/common.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");
+
+function getTooltipContent(doc) {
+  let div = doc.createElementNS(HTML_NS, "div");
+  div.style.height = "50px";
+  div.style.boxSizing = "border-box";
+  div.textContent = "tooltip";
+  return div;
+}
+
+add_task(function* () {
+  yield addTab("about:blank");
+  let [,, doc] = yield createHost("bottom", TEST_URI);
+
+  let tooltip = new HTMLTooltip({doc}, {});
+
+  info("Set tooltip content");
+  yield tooltip.setContent(getTooltipContent(doc), 100, 50);
+
+  is(tooltip.isVisible(), false, "Tooltip is not visible");
+
+  info("Show the tooltip and check the expected events are fired.");
+
+  let shown = 0;
+  tooltip.on("shown", () => shown++);
+
+  let onShown = tooltip.once("shown");
+  tooltip.show(doc.getElementById("box1"));
+
+  yield onShown;
+  is(shown, 1, "Event shown was fired once");
+
+  is(tooltip.isVisible(), true, "Tooltip is visible");
+
+  info("Hide the tooltip and check the expected events are fired.");
+
+  let hidden = 0;
+  tooltip.on("hidden", () => hidden++);
+
+  let onPopupHidden = tooltip.once("hidden");
+  tooltip.hide();
+
+  yield onPopupHidden;
+  is(hidden, 1, "Event hidden was fired once");
+
+  is(tooltip.isVisible(), false, "Tooltip is not visible");
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/test/browser_html_tooltip-02.js
@@ -0,0 +1,98 @@
+/* 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 is closed when clicking outside of its container.
+ */
+
+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"?>
+  <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);
+
+  yield testTooltipNotClosingOnInsideClick(doc);
+  yield testConsumeOutsideClicksFalse(doc);
+  yield testConsumeOutsideClicksTrue(doc);
+});
+
+function* testTooltipNotClosingOnInsideClick(doc) {
+  info("Test a tooltip is not closed when clicking inside itself");
+
+  let tooltip = new HTMLTooltip({doc}, {});
+  yield tooltip.setContent(getTooltipContent(doc), 100, 50);
+  yield showTooltip(tooltip, doc.getElementById("box1"));
+
+  let onTooltipContainerClick = once(tooltip.container, "click");
+  EventUtils.synthesizeMouseAtCenter(tooltip.container, {}, doc.defaultView);
+  yield onTooltipContainerClick;
+  is(tooltip.isVisible(), true, "Tooltip is still visible");
+
+  tooltip.destroy();
+}
+
+function* testConsumeOutsideClicksFalse(doc) {
+  info("Test closing a tooltip via click with consumeOutsideClicks: false");
+  let box4 = doc.getElementById("box4");
+
+  let tooltip = new HTMLTooltip({doc}, {consumeOutsideClicks: false});
+  yield tooltip.setContent(getTooltipContent(doc), 100, 50);
+  yield showTooltip(tooltip, doc.getElementById("box1"));
+
+  let onBox4Clicked = once(box4, "click");
+  let onHidden = once(tooltip, "hidden");
+  EventUtils.synthesizeMouseAtCenter(box4, {}, doc.defaultView);
+  yield onHidden;
+  yield onBox4Clicked;
+
+  is(tooltip.isVisible(), false, "Tooltip is hidden");
+
+  tooltip.destroy();
+}
+
+function* testConsumeOutsideClicksTrue(doc) {
+  info("Test closing a tooltip via click with consumeOutsideClicks: true");
+  let box4 = doc.getElementById("box4");
+
+  // Count clicks on box4
+  let box4clicks = 0;
+  box4.addEventListener("click", () => box4clicks++);
+
+  let tooltip = new HTMLTooltip({doc}, {consumeOutsideClicks: true});
+  yield tooltip.setContent(getTooltipContent(doc), 100, 50);
+  yield showTooltip(tooltip, doc.getElementById("box1"));
+
+  let onHidden = once(tooltip, "hidden");
+  EventUtils.synthesizeMouseAtCenter(box4, {}, doc.defaultView);
+  yield onHidden;
+
+  is(box4clicks, 0, "box4 catched no click event");
+  is(tooltip.isVisible(), false, "Tooltip is hidden");
+
+  tooltip.destroy();
+}
+
+function getTooltipContent(doc) {
+  let div = doc.createElementNS(HTML_NS, "div");
+  div.style.height = "50px";
+  div.style.boxSizing = "border-box";
+  div.textContent = "tooltip";
+  return div;
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/test/browser_html_tooltip-03.js
@@ -0,0 +1,93 @@
+/* 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 autofocus configuration option.
+ */
+
+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"?>
+  <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">
+        <textbox id="box4-input"></textbox>
+      </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);
+
+  yield testTooltipWithAutoFocus(doc);
+  yield testTooltipWithoutAutoFocus(doc);
+});
+
+function* testTooltipWithAutoFocus(doc) {
+  info("Test a tooltip with autofocus takes focus when displayed");
+  let textbox = doc.querySelector("textbox");
+
+  info("Focus a XUL textbox");
+  let onInputFocus = once(textbox, "focus");
+  EventUtils.synthesizeMouseAtCenter(textbox, {}, doc.defaultView);
+  yield onInputFocus;
+
+  is(getFocusedDocument(doc), doc, "Focus is in the XUL document");
+
+  let tooltip = new HTMLTooltip({doc}, {autofocus: true});
+  let tooltipNode = getTooltipContent(doc);
+  yield tooltip.setContent(tooltipNode, 150, 50);
+
+  yield showTooltip(tooltip, doc.getElementById("box1"));
+  is(getFocusedDocument(doc), tooltipNode.ownerDocument,
+    "Focus is in the tooltip document");
+
+  yield hideTooltip(tooltip);
+}
+
+function* testTooltipWithoutAutoFocus(doc) {
+  info("Test a tooltip can be closed by clicking outside");
+  let textbox = doc.querySelector("textbox");
+
+  info("Focus a XUL textbox");
+  let onInputFocus = once(textbox, "focus");
+  EventUtils.synthesizeMouseAtCenter(textbox, {}, doc.defaultView);
+  yield onInputFocus;
+
+  is(getFocusedDocument(doc), doc, "Focus is in the XUL document");
+
+  let tooltip = new HTMLTooltip({doc}, {autofocus: false});
+  let tooltipNode = getTooltipContent(doc);
+  yield tooltip.setContent(tooltipNode, 150, 50);
+
+  yield showTooltip(tooltip, doc.getElementById("box1"));
+  is(getFocusedDocument(doc), doc, "Focus is still in the XUL document");
+
+  yield hideTooltip(tooltip);
+}
+
+function getFocusedDocument(doc) {
+  let activeElement = doc.activeElement;
+  while (activeElement && activeElement.contentDocument) {
+    activeElement = activeElement.contentDocument.activeElement;
+  }
+  return activeElement.ownerDocument;
+}
+
+function getTooltipContent(doc) {
+  let div = doc.createElementNS(HTML_NS, "div");
+  div.style.height = "50px";
+  div.style.boxSizing = "border-box";
+  return div;
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/test/browser_html_tooltip-04.js
@@ -0,0 +1,108 @@
+/* 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 positioning for a small tooltip element (should aways
+ * find a way to fit).
+ */
+
+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/common.css"?>
+  <window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+   title="Tooltip test">
+    <vbox flex="1">
+      <hbox style="height: 10px">spacer</hbox>
+      <hbox id="box1" style="height: 50px">test1</hbox>
+      <hbox id="box2" style="height: 50px">test2</hbox>
+      <hbox flex="1">MIDDLE</hbox>
+      <hbox id="box3" style="height: 50px">test3</hbox>
+      <hbox id="box4" style="height: 50px">test4</hbox>
+      <hbox style="height: 10px">spacer</hbox>
+    </vbox>
+  </window>`;
+
+const {HTMLTooltip} = require("devtools/client/shared/widgets/HTMLTooltip");
+loadHelperScript("helper_html_tooltip.js");
+
+const TOOLTIP_HEIGHT = 30;
+const TOOLTIP_WIDTH = 100;
+
+add_task(function* () {
+  // Force the toolbox to be 400px high;
+  yield pushPref("devtools.toolbox.footer.height", 400);
+
+  yield addTab("about:blank");
+  let [,, doc] = yield createHost("bottom", TEST_URI);
+
+  info("Create HTML tooltip");
+  let tooltip = new HTMLTooltip({doc}, {});
+  let div = doc.createElementNS(HTML_NS, "div");
+  div.style.height = "100%";
+  yield tooltip.setContent(div, TOOLTIP_WIDTH, TOOLTIP_HEIGHT);
+
+  let box1 = doc.getElementById("box1");
+  let box2 = doc.getElementById("box2");
+  let box3 = doc.getElementById("box3");
+  let box4 = doc.getElementById("box4");
+  let height = TOOLTIP_HEIGHT, width = TOOLTIP_WIDTH;
+
+  // box1: Can only fit below box1
+  info("Display the tooltip on box1.");
+  yield showTooltip(tooltip, box1);
+  let expectedTooltipGeometry = {position: "bottom", height, width};
+  checkTooltipGeometry(tooltip, box1, expectedTooltipGeometry);
+  yield hideTooltip(tooltip);
+
+  info("Try to display the tooltip on top of box1.");
+  yield showTooltip(tooltip, box1, "top");
+  expectedTooltipGeometry = {position: "bottom", height, width};
+  checkTooltipGeometry(tooltip, box1, expectedTooltipGeometry);
+  yield hideTooltip(tooltip);
+
+  // box2: Can fit above or below, will default to bottom, more height
+  // available.
+  info("Try to display the tooltip on box2.");
+  yield showTooltip(tooltip, box2);
+  expectedTooltipGeometry = {position: "bottom", height, width};
+  checkTooltipGeometry(tooltip, box2, expectedTooltipGeometry);
+  yield hideTooltip(tooltip);
+
+  info("Try to display the tooltip on top of box2.");
+  yield showTooltip(tooltip, box2, "top");
+  expectedTooltipGeometry = {position: "top", height, width};
+  checkTooltipGeometry(tooltip, box2, expectedTooltipGeometry);
+  yield hideTooltip(tooltip);
+
+  // box3: Can fit above or below, will default to top, more height available.
+  info("Try to display the tooltip on box3.");
+  yield showTooltip(tooltip, box3);
+  expectedTooltipGeometry = {position: "top", height, width};
+  checkTooltipGeometry(tooltip, box3, expectedTooltipGeometry);
+  yield hideTooltip(tooltip);
+
+  info("Try to display the tooltip on bottom of box3.");
+  yield showTooltip(tooltip, box3, "bottom");
+  expectedTooltipGeometry = {position: "bottom", height, width};
+  checkTooltipGeometry(tooltip, box3, expectedTooltipGeometry);
+  yield hideTooltip(tooltip);
+
+  // box4: Can only fit above box4
+  info("Display the tooltip on box4.");
+  yield showTooltip(tooltip, box4);
+  expectedTooltipGeometry = {position: "top", height, width};
+  checkTooltipGeometry(tooltip, box4, expectedTooltipGeometry);
+  yield hideTooltip(tooltip);
+
+  info("Try to display the tooltip on bottom of box4.");
+  yield showTooltip(tooltip, box4, "bottom");
+  expectedTooltipGeometry = {position: "top", height, width};
+  checkTooltipGeometry(tooltip, box4, expectedTooltipGeometry);
+  yield hideTooltip(tooltip);
+
+  is(tooltip.isVisible(), false, "Tooltip is not visible");
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/test/browser_html_tooltip-05.js
@@ -0,0 +1,108 @@
+/* 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 positioning for a huge tooltip element (can not fit in
+ * the viewport).
+ */
+
+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/common.css"?>
+  <window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+   title="Tooltip test">
+    <vbox flex="1">
+      <hbox id="box1" style="height: 50px">test1</hbox>
+      <hbox id="box2" style="height: 50px">test2</hbox>
+      <hbox id="box3" style="height: 50px">test3</hbox>
+      <hbox id="box4" style="height: 50px">test4</hbox>
+    </vbox>
+  </window>`;
+
+const {HTMLTooltip} = require("devtools/client/shared/widgets/HTMLTooltip");
+loadHelperScript("helper_html_tooltip.js");
+
+const TOOLTIP_HEIGHT = 200;
+const TOOLTIP_WIDTH = 200;
+
+add_task(function* () {
+  // Force the toolbox to be 200px high;
+  yield pushPref("devtools.toolbox.footer.height", 200);
+
+  yield addTab("about:blank");
+  let [,, doc] = yield createHost("bottom", TEST_URI);
+
+  info("Create HTML tooltip");
+  let tooltip = new HTMLTooltip({doc}, {});
+  let div = doc.createElementNS(HTML_NS, "div");
+  div.style.height = "100%";
+  yield tooltip.setContent(div, TOOLTIP_WIDTH, TOOLTIP_HEIGHT);
+
+  let box1 = doc.getElementById("box1");
+  let box2 = doc.getElementById("box2");
+  let box3 = doc.getElementById("box3");
+  let box4 = doc.getElementById("box4");
+  let width = TOOLTIP_WIDTH;
+
+  // box1: Can not fit above or below box1, default to bottom with a reduced
+  // height of 150px.
+  info("Display the tooltip on box1.");
+  yield showTooltip(tooltip, box1);
+  let expectedTooltipGeometry = {position: "bottom", height: 150, width};
+  checkTooltipGeometry(tooltip, box1, expectedTooltipGeometry);
+  yield hideTooltip(tooltip);
+
+  info("Try to display the tooltip on top of box1.");
+  yield showTooltip(tooltip, box1, "top");
+  expectedTooltipGeometry = {position: "bottom", height: 150, width};
+  checkTooltipGeometry(tooltip, box1, expectedTooltipGeometry);
+  yield hideTooltip(tooltip);
+
+  // box2: Can not fit above or below box2, default to bottom with a reduced
+  // height of 100px.
+  info("Try to display the tooltip on box2.");
+  yield showTooltip(tooltip, box2);
+  expectedTooltipGeometry = {position: "bottom", height: 100, width};
+  checkTooltipGeometry(tooltip, box2, expectedTooltipGeometry);
+  yield hideTooltip(tooltip);
+
+  info("Try to display the tooltip on top of box2.");
+  yield showTooltip(tooltip, box2, "top");
+  expectedTooltipGeometry = {position: "bottom", height: 100, width};
+  checkTooltipGeometry(tooltip, box2, expectedTooltipGeometry);
+  yield hideTooltip(tooltip);
+
+  // box3: Can not fit above or below box3, default to top with a reduced height
+  // of 100px.
+  info("Try to display the tooltip on box3.");
+  yield showTooltip(tooltip, box3);
+  expectedTooltipGeometry = {position: "top", height: 100, width};
+  checkTooltipGeometry(tooltip, box3, expectedTooltipGeometry);
+  yield hideTooltip(tooltip);
+
+  info("Try to display the tooltip on bottom of box3.");
+  yield showTooltip(tooltip, box3, "bottom");
+  expectedTooltipGeometry = {position: "top", height: 100, width};
+  checkTooltipGeometry(tooltip, box3, expectedTooltipGeometry);
+  yield hideTooltip(tooltip);
+
+  // box4: Can not fit above or below box4, default to top with a reduced height
+  // of 150px.
+  info("Display the tooltip on box4.");
+  yield showTooltip(tooltip, box4);
+  expectedTooltipGeometry = {position: "top", height: 150, width};
+  checkTooltipGeometry(tooltip, box4, expectedTooltipGeometry);
+  yield hideTooltip(tooltip);
+
+  info("Try to display the tooltip on bottom of box4.");
+  yield showTooltip(tooltip, box4, "bottom");
+  expectedTooltipGeometry = {position: "top", height: 150, width};
+  checkTooltipGeometry(tooltip, box4, expectedTooltipGeometry);
+  yield hideTooltip(tooltip);
+
+  is(tooltip.isVisible(), false, "Tooltip is not visible");
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/test/helper_html_tooltip.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint no-unused-vars: [2, {"vars": "local", "args": "none"}] */
+
+"use strict";
+
+/**
+ * Helper methods for the HTMLTooltip integration tests.
+ */
+
+/**
+ * Display an existing HTMLTooltip on an anchor. Returns a promise that will
+ * resolve when the tooltip "shown" event has been fired.
+ *
+ * @param {HTMLTooltip} tooltip
+ *        The tooltip instance to display
+ * @param {Node} anchor
+ *        The anchor that should be used to display the tooltip
+ * @param {String} position
+ *        The preferred display position ("top", "bottom")
+ * @return {Promise} promise that resolves when the "shown" event is fired
+ */
+function showTooltip(tooltip, anchor, position) {
+  let onShown = tooltip.once("shown");
+  tooltip.show(anchor, {position});
+  return onShown;
+}
+
+/**
+ * Hide an existing HTMLTooltip. Returns a promise that will resolve when the
+ * tooltip "hidden" event has been fired.
+ *
+ * @param {HTMLTooltip} tooltip
+ *        The tooltip instance to hide
+ * @return {Promise} promise that resolves when the "hidden" event is fired
+ */
+function hideTooltip(tooltip) {
+  let onPopupHidden = tooltip.once("hidden");
+  tooltip.hide();
+  return onPopupHidden;
+}
+
+/**
+ * Test helper designed to check that a tooltip is displayed at the expected
+ * position relative to an anchor, given a set of expectations.
+ *
+ * @param {HTMLTooltip} tooltip
+ *        The HTMLTooltip instance to check
+ * @param {Node} anchor
+ *        The tooltip's anchor
+ * @param {Object} expected
+ *        - {String} position : "top" or "bottom"
+ *        - {Boolean} leftAligned
+ *        - {Number} width: expected tooltip width
+ *        - {Number} height: expected tooltip height
+ */
+function checkTooltipGeometry(tooltip, anchor,
+    {position, leftAligned = true, height, width} = {}) {
+  info("Check the tooltip geometry matches expected position and dimensions");
+  let tooltipRect = tooltip.container.getBoundingClientRect();
+  let anchorRect = anchor.getBoundingClientRect();
+
+  if (position === "top") {
+    is(tooltipRect.bottom, anchorRect.top, "Tooltip is above the anchor");
+  } else if (position === "bottom") {
+    is(tooltipRect.top, anchorRect.bottom, "Tooltip is below the anchor");
+  } else {
+    ok(false, "Invalid position provided to checkTooltipGeometry");
+  }
+
+  if (leftAligned) {
+    is(tooltipRect.left, anchorRect.left,
+      "Tooltip left-aligned with the anchor");
+  }
+
+  is(tooltipRect.height, height, "Tooltip has the expected height");
+  is(tooltipRect.width, width, "Tooltip has the expected width");
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/widgets/HTMLTooltip.js
@@ -0,0 +1,295 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EventEmitter = require("devtools/shared/event-emitter");
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+const IFRAME_URL = "chrome://devtools/content/shared/widgets/tooltip-frame.xhtml";
+const IFRAME_CONTAINER_ID = "tooltip-iframe-container";
+
+/**
+ * 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"
+ *        - {Boolean} autofocus
+ *          Defaults to true. Should the tooltip be focused when opening it.
+ *        - {Boolean} consumeOutsideClicks
+ *          Defaults to true. The tooltip is closed when clicking outside.
+ *          Should this event be stopped and consumed or not.
+ */
+function HTMLTooltip(toolbox,
+  {type = "normal", autofocus = true, consumeOutsideClicks = true} = {}) {
+  EventEmitter.decorate(this);
+
+  this.document = toolbox.doc;
+  this.type = type;
+  this.autofocus = autofocus;
+  this.consumeOutsideClicks = consumeOutsideClicks;
+
+  // Use the topmost window to listen for click events to close the tooltip
+  this.topWindow = this.document.defaultView.top;
+
+  this._onClick = this._onClick.bind(this);
+
+  this.container = this._createContainer();
+
+  // Promise that will resolve when the container can be filled with content.
+  this.containerReady = new Promise(resolve => {
+    if (this._isXUL()) {
+      // In XUL context, load a placeholder document in the iframe container.
+      let onLoad = () => {
+        this.container.removeEventListener("load", onLoad, true);
+        resolve();
+      };
+
+      this.container.addEventListener("load", onLoad, true);
+      this.container.setAttribute("src", IFRAME_URL);
+    } else {
+      // In non-XUL context the container is ready to use as is.
+      resolve();
+    }
+  });
+}
+
+module.exports.HTMLTooltip = HTMLTooltip;
+
+HTMLTooltip.prototype = {
+  position: {
+    TOP: "top",
+    BOTTOM: "bottom",
+  },
+
+  get parent() {
+    if (this._isXUL()) {
+      // In XUL context, we are wrapping the HTML content in an iframe.
+      let win = this.container.contentWindow.wrappedJSObject;
+      return win.document.getElementById(IFRAME_CONTAINER_ID);
+    }
+    return this.container;
+  },
+
+  /**
+   * 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 {Number} width
+   *        Preferred width for the tooltip container
+   * @param {Number} height
+   *        Preferred height for the tooltip container
+   * @return {Promise} a promise that will resolve when the content has been
+   *         added in the tooltip container.
+   */
+  setContent: function (content, width, height) {
+    this.preferredWidth = width;
+    this.preferredHeight = height;
+
+    return this.containerReady.then(() => {
+      this.parent.innerHTML = "";
+      this.parent.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.
+   *
+   * @param {Element} anchor
+   *        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} = {}) {
+    this.containerReady.then(() => {
+      let {top, left, width, height} = this._findBestPosition(anchor, position);
+
+      if (this._isXUL()) {
+        this.container.setAttribute("width", width);
+        this.container.setAttribute("height", height);
+      } else {
+        this.container.style.width = width + "px";
+        this.container.style.height = height + "px";
+      }
+
+      this.container.style.top = top + "px";
+      this.container.style.left = left + "px";
+      this.container.style.display = "block";
+
+      if (this.autofocus) {
+        this.container.focus();
+      }
+
+      this.attachEventsTimer = this.document.defaultView.setTimeout(() => {
+        this.topWindow.addEventListener("click", this._onClick, true);
+        this.emit("shown");
+      }, 0);
+    });
+  },
+
+  /**
+   * Hide the current tooltip. The event "hidden" will be fired when the tooltip
+   * is hidden.
+   */
+  hide: function () {
+    this.document.defaultView.clearTimeout(this.attachEventsTimer);
+
+    if (this.isVisible()) {
+      this.topWindow.removeEventListener("click", this._onClick, true);
+      this.container.style.display = "none";
+      this.emit("hidden");
+    }
+  },
+
+  /**
+   * Check if the tooltip is currently displayed.
+   * @return {Boolean} true if the tooltip is visible
+   */
+  isVisible: function () {
+    let win = this.document.defaultView;
+    return win.getComputedStyle(this.container).display != "none";
+  },
+
+  /**
+   * Destroy the tooltip instance. Hide the tooltip if displayed, remove the
+   * tooltip container from the document.
+   */
+  destroy: function () {
+    this.hide();
+    this.container.remove();
+  },
+
+  _createContainer: function () {
+    let container;
+    if (this._isXUL()) {
+      container = this.document.createElementNS(XHTML_NS, "iframe");
+      container.classList.add("devtools-tooltip-iframe");
+      this.document.querySelector("window").appendChild(container);
+    } else {
+      container = this.document.createElementNS(XHTML_NS, "div");
+      this.document.body.appendChild(container);
+    }
+
+    container.classList.add("theme-body");
+    container.classList.add("devtools-htmltooltip-container");
+
+    return container;
+  },
+
+  _onClick: function (e) {
+    if (this._isInTooltipContainer(e.target)) {
+      return;
+    }
+
+    this.hide();
+    if (this.consumeOutsideClicks) {
+      e.preventDefault();
+      e.stopPropagation();
+    }
+  },
+
+  _isInTooltipContainer: function (node) {
+    let contentWindow = this.parent.ownerDocument.defaultView;
+    let win = node.ownerDocument.defaultView;
+
+    if (win === contentWindow) {
+      // If node is in the same window as the tooltip, check if the tooltip
+      // parent contains node.
+      return this.parent.contains(node);
+    }
+
+    // Otherwise check if the node window is in the tooltip window.
+    while (win.parent && win.parent != win) {
+      win = win.parent;
+      if (win === contentWindow) {
+        return true;
+      }
+    }
+    return false;
+  },
+
+  _findBestPosition: function (anchor, position) {
+    let top, left;
+    let {TOP, BOTTOM} = this.position;
+
+    let {left: anchorLeft, top: anchorTop, height: anchorHeight}
+      = this._getRelativeRect(anchor, this.document);
+
+    let {bottom: docBottom, right: docRight} =
+      this.document.documentElement.getBoundingClientRect();
+
+    let height = this.preferredHeight;
+    // Check if the popup can fit above the anchor.
+    let availableTop = anchorTop;
+    let fitsAbove = availableTop >= height;
+    // Check if the popup can fit below the anchor.
+    let availableBelow = docBottom - (anchorTop + anchorHeight);
+    let fitsBelow = availableBelow >= height;
+
+    let isPositionSuitable = (fitsAbove && position === TOP)
+      || (fitsBelow && position === BOTTOM);
+    if (!isPositionSuitable) {
+      // If the preferred position does not fit the preferred height,
+      // pick the position offering the most height.
+      position = availableTop > availableBelow ? TOP : BOTTOM;
+    }
+
+    // Calculate height, capped by the maximum height available.
+    height = Math.min(height, Math.max(availableTop, availableBelow));
+    top = position === TOP ? anchorTop - height : anchorTop + anchorHeight;
+
+    let availableWidth = docRight;
+    let width = Math.min(this.preferredWidth, availableWidth);
+
+    // By default, align the tooltip's left edge with the anchor left edge.
+    if (anchorLeft + width <= docRight) {
+      left = anchorLeft;
+    } else {
+      // If the tooltip cannot fit, shift to the left just enough to fit.
+      left = docRight - width;
+    }
+
+    return {top, left, width, height};
+  },
+
+  /**
+   * 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();
+
+    // Find the smallest top/left coordinates from all quads.
+    let top = Infinity, left = Infinity;
+    let quads = node.getBoxQuads({relativeTo: relativeTo});
+    for (let quad of quads) {
+      top = Math.min(top, quad.bounds.top);
+      left = Math.min(left, quad.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};
+  },
+
+  _isXUL: function () {
+    return this.document.documentElement.namespaceURI === XUL_NS;
+  },
+};
--- a/devtools/client/shared/widgets/moz.build
+++ b/devtools/client/shared/widgets/moz.build
@@ -15,16 +15,17 @@ DevToolsModules(
     'Chart.jsm',
     'CubicBezierPresets.js',
     'CubicBezierWidget.js',
     'FastListWidget.js',
     'FilterWidget.js',
     'FlameGraph.js',
     'Graphs.js',
     'GraphsWorker.js',
+    'HTMLTooltip.js',
     'LineGraphWidget.js',
     'MdnDocsWidget.js',
     'MountainGraphWidget.js',
     'SideMenuWidget.jsm',
     'SimpleListWidget.jsm',
     'Spectrum.js',
     'TableWidget.js',
     'Tooltip.js',
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip-frame.xhtml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<!DOCTYPE html>
+
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
+  <script type="application/javascript;version=1.8" src="chrome://devtools/content/shared/theme-switching.js"/>
+  <style>
+    html, body, #tooltip-iframe-container {
+      margin: 0;
+      padding: 0;
+      width: 100%;
+      height: 100%;
+      overflow: hidden;
+    }
+  </style>
+</head>
+<body role="application" class="theme-body">
+  <div id="tooltip-iframe-container"></div>
+</body>
+</html>
--- a/devtools/client/themes/common.css
+++ b/devtools/client/themes/common.css
@@ -241,16 +241,22 @@
   background-position: 0 0, 10px 10px;
 }
 
 .devtools-tooltip-iframe {
   border: none;
   background: transparent;
 }
 
+.devtools-htmltooltip-container {
+  display: none;
+  position: fixed;
+  z-index: 9999;
+}
+
 /* links to source code, like displaying `myfile.js:45` */
 
 .devtools-source-link {
   font-family: var(--monospace-font-family);
   color: var(--theme-highlight-blue);
   cursor: pointer;
   white-space: nowrap;
   display: flex;