summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/widgets/tooltip
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/shared/widgets/tooltip')
-rw-r--r--devtools/client/shared/widgets/tooltip/EventTooltipHelper.js384
-rw-r--r--devtools/client/shared/widgets/tooltip/HTMLTooltip.js1061
-rw-r--r--devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js145
-rw-r--r--devtools/client/shared/widgets/tooltip/RulePreviewTooltip.js69
-rw-r--r--devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js270
-rw-r--r--devtools/client/shared/widgets/tooltip/SwatchColorPickerTooltip.js357
-rw-r--r--devtools/client/shared/widgets/tooltip/SwatchCubicBezierTooltip.js95
-rw-r--r--devtools/client/shared/widgets/tooltip/SwatchFilterTooltip.js117
-rw-r--r--devtools/client/shared/widgets/tooltip/SwatchLinearEasingFunctionTooltip.js97
-rw-r--r--devtools/client/shared/widgets/tooltip/TooltipToggle.js203
-rw-r--r--devtools/client/shared/widgets/tooltip/VariableTooltipHelper.js31
-rw-r--r--devtools/client/shared/widgets/tooltip/css-compatibility-tooltip-helper.js292
-rw-r--r--devtools/client/shared/widgets/tooltip/css-query-container-tooltip-helper.js145
-rw-r--r--devtools/client/shared/widgets/tooltip/inactive-css-tooltip-helper.js127
-rw-r--r--devtools/client/shared/widgets/tooltip/moz.build22
15 files changed, 3415 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/tooltip/EventTooltipHelper.js b/devtools/client/shared/widgets/tooltip/EventTooltipHelper.js
new file mode 100644
index 0000000000..2107180e79
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/EventTooltipHelper.js
@@ -0,0 +1,384 @@
+/* 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 { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+const L10N = new LocalizationHelper(
+ "devtools/client/locales/inspector.properties"
+);
+
+const Editor = require("resource://devtools/client/shared/sourceeditor/editor.js");
+const beautify = require("resource://devtools/shared/jsbeautify/beautify.js");
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const CONTAINER_WIDTH = 500;
+
+class EventTooltip extends EventEmitter {
+ /**
+ * Set the content of a provided HTMLTooltip instance to display a list of event
+ * listeners, with their event type, capturing argument and a link to the code
+ * of the event handler.
+ *
+ * @param {HTMLTooltip} tooltip
+ * The tooltip instance on which the event details content should be set
+ * @param {Array} eventListenerInfos
+ * A list of event listeners
+ * @param {Toolbox} toolbox
+ * Toolbox used to select debugger panel
+ * @param {NodeFront} nodeFront
+ * The nodeFront we're displaying event listeners for.
+ */
+ constructor(tooltip, eventListenerInfos, toolbox, nodeFront) {
+ super();
+
+ this._tooltip = tooltip;
+ this._toolbox = toolbox;
+ this._eventEditors = new WeakMap();
+ this._nodeFront = nodeFront;
+ this._eventListenersAbortController = new AbortController();
+
+ // Used in tests: add a reference to the EventTooltip instance on the HTMLTooltip.
+ this._tooltip.eventTooltip = this;
+
+ this._headerClicked = this._headerClicked.bind(this);
+ this._eventToggleCheckboxChanged =
+ this._eventToggleCheckboxChanged.bind(this);
+
+ this._subscriptions = [];
+
+ const config = {
+ mode: Editor.modes.js,
+ lineNumbers: false,
+ lineWrapping: true,
+ readOnly: true,
+ styleActiveLine: true,
+ extraKeys: {},
+ theme: "mozilla markup-view",
+ };
+
+ const doc = this._tooltip.doc;
+ this.container = doc.createElementNS(XHTML_NS, "div");
+ this.container.className = "devtools-tooltip-events-container";
+
+ const sourceMapURLService = this._toolbox.sourceMapURLService;
+
+ const Bubbling = L10N.getStr("eventsTooltip.Bubbling");
+ const Capturing = L10N.getStr("eventsTooltip.Capturing");
+ for (const listener of eventListenerInfos) {
+ // Create this early so we can refer to it from a closure, below.
+ const content = doc.createElementNS(XHTML_NS, "div");
+
+ // Header
+ const header = doc.createElementNS(XHTML_NS, "div");
+ header.className = "event-header";
+ const arrow = doc.createElementNS(XHTML_NS, "span");
+ arrow.className = "theme-twisty";
+ header.appendChild(arrow);
+ this.container.appendChild(header);
+
+ if (!listener.hide.type) {
+ const eventTypeLabel = doc.createElementNS(XHTML_NS, "span");
+ eventTypeLabel.className = "event-tooltip-event-type";
+ eventTypeLabel.textContent = listener.type;
+ eventTypeLabel.setAttribute("title", listener.type);
+ header.appendChild(eventTypeLabel);
+ }
+
+ const filename = doc.createElementNS(XHTML_NS, "span");
+ filename.className = "event-tooltip-filename devtools-monospace";
+
+ let location = null;
+ let text = listener.origin;
+ let title = text;
+ if (listener.hide.filename) {
+ text = L10N.getStr("eventsTooltip.unknownLocation");
+ title = L10N.getStr("eventsTooltip.unknownLocationExplanation");
+ } else {
+ location = this._parseLocation(listener.origin);
+
+ // There will be no source actor if the listener is a native function
+ // or wasn't a debuggee, in which case there's also not going to be
+ // a sourcemap, so we don't need to worry about subscribing.
+ if (location && listener.sourceActor) {
+ location.id = listener.sourceActor;
+
+ this._subscriptions.push(
+ sourceMapURLService.subscribeByID(
+ location.id,
+ location.line,
+ location.column,
+ originalLocation => {
+ const currentLoc = originalLocation || location;
+
+ const newURI = currentLoc.url + ":" + currentLoc.line;
+ filename.textContent = newURI;
+ filename.setAttribute("title", newURI);
+
+ // This is emitted for testing.
+ this._tooltip.emitForTests("event-tooltip-source-map-ready");
+ }
+ )
+ );
+ }
+ }
+
+ filename.textContent = text;
+ filename.setAttribute("title", title);
+ header.appendChild(filename);
+
+ if (!listener.hide.debugger) {
+ const debuggerIcon = doc.createElementNS(XHTML_NS, "div");
+ debuggerIcon.className = "event-tooltip-debugger-icon";
+ const openInDebugger = L10N.getStr("eventsTooltip.openInDebugger");
+ debuggerIcon.setAttribute("title", openInDebugger);
+ header.appendChild(debuggerIcon);
+ }
+
+ const attributesContainer = doc.createElementNS(XHTML_NS, "div");
+ attributesContainer.className = "event-tooltip-attributes-container";
+ header.appendChild(attributesContainer);
+
+ if (listener.tags) {
+ for (const tag of listener.tags.split(",")) {
+ const attributesBox = doc.createElementNS(XHTML_NS, "div");
+ attributesBox.className = "event-tooltip-attributes-box";
+ attributesContainer.appendChild(attributesBox);
+
+ const tagBox = doc.createElementNS(XHTML_NS, "span");
+ tagBox.className = "event-tooltip-attributes";
+ tagBox.textContent = tag;
+ tagBox.setAttribute("title", tag);
+ attributesBox.appendChild(tagBox);
+ }
+ }
+
+ if (!listener.hide.capturing) {
+ const attributesBox = doc.createElementNS(XHTML_NS, "div");
+ attributesBox.className = "event-tooltip-attributes-box";
+ attributesContainer.appendChild(attributesBox);
+
+ const capturing = doc.createElementNS(XHTML_NS, "span");
+ capturing.className = "event-tooltip-attributes";
+
+ const phase = listener.capturing ? Capturing : Bubbling;
+ capturing.textContent = phase;
+ capturing.setAttribute("title", phase);
+ attributesBox.appendChild(capturing);
+ }
+
+ const toggleListenerCheckbox = doc.createElementNS(XHTML_NS, "input");
+ toggleListenerCheckbox.type = "checkbox";
+ toggleListenerCheckbox.className =
+ "event-tooltip-listener-toggle-checkbox";
+ if (listener.eventListenerInfoId) {
+ toggleListenerCheckbox.checked = listener.enabled;
+ toggleListenerCheckbox.setAttribute(
+ "data-event-listener-info-id",
+ listener.eventListenerInfoId
+ );
+ toggleListenerCheckbox.addEventListener(
+ "change",
+ this._eventToggleCheckboxChanged,
+ { signal: this._eventListenersAbortController.signal }
+ );
+ } else {
+ toggleListenerCheckbox.checked = true;
+ toggleListenerCheckbox.setAttribute("disabled", true);
+ }
+ header.appendChild(toggleListenerCheckbox);
+
+ // Content
+ const editor = new Editor(config);
+ this._eventEditors.set(content, {
+ editor,
+ handler: listener.handler,
+ native: listener.native,
+ appended: false,
+ location,
+ });
+
+ content.className = "event-tooltip-content-box";
+ this.container.appendChild(content);
+
+ this._addContentListeners(header);
+ }
+
+ this._tooltip.panel.innerHTML = "";
+ this._tooltip.panel.appendChild(this.container);
+ this._tooltip.setContentSize({ width: CONTAINER_WIDTH, height: Infinity });
+ }
+
+ _addContentListeners(header) {
+ header.addEventListener("click", this._headerClicked, {
+ signal: this._eventListenersAbortController.signal,
+ });
+ }
+
+ _headerClicked(event) {
+ // Clicking on the checkbox shouldn't impact the header (checkbox state change is
+ // handled in _eventToggleCheckboxChanged).
+ if (
+ event.target.classList.contains("event-tooltip-listener-toggle-checkbox")
+ ) {
+ event.stopPropagation();
+ return;
+ }
+
+ if (event.target.classList.contains("event-tooltip-debugger-icon")) {
+ this._debugClicked(event);
+ event.stopPropagation();
+ return;
+ }
+
+ const doc = this._tooltip.doc;
+ const header = event.currentTarget;
+ const content = header.nextElementSibling;
+
+ if (content.hasAttribute("open")) {
+ header.classList.remove("content-expanded");
+ content.removeAttribute("open");
+ } else {
+ // Close other open events first
+ const openHeaders = doc.querySelectorAll(
+ ".event-header.content-expanded"
+ );
+ const openContent = doc.querySelectorAll(
+ ".event-tooltip-content-box[open]"
+ );
+ for (const node of openHeaders) {
+ node.classList.remove("content-expanded");
+ }
+ for (const node of openContent) {
+ node.removeAttribute("open");
+ }
+
+ header.classList.add("content-expanded");
+ content.setAttribute("open", "");
+
+ const eventEditor = this._eventEditors.get(content);
+
+ if (eventEditor.appended) {
+ return;
+ }
+
+ const { editor, handler } = eventEditor;
+
+ const iframe = doc.createElementNS(XHTML_NS, "iframe");
+ iframe.classList.add("event-tooltip-editor-frame");
+
+ editor.appendTo(content, iframe).then(() => {
+ const tidied = beautify.js(handler, { indent_size: 2 });
+ editor.setText(tidied);
+
+ eventEditor.appended = true;
+
+ const container = header.parentElement.getBoundingClientRect();
+ if (header.getBoundingClientRect().top < container.top) {
+ header.scrollIntoView(true);
+ } else if (content.getBoundingClientRect().bottom > container.bottom) {
+ content.scrollIntoView(false);
+ }
+
+ this._tooltip.emitForTests("event-tooltip-ready");
+ });
+ }
+ }
+
+ _debugClicked(event) {
+ const header = event.currentTarget;
+ const content = header.nextElementSibling;
+
+ const { location } = this._eventEditors.get(content);
+ if (location) {
+ // Save a copy of toolbox as it will be set to null when we hide the tooltip.
+ const toolbox = this._toolbox;
+
+ this._tooltip.hide();
+
+ toolbox.viewSourceInDebugger(
+ location.url,
+ location.line,
+ location.column,
+ location.id
+ );
+ }
+ }
+
+ async _eventToggleCheckboxChanged(event) {
+ const checkbox = event.currentTarget;
+ const id = checkbox.getAttribute("data-event-listener-info-id");
+ if (checkbox.checked) {
+ await this._nodeFront.enableEventListener(id);
+ } else {
+ await this._nodeFront.disableEventListener(id);
+ }
+ this.emit("event-tooltip-listener-toggled", {
+ hasDisabledEventListeners:
+ // No need to query the other checkboxes if the handled checkbox is unchecked
+ !checkbox.checked ||
+ this._tooltip.doc.querySelector(
+ `input.event-tooltip-listener-toggle-checkbox:not(:checked)`
+ ) !== null,
+ });
+ }
+
+ /**
+ * Parse URI and return {url, line, column}; or return null if it can't be parsed.
+ */
+ _parseLocation(uri) {
+ if (uri && uri !== "?") {
+ uri = uri.replace(/"/g, "");
+
+ let matches = uri.match(/(.*):(\d+):(\d+$)/);
+
+ if (matches) {
+ return {
+ url: matches[1],
+ line: parseInt(matches[2], 10),
+ column: parseInt(matches[3], 10),
+ };
+ } else if ((matches = uri.match(/(.*):(\d+$)/))) {
+ return {
+ url: matches[1],
+ line: parseInt(matches[2], 10),
+ column: null,
+ };
+ }
+ return { url: uri, line: 1, column: null };
+ }
+ return null;
+ }
+
+ destroy() {
+ if (this._tooltip) {
+ const boxes = this.container.querySelectorAll(
+ ".event-tooltip-content-box"
+ );
+
+ for (const box of boxes) {
+ const { editor } = this._eventEditors.get(box);
+ editor.destroy();
+ }
+
+ this._eventEditors = null;
+ this._tooltip.eventTooltip = null;
+ }
+
+ this.clearEvents();
+ if (this._eventListenersAbortController) {
+ this._eventListenersAbortController.abort();
+ this._eventListenersAbortController = null;
+ }
+
+ for (const unsubscribe of this._subscriptions) {
+ unsubscribe();
+ }
+
+ this._toolbox = this._tooltip = this._nodeFront = null;
+ }
+}
+
+module.exports.EventTooltip = EventTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/HTMLTooltip.js b/devtools/client/shared/widgets/tooltip/HTMLTooltip.js
new file mode 100644
index 0000000000..7c219b61af
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/HTMLTooltip.js
@@ -0,0 +1,1061 @@
+/* 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 XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+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 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: -0.5,
+ 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
+ * 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."
+ //
+ // [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 && 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();
+ this.container.classList.toggle("tooltip-container-xul", this.useXulWrapper);
+
+ 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> <!-- div wrapper used to isolate the tooltip container -->
+ // <div> <! the actual tooltip.container element -->
+ this.xulPanelWrapper = this._createXulPanelWrapper();
+ const inner = this.doc.createElementNS(XHTML_NS, "div");
+ inner.classList.add("tooltip-xul-wrapper-inner");
+
+ this.doc.documentElement.appendChild(this.xulPanelWrapper);
+ this.xulPanelWrapper.appendChild(inner);
+ inner.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 = this.doc.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.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 horizonal 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 {
+ const themeWidth = 2 * EXTRA_BORDER[this.type];
+ preferredWidth = this.preferredWidth + themeWidth;
+ }
+
+ 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 + 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;
+ 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");
+ if (this.useXulWrapper) {
+ await this._hideXulWrapper();
+ }
+
+ this.emit("hidden");
+
+ const tooltipHasFocus = 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("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) {
+ this.xulPanelWrapper.moveTo(left, top);
+ },
+
+ _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,
+ };
+ },
+};
diff --git a/devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js b/devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js
new file mode 100644
index 0000000000..aa2cc75eb7
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js
@@ -0,0 +1,145 @@
+/* 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 { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+const L10N = new LocalizationHelper(
+ "devtools/client/locales/inspector.properties"
+);
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+// Default image tooltip max dimension
+const MAX_DIMENSION = 200;
+const CONTAINER_MIN_WIDTH = 100;
+// Should remain synchronized with tooltips.css --image-tooltip-image-padding
+const IMAGE_PADDING = 4;
+// Should remain synchronized with tooltips.css --image-tooltip-label-height
+const LABEL_HEIGHT = 20;
+
+/**
+ * Image preview tooltips should be provided with the naturalHeight and
+ * naturalWidth value for the image to display. This helper loads the provided
+ * image URL in an image object in order to retrieve the image dimensions after
+ * the load.
+ *
+ * @param {Document} doc the document element to use to create the image object
+ * @param {String} imageUrl the url of the image to measure
+ * @return {Promise} returns a promise that will resolve after the iamge load:
+ * - {Number} naturalWidth natural width of the loaded image
+ * - {Number} naturalHeight natural height of the loaded image
+ */
+function getImageDimensions(doc, imageUrl) {
+ return new Promise(resolve => {
+ const imgObj = new doc.defaultView.Image();
+ imgObj.onload = () => {
+ imgObj.onload = null;
+ const { naturalWidth, naturalHeight } = imgObj;
+ resolve({ naturalWidth, naturalHeight });
+ };
+ imgObj.src = imageUrl;
+ });
+}
+
+/**
+ * Set the tooltip content of a provided HTMLTooltip instance to display an
+ * image preview matching the provided imageUrl.
+ *
+ * @param {HTMLTooltip} tooltip
+ * The tooltip instance on which the image preview content should be set
+ * @param {Document} doc
+ * A document element to create the HTML elements needed for the tooltip
+ * @param {String} imageUrl
+ * Absolute URL of the image to display in the tooltip
+ * @param {Object} options
+ * - {Number} naturalWidth mandatory, width of the image to display
+ * - {Number} naturalHeight mandatory, height of the image to display
+ * - {Number} maxDim optional, max width/height of the preview
+ * - {Boolean} hideDimensionLabel optional, pass true to hide the label
+ * - {Boolean} hideCheckeredBackground optional, pass true to hide
+ the checkered background
+ */
+function setImageTooltip(tooltip, doc, imageUrl, options) {
+ let {
+ naturalWidth,
+ naturalHeight,
+ hideDimensionLabel,
+ hideCheckeredBackground,
+ maxDim,
+ } = options;
+ maxDim = maxDim || MAX_DIMENSION;
+
+ let imgHeight = naturalHeight;
+ let imgWidth = naturalWidth;
+ if (imgHeight > maxDim || imgWidth > maxDim) {
+ const scale = maxDim / Math.max(imgHeight, imgWidth);
+ // Only allow integer values to avoid rounding errors.
+ imgHeight = Math.floor(scale * naturalHeight);
+ imgWidth = Math.ceil(scale * naturalWidth);
+ }
+
+ // Create tooltip content
+ const container = doc.createElementNS(XHTML_NS, "div");
+ container.classList.add("devtools-tooltip-image-container");
+
+ const wrapper = doc.createElementNS(XHTML_NS, "div");
+ wrapper.classList.add("devtools-tooltip-image-wrapper");
+ container.appendChild(wrapper);
+
+ const img = doc.createElementNS(XHTML_NS, "img");
+ img.classList.add("devtools-tooltip-image");
+ img.classList.toggle("devtools-tooltip-tiles", !hideCheckeredBackground);
+ img.style.height = imgHeight;
+ img.src = encodeURI(imageUrl);
+ wrapper.appendChild(img);
+
+ if (!hideDimensionLabel) {
+ const dimensions = doc.createElementNS(XHTML_NS, "div");
+ dimensions.classList.add("devtools-tooltip-image-dimensions");
+ container.appendChild(dimensions);
+
+ const label = naturalWidth + " \u00D7 " + naturalHeight;
+ const span = doc.createElementNS(XHTML_NS, "span");
+ span.classList.add("theme-comment", "devtools-tooltip-caption");
+ span.textContent = label;
+ dimensions.appendChild(span);
+ }
+
+ tooltip.panel.innerHTML = "";
+ tooltip.panel.appendChild(container);
+
+ // Calculate tooltip dimensions
+ const width = Math.max(CONTAINER_MIN_WIDTH, imgWidth + 2 * IMAGE_PADDING);
+ let height = imgHeight + 2 * IMAGE_PADDING;
+ if (!hideDimensionLabel) {
+ height += parseFloat(LABEL_HEIGHT);
+ }
+
+ tooltip.setContentSize({ width, height });
+}
+
+/*
+ * Set the tooltip content of a provided HTMLTooltip instance to display a
+ * fallback error message when an image preview tooltip can not be displayed.
+ *
+ * @param {HTMLTooltip} tooltip
+ * The tooltip instance on which the image preview content should be set
+ * @param {Document} doc
+ * A document element to create the HTML elements needed for the tooltip
+ */
+function setBrokenImageTooltip(tooltip, doc) {
+ const div = doc.createElementNS(XHTML_NS, "div");
+ div.className = "theme-comment devtools-tooltip-image-broken";
+ const message = L10N.getStr("previewTooltip.image.brokenImage");
+ div.textContent = message;
+
+ tooltip.panel.innerHTML = "";
+ tooltip.panel.appendChild(div);
+ tooltip.setContentSize({ width: "auto", height: "auto" });
+}
+
+module.exports.getImageDimensions = getImageDimensions;
+module.exports.setImageTooltip = setImageTooltip;
+module.exports.setBrokenImageTooltip = setBrokenImageTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/RulePreviewTooltip.js b/devtools/client/shared/widgets/tooltip/RulePreviewTooltip.js
new file mode 100644
index 0000000000..87e089d604
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/RulePreviewTooltip.js
@@ -0,0 +1,69 @@
+/* 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 { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+const {
+ HTMLTooltip,
+} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js");
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const L10N = new LocalizationHelper(
+ "devtools/client/locales/inspector.properties"
+);
+
+/**
+ * Tooltip displayed for when a CSS property is selected/highlighted.
+ * TODO: For now, the tooltip content only shows "No Associated Rule". In Bug 1528288,
+ * we will be implementing content for showing the source CSS rule.
+ */
+class RulePreviewTooltip {
+ constructor(doc) {
+ this.show = this.show.bind(this);
+ this.destroy = this.destroy.bind(this);
+
+ // Initialize tooltip structure.
+ this._tooltip = new HTMLTooltip(doc, {
+ type: "arrow",
+ consumeOutsideClicks: true,
+ useXulWrapper: true,
+ });
+
+ this.container = doc.createElementNS(XHTML_NS, "div");
+ this.container.className = "rule-preview-tooltip-container";
+
+ this.message = doc.createElementNS(XHTML_NS, "span");
+ this.message.className = "rule-preview-tooltip-message";
+ this.message.textContent = L10N.getStr(
+ "rulePreviewTooltip.noAssociatedRule"
+ );
+ this.container.appendChild(this.message);
+
+ // TODO: Implement structure for showing the source CSS rule.
+
+ this._tooltip.panel.innerHTML = "";
+ this._tooltip.panel.appendChild(this.container);
+ this._tooltip.setContentSize({ width: "auto", height: "auto" });
+ }
+
+ /**
+ * Shows the tooltip on a given element.
+ *
+ * @param {Element} element
+ * The target element to show the tooltip with.
+ */
+ show(element) {
+ element.addEventListener("mouseout", () => this._tooltip.hide());
+ this._tooltip.show(element);
+ }
+
+ destroy() {
+ this._tooltip.destroy();
+ this.container = null;
+ this.message = null;
+ }
+}
+
+module.exports = RulePreviewTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js b/devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js
new file mode 100644
index 0000000000..acc71125e8
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js
@@ -0,0 +1,270 @@
+/* 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");
+const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js");
+const {
+ HTMLTooltip,
+} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js");
+
+loader.lazyRequireGetter(
+ this,
+ "KeyCodes",
+ "resource://devtools/client/shared/keycodes.js",
+ true
+);
+
+/**
+ * Base class for all (color, gradient, ...)-swatch based value editors inside
+ * tooltips
+ *
+ * @param {Document} document
+ * The document to attach the SwatchBasedEditorTooltip. This should be the
+ * toolbox document
+ */
+
+class SwatchBasedEditorTooltip {
+ constructor(document) {
+ EventEmitter.decorate(this);
+
+ // This one will consume outside clicks as it makes more sense to let the user
+ // close the tooltip by clicking out
+ // It will also close on <escape> and <enter>
+ this.tooltip = new HTMLTooltip(document, {
+ type: "arrow",
+ consumeOutsideClicks: true,
+ useXulWrapper: true,
+ });
+
+ // By default, swatch-based editor tooltips revert value change on <esc> and
+ // commit value change on <enter>
+ this.shortcuts = new KeyShortcuts({
+ window: this.tooltip.doc.defaultView,
+ });
+ this.shortcuts.on("Escape", event => {
+ if (!this.tooltip.isVisible()) {
+ return;
+ }
+ this.revert();
+ this.hide();
+ event.stopPropagation();
+ event.preventDefault();
+ });
+ this.shortcuts.on("Return", event => {
+ if (!this.tooltip.isVisible()) {
+ return;
+ }
+ this.commit();
+ this.hide();
+ event.stopPropagation();
+ event.preventDefault();
+ });
+
+ // All target swatches are kept in a map, indexed by swatch DOM elements
+ this.swatches = new Map();
+
+ // When a swatch is clicked, and for as long as the tooltip is shown, the
+ // activeSwatch property will hold the reference to the swatch DOM element
+ // that was clicked
+ this.activeSwatch = null;
+
+ this._onSwatchClick = this._onSwatchClick.bind(this);
+ this._onSwatchKeyDown = this._onSwatchKeyDown.bind(this);
+ }
+
+ /**
+ * Reports if the tooltip is currently shown
+ *
+ * @return {Boolean} True if the tooltip is displayed.
+ */
+ isVisible() {
+ return this.tooltip.isVisible();
+ }
+
+ /**
+ * Reports if the tooltip is currently editing the targeted value
+ *
+ * @return {Boolean} True if the tooltip is editing.
+ */
+ isEditing() {
+ return this.isVisible();
+ }
+
+ /**
+ * Show the editor tooltip for the currently active swatch.
+ *
+ * @return {Promise} a promise that resolves once the editor tooltip is displayed, or
+ * immediately if there is no currently active swatch.
+ */
+ show() {
+ if (this.tooltipAnchor) {
+ const onShown = this.tooltip.once("shown");
+
+ this.tooltip.show(this.tooltipAnchor);
+ this.tooltip.once("hidden", () => this.onTooltipHidden());
+
+ return onShown;
+ }
+
+ return Promise.resolve();
+ }
+
+ /**
+ * Can be overridden by subclasses if implementation specific behavior is needed on
+ * tooltip hidden.
+ */
+ onTooltipHidden() {
+ // When the tooltip is closed by clicking outside the panel we want to commit any
+ // changes.
+ if (!this._reverted) {
+ this.commit();
+ }
+ this._reverted = false;
+
+ // Once the tooltip is hidden we need to clean up any remaining objects.
+ this.activeSwatch = null;
+ }
+
+ hide() {
+ if (this.swatchActivatedWithKeyboard) {
+ this.activeSwatch.focus();
+ this.swatchActivatedWithKeyboard = null;
+ }
+
+ this.tooltip.hide();
+ }
+
+ /**
+ * Add a new swatch DOM element to the list of swatch elements this editor
+ * tooltip knows about. That means from now on, clicking on that swatch will
+ * toggle the editor.
+ *
+ * @param {node} swatchEl
+ * The element to add
+ * @param {object} callbacks
+ * Callbacks that will be executed when the editor wants to preview a
+ * value change, or revert a change, or commit a change.
+ * - onShow: will be called when one of the swatch tooltip is shown
+ * - onPreview: will be called when one of the sub-classes calls
+ * preview
+ * - onRevert: will be called when the user ESCapes out of the tooltip
+ * - onCommit: will be called when the user presses ENTER or clicks
+ * outside the tooltip.
+ */
+ addSwatch(swatchEl, callbacks = {}) {
+ if (!callbacks.onShow) {
+ callbacks.onShow = function () {};
+ }
+ if (!callbacks.onPreview) {
+ callbacks.onPreview = function () {};
+ }
+ if (!callbacks.onRevert) {
+ callbacks.onRevert = function () {};
+ }
+ if (!callbacks.onCommit) {
+ callbacks.onCommit = function () {};
+ }
+
+ this.swatches.set(swatchEl, {
+ callbacks,
+ });
+ swatchEl.addEventListener("click", this._onSwatchClick);
+ swatchEl.addEventListener("keydown", this._onSwatchKeyDown);
+ }
+
+ removeSwatch(swatchEl) {
+ if (this.swatches.has(swatchEl)) {
+ if (this.activeSwatch === swatchEl) {
+ this.hide();
+ this.activeSwatch = null;
+ }
+ swatchEl.removeEventListener("click", this._onSwatchClick);
+ swatchEl.removeEventListener("keydown", this._onSwatchKeyDown);
+ this.swatches.delete(swatchEl);
+ }
+ }
+
+ _onSwatchKeyDown(event) {
+ if (
+ event.keyCode === KeyCodes.DOM_VK_RETURN ||
+ event.keyCode === KeyCodes.DOM_VK_SPACE
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ this._onSwatchClick(event);
+ }
+ }
+
+ _onSwatchClick(event) {
+ const { shiftKey, clientX, clientY, target } = event;
+
+ // If mouse coordinates are 0, the event listener could have been triggered
+ // by a keybaord
+ this.swatchActivatedWithKeyboard =
+ event.key && clientX === 0 && clientY === 0;
+
+ if (shiftKey) {
+ event.stopPropagation();
+ return;
+ }
+
+ const swatch = this.swatches.get(target);
+
+ if (swatch) {
+ this.activeSwatch = target;
+ this.show();
+ swatch.callbacks.onShow();
+ event.stopPropagation();
+ }
+ }
+
+ /**
+ * Not called by this parent class, needs to be taken care of by sub-classes
+ */
+ preview(value) {
+ if (this.activeSwatch) {
+ const swatch = this.swatches.get(this.activeSwatch);
+ swatch.callbacks.onPreview(value);
+ }
+ }
+
+ /**
+ * This parent class only calls this on <esc> keydown
+ */
+ revert() {
+ if (this.activeSwatch) {
+ this._reverted = true;
+ const swatch = this.swatches.get(this.activeSwatch);
+ this.tooltip.once("hidden", () => {
+ swatch.callbacks.onRevert();
+ });
+ }
+ }
+
+ /**
+ * This parent class only calls this on <enter> keydown
+ */
+ commit() {
+ if (this.activeSwatch) {
+ const swatch = this.swatches.get(this.activeSwatch);
+ swatch.callbacks.onCommit();
+ }
+ }
+
+ get tooltipAnchor() {
+ return this.activeSwatch;
+ }
+
+ destroy() {
+ this.swatches.clear();
+ this.activeSwatch = null;
+ this.tooltip.off("keydown", this._onTooltipKeydown);
+ this.tooltip.destroy();
+ this.shortcuts.destroy();
+ }
+}
+
+module.exports = SwatchBasedEditorTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/SwatchColorPickerTooltip.js b/devtools/client/shared/widgets/tooltip/SwatchColorPickerTooltip.js
new file mode 100644
index 0000000000..abcb63333b
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/SwatchColorPickerTooltip.js
@@ -0,0 +1,357 @@
+/* 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 { colorUtils } = require("resource://devtools/shared/css/color.js");
+const Spectrum = require("resource://devtools/client/shared/widgets/Spectrum.js");
+const SwatchBasedEditorTooltip = require("resource://devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js");
+const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+const L10N = new LocalizationHelper(
+ "devtools/client/locales/inspector.properties"
+);
+const { openDocLink } = require("resource://devtools/client/shared/link.js");
+const {
+ A11Y_CONTRAST_LEARN_MORE_LINK,
+} = require("resource://devtools/client/accessibility/constants.js");
+loader.lazyRequireGetter(
+ this,
+ "throttle",
+ "resource://devtools/shared/throttle.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ ["getFocusableElements", "wrapMoveFocus"],
+ "resource://devtools/client/shared/focus.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "PICKER_TYPES",
+ "resource://devtools/shared/picker-constants.js"
+);
+
+const TELEMETRY_PICKER_EYEDROPPER_OPEN_COUNT =
+ "DEVTOOLS_PICKER_EYEDROPPER_OPENED_COUNT";
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * The swatch color picker tooltip class is a specific class meant to be used
+ * along with output-parser's generated color swatches.
+ * It extends the parent SwatchBasedEditorTooltip class.
+ * It just wraps a standard Tooltip and sets its content with an instance of a
+ * color picker.
+ *
+ * The activeSwatch element expected by the tooltip must follow some guidelines
+ * to be compatible with this feature:
+ * - the background-color of the activeSwatch should be set to the current
+ * color, it will be updated when the color is changed via the color-picker.
+ * - the `data-color` attribute should be set either on the activeSwatch or on
+ * a parent node, and should also contain the current color.
+ * - finally if the color value should be displayed next to the swatch as text,
+ * the activeSwatch should have a nextSibling. Note that this sibling may
+ * contain more than just text initially, but it will be updated after a color
+ * change and will only contain the text.
+ *
+ * An example of valid markup (with data-color on a parent and a nextSibling):
+ *
+ * <span data-color="#FFF"> <!-- activeSwatch.closest("[data-color]") -->
+ * <span
+ * style="background-color: rgb(255, 255, 255);"
+ * ></span> <!-- activeSwatch -->
+ * <span>#FFF</span> <!-- activeSwatch.nextSibling -->
+ * </span>
+ *
+ * Another example with everything on the activeSwatch itself:
+ *
+ * <span> <!-- container, to illustrate that the swatch has no sibling here. -->
+ * <span
+ * data-color="#FFF"
+ * style="background-color: rgb(255, 255, 255);"
+ * ></span> <!-- activeSwatch & activeSwatch.closest("[data-color]") -->
+ * </span>
+ *
+ * @param {Document} document
+ * The document to attach the SwatchColorPickerTooltip. This is either the toolbox
+ * document if the tooltip is a popup tooltip or the panel's document if it is an
+ * inline editor.
+ * @param {InspectorPanel} inspector
+ * The inspector panel, needed for the eyedropper.
+ */
+
+class SwatchColorPickerTooltip extends SwatchBasedEditorTooltip {
+ constructor(document, inspector) {
+ super(document);
+ this.inspector = inspector;
+
+ // Creating a spectrum instance. this.spectrum will always be a promise that
+ // resolves to the spectrum instance
+ this.spectrum = this.setColorPickerContent([0, 0, 0, 1]);
+ this._onSpectrumColorChange = this._onSpectrumColorChange.bind(this);
+ this._openEyeDropper = this._openEyeDropper.bind(this);
+ this._openDocLink = this._openDocLink.bind(this);
+ this._onTooltipKeydown = this._onTooltipKeydown.bind(this);
+
+ // Selecting color by hovering on the spectrum widget could create a lot
+ // of requests. Throttle by 50ms to avoid this. See Bug 1665547.
+ this._selectColor = throttle(this._selectColor.bind(this), 50);
+
+ this.tooltip.container.addEventListener("keydown", this._onTooltipKeydown);
+ }
+
+ /**
+ * Fill the tooltip with a new instance of the spectrum color picker widget
+ * initialized with the given color, and return the instance of spectrum
+ */
+
+ setColorPickerContent(color) {
+ const { doc } = this.tooltip;
+ this.tooltip.panel.innerHTML = "";
+
+ const container = doc.createElementNS(XHTML_NS, "div");
+ container.id = "spectrum-tooltip";
+
+ const node = doc.createElementNS(XHTML_NS, "div");
+ node.id = "spectrum";
+ container.appendChild(node);
+
+ const widget = new Spectrum(node, color);
+ this.tooltip.panel.appendChild(container);
+ this.tooltip.setContentSize({ width: 215 });
+
+ widget.inspector = this.inspector;
+
+ // Wait for the tooltip to be shown before calling widget.show
+ // as it expect to be visible in order to compute DOM element sizes.
+ this.tooltip.once("shown", () => {
+ widget.show();
+ });
+
+ return widget;
+ }
+
+ /**
+ * Overriding the SwatchBasedEditorTooltip.show function to set spectrum's
+ * color.
+ */
+ async show() {
+ // set contrast enabled for the spectrum
+ const name = this.activeSwatch.dataset.propertyName;
+ const colorFunction = this.activeSwatch.dataset.colorFunction;
+
+ // Only enable contrast if the type of property is color
+ // and its value isn't inside a color-modifying function (e.g. color-mix()).
+ this.spectrum.contrastEnabled =
+ name === "color" && colorFunction !== "color-mix";
+ if (this.spectrum.contrastEnabled) {
+ const { nodeFront } = this.inspector.selection;
+ const { pageStyle } = nodeFront.inspectorFront;
+ this.spectrum.textProps = await pageStyle.getComputed(nodeFront, {
+ filterProperties: ["font-size", "font-weight", "opacity"],
+ });
+ this.spectrum.backgroundColorData = await nodeFront.getBackgroundColor();
+ }
+
+ // Then set spectrum's color and listen to color changes to preview them
+ if (this.activeSwatch) {
+ this._originalColor = this._getSwatchColorContainer().dataset.color;
+ const color = this.activeSwatch.style.backgroundColor;
+
+ this.spectrum.off("changed", this._onSpectrumColorChange);
+ this.spectrum.rgb = this._colorToRgba(color);
+ this.spectrum.on("changed", this._onSpectrumColorChange);
+ this.spectrum.updateUI();
+ }
+
+ // Call then parent class' show function
+ await super.show();
+
+ const eyeButton =
+ this.tooltip.container.querySelector("#eyedropper-button");
+ const canShowEyeDropper = await this.inspector.supportsEyeDropper();
+ if (canShowEyeDropper) {
+ eyeButton.disabled = false;
+ eyeButton.removeAttribute("title");
+ eyeButton.addEventListener("click", this._openEyeDropper);
+ } else {
+ eyeButton.disabled = true;
+ eyeButton.title = L10N.getStr("eyedropper.disabled.title");
+ }
+
+ const learnMoreButton =
+ this.tooltip.container.querySelector("#learn-more-button");
+ if (learnMoreButton) {
+ learnMoreButton.addEventListener("click", this._openDocLink);
+ learnMoreButton.addEventListener("keydown", e => e.stopPropagation());
+ }
+
+ // Add focus to the first focusable element in the tooltip and attach keydown
+ // event listener to tooltip
+ this.focusableElements[0].focus();
+ this.tooltip.container.addEventListener(
+ "keydown",
+ this._onTooltipKeydown,
+ true
+ );
+
+ this.emit("ready");
+ }
+
+ _onTooltipKeydown(event) {
+ const { target, key, shiftKey } = event;
+
+ if (key !== "Tab") {
+ return;
+ }
+
+ const focusMoved = !!wrapMoveFocus(
+ this.focusableElements,
+ target,
+ shiftKey
+ );
+ if (focusMoved) {
+ // Focus was moved to the begining/end of the tooltip, so we need to prevent the
+ // default focus change that would happen here.
+ event.preventDefault();
+ }
+
+ event.stopPropagation();
+ }
+
+ _getSwatchColorContainer() {
+ // Depending on the UI, the data-color attribute might be set on the
+ // swatch itself, or a parent node.
+ // This data attribute is also used for the "Copy color" feature, so it
+ // can be useful to set it on a container rather than on the swatch.
+ return this.activeSwatch.closest("[data-color]");
+ }
+
+ _onSpectrumColorChange(rgba, cssColor) {
+ this._selectColor(cssColor);
+ }
+
+ _selectColor(color) {
+ if (this.activeSwatch) {
+ this.activeSwatch.style.backgroundColor = color;
+
+ color = this._toDefaultType(color);
+
+ this._getSwatchColorContainer().dataset.color = color;
+ if (this.activeSwatch.nextSibling) {
+ this.activeSwatch.nextSibling.textContent = color;
+ }
+ this.preview(color);
+
+ if (this.eyedropperOpen) {
+ this.commit();
+ }
+ }
+ }
+
+ /**
+ * Override the implementation from SwatchBasedEditorTooltip.
+ */
+ onTooltipHidden() {
+ // If the tooltip is hidden while the eyedropper is being used, we should not commit
+ // the changes.
+ if (this.eyedropperOpen) {
+ return;
+ }
+
+ super.onTooltipHidden();
+ this.tooltip.container.removeEventListener(
+ "keydown",
+ this._onTooltipKeydown
+ );
+ }
+
+ _openEyeDropper() {
+ const { inspectorFront, toolbox, telemetry } = this.inspector;
+
+ telemetry
+ .getHistogramById(TELEMETRY_PICKER_EYEDROPPER_OPEN_COUNT)
+ .add(true);
+
+ // cancelling picker(if it is already selected) on opening eye-dropper
+ toolbox.nodePicker.stop({ canceled: true });
+
+ // disable simulating touch events if RDM is active
+ toolbox.tellRDMAboutPickerState(true, PICKER_TYPES.EYEDROPPER);
+
+ // pickColorFromPage will focus the content document. If the devtools are in a
+ // separate window, the colorpicker tooltip will be closed before pickColorFromPage
+ // resolves. Flip the flag early to avoid issues with onTooltipHidden().
+ this.eyedropperOpen = true;
+
+ inspectorFront.pickColorFromPage({ copyOnSelect: false }).then(() => {
+ // close the colorpicker tooltip so that only the eyedropper is open.
+ this.hide();
+
+ this.tooltip.emit("eyedropper-opened");
+ }, console.error);
+
+ inspectorFront.once("color-picked", color => {
+ toolbox.win.focus();
+ this._selectColor(color);
+ this._onEyeDropperDone();
+ });
+
+ inspectorFront.once("color-pick-canceled", () => {
+ this._onEyeDropperDone();
+ });
+ }
+
+ _openDocLink() {
+ openDocLink(A11Y_CONTRAST_LEARN_MORE_LINK);
+ this.hide();
+ }
+
+ _onEyeDropperDone() {
+ // enable simulating touch events if RDM is active
+ this.inspector.toolbox.tellRDMAboutPickerState(
+ false,
+ PICKER_TYPES.EYEDROPPER
+ );
+
+ this.eyedropperOpen = false;
+ this.activeSwatch = null;
+ }
+
+ _colorToRgba(color) {
+ color = new colorUtils.CssColor(color);
+ const rgba = color.getRGBATuple();
+ return [rgba.r, rgba.g, rgba.b, rgba.a];
+ }
+
+ _toDefaultType(color) {
+ const colorObj = new colorUtils.CssColor(color);
+ colorObj.setAuthoredUnitFromColor(this._originalColor);
+ return colorObj.toString();
+ }
+
+ /**
+ * Overriding the SwatchBasedEditorTooltip.isEditing function to consider the
+ * eyedropper.
+ */
+ isEditing() {
+ return this.tooltip.isVisible() || this.eyedropperOpen;
+ }
+
+ get focusableElements() {
+ return getFocusableElements(this.tooltip.container).filter(
+ el => !!el.offsetParent
+ );
+ }
+
+ destroy() {
+ super.destroy();
+ this.inspector = null;
+ this.spectrum.off("changed", this._onSpectrumColorChange);
+ this.spectrum.destroy();
+ }
+}
+
+module.exports = SwatchColorPickerTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/SwatchCubicBezierTooltip.js b/devtools/client/shared/widgets/tooltip/SwatchCubicBezierTooltip.js
new file mode 100644
index 0000000000..bfec4bccea
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/SwatchCubicBezierTooltip.js
@@ -0,0 +1,95 @@
+/* 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 {
+ CubicBezierWidget,
+} = require("resource://devtools/client/shared/widgets/CubicBezierWidget.js");
+const SwatchBasedEditorTooltip = require("resource://devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js");
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * The swatch cubic-bezier tooltip class is a specific class meant to be used
+ * along with rule-view's generated cubic-bezier swatches.
+ * It extends the parent SwatchBasedEditorTooltip class.
+ * It just wraps a standard Tooltip and sets its content with an instance of a
+ * CubicBezierWidget.
+ *
+ * @param {Document} document
+ * The document to attach the SwatchCubicBezierTooltip. This is either the toolbox
+ * document if the tooltip is a popup tooltip or the panel's document if it is an
+ * inline editor.
+ */
+
+class SwatchCubicBezierTooltip extends SwatchBasedEditorTooltip {
+ constructor(document) {
+ super(document);
+
+ // Creating a cubic-bezier instance.
+ // this.widget will always be a promise that resolves to the widget instance
+ this.widget = this.setCubicBezierContent([0, 0, 1, 1]);
+ this._onUpdate = this._onUpdate.bind(this);
+ }
+
+ /**
+ * Fill the tooltip with a new instance of the cubic-bezier widget
+ * initialized with the given value, and return a promise that resolves to
+ * the instance of the widget
+ */
+
+ async setCubicBezierContent(bezier) {
+ const { doc } = this.tooltip;
+ this.tooltip.panel.innerHTML = "";
+
+ const container = doc.createElementNS(XHTML_NS, "div");
+ container.className = "cubic-bezier-container";
+
+ this.tooltip.panel.appendChild(container);
+ this.tooltip.setContentSize({ width: 510, height: 370 });
+
+ await this.tooltip.once("shown");
+ return new CubicBezierWidget(container, bezier);
+ }
+
+ /**
+ * Overriding the SwatchBasedEditorTooltip.show function to set the cubic
+ * bezier curve in the widget
+ */
+ async show() {
+ // Call the parent class' show function
+ await super.show();
+ // Then set the curve and listen to changes to preview them
+ if (this.activeSwatch) {
+ this.currentBezierValue = this.activeSwatch.nextSibling;
+ this.widget.then(widget => {
+ widget.off("updated", this._onUpdate);
+ widget.cssCubicBezierValue = this.currentBezierValue.textContent;
+ widget.on("updated", this._onUpdate);
+ this.emit("ready");
+ });
+ }
+ }
+
+ _onUpdate(bezier) {
+ if (!this.activeSwatch) {
+ return;
+ }
+
+ this.currentBezierValue.textContent = bezier + "";
+ this.preview(bezier + "");
+ }
+
+ destroy() {
+ super.destroy();
+ this.currentBezierValue = null;
+ this.widget.then(widget => {
+ widget.off("updated", this._onUpdate);
+ widget.destroy();
+ });
+ }
+}
+
+module.exports = SwatchCubicBezierTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/SwatchFilterTooltip.js b/devtools/client/shared/widgets/tooltip/SwatchFilterTooltip.js
new file mode 100644
index 0000000000..cc28176a13
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/SwatchFilterTooltip.js
@@ -0,0 +1,117 @@
+/* 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 {
+ CSSFilterEditorWidget,
+} = require("resource://devtools/client/shared/widgets/FilterWidget.js");
+const SwatchBasedEditorTooltip = require("resource://devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js");
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * The swatch-based css filter tooltip class is a specific class meant to be
+ * used along with rule-view's generated css filter swatches.
+ * It extends the parent SwatchBasedEditorTooltip class.
+ * It just wraps a standard Tooltip and sets its content with an instance of a
+ * CSSFilterEditorWidget.
+ *
+ * @param {Document} document
+ * The document to attach the SwatchFilterTooltip. This is either the toolbox
+ * document if the tooltip is a popup tooltip or the panel's document if it is an
+ * inline editor.
+ */
+
+class SwatchFilterTooltip extends SwatchBasedEditorTooltip {
+ constructor(document) {
+ super(document);
+
+ // Creating a filter editor instance.
+ this.widget = this.setFilterContent("none");
+ this._onUpdate = this._onUpdate.bind(this);
+ }
+
+ /**
+ * Fill the tooltip with a new instance of the CSSFilterEditorWidget
+ * widget initialized with the given filter value, and return a promise
+ * that resolves to the instance of the widget when ready.
+ */
+
+ setFilterContent(filter) {
+ const { doc } = this.tooltip;
+ this.tooltip.panel.innerHTML = "";
+
+ const container = doc.createElementNS(XHTML_NS, "div");
+ container.id = "filter-container";
+
+ this.tooltip.panel.appendChild(container);
+ this.tooltip.setContentSize({ width: 510, height: 200 });
+
+ return new CSSFilterEditorWidget(container, filter);
+ }
+
+ async show() {
+ // Call the parent class' show function
+ await super.show();
+ // Then set the filter value and listen to changes to preview them
+ if (this.activeSwatch) {
+ this.currentFilterValue = this.activeSwatch.nextSibling;
+ this.widget.off("updated", this._onUpdate);
+ this.widget.on("updated", this._onUpdate);
+ this.widget.setCssValue(this.currentFilterValue.textContent);
+ this.widget.render();
+ this.emit("ready");
+ }
+ }
+
+ _onUpdate(filters) {
+ if (!this.activeSwatch) {
+ return;
+ }
+
+ // Remove the old children and reparse the property value to
+ // recompute them.
+ while (this.currentFilterValue.firstChild) {
+ this.currentFilterValue.firstChild.remove();
+ }
+ const node = this._parser.parseCssProperty(
+ "filter",
+ filters,
+ this._options
+ );
+ this.currentFilterValue.appendChild(node);
+
+ this.preview();
+ }
+
+ destroy() {
+ super.destroy();
+ this.currentFilterValue = null;
+ this.widget.off("updated", this._onUpdate);
+ this.widget.destroy();
+ }
+
+ /**
+ * Like SwatchBasedEditorTooltip.addSwatch, but accepts a parser object
+ * to use when previewing the updated property value.
+ *
+ * @param {node} swatchEl
+ * @see SwatchBasedEditorTooltip.addSwatch
+ * @param {object} callbacks
+ * @see SwatchBasedEditorTooltip.addSwatch
+ * @param {object} parser
+ * A parser object; @see OutputParser object
+ * @param {object} options
+ * options to pass to the output parser, with
+ * the option |filterSwatch| set.
+ */
+ addSwatch(swatchEl, callbacks, parser, options) {
+ super.addSwatch(swatchEl, callbacks);
+ this._parser = parser;
+ this._options = options;
+ }
+}
+
+module.exports = SwatchFilterTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/SwatchLinearEasingFunctionTooltip.js b/devtools/client/shared/widgets/tooltip/SwatchLinearEasingFunctionTooltip.js
new file mode 100644
index 0000000000..371bbd79fc
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/SwatchLinearEasingFunctionTooltip.js
@@ -0,0 +1,97 @@
+/* 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 {
+ LinearEasingFunctionWidget,
+} = require("devtools/client/shared/widgets/LinearEasingFunctionWidget");
+const SwatchBasedEditorTooltip = require("devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip");
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * The swatch linear-easing-function tooltip class is a specific class meant to be used
+ * along with rule-view's generated linear-easing-function swatches.
+ * It extends the parent SwatchBasedEditorTooltip class.
+ * It just wraps a standard Tooltip and sets its content with an instance of a
+ * LinearEasingFunctionWidget.
+ *
+ * @param {Document} document
+ * The document to attach the SwatchLinearEasingFunctionTooltip. This is either the toolbox
+ * document if the tooltip is a popup tooltip or the panel's document if it is an
+ * inline editor.
+ */
+
+class SwatchLinearEasingFunctionTooltip extends SwatchBasedEditorTooltip {
+ constructor(document) {
+ super(document);
+
+ this.onWidgetUpdated = this.onWidgetUpdated.bind(this);
+
+ // Creating a linear-easing-function instance.
+ // this.widget will always be a promise that resolves to the widget instance
+ this.widget = this.createWidget();
+ }
+
+ /**
+ * Fill the tooltip with a new instance of the linear-easing-function widget
+ * initialized with the given value, and return a promise that resolves to
+ * the instance of the widget
+ */
+
+ async createWidget() {
+ const { doc } = this.tooltip;
+ this.tooltip.panel.innerHTML = "";
+
+ const container = doc.createElementNS(XHTML_NS, "div");
+ container.className = "linear-easing-function-container";
+
+ this.tooltip.panel.appendChild(container);
+ this.tooltip.setContentSize({ width: 400, height: 400 });
+
+ await this.tooltip.once("shown");
+ return new LinearEasingFunctionWidget(container);
+ }
+
+ /**
+ * Overriding the SwatchBasedEditorTooltip.show function to set the linear function line
+ * in the widget
+ */
+ async show() {
+ // Call the parent class' show function
+ await super.show();
+ // Then set the line and listen to changes to preview them
+ if (this.activeSwatch) {
+ this.ruleViewCurrentLinearValueElement = this.activeSwatch.nextSibling;
+ this.widget.then(widget => {
+ widget.off("updated", this.onWidgetUpdated);
+ widget.setCssLinearValue(this.activeSwatch.getAttribute("data-linear"));
+ widget.on("updated", this.onWidgetUpdated);
+ this.emit("ready");
+ });
+ }
+ }
+
+ onWidgetUpdated(newValue) {
+ if (!this.activeSwatch) {
+ return;
+ }
+
+ this.ruleViewCurrentLinearValueElement.textContent = newValue;
+ this.activeSwatch.setAttribute("data-linear", newValue);
+ this.preview(newValue);
+ }
+
+ destroy() {
+ super.destroy();
+ this.currentFunctionText = null;
+ this.widget.then(widget => {
+ widget.off("updated", this.onWidgetUpdated);
+ widget.destroy();
+ });
+ }
+}
+
+module.exports = SwatchLinearEasingFunctionTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/TooltipToggle.js b/devtools/client/shared/widgets/tooltip/TooltipToggle.js
new file mode 100644
index 0000000000..36280b33ab
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/TooltipToggle.js
@@ -0,0 +1,203 @@
+/* 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 DEFAULT_TOGGLE_DELAY = 50;
+
+/**
+ * Tooltip helper designed to show/hide the tooltip when the mouse hovers over
+ * particular nodes.
+ *
+ * This works by tracking mouse movements on a base container node (baseNode)
+ * and showing the tooltip when the mouse stops moving. A callback can be
+ * provided to the start() method to know whether or not the node being
+ * hovered over should indeed receive the tooltip.
+ */
+function TooltipToggle(tooltip) {
+ this.tooltip = tooltip;
+ this.win = tooltip.doc.defaultView;
+
+ this._onMouseMove = this._onMouseMove.bind(this);
+ this._onMouseOut = this._onMouseOut.bind(this);
+
+ this._onTooltipMouseOver = this._onTooltipMouseOver.bind(this);
+ this._onTooltipMouseOut = this._onTooltipMouseOut.bind(this);
+}
+
+module.exports.TooltipToggle = TooltipToggle;
+
+TooltipToggle.prototype = {
+ /**
+ * Start tracking mouse movements on the provided baseNode to show the
+ * tooltip.
+ *
+ * 2 Ways to make this work:
+ * - Provide a single node to attach the tooltip to, as the baseNode, and
+ * omit the second targetNodeCb argument
+ * - Provide a baseNode that is the container of possibly numerous children
+ * elements that may receive a tooltip. In this case, provide the second
+ * targetNodeCb argument to decide wether or not a child should receive
+ * a tooltip.
+ *
+ * Note that if you call this function a second time, it will itself call
+ * stop() before adding mouse tracking listeners again.
+ *
+ * @param {node} baseNode
+ * The container for all target nodes
+ * @param {Function} targetNodeCb
+ * A function that accepts a node argument and that checks if a tooltip
+ * should be displayed. Possible return values are:
+ * - false (or a falsy value) if the tooltip should not be displayed
+ * - true if the tooltip should be displayed
+ * - a DOM node to display the tooltip on the returned anchor
+ * The function can also return a promise that will resolve to one of
+ * the values listed above.
+ * If omitted, the tooltip will be shown everytime.
+ * @param {Object} options
+ Set of optional arguments:
+ * - {Number} toggleDelay
+ * An optional delay (in ms) that will be observed before showing
+ * and before hiding the tooltip. Defaults to DEFAULT_TOGGLE_DELAY.
+ * - {Boolean} interactive
+ * If enabled, the tooltip is not hidden when mouse leaves the
+ * target element and enters the tooltip. Allows the tooltip
+ * content to be interactive.
+ */
+ start(
+ baseNode,
+ targetNodeCb,
+ { toggleDelay = DEFAULT_TOGGLE_DELAY, interactive = false } = {}
+ ) {
+ this.stop();
+
+ if (!baseNode) {
+ // Calling tool is in the process of being destroyed.
+ return;
+ }
+
+ this._baseNode = baseNode;
+ this._targetNodeCb = targetNodeCb || (() => true);
+ this._toggleDelay = toggleDelay;
+ this._interactive = interactive;
+
+ baseNode.addEventListener("mousemove", this._onMouseMove);
+ baseNode.addEventListener("mouseout", this._onMouseOut);
+
+ if (this._interactive) {
+ this.tooltip.container.addEventListener(
+ "mouseover",
+ this._onTooltipMouseOver
+ );
+ this.tooltip.container.addEventListener(
+ "mouseout",
+ this._onTooltipMouseOut
+ );
+ }
+ },
+
+ /**
+ * If the start() function has been used previously, and you want to get rid
+ * of this behavior, then call this function to remove the mouse movement
+ * tracking
+ */
+ stop() {
+ this.win.clearTimeout(this.toggleTimer);
+
+ if (!this._baseNode) {
+ return;
+ }
+
+ this._baseNode.removeEventListener("mousemove", this._onMouseMove);
+ this._baseNode.removeEventListener("mouseout", this._onMouseOut);
+
+ if (this._interactive) {
+ this.tooltip.container.removeEventListener(
+ "mouseover",
+ this._onTooltipMouseOver
+ );
+ this.tooltip.container.removeEventListener(
+ "mouseout",
+ this._onTooltipMouseOut
+ );
+ }
+
+ this._baseNode = null;
+ this._targetNodeCb = null;
+ this._lastHovered = null;
+ },
+
+ _onMouseMove(event) {
+ if (event.target !== this._lastHovered) {
+ this._lastHovered = event.target;
+
+ this.win.clearTimeout(this.toggleTimer);
+ this.toggleTimer = this.win.setTimeout(() => {
+ this.tooltip.hide();
+ this.isValidHoverTarget(event.target).then(
+ target => {
+ if (target === null || !this._baseNode) {
+ // bail out if no target or if the toggle has been destroyed.
+ return;
+ }
+ this.tooltip.show(target);
+ },
+ reason => {
+ console.error(
+ "isValidHoverTarget rejected with unexpected reason:"
+ );
+ console.error(reason);
+ }
+ );
+ }, this._toggleDelay);
+ }
+ },
+
+ /**
+ * Is the given target DOMNode a valid node for toggling the tooltip on hover.
+ * This delegates to the user-defined _targetNodeCb callback.
+ * @return {Promise} a promise that will resolve the anchor to use for the
+ * tooltip or null if no valid target was found.
+ */
+ async isValidHoverTarget(target) {
+ const res = await this._targetNodeCb(target, this.tooltip);
+ if (res) {
+ return res.nodeName ? res : target;
+ }
+
+ return null;
+ },
+
+ _onMouseOut(event) {
+ // Only hide the tooltip if the mouse leaves baseNode.
+ if (
+ event &&
+ this._baseNode &&
+ this._baseNode.contains(event.relatedTarget)
+ ) {
+ return;
+ }
+
+ this._lastHovered = null;
+ this.win.clearTimeout(this.toggleTimer);
+ this.toggleTimer = this.win.setTimeout(() => {
+ this.tooltip.hide();
+ }, this._toggleDelay);
+ },
+
+ _onTooltipMouseOver() {
+ this.win.clearTimeout(this.toggleTimer);
+ },
+
+ _onTooltipMouseOut() {
+ this.win.clearTimeout(this.toggleTimer);
+ this.toggleTimer = this.win.setTimeout(() => {
+ this.tooltip.hide();
+ }, this._toggleDelay);
+ },
+
+ destroy() {
+ this.stop();
+ },
+};
diff --git a/devtools/client/shared/widgets/tooltip/VariableTooltipHelper.js b/devtools/client/shared/widgets/tooltip/VariableTooltipHelper.js
new file mode 100644
index 0000000000..bd458fbbf1
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/VariableTooltipHelper.js
@@ -0,0 +1,31 @@
+/* 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 XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * Set the tooltip content of a provided HTMLTooltip instance to display a
+ * variable preview matching the provided text.
+ *
+ * @param {HTMLTooltip} tooltip
+ * The tooltip instance on which the text preview content should be set.
+ * @param {Document} doc
+ * A document element to create the HTML elements needed for the tooltip.
+ * @param {String} text
+ * Text to display in tooltip.
+ */
+function setVariableTooltip(tooltip, doc, text) {
+ // Create tooltip content
+ const div = doc.createElementNS(XHTML_NS, "div");
+ div.classList.add("devtools-monospace", "devtools-tooltip-css-variable");
+ div.textContent = text;
+
+ tooltip.panel.innerHTML = "";
+ tooltip.panel.appendChild(div);
+ tooltip.setContentSize({ width: "auto", height: "auto" });
+}
+
+module.exports.setVariableTooltip = setVariableTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/css-compatibility-tooltip-helper.js b/devtools/client/shared/widgets/tooltip/css-compatibility-tooltip-helper.js
new file mode 100644
index 0000000000..40755a212b
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/css-compatibility-tooltip-helper.js
@@ -0,0 +1,292 @@
+/* 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 { BrowserLoader } = ChromeUtils.import(
+ "resource://devtools/shared/loader/browser-loader.js"
+);
+
+loader.lazyRequireGetter(
+ this,
+ "openDocLink",
+ "resource://devtools/client/shared/link.js",
+ true
+);
+
+class CssCompatibilityTooltipHelper {
+ constructor() {
+ this.addTab = this.addTab.bind(this);
+ }
+
+ #currentTooltip = null;
+ #currentUrl = null;
+
+ #createElement(doc, tag, classList = [], attributeList = {}) {
+ const XHTML_NS = "http://www.w3.org/1999/xhtml";
+ const newElement = doc.createElementNS(XHTML_NS, tag);
+ for (const elementClass of classList) {
+ newElement.classList.add(elementClass);
+ }
+
+ for (const key in attributeList) {
+ newElement.setAttribute(key, attributeList[key]);
+ }
+
+ return newElement;
+ }
+
+ /*
+ * Attach the UnsupportedBrowserList component to the
+ * ".compatibility-browser-list-wrapper" div to render the
+ * unsupported browser list
+ */
+ #renderUnsupportedBrowserList(container, unsupportedBrowsers) {
+ // Mount the ReactDOM only if the unsupported browser
+ // list is not empty. Else "compatibility-browser-list-wrapper"
+ // is not defined. For example, for property clip,
+ // unsupportedBrowsers is an empty array
+ if (!unsupportedBrowsers.length) {
+ return;
+ }
+
+ const { require } = BrowserLoader({
+ baseURI: "resource://devtools/client/shared/widgets/tooltip/",
+ window: this.#currentTooltip.doc.defaultView,
+ });
+ const {
+ createFactory,
+ createElement,
+ } = require("resource://devtools/client/shared/vendor/react.js");
+ const ReactDOM = require("resource://devtools/client/shared/vendor/react-dom.js");
+ const UnsupportedBrowserList = createFactory(
+ require("resource://devtools/client/inspector/compatibility/components/UnsupportedBrowserList.js")
+ );
+
+ const unsupportedBrowserList = createElement(UnsupportedBrowserList, {
+ browsers: unsupportedBrowsers,
+ });
+ ReactDOM.render(
+ unsupportedBrowserList,
+ container.querySelector(".compatibility-browser-list-wrapper")
+ );
+ }
+
+ /*
+ * Get the first paragraph for the compatibility tooltip
+ * Return a subtree similar to:
+ * <p data-l10n-id="css-compatibility-default-message"
+ * data-l10n-args="{&quot;property&quot;:&quot;user-select&quot;}">
+ * </p>
+ */
+ #getCompatibilityMessage(doc, data) {
+ const { msgId, property } = data;
+ return this.#createElement(doc, "p", [], {
+ "data-l10n-id": msgId,
+ "data-l10n-args": JSON.stringify({ property }),
+ });
+ }
+
+ /**
+ * Gets the paragraph elements related to the browserList.
+ * This returns an array with following subtree:
+ * [
+ * <p data-l10n-id="css-compatibility-browser-list-message"></p>,
+ * <p>
+ * <ul class="compatibility-unsupported-browser-list">
+ * <list-element />
+ * </ul>
+ * </p>
+ * ]
+ * The first element is the message and the second element is the
+ * unsupported browserList itself.
+ * If the unsupportedBrowser is an empty array, we return an empty
+ * array back.
+ */
+ #getBrowserListContainer(doc, unsupportedBrowsers) {
+ if (!unsupportedBrowsers.length) {
+ return null;
+ }
+
+ const browserList = this.#createElement(doc, "p");
+ const browserListWrapper = this.#createElement(doc, "div", [
+ "compatibility-browser-list-wrapper",
+ ]);
+ browserList.appendChild(browserListWrapper);
+
+ return browserList;
+ }
+
+ /*
+ * This is the learn more message element linking to the MDN documentation
+ * for the particular incompatible CSS declaration.
+ * The element returned is:
+ * <p data-l10n-id="css-compatibility-learn-more-message"
+ * data-l10n-args="{&quot;property&quot;:&quot;user-select&quot;}">
+ * <span data-l10n-name="link" class="link"></span>
+ * </p>
+ */
+ #getLearnMoreMessage(doc, { rootProperty }) {
+ const learnMoreMessage = this.#createElement(doc, "p", [], {
+ "data-l10n-id": "css-compatibility-learn-more-message",
+ "data-l10n-args": JSON.stringify({ rootProperty }),
+ });
+ learnMoreMessage.appendChild(
+ this.#createElement(doc, "span", ["link"], {
+ "data-l10n-name": "link",
+ })
+ );
+
+ return learnMoreMessage;
+ }
+
+ /**
+ * Fill the tooltip with inactive CSS information.
+ *
+ * @param {Object} data
+ * An object in the following format: {
+ * // Type of compatibility issue
+ * type: <string>,
+ * // The CSS declaration that has compatibility issues
+ * // The raw CSS declaration name that has compatibility issues
+ * declaration: <string>,
+ * property: <string>,
+ * // Alias to the given CSS property
+ * alias: <Array>,
+ * // Link to MDN documentation for the particular CSS rule
+ * url: <string>,
+ * deprecated: <boolean>,
+ * experimental: <boolean>,
+ * // An array of all the browsers that don't support the given CSS rule
+ * unsupportedBrowsers: <Array>,
+ * }
+ * @param {HTMLTooltip} tooltip
+ * The tooltip we are targetting.
+ */
+ async setContent(data, tooltip) {
+ const fragment = this.getTemplate(data, tooltip);
+ const { doc } = tooltip;
+
+ tooltip.panel.innerHTML = "";
+
+ tooltip.panel.addEventListener("click", this.addTab);
+ tooltip.once("hidden", () => {
+ tooltip.panel.removeEventListener("click", this.addTab);
+ });
+
+ // Because Fluent is async we need to manually translate the fragment and
+ // then insert it into the tooltip. This is needed in order for the tooltip
+ // to size to the contents properly and for tests.
+ await doc.l10n.translateFragment(fragment);
+ doc.l10n.pauseObserving();
+ tooltip.panel.appendChild(fragment);
+ doc.l10n.resumeObserving();
+
+ // Size the content.
+ tooltip.setContentSize({ width: 267, height: Infinity });
+ }
+
+ /**
+ * Get the template that the Fluent string will be merged with. This template
+ * looks like this:
+ *
+ * <div class="devtools-tooltip-css-compatibility">
+ * <p data-l10n-id="css-compatibility-default-message"
+ * data-l10n-args="{&quot;property&quot;:&quot;user-select&quot;}">
+ * <strong></strong>
+ * </p>
+ * <browser-list />
+ * <p data-l10n-id="css-compatibility-learn-more-message"
+ * data-l10n-args="{&quot;property&quot;:&quot;user-select&quot;}">
+ * <span data-l10n-name="link" class="link"></span>
+ * <strong></strong>
+ * </p>
+ * </div>
+ *
+ * @param {Object} data
+ * An object in the following format: {
+ * // Type of compatibility issue
+ * type: <string>,
+ * // The CSS declaration that has compatibility issues
+ * // The raw CSS declaration name that has compatibility issues
+ * declaration: <string>,
+ * property: <string>,
+ * // Alias to the given CSS property
+ * alias: <Array>,
+ * // Link to MDN documentation for the particular CSS rule
+ * url: <string>,
+ * // Link to the spec for the particular CSS rule
+ * specUrl: <string>,
+ * deprecated: <boolean>,
+ * experimental: <boolean>,
+ * // An array of all the browsers that don't support the given CSS rule
+ * unsupportedBrowsers: <Array>,
+ * }
+ * @param {HTMLTooltip} tooltip
+ * The tooltip we are targetting.
+ */
+ getTemplate(data, tooltip) {
+ const { doc } = tooltip;
+ const { specUrl, url, unsupportedBrowsers } = data;
+
+ this.#currentTooltip = tooltip;
+ this.#currentUrl = url
+ ? `${url}?utm_source=devtools&utm_medium=inspector-css-compatibility&utm_campaign=default`
+ : specUrl;
+ const templateNode = this.#createElement(doc, "template");
+
+ const tooltipContainer = this.#createElement(doc, "div", [
+ "devtools-tooltip-css-compatibility",
+ ]);
+
+ tooltipContainer.appendChild(this.#getCompatibilityMessage(doc, data));
+ const browserListContainer = this.#getBrowserListContainer(
+ doc,
+ unsupportedBrowsers
+ );
+ if (browserListContainer) {
+ tooltipContainer.appendChild(browserListContainer);
+ this.#renderUnsupportedBrowserList(tooltipContainer, unsupportedBrowsers);
+ }
+
+ if (this.#currentUrl) {
+ tooltipContainer.appendChild(this.#getLearnMoreMessage(doc, data));
+ }
+
+ templateNode.content.appendChild(tooltipContainer);
+ return doc.importNode(templateNode.content, true);
+ }
+
+ /**
+ * Hide the tooltip, open `this.#currentUrl` in a new tab and focus it.
+ *
+ * @param {DOMEvent} event
+ * The click event originating from the tooltip.
+ *
+ */
+ addTab(event) {
+ // The XUL panel swallows click events so handlers can't be added directly
+ // to the link span. As a workaround we listen to all click events in the
+ // panel and if a link span is clicked we proceed.
+ if (event.target.className !== "link") {
+ return;
+ }
+
+ const tooltip = this.#currentTooltip;
+ tooltip.hide();
+
+ const isMacOS = Services.appinfo.OS === "Darwin";
+ openDocLink(this.#currentUrl, {
+ relatedToCurrent: true,
+ inBackground: isMacOS ? event.metaKey : event.ctrlKey,
+ });
+ }
+
+ destroy() {
+ this.#currentTooltip = null;
+ this.#currentUrl = null;
+ }
+}
+
+module.exports = CssCompatibilityTooltipHelper;
diff --git a/devtools/client/shared/widgets/tooltip/css-query-container-tooltip-helper.js b/devtools/client/shared/widgets/tooltip/css-query-container-tooltip-helper.js
new file mode 100644
index 0000000000..633e57b9cf
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/css-query-container-tooltip-helper.js
@@ -0,0 +1,145 @@
+/* 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 XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+class CssQueryContainerTooltipHelper {
+ /**
+ * Fill the tooltip with container information.
+ */
+ async setContent(data, tooltip) {
+ const res = await data.rule.domRule.getQueryContainerForNode(
+ data.ancestorIndex,
+ data.rule.inherited ||
+ data.rule.elementStyle.ruleView.inspector.selection.nodeFront
+ );
+
+ const fragment = this.#getTemplate(res, tooltip);
+ tooltip.panel.innerHTML = "";
+ tooltip.panel.appendChild(fragment);
+
+ // Size the content.
+ tooltip.setContentSize({ width: 267, height: Infinity });
+ }
+
+ /**
+ * Get the template of the tooltip.
+ *
+ * @param {Object} data
+ * @param {NodeFront} data.node
+ * @param {string} data.containerType
+ * @param {string} data.inlineSize
+ * @param {string} data.blockSize
+ * @param {HTMLTooltip} tooltip
+ * The tooltip we are targetting.
+ */
+ #getTemplate(data, tooltip) {
+ const { doc } = tooltip;
+
+ const templateNode = doc.createElementNS(XHTML_NS, "template");
+
+ const tooltipContainer = doc.createElementNS(XHTML_NS, "div");
+ tooltipContainer.classList.add("devtools-tooltip-query-container");
+ templateNode.content.appendChild(tooltipContainer);
+
+ const nodeContainer = doc.createElementNS(XHTML_NS, "header");
+ tooltipContainer.append(nodeContainer);
+
+ const containerQueryLabel = doc.createElementNS(XHTML_NS, "span");
+ containerQueryLabel.classList.add("property-name");
+ containerQueryLabel.appendChild(doc.createTextNode(`query container`));
+
+ const nodeEl = doc.createElementNS(XHTML_NS, "span");
+ nodeEl.classList.add("objectBox-node");
+ nodeContainer.append(doc.createTextNode("<"), nodeEl);
+
+ const nodeNameEl = doc.createElementNS(XHTML_NS, "span");
+ nodeNameEl.classList.add("tag-name");
+ nodeNameEl.appendChild(
+ doc.createTextNode(data.node.nodeName.toLowerCase())
+ );
+
+ nodeEl.appendChild(nodeNameEl);
+
+ if (data.node.id) {
+ const idEl = doc.createElementNS(XHTML_NS, "span");
+ idEl.classList.add("attribute-name");
+ idEl.appendChild(doc.createTextNode(`#${data.node.id}`));
+ nodeEl.appendChild(idEl);
+ }
+
+ for (const attr of data.node.attributes) {
+ if (attr.name !== "class") {
+ continue;
+ }
+ for (const cls of attr.value.split(/\s/)) {
+ const el = doc.createElementNS(XHTML_NS, "span");
+ el.classList.add("attribute-name");
+ el.appendChild(doc.createTextNode(`.${cls}`));
+ nodeEl.appendChild(el);
+ }
+ }
+ nodeContainer.append(doc.createTextNode(">"));
+
+ const ul = doc.createElementNS(XHTML_NS, "ul");
+ tooltipContainer.appendChild(ul);
+
+ const containerTypeEl = doc.createElementNS(XHTML_NS, "li");
+ const containerTypeLabel = doc.createElementNS(XHTML_NS, "span");
+ containerTypeLabel.classList.add("property-name");
+ containerTypeLabel.appendChild(doc.createTextNode(`container-type`));
+
+ const containerTypeValue = doc.createElementNS(XHTML_NS, "span");
+ containerTypeValue.classList.add("property-value");
+ containerTypeValue.appendChild(doc.createTextNode(data.containerType));
+
+ containerTypeEl.append(
+ containerTypeLabel,
+ doc.createTextNode(": "),
+ containerTypeValue
+ );
+ ul.appendChild(containerTypeEl);
+
+ const inlineSizeEl = doc.createElementNS(XHTML_NS, "li");
+
+ const inlineSizeLabel = doc.createElementNS(XHTML_NS, "span");
+ inlineSizeLabel.classList.add("property-name");
+ inlineSizeLabel.appendChild(doc.createTextNode(`inline-size`));
+
+ const inlineSizeValue = doc.createElementNS(XHTML_NS, "span");
+ inlineSizeValue.classList.add("property-value");
+ inlineSizeValue.appendChild(doc.createTextNode(data.inlineSize));
+
+ inlineSizeEl.append(
+ inlineSizeLabel,
+ doc.createTextNode(": "),
+ inlineSizeValue
+ );
+ ul.appendChild(inlineSizeEl);
+
+ if (data.containerType != "inline-size") {
+ const blockSizeEl = doc.createElementNS(XHTML_NS, "li");
+ const blockSizeLabel = doc.createElementNS(XHTML_NS, "span");
+ blockSizeLabel.classList.add("property-name");
+ blockSizeLabel.appendChild(doc.createTextNode(`block-size`));
+
+ const blockSizeValue = doc.createElementNS(XHTML_NS, "span");
+ blockSizeValue.classList.add("property-value");
+ blockSizeValue.appendChild(doc.createTextNode(data.blockSize));
+
+ blockSizeEl.append(
+ blockSizeLabel,
+ doc.createTextNode(": "),
+ blockSizeValue
+ );
+ ul.appendChild(blockSizeEl);
+ }
+
+ return doc.importNode(templateNode.content, true);
+ }
+}
+
+module.exports = CssQueryContainerTooltipHelper;
diff --git a/devtools/client/shared/widgets/tooltip/inactive-css-tooltip-helper.js b/devtools/client/shared/widgets/tooltip/inactive-css-tooltip-helper.js
new file mode 100644
index 0000000000..38ecd282f4
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/inactive-css-tooltip-helper.js
@@ -0,0 +1,127 @@
+/* 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";
+
+loader.lazyRequireGetter(
+ this,
+ "openDocLink",
+ "resource://devtools/client/shared/link.js",
+ true
+);
+
+class InactiveCssTooltipHelper {
+ constructor() {
+ this.addTab = this.addTab.bind(this);
+ }
+
+ /**
+ * Fill the tooltip with inactive CSS information.
+ *
+ * @param {String} propertyName
+ * The property name to be displayed in bold.
+ * @param {String} text
+ * The main text, which follows property name.
+ */
+ async setContent(data, tooltip) {
+ const fragment = this.getTemplate(data, tooltip);
+ const { doc } = tooltip;
+
+ tooltip.panel.innerHTML = "";
+
+ tooltip.panel.addEventListener("click", this.addTab);
+ tooltip.once("hidden", () => {
+ tooltip.panel.removeEventListener("click", this.addTab);
+ });
+
+ // Because Fluent is async we need to manually translate the fragment and
+ // then insert it into the tooltip. This is needed in order for the tooltip
+ // to size to the contents properly and for tests.
+ await doc.l10n.translateFragment(fragment);
+ doc.l10n.pauseObserving();
+ tooltip.panel.appendChild(fragment);
+ doc.l10n.resumeObserving();
+
+ // Size the content.
+ tooltip.setContentSize({ width: 267, height: Infinity });
+ }
+
+ /**
+ * Get the template that the Fluent string will be merged with. This template
+ * looks something like this but there is a variable amount of properties in the
+ * fix section:
+ *
+ * <div class="devtools-tooltip-inactive-css">
+ * <p data-l10n-id="inactive-css-not-grid-or-flex-container"
+ * data-l10n-args="{&quot;property&quot;:&quot;align-content&quot;}">
+ * </p>
+ * <p data-l10n-id="inactive-css-not-grid-or-flex-container-fix">
+ * <span data-l10n-name="link" class="link"></span>
+ * </p>
+ * </div>
+ *
+ * @param {Object} data
+ * An object in the following format: {
+ * fixId: "inactive-css-not-grid-item-fix-2", // Fluent id containing the
+ * // Inactive CSS fix.
+ * msgId: "inactive-css-not-grid-item", // Fluent id containing the
+ * // Inactive CSS message.
+ * property: "color", // Property name
+ * }
+ * @param {HTMLTooltip} tooltip
+ * The tooltip we are targetting.
+ */
+ getTemplate(data, tooltip) {
+ const XHTML_NS = "http://www.w3.org/1999/xhtml";
+ const { fixId, msgId, property, display, learnMoreURL } = data;
+ const { doc } = tooltip;
+
+ const documentURL =
+ learnMoreURL || `https://developer.mozilla.org/docs/Web/CSS/${property}`;
+ this._currentTooltip = tooltip;
+ this._currentUrl = `${documentURL}?utm_source=devtools&utm_medium=inspector-inactive-css`;
+
+ const templateNode = doc.createElementNS(XHTML_NS, "template");
+
+ // eslint-disable-next-line
+ templateNode.innerHTML = `
+ <div class="devtools-tooltip-inactive-css">
+ <p data-l10n-id="${msgId}"
+ data-l10n-args='${JSON.stringify({ property, display })}'>
+ </p>
+ <p data-l10n-id="${fixId}">
+ <span data-l10n-name="link" class="link"></span>
+ </p>
+ </div>`;
+
+ return doc.importNode(templateNode.content, true);
+ }
+
+ /**
+ * Hide the tooltip, open `this._currentUrl` in a new tab and focus it.
+ *
+ * @param {DOMEvent} event
+ * The click event originating from the tooltip.
+ *
+ */
+ addTab(event) {
+ // The XUL panel swallows click events so handlers can't be added directly
+ // to the link span. As a workaround we listen to all click events in the
+ // panel and if a link span is clicked we proceed.
+ if (event.target.className !== "link") {
+ return;
+ }
+
+ const tooltip = this._currentTooltip;
+ tooltip.hide();
+ openDocLink(this._currentUrl);
+ }
+
+ destroy() {
+ this._currentTooltip = null;
+ this._currentUrl = null;
+ }
+}
+
+module.exports = InactiveCssTooltipHelper;
diff --git a/devtools/client/shared/widgets/tooltip/moz.build b/devtools/client/shared/widgets/tooltip/moz.build
new file mode 100644
index 0000000000..40effd4196
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/moz.build
@@ -0,0 +1,22 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ "css-compatibility-tooltip-helper.js",
+ "css-query-container-tooltip-helper.js",
+ "EventTooltipHelper.js",
+ "HTMLTooltip.js",
+ "ImageTooltipHelper.js",
+ "inactive-css-tooltip-helper.js",
+ "RulePreviewTooltip.js",
+ "SwatchBasedEditorTooltip.js",
+ "SwatchColorPickerTooltip.js",
+ "SwatchCubicBezierTooltip.js",
+ "SwatchFilterTooltip.js",
+ "SwatchLinearEasingFunctionTooltip.js",
+ "TooltipToggle.js",
+ "VariableTooltipHelper.js",
+)