Bug 1461522 - Add doorhanger type to HTMLTooltip; r?jdescottes draft
authorBrian Birtles <birtles@gmail.com>
Thu, 28 Jun 2018 15:04:13 +0900
changeset 813898 9544afc6116630a97af5f79cd91ee5756bf3b727
parent 813897 14ca57e648d6d647dfdaf2f9857e97ba5bd90aed
child 813899 04040a4a430c590cfe90525c894abe685428ed9c
push id115042
push userbbirtles@mozilla.com
push dateWed, 04 Jul 2018 04:36:27 +0000
reviewersjdescottes
bugs1461522
milestone63.0a1
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);
 }