1// Copyright 2014 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/**
6 * @fileoverview Bubble implementation.
7 */
8
9// TODO(xiyuan): Move this into shared.
10cr.define('cr.ui', function() {
11  /**
12   * Creates a bubble div.
13   * @constructor
14   * @extends {HTMLDivElement}
15   */
16  var Bubble = cr.ui.define('div');
17
18  /**
19   * Bubble key codes.
20   * @enum {number}
21   */
22  var KeyCodes = {
23    TAB: 9,
24    ENTER: 13,
25    ESC: 27,
26    SPACE: 32
27  };
28
29  /**
30   * Bubble attachment side.
31   * @enum {string}
32   */
33  Bubble.Attachment = {
34    RIGHT: 'bubble-right',
35    LEFT: 'bubble-left',
36    TOP: 'bubble-top',
37    BOTTOM: 'bubble-bottom'
38  };
39
40  Bubble.prototype = {
41    __proto__: HTMLDivElement.prototype,
42
43    // Anchor element for this bubble.
44    anchor_: undefined,
45
46    // If defined, sets focus to this element once bubble is closed. Focus is
47    // set to this element only if there's no any other focused element.
48    elementToFocusOnHide_: undefined,
49
50    // With help of these elements we create closed artificial tab-cycle through
51    // bubble elements.
52    firstBubbleElement_: undefined,
53    lastBubbleElement_: undefined,
54
55    // Whether to hide bubble when key is pressed.
56    hideOnKeyPress_: true,
57
58    /** @override */
59    decorate: function() {
60      this.docKeyDownHandler_ = this.handleDocKeyDown_.bind(this);
61      this.selfClickHandler_ = this.handleSelfClick_.bind(this);
62      this.ownerDocument.addEventListener('click',
63                                          this.handleDocClick_.bind(this));
64      this.ownerDocument.addEventListener('keydown',
65                                          this.docKeyDownHandler_);
66      window.addEventListener('blur', this.handleWindowBlur_.bind(this));
67      this.addEventListener('webkitTransitionEnd',
68                            this.handleTransitionEnd_.bind(this));
69      // Guard timer for 200ms + epsilon.
70      ensureTransitionEndEvent(this, 250);
71    },
72
73    /**
74     * Element that should be focused on hide.
75     * @type {HTMLElement}
76     */
77    set elementToFocusOnHide(value) {
78      this.elementToFocusOnHide_ = value;
79    },
80
81    /**
82     * Element that should be focused on shift-tab of first bubble element
83     * to create artificial closed tab-cycle through bubble.
84     * Usually close-button.
85     * @type {HTMLElement}
86     */
87    set lastBubbleElement(value) {
88      this.lastBubbleElement_ = value;
89    },
90
91    /**
92     * Element that should be focused on tab of last bubble element
93     * to create artificial closed tab-cycle through bubble.
94     * Same element as first focused on bubble opening.
95     * @type {HTMLElement}
96     */
97    set firstBubbleElement(value) {
98      this.firstBubbleElement_ = value;
99    },
100
101    /**
102     * Whether to hide bubble when key is pressed.
103     * @type {boolean}
104     */
105    set hideOnKeyPress(value) {
106      this.hideOnKeyPress_ = value;
107    },
108
109    /**
110     * Whether to hide bubble when clicked inside bubble element.
111     * Default is true.
112     * @type {boolean}
113     */
114    set hideOnSelfClick(value) {
115      if (value)
116        this.removeEventListener('click', this.selfClickHandler_);
117      else
118        this.addEventListener('click', this.selfClickHandler_);
119    },
120
121    /**
122     * Handler for click event which prevents bubble auto hide.
123     * @private
124     */
125    handleSelfClick_: function(e) {
126      // Allow clicking on [x] button.
127      if (e.target && e.target.classList.contains('close-button'))
128        return;
129      e.stopPropagation();
130    },
131
132    /**
133     * Sets the attachment of the bubble.
134     * @param {!Attachment} attachment Bubble attachment.
135     */
136    setAttachment_: function(attachment) {
137      for (var k in Bubble.Attachment) {
138        var v = Bubble.Attachment[k];
139        this.classList.toggle(v, v == attachment);
140      }
141    },
142
143    /**
144     * Shows the bubble for given anchor element.
145     * @param {!Object} pos Bubble position (left, top, right, bottom in px).
146     * @param {!Attachment} attachment Bubble attachment (on which side of the
147     *     specified position it should be displayed).
148     * @param {HTMLElement} opt_content Content to show in bubble.
149     *     If not specified, bubble element content is shown.
150     * @private
151     */
152    showContentAt_: function(pos, attachment, opt_content) {
153      this.style.top = this.style.left = this.style.right = this.style.bottom =
154          'auto';
155      for (var k in pos) {
156        if (typeof pos[k] == 'number')
157          this.style[k] = pos[k] + 'px';
158      }
159      if (opt_content !== undefined) {
160        this.innerHTML = '';
161        this.appendChild(opt_content);
162      }
163      this.setAttachment_(attachment);
164      this.hidden = false;
165      this.classList.remove('faded');
166    },
167
168    /**
169     * Shows the bubble for given anchor element. Bubble content is not cleared.
170     * @param {!HTMLElement} el Anchor element of the bubble.
171     * @param {!Attachment} attachment Bubble attachment (on which side of the
172     *     element it should be displayed).
173     * @param {number=} opt_offset Offset of the bubble.
174     * @param {number=} opt_padding Optional padding of the bubble.
175     */
176    showForElement: function(el, attachment, opt_offset, opt_padding) {
177      this.showContentForElement(
178          el, attachment, undefined, opt_offset, opt_padding);
179    },
180
181    /**
182     * Shows the bubble for given anchor element.
183     * @param {!HTMLElement} el Anchor element of the bubble.
184     * @param {!Attachment} attachment Bubble attachment (on which side of the
185     *     element it should be displayed).
186     * @param {HTMLElement} opt_content Content to show in bubble.
187     *     If not specified, bubble element content is shown.
188     * @param {number=} opt_offset Offset of the bubble attachment point from
189     *     left (for vertical attachment) or top (for horizontal attachment)
190     *     side of the element. If not specified, the bubble is positioned to
191     *     be aligned with the left/top side of the element but not farther than
192     *     half of its width/height.
193     * @param {number=} opt_padding Optional padding of the bubble.
194     */
195    showContentForElement: function(el, attachment, opt_content,
196                                    opt_offset, opt_padding) {
197      /** @const */ var ARROW_OFFSET = 25;
198      /** @const */ var DEFAULT_PADDING = 18;
199
200      if (opt_padding == undefined)
201        opt_padding = DEFAULT_PADDING;
202
203      var origin = cr.ui.login.DisplayManager.getPosition(el);
204      var offset = opt_offset == undefined ?
205          [Math.min(ARROW_OFFSET, el.offsetWidth / 2),
206           Math.min(ARROW_OFFSET, el.offsetHeight / 2)] :
207          [opt_offset, opt_offset];
208
209      var pos = {};
210      if (isRTL()) {
211        switch (attachment) {
212          case Bubble.Attachment.TOP:
213            pos.right = origin.right + offset[0] - ARROW_OFFSET;
214            pos.bottom = origin.bottom + el.offsetHeight + opt_padding;
215            break;
216          case Bubble.Attachment.RIGHT:
217            pos.top = origin.top + offset[1] - ARROW_OFFSET;
218            pos.right = origin.right + el.offsetWidth + opt_padding;
219            break;
220          case Bubble.Attachment.BOTTOM:
221            pos.right = origin.right + offset[0] - ARROW_OFFSET;
222            pos.top = origin.top + el.offsetHeight + opt_padding;
223            break;
224          case Bubble.Attachment.LEFT:
225            pos.top = origin.top + offset[1] - ARROW_OFFSET;
226            pos.left = origin.left + el.offsetWidth + opt_padding;
227            break;
228        }
229      } else {
230        switch (attachment) {
231          case Bubble.Attachment.TOP:
232            pos.left = origin.left + offset[0] - ARROW_OFFSET;
233            pos.bottom = origin.bottom + el.offsetHeight + opt_padding;
234            break;
235          case Bubble.Attachment.RIGHT:
236            pos.top = origin.top + offset[1] - ARROW_OFFSET;
237            pos.left = origin.left + el.offsetWidth + opt_padding;
238            break;
239          case Bubble.Attachment.BOTTOM:
240            pos.left = origin.left + offset[0] - ARROW_OFFSET;
241            pos.top = origin.top + el.offsetHeight + opt_padding;
242            break;
243          case Bubble.Attachment.LEFT:
244            pos.top = origin.top + offset[1] - ARROW_OFFSET;
245            pos.right = origin.right + el.offsetWidth + opt_padding;
246            break;
247        }
248      }
249
250      this.anchor_ = el;
251      this.showContentAt_(pos, attachment, opt_content);
252    },
253
254    /**
255     * Shows the bubble for given anchor element.
256     * @param {!HTMLElement} el Anchor element of the bubble.
257     * @param {string} text Text content to show in bubble.
258     * @param {!Attachment} attachment Bubble attachment (on which side of the
259     *     element it should be displayed).
260     * @param {number=} opt_offset Offset of the bubble attachment point from
261     *     left (for vertical attachment) or top (for horizontal attachment)
262     *     side of the element. If not specified, the bubble is positioned to
263     *     be aligned with the left/top side of the element but not farther than
264     *     half of its weight/height.
265     * @param {number=} opt_padding Optional padding of the bubble.
266     */
267    showTextForElement: function(el, text, attachment,
268                                 opt_offset, opt_padding) {
269      var span = this.ownerDocument.createElement('span');
270      span.textContent = text;
271      this.showContentForElement(el, attachment, span, opt_offset, opt_padding);
272    },
273
274    /**
275     * Hides the bubble.
276     */
277    hide: function() {
278      if (!this.classList.contains('faded'))
279        this.classList.add('faded');
280    },
281
282    /**
283     * Hides the bubble anchored to the given element (if any).
284     * @param {!Object} el Anchor element.
285     */
286    hideForElement: function(el) {
287      if (!this.hidden && this.anchor_ == el)
288        this.hide();
289    },
290
291    /**
292     * Handler for faded transition end.
293     * @private
294     */
295    handleTransitionEnd_: function(e) {
296      if (this.classList.contains('faded')) {
297        this.hidden = true;
298        if (this.elementToFocusOnHide_)
299          this.elementToFocusOnHide_.focus();
300      }
301    },
302
303    /**
304     * Handler of document click event.
305     * @private
306     */
307    handleDocClick_: function(e) {
308      // Ignore clicks on anchor element.
309      if (e.target == this.anchor_)
310        return;
311
312      if (!this.hidden)
313        this.hide();
314    },
315
316    /**
317     * Handle of document keydown event.
318     * @private
319     */
320    handleDocKeyDown_: function(e) {
321      if (this.hidden)
322        return;
323
324      if (this.hideOnKeyPress_) {
325        this.hide();
326        return;
327      }
328      // Artificial tab-cycle.
329      if (e.keyCode == KeyCodes.TAB && e.shiftKey == true &&
330          e.target == this.firstBubbleElement_) {
331        this.lastBubbleElement_.focus();
332        e.preventDefault();
333      }
334      if (e.keyCode == KeyCodes.TAB && e.shiftKey == false &&
335          e.target == this.lastBubbleElement_) {
336        this.firstBubbleElement_.focus();
337        e.preventDefault();
338      }
339      // Close bubble on ESC or on hitting spacebar or Enter at close-button.
340      if (e.keyCode == KeyCodes.ESC ||
341          ((e.keyCode == KeyCodes.ENTER || e.keyCode == KeyCodes.SPACE) &&
342             e.target && e.target.classList.contains('close-button')))
343        this.hide();
344    },
345
346    /**
347     * Handler of window blur event.
348     * @private
349     */
350    handleWindowBlur_: function(e) {
351      if (!this.hidden)
352        this.hide();
353    }
354  };
355
356  return {
357    Bubble: Bubble
358  };
359});
360