• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (c) 2012 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5// require: event_tracker.js
6
7// TODO(vitalyp): Inline the enums below into cr.ui definition function, remove
8// cr.exportPath() call and remove exportPath from exports in cr.js when this
9// issue will be fixed:
10// https://github.com/google/closure-compiler/issues/544
11cr.exportPath('cr.ui');
12
13/**
14 * The arrow location specifies how the arrow and bubble are positioned in
15 * relation to the anchor node.
16 * @enum {string}
17 */
18cr.ui.ArrowLocation = {
19  // The arrow is positioned at the top and the start of the bubble. In left
20  // to right mode this is the top left. The entire bubble is positioned below
21  // the anchor node.
22  TOP_START: 'top-start',
23  // The arrow is positioned at the top and the end of the bubble. In left to
24  // right mode this is the top right. The entire bubble is positioned below
25  // the anchor node.
26  TOP_END: 'top-end',
27  // The arrow is positioned at the bottom and the start of the bubble. In
28  // left to right mode this is the bottom left. The entire bubble is
29  // positioned above the anchor node.
30  BOTTOM_START: 'bottom-start',
31  // The arrow is positioned at the bottom and the end of the bubble. In
32  // left to right mode this is the bottom right. The entire bubble is
33  // positioned above the anchor node.
34  BOTTOM_END: 'bottom-end'
35};
36
37/**
38 * The bubble alignment specifies the position of the bubble in relation to
39 * the anchor node.
40 * @enum {string}
41 */
42cr.ui.BubbleAlignment = {
43  // The bubble is positioned just above or below the anchor node (as
44  // specified by the arrow location) so that the arrow points at the midpoint
45  // of the anchor.
46  ARROW_TO_MID_ANCHOR: 'arrow-to-mid-anchor',
47  // The bubble is positioned just above or below the anchor node (as
48  // specified by the arrow location) so that its reference edge lines up with
49  // the edge of the anchor.
50  BUBBLE_EDGE_TO_ANCHOR_EDGE: 'bubble-edge-anchor-edge',
51  // The bubble is positioned so that it is entirely within view and does not
52  // obstruct the anchor element, if possible. The specified arrow location is
53  // taken into account as the preferred alignment but may be overruled if
54  // there is insufficient space (see BubbleBase.reposition for the exact
55  // placement algorithm).
56  ENTIRELY_VISIBLE: 'entirely-visible'
57};
58
59cr.define('cr.ui', function() {
60  /**
61   * Abstract base class that provides common functionality for implementing
62   * free-floating informational bubbles with a triangular arrow pointing at an
63   * anchor node.
64   * @constructor
65   * @extends {HTMLDivElement}
66   * @implements {EventListener}
67   */
68  var BubbleBase = cr.ui.define('div');
69
70  /**
71   * The horizontal distance between the tip of the arrow and the reference edge
72   * of the bubble (as specified by the arrow location). In pixels.
73   * @type {number}
74   * @const
75   */
76  BubbleBase.ARROW_OFFSET = 30;
77
78  /**
79   * Minimum horizontal spacing between edge of bubble and edge of viewport
80   * (when using the ENTIRELY_VISIBLE alignment). In pixels.
81   * @type {number}
82   * @const
83   */
84  BubbleBase.MIN_VIEWPORT_EDGE_MARGIN = 2;
85
86  BubbleBase.prototype = {
87    // Set up the prototype chain.
88    __proto__: HTMLDivElement.prototype,
89
90    /**
91     * @type {Node}
92     * @private
93     */
94    anchorNode_: null,
95
96    /**
97     * Initialization function for the cr.ui framework.
98     */
99    decorate: function() {
100      this.className = 'bubble';
101      this.innerHTML =
102          '<div class="bubble-content"></div>' +
103          '<div class="bubble-shadow"></div>' +
104          '<div class="bubble-arrow"></div>';
105      this.hidden = true;
106      this.bubbleAlignment = cr.ui.BubbleAlignment.ENTIRELY_VISIBLE;
107    },
108
109    /**
110     * Set the anchor node, i.e. the node that this bubble points at. Only
111     * available when the bubble is not being shown.
112     * @param {HTMLElement} node The new anchor node.
113     */
114    set anchorNode(node) {
115      if (!this.hidden)
116        return;
117
118      this.anchorNode_ = node;
119    },
120
121    /**
122     * Set the conent of the bubble. Only available when the bubble is not being
123     * shown.
124     * @param {HTMLElement} node The root node of the new content.
125     */
126    set content(node) {
127      if (!this.hidden)
128        return;
129
130      var bubbleContent = this.querySelector('.bubble-content');
131      bubbleContent.innerHTML = '';
132      bubbleContent.appendChild(node);
133    },
134
135    /**
136     * Set the arrow location. Only available when the bubble is not being
137     * shown.
138     * @param {cr.ui.ArrowLocation} location The new arrow location.
139     */
140    set arrowLocation(location) {
141      if (!this.hidden)
142        return;
143
144      this.arrowAtRight_ = location == cr.ui.ArrowLocation.TOP_END ||
145                           location == cr.ui.ArrowLocation.BOTTOM_END;
146      if (document.documentElement.dir == 'rtl')
147        this.arrowAtRight_ = !this.arrowAtRight_;
148      this.arrowAtTop_ = location == cr.ui.ArrowLocation.TOP_START ||
149                         location == cr.ui.ArrowLocation.TOP_END;
150    },
151
152    /**
153     * Set the bubble alignment. Only available when the bubble is not being
154     * shown.
155     * @param {cr.ui.BubbleAlignment} alignment The new bubble alignment.
156     */
157    set bubbleAlignment(alignment) {
158      if (!this.hidden)
159        return;
160
161      this.bubbleAlignment_ = alignment;
162    },
163
164    /**
165     * Update the position of the bubble. Whenever the layout may have changed,
166     * the bubble should either be repositioned by calling this function or
167     * hidden so that it does not point to a nonsensical location on the page.
168     */
169    reposition: function() {
170      var documentWidth = document.documentElement.clientWidth;
171      var documentHeight = document.documentElement.clientHeight;
172      var anchor = this.anchorNode_.getBoundingClientRect();
173      var anchorMid = (anchor.left + anchor.right) / 2;
174      var bubble = this.getBoundingClientRect();
175      var arrow = this.querySelector('.bubble-arrow').getBoundingClientRect();
176
177      if (this.bubbleAlignment_ == cr.ui.BubbleAlignment.ENTIRELY_VISIBLE) {
178        // Work out horizontal placement. The bubble is initially positioned so
179        // that the arrow tip points toward the midpoint of the anchor and is
180        // BubbleBase.ARROW_OFFSET pixels from the reference edge and (as
181        // specified by the arrow location). If the bubble is not entirely
182        // within view, it is then shifted, preserving the arrow tip position.
183        var left = this.arrowAtRight_ ?
184           anchorMid + BubbleBase.ARROW_OFFSET - bubble.width :
185           anchorMid - BubbleBase.ARROW_OFFSET;
186        var max_left_pos =
187            documentWidth - bubble.width - BubbleBase.MIN_VIEWPORT_EDGE_MARGIN;
188        var min_left_pos = BubbleBase.MIN_VIEWPORT_EDGE_MARGIN;
189        if (document.documentElement.dir == 'rtl')
190          left = Math.min(Math.max(left, min_left_pos), max_left_pos);
191        else
192          left = Math.max(Math.min(left, max_left_pos), min_left_pos);
193        var arrowTip = Math.min(
194            Math.max(arrow.width / 2,
195                     this.arrowAtRight_ ? left + bubble.width - anchorMid :
196                                          anchorMid - left),
197            bubble.width - arrow.width / 2);
198
199        // Work out the vertical placement, attempting to fit the bubble
200        // entirely into view. The following placements are considered in
201        // decreasing order of preference:
202        // * Outside the anchor, arrow tip touching the anchor (arrow at
203        //   top/bottom as specified by the arrow location).
204        // * Outside the anchor, arrow tip touching the anchor (arrow at
205        //   bottom/top, opposite the specified arrow location).
206        // * Outside the anchor, arrow tip overlapping the anchor (arrow at
207        //   top/bottom as specified by the arrow location).
208        // * Outside the anchor, arrow tip overlapping the anchor (arrow at
209        //   bottom/top, opposite the specified arrow location).
210        // * Overlapping the anchor.
211        var offsetTop = Math.min(documentHeight - anchor.bottom - bubble.height,
212                                 arrow.height / 2);
213        var offsetBottom = Math.min(anchor.top - bubble.height,
214                                    arrow.height / 2);
215        if (offsetTop < 0 && offsetBottom < 0) {
216          var top = 0;
217          this.updateArrowPosition_(false, false, arrowTip);
218        } else if (offsetTop > offsetBottom ||
219                   offsetTop == offsetBottom && this.arrowAtTop_) {
220          var top = anchor.bottom + offsetTop;
221          this.updateArrowPosition_(true, true, arrowTip);
222        } else {
223          var top = anchor.top - bubble.height - offsetBottom;
224          this.updateArrowPosition_(true, false, arrowTip);
225        }
226      } else {
227        if (this.bubbleAlignment_ ==
228            cr.ui.BubbleAlignment.BUBBLE_EDGE_TO_ANCHOR_EDGE) {
229          var left = this.arrowAtRight_ ? anchor.right - bubble.width :
230              anchor.left;
231        } else {
232          var left = this.arrowAtRight_ ?
233              anchorMid - this.clientWidth + BubbleBase.ARROW_OFFSET :
234              anchorMid - BubbleBase.ARROW_OFFSET;
235        }
236        var top = this.arrowAtTop_ ? anchor.bottom + arrow.height / 2 :
237            anchor.top - this.clientHeight - arrow.height / 2;
238        this.updateArrowPosition_(true, this.arrowAtTop_,
239                                  BubbleBase.ARROW_OFFSET);
240      }
241
242      this.style.left = left + 'px';
243      this.style.top = top + 'px';
244    },
245
246    /**
247     * Show the bubble.
248     */
249    show: function() {
250      if (!this.hidden)
251        return;
252
253      this.attachToDOM_();
254      this.hidden = false;
255      this.reposition();
256
257      var doc = assert(this.ownerDocument);
258      this.eventTracker_ = new EventTracker;
259      this.eventTracker_.add(doc, 'keydown', this, true);
260      this.eventTracker_.add(doc, 'mousedown', this, true);
261    },
262
263    /**
264     * Hide the bubble.
265     */
266    hide: function() {
267      if (this.hidden)
268        return;
269
270      this.eventTracker_.removeAll();
271      this.hidden = true;
272      this.parentNode.removeChild(this);
273    },
274
275    /**
276     * Handle keyboard events, dismissing the bubble if necessary.
277     * @param {Event} event The event.
278     */
279    handleEvent: function(event) {
280      // Close the bubble when the user presses <Esc>.
281      if (event.type == 'keydown' && event.keyCode == 27) {
282        this.hide();
283        event.preventDefault();
284        event.stopPropagation();
285      }
286    },
287
288    /**
289     * Attach the bubble to the document's DOM.
290     * @private
291     */
292    attachToDOM_: function() {
293      document.body.appendChild(this);
294    },
295
296    /**
297     * Update the arrow so that it appears at the correct position.
298     * @param {boolean} visible Whether the arrow should be visible.
299     * @param {boolean} atTop Whether the arrow should be at the top of the
300     * bubble.
301     * @param {number} tipOffset The horizontal distance between the tip of the
302     * arrow and the reference edge of the bubble (as specified by the arrow
303     * location).
304     * @private
305     */
306    updateArrowPosition_: function(visible, atTop, tipOffset) {
307      var bubbleArrow = this.querySelector('.bubble-arrow');
308      bubbleArrow.hidden = !visible;
309      if (!visible)
310        return;
311
312      var edgeOffset = (-bubbleArrow.clientHeight / 2) + 'px';
313      bubbleArrow.style.top = atTop ? edgeOffset : 'auto';
314      bubbleArrow.style.bottom = atTop ? 'auto' : edgeOffset;
315
316      edgeOffset = (tipOffset - bubbleArrow.offsetWidth / 2) + 'px';
317      bubbleArrow.style.left = this.arrowAtRight_ ? 'auto' : edgeOffset;
318      bubbleArrow.style.right = this.arrowAtRight_ ? edgeOffset : 'auto';
319    },
320  };
321
322  /**
323   * A bubble that remains open until the user explicitly dismisses it or clicks
324   * outside the bubble after it has been shown for at least the specified
325   * amount of time (making it less likely that the user will unintentionally
326   * dismiss the bubble). The bubble repositions itself on layout changes.
327   */
328  var Bubble = cr.ui.define('div');
329
330  Bubble.prototype = {
331    // Set up the prototype chain.
332    __proto__: BubbleBase.prototype,
333
334    /**
335     * Initialization function for the cr.ui framework.
336     */
337    decorate: function() {
338      BubbleBase.prototype.decorate.call(this);
339
340      var close = document.createElement('div');
341      close.className = 'bubble-close';
342      this.insertBefore(close, this.querySelector('.bubble-content'));
343
344      this.handleCloseEvent = this.hide;
345      this.deactivateToDismissDelay_ = 0;
346      this.bubbleAlignment = cr.ui.BubbleAlignment.ARROW_TO_MID_ANCHOR;
347    },
348
349    /**
350     * Handler for close events triggered when the close button is clicked. By
351     * default, set to this.hide. Only available when the bubble is not being
352     * shown.
353     * @param {function(): *} handler The new handler, a function with no
354     *     parameters.
355     */
356    set handleCloseEvent(handler) {
357      if (!this.hidden)
358        return;
359
360      this.handleCloseEvent_ = handler;
361    },
362
363    /**
364     * Set the delay before the user is allowed to click outside the bubble to
365     * dismiss it. Using a delay makes it less likely that the user will
366     * unintentionally dismiss the bubble.
367     * @param {number} delay The delay in milliseconds.
368     */
369    set deactivateToDismissDelay(delay) {
370      this.deactivateToDismissDelay_ = delay;
371    },
372
373    /**
374     * Hide or show the close button.
375     * @param {boolean} isVisible True if the close button should be visible.
376     */
377    set closeButtonVisible(isVisible) {
378      this.querySelector('.bubble-close').hidden = !isVisible;
379    },
380
381    /**
382     * Show the bubble.
383     */
384    show: function() {
385      if (!this.hidden)
386        return;
387
388      BubbleBase.prototype.show.call(this);
389
390      this.showTime_ = Date.now();
391      this.eventTracker_.add(window, 'resize', this.reposition.bind(this));
392    },
393
394    /**
395     * Handle keyboard and mouse events, dismissing the bubble if necessary.
396     * @param {Event} event The event.
397     */
398    handleEvent: function(event) {
399      BubbleBase.prototype.handleEvent.call(this, event);
400
401      if (event.type == 'mousedown') {
402        // Dismiss the bubble when the user clicks on the close button.
403        if (event.target == this.querySelector('.bubble-close')) {
404          this.handleCloseEvent_();
405        // Dismiss the bubble when the user clicks outside it after the
406        // specified delay has passed.
407        } else if (!this.contains(event.target) &&
408            Date.now() - this.showTime_ >= this.deactivateToDismissDelay_) {
409          this.hide();
410        }
411      }
412    },
413  };
414
415  /**
416   * A bubble that closes automatically when the user clicks or moves the focus
417   * outside the bubble and its target element, scrolls the underlying document
418   * or resizes the window.
419   * @constructor
420   * @extends {cr.ui.BubbleBase}
421   */
422  var AutoCloseBubble = cr.ui.define('div');
423
424  AutoCloseBubble.prototype = {
425    // Set up the prototype chain.
426    __proto__: BubbleBase.prototype,
427
428    /**
429     * Initialization function for the cr.ui framework.
430     */
431    decorate: function() {
432      BubbleBase.prototype.decorate.call(this);
433      this.classList.add('auto-close-bubble');
434    },
435
436    /**
437     * Set the DOM sibling node, i.e. the node as whose sibling the bubble
438     * should join the DOM to ensure that focusable elements inside the bubble
439     * follow the target element in the document's tab order. Only available
440     * when the bubble is not being shown.
441     * @param {HTMLElement} node The new DOM sibling node.
442     */
443    set domSibling(node) {
444      if (!this.hidden)
445        return;
446
447      this.domSibling_ = node;
448    },
449
450    /**
451     * Show the bubble.
452     */
453    show: function() {
454      if (!this.hidden)
455        return;
456
457      BubbleBase.prototype.show.call(this);
458      this.domSibling_.showingBubble = true;
459
460      var doc = this.ownerDocument;
461      this.eventTracker_.add(doc, 'mousewheel', this, true);
462      this.eventTracker_.add(doc, 'scroll', this, true);
463      this.eventTracker_.add(doc, 'elementFocused', this, true);
464      this.eventTracker_.add(window, 'resize', this);
465    },
466
467    /**
468     * Hide the bubble.
469     */
470    hide: function() {
471      BubbleBase.prototype.hide.call(this);
472      this.domSibling_.showingBubble = false;
473    },
474
475    /**
476     * Handle events, closing the bubble when the user clicks or moves the focus
477     * outside the bubble and its target element, scrolls the underlying
478     * document or resizes the window.
479     * @param {Event} event The event.
480     */
481    handleEvent: function(event) {
482      BubbleBase.prototype.handleEvent.call(this, event);
483
484      switch (event.type) {
485        // Close the bubble when the user clicks outside it, except if it is a
486        // left-click on the bubble's target element (allowing the target to
487        // handle the event and close the bubble itself).
488        case 'mousedown':
489          if (event.button == 0 &&
490              this.anchorNode_.contains(assertInstanceof(event.target, Node))) {
491            break;
492          }
493        // Close the bubble when the underlying document is scrolled.
494        case 'mousewheel':
495        case 'scroll':
496          if (this.contains(assertInstanceof(event.target, Node)))
497            break;
498        // Close the bubble when the window is resized.
499        case 'resize':
500          this.hide();
501          break;
502        // Close the bubble when the focus moves to an element that is not the
503        // bubble target and is not inside the bubble.
504        case 'elementFocused':
505          var target = assertInstanceof(event.target, Node);
506          if (!this.anchorNode_.contains(target) && !this.contains(target)) {
507            this.hide();
508          }
509          break;
510      }
511    },
512
513    /**
514     * Attach the bubble to the document's DOM, making it a sibling of the
515     * |domSibling_| so that focusable elements inside the bubble follow the
516     * target element in the document's tab order.
517     * @private
518     */
519    attachToDOM_: function() {
520      var parent = this.domSibling_.parentNode;
521      parent.insertBefore(this, this.domSibling_.nextSibling);
522    },
523  };
524
525
526  return {
527    BubbleBase: BubbleBase,
528    Bubble: Bubble,
529    AutoCloseBubble: AutoCloseBubble
530  };
531});
532