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