Bug 1270462 - part1: extract devtools tooltip toggle logic to separate file;r=bgrins,jsnajdr draft
authorJulian Descottes <jdescottes@mozilla.com>
Fri, 06 May 2016 14:54:30 +0200
changeset 364350 5dcca2d5887ffc98fec621092640073a0909c13f
parent 363787 989b5ce5f7cf848a8dcacae1fab74388257b63eb
child 364351 279bab30f631a3a65a93b52226c6980210abf2f1
push id17416
push userjdescottes@mozilla.com
push dateFri, 06 May 2016 12:57:02 +0000
reviewersbgrins, jsnajdr
bugs1270462
milestone49.0a1
Bug 1270462 - part1: extract devtools tooltip toggle logic to separate file;r=bgrins,jsnajdr The code used to make the tooltip appear/disappear when hovering targets has been extracted to a separate class that can be shared between the current Tooltip.js implementation and the upcoming HTMLTooltip. MozReview-Commit-ID: UYSjPFeMYK
devtools/client/debugger/debugger-view.js
devtools/client/debugger/test/mochitest/head.js
devtools/client/debugger/views/variable-bubble-view.js
devtools/client/inspector/markup/test/browser_markup_dragdrop_tooltip.js
devtools/client/inspector/markup/test/browser_markup_image_tooltip.js
devtools/client/inspector/markup/test/browser_markup_image_tooltip_mutations.js
devtools/client/inspector/rules/test/head.js
devtools/client/inspector/shared/test/head.js
devtools/client/inspector/test/head.js
devtools/client/netmonitor/test/browser_net_image-tooltip.js
devtools/client/shared/widgets/Tooltip.js
devtools/client/shared/widgets/moz.build
devtools/client/shared/widgets/tooltip/TooltipToggle.js
devtools/client/shared/widgets/tooltip/moz.build
--- a/devtools/client/debugger/debugger-view.js
+++ b/devtools/client/debugger/debugger-view.js
@@ -18,18 +18,16 @@ const GLOBAL_SEARCH_LINE_MAX_LENGTH = 30
 const GLOBAL_SEARCH_ACTION_MAX_DELAY = 1500; // ms
 const FUNCTION_SEARCH_ACTION_MAX_DELAY = 400; // ms
 const SEARCH_GLOBAL_FLAG = "!";
 const SEARCH_FUNCTION_FLAG = "@";
 const SEARCH_TOKEN_FLAG = "#";
 const SEARCH_LINE_FLAG = ":";
 const SEARCH_VARIABLE_FLAG = "*";
 const SEARCH_AUTOFILL = [SEARCH_GLOBAL_FLAG, SEARCH_FUNCTION_FLAG, SEARCH_TOKEN_FLAG];
