| /** |
| * @license |
| * Copyright (C) 2020 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. |
| */ |
| import '../../../scripts/bundled-polymer.js'; |
| |
| import '../../../styles/shared-styles.js'; |
| import '../../../scripts/rootElement.js'; |
| import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js'; |
| |
| 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; |
| |
| /** |
| * The mixin for gr-hovercard-behavior. |
| * |
| * @example |
| * |
| * // LegacyElementMixin is still needed to support the old lifecycles |
| * // TODO: Replace old life cycles with new ones. |
| * |
| * class YourComponent extends hovercardBehaviorMixin( |
| * LegacyElementMixin(PolymerElement) |
| * ) { |
| * static get is() { return ''; } |
| * static get template() { return html``; } |
| * } |
| * |
| * customElements.define(GrHovercard.is, GrHovercard); |
| * |
| * @see gr-hovercard.js |
| * |
| * // following annotations are required for polylint |
| * @polymer |
| * @mixinFunction |
| */ |
| export const hovercardBehaviorMixin = superClass => class extends superClass { |
| static get properties() { |
| return { |
| /** |
| * @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: 'right', |
| }, |
| |
| container: Object, |
| /** |
| * ID for the container element. |
| * |
| * @type {string} |
| */ |
| containerId: { |
| type: String, |
| value: 'gr-hovercard-container', |
| }, |
| }; |
| } |
| |
| /** @override */ |
| attached() { |
| super.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, 'click', 'hide'); |
| } |
| |
| /** @override */ |
| created() { |
| super.created(); |
| this.addEventListener('mouseleave', |
| e => this.hide(e)); |
| } |
| |
| /** @override */ |
| ready() { |
| super.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, 'click', 'hide'); |
| } |
| |
| /** |
| * Returns the target element that the hovercard is anchored to (the `id` of |
| * the `for` property). |
| * |
| * @type {HTMLElement} |
| */ |
| get target() { |
| const parentNode = dom(this).parentNode; |
| // If the parentNode is a document fragment, then we need to use the host. |
| const ownerRoot = dom(this).getOwnerRoot(); |
| let target; |
| if (this.for) { |
| target = 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.$.container.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; |
| } |
| }; |