Source code

Revision control

Copy as Markdown

Other Tools

/* 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("resource://devtools/shared/event-emitter.js");
loader.lazyRequireGetter(
this,
"focusableSelector",
"resource://devtools/client/shared/focus.js",
true
);
loader.lazyRequireGetter(
this,
"TooltipToggle",
"resource://devtools/client/shared/widgets/tooltip/TooltipToggle.js",
true
);
loader.lazyRequireGetter(
this,
"listenOnce",
"resource://devtools/shared/async-utils.js",
true
);
loader.lazyRequireGetter(
this,
"DevToolsUtils",
"resource://devtools/shared/DevToolsUtils.js"
);
const XHTML_NS = "http://www.w3.org/1999/xhtml";
const POSITION = {
TOP: "top",
BOTTOM: "bottom",
};
module.exports.POSITION = POSITION;
const TYPE = {
NORMAL: "normal",
ARROW: "arrow",
DOORHANGER: "doorhanger",
};
module.exports.TYPE = TYPE;
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,
};
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 with the panel border
arrow: 14,
// The doorhanger arrow is 10px tall, but merges on 1px with the panel border
doorhanger: 9,
};
/**
* Calculate the vertical position & offsets to use for the tooltip. Will attempt to
* respect the provided height and position preferences, unless the available height
* prevents this.
*
* @param {DOMRect} anchorRect
* Bounding rectangle for the anchor, relative to the tooltip document.
* @param {DOMRect} 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 {Number} height
* Preferred height for the tooltip.
* @param {String} pos
* Preferred position for the tooltip. Possible values: "top" or "bottom".
* @param {Number} offset
* Offset between the top of the anchor and the tooltip.
* @return {Object}
* - {Number} top: the top offset for the tooltip.
* - {Number} height: the height to use for the tooltip container.
* - {String} computedPosition: Can differ from the preferred position depending
* on the available height). "top" or "bottom"
*/
const calculateVerticalPosition = (
anchorRect,
viewportRect,
height,
pos,
offset
) => {
const { TOP, BOTTOM } = POSITION;
let { top: anchorTop, height: anchorHeight } = anchorRect;
// Translate to the available viewport space before calculating dimensions and position.
anchorTop -= viewportRect.top;
// Calculate available space for the tooltip.
const availableTop = anchorTop;
const availableBottom = viewportRect.height - (anchorTop + anchorHeight);
// Find POSITION
let keepPosition = false;
if (pos === TOP) {
keepPosition = availableTop >= height + offset;
} else if (pos === BOTTOM) {
keepPosition = availableBottom >= height + offset;
}
if (!keepPosition) {
pos = availableTop > availableBottom ? TOP : BOTTOM;
}
// Calculate HEIGHT.
const availableHeight = pos === TOP ? availableTop : availableBottom;
height = Math.min(height, availableHeight - offset);
// Calculate TOP.
let top =
pos === TOP
? anchorTop - height - offset
: anchorTop + anchorHeight + offset;
// Translate back to absolute coordinates by re-including viewport top margin.
top += viewportRect.top;
return {
top: Math.round(top),
height: Math.round(height),
computedPosition: pos,
};
};
/**
* Calculate the horizontal position & offsets to use for the tooltip. Will
* attempt to respect the provided width and position preferences, unless the
* 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,
isMenuTooltip
) => {
// All tooltips from content should follow the writing direction.
//
// For tooltips (including doorhanger tooltips) we follow the writing
// direction but for menus created using 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."
//
//
// So for those we need to check if the anchor is more right or left.
let hangDirection;
if (type === TYPE.DOORHANGER && isMenuTooltip) {
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;
// Calculate tooltip width.
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 = ARROW_WIDTH[type];
let arrowStart;
// 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 + 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(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 - 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 =
hangDirection === "right"
? arrowStart
: tooltipWidth - arrowWidth - arrowStart;
return {
left: Math.round(left),
width: Math.round(tooltipWidth),
arrowLeft: Math.round(arrowLeft),
};
};
/**
* Get the bounding client rectangle for a given node, relative to a custom
* reference element (instead of the default for getBoundingClientRect which
* is always the element's ownerDocument).
*/
const getRelativeRect = function (node, relativeTo) {
// getBoxQuads is a non-standard WebAPI which will not work on non-firefox
// browser when running launchpad on Chrome.
if (
!node.getBoxQuads ||
!node.getBoxQuads({
relativeTo,
createFramesForSuppressedWhitespace: false,
})[0]
) {
const { top, left, width, height } = node.getBoundingClientRect();
const right = left + width;
const bottom = top + height;
return { top, right, bottom, left, width, height };
}
// Width and Height can be taken from the rect.
const { width, height } = node.getBoundingClientRect();
const quadBounds = node
.getBoxQuads({ relativeTo, createFramesForSuppressedWhitespace: false })[0]
.getBounds();
const top = quadBounds.top;
const left = quadBounds.left;
// Compute right and bottom coordinates using the rest of the data.
const right = left + width;
const bottom = top + height;
return { top, right, bottom, left, width, height };
};
/**
* The HTMLTooltip can display HTML content in a tooltip popup.
*
* @param {Document} toolboxDoc
* The toolbox document to attach the HTMLTooltip popup.
* @param {Object}
* - {String} className
* A string separated list of classes to add to the tooltip container
* element.
* - {Boolean} consumeOutsideClicks
* Defaults to true. The tooltip is closed when clicking outside.
* Should this event be stopped and consumed or not.
* - {String} id
* The ID to assign to the tooltip container element.
* - {Boolean} isMenuTooltip
* Defaults to false. If the tooltip is a menu then this should be set
* to true.
* - {String} type
* Display type of the tooltip. Possible values: "normal", "arrow", and
* "doorhanger".
* - {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.
* - {Boolean} noAutoHide
* Defaults to false. If this property is set to false or omitted, the
* tooltip will automatically disappear after a few seconds. If this
* attribute is set to true, this will not happen and the tooltip will
* only hide when the user moves the mouse to another element.
*/
function HTMLTooltip(
toolboxDoc,
{
className = "",
consumeOutsideClicks = true,
id = "",
isMenuTooltip = false,
type = "normal",
useXulWrapper = false,
noAutoHide = false,
} = {}
) {
EventEmitter.decorate(this);
this.doc = toolboxDoc;
this.id = id;
this.className = className;
this.type = type;
this.noAutoHide = noAutoHide;
// consumeOutsideClicks cannot be used if the tooltip is not closed on click
this.consumeOutsideClicks = this.noAutoHide ? false : consumeOutsideClicks;
this.isMenuTooltip = isMenuTooltip;
this.useXulWrapper = this._isXULPopupAvailable() && useXulWrapper;
this.preferredWidth = "auto";
this.preferredHeight = "auto";
// The top window is used to attach click event listeners to close the tooltip if the
// user clicks on the content page.
this.topWindow = this._getTopWindow();
this._position = null;
this._onClick = this._onClick.bind(this);
this._onMouseup = this._onMouseup.bind(this);
this._onXulPanelHidden = this._onXulPanelHidden.bind(this);
this.container = this._createContainer();
if (this.useXulWrapper) {
// When using a XUL panel as the wrapper, the actual markup for the tooltip is as
// follows :
// <panel> <!-- XUL panel used to position the tooltip anywhere on screen -->
// <div> <! the actual tooltip-container element -->
this.xulPanelWrapper = this._createXulPanelWrapper();
this.doc.documentElement.appendChild(this.xulPanelWrapper);
this.xulPanelWrapper.appendChild(this.container);
} else if (this._hasXULRootElement()) {
this.doc.documentElement.appendChild(this.container);
} else {
// In non-XUL context the container is ready to use as is.
this.doc.body.appendChild(this.container);
}
}
module.exports.HTMLTooltip = HTMLTooltip;
HTMLTooltip.prototype = {
/**
* The tooltip panel is the parentNode of the tooltip content.
*/
get panel() {
return this.container.querySelector(".tooltip-panel");
},
/**
* The arrow element. Might be null depending on the tooltip type.
*/
get arrow() {
return this.container.querySelector(".tooltip-arrow");
},
/**
* Retrieve the displayed position used for the tooltip. Null if the tooltip is hidden.
*/
get position() {
return this.isVisible() ? this._position : null;
},
get toggle() {
if (!this._toggle) {
this._toggle = new TooltipToggle(this);
}
return this._toggle;
},
/**
* Set the preferred width/height of the panel content.
* The panel content is set by appending content to `this.panel`.
*
* @param {Object}
* - {Number} width: preferred width for the tooltip container. If not specified
* the tooltip container will be measured before being displayed, and the
* measured width will be used as the preferred width.
* - {Number} height: preferred height for the tooltip container. If
* not specified the tooltip container will be measured before being
* displayed, and the measured height will be used as the preferred
* height.
*
* For tooltips whose content height may change while being
* displayed, the special value Infinity may be used to produce
* a flexible container that accommodates resizing content. Note,
* however, that when used in combination with the XUL wrapper the
* unfilled part of this container will consume all mouse events
* making content behind this area inaccessible until the tooltip is
* dismissed.
*/
setContentSize({ width = "auto", height = "auto" } = {}) {
this.preferredWidth = width;
this.preferredHeight = height;
},
/**
* Show the tooltip next to the provided anchor element, or update the tooltip position
* if it was already visible. 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} options
* Optional settings for positioning the tooltip.
* @param {String} options.position
* Optional, possible values: top|bottom
* If layout permits, the tooltip will be displayed on top/bottom
* of the anchor. If omitted, the tooltip will be displayed where
* more space is available.
* @param {Number} options.x
* Optional, horizontal offset between the anchor and the tooltip.
* @param {Number} options.y
* Optional, vertical offset between the anchor and the tooltip.
*/
async show(anchor, options) {
const { left, top } = this._updateContainerBounds(anchor, options);
const isTooltipVisible = this.isVisible();
if (this.useXulWrapper) {
if (!isTooltipVisible) {
await this._showXulWrapperAt(left, top);
} else {
this._moveXulWrapperTo(left, top);
}
} else {
this.container.style.left = left + "px";
this.container.style.top = top + "px";
}
if (isTooltipVisible) {
return;
}
this.container.classList.add("tooltip-visible");
// Keep a pointer on the focused element to refocus it when hiding the tooltip.
this._focusedElement = anchor.ownerDocument.activeElement;
if (this.doc.defaultView) {
if (!this._pendingEventListenerPromise) {
// On Windows and Linux, if the tooltip is shown on mousedown/click (which is the
// case for the MenuButton component for example), attaching the events listeners
// on the window right away would trigger the callbacks; which means the tooltip
// would be instantly hidden. To prevent such thing, the event listeners are set
// on the next tick.
this._pendingEventListenerPromise = new Promise(resolve => {
this.doc.defaultView.setTimeout(() => {
// Update the top window reference each time in case the host changes.
this.topWindow = this._getTopWindow();
this.topWindow.addEventListener("click", this._onClick, true);
this.topWindow.addEventListener("mouseup", this._onMouseup, true);
resolve();
}, 0);
});
}
await this._pendingEventListenerPromise;
this._pendingEventListenerPromise = null;
}
// This is redundant with tooltip-visible, and tooltip-visible
// should only be added from here, after the click listener is set.
// Otherwise, code listening to tooltip-visible may be firing a click that would be lost.
// Unfortunately, doing this cause many non trivial test failures.
this.container.classList.add("tooltip-shown");
this.emit("shown");
},
startTogglingOnHover(baseNode, targetNodeCb, options) {
this.toggle.start(baseNode, targetNodeCb, options);
},
stopTogglingOnHover() {
this.toggle.stop();
},
_updateContainerBounds(anchor, { position, x = 0, y = 0 } = {}) {
// Get anchor geometry
let anchorRect = getRelativeRect(anchor, this.doc);
if (this.useXulWrapper) {
anchorRect = this._convertToScreenRect(anchorRect);
}
const { viewportRect, windowRect } = this._getBoundingRects(anchorRect);
// Calculate the horizontal position and width
let preferredWidth;
// Record the height too since it might save us from having to look it up
// later.
let measuredHeight;
const currentScrollTop = this.panel.scrollTop;
if (this.preferredWidth === "auto") {
// Reset any styles that constrain the dimensions we want to calculate.
this.container.style.width = "auto";
if (this.preferredHeight === "auto") {
this.container.style.height = "auto";
}
({ width: preferredWidth, height: measuredHeight } =
this._measureContainerSize());
} else {
preferredWidth = this.preferredWidth;
}
const anchorWin = anchor.ownerDocument.defaultView;
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,
windowRect,
preferredWidth,
this.type,
x,
borderRadius,
isRtl,
this.isMenuTooltip
);
// 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 || 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) {
// We already have a valid height measured in a previous step.
preferredHeight = measuredHeight;
} else {
this.container.style.height = "auto";
({ height: preferredHeight } = this._measureContainerSize());
}
preferredHeight += verticalMargin;
} else {
const themeHeight = EXTRA_HEIGHT[this.type] + verticalMargin;
preferredHeight = this.preferredHeight + themeHeight;
}
const { top, height, computedPosition } = calculateVerticalPosition(
anchorRect,
viewportRect,
preferredHeight,
position,
y
);
this._position = computedPosition;
const isTop = computedPosition === POSITION.TOP;
this.container.classList.toggle("tooltip-top", isTop);
this.container.classList.toggle("tooltip-bottom", !isTop);
// If the preferred height is set to Infinity, the tooltip container should grow based
// on its content's height and use as much height as possible.
this.container.classList.toggle(
"tooltip-flexible-height",
this.preferredHeight === Infinity
);
this.container.style.height = height + "px";
this.panel.scrollTop = currentScrollTop;
return { left, top };
},
/**
* 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.
*
* - 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.
*
* @param {Object} anchorRect
* DOMRect-like object of the target anchor element.
* We need to pass this to detect the case when the anchor is not in
* the current window (because, the center of the window is in
* a different window to the anchor).
*
* @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 window rect.
*/
_getBoundingRects(anchorRect) {
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;
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,
};
// If the anchor is outside the viewport, it possibly means we have a
// multi-monitor environment where the anchor is displayed on a different
// monitor to the "current" screen (as determined by the center of the
// window). This can happen when, for example, the screen is spread across
// two monitors.
//
// In this case we simply expand viewport in the direction of the anchor
// so that we can still calculate the popup position correctly.
if (anchorRect.left > viewportRect.right) {
const diffWidth = windowRect.right - viewportRect.right;
viewportRect.right += diffWidth;
viewportRect.width += diffWidth;
}
if (anchorRect.right < viewportRect.left) {
const diffWidth = viewportRect.left - windowRect.left;
viewportRect.left -= diffWidth;
viewportRect.width += diffWidth;
}
} else {
viewportRect = windowRect =
this.doc.documentElement.getBoundingClientRect();
}
return { viewportRect, windowRect };
},
_measureContainerSize() {
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);
}
this.container.classList.add("tooltip-hidden");
// Set either of the tooltip-top or tooltip-bottom styles so that we get an
// accurate height. We're assuming that the two styles will be symmetrical
// and that we will clear this as necessary later.
this.container.classList.add("tooltip-top");
this.container.classList.remove("tooltip-bottom");
const { width, height } = this.container.getBoundingClientRect();
this.container.classList.remove("tooltip-hidden");
if (this.useXulWrapper && !this.isVisible()) {
xulParent.appendChild(this.container);
}
return { width, height };
},
/**
* Hide the current tooltip. The event "hidden" will be fired when the tooltip
* is hidden.
*/
async hide({ fromMouseup = false } = {}) {
// Exit if the disable autohide setting is in effect or if hide() is called
// from a mouseup event and the tooltip has noAutoHide set to true.
if (
Services.prefs.getBoolPref("devtools.popup.disable_autohide", false) ||
(this.noAutoHide && this.isVisible() && fromMouseup)
) {
return;
}
if (!this.isVisible()) {
this.emit("hidden");
return;
}
// If the tooltip is hidden from a mouseup event, wait for a potential click event
// to be consumed before removing event listeners.
if (fromMouseup) {
await new Promise(resolve => this.topWindow.setTimeout(resolve, 0));
}
if (this._pendingEventListenerPromise) {
this._pendingEventListenerPromise.then(() => this.removeEventListeners());
} else {
this.removeEventListeners();
}
this.container.classList.remove("tooltip-visible", "tooltip-shown");
if (this.useXulWrapper) {
await this._hideXulWrapper();
}
this.emit("hidden");
const tooltipHasFocus =
this.doc.hasFocus() && this.container.contains(this.doc.activeElement);
if (tooltipHasFocus && this._focusedElement) {
this._focusedElement.focus();
this._focusedElement = null;
}
},
removeEventListeners() {
this.topWindow.removeEventListener("click", this._onClick, true);
this.topWindow.removeEventListener("mouseup", this._onMouseup, true);
},
/**
* Check if the tooltip is currently displayed.
* @return {Boolean} true if the tooltip is visible
*/
isVisible() {
return this.container.classList.contains("tooltip-visible");
},
/**
* Destroy the tooltip instance. Hide the tooltip if displayed, remove the
* tooltip container from the document.
*/
destroy() {
this.hide();
this.removeEventListeners();
this.container.remove();
if (this.xulPanelWrapper) {
this.xulPanelWrapper.remove();
}
if (this._toggle) {
this._toggle.destroy();
this._toggle = null;
}
},
_createContainer() {
const container = this.doc.createElementNS(XHTML_NS, "div");
container.setAttribute("type", this.type);
if (this.id) {
container.setAttribute("id", this.id);
}
container.classList.add("tooltip-container");
if (this.className) {
container.classList.add(...this.className.split(" "));
}
const filler = this.doc.createElementNS(XHTML_NS, "div");
filler.classList.add("tooltip-filler");
container.appendChild(filler);
const panel = this.doc.createElementNS(XHTML_NS, "div");
panel.classList.add("tooltip-panel");
container.appendChild(panel);
if (this.type === TYPE.ARROW || this.type === TYPE.DOORHANGER) {
const arrow = this.doc.createElementNS(XHTML_NS, "div");
arrow.classList.add("tooltip-arrow");
container.appendChild(arrow);
}
return container;
},
_onClick(e) {
if (this._isInTooltipContainer(e.target)) {
return;
}
if (this.consumeOutsideClicks && e.button === 0) {
// Consume only left click events (button === 0).
e.preventDefault();
e.stopPropagation();
}
},
/**
* Hide the tooltip on mouseup rather than on click because the surrounding markup
* may change on mousedown in a way that prevents a "click" event from being fired.
* If the element that received the mousedown and the mouseup are different, click
* will not be fired.
*/
_onMouseup(e) {
if (this._isInTooltipContainer(e.target)) {
return;
}
this.hide({ fromMouseup: true });
},
_isInTooltipContainer(node) {
// Check if the target is the tooltip arrow.
if (this.arrow && this.arrow === node) {
return true;
}
if (typeof node.closest == "function" && node.closest("menupopup")) {
// Ignore events from menupopup elements which will not be children of the
// tooltip container even if their owner element is in the tooltip.
// See Bug 1811002.
return true;
}
const tooltipWindow = this.panel.ownerDocument.defaultView;
let win = node.ownerDocument.defaultView;
// Check if the tooltip panel contains the node if they live in the same document.
if (win === tooltipWindow) {
return this.panel.contains(node);
}
// Check if the node window is in the tooltip container.
while (win.parent && win.parent !== win) {
if (win.parent === tooltipWindow) {
// If the parent window is the tooltip window, check if the tooltip contains
// the current frame element.
return this.panel.contains(win.frameElement);
}
win = win.parent;
}
return false;
},
_onXulPanelHidden() {
if (this.isVisible()) {
this.hide();
}
},
/**
* Focus on the first focusable item in the tooltip.
*
* Returns true if we found something to focus on, false otherwise.
*/
focus() {
const focusableElement = this.panel.querySelector(focusableSelector);
if (focusableElement) {
focusableElement.focus();
}
return !!focusableElement;
},
/**
* Focus on the last focusable item in the tooltip.
*
* Returns true if we found something to focus on, false otherwise.
*/
focusEnd() {
const focusableElements = this.panel.querySelectorAll(focusableSelector);
if (focusableElements.length) {
focusableElements[focusableElements.length - 1].focus();
}
return focusableElements.length !== 0;
},
_getTopWindow() {
return DevToolsUtils.getTopWindow(this.doc.defaultView);
},
/**
* Check if the tooltip's owner document has XUL root element.
*/
_hasXULRootElement() {
return this.doc.documentElement.namespaceURI === XUL_NS;
},
_isXULPopupAvailable() {
return this.doc.nodePrincipal.isSystemPrincipal;
},
_createXulPanelWrapper() {
const panel = this.doc.createXULElement("panel");
// XUL panel is only a way to display DOM elements outside of the document viewport,
// so disable all features that impact the behavior.
panel.setAttribute("animate", false);
panel.setAttribute("consumeoutsideclicks", false);
panel.setAttribute("incontentshell", false);
panel.setAttribute("noautofocus", true);
panel.setAttribute("noautohide", this.noAutoHide);
panel.setAttribute("ignorekeys", true);
panel.setAttribute("tooltip", "aHTMLTooltip");
// Use type="arrow" to prevent side effects (see Bug 1285206)
panel.setAttribute("type", "arrow");
panel.setAttribute("tooltip-type", this.type);
panel.setAttribute("flip", "none");
panel.setAttribute("level", "top");
panel.setAttribute("class", "tooltip-xul-wrapper");
// Stop this appearing as an alert to accessibility.
panel.setAttribute("role", "presentation");
return panel;
},
_showXulWrapperAt(left, top) {
this.xulPanelWrapper.addEventListener(
"popuphidden",
this._onXulPanelHidden
);
const onPanelShown = listenOnce(this.xulPanelWrapper, "popupshown");
this.xulPanelWrapper.openPopupAtScreen(left, top, false);
return onPanelShown;
},
_moveXulWrapperTo(left, top) {
// FIXME: moveTo should probably account for margins when called from
// script. Our current shadow set-up only supports one margin, so it's fine
// to use the margin top in both directions.
const margin = parseFloat(
this.xulPanelWrapper.ownerGlobal.getComputedStyle(this.xulPanelWrapper)
.marginTop
);
this.xulPanelWrapper.moveTo(left + margin, top + margin);
},
_hideXulWrapper() {
this.xulPanelWrapper.removeEventListener(
"popuphidden",
this._onXulPanelHidden
);
if (this.xulPanelWrapper.state === "closed") {
// XUL panel is already closed, resolve immediately.
return Promise.resolve();
}
const onPanelHidden = listenOnce(this.xulPanelWrapper, "popuphidden");
this.xulPanelWrapper.hidePopup();
return onPanelHidden;
},
/**
* Convert from coordinates relative to the tooltip's document, to coordinates relative
* to the "available" screen. By "available" we mean the screen, excluding the OS bars
* display on screen edges.
*/
_convertToScreenRect({ left, top, width, height }) {
// mozInnerScreenX/Y are the coordinates of the top left corner of the window's
// viewport, excluding chrome UI.
left += this.doc.defaultView.mozInnerScreenX;
top += this.doc.defaultView.mozInnerScreenY;
return {
top,
right: left + width,
bottom: top + height,
left,
width,
height,
};
},
};