| /** |
| * @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 {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js'; |
| import {getRootElement} from '../../../scripts/rootElement.js'; |
| |
| const HOVER_CLASS = 'hovered'; |
| const HIDE_CLASS = 'hide'; |
| |
| /** |
| * 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; |
| |
| /** |
| * How long should be wait before showing the hovercard when the user hovers |
| * over the element? |
| */ |
| const SHOW_DELAY_MS = 500; |
| |
| /** |
| * 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', 'showDelayed'); |
| this.listen(this._target, 'focus', 'showDelayed'); |
| 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 = 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); |
| 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) { |
| this._isScheduledToShow = false; |
| if (!this._isShowing) { |
| return; |
| } |
| |
| // If 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 (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 with a fixed delay. |
| */ |
| showDelayed() { |
| this.showDelayedBy(SHOW_DELAY_MS); |
| } |
| |
| /** |
| * Shows/opens the hovercard with the given delay. |
| */ |
| showDelayedBy(delayMs) { |
| if (this._isShowing || this._isScheduledToShow) return; |
| this._isScheduledToShow = true; |
| setTimeout(() => { |
| // This happens when the mouse leaves the target before the delay is over. |
| if (!this._isScheduledToShow) return; |
| this._isScheduledToShow = false; |
| this.show(); |
| }, delayMs); |
| } |
| |
| /** |
| * Shows/opens the hovercard. This occurs when the user triggers the |
| * `mousenter` event on the hovercard's `target` element. |
| */ |
| show() { |
| 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); |
| // We temporarily hide the hovercard until we have found the correct |
| // position for it. |
| this.classList.add(HIDE_CLASS); |
| this.classList.add(HOVER_CLASS); |
| // Make sure that the hovercard actually rendered and all dom-if |
| // statements processed, so that we can measure the (invisible) |
| // hovercard properly in updatePosition(). |
| flush(); |
| this.updatePosition(); |
| this.classList.remove(HIDE_CLASS); |
| } |
| |
| updatePosition() { |
| const positionsToTry = new Set( |
| [this.position, 'right', 'bottom-right', 'top-right', |
| 'bottom', 'top', 'bottom-left', 'top-left', 'left']); |
| for (const position of positionsToTry) { |
| this.updatePositionTo(position); |
| if (this._isInsideViewport()) return; |
| } |
| console.warn('Could not find a visible position for the hovercard.'); |
| } |
| |
| _isInsideViewport() { |
| const thisRect = this.getBoundingClientRect(); |
| if (thisRect.top < 0) return false; |
| if (thisRect.left < 0) return false; |
| const docuRect = document.documentElement.getBoundingClientRect(); |
| if (thisRect.bottom > docuRect.height) return false; |
| if (thisRect.right > docuRect.width) return false; |
| return true; |
| } |
| |
| /** |
| * Updates the hovercard's position based 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). |
| */ |
| updatePositionTo(position) { |
| if (!this._target) { return; } |
| |
| // Make sure that thisRect will not get any paddings and such included |
| // in the width and height of the bounding client rect. |
| this.style.cssText = ''; |
| |
| const docuRect = document.documentElement.getBoundingClientRect(); |
| const targetRect = this._target.getBoundingClientRect(); |
| const thisRect = this.getBoundingClientRect(); |
| |
| const targetLeft = targetRect.left - docuRect.left; |
| const targetTop = targetRect.top - docuRect.top; |
| |
| let hovercardLeft; |
| let hovercardTop; |
| const diagonalPadding = this.offset + DIAGONAL_OVERFLOW; |
| let cssText = ''; |
| |
| switch (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 = targetLeft + targetRect.width + 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 = targetLeft + targetRect.width + this.offset; |
| hovercardTop = targetTop + 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 = targetLeft - thisRect.width - this.offset; |
| hovercardTop = targetTop + 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 = targetLeft - thisRect.width - this.offset; |
| hovercardTop = targetTop - 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 = targetLeft + targetRect.width + this.offset; |
| hovercardTop = targetTop - 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; |
| } |
| |
| 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; |
| } |
| }; |