| // Copyright (c) 2011 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| // require: event_tracker.js |
| |
| cr.define('cr.ui', function() { |
| 'use strict'; |
| |
| /** |
| * ExpandableBubble is a free-floating compact informational bubble with an |
| * arrow that points at a place of interest on the page. When clicked, the |
| * bubble expands to show more of its content. Width of the bubble is the |
| * width of the node it is overlapping when unexpanded. Expanded, it is of a |
| * fixed width, but variable height. Currently the arrow is always positioned |
| * at the bottom right and points down. |
| * @constructor |
| * @extends {cr.ui.div} |
| */ |
| var ExpandableBubble = cr.ui.define('div'); |
| |
| ExpandableBubble.prototype = { |
| __proto__: HTMLDivElement.prototype, |
| |
| /** @inheritDoc */ |
| decorate: function() { |
| this.className = 'expandable-bubble'; |
| this.innerHTML = |
| '<div class="expandable-bubble-contents">' + |
| '<div class="expandable-bubble-title"></div>' + |
| '<div class="expandable-bubble-main" hidden></div>' + |
| '</div>' + |
| '<div class="expandable-bubble-close" hidden></div>' + |
| '<div class="expandable-bubble-shadow"></div>' + |
| '<div class="expandable-bubble-arrow"></div>'; |
| |
| this.hidden = true; |
| this.handleCloseEvent = this.hide; |
| }, |
| |
| /** |
| * Sets the title of the bubble. The title is always visible when the |
| * bubble is visible. |
| * @type {Node} An HTML element to set as the title. |
| */ |
| set contentTitle(node) { |
| var bubbleTitle = this.querySelector('.expandable-bubble-title'); |
| bubbleTitle.textContent = ''; |
| bubbleTitle.appendChild(node); |
| }, |
| |
| /** |
| * Sets the content node of the bubble. The content node is only visible |
| * when the bubble is expanded. |
| * @param {Node} An HTML element. |
| */ |
| set content(node) { |
| var bubbleMain = this.querySelector('.expandable-bubble-main'); |
| bubbleMain.textContent = ''; |
| bubbleMain.appendChild(node); |
| }, |
| |
| /** |
| * Sets the anchor node, i.e. the node that this bubble points at and |
| * partially overlaps. |
| * @param {HTMLElement} node The new anchor node. |
| */ |
| set anchorNode(node) { |
| this.anchorNode_ = node; |
| |
| if (!this.hidden) |
| this.resizeAndReposition_(); |
| }, |
| |
| /** |
| * Handles the close event which is triggered when the close button |
| * is clicked. By default is set to this.hide. |
| * @param {function} A function with no parameters |
| */ |
| set handleCloseEvent(func) { |
| this.handleCloseEvent_ = func; |
| }, |
| |
| /** |
| * Updates the position of the bubble. |
| * @private |
| */ |
| reposition_: function() { |
| var clientRect = this.anchorNode_.getBoundingClientRect(); |
| if (clientRect.width <= 0) { |
| // When the page loads initially, the icons for the apps haven't loaded |
| // yet so the width of the anchor is 0. We then make sure we don't draw |
| // at 0,0 by drawing off-screen instead. We'll get another chance to |
| // reposition when the icons have loaded. |
| this.style.top = "-999px"; |
| return; |
| } |
| |
| this.style.left = this.style.right = clientRect.left + 'px'; |
| |
| var top = clientRect.top - 1; |
| this.style.top = this.expanded ? |
| (top - this.offsetHeight + this.unexpandedHeight) + 'px' : |
| top + 'px'; |
| }, |
| |
| /** |
| * Resizes the bubble and then repositions it. |
| * @private |
| */ |
| resizeAndReposition_: function() { |
| var clientRect = this.anchorNode_.getBoundingClientRect(); |
| var width = clientRect.width; |
| |
| var bubbleTitle = this.querySelector('.expandable-bubble-title'); |
| var closeElement = this.querySelector('.expandable-bubble-close'); |
| var closeWidth = this.expanded ? closeElement.clientWidth : 0; |
| var margin = 12; |
| |
| if (this.expanded) { |
| // We always show the full title but never show less width than 250 |
| // pixels. |
| var expandedWidth = |
| Math.max(250, bubbleTitle.scrollWidth + closeWidth + margin); |
| this.style.marginLeft = (width - expandedWidth) + 'px'; |
| width = expandedWidth; |
| } else { |
| this.style.marginLeft = '0'; |
| } |
| |
| // Width is dynamic (when not expanded) based on the width of the anchor |
| // node, and the title and shadow need to follow suit. |
| this.style.width = width + 'px'; |
| bubbleTitle.style.width = Math.max(0, width - margin - closeWidth) + 'px'; |
| var bubbleContent = this.querySelector('.expandable-bubble-main'); |
| bubbleContent.style.width = Math.max(0, width - margin) + 'px'; |
| var bubbleShadow = this.querySelector('.expandable-bubble-shadow'); |
| bubbleShadow.style.width = width ? width + 2 + 'px' : 0 + 'px'; |
| |
| // Also reposition the bubble -- dimensions have potentially changed. |
| this.reposition_(); |
| }, |
| |
| /* |
| * Expand the bubble (bringing the full content into view). |
| * @private |
| */ |
| expandBubble_: function() { |
| this.querySelector('.expandable-bubble-main').hidden = false; |
| this.querySelector('.expandable-bubble-close').hidden = false; |
| this.expanded = true; |
| this.resizeAndReposition_(); |
| }, |
| |
| /** |
| * Collapse the bubble, hiding the main content and the close button. |
| * This is automatically called when the window is resized. |
| * @private |
| */ |
| collapseBubble_: function() { |
| this.querySelector('.expandable-bubble-main').hidden = true; |
| this.querySelector('.expandable-bubble-close').hidden = true; |
| this.expanded = false; |
| this.resizeAndReposition_(); |
| }, |
| |
| /** |
| * The onclick handler for the notification (expands the bubble). |
| * @param {Event} e The event. |
| * @private |
| */ |
| onNotificationClick_ : function(e) { |
| if (!this.contains(e.target)) |
| return; |
| |
| if (!this.expanded) { |
| // Save the height of the unexpanded bubble, so we can make sure to |
| // position it correctly (arrow points in the same location) after |
| // we expand it. |
| this.unexpandedHeight = this.offsetHeight; |
| } |
| |
| this.expandBubble_(); |
| }, |
| |
| /** |
| * Shows the bubble. The bubble will start collapsed and expand when |
| * clicked. |
| */ |
| show: function() { |
| if (!this.hidden) |
| return; |
| |
| document.body.appendChild(this); |
| this.hidden = false; |
| this.resizeAndReposition_(); |
| |
| this.eventTracker_ = new EventTracker; |
| this.eventTracker_.add(window, |
| 'load', this.resizeAndReposition_.bind(this)); |
| this.eventTracker_.add(window, |
| 'resize', this.resizeAndReposition_.bind(this)); |
| this.eventTracker_.add(this, 'click', this.onNotificationClick_); |
| |
| var doc = this.ownerDocument; |
| this.eventTracker_.add(doc, 'keydown', this, true); |
| this.eventTracker_.add(doc, 'mousedown', this, true); |
| }, |
| |
| /** |
| * Hides the bubble from view. |
| */ |
| hide: function() { |
| this.hidden = true; |
| this.eventTracker_.removeAll(); |
| this.parentNode.removeChild(this); |
| }, |
| |
| /** |
| * Handles keydown and mousedown events, dismissing the bubble if |
| * necessary. |
| * @param {Event} e The event. |
| * @private |
| */ |
| handleEvent: function(e) { |
| var handled = false; |
| switch (e.type) { |
| case 'keydown': |
| if (e.keyCode == 27) { // Esc. |
| if (this.expanded) { |
| this.collapseBubble_(); |
| handled = true; |
| } |
| } |
| break; |
| |
| case 'mousedown': |
| if (e.target == this.querySelector('.expandable-bubble-close')) { |
| this.handleCloseEvent_(); |
| handled = true; |
| } else if (!this.contains(e.target)) { |
| if (this.expanded) { |
| this.collapseBubble_(); |
| handled = true; |
| } |
| } |
| break; |
| } |
| |
| if (handled) { |
| // The bubble emulates a focus grab when expanded, so when we've |
| // collapsed/hide the bubble we consider the event handles and don't |
| // need to propagate it further. |
| e.stopPropagation(); |
| e.preventDefault(); |
| } |
| }, |
| }; |
| |
| /** |
| * Whether the bubble is expanded or not. |
| * @type {boolean} |
| */ |
| cr.defineProperty(ExpandableBubble, 'expanded', cr.PropertyKind.BOOL_ATTR); |
| |
| return { |
| ExpandableBubble: ExpandableBubble |
| }; |
| }); |