Bug 1461522 - Add doorhanger type to HTMLTooltip; r=jdescottes
authorBrian Birtles <birtles@gmail.com>
Thu, 28 Jun 2018 15:04:13 +0900
changeset 424945 42d6ff34217176d4b38a4dfe6a1d1f3da0c545bb
parent 424944 95fe4a4625fe6d10e43200ceef5df7df0d521dbb
child 424946 dd9b63632e1982b37fcd4eff8cee95df9e064001
push id65903
push userbbirtles@mozilla.com
push dateWed, 04 Jul 2018 07:23:06 +0000
treeherderautoland@cc6ec2789c9d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjdescottes
bugs1461522
milestone63.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1461522 - Add doorhanger type to HTMLTooltip; r=jdescottes MozReview-Commit-ID: 6Oq9qauwngX
devtools/client/shared/test/browser.ini
devtools/client/shared/test/browser_html_tooltip_doorhanger-01.js
devtools/client/shared/test/browser_html_tooltip_doorhanger-02.js
devtools/client/shared/test/doc_html_tooltip_doorhanger-01.xul
devtools/client/shared/test/doc_html_tooltip_doorhanger-02.xul
devtools/client/shared/widgets/tooltip/HTMLTooltip.js
devtools/client/themes/tooltips.css
devtools/client/themes/variables.css
--- a/devtools/client/shared/test/browser.ini
+++ b/devtools/client/shared/test/browser.ini
@@ -8,16 +8,18 @@ support-files =
   doc_filter-editor-01.html
   doc_html_tooltip-02.xul
   doc_html_tooltip-03.xul
   doc_html_tooltip-04.xul
   doc_html_tooltip-05.xul
   doc_html_tooltip.xul
   doc_html_tooltip_arrow-01.xul
   doc_html_tooltip_arrow-02.xul
+  doc_html_tooltip_doorhanger-01.xul
+  doc_html_tooltip_doorhanger-02.xul
   doc_html_tooltip_hover.xul
   doc_html_tooltip_rtl.xul
   doc_inplace-editor_autocomplete_offset.xul
   doc_layoutHelpers-getBoxQuads.html
   doc_layoutHelpers.html
   doc_options-view.xul
   doc_spectrum.html
   doc_tableWidget_basic.html
@@ -133,16 +135,18 @@ skip-if = e10s # Bug 1221911, bug 122228
 [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_html_tooltip_arrow-01.js]
 [browser_html_tooltip_arrow-02.js]
 [browser_html_tooltip_consecutive-show.js]
