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.
5 // require: event_tracker.js
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
11 cr.exportPath('cr.ui');
14 * The arrow location specifies how the arrow and bubble are positioned in
15 * relation to the anchor node.
18 cr.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
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
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'
38 * The bubble alignment specifies the position of the bubble in relation to
42 cr.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
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'
59 cr.define('cr.ui', function() {
61 * Abstract base class that provides common functionality for implementing
62 * free-floating informational bubbles with a triangular arrow pointing at an
65 * @extends {HTMLDivElement}
66 * @implements {EventListener}
68 var BubbleBase = cr.ui.define('div');
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.
76 BubbleBase.ARROW_OFFSET = 30;
79 * Minimum horizontal spacing between edge of bubble and edge of viewport
80 * (when using the ENTIRELY_VISIBLE alignment). In pixels.
84 BubbleBase.MIN_VIEWPORT_EDGE_MARGIN = 2;
86 BubbleBase.prototype = {
87 // Set up the prototype chain.
88 __proto__: HTMLDivElement.prototype,
97 * Initialization function for the cr.ui framework.
99 decorate: function() {
100 this.className = 'bubble';
102 '<div class="bubble-content"></div>' +
103 '<div class="bubble-shadow"></div>' +
104 '<div class="bubble-arrow"></div>';
106 this.bubbleAlignment = cr.ui.BubbleAlignment.ENTIRELY_VISIBLE;
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.
114 set anchorNode(node) {
118 this.anchorNode_ = node;
122 * Set the conent of the bubble. Only available when the bubble is not being
124 * @param {HTMLElement} node The root node of the new content.
130 var bubbleContent = this.querySelector('.bubble-content');
131 bubbleContent.innerHTML = '';
132 bubbleContent.appendChild(node);
136 * Set the arrow location. Only available when the bubble is not being
138 * @param {cr.ui.ArrowLocation} location The new arrow location.
140 set arrowLocation(location) {
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;
153 * Set the bubble alignment. Only available when the bubble is not being
155 * @param {cr.ui.BubbleAlignment} alignment The new bubble alignment.
157 set bubbleAlignment(alignment) {
161 this.bubbleAlignment_ = alignment;
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.
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();
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;
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);
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 :
197 bubble.width - arrow.width / 2);
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,
213 var offsetBottom = Math.min(anchor.top - bubble.height,
215 if (offsetTop < 0 && offsetBottom < 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);
223 var top = anchor.top - bubble.height - offsetBottom;
224 this.updateArrowPosition_(true, false, arrowTip);
227 if (this.bubbleAlignment_ ==
228 cr.ui.BubbleAlignment.BUBBLE_EDGE_TO_ANCHOR_EDGE) {
229 var left = this.arrowAtRight_ ? anchor.right - bubble.width :
232 var left = this.arrowAtRight_ ?
233 anchorMid - this.clientWidth + BubbleBase.ARROW_OFFSET :
234 anchorMid - BubbleBase.ARROW_OFFSET;
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);
242 this.style.left = left + 'px';
243 this.style.top = top + 'px';
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);
270 this.eventTracker_.removeAll();
272 this.parentNode.removeChild(this);
276 * Handle keyboard events, dismissing the bubble if necessary.
277 * @param {Event} event The event.
279 handleEvent: function(event) {
280 // Close the bubble when the user presses <Esc>.
281 if (event.type == 'keydown' && event.keyCode == 27) {
283 event.preventDefault();
284 event.stopPropagation();
289 * Attach the bubble to the document's DOM.
292 attachToDOM_: function() {
293 document.body.appendChild(this);
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
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
306 updateArrowPosition_: function(visible, atTop, tipOffset) {
307 var bubbleArrow = this.querySelector('.bubble-arrow');
308 bubbleArrow.hidden = !visible;
312 var edgeOffset = (-bubbleArrow.clientHeight / 2) + 'px';
313 bubbleArrow.style.top = atTop ? edgeOffset : 'auto';
314 bubbleArrow.style.bottom = atTop ? 'auto' : edgeOffset;
316 edgeOffset = (tipOffset - bubbleArrow.offsetWidth / 2) + 'px';
317 bubbleArrow.style.left = this.arrowAtRight_ ? 'auto' : edgeOffset;
318 bubbleArrow.style.right = this.arrowAtRight_ ? edgeOffset : 'auto';
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.
328 * @extends {cr.ui.BubbleBase}
330 var Bubble = cr.ui.define('div');
333 // Set up the prototype chain.
334 __proto__: BubbleBase.prototype,
337 * Initialization function for the cr.ui framework.
339 decorate: function() {
340 BubbleBase.prototype.decorate.call(this);
342 var close = document.createElement('div');
343 close.className = 'bubble-close';
344 this.insertBefore(close, this.querySelector('.bubble-content'));
346 this.handleCloseEvent = this.hide;
347 this.deactivateToDismissDelay_ = 0;
348 this.bubbleAlignment = cr.ui.BubbleAlignment.ARROW_TO_MID_ANCHOR;
352 * Handler for close events triggered when the close button is clicked. By
353 * default, set to this.hide. Only available when the bubble is not being
355 * @param {function(): *} handler The new handler, a function with no
358 set handleCloseEvent(handler) {
362 this.handleCloseEvent_ = handler;
366 * Set the delay before the user is allowed to click outside the bubble to
367 * dismiss it. Using a delay makes it less likely that the user will
368 * unintentionally dismiss the bubble.
369 * @param {number} delay The delay in milliseconds.
371 set deactivateToDismissDelay(delay) {
372 this.deactivateToDismissDelay_ = delay;
376 * Hide or show the close button.
377 * @param {boolean} isVisible True if the close button should be visible.
379 set closeButtonVisible(isVisible) {
380 this.querySelector('.bubble-close').hidden = !isVisible;
390 BubbleBase.prototype.show.call(this);
392 this.showTime_ = Date.now();
393 this.eventTracker_.add(window, 'resize', this.reposition.bind(this));
397 * Handle keyboard and mouse events, dismissing the bubble if necessary.
398 * @param {Event} event The event.
399 * @suppress {checkTypes}
400 * TODO(vitalyp): remove suppression when the extern
401 * Node.prototype.contains() will be fixed.
403 handleEvent: function(event) {
404 BubbleBase.prototype.handleEvent.call(this, event);
406 if (event.type == 'mousedown') {
407 // Dismiss the bubble when the user clicks on the close button.
408 if (event.target == this.querySelector('.bubble-close')) {
409 this.handleCloseEvent_();
410 // Dismiss the bubble when the user clicks outside it after the
411 // specified delay has passed.
412 } else if (!this.contains(event.target) &&
413 Date.now() - this.showTime_ >= this.deactivateToDismissDelay_) {
421 * A bubble that closes automatically when the user clicks or moves the focus
422 * outside the bubble and its target element, scrolls the underlying document
423 * or resizes the window.
425 * @extends {cr.ui.BubbleBase}
427 var AutoCloseBubble = cr.ui.define('div');
429 AutoCloseBubble.prototype = {
430 // Set up the prototype chain.
431 __proto__: BubbleBase.prototype,
434 * Initialization function for the cr.ui framework.
436 decorate: function() {
437 BubbleBase.prototype.decorate.call(this);
438 this.classList.add('auto-close-bubble');
442 * Set the DOM sibling node, i.e. the node as whose sibling the bubble
443 * should join the DOM to ensure that focusable elements inside the bubble
444 * follow the target element in the document's tab order. Only available
445 * when the bubble is not being shown.
446 * @param {HTMLElement} node The new DOM sibling node.
448 set domSibling(node) {
452 this.domSibling_ = node;
462 BubbleBase.prototype.show.call(this);
463 this.domSibling_.showingBubble = true;
465 var doc = this.ownerDocument;
466 this.eventTracker_.add(doc, 'mousewheel', this, true);
467 this.eventTracker_.add(doc, 'scroll', this, true);
468 this.eventTracker_.add(doc, 'elementFocused', this, true);
469 this.eventTracker_.add(window, 'resize', this);
476 BubbleBase.prototype.hide.call(this);
477 this.domSibling_.showingBubble = false;
481 * Handle events, closing the bubble when the user clicks or moves the focus
482 * outside the bubble and its target element, scrolls the underlying
483 * document or resizes the window.
484 * @param {Event} event The event.
485 * @suppress {checkTypes}
486 * TODO(vitalyp): remove suppression when the extern
487 * Node.prototype.contains() will be fixed.
489 handleEvent: function(event) {
490 BubbleBase.prototype.handleEvent.call(this, event);
492 switch (event.type) {
493 // Close the bubble when the user clicks outside it, except if it is a
494 // left-click on the bubble's target element (allowing the target to
495 // handle the event and close the bubble itself).
497 var target = assertInstanceof(event.target, Node);
498 if (event.button == 0 && this.anchorNode_.contains(target))
500 // Close the bubble when the underlying document is scrolled.
503 var target = assertInstanceof(event.target, Node);
504 if (this.contains(target))
506 // Close the bubble when the window is resized.
510 // Close the bubble when the focus moves to an element that is not the
511 // bubble target and is not inside the bubble.
512 case 'elementFocused':
513 var target = assertInstanceof(event.target, Node);
514 if (!this.anchorNode_.contains(target) && !this.contains(target))
521 * Attach the bubble to the document's DOM, making it a sibling of the
522 * |domSibling_| so that focusable elements inside the bubble follow the
523 * target element in the document's tab order.
526 attachToDOM_: function() {
527 var parent = this.domSibling_.parentNode;
528 parent.insertBefore(this, this.domSibling_.nextSibling);
534 BubbleBase: BubbleBase,
536 AutoCloseBubble: AutoCloseBubble