summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/widgets/tooltip/TooltipToggle.js
blob: b53664f34929fa130d295afa8423f80214d93317 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
/* -*- 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 {Task} = require("devtools/shared/task");

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: function (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: function () {
    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: function (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) {
            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.
   */
  isValidHoverTarget: Task.async(function* (target) {
    let res = yield this._targetNodeCb(target, this.tooltip);
    if (res) {
      return res.nodeName ? res : target;
    }

    return null;
  }),

  _onMouseOut: function (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: function () {
    this.stop();
  }
};