| /** |
| * @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 '../../../styles/shared-styles'; |
| import {flush} from '@polymer/polymer/lib/legacy/polymer.dom'; |
| import {getRootElement} from '../../../scripts/rootElement'; |
| import {Constructor} from '../../../utils/common-util'; |
| import {PolymerElement} from '@polymer/polymer/polymer-element'; |
| import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin'; |
| import {property, observe} from '@polymer/decorators'; |
| import { |
| pushScrollLock, |
| removeScrollLock, |
| } from '@polymer/iron-overlay-behavior/iron-scroll-manager'; |
| import {ShowAlertEventDetail} from '../../../types/events'; |
| import {debounce, DelayedTask} from '../../../utils/async-util'; |
| interface ReloadEventDetail { |
| clearPatchset?: boolean; |
| } |
| |
| const HOVER_CLASS = 'hovered'; |
| const HIDE_CLASS = 'hide'; |
| |
| /** |
| * ID for the container element. |
| */ |
| const containerId = 'gr-hovercard-container'; |
| |
| export function getHovercardContainer( |
| options: {createIfNotExists: boolean} = {createIfNotExists: false} |
| ): HTMLElement | null { |
| let container = getRootElement().querySelector<HTMLElement>( |
| `#${containerId}` |
| ); |
| if (!container && options.createIfNotExists) { |
| // If it does not exist, create and initialize the hovercard container. |
| container = document.createElement('div'); |
| container.setAttribute('id', containerId); |
| getRootElement().appendChild(container); |
| } |
| return container; |
| } |
| |
| /** |
| * How long should we wait before showing the hovercard when the user hovers |
| * over the element? |
| */ |
| const SHOW_DELAY_MS = 550; |
| |
| /** |
| * How long should we wait before hiding the hovercard when the user moves from |
| * target to the hovercard. |
| * |
| * Note: this should be lower than SHOW_DELAY_MS to avoid flickering. |
| */ |
| const HIDE_DELAY_MS = 500; |
| |
| /** |
| * The mixin for gr-hovercard-behavior. |
| * |
| * @example |
| * |
| * class YourComponent extends hovercardBehaviorMixin( |
| * PolymerElement |
| * |
| * @see gr-hovercard.ts |
| * |
| * // following annotations are required for polylint |
| * @polymer |
| * @mixinFunction |
| */ |
| export const hovercardBehaviorMixin = dedupingMixin( |
| <T extends Constructor<PolymerElement>>( |
| superClass: T |
| ): T & Constructor<GrHovercardBehaviorInterface> => { |
| /** |
| * @polymer |
| * @mixinClass |
| */ |
| class Mixin extends superClass { |
| @property({type: Object}) |
| _target: HTMLElement | null = null; |
| |
| // Determines whether or not the hovercard is visible. |
| @property({type: Boolean}) |
| _isShowing = false; |
| |
| // The `id` of the element that the hovercard is anchored to. |
| @property({type: String}) |
| for?: string; |
| |
| /** |
| * The spacing between the top of the hovercard and the element it is |
| * anchored to. |
| */ |
| @property({type: Number}) |
| offset = 14; |
| |
| /** |
| * Positions the hovercard to the top, right, bottom, left, bottom-left, |
| * bottom-right, top-left, or top-right of its content. |
| */ |
| @property({type: String}) |
| position = 'right'; |
| |
| @property({type: Object}) |
| container: HTMLElement | null = null; |
| |
| private hideTask?: DelayedTask; |
| |
| private showTask?: DelayedTask; |
| |
| private isScheduledToShow?: boolean; |
| |
| private isScheduledToHide?: boolean; |
| |
| /** @override */ |
| connectedCallback() { |
| super.connectedCallback(); |
| if (!this._target) { |
| this._target = this.target; |
| } |
| this._target.addEventListener('mouseenter', this.debounceShow); |
| this._target.addEventListener('focus', this.debounceShow); |
| this._target.addEventListener('mouseleave', this.debounceHide); |
| this._target.addEventListener('blur', this.debounceHide); |
| |
| // when click, dismiss immediately |
| this._target.addEventListener('click', this.hide); |
| |
| // show the hovercard if mouse moves to hovercard |
| // this will cancel pending hide as well |
| this.addEventListener('mouseenter', this.show); |
| this.addEventListener('mouseenter', this.lock); |
| // when leave hovercard, hide it immediately |
| this.addEventListener('mouseleave', this.hide); |
| this.addEventListener('mouseleave', this.unlock); |
| } |
| |
| disconnectedCallback() { |
| this.cancelShowTask(); |
| this.cancelHideTask(); |
| this.unlock(); |
| super.disconnectedCallback(); |
| } |
| |
| /** @override */ |
| ready() { |
| super.ready(); |
| // First, check to see if the container has already been created. |
| this.container = getHovercardContainer({createIfNotExists: true}); |
| } |
| |
| removeListeners() { |
| this._target?.removeEventListener('mouseenter', this.debounceShow); |
| this._target?.removeEventListener('focus', this.debounceShow); |
| this._target?.removeEventListener('mouseleave', this.debounceHide); |
| this._target?.removeEventListener('blur', this.debounceHide); |
| this._target?.removeEventListener('click', this.hide); |
| } |
| |
| readonly debounceHide = () => { |
| this.cancelShowTask(); |
| if (!this._isShowing || this.isScheduledToHide) return; |
| this.isScheduledToHide = true; |
| this.hideTask = debounce( |
| this.hideTask, |
| () => { |
| // This happens when hide immediately through click or mouse leave |
| // on the hovercard |
| if (!this.isScheduledToHide) return; |
| this.hide(); |
| }, |
| HIDE_DELAY_MS |
| ); |
| }; |
| |
| cancelHideTask() { |
| if (this.hideTask) { |
| this.hideTask.cancel(); |
| this.isScheduledToHide = false; |
| } |
| } |
| |
| /** |
| * Hovercard elements are created outside of <gr-app>, so if you want to fire |
| * events, then you probably want to do that through the target element. |
| */ |
| |
| dispatchEventThroughTarget(eventName: string): void; |
| |
| dispatchEventThroughTarget( |
| eventName: 'show-alert', |
| detail: ShowAlertEventDetail |
| ): void; |
| |
| dispatchEventThroughTarget( |
| eventName: 'reload', |
| detail: ReloadEventDetail |
| ): void; |
| |
| dispatchEventThroughTarget(eventName: string, detail?: unknown) { |
| if (!detail) detail = {}; |
| if (this._target) |
| this._target.dispatchEvent( |
| new CustomEvent(eventName, { |
| detail, |
| bubbles: true, |
| composed: true, |
| }) |
| ); |
| } |
| |
| /** |
| * Returns the target element that the hovercard is anchored to (the `id` of |
| * the `for` property). |
| */ |
| get target(): HTMLElement { |
| const parentNode = this.parentNode; |
| // If the parentNode is a document fragment, then we need to use the host. |
| const ownerRoot = this.getRootNode() as ShadowRoot; |
| let target; |
| if (this.for) { |
| target = ownerRoot.querySelector('#' + this.for); |
| } else { |
| target = |
| !parentNode || parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE |
| ? ownerRoot.host |
| : parentNode; |
| } |
| return target as HTMLElement; |
| } |
| |
| /** |
| * unlock scroll, this will resume the scroll outside of the hovercard. |
| */ |
| readonly unlock = () => { |
| removeScrollLock(this); |
| }; |
| |
| /** |
| * 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). |
| * |
| */ |
| readonly hide = (e?: MouseEvent) => { |
| this.cancelHideTask(); |
| this.cancelShowTask(); |
| 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) { |
| if ( |
| e.relatedTarget === this || |
| (e.target === this && e.relatedTarget === 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. |
| */ |
| readonly debounceShow = () => { |
| this.debounceShowBy(SHOW_DELAY_MS); |
| }; |
| |
| /** |
| * Shows/opens the hovercard with the given delay. |
| */ |
| debounceShowBy(delayMs: number) { |
| this.cancelHideTask(); |
| if (this._isShowing || this.isScheduledToShow) return; |
| this.isScheduledToShow = true; |
| this.showTask = debounce( |
| this.showTask, |
| () => { |
| // This happens when the mouse leaves the target before the delay is over. |
| if (!this.isScheduledToShow) return; |
| this.show(); |
| }, |
| delayMs |
| ); |
| } |
| |
| cancelShowTask() { |
| if (this.showTask) { |
| this.showTask.cancel(); |
| this.isScheduledToShow = false; |
| } |
| } |
| |
| /** |
| * Lock background scroll but enable scroll inside of current hovercard. |
| */ |
| readonly lock = () => { |
| pushScrollLock(this); |
| }; |
| |
| /** |
| * Shows/opens the hovercard. This occurs when the user triggers the |
| * `mousenter` event on the hovercard's `target` element. |
| */ |
| readonly show = () => { |
| this.cancelHideTask(); |
| this.cancelShowTask(); |
| if (this._isShowing || !this.container) { |
| 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: string) { |
| 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; |
| |
| switch (position) { |
| case 'top': |
| hovercardLeft = |
| targetLeft + (targetRect.width - thisRect.width) / 2; |
| hovercardTop = targetTop - thisRect.height - this.offset; |
| break; |
| case 'bottom': |
| hovercardLeft = |
| targetLeft + (targetRect.width - thisRect.width) / 2; |
| hovercardTop = targetTop + targetRect.height + this.offset; |
| break; |
| case 'left': |
| hovercardLeft = targetLeft - thisRect.width - this.offset; |
| hovercardTop = |
| targetTop + (targetRect.height - thisRect.height) / 2; |
| break; |
| case 'right': |
| hovercardLeft = targetLeft + targetRect.width + this.offset; |
| hovercardTop = |
| targetTop + (targetRect.height - thisRect.height) / 2; |
| break; |
| case 'bottom-right': |
| hovercardLeft = targetLeft + targetRect.width + this.offset; |
| hovercardTop = targetTop; |
| break; |
| case 'bottom-left': |
| hovercardLeft = targetLeft - thisRect.width - this.offset; |
| hovercardTop = targetTop; |
| break; |
| case 'top-left': |
| hovercardLeft = targetLeft - thisRect.width - this.offset; |
| hovercardTop = targetTop + targetRect.height - thisRect.height; |
| break; |
| case 'top-right': |
| hovercardLeft = targetLeft + targetRect.width + this.offset; |
| hovercardTop = targetTop + targetRect.height - thisRect.height; |
| break; |
| } |
| |
| this.style.left = `${hovercardLeft}px`; |
| this.style.top = `${hovercardTop}px`; |
| } |
| |
| /** |
| * Responds to a change in the `for` value and gets the updated `target` |
| * element for the hovercard. |
| */ |
| @observe('for') |
| _forChanged() { |
| this._target = this.target; |
| } |
| } |
| |
| return Mixin; |
| } |
| ); |
| |
| export interface GrHovercardBehaviorInterface { |
| ready(): void; |
| removeListeners(): void; |
| debounceHide(): void; |
| cancelHideTask(): void; |
| dispatchEventThroughTarget(eventName: string, detail?: unknown): void; |
| hide(e?: MouseEvent): void; |
| debounceShow(): void; |
| debounceShowBy(delayMs: number): void; |
| cancelShowTask(): void; |
| show(): void; |
| updatePosition(): void; |
| updatePositionTo(position: string): void; |
| } |