-const EDITOR_VARIABLE_HOVER_DELAY = 750; // ms
-const EDITOR_VARIABLE_POPUP_POSITION = "topcenter bottomleft";
 const TOOLBAR_ORDER_POPUP_POSITION = "topcenter bottomleft";
 const RESIZE_REFRESH_RATE = 50; // ms
 const PROMISE_DEBUGGER_URL =
   "chrome://devtools/content/promisedebugger/promise-debugger.xhtml";
 
 const EventListenersView = require('./content/views/event-listeners-view');
 const SourcesView = require('./content/views/sources-view');
 var actions = Object.assign(
--- a/devtools/client/debugger/test/mochitest/head.js
+++ b/devtools/client/debugger/test/mochitest/head.js
@@ -841,17 +841,17 @@ function intendOpenVarPopup(aPanel, aPos
   window.setTimeout(
     function() {
       if(tooltip.isEmpty()) {
         deferred.resolve(false);
       } else {
         deferred.resolve(true);
       }
     },
-    tooltip.defaultShowDelay + 1000
+    bubble.TOOLTIP_SHOW_DELAY + 1000
   );
 
   return deferred.promise;
 }
 
 function hideVarPopup(aPanel) {
   let bubble = aPanel.panelWin.DebuggerView.VariableBubble;
   let tooltip = bubble._tooltip.panel;
--- a/devtools/client/debugger/views/variable-bubble-view.js
+++ b/devtools/client/debugger/views/variable-bubble-view.js
@@ -21,16 +21,27 @@ function VariableBubbleView(DebuggerCont
 
   this._onMouseMove = this._onMouseMove.bind(this);
   this._onMouseOut = this._onMouseOut.bind(this);
   this._onPopupHiding = this._onPopupHiding.bind(this);
 }
 
 VariableBubbleView.prototype = {
   /**
+   * Delay before showing the variables bubble tooltip when hovering a valid
+   * target.
+   */
+  TOOLTIP_SHOW_DELAY: 750,
+
+  /**
+   * Tooltip position for the variables bubble tooltip.
+   */
+  TOOLTIP_POSITION: "topcenter bottomleft",
+
+  /**
    * Initialization function, called when the debugger is started.
    */
   initialize: function() {
     dumpn("Initializing the VariableBubbleView");
 
     this._toolbox = DebuggerController._toolbox;
     this._editorContainer = document.getElementById("editor");
     this._editorContainer.addEventListener("mousemove", this._onMouseMove, false);
@@ -44,18 +55,17 @@ VariableBubbleView.prototype = {
         emitter: this._editorContainer,
         event: "scroll",
         useCapture: true
       }, {
         emitter: document,
         event: "keydown"
       }]
     });
-    this._tooltip.defaultPosition = EDITOR_VARIABLE_POPUP_POSITION;
-    this._tooltip.defaultShowDelay = EDITOR_VARIABLE_HOVER_DELAY;
+    this._tooltip.defaultPosition = this.TOOLTIP_POSITION;
     this._tooltip.panel.addEventListener("popuphiding", this._onPopupHiding);
   },
 
   /**
    * Destruction function, called when the debugger is closed.
    */
   destroy: function() {
     dumpn("Destroying the VariableBubbleView");
@@ -270,17 +280,17 @@ VariableBubbleView.prototype = {
     let isPopupVisible = !this._tooltip.isHidden();
     if (isResumed || isSelecting || isPopupVisible) {
       clearNamedTimeout("editor-mouse-move");
       return;
     }
     // Allow events to settle down first. If the mouse hovers over
     // a certain point in the editor long enough, try showing a variable bubble.
     setNamedTimeout("editor-mouse-move",
-      EDITOR_VARIABLE_HOVER_DELAY, () => this._findIdentifier(e.clientX, e.clientY));
+      this.TOOLTIP_SHOW_DELAY, () => this._findIdentifier(e.clientX, e.clientY));
   },
 
   /**
    * The mouseout listener for the source editor container node.
    */
   _onMouseOut: function() {
     clearNamedTimeout("editor-mouse-move");
   },
--- a/devtools/client/inspector/markup/test/browser_markup_dragdrop_tooltip.js
+++ b/devtools/client/inspector/markup/test/browser_markup_dragdrop_tooltip.js
@@ -11,30 +11,30 @@ add_task(function* () {
   let {inspector} = yield openInspectorForURL(TEST_URL);
   let {markup} = inspector;
 
   info("Get the tooltip target element for the image's src attribute");
   let img = yield getContainerForSelector("img", inspector);
   let target = img.editor.getAttributeElement("src").querySelector(".link");
 
   info("Check that the src attribute of the image is a valid tooltip target");
-  let isValid = yield markup.tooltip.isValidHoverTarget(target);
+  let isValid = yield isHoverTooltipTarget(markup.tooltip, target);
   ok(isValid, "The element is a valid tooltip target");
 
   info("Start dragging the test div");
   yield simulateNodeDrag(inspector, "div");
 
   info("Now check that the src attribute of the image isn't a valid target");
   try {
-    yield markup.tooltip.isValidHoverTarget(target);
+    yield isHoverTooltipTarget(markup.tooltip, target);
     isValid = true;
   } catch (e) {
     isValid = false;
   }
   ok(!isValid, "The element is not a valid tooltip target");
 
   info("Stop dragging the test div");
   yield simulateNodeDrop(inspector, "div");
 
   info("Check again the src attribute of the image");
-  isValid = yield markup.tooltip.isValidHoverTarget(target);
+  isValid = yield isHoverTooltipTarget(markup.tooltip, target);
   ok(isValid, "The element is a valid tooltip target");
 });
