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