+[browser_html_tooltip_doorhanger-01.js]
+[browser_html_tooltip_doorhanger-02.js]
 [browser_html_tooltip_height-auto.js]
 [browser_html_tooltip_hover.js]
 [browser_html_tooltip_offset.js]
 [browser_html_tooltip_rtl.js]
 [browser_html_tooltip_variable-height.js]
 [browser_html_tooltip_width-auto.js]
 [browser_html_tooltip_xul-wrapper.js]
 [browser_html_tooltip_zoom.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/test/browser_html_tooltip_doorhanger-01.js
@@ -0,0 +1,91 @@
+/* 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 "doorhanger" type's hang direction. It should hang
+ * towards the middle of the screen.
+ */
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip_doorhanger-01.xul";
+
+const {HTMLTooltip} =
+  require("devtools/client/shared/widgets/tooltip/HTMLTooltip");
+loadHelperScript("helper_html_tooltip.js");
+
+let useXulWrapper;
+
+add_task(async function() {
+  // Force the toolbox to be 200px high;
+  await pushPref("devtools.toolbox.footer.height", 200);
+
+  await addTab("about:blank");
+  const [,, doc] = await createHost("bottom", TEST_URI);
+
+  info("Run tests for a Tooltip without using a XUL panel");
+  useXulWrapper = false;
+  await runTests(doc);
+
+  info("Run tests for a Tooltip with a XUL panel");
+  useXulWrapper = true;
+  await runTests(doc);
+});
+
+async function runTests(doc) {
+  info("Create HTML tooltip");
+  const tooltip = new HTMLTooltip(doc, {type: "doorhanger", useXulWrapper});
+  const div = doc.createElementNS(HTML_NS, "div");
+  div.style.width = "200px";
+  div.style.height = "35px";
+  tooltip.setContent(div);
+
+  const docBounds = doc.documentElement.getBoundingClientRect();
+
+  const elements = [...doc.querySelectorAll(".anchor")];
+  for (const el of elements) {
+    info("Display the tooltip on an anchor.");
+    await showTooltip(tooltip, el);
+
+    const arrow = tooltip.arrow;
+    ok(arrow, "Tooltip has an arrow");
+
+    // Get the geometry of the anchor, the tooltip panel & arrow.
+    const anchorBounds = el.getBoxQuads({ relativeTo: doc })[0].getBounds();
+    const panelBounds =
+      tooltip.panel.getBoxQuads({ relativeTo: doc })[0].getBounds();
+    const arrowBounds = arrow.getBoxQuads({ relativeTo: doc })[0].getBounds();
+
+    // Work out which side of the view the anchor is on.
+    const center = bounds => bounds.left + bounds.width / 2;
+    const anchorSide =
+      center(anchorBounds) < center(docBounds)
+      ? "left"
+      : "right";
+
+    // Work out which direction the doorhanger hangs.
+    //
+    // We can do that just by checking which edge of the panel the center of the
+    // arrow is closer to.
+    const panelDirection =
+      center(arrowBounds) - panelBounds.left <
+        panelBounds.right - center(arrowBounds)
+      ? "right"
+      : "left";
+
+    const params =
+      `document: ${docBounds.left}<->${docBounds.right}, ` +
+      `anchor: ${anchorBounds.left}<->${anchorBounds.right}, ` +
+      `panel: ${panelBounds.left}<->${panelBounds.right}, ` +
+      `anchor side: ${anchorSide}, ` +
+      `panel direction: ${panelDirection}`;
+    ok(anchorSide !== panelDirection,
+       `Doorhanger hangs towards center (${params})`);
+
+    await hideTooltip(tooltip);
+  }
+
+  tooltip.destroy();
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/test/browser_html_tooltip_doorhanger-02.js
@@ -0,0 +1,72 @@
+/* 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 "doorhanger" type's arrow tip is precisely centered on
+ * the anchor when the anchor is small.
+ */
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip_doorhanger-02.xul";
+
+const {HTMLTooltip} =
+  require("devtools/client/shared/widgets/tooltip/HTMLTooltip");
+loadHelperScript("helper_html_tooltip.js");
+
+let useXulWrapper;
+
+add_task(async function() {
+  // Force the toolbox to be 200px high;
+  await pushPref("devtools.toolbox.footer.height", 200);
+
+  await addTab("about:blank");
+  const [,, doc] = await createHost("bottom", TEST_URI);
+
+  info("Run tests for a Tooltip without using a XUL panel");
+  useXulWrapper = false;
+  await runTests(doc);
+
+  info("Run tests for a Tooltip with a XUL panel");
+  useXulWrapper = true;
+  await runTests(doc);
+});
+
+async function runTests(doc) {
+  info("Create HTML tooltip");
+  const tooltip = new HTMLTooltip(doc, {type: "doorhanger", useXulWrapper});
+  const div = doc.createElementNS(HTML_NS, "div");
+  div.style.width = "200px";
+  div.style.height = "35px";
+  tooltip.setContent(div);
+
+  const elements = [...doc.querySelectorAll(".anchor")];
+  for (const el of elements) {
+    info("Display the tooltip on an anchor.");
+    await showTooltip(tooltip, el);
+
+    const arrow = tooltip.arrow;
+    ok(arrow, "Tooltip has an arrow");
+
+    // Get the geometry of the anchor and arrow.
+    const anchorBounds = el.getBoxQuads({ relativeTo: doc })[0].getBounds();
+    const arrowBounds = arrow.getBoxQuads({ relativeTo: doc })[0].getBounds();
+
+    // Compare the centers
+    const center = bounds => bounds.left + bounds.width / 2;
+    const delta = Math.abs(center(anchorBounds) - center(arrowBounds));
+    const describeBounds = bounds =>
+      `${bounds.left}<--[${center(bounds)}]-->${bounds.right}`;
+    const params =
+      `anchor: ${describeBounds(anchorBounds)}, ` +
+      `arrow: ${describeBounds(arrowBounds)}`;
+    ok(delta < 1,
+       `Arrow center is roughly aligned with anchor center (${params})`);
+
+    await hideTooltip(tooltip);
+  }
+
+  tooltip.destroy();
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/test/doc_html_tooltip_doorhanger-01.xul
@@ -0,0 +1,48 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://devtools/skin/light-theme.css"?>
+<window class="theme-light"
+        xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+        xmlns:html="http://www.w3.org/1999/xhtml"
+        title="Tooltip test">
+
+<vbox flex="1" style="position: relative">
+  <!-- Left edge -->
+  <html:div class="anchor"
+    style="width:10px; height: 10px; position: absolute; background: red;
+           top: 0; left: 0;">
+  </html:div>
+
+  <!-- Not left edge but still left of center plus RTL direction (which should
+       no affect the hang direction) -->
+  <html:div class="anchor"
+    style="width:10px; height: 10px; position: absolute; background: red;
+    top: 0; left: 25px; direction: rtl">
+  </html:div>
+
+  <!-- Wide but still left of center -->
+  <html:div class="anchor"
+    style="width:80%; height: 10px; position: absolute; background: red;
+    top: 0; left: 50px;">
+  </html:div>
+
+  <!-- Right edge -->
+  <html:div class="anchor"
+    style="width:10px; height: 10px; position: absolute; background: red;
+    bottom: 0; right: 0;">
+  </html:div>
+
+  <!-- Not right edge but still right of center plus RTL direction (which should
+       no affect the hang direction) -->
+  <html:div class="anchor"
+    style="width:10px; height: 10px; position: absolute; background: red;
+    bottom: 0; right: 25px; direction: rtl">
+  </html:div>
+
+  <!-- Wide but still right of center -->
+  <html:div class="anchor"
+    style="width:80%; height: 10px; position: absolute; background: red;
+    bottom: 0; right: 50px;">
+  </html:div>
+  </vbox>
+</window>
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/test/doc_html_tooltip_doorhanger-02.xul
@@ -0,0 +1,34 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://devtools/skin/light-theme.css"?>
+<window class="theme-light"
+        xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+        xmlns:html="http://www.w3.org/1999/xhtml"
+        title="Tooltip test">
+
+<vbox flex="1" style="position: relative">
+  <!-- Towards the left -->
+  <html:div class="anchor"
+    style="width:10px; height: 10px; position: absolute; background: red;
+    top: 0; left: 25px;">
+  </html:div>
+
+  <!-- Towards the left with RTL direction -->
+  <html:div class="anchor"
+    style="width:10px; height: 10px; position: absolute; background: red;
+    top: 0; left: 50px; direction: rtl;">
+  </html:div>
+
+  <!-- Towards the right -->
+  <html:div class="anchor"
+    style="width:10px; height: 10px; position: absolute; background: red;
+    bottom: 0; right: 25px;">
+  </html:div>
+
+  <!-- Towards the right with RTL direction -->
+  <html:div class="anchor"
+    style="width:10px; height: 10px; position: absolute; background: red;
+    bottom: 0; right: 50px; direction: rtl;">
+  </html:div>
+  </vbox>
+</window>
--- a/devtools/client/shared/widgets/tooltip/HTMLTooltip.js
+++ b/devtools/client/shared/widgets/tooltip/HTMLTooltip.js
@@ -19,34 +19,51 @@ const POSITION = {
   BOTTOM: "bottom",
 };
 
 module.exports.POSITION = POSITION;
 
 const TYPE = {
   NORMAL: "normal",
   ARROW: "arrow",
+  DOORHANGER: "doorhanger",
 };
 
 module.exports.TYPE = TYPE;
 
-const ARROW_WIDTH = 32;
+const ARROW_WIDTH = {
+  "normal": 0,
+  "arrow": 32,
+  // This is the value calculated for the .tooltip-arrow element in tooltip.css
+  // which includes the arrow width (20px) plus the extra margin added so that
+  // the drop shadow is not cropped (2px each side).
+  "doorhanger": 24,
+};
 
-// Default offset between the tooltip's left edge and the tooltip arrow.
-const ARROW_OFFSET = 20;
+const ARROW_OFFSET = {
+  "normal": 0,
+  // Default offset between the tooltip's edge and the tooltip arrow.
+  "arrow": 20,
+  // Match other Firefox menus which use 10px from edge (but subtract the 2px
+  // margin included in the ARROW_WIDTH above).
+  "doorhanger": 8,
+};
 
 const EXTRA_HEIGHT = {
   "normal": 0,
   // The arrow is 16px tall, but merges on 3px with the panel border
   "arrow": 13,
+  // The doorhanger arrow is 10px tall, but merges on 1px with the panel border
+  "doorhanger": 9,
 };
 
 const EXTRA_BORDER = {
   "normal": 0,
   "arrow": 3,
+  "doorhanger": 0,
 };
 
 /**
  * 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
@@ -115,39 +132,72 @@ const calculateVerticalPosition = (
  * available width prevents this.
  *
  * @param {DOMRect} anchorRect
  *        Bounding rectangle for the anchor, relative to the tooltip document.
  * @param {DOMRect} viewportRect
  *        Bounding rectangle for the viewport. top/left can be different from
  *        0 if some space should not be used by tooltips (for instance OS
  *        toolbars, taskbars etc.).
+ * @param {DOMRect} windowRect
+ *        Bounding rectangle for the window. Used to determine which direction
+ *        doorhangers should hang.
  * @param {Number} width
  *        Preferred width for the tooltip.
  * @param {String} type
  *        The tooltip type (e.g. "arrow").
  * @param {Number} offset
  *        Horizontal offset in pixels.
+ * @param {Number} borderRadius
+ *        The border radius of the panel. This is added to ARROW_OFFSET to
+ *        calculate the distance from the edge of the tooltip to the start
+ *        of arrow. It is separate from ARROW_OFFSET since it will vary by
+ *        platform.
  * @param {Boolean} isRtl
  *        If the anchor is in RTL, the tooltip should be aligned to the right.
  * @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 = (
   anchorRect,
   viewportRect,
+  windowRect,
   width,
   type,
   offset,
+  borderRadius,
   isRtl
 ) => {
   // Which direction should the tooltip go?
-  const hangDirection = isRtl ? "left" : "right";
+  //
+  // For tooltips we follow the writing direction but for doorhangers the
+  // guidelines[1] say that,
+  //
+  //   "Doorhangers opening on the right side of the view show the directional
+  //   arrow on the right.
+  //
+  //   Doorhangers opening on the left side of the view show the directional
+  //   arrow on the left.
+  //
+  //   Never place the directional arrow at the center of doorhangers."
+  //
+  // [1] https://design.firefox.com/photon/components/doorhangers.html#directional-arrow
+  //
+  // So for those we need to check if the anchor is more right or left.
+  let hangDirection;
+  if (type === TYPE.DOORHANGER) {
+    const anchorCenter = anchorRect.left + anchorRect.width / 2;
+    const viewCenter = windowRect.left + windowRect.width / 2;
+    hangDirection = anchorCenter >= viewCenter ? "left" : "right";
+  } else {
+    hangDirection = isRtl ? "left" : "right";
+  }
+
   const anchorWidth = anchorRect.width;
 
   // Calculate logical start of anchor relative to the viewport.
   const anchorStart =
     hangDirection === "right"
       ? anchorRect.left - viewportRect.left
       : viewportRect.right - anchorRect.right;
 
@@ -155,37 +205,39 @@ const calculateHorizontalPosition = (
   const tooltipWidth = Math.min(width, viewportRect.width);
 
   // Calculate tooltip start.
   let tooltipStart = anchorStart + offset;
   tooltipStart = Math.min(tooltipStart, viewportRect.width - tooltipWidth);
   tooltipStart = Math.max(0, tooltipStart);
 
   // Calculate arrow start (tooltip's start might be updated)
-  const arrowWidth = type === TYPE.ARROW ? ARROW_WIDTH : 0;
+  const arrowWidth = ARROW_WIDTH[type];
   let arrowStart;
-  // Arrow style tooltips may need to be shifted
-  if (type === TYPE.ARROW) {
+  // Arrow and doorhanger style tooltips may need to be shifted
+  if (type === TYPE.ARROW || type === TYPE.DOORHANGER) {
+    const arrowOffset = ARROW_OFFSET[type] + borderRadius;
+
     // Where will the point of the arrow be if we apply the standard offset?
-    const arrowCenter = tooltipStart + ARROW_OFFSET + arrowWidth / 2;
+    const arrowCenter = tooltipStart + arrowOffset + arrowWidth / 2;
 
     // How does that compare to the center of the anchor?
     const anchorCenter = anchorStart + anchorWidth / 2;
 
     // If the anchor is too narrow, align the arrow and the anchor center.
     if (arrowCenter > anchorCenter) {
       tooltipStart = Math.max(0, tooltipStart - (arrowCenter - anchorCenter));
     }
     // Arrow's start offset relative to the anchor.
-    arrowStart = Math.min(ARROW_OFFSET, (anchorWidth - arrowWidth) / 2) | 0;
+    arrowStart = Math.min(arrowOffset, (anchorWidth - arrowWidth) / 2) | 0;
     // Translate the coordinate to tooltip container
     arrowStart += anchorStart - tooltipStart;
     // Make sure the arrow remains in the tooltip container.
-    arrowStart = Math.min(arrowStart, tooltipWidth - arrowWidth);
-    arrowStart = Math.max(arrowStart, 0);
+    arrowStart = Math.min(arrowStart, tooltipWidth - arrowWidth - borderRadius);
+    arrowStart = Math.max(arrowStart, borderRadius);
   }
 
   // Convert from logical coordinates to physical
   const left =
     hangDirection === "right"
       ? viewportRect.left + tooltipStart
       : viewportRect.right - tooltipStart - tooltipWidth;
   const arrowLeft =
@@ -227,17 +279,18 @@ const getRelativeRect = function(node, r
 
 /**
  * The HTMLTooltip can display HTML content in a tooltip popup.
  *
  * @param {Document} toolboxDoc
  *        The toolbox document to attach the HTMLTooltip popup.
  * @param {Object}
  *        - {String} type
- *          Display type of the tooltip. Possible values: "normal", "arrow"
+ *          Display type of the tooltip. Possible values: "normal", "arrow", and
+ *          "doorhanger".
  *        - {Boolean} autofocus
  *          Defaults to false. 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.
  *        - {Boolean} useXulWrapper
  *          Defaults to false. If the tooltip is hosted in a XUL document, use a XUL panel
  *          in order to use all the screen viewport available.
@@ -370,18 +423,17 @@ HTMLTooltip.prototype = {
    */
   async show(anchor, {position, x = 0, y = 0} = {}) {
     // Get anchor geometry
     let anchorRect = getRelativeRect(anchor, this.doc);
     if (this.useXulWrapper) {
       anchorRect = this._convertToScreenRect(anchorRect);
     }
 
-    // Get viewport size
-    const viewportRect = this._getViewportRect();
+    const { viewportRect, windowRect } = this._getBoundingRects();
 
     // Calculate the horizonal position and width
     let preferredWidth;
     // Record the height too since it might save us from having to look it up
     // later.
     let measuredHeight;
     if (this.preferredWidth === "auto") {
       // Reset any styles that constrain the dimensions we want to calculate.
@@ -394,43 +446,79 @@ HTMLTooltip.prototype = {
         height: measuredHeight,
       } = this._measureContainerSize());
     } else {
       const themeWidth = 2 * EXTRA_BORDER[this.type];
       preferredWidth = this.preferredWidth + themeWidth;
     }
 
     const anchorWin = anchor.ownerDocument.defaultView;
-    const isRtl = anchorWin.getComputedStyle(anchor).direction === "rtl";
+    const anchorCS = anchorWin.getComputedStyle(anchor);
+    const isRtl = anchorCS.direction === "rtl";
+
+    let borderRadius = 0;
+    if (this.type === TYPE.DOORHANGER) {
+      borderRadius = parseFloat(
+        anchorCS.getPropertyValue("--theme-arrowpanel-border-radius")
+      );
+      if (Number.isNaN(borderRadius)) {
+        borderRadius = 0;
+      }
+    }
+
     const {left, width, arrowLeft} = calculateHorizontalPosition(
-      anchorRect, viewportRect, preferredWidth, this.type, x, isRtl);
+      anchorRect,
+      viewportRect,
+      windowRect,
+      preferredWidth,
+      this.type,
+      x,
+      borderRadius,
+      isRtl
+    );
 
     // If we constrained the width, then any measured height we have is no
     // longer valid.
     if (measuredHeight && width !== preferredWidth) {
       measuredHeight = undefined;
     }
 
     // Apply width and arrow positioning
     this.container.style.width = width + "px";
-    if (this.type === TYPE.ARROW) {
+    if (this.type === TYPE.ARROW || this.type === TYPE.DOORHANGER) {
       this.arrow.style.left = arrowLeft + "px";
     }
 
+    // Work out how much vertical margin we have.
+    //
+    // This relies on us having set either .tooltip-top or .tooltip-bottom
+    // and on the margins for both being symmetrical. Fortunately the call to
+    // _measureContainerSize above will set .tooltip-top for us and it also
+    // assumes these styles are symmetrical so this should be ok.
+    const panelWindow = this.panel.ownerDocument.defaultView;
+    const panelComputedStyle = panelWindow.getComputedStyle(this.panel);
+    const verticalMargin =
+      parseFloat(panelComputedStyle.marginTop) +
+      parseFloat(panelComputedStyle.marginBottom);
+
     // Calculate the vertical position and height
     let preferredHeight;
     if (this.preferredHeight === "auto") {
       if (measuredHeight) {
         this.container.style.height = "auto";
         preferredHeight = measuredHeight;
       } else {
         ({ height: preferredHeight } = this._measureContainerSize());
       }
+      preferredHeight += verticalMargin;
     } else {
-      const themeHeight = EXTRA_HEIGHT[this.type] + 2 * EXTRA_BORDER[this.type];
+      const themeHeight =
+        EXTRA_HEIGHT[this.type] +
+        verticalMargin +
+        2 * EXTRA_BORDER[this.type];
       preferredHeight = this.preferredHeight + themeHeight;
     }
 
     const {top, height, computedPosition} =
       calculateVerticalPosition(anchorRect, viewportRect, preferredHeight, position, y);
 
     this._position = computedPosition;
     const isTop = computedPosition === POSITION.TOP;
@@ -462,42 +550,85 @@ HTMLTooltip.prototype = {
       // Update the top window reference each time in case the host changes.
       this.topWindow = this._getTopWindow();
       this.topWindow.addEventListener("click", this._onClick, true);
       this.emit("shown");
     }, 0);
   },
 
   /**
-   * Calculate the rect of the viewport that limits the tooltip dimensions. When using a
-   * XUL panel wrapper, the viewport will be able to use the whole screen (excluding space
-   * reserved by the OS for toolbars etc.). Otherwise, the viewport is limited to the
-   * tooltip's document.
+   * Calculate the following boundary rectangles:
+   *
+   * - Viewport rect: This is the region that limits the tooltip dimensions.
+   *   When using a XUL panel wrapper, the tooltip will be able to use the whole
+   *   screen (excluding space reserved by the OS for toolbars etc.) and hence
+   *   the result will be in screen coordinates.
+   *   Otherwise, the tooltip is limited to the tooltip's document.
    *
-   * @return {Object} DOMRect-like object with the Number properties: top, right, bottom,
-   *         left, width, height
+   * - Window rect: This is the bounds of the view in which the tooltip is
+   *   presented. It is reported in the same coordinates as the viewport
+   *   rect and is used for determining in which direction a doorhanger-type
+   *   tooltip should "hang".
+   *   When using the XUL panel wrapper this will be the dimensions of the
+   *   window in screen coordinates. Otherwise it will be the same as the
+   *   viewport rect.
+   *
+   * @return {Object} An object with the following properties
+   *         viewportRect {Object} DOMRect-like object with the Number
+   *                      properties: top, right, bottom, left, width, height
+   *                      representing the viewport rect.
+   *         windowRect   {Object} DOMRect-like object with the Number
+   *                      properties: top, right, bottom, left, width, height
+   *                      representing the viewport rect.
    */
-  _getViewportRect: function() {
+  _getBoundingRects: function() {
+    let viewportRect;
+    let windowRect;
+
     if (this.useXulWrapper) {
-      // availLeft/Top are the coordinates first pixel available on the screen for
-      // applications (excluding space dedicated for OS toolbars, menus etc...)
-      // availWidth/Height are the dimensions available to applications excluding all
-      // the OS reserved space
-      const {availLeft, availTop, availHeight, availWidth} = this.doc.defaultView.screen;
-      return {
+      // availLeft/Top are the coordinates first pixel available on the screen
+      // for applications (excluding space dedicated for OS toolbars, menus
+      // etc...)
+      // availWidth/Height are the dimensions available to applications
+      // excluding all the OS reserved space
+      const {
+        availLeft,
+        availTop,
+        availHeight,
+        availWidth,
+      } = this.doc.defaultView.screen;
+      viewportRect = {
         top: availTop,
         right: availLeft + availWidth,
         bottom: availTop + availHeight,
         left: availLeft,
         width: availWidth,
         height: availHeight,
       };
+
+      const {
+        screenX,
+        screenY,
+        outerWidth,
+        outerHeight,
+      } = this.doc.defaultView;
+      windowRect = {
+        top: screenY,
+        right: screenX + outerWidth,
+        bottom: screenY + outerHeight,
+        left: screenX,
+        width: outerWidth,
+        height: outerHeight,
+      };
+    } else {
+      viewportRect = windowRect =
+        this.doc.documentElement.getBoundingClientRect();
     }
 
-    return this.doc.documentElement.getBoundingClientRect();
+    return { viewportRect, windowRect };
   },
 
   _measureContainerSize: function() {
     const xulParent = this.container.parentNode;
     if (this.useXulWrapper && !this.isVisible()) {
       // Move the container out of the XUL Panel to measure it.
       this.doc.documentElement.appendChild(this.container);
     }
@@ -568,17 +699,17 @@ HTMLTooltip.prototype = {
   _createContainer: function() {
     const container = this.doc.createElementNS(XHTML_NS, "div");
     container.setAttribute("type", this.type);
     container.classList.add("tooltip-container");
 
     let html = '<div class="tooltip-filler"></div>';
     html += '<div class="tooltip-panel"></div>';
 
-    if (this.type === TYPE.ARROW) {
+    if (this.type === TYPE.ARROW || this.type === TYPE.DOORHANGER) {
       html += '<div class="tooltip-arrow"></div>';
     }
     // eslint-disable-next-line no-unsanitized/property
     container.innerHTML = html;
     return container;
   },
 
   _onClick: function(e) {
--- a/devtools/client/themes/tooltips.css
+++ b/devtools/client/themes/tooltips.css
@@ -257,16 +257,222 @@
   transform: rotate(225deg);
 }
 
 .tooltip-top .tooltip-arrow:before {
   margin-top: -12px;
   transform: rotate(45deg);
 }
 
+/* Tooltip : doorhanger style */
+
+:root {
+  --theme-arrowpanel-border-radius: 0px;
+}
+:root[platform="mac"] {
+  --theme-arrowpanel-border-radius: 3.5px;
+}
+
+.tooltip-container[type="doorhanger"] > .tooltip-panel {
+  padding: 4px 0;
+  color: var(--theme-arrowpanel-color);
+  margin: 4px;
+  max-width: 320px;
+}
+
+.tooltip-container[type="doorhanger"] > .tooltip-panel,
+.tooltip-container[type="doorhanger"] > .tooltip-arrow::before {
+  background: var(--theme-arrowpanel-background);
+  border: 1px solid var(--theme-arrowpanel-border-color);
+  border-radius: var(--theme-arrowpanel-border-radius);
+  box-shadow: 0 0 4px hsla(210,4%,10%,.2);
+}
+
+:root[platform="mac"] .tooltip-container[type="doorhanger"] > .tooltip-panel,
+:root[platform="mac"] .tooltip-container[type="doorhanger"] > .tooltip-arrow::before {
+  box-shadow: none;
+  /*
+   * The above should be:
+   *
+   *   box-shadow: 0 0 0 1px var(--theme-arrowpanel-border-color);
+   *
+   * but although that gives the right emphasis to the border it makes the
+   * platform shadow much too dark.
+   */
+}
+
+:root[platform="mac"].theme-light .tooltip-container[type="doorhanger"] > .tooltip-panel,
+:root[platform="mac"].theme-light .tooltip-container[type="doorhanger"] > .tooltip-arrow::before {
+  border: none;
+}
+
+.tooltip-container[type="doorhanger"] > .tooltip-arrow {
+  /* Desired width of the arrow */
+  --arrow-width: 20px;
+
+  /* Amount of room to allow for the shadow. Should be about half the radius. */
+  --shadow-radius: 4px;
+  --shadow-margin: calc(var(--shadow-radius) / 2);
+
+  /*
+   * Crop the arrow region to show half the arrow plus allow room for margins.
+   *
+   * The ARROW_WIDTH in HTMLTooltip.js needs to match the following value.
+   */
+  width: calc(var(--arrow-width) + 2 * var(--shadow-margin));
+  height: calc(var(--arrow-width) / 2 + var(--shadow-margin));
+}
+
+.tooltip-container[type="doorhanger"] > .tooltip-arrow::before {
+  /* Make sure the border is included in the size */
+  box-sizing: border-box;
+
+  /* Don't inherit any rounded corners. */
+  border-radius: 0;
+
+  /*
+   * When the box is rotated, it should have width <arrow-width>.
+   * That makes the length of one side of the box equal to:
+   *
+   *    (<arrow-width> / 2) / sin 45
+   */
+  --sin-45: 0.707106781;
+  --square-side: calc(var(--arrow-width) / 2 / var(--sin-45));
+  width: var(--square-side);
+  height: var(--square-side);
+
+  /*
+   * The rotated square will overshoot the left side
+   * and need to be shifted in by:
+   *
+   *   (<arrow-width> - square side) / 2
+   *
+   * But we also want to shift it in so that the box-shadow
+   * is not clipped when we clip the parent so we add
+   * a suitable margin for that.
+   */
+  --overhang: calc((var(--arrow-width) - var(--square-side)) / 2);
+  margin-left: calc(var(--overhang) + var(--shadow-margin));
+}
+
+.tooltip-container[type="doorhanger"].tooltip-top > .tooltip-panel {
+  /* Drop the margin between the doorhanger and the arrow. */
+  margin-bottom: 0;
+}
+
+.tooltip-container[type="doorhanger"].tooltip-bottom > .tooltip-panel {
+  /* Drop the margin between the doorhanger and the arrow. */
+  margin-top: 0;
+}
+
+.tooltip-container[type="doorhanger"].tooltip-top > .tooltip-arrow {
+  /* Overlap the arrow with the 1px border of the doorhanger */
+  margin-top: -1px;
+}
+
+.tooltip-container[type="doorhanger"].tooltip-bottom > .tooltip-arrow {
+  /* Overlap the arrow with the 1px border of the doorhanger */
+  margin-bottom: -1px;
+}
+
+.tooltip-container[type="doorhanger"].tooltip-top > .tooltip-arrow::before {
+  /* Show only the bottom half of the box */
+  margin-top: calc(var(--square-side) / -2);
+}
+
+.tooltip-container[type="doorhanger"].tooltip-bottom > .tooltip-arrow::before {
+  /* Shift the rotated box in so that it is not clipped */
+  margin-top: calc(var(--overhang) + var(--shadow-margin));
+}
+
+.tooltip-container[type="doorhanger"] .tooltip-panel ul {
+  /* Override the display: -moz-box declaration in minimal-xul.css
+   * or else menu items won't stack. */
+  display: block;
+}
+
+.tooltip-container[type="doorhanger"] .menuitem > .command {
+  display: flex;
+  align-items: baseline;
+  margin: 0;
+  padding: 4px 12px;
+  outline: none;
+}
+
+.tooltip-container[type="doorhanger"] .menuitem > button.command:-moz-any([role="menuitem"],[role="menuitemcheckbox"]) {
+  -moz-appearance: none;
+  border: none;
+  color: var(--theme-arrowpanel-color);
+  background-color: transparent;
+  text-align: start;
+  width: 100%;
+}
+
+.tooltip-container[type="doorhanger"] .menuitem > .command:not(:-moz-any([disabled],[open],:active)):-moz-any(:hover,:focus) {
+  background-color: var(--theme-arrowpanel-dimmed);
+}
+
+.tooltip-container[type="doorhanger"] .menuitem > .command:-moz-focusring::-moz-focus-inner {
+  border-color: transparent;
+}
+
+.tooltip-container[type="doorhanger"] .menuitem > .command:not([disabled]):-moz-any([open],:hover:active) {
+  background-color: var(--theme-arrowpanel-dimmed-further);
+  box-shadow: 0 1px 0 hsla(210,4%,10%,.03) inset;
+}
+
+.tooltip-container[type="doorhanger"] .menuitem > .command[aria-checked="true"] {
+  list-style-image: none;
+  -moz-context-properties: fill;
+  fill: currentColor;
+  background: url(chrome://browser/skin/check.svg) no-repeat transparent;
+  background-size: 11px 11px;
+  background-position: center left 7px;
+}
+
+.tooltip-container[type="doorhanger"] .menuitem > .command[aria-checked="true"]:-moz-locale-dir(rtl) {
+  background-position: center right 7px;
+}
+
+.tooltip-container[type="doorhanger"] .menuitem > .command > .label {
+  flex: 1;
+  padding-inline-start: 16px;
+  font: menu;
+}
+
+.tooltip-container[type="doorhanger"] .menuitem > .command.iconic > .label::before {
+  content: " ";
+  display: inline-block;
+  margin-inline-end: 8px;
+  width: 16px;
+  height: 16px;
+  vertical-align: top;
+  -moz-context-properties: fill;
+  fill: currentColor;
+  /*
+   * The icons in the sidebar menu have opacity: 0.8 here, but those in the
+   * hamburger menu don't. For now we match the hamburger menu styling,
+   * especially because the 80% opacity makes the icons look dull in dark mode.
+   */
+}
+
+.tooltip-container[type="doorhanger"] .menuitem > .command > .accelerator {
+  margin-inline-start: 10px;
+  color: var(--theme-arrowpanel-disabled-color);
+  font: message-box;
+}
+
+.tooltip-container[type="doorhanger"] hr {
+  display: block;
+  border: none;
+  border-top: 1px solid var(--theme-arrowpanel-separator);
+  margin: 6px 0;
+  padding: 0;
+}
+
 /* Tooltip: Events */
 
 .event-header {
   display: flex;
   align-items: center;
   cursor: pointer;
   overflow: hidden;
 }
--- a/devtools/client/themes/variables.css
+++ b/devtools/client/themes/variables.css
@@ -88,24 +88,45 @@
   /* Icon filters */
   --theme-icon-checked-filter: url(chrome://devtools/skin/images/filters.svg#icon-checked-light);
 
   /* Tooltips */
   --theme-tooltip-border: #d9e1e8;
   --theme-tooltip-background: rgba(255, 255, 255, .9);
   --theme-tooltip-shadow: rgba(155, 155, 155, 0.26);
 
+  /* Doorhangers */
+  /* These colors are based on the colors used for doorhangers elsewhere in
+   * Firefox. */
+  --theme-arrowpanel-background: white;
+  --theme-arrowpanel-color: -moz-fieldText;
+  --theme-arrowpanel-border-color: var(--grey-90-a20);
+  --theme-arrowpanel-separator: var(--grey-90-a20);
+  --theme-arrowpanel-dimmed: hsla(0,0%,80%,.3);
+  --theme-arrowpanel-dimmed-further: hsla(0,0%,80%,.45);
+  --theme-arrowpanel-disabled-color: GrayText;
+
   /* Command line */
   --theme-command-line-image: url(chrome://devtools/skin/images/commandline-icon.svg#light-theme);
   --theme-command-line-image-focus: url(chrome://devtools/skin/images/commandline-icon.svg#light-theme-focus);
 
   --theme-codemirror-gutter-background: #f4f4f4;
   --theme-messageCloseButtonFilter: invert(0);
 }
 
+/*
+ * For doorhangers elsewhere in Fireflox, Mac uses a fixed color different to
+ * -moz-fieldText and a slightly lighter border color (presumably since it
+ * combines with the platform shadow).
+ */
+:root[platform="mac"].theme-light {
+  --theme-arrowpanel-color: rgb(26,26,26);
+  --theme-arrowpanel-border-color: hsla(210,4%,10%,.05);
+}
+
 :root.theme-dark {
   --theme-body-background: var(--grey-80);
   --theme-sidebar-background: #1B1B1D;
   --theme-contrast-background: #ffb35b;
 
   /* Toolbar */
   --theme-tab-toolbar-background: var(--grey-90);
   --theme-toolbar-background: #1B1B1D;
@@ -175,16 +196,27 @@
   /* Icon filters */
   --theme-icon-checked-filter: url(chrome://devtools/skin/images/filters.svg#icon-checked-dark);
 
   /* Tooltips */
   --theme-tooltip-border: #434850;
   --theme-tooltip-background: rgba(19, 28, 38, .9);
   --theme-tooltip-shadow: rgba(25, 25, 25, 0.76);
 
+  /* Doorhangers */
+  /* These colors are based on the colors used for doorhangers elsewhere in
+   * Firefox. */
+  --theme-arrowpanel-background: var(--grey-60);
+  --theme-arrowpanel-color: rgb(249,249,250);
+  --theme-arrowpanel-border-color: #27272b;
+  --theme-arrowpanel-separator: rgba(249,249,250,.1);
+  --theme-arrowpanel-dimmed: rgba(249,249,250,.1);
+  --theme-arrowpanel-dimmed-further: rgba(249,249,250,.15);
+  --theme-arrowpanel-disabled-color: rgba(249,249,250,.5);
+
   /* Command line */
   --theme-command-line-image: url(chrome://devtools/skin/images/commandline-icon.svg#dark-theme);
   --theme-command-line-image-focus: url(chrome://devtools/skin/images/commandline-icon.svg#dark-theme-focus);
 
   --theme-codemirror-gutter-background: #262b37;
   --theme-messageCloseButtonFilter: invert(1);
 }
 
@@ -247,10 +279,11 @@
   --grey-40: #b1b1b3;
   --grey-50: #737373;
   --grey-60: #4a4a4f;
   --grey-60-a50: rgba(74, 74, 79, 0.5);
   --grey-70: #38383d;
   --grey-80: #2a2a2e;
   --grey-90: #0c0c0d;
   --grey-90-a10: rgba(12, 12, 13, 0.1);
+  --grey-90-a20: rgba(12, 12, 13, 0.2);
   --grey-90-a80: rgba(12, 12, 13, 0.8);
 }