--- a/devtools/client/inspector/markup/test/browser_markup_image_tooltip.js
+++ b/devtools/client/inspector/markup/test/browser_markup_image_tooltip.js
@@ -38,17 +38,17 @@ function* getImageTooltipTarget({selecto
   if (isImg) {
     target = container.editor.getAttributeElement("src").querySelector(".link");
   }
   return target;
 }
 
 function* assertTooltipShownOn(element, {markup}) {
   info("Is the element a valid hover target");
-  let isValid = yield markup.tooltip.isValidHoverTarget(element);
+  let isValid = yield isHoverTooltipTarget(markup.tooltip, element);
   ok(isValid, "The element is a valid hover target for the image tooltip");
 }
 
 function checkImageTooltip({selector, size}, {markup}) {
   let images = markup.tooltip.panel.getElementsByTagName("image");
   is(images.length, 1, "Tooltip for [" + selector + "] contains an image");
 
   let label = markup.tooltip.panel.querySelector(".devtools-tooltip-caption");
--- a/devtools/client/inspector/markup/test/browser_markup_image_tooltip_mutations.js
+++ b/devtools/client/inspector/markup/test/browser_markup_image_tooltip_mutations.js
@@ -32,28 +32,28 @@ add_task(function* () {
   let container = getContainerForNodeFront(img, inspector);
   ok(container, "Found markup container for the image.");
 
   let target = container.editor.getAttributeElement("src")
                                .querySelector(".link");
   ok(target, "Found the src attribute in the markup view.");
 
   info("Showing tooltip on the src link.");
-  yield inspector.markup.tooltip.isValidHoverTarget(target);
+  yield isHoverTooltipTarget(inspector.markup.tooltip, target);
 
   checkImageTooltip(INITIAL_SRC_SIZE, inspector);
 
   info("Updating the image src.");
   yield updateImageSrc(img, UPDATED_SRC, inspector);
 
   target = container.editor.getAttributeElement("src").querySelector(".link");
   ok(target, "Found the src attribute in the markup view after mutation.");
 
   info("Showing tooltip on the src link.");
-  yield inspector.markup.tooltip.isValidHoverTarget(target);
+  yield isHoverTooltipTarget(inspector.markup.tooltip, target);
 
   info("Checking that the new image was shown.");
   checkImageTooltip(UPDATED_SRC_SIZE, inspector);
 });
 
 /**
  * Updates the src attribute of the image. Return a Promise.
  */
--- a/devtools/client/inspector/rules/test/head.js
+++ b/devtools/client/inspector/rules/test/head.js
@@ -165,46 +165,16 @@ var focusEditableField = Task.async(func
 
   info("Editable field gained focus, returning the input field now");
   let onEdit = inplaceEditor(editable.ownerDocument.activeElement);
 
   return onEdit;
 });
 
 /**
- * Given a tooltip object instance (see Tooltip.js), checks if it is set to
- * toggle and hover and if so, checks if the given target is a valid hover
- * target. This won't actually show the tooltip (the less we interact with XUL
- * panels during test runs, the better).
- *
- * @return a promise that resolves when the answer is known
- */
-function isHoverTooltipTarget(tooltip, target) {
-  if (!tooltip._basedNode || !tooltip.panel) {
-    return promise.reject(new Error(
-      "The tooltip passed isn't set to toggle on hover or is not a tooltip"));
-  }
-  return tooltip.isValidHoverTarget(target);
-}
-
-/**
- * Same as isHoverTooltipTarget except that it will fail the test if there is no
- * tooltip defined on hover of the given element
- *
- * @return a promise
- */
-function assertHoverTooltipOn(tooltip, element) {
-  return isHoverTooltipTarget(tooltip, element).then(() => {
-    ok(true, "A tooltip is defined on hover of the given element");
-  }, () => {
-    ok(false, "No tooltip is defined on hover of the given element");
-  });
-}
-
-/**
  * When a tooltip is closed, this ends up "commiting" the value changed within
  * the tooltip (e.g. the color in case of a colorpicker) which, in turn, ends up
  * setting the value of the corresponding css property in the rule-view.
  * Use this function to close the tooltip and make sure the test waits for the
  * ruleview-changed event.
  * @param {Tooltip} tooltip
  * @param {CSSRuleView} view
  */
--- a/devtools/client/inspector/shared/test/head.js
+++ b/devtools/client/inspector/shared/test/head.js
@@ -194,46 +194,16 @@ var focusEditableField = Task.async(func
 
   info("Editable field gained focus, returning the input field now");
   let onEdit = inplaceEditor(editable.ownerDocument.activeElement);
 
   return onEdit;
 });
 
 /**
- * Given a tooltip object instance (see Tooltip.js), checks if it is set to
- * toggle and hover and if so, checks if the given target is a valid hover
- * target. This won't actually show the tooltip (the less we interact with XUL
- * panels during test runs, the better).
- *
- * @return a promise that resolves when the answer is known
- */
-function isHoverTooltipTarget(tooltip, target) {
-  if (!tooltip._basedNode || !tooltip.panel) {
-    return promise.reject(new Error(
-      "The tooltip passed isn't set to toggle on hover or is not a tooltip"));
-  }
-  return tooltip.isValidHoverTarget(target);
-}
-
-/**
- * Same as isHoverTooltipTarget except that it will fail the test if there is no
- * tooltip defined on hover of the given element
- *
- * @return a promise
- */
-function assertHoverTooltipOn(tooltip, element) {
-  return isHoverTooltipTarget(tooltip, element).then(() => {
-    ok(true, "A tooltip is defined on hover of the given element");
-  }, () => {
-    ok(false, "No tooltip is defined on hover of the given element");
-  });
-}
-
-/**
  * Polls a given function waiting for it to return true.
  *
  * @param {Function} validatorFn
  *        A validator function that returns a boolean.
  *        This is called every few milliseconds to check if the result is true.
  *        When it is true, the promise resolves.
  * @param {String} name
  *        Optional name of the test. This is used to generate
--- a/devtools/client/inspector/test/head.js
+++ b/devtools/client/inspector/test/head.js
@@ -772,8 +772,38 @@ var waitForTab = Task.async(function*() 
  * @param {Window} win
  *        The window containing the panel
  */
 function synthesizeKeys(input, win) {
   for (let key of input.split("")) {
     EventUtils.synthesizeKey(key, {}, win);
   }
 }
