/** * @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; }, }); })();