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