+
+/**
+ * Given a tooltip object instance (see Tooltip.js), checks if it is set to
+ * toggle and hover and if so, checks if the given target is a valid hover
+ * target. This won't actually show the tooltip (the less we interact with XUL
+ * panels during test runs, the better).
+ *
+ * @return a promise that resolves when the answer is known
+ */
+function isHoverTooltipTarget(tooltip, target) {
+  if (!tooltip._toggle._baseNode || !tooltip.panel) {
+    return promise.reject(new Error(
+      "The tooltip passed isn't set to toggle on hover or is not a tooltip"));
+  }
+  return tooltip._toggle.isValidHoverTarget(target);
+}
+
+/**
+ * Same as isHoverTooltipTarget except that it will fail the test if there is no
+ * tooltip defined on hover of the given element
+ *
+ * @return a promise
+ */
+function assertHoverTooltipOn(tooltip, element) {
+  return isHoverTooltipTarget(tooltip, element).then(() => {
+    ok(true, "A tooltip is defined on hover of the given element");
+  }, () => {
+    ok(false, "No tooltip is defined on hover of the given element");
+  });
+}
--- a/devtools/client/netmonitor/test/browser_net_image-tooltip.js
+++ b/devtools/client/netmonitor/test/browser_net_image-tooltip.js
@@ -59,17 +59,18 @@ function test() {
       });
     }
 
     /**
      * @return a promise that resolves when the tooltip is shown
      */
     function showTooltipOn(tooltip, element) {
       return Task.spawn(function*() {
-        let isTarget = yield tooltip.isValidHoverTarget(element);
+        let isValidTarget = yield tooltip._toggle.isValidHoverTarget(element);
+        ok(isValidTarget, "Element is a valid tooltip target");
         let onShown = tooltip.once("shown");
         tooltip.show();
         yield onShown;
         return tooltip;
       });
     }
 
     aDebuggee.performRequests();
