| // Copyright (c) 2012 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 |
| |
| // TODO(vitalyp): Inline the enums below into cr.ui definition function, remove |
| // cr.exportPath() call and remove exportPath from exports in cr.js when this |
| // issue will be fixed: |
| // https://github.com/google/closure-compiler/issues/544 |
| cr.exportPath('cr.ui'); |
| |
| /** |
| * The arrow location specifies how the arrow and bubble are positioned in |
| * relation to the anchor node. |
| * @enum {string} |
| */ |
| cr.ui.ArrowLocation = { |
| // The arrow is positioned at the top and the start of the bubble. In left |
| // to right mode this is the top left. The entire bubble is positioned below |
| // the anchor node. |
| TOP_START: 'top-start', |
| // The arrow is positioned at the top and the end of the bubble. In left to |
| // right mode this is the top right. The entire bubble is positioned below |
| // the anchor node. |
| TOP_END: 'top-end', |
| // The arrow is positioned at the bottom and the start of the bubble. In |
| // left to right mode this is the bottom left. The entire bubble is |
| // positioned above the anchor node. |
| BOTTOM_START: 'bottom-start', |
| // The arrow is positioned at the bottom and the end of the bubble. In |
| // left to right mode this is the bottom right. The entire bubble is |
| // positioned above the anchor node. |
| BOTTOM_END: 'bottom-end' |
| }; |
| |
| /** |
| * The bubble alignment specifies the position of the bubble in relation to |
| * the anchor node. |
| * @enum {string} |
| */ |
| cr.ui.BubbleAlignment = { |
| // The bubble is positioned just above or below the anchor node (as |
| // specified by the arrow location) so that the arrow points at the midpoint |
| // of the anchor. |
| ARROW_TO_MID_ANCHOR: 'arrow-to-mid-anchor', |
| // The bubble is positioned just above or below the anchor node (as |
| // specified by the arrow location) so that its reference edge lines up with |
| // the edge of the anchor. |
| BUBBLE_EDGE_TO_ANCHOR_EDGE: 'bubble-edge-anchor-edge', |
| // The bubble is positioned so that it is entirely within view and does not |
| // obstruct the anchor element, if possible. The specified arrow location is |
| // taken into account as the preferred alignment but may be overruled if |
| // there is insufficient space (see BubbleBase.reposition for the exact |
| // placement algorithm). |
| ENTIRELY_VISIBLE: 'entirely-visible' |
| }; |
| |
| cr.define('cr.ui', function() { |
| /** |
| * Abstract base class that provides common functionality for implementing |
| * free-floating informational bubbles with a triangular arrow pointing at an |
| * anchor node. |
| * @constructor |
| * @extends {HTMLDivElement} |
| * @implements {EventListener} |
| */ |
| const BubbleBase = cr.ui.define('div'); |
| |
| /** |
| * The horizontal distance between the tip of the arrow and the reference edge |
| * of the bubble (as specified by the arrow location). In pixels. |
| * @type {number} |
| * @const |
| */ |
| BubbleBase.ARROW_OFFSET = 30; |
| |
| /** |
| * Minimum horizontal spacing between edge of bubble and edge of viewport |
| * (when using the ENTIRELY_VISIBLE alignment). In pixels. |
| * @type {number} |
| * @const |
| */ |
| BubbleBase.MIN_VIEWPORT_EDGE_MARGIN = 2; |
| |
| BubbleBase.prototype = { |
| // Set up the prototype chain. |
| __proto__: HTMLDivElement.prototype, |
| |
| /** |
| * @type {Node} |
| * @private |
| */ |
| anchorNode_: null, |
| |
| /** |
| * Initialization function for the cr.ui framework. |
| */ |
| decorate: function() { |
| this.className = 'bubble'; |
| this.innerHTML = '<div class="bubble-content"></div>' + |
| '<div class="bubble-shadow"></div>' + |
| '<div class="bubble-arrow"></div>'; |
| this.hidden = true; |
| this.bubbleAlignment = cr.ui.BubbleAlignment.ENTIRELY_VISIBLE; |
| }, |
| |
| /** |
| * Set the anchor node, i.e. the node that this bubble points at. Only |
| * available when the bubble is not being shown. |
| * @param {HTMLElement} node The new anchor node. |
| */ |
| set anchorNode(node) { |
| if (!this.hidden) { |
| return; |
| } |
| |
| this.anchorNode_ = node; |
| }, |
| |
| /** |
| * Set the conent of the bubble. Only available when the bubble is not being |
| * shown. |
| * @param {HTMLElement} node The root node of the new content. |
| */ |
| set content(node) { |
| if (!this.hidden) { |
| return; |
| } |
| |
| const bubbleContent = this.querySelector('.bubble-content'); |
| bubbleContent.innerHTML = ''; |
| bubbleContent.appendChild(node); |
| }, |
| |
| /** |
| * Set the arrow location. Only available when the bubble is not being |
| * shown. |
| * @param {cr.ui.ArrowLocation} location The new arrow location. |
| */ |
| set arrowLocation(location) { |
| if (!this.hidden) { |
| return; |
| } |
| |
| this.arrowAtRight_ = location == cr.ui.ArrowLocation.TOP_END || |
| location == cr.ui.ArrowLocation.BOTTOM_END; |
| if (document.documentElement.dir == 'rtl') { |
| this.arrowAtRight_ = !this.arrowAtRight_; |
| } |
| this.arrowAtTop_ = location == cr.ui.ArrowLocation.TOP_START || |
| location == cr.ui.ArrowLocation.TOP_END; |
| }, |
| |
| /** |
| * Set the bubble alignment. Only available when the bubble is not being |
| * shown. |
| * @param {cr.ui.BubbleAlignment} alignment The new bubble alignment. |
| */ |
| set bubbleAlignment(alignment) { |
| if (!this.hidden) { |
| return; |
| } |
| |
| this.bubbleAlignment_ = alignment; |
| }, |
| |
| /** |
| * Update the position of the bubble. Whenever the layout may have changed, |
| * the bubble should either be repositioned by calling this function or |
| * hidden so that it does not point to a nonsensical location on the page. |
| */ |
| reposition: function() { |
| const documentWidth = document.documentElement.clientWidth; |
| const documentHeight = document.documentElement.clientHeight; |
| const anchor = this.anchorNode_.getBoundingClientRect(); |
| const anchorMid = (anchor.left + anchor.right) / 2; |
| const bubble = this.getBoundingClientRect(); |
| const arrow = this.querySelector('.bubble-arrow').getBoundingClientRect(); |
| |
| let left; |
| let top; |
| if (this.bubbleAlignment_ == cr.ui.BubbleAlignment.ENTIRELY_VISIBLE) { |
| // Work out horizontal placement. The bubble is initially positioned so |
| // that the arrow tip points toward the midpoint of the anchor and is |
| // BubbleBase.ARROW_OFFSET pixels from the reference edge and (as |
| // specified by the arrow location). If the bubble is not entirely |
| // within view, it is then shifted, preserving the arrow tip position. |
| left = this.arrowAtRight_ ? |
| anchorMid + BubbleBase.ARROW_OFFSET - bubble.width : |
| anchorMid - BubbleBase.ARROW_OFFSET; |
| const maxLeftPos = |
| documentWidth - bubble.width - BubbleBase.MIN_VIEWPORT_EDGE_MARGIN; |
| const minLeftPos = BubbleBase.MIN_VIEWPORT_EDGE_MARGIN; |
| if (document.documentElement.dir == 'rtl') { |
| left = Math.min(Math.max(left, minLeftPos), maxLeftPos); |
| } else { |
| left = Math.max(Math.min(left, maxLeftPos), minLeftPos); |
| } |
| const arrowTip = Math.min( |
| Math.max( |
| arrow.width / 2, |
| this.arrowAtRight_ ? left + bubble.width - anchorMid : |
| anchorMid - left), |
| bubble.width - arrow.width / 2); |
| |
| // Work out the vertical placement, attempting to fit the bubble |
| // entirely into view. The following placements are considered in |
| // decreasing order of preference: |
| // * Outside the anchor, arrow tip touching the anchor (arrow at |
| // top/bottom as specified by the arrow location). |
| // * Outside the anchor, arrow tip touching the anchor (arrow at |
| // bottom/top, opposite the specified arrow location). |
| // * Outside the anchor, arrow tip overlapping the anchor (arrow at |
| // top/bottom as specified by the arrow location). |
| // * Outside the anchor, arrow tip overlapping the anchor (arrow at |
| // bottom/top, opposite the specified arrow location). |
| // * Overlapping the anchor. |
| const offsetTop = Math.min( |
| documentHeight - anchor.bottom - bubble.height, arrow.height / 2); |
| const offsetBottom = |
| Math.min(anchor.top - bubble.height, arrow.height / 2); |
| if (offsetTop < 0 && offsetBottom < 0) { |
| top = 0; |
| this.updateArrowPosition_(false, false, arrowTip); |
| } else if ( |
| offsetTop > offsetBottom || |
| offsetTop == offsetBottom && this.arrowAtTop_) { |
| top = anchor.bottom + offsetTop; |
| this.updateArrowPosition_(true, true, arrowTip); |
| } else { |
| top = anchor.top - bubble.height - offsetBottom; |
| this.updateArrowPosition_(true, false, arrowTip); |
| } |
| } else { |
| if (this.bubbleAlignment_ == |
| cr.ui.BubbleAlignment.BUBBLE_EDGE_TO_ANCHOR_EDGE) { |
| left = this.arrowAtRight_ ? anchor.right - bubble.width : anchor.left; |
| } else { |
| left = this.arrowAtRight_ ? |
| anchorMid - this.clientWidth + BubbleBase.ARROW_OFFSET : |
| anchorMid - BubbleBase.ARROW_OFFSET; |
| } |
| top = this.arrowAtTop_ ? |
| anchor.bottom + arrow.height / 2 : |
| anchor.top - this.clientHeight - arrow.height / 2; |
| this.updateArrowPosition_( |
| true, this.arrowAtTop_, BubbleBase.ARROW_OFFSET); |
| } |
| |
| this.style.left = left + 'px'; |
| this.style.top = top + 'px'; |
| }, |
| |
| /** |
| * Show the bubble. |
| */ |
| show: function() { |
| if (!this.hidden) { |
| return; |
| } |
| |
| this.attachToDOM_(); |
| this.hidden = false; |
| this.reposition(); |
| |
| const doc = assert(this.ownerDocument); |
| this.eventTracker_ = new EventTracker; |
| this.eventTracker_.add(doc, 'keydown', this, true); |
| this.eventTracker_.add(doc, 'mousedown', this, true); |
| }, |
| |
| /** |
| * Hide the bubble. |
| */ |
| hide: function() { |
| if (this.hidden) { |
| return; |
| } |
| |
| this.eventTracker_.removeAll(); |
| this.hidden = true; |
| this.parentNode.removeChild(this); |
| }, |
| |
| /** |
| * Handle keyboard events, dismissing the bubble if necessary. |
| * @param {Event} event The event. |
| */ |
| handleEvent: function(event) { |
| // Close the bubble when the user presses <Esc>. |
| if (event.type == 'keydown' && event.keyCode == 27) { |
| this.hide(); |
| event.preventDefault(); |
| event.stopPropagation(); |
| } |
| }, |
| |
| /** |
| * Attach the bubble to the document's DOM. |
| * @private |
| */ |
| attachToDOM_: function() { |
| document.body.appendChild(this); |
| }, |
| |
| /** |
| * Update the arrow so that it appears at the correct position. |
| * @param {boolean} visible Whether the arrow should be visible. |
| * @param {boolean} atTop Whether the arrow should be at the top of the |
| * bubble. |
| * @param {number} tipOffset The horizontal distance between the tip of the |
| * arrow and the reference edge of the bubble (as specified by the arrow |
| * location). |
| * @private |
| */ |
| updateArrowPosition_: function(visible, atTop, tipOffset) { |
| const bubbleArrow = this.querySelector('.bubble-arrow'); |
| bubbleArrow.hidden = !visible; |
| if (!visible) { |
| return; |
| } |
| |
| let edgeOffset = (-bubbleArrow.clientHeight / 2) + 'px'; |
| bubbleArrow.style.top = atTop ? edgeOffset : 'auto'; |
| bubbleArrow.style.bottom = atTop ? 'auto' : edgeOffset; |
| |
| edgeOffset = (tipOffset - bubbleArrow.offsetWidth / 2) + 'px'; |
| bubbleArrow.style.left = this.arrowAtRight_ ? 'auto' : edgeOffset; |
| bubbleArrow.style.right = this.arrowAtRight_ ? edgeOffset : 'auto'; |
| }, |
| }; |
| |
| /** |
| * A bubble that remains open until the user explicitly dismisses it or clicks |
| * outside the bubble after it has been shown for at least the specified |
| * amount of time (making it less likely that the user will unintentionally |
| * dismiss the bubble). The bubble repositions itself on layout changes. |
| * @constructor |
| * @extends {cr.ui.BubbleBase} |
| */ |
| const Bubble = cr.ui.define('div'); |
| |
| Bubble.prototype = { |
| // Set up the prototype chain. |
| __proto__: BubbleBase.prototype, |
| |
| /** |
| * Initialization function for the cr.ui framework. |
| */ |
| decorate: function() { |
| BubbleBase.prototype.decorate.call(this); |
| |
| const close = document.createElement('div'); |
| close.className = 'bubble-close'; |
| this.insertBefore(close, this.querySelector('.bubble-content')); |
| |
| this.handleCloseEvent = this.hide; |
| this.deactivateToDismissDelay_ = 0; |
| this.bubbleAlignment = cr.ui.BubbleAlignment.ARROW_TO_MID_ANCHOR; |
| }, |
| |
| /** |
| * Handler for close events triggered when the close button is clicked. By |
| * default, set to this.hide. Only available when the bubble is not being |
| * shown. |
| * @param {function(): *} handler The new handler, a function with no |
| * parameters. |
| */ |
| set handleCloseEvent(handler) { |
| if (!this.hidden) { |
| return; |
| } |
| |
| this.handleCloseEvent_ = handler; |
| }, |
| |
| /** |
| * Set the delay before the user is allowed to click outside the bubble to |
| * dismiss it. Using a delay makes it less likely that the user will |
| * unintentionally dismiss the bubble. |
| * @param {number} delay The delay in milliseconds. |
| */ |
| set deactivateToDismissDelay(delay) { |
| this.deactivateToDismissDelay_ = delay; |
| }, |
| |
| /** |
| * Hide or show the close button. |
| * @param {boolean} isVisible True if the close button should be visible. |
| */ |
| set closeButtonVisible(isVisible) { |
| this.querySelector('.bubble-close').hidden = !isVisible; |
| }, |
| |
| /** |
| * Show the bubble. |
| */ |
| show: function() { |
| if (!this.hidden) { |
| return; |
| } |
| |
| BubbleBase.prototype.show.call(this); |
| |
| this.showTime_ = Date.now(); |
| this.eventTracker_.add(window, 'resize', this.reposition.bind(this)); |
| }, |
| |
| /** |
| * Handle keyboard and mouse events, dismissing the bubble if necessary. |
| * @param {Event} event The event. |
| * @suppress {checkTypes} |
| * TODO(vitalyp): remove suppression when the extern |
| * Node.prototype.contains() will be fixed. |
| */ |
| handleEvent: function(event) { |
| BubbleBase.prototype.handleEvent.call(this, event); |
| |
| if (event.type == 'mousedown') { |
| // Dismiss the bubble when the user clicks on the close button. |
| if (event.target == this.querySelector('.bubble-close')) { |
| this.handleCloseEvent_(); |
| // Dismiss the bubble when the user clicks outside it after the |
| // specified delay has passed. |
| } else if ( |
| !this.contains(event.target) && |
| Date.now() - this.showTime_ >= this.deactivateToDismissDelay_) { |
| this.hide(); |
| } |
| } |
| }, |
| }; |
| |
| /** |
| * A bubble that closes automatically when the user clicks or moves the focus |
| * outside the bubble and its target element, scrolls the underlying document |
| * or resizes the window. |
| * @constructor |
| * @extends {cr.ui.BubbleBase} |
| */ |
| const AutoCloseBubble = cr.ui.define('div'); |
| |
| AutoCloseBubble.prototype = { |
| // Set up the prototype chain. |
| __proto__: BubbleBase.prototype, |
| |
| /** |
| * Initialization function for the cr.ui framework. |
| */ |
| decorate: function() { |
| BubbleBase.prototype.decorate.call(this); |
| this.classList.add('auto-close-bubble'); |
| }, |
| |
| /** |
| * Set the DOM sibling node, i.e. the node as whose sibling the bubble |
| * should join the DOM to ensure that focusable elements inside the bubble |
| * follow the target element in the document's tab order. Only available |
| * when the bubble is not being shown. |
| * @param {HTMLElement} node The new DOM sibling node. |
| */ |
| set domSibling(node) { |
| if (!this.hidden) { |
| return; |
| } |
| |
| this.domSibling_ = node; |
| }, |
| |
| /** |
| * Show the bubble. |
| */ |
| show: function() { |
| if (!this.hidden) { |
| return; |
| } |
| |
| BubbleBase.prototype.show.call(this); |
| this.domSibling_.showingBubble = true; |
| |
| const doc = this.ownerDocument; |
| this.eventTracker_.add(doc, 'click', this, true); |
| this.eventTracker_.add(doc, 'mousewheel', this, true); |
| this.eventTracker_.add(doc, 'scroll', this, true); |
| this.eventTracker_.add(doc, 'elementFocused', this, true); |
| this.eventTracker_.add(window, 'resize', this); |
| }, |
| |
| /** |
| * Hide the bubble. |
| */ |
| hide: function() { |
| BubbleBase.prototype.hide.call(this); |
| this.domSibling_.showingBubble = false; |
| }, |
| |
| /** |
| * Handle events, closing the bubble when the user clicks or moves the focus |
| * outside the bubble and its target element, scrolls the underlying |
| * document or resizes the window. |
| * @param {Event} event The event. |
| * @suppress {checkTypes} |
| * TODO(vitalyp): remove suppression when the extern |
| * Node.prototype.contains() will be fixed. |
| */ |
| handleEvent: function(event) { |
| BubbleBase.prototype.handleEvent.call(this, event); |
| |
| let target; |
| switch (event.type) { |
| // Close the bubble when the user clicks outside it, except if it is a |
| // left-click on the bubble's target element (allowing the target to |
| // handle the event and close the bubble itself). |
| case 'mousedown': |
| case 'click': |
| target = assertInstanceof(event.target, Node); |
| if (event.button == 0 && this.anchorNode_.contains(target)) { |
| break; |
| } |
| // Close the bubble when the underlying document is scrolled. |
| case 'mousewheel': |
| case 'scroll': |
| target = assertInstanceof(event.target, Node); |
| if (this.contains(target)) { |
| break; |
| } |
| // Close the bubble when the window is resized. |
| case 'resize': |
| this.hide(); |
| break; |
| // Close the bubble when the focus moves to an element that is not the |
| // bubble target and is not inside the bubble. |
| case 'elementFocused': |
| target = assertInstanceof(event.target, Node); |
| if (!this.anchorNode_.contains(target) && !this.contains(target)) { |
| this.hide(); |
| } |
| break; |
| } |
| }, |
| |
| /** |
| * Attach the bubble to the document's DOM, making it a sibling of the |
| * |domSibling_| so that focusable elements inside the bubble follow the |
| * target element in the document's tab order. |
| * @private |
| */ |
| attachToDOM_: function() { |
| const parent = this.domSibling_.parentNode; |
| parent.insertBefore(this, this.domSibling_.nextSibling); |
| }, |
| }; |
| |
| |
| return { |
| BubbleBase: BubbleBase, |
| Bubble: Bubble, |
| AutoCloseBubble: AutoCloseBubble |
| }; |
| }); |