// Copyright 2014 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. /** * @fileoverview Bubble implementation. */ // TODO(xiyuan): Move this into shared. cr.define('cr.ui', function() { /** * Creates a bubble div. * @constructor * @extends {HTMLDivElement} */ var Bubble = cr.ui.define('div'); /** * Bubble key codes. * @enum {number} */ var Keys = { TAB: 'Tab', ENTER: 'Enter', ESC: 'Escape', SPACE: ' ' }; /** * Bubble attachment side. * @enum {number} */ Bubble.Attachment = { RIGHT: 0, LEFT: 1, TOP: 2, BOTTOM: 3 }; Bubble.prototype = { __proto__: HTMLDivElement.prototype, // Anchor element for this bubble. anchor_: undefined, // If defined, sets focus to this element once bubble is closed. Focus is // set to this element only if there's no any other focused element. elementToFocusOnHide_: undefined, // With help of these elements we create closed artificial tab-cycle through // bubble elements. firstBubbleElement_: undefined, lastBubbleElement_: undefined, // Whether to hide bubble when key is pressed. hideOnKeyPress_: true, persistent_: false, /** @override */ decorate: function() { this.docKeyDownHandler_ = this.handleDocKeyDown_.bind(this); this.selfClickHandler_ = this.handleSelfClick_.bind(this); this.ownerDocument.addEventListener('click', this.handleDocClick_.bind(this)); // Set useCapture to true because scroll event does not bubble. this.ownerDocument.addEventListener('scroll', this.handleScroll_.bind(this), true); this.ownerDocument.addEventListener('keydown', this.docKeyDownHandler_); window.addEventListener('blur', this.handleWindowBlur_.bind(this)); this.addEventListener('transitionend', this.handleTransitionEnd_.bind(this)); // Guard timer for 200ms + epsilon. ensureTransitionEndEvent(this, 250); }, /** * Element that should be focused on hide. * @type {HTMLElement} */ set elementToFocusOnHide(value) { this.elementToFocusOnHide_ = value; }, /** * Element that should be focused on shift-tab of first bubble element * to create artificial closed tab-cycle through bubble. * Usually close-button. * @type {HTMLElement} */ set lastBubbleElement(value) { this.lastBubbleElement_ = value; }, /** * Whether the bubble should remain shown on user action events (e.g. on * user clicking, or scrolling outside the bubble). Note that * {@code this.hideOnKeyPress} has precedence. * @type {boolean} */ set persistent(value) { this.persistent_ = value }, /** * If set, the element at which the bubble is anchored. * @type {HTMLElement|undefined} */ get anchor() { return this.anchor_; }, /** * Element that should be focused on tab of last bubble element * to create artificial closed tab-cycle through bubble. * Same element as first focused on bubble opening. * @type {HTMLElement} */ set firstBubbleElement(value) { this.firstBubbleElement_ = value; }, /** * Whether to hide bubble when key is pressed. * @type {boolean} */ set hideOnKeyPress(value) { this.hideOnKeyPress_ = value; }, /** * Whether to hide bubble when clicked inside bubble element. * Default is true. * @type {boolean} */ set hideOnSelfClick(value) { if (value) this.removeEventListener('click', this.selfClickHandler_); else this.addEventListener('click', this.selfClickHandler_); }, /** * Handler for click event which prevents bubble auto hide. * @private */ handleSelfClick_: function(e) { // Allow clicking on [x] button. if (e.target && e.target.classList.contains('close-button')) return; e.stopPropagation(); }, /** * Sets the attachment of the bubble. * @param {!Attachment} attachment Bubble attachment. */ setAttachment_: function(attachment) { var styleClassList = ['bubble-right', 'bubble-left', 'bubble-top', 'bubble-bottom']; for (var i = 0; i < styleClassList.length; ++i) this.classList.toggle(styleClassList[i], i == attachment); }, /** * Shows the bubble for given anchor element. * @param {!Object} pos Bubble position (left, top, right, bottom in px). * @param {HTMLElement} opt_content Content to show in bubble. * If not specified, bubble element content is shown. * @param {Attachment=} opt_attachment Bubble attachment (on which side of * the element it should be displayed). * @param {boolean=} opt_oldstyle Optional flag to force old style bubble, * i.e. pre-MD-style. * @private */ showContentAt_: function(pos, opt_content, opt_attachment, opt_oldstyle) { this.style.top = this.style.left = this.style.right = this.style.bottom = 'auto'; for (var k in pos) { if (typeof pos[k] == 'number') this.style[k] = pos[k] + 'px'; } if (opt_content !== undefined) this.replaceContent(opt_content); if (opt_oldstyle) { this.setAttribute('oldstyle', ''); this.setAttachment_(opt_attachment); } this.hidden = false; this.classList.remove('faded'); }, /** * Replaces error message content with the given DOM element. * @param {HTMLElement} content Content to show in bubble. */ replaceContent: function(content) { this.innerHTML = ''; this.appendChild(content); }, /** * Shows the bubble for given anchor element. Bubble content is not cleared. * @param {!HTMLElement} el Anchor element of the bubble. * @param {!Attachment} attachment Bubble attachment (on which side of the * element it should be displayed). * @param {number=} opt_offset Offset of the bubble. * @param {number=} opt_padding Optional padding of the bubble. */ showForElement: function(el, attachment, opt_offset, opt_padding) { /* showForElement() is used only to display Accessibility popup in * oobe_screen_network*. It requires old-style bubble, so it is safe * to always set this flag here. */ this.showContentForElement( el, attachment, undefined, opt_offset, opt_padding, undefined, true); }, /** * Shows the bubble for given anchor element. * @param {!HTMLElement} el Anchor element of the bubble. * @param {!Attachment} attachment Bubble attachment (on which side of the * element it should be displayed). * @param {HTMLElement} opt_content Content to show in bubble. * If not specified, bubble element content is shown. * @param {number=} opt_offset Offset of the bubble attachment point from * left (for vertical attachment) or top (for horizontal attachment) * side of the element. If not specified, the bubble is positioned to * be aligned with the left/top side of the element but not farther than * half of its width/height. * @param {number=} opt_padding Optional padding of the bubble. * @param {boolean=} opt_match_width Optional flag to force the bubble have * the same width as the element it it attached to. * @param {boolean=} opt_oldstyle Optional flag to force old style bubble, * i.e. pre-MD-style. */ showContentForElement: function( el, attachment, opt_content, opt_offset, opt_padding, opt_match_width, opt_oldstyle) { /** @const */ var ARROW_OFFSET = 25; /** @const */ var DEFAULT_PADDING = 18; if (opt_padding == undefined) opt_padding = DEFAULT_PADDING; if (!opt_oldstyle) opt_padding += 10; var origin = cr.ui.login.DisplayManager.getPosition(el); var offset = opt_offset == undefined ? [Math.min(ARROW_OFFSET, el.offsetWidth / 2), Math.min(ARROW_OFFSET, el.offsetHeight / 2)] : [opt_offset, opt_offset]; var pos = {}; if (isRTL()) { switch (attachment) { case Bubble.Attachment.TOP: pos.right = origin.right + offset[0] - ARROW_OFFSET; pos.bottom = origin.bottom + el.offsetHeight + opt_padding; break; case Bubble.Attachment.RIGHT: pos.top = origin.top + offset[1] - ARROW_OFFSET; pos.right = origin.right + el.offsetWidth + opt_padding; break; case Bubble.Attachment.BOTTOM: pos.right = origin.right + offset[0] - ARROW_OFFSET; pos.top = origin.top + el.offsetHeight + opt_padding; break; case Bubble.Attachment.LEFT: pos.top = origin.top + offset[1] - ARROW_OFFSET; pos.left = origin.left + el.offsetWidth + opt_padding; break; } } else { switch (attachment) { case Bubble.Attachment.TOP: pos.left = origin.left + offset[0] - ARROW_OFFSET; pos.bottom = origin.bottom + el.offsetHeight + opt_padding; break; case Bubble.Attachment.RIGHT: pos.top = origin.top + offset[1] - ARROW_OFFSET; pos.left = origin.left + el.offsetWidth + opt_padding; break; case Bubble.Attachment.BOTTOM: pos.left = origin.left + offset[0] - ARROW_OFFSET; pos.top = origin.top + el.offsetHeight + opt_padding; break; case Bubble.Attachment.LEFT: pos.top = origin.top + offset[1] - ARROW_OFFSET; pos.right = origin.right + el.offsetWidth + opt_padding; break; } } this.style.width = ''; this.removeAttribute('match-width'); if (opt_match_width) { this.setAttribute('match-width', ''); var elWidth = window.getComputedStyle(el, null).getPropertyValue('width'); var paddingLeft = parseInt(window.getComputedStyle(this, null) .getPropertyValue('padding-left')); var paddingRight = parseInt(window.getComputedStyle(this, null) .getPropertyValue('padding-right')); if (elWidth) this.style.width = (parseInt(elWidth) - paddingLeft - paddingRight) + 'px'; } this.anchor_ = el; this.showContentAt_(pos, opt_content, attachment, opt_oldstyle); }, /** * Shows the bubble for given anchor element. * @param {!HTMLElement} el Anchor element of the bubble. * @param {string} text Text content to show in bubble. * @param {!Attachment} attachment Bubble attachment (on which side of the * element it should be displayed). * @param {number=} opt_offset Offset of the bubble attachment point from * left (for vertical attachment) or top (for horizontal attachment) * side of the element. If not specified, the bubble is positioned to * be aligned with the left/top side of the element but not farther than * half of its weight/height. * @param {number=} opt_padding Optional padding of the bubble. */ showTextForElement: function( el, text, attachment, opt_offset, opt_padding) { var span = this.ownerDocument.createElement('span'); span.textContent = text; this.showContentForElement(el, attachment, span, opt_offset, opt_padding); }, /** * Hides the bubble. */ hide: function() { if (!this.classList.contains('faded')) this.classList.add('faded'); }, /** * Hides the bubble anchored to the given element (if any). * @param {!Object} el Anchor element. */ hideForElement: function(el) { if (!this.hidden && this.anchor_ == el) this.hide(); }, /** * Handler for faded transition end. * @private */ handleTransitionEnd_: function(e) { if (this.classList.contains('faded')) { this.hidden = true; if (this.elementToFocusOnHide_) this.elementToFocusOnHide_.focus(); } }, /** * Handler of scroll event. * @private */ handleScroll_: function(e) { if (!this.hidden && !this.persistent_) this.hide(); }, /** * Handler of document click event. * @private */ handleDocClick_: function(e) { // Ignore clicks on anchor element. if (e.target == this.anchor_) return; if (!this.hidden && !this.persistent_) this.hide(); }, /** * Handle of document keydown event. * @private */ handleDocKeyDown_: function(e) { if (this.hidden) return; if (this.hideOnKeyPress_) { this.hide(); return; } // Artificial tab-cycle. if (e.key == Keys.TAB && e.shiftKey == true && e.target == this.firstBubbleElement_) { this.lastBubbleElement_.focus(); e.preventDefault(); } if (e.key == Keys.TAB && e.shiftKey == false && e.target == this.lastBubbleElement_) { this.firstBubbleElement_.focus(); e.preventDefault(); } // Close bubble on ESC or on hitting spacebar or Enter at close-button. if ((e.key == Keys.ESC && !this.persistent_) || ((e.key == Keys.ENTER || e.key == Keys.SPACE) && e.target && e.target.classList.contains('close-button'))) this.hide(); }, /** * Handler of window blur event. * @private */ handleWindowBlur_: function(e) { if (!this.hidden && !this.persistent_) this.hide(); } }; return { Bubble: Bubble }; });