--- a/devtools/client/shared/widgets/Tooltip.js
+++ b/devtools/client/shared/widgets/Tooltip.js
@@ -6,16 +6,17 @@
 
 const {Cu, Ci} = require("chrome");
 const promise = require("promise");
 const {Spectrum} = require("devtools/client/shared/widgets/Spectrum");
 const {CubicBezierWidget} =
       require("devtools/client/shared/widgets/CubicBezierWidget");
 const {MdnDocsWidget} = require("devtools/client/shared/widgets/MdnDocsWidget");
 const {CSSFilterEditorWidget} = require("devtools/client/shared/widgets/FilterWidget");
+const {TooltipToggle} = require("devtools/client/shared/widgets/tooltip/TooltipToggle");
 const EventEmitter = require("devtools/shared/event-emitter");
 const {colorUtils} = require("devtools/client/shared/css-color");
 const Heritage = require("sdk/core/heritage");
 const {Eyedropper} = require("devtools/client/eyedropper/eyedropper");
 const Editor = require("devtools/client/sourceeditor/editor");
 const Services = require("Services");
 
 loader.lazyRequireGetter(this, "beautify", "devtools/shared/jsbeautify/beautify");
@@ -183,18 +184,21 @@ function Tooltip(doc, options) {
   this.options = new OptionsStore({
     consumeOutsideClick: false,
     closeOnKeys: [ESCAPE_KEYCODE],
     noAutoFocus: true,
     closeOnEvents: []
   }, options);
   this.panel = PanelFactory.get(doc, this.options);
 
-  // Used for namedTimeouts in the mouseover handling
-  this.uid = "tooltip-" + Date.now();
+  // Create tooltip toggle helper and decorate the Tooltip instance with
+  // shortcut methods.
+  this._toggle = new TooltipToggle(this);
+  this.startTogglingOnHover = this._toggle.start.bind(this._toggle);
+  this.stopTogglingOnHover = this._toggle.stop.bind(this._toggle);
 
   // Emit show/hide events when the panel does.
   for (let eventName of POPUP_EVENTS) {
     this["_onPopup" + eventName] = (name => {
       return e => {
         if (e.target === this.panel) {
           this.emit(name);
         }
@@ -237,17 +241,16 @@ module.exports.Tooltip = Tooltip;
 
 Tooltip.prototype = {
   defaultPosition: "before_start",
   // px
   defaultOffsetX: 0,
   // px
   defaultOffsetY: 0,
   // px
-  defaultShowDelay: 50,
 
   /**
    * Show the tooltip. It might be wise to append some content first if you
    * don't want the tooltip to be empty. You may access the content of the
    * tooltip by setting a XUL node to t.content.
    * @param {node} anchor
    *        Which node should the tooltip be shown on
    * @param {string} position [optional]
@@ -328,155 +331,25 @@ Tooltip.prototype = {
           emitter[remove](event, this.hide, useCapture);
           break;
         }
       }
     }
 
     this.content = null;
 
-    if (this._basedNode) {
-      this.stopTogglingOnHover();
-    }
+    this._toggle.destroy();
 
     this.doc = null;
 
     this.panel.remove();
     this.panel = null;
   },
 
   /**
-   * Show/hide the tooltip when the mouse hovers over particular nodes.
-   *
-   * 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.
-   *
-   * This works by tracking mouse movements on a base container node (baseNode)
-   * and showing the tooltip when the mouse stops moving. The targetNodeCb
-   * callback is used to know whether or not the particular element being
-   * hovered over should indeed receive the tooltip. If you don't provide it
-   * it's equivalent to a function that always returns true.
-   *
-   * Note that if you call this function a second time, it will itself call
-   * stopTogglingOnHover 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 returns true or false
-   *        (or a promise that resolves or rejects) to signify if the tooltip
-   *        should be shown on that node or not.
-   *        If the promise rejects, it must reject `false` as value.
-   *        Any other value is going to be logged as unexpected error.
-   *        Additionally, the function receives a second argument which is the
-   *        tooltip instance itself, to be used to add/modify the content of the
-   *        tooltip if needed. If omitted, the tooltip will be shown everytime.
-   * @param {Number} showDelay
-   *        An optional delay that will be observed before showing the tooltip.
-   *        Defaults to this.defaultShowDelay.
-   */
-  startTogglingOnHover: function(baseNode, targetNodeCb,
-                                 showDelay=this.defaultShowDelay) {
-    if (this._basedNode) {
-      this.stopTogglingOnHover();
-    }
-    if (!baseNode) {
-      // Calling tool is in the process of being destroyed.
-      return;
-    }
-
-    this._basedNode = baseNode;
-    this._showDelay = showDelay;
-    this._targetNodeCb = targetNodeCb || (() => true);
-
-    this._onBaseNodeMouseMove = this._onBaseNodeMouseMove.bind(this);
-    this._onBaseNodeMouseLeave = this._onBaseNodeMouseLeave.bind(this);
-
-    baseNode.addEventListener("mousemove", this._onBaseNodeMouseMove, false);
-    baseNode.addEventListener("mouseleave", this._onBaseNodeMouseLeave, false);
-  },
-
-  /**
-   * If the startTogglingOnHover function has been used previously, and you want
-   * to get rid of this behavior, then call this function to remove the mouse
-   * movement tracking
-   */
-  stopTogglingOnHover: function() {
-    clearNamedTimeout(this.uid);
-
-    if (!this._basedNode) {
-      return;
-    }
-
-    this._basedNode.removeEventListener("mousemove",
-      this._onBaseNodeMouseMove, false);
-    this._basedNode.removeEventListener("mouseleave",
-      this._onBaseNodeMouseLeave, false);
-
-    this._basedNode = null;
-    this._targetNodeCb = null;
-    this._lastHovered = null;
-  },
-
-  _onBaseNodeMouseMove: function(event) {
-    if (event.target !== this._lastHovered) {
-      this.hide();
-      this._lastHovered = event.target;
-      setNamedTimeout(this.uid, this._showDelay, () => {
-        this.isValidHoverTarget(event.target).then(target => {
-          this.show(target);
-        }, reason => {
-          if (reason === false) {
-            // isValidHoverTarget rejects with false if the tooltip should
-            // not be shown. This can be safely ignored.
-            return;
-          }
-          // Report everything else. Reason might be error that should not be
-          // hidden.
-          console.error("isValidHoverTarget rejected with an unexpected reason:");
-          console.error(reason);
-        });
-      });
-    }
-  },
-
-  /**
-   * Is the given target DOMNode a valid node for toggling the tooltip on hover.
-   * This delegates to the user-defined _targetNodeCb callback.
-   * @return a promise that resolves or rejects depending if the tooltip should
-   * be shown or not. If it resolves, it does to the actual anchor to be used
-   */
-  isValidHoverTarget: function(target) {
-    // Execute the user-defined callback which should return either true/false
-    // or a promise that resolves or rejects
-    let res = this._targetNodeCb(target, this);
-
-    // The callback can additionally return a DOMNode to replace the anchor of
-    // the tooltip when shown
-    if (res && res.then) {
-      return res.then(arg => {
-        return arg instanceof Ci.nsIDOMNode ? arg : target;
-      });
-    }
-    let newTarget = res instanceof Ci.nsIDOMNode ? res : target;
-    return res ? promise.resolve(newTarget) : promise.reject(false);
-  },
-
-  _onBaseNodeMouseLeave: function() {
-    clearNamedTimeout(this.uid);
-    this._lastHovered = null;
-    this.hide();
-  },
-
-  /**
    * Set the content of this tooltip. Will first empty the tooltip and then
    * append the new content element.
    * Consider using one of the set<type>Content() functions instead.
    * @param {node} content
    *        A node that can be appended in the tooltip XUL element
    */
   set content(content) {
     if (this.content == content) {
--- a/devtools/client/shared/widgets/moz.build
+++ b/devtools/client/shared/widgets/moz.build
@@ -1,14 +1,18 @@
 # -*- Mode: python; c-basic-offset: 4; 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/.
 
+DIRS += [
+    'tooltip',
+]
+
 DevToolsModules(
     'AbstractTreeItem.jsm',
     'BarGraphWidget.js',
     'BreadcrumbsWidget.jsm',
     'Chart.jsm',
     'CubicBezierPresets.js',
     'CubicBezierWidget.js',
     'FastListWidget.js',
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/TooltipToggle.js
@@ -0,0 +1,152 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const DEFAULT_SHOW_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._onMouseLeave = this._onMouseLeave.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 returns true or false
+   *        (or a promise that resolves or rejects) to signify if the tooltip
+   *        should be shown on that node or not.
+   *        If the promise rejects, it must reject `false` as value.
+   *        Any other value is going to be logged as unexpected error.
+   *        Additionally, the function receives a second argument which is the
+   *        tooltip instance itself, to be used to add/modify the content of the
+   *        tooltip if needed. If omitted, the tooltip will be shown everytime.
+   * @param {Number} showDelay
+   *        An optional delay that will be observed before showing the tooltip.
+   *        Defaults to DEFAULT_SHOW_DELAY.
+   */
+  start: function (baseNode, targetNodeCb, showDelay = DEFAULT_SHOW_DELAY) {
+    this.stop();
+
+    if (!baseNode) {
+      // Calling tool is in the process of being destroyed.
+      return;
+    }
+
+    this._baseNode = baseNode;
+    this._showDelay = showDelay;
+    this._targetNodeCb = targetNodeCb || (() => true);
+
+    baseNode.addEventListener("mousemove", this._onMouseMove, false);
+    baseNode.addEventListener("mouseleave", this._onMouseLeave, false);
+  },
+
+  /**
+   * 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: function () {
+    this.win.clearTimeout(this.toggleTimer);
+
+    if (!this._baseNode) {
+      return;
+    }
+
+    this._baseNode.removeEventListener("mousemove", this._onMouseMove, false);
+    this._baseNode.removeEventListener("mouseleave", this._onMouseLeave, false);
+
+    this._baseNode = null;
+    this._targetNodeCb = null;
+    this._lastHovered = null;
+  },
+
+  _onMouseMove: function (event) {
+    if (event.target !== this._lastHovered) {
+      this.tooltip.hide();
+      this._lastHovered = event.target;
+
+      this.win.clearTimeout(this.toggleTimer);
+      this.toggleTimer = this.win.setTimeout(() => {
+        this.isValidHoverTarget(event.target).then(target => {
+          this.tooltip.show(target);
+        }, reason => {
+          if (reason === false) {
+            // isValidHoverTarget rejects with false if the tooltip should
+            // not be shown. This can be safely ignored.
+            return;
+          }
+          console.error("isValidHoverTarget rejected with unexpected reason:");
+          console.error(reason);
+        });
+      }, this._showDelay);
+    }
+  },
+
+  /**
+   * Is the given target DOMNode a valid node for toggling the tooltip on hover.
+   * This delegates to the user-defined _targetNodeCb callback.
+   * @return a promise that resolves or rejects depending if the tooltip should
+   * be shown or not. If it resolves, it does to the actual anchor to be used
+   */
+  isValidHoverTarget: function (target) {
+    // Execute the user-defined callback which should return either true/false
+    // or a promise that resolves or rejects
+    let res = this._targetNodeCb(target, this.tooltip);
+
+    // The callback can additionally return a DOMNode to replace the anchor of
+    // the tooltip when shown
+    if (res && res.then) {
+      return res.then(arg => {
+        return arg && arg.nodeName ? arg : target;
+      });
+    }
+    let newTarget = res && res.nodeName ? res : target;
+    return new Promise((resolve, reject) => {
+      res ? resolve(newTarget) : reject(false);
+    });
+  },
+
+  _onMouseLeave: function () {
+    this.win.clearTimeout(this.toggleTimer);
+    this._lastHovered = null;
+    this.tooltip.hide();
+  },
+
+  destroy: function () {
+    this.stop();
+  }
+};
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/moz.build
@@ -0,0 +1,9 @@
+# -*- Mode: python; c-basic-offset: 4; 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(
+    'TooltipToggle.js',
+)