diff options
Diffstat (limited to 'polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js')
-rw-r--r-- | polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js | 321 |
1 files changed, 321 insertions, 0 deletions
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js new file mode 100644 index 0000000000..f9c1da161c --- /dev/null +++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js @@ -0,0 +1,321 @@ +/** + * @license + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +(function() { + 'use strict'; + const HOVER_CLASS = 'hovered'; + + /** + * When the hovercard is positioned diagonally (bottom-left, bottom-right, + * top-left, or top-right), we add additional (invisible) padding so that the + * area that a user can hover over to access the hovercard is larger. + */ + const DIAGONAL_OVERFLOW = 15; + + Polymer({ + is: 'gr-hovercard', + + properties: { + /** + * @type {?} + */ + _target: Object, + + /** + * Determines whether or not the hovercard is visible. + * + * @type {boolean} + */ + _isShowing: { + type: Boolean, + value: false, + }, + /** + * The `id` of the element that the hovercard is anchored to. + * + * @type {string} + */ + for: { + type: String, + observer: '_forChanged', + }, + + /** + * The spacing between the top of the hovercard and the element it is + * anchored to. + * + * @type {number} + */ + offset: { + type: Number, + value: 14, + }, + + /** + * Positions the hovercard to the top, right, bottom, left, bottom-left, + * bottom-right, top-left, or top-right of its content. + * + * @type {string} + */ + position: { + type: String, + value: 'bottom', + }, + + container: Object, + /** + * ID for the container element. + * + * @type {string} + */ + containerId: { + type: String, + value: 'gr-hovercard-container', + }, + }, + + listeners: { + mouseleave: 'hide', + }, + + attached() { + if (!this._target) { this._target = this.target; } + this.listen(this._target, 'mouseenter', 'show'); + this.listen(this._target, 'focus', 'show'); + this.listen(this._target, 'mouseleave', 'hide'); + this.listen(this._target, 'blur', 'hide'); + this.listen(this._target, 'tap', 'hide'); + }, + + ready() { + // First, check to see if the container has already been created. + this.container = Gerrit.getRootElement() + .querySelector('#' + this.containerId); + + if (this.container) { return; } + + // If it does not exist, create and initialize the hovercard container. + this.container = document.createElement('div'); + this.container.setAttribute('id', this.containerId); + Gerrit.getRootElement().appendChild(this.container); + }, + + removeListeners() { + this.unlisten(this._target, 'mouseenter', 'show'); + this.unlisten(this._target, 'focus', 'show'); + this.unlisten(this._target, 'mouseleave', 'hide'); + this.unlisten(this._target, 'blur', 'hide'); + this.unlisten(this._target, 'tap', 'hide'); + }, + + /** + * Returns the target element that the hovercard is anchored to (the `id` of + * the `for` property). + * + * @type {HTMLElement} + */ + get target() { + const parentNode = Polymer.dom(this).parentNode; + // If the parentNode is a document fragment, then we need to use the host. + const ownerRoot = Polymer.dom(this).getOwnerRoot(); + let target; + if (this.for) { + target = Polymer.dom(ownerRoot).querySelector('#' + this.for); + } else { + target = parentNode.nodeType == Node.DOCUMENT_FRAGMENT_NODE ? + ownerRoot.host : + parentNode; + } + return target; + }, + + /** + * Hides/closes the hovercard. This occurs when the user triggers the + * `mouseleave` event on the hovercard's `target` element (as long as the + * user is not hovering over the hovercard). + * + * @param {Event} e DOM Event (e.g. `mouseleave` event) + */ + hide(e) { + const targetRect = this._target.getBoundingClientRect(); + const x = e.clientX; + const y = e.clientY; + if (x > targetRect.left && x < targetRect.right && y > targetRect.top && + y < targetRect.bottom) { + // Sometimes the hovercard itself obscures the mouse pointer, and + // that generates a mouseleave event. We don't want to hide the hovercard + // in that situation. + return; + } + + // If the hovercard is already hidden or the user is now hovering over the + // hovercard or the user is returning from the hovercard but now hovering + // over the target (to stop an annoying flicker effect), just return. + if (!this._isShowing || e.toElement === this || + (e.fromElement === this && e.toElement === this._target)) { + return; + } + + // Mark that the hovercard is not visible and do not allow focusing + this._isShowing = false; + + // Clear styles in preparation for the next time we need to show the card + this.classList.remove(HOVER_CLASS); + + // Reset and remove the hovercard from the DOM + this.style.cssText = ''; + this.$.hovercard.setAttribute('tabindex', -1); + + // Remove the hovercard from the container, given that it is still a child + // of the container. + if (this.container.contains(this)) { + this.container.removeChild(this); + } + }, + + /** + * Shows/opens the hovercard. This occurs when the user triggers the + * `mousenter` event on the hovercard's `target` element. + * + * @param {Event} e DOM Event (e.g., `mouseenter` event) + */ + show(e) { + if (this._isShowing) { + return; + } + + // Mark that the hovercard is now visible + this._isShowing = true; + this.setAttribute('tabindex', 0); + + // Add it to the DOM and calculate its position + this.container.appendChild(this); + this.updatePosition(); + + // Trigger the transition + this.classList.add(HOVER_CLASS); + }, + + /** + * Updates the hovercard's position based on the `position` attribute + * and the current position of the `target` element. + * + * The hovercard is supposed to stay open if the user hovers over it. + * To keep it open when the user moves away from the target, the bounding + * rects of the target and hovercard must touch or overlap. + * + * NOTE: You do not need to directly call this method unless you need to + * update the position of the tooltip while it is already visible (the + * target element has moved and the tooltip is still open). + */ + updatePosition() { + if (!this._target) { return; } + + // Calculate the necessary measurements and positions + const parentRect = document.documentElement.getBoundingClientRect(); + const targetRect = this._target.getBoundingClientRect(); + const thisRect = this.getBoundingClientRect(); + + const targetLeft = targetRect.left - parentRect.left; + const targetTop = targetRect.top - parentRect.top; + + let hovercardLeft; + let hovercardTop; + const diagonalPadding = this.offset + DIAGONAL_OVERFLOW; + let cssText = ''; + + // Find the top and left position values based on the position attribute + // of the hovercard. + switch (this.position) { + case 'top': + hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2; + hovercardTop = targetTop - thisRect.height - this.offset; + cssText += `padding-bottom:${this.offset + }px; margin-bottom:-${this.offset}px;`; + break; + case 'bottom': + hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2; + hovercardTop = targetTop + targetRect.height + this.offset; + cssText += + `padding-top:${this.offset}px; margin-top:-${this.offset}px;`; + break; + case 'left': + hovercardLeft = targetLeft - thisRect.width - this.offset; + hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2; + cssText += + `padding-right:${this.offset}px; margin-right:-${this.offset}px;`; + break; + case 'right': + hovercardLeft = targetRect.right + this.offset; + hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2; + cssText += + `padding-left:${this.offset}px; margin-left:-${this.offset}px;`; + break; + case 'bottom-right': + hovercardLeft = targetRect.left + targetRect.width + this.offset; + hovercardTop = targetRect.top + targetRect.height + this.offset; + cssText += `padding-top:${diagonalPadding}px;`; + cssText += `padding-left:${diagonalPadding}px;`; + cssText += `margin-left:-${diagonalPadding}px;`; + cssText += `margin-top:-${diagonalPadding}px;`; + break; + case 'bottom-left': + hovercardLeft = targetRect.left - thisRect.width - this.offset; + hovercardTop = targetRect.top + targetRect.height + this.offset; + cssText += `padding-top:${diagonalPadding}px;`; + cssText += `padding-right:${diagonalPadding}px;`; + cssText += `margin-right:-${diagonalPadding}px;`; + cssText += `margin-top:-${diagonalPadding}px;`; + break; + case 'top-left': + hovercardLeft = targetRect.left - thisRect.width - this.offset; + hovercardTop = targetRect.top - thisRect.height - this.offset; + cssText += `padding-bottom:${diagonalPadding}px;`; + cssText += `padding-right:${diagonalPadding}px;`; + cssText += `margin-bottom:-${diagonalPadding}px;`; + cssText += `margin-right:-${diagonalPadding}px;`; + break; + case 'top-right': + hovercardLeft = targetRect.left + targetRect.width + this.offset; + hovercardTop = targetRect.top - thisRect.height - this.offset; + cssText += `padding-bottom:${diagonalPadding}px;`; + cssText += `padding-left:${diagonalPadding}px;`; + cssText += `margin-bottom:-${diagonalPadding}px;`; + cssText += `margin-left:-${diagonalPadding}px;`; + break; + } + + // Prevent hovercard from appearing outside the viewport. + // TODO(kaspern): fix hovercard appearing outside viewport on bottom and + // right. + if (hovercardLeft < 0) { hovercardLeft = 0; } + if (hovercardTop < 0) { hovercardTop = 0; } + // Set the hovercard's position + cssText += `left:${hovercardLeft}px; top:${hovercardTop}px;`; + this.style.cssText = cssText; + }, + + /** + * Responds to a change in the `for` value and gets the updated `target` + * element for the hovercard. + * + * @private + */ + _forChanged() { + this._target = this.target; + }, + }); +})(); |