| /** |
| * @license |
| * Copyright 2020 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import {Constructor} from '../../utils/common-util'; |
| import {LitElement, PropertyValues} from 'lit'; |
| import {property, query} from 'lit/decorators.js'; |
| import {ShowAlertEventDetail} from '../../types/events'; |
| import {debounce, DelayedTask} from '../../utils/async-util'; |
| import {hovercardStyles} from '../../styles/gr-hovercard-styles'; |
| import {sharedStyles} from '../../styles/shared-styles'; |
| import {DependencyRequestEvent} from '../../models/dependency'; |
| import { |
| addShortcut, |
| findActiveElement, |
| isElementTarget, |
| Key, |
| Modifier, |
| } from '../../utils/dom-util'; |
| import {ShortcutController} from '../../elements/lit/shortcut-controller'; |
| import { |
| getFocusableElements, |
| getFocusableElementsReverse, |
| } from '../../utils/focusable'; |
| import {getAppContext} from '../../services/app-context'; |
| import { |
| ReportingService, |
| Timer, |
| } from '../../services/gr-reporting/gr-reporting'; |
| |
| interface ReloadEventDetail { |
| clearPatchset?: boolean; |
| } |
| |
| const HOVER_CLASS = 'hovered'; |
| const HIDE_CLASS = 'hide'; |
| |
| /** |
| * ID for the container element. |
| */ |
| const containerId = 'gr-hovercard-container'; |
| |
| export interface MouseKeyboardOrFocusEvent { |
| keyboardEvent?: KeyboardEvent; |
| mouseEvent?: MouseEvent; |
| focusEvent?: FocusEvent; |
| } |
| |
| /** |
| * 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 hovercard behavior. |
| * |
| * @example |
| * |
| * class YourComponent extends hovercardBehaviorMixin( |
| * LitElement) |
| * |
| * @see gr-hovercard.ts |
| * |
| * // following annotations are required for polylint |
| * @lit |
| * @mixinFunction |
| */ |
| export const HovercardMixin = <T extends Constructor<LitElement>>( |
| superClass: T |
| ) => { |
| /** |
| * @lit |
| * @mixinClass |
| */ |
| class Mixin extends superClass { |
| @query('#container') |
| topElement?: HTMLElement; |
| |
| @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 but used in tests. |
| hideTask?: DelayedTask; |
| |
| showTask?: DelayedTask; |
| |
| isScheduledToShow?: boolean; |
| |
| isScheduledToHide?: boolean; |
| |
| openedByKeyboard = false; |
| |
| reporting: ReportingService = getAppContext().reportingService; |
| |
| reportingTimer?: Timer; |
| |
| private targetCleanups: Array<() => void> = []; |
| |
| /** Called in disconnectedCallback. */ |
| private cleanups: (() => void)[] = []; |
| |
| static get styles() { |
| return [sharedStyles, hovercardStyles]; |
| } |
| |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| constructor(...args: any[]) { |
| super(...args); |
| // show the hovercard if mouse moves to hovercard |
| // this will cancel pending hide as well |
| this.addEventListener('mouseenter', this.mouseShow); |
| // when leave hovercard, hide it immediately |
| this.addEventListener('mouseleave', this.mouseHide); |
| const keyboardController = new ShortcutController(this); |
| keyboardController.addGlobal({key: Key.ESC}, (e: KeyboardEvent) => |
| this.hide({keyboardEvent: e}) |
| ); |
| } |
| |
| override connectedCallback() { |
| super.connectedCallback(); |
| // We have to cache the target because when we this.container.appendChild |
| // in show we can not pick the container as target when we reconnect. |
| if (!this._target) { |
| this._target = this.target; |
| this.addTargetEventListeners(); |
| } |
| |
| this.container = this.getContainer(); |
| this.cleanups.push( |
| addShortcut( |
| this, |
| {key: Key.TAB}, |
| (e: KeyboardEvent) => { |
| this.pressTab(e); |
| }, |
| { |
| preventDefault: false, |
| } |
| ) |
| ); |
| this.cleanups.push( |
| addShortcut( |
| this, |
| {key: Key.TAB, modifiers: [Modifier.SHIFT_KEY]}, |
| (e: KeyboardEvent) => { |
| this.pressShiftTab(e); |
| }, |
| { |
| preventDefault: false, |
| } |
| ) |
| ); |
| } |
| |
| override disconnectedCallback() { |
| this.cancelShowTask(); |
| this.cancelHideTask(); |
| for (const cleanup of this.cleanups) cleanup(); |
| this.cleanups = []; |
| super.disconnectedCallback(); |
| } |
| |
| private addTargetEventListeners() { |
| // We intentionally listen on 'mousemove' instead of 'mouseenter', because |
| // otherwise the target appearing under the mouse cursor would also |
| // trigger the hovercard, which can annoying for the user, for example |
| // when added reviewer chips appear in the reply dialog via keyboard |
| // interaction. |
| this._target?.addEventListener('mousemove', this.mouseDebounceShow); |
| this._target?.addEventListener('mouseleave', this.mouseDebounceHide); |
| this._target?.addEventListener('blur', this.focusDebounceHide); |
| this._target?.addEventListener('click', this.mouseHide); |
| if (this._target) { |
| this.targetCleanups.push( |
| addShortcut(this._target, {key: Key.ENTER}, (e: KeyboardEvent) => { |
| this.show({keyboardEvent: e}); |
| }) |
| ); |
| this.targetCleanups.push( |
| addShortcut(this._target, {key: Key.SPACE}, (e: KeyboardEvent) => { |
| this.show({keyboardEvent: e}); |
| }) |
| ); |
| } |
| this.addEventListener('request-dependency', this.resolveDep); |
| this.addEventListener('reload', this.reload); |
| } |
| |
| private removeTargetEventListeners() { |
| this._target?.removeEventListener('mousemove', this.mouseDebounceShow); |
| this._target?.removeEventListener('mouseleave', this.mouseDebounceHide); |
| this._target?.removeEventListener('blur', this.focusDebounceHide); |
| this._target?.removeEventListener('click', this.mouseHide); |
| for (const cleanup of this.targetCleanups) { |
| cleanup(); |
| } |
| this.targetCleanups = []; |
| this.removeEventListener('request-dependency', this.resolveDep); |
| this.removeEventListener('reload', this.reload); |
| } |
| |
| /** |
| * Responds to a change in the `for` value and gets the updated `target` |
| * element for the hovercard. |
| */ |
| override updated(changedProperties: PropertyValues) { |
| super.updated(changedProperties); |
| if (changedProperties.has('for')) { |
| this.removeTargetEventListeners(); |
| this._target = this.target; |
| this.addTargetEventListeners(); |
| } |
| } |
| |
| readonly reload = () => { |
| this.dispatchEventThroughTarget('reload'); |
| }; |
| |
| readonly mouseDebounceHide = (e: MouseEvent) => { |
| this.debounceHide({mouseEvent: e}); |
| }; |
| |
| readonly mouseDebounceShow = (e: MouseEvent) => { |
| this.debounceShow({mouseEvent: e}); |
| }; |
| |
| readonly mouseHide = (e: MouseEvent) => { |
| this.hide({mouseEvent: e}); |
| }; |
| |
| readonly mouseShow = (e: MouseEvent) => { |
| this.show({mouseEvent: e}); |
| }; |
| |
| readonly focusDebounceHide = (e: FocusEvent) => { |
| this.debounceHide({focusEvent: e}); |
| }; |
| |
| readonly debounceHide = (props: MouseKeyboardOrFocusEvent) => { |
| 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(props); |
| }, |
| HIDE_DELAY_MS |
| ); |
| }; |
| |
| cancelHideTask() { |
| if (!this.hideTask) return; |
| this.hideTask.cancel(); |
| this.isScheduledToHide = false; |
| this.hideTask = undefined; |
| } |
| |
| /** |
| * 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, |
| }) |
| ); |
| } |
| |
| getHost(): HTMLElement { |
| let el = this._target as Node; |
| while (el) { |
| if ((el as HTMLElement).tagName === 'DIALOG') { |
| return el as HTMLElement; |
| } |
| el = el.parentNode || (el as ShadowRoot).host; |
| } |
| return document.body; |
| } |
| |
| getContainer(): HTMLElement | null { |
| const host = this.getHost(); |
| let container = host.querySelector<HTMLElement>(`#${containerId}`); |
| if (!container) { |
| // If it does not exist, create and initialize the hovercard container. |
| container = document.createElement('div'); |
| container.setAttribute('id', containerId); |
| host.appendChild(container); |
| } |
| return container; |
| } |
| |
| /** |
| * 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; |
| } |
| |
| private readonly documentClickListener = (e: MouseEvent) => { |
| if (!e.target || !isElementTarget(e.target)) return; |
| if (this.contains(e.target)) return; |
| this.forceHide(); |
| }; |
| |
| private containerClickListener = (e: MouseEvent) => { |
| e.stopPropagation(); |
| }; |
| |
| /** |
| * Hovercards aren't children of <gr-app>. Dependencies must be resolved via |
| * their targets, so re-route 'request-dependency' events. |
| */ |
| readonly resolveDep = (e: DependencyRequestEvent<unknown>) => { |
| this._target?.dispatchEvent( |
| new DependencyRequestEvent<unknown>(e.dependency, e.callback) |
| ); |
| }; |
| |
| readonly forceHide = () => { |
| this.hide({keyboardEvent: new KeyboardEvent('enter')}); |
| }; |
| |
| /** |
| * 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). If event is not specified |
| * in props, code assumes mouseEvent |
| */ |
| readonly hide = (props: MouseKeyboardOrFocusEvent) => { |
| this.cancelHideTask(); |
| this.cancelShowTask(); |
| if (!this._isShowing) { |
| return; |
| } |
| if (!props?.keyboardEvent && this.openedByKeyboard) return; |
| // If the user is clicking on a link and still 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 (props?.mouseEvent) { |
| const e = props.mouseEvent; |
| if ( |
| e.relatedTarget === this || |
| (e.target === this && e.relatedTarget === this._target) |
| ) { |
| return; |
| } |
| } |
| if (this.openedByKeyboard) { |
| if (this._target) { |
| this._target.focus(); |
| } |
| } |
| // Make sure to reset the keyboard variable so new shows will not |
| // assume keyboard is the reason for opening the hovercard. |
| this.openedByKeyboard = false; |
| |
| // 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.topElement?.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); |
| } |
| document.removeEventListener('click', this.documentClickListener); |
| this.container?.removeEventListener('click', this.containerClickListener); |
| this.reportingTimer?.end({ |
| targetId: this._target?.id, |
| tagName: this.tagName, |
| }); |
| }; |
| |
| /** |
| * Shows/opens the hovercard with a fixed delay. |
| */ |
| readonly debounceShow = (props: MouseKeyboardOrFocusEvent) => { |
| this.debounceShowBy(SHOW_DELAY_MS, props); |
| }; |
| |
| /** |
| * Shows/opens the hovercard with the given delay. |
| */ |
| debounceShowBy(delayMs: number, props: MouseKeyboardOrFocusEvent) { |
| 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(props); |
| }, |
| delayMs |
| ); |
| } |
| |
| override focus(options?: FocusOptions): void { |
| const a = getFocusableElements(this).next(); |
| if (!a.done) a.value.focus(options); |
| } |
| |
| pressTab(e: KeyboardEvent) { |
| const activeElement = findActiveElement(document); |
| const lastFocusable = getFocusableElementsReverse(this).next(); |
| if (!lastFocusable.done && activeElement === lastFocusable.value) { |
| e.preventDefault(); |
| this.forceHide(); |
| } |
| } |
| |
| pressShiftTab(e: KeyboardEvent) { |
| const activeElement = findActiveElement(document); |
| const firstFocusable = getFocusableElements(this).next(); |
| if (!firstFocusable.done && activeElement === firstFocusable.value) { |
| e.preventDefault(); |
| this.forceHide(); |
| } |
| } |
| |
| cancelShowTask() { |
| if (!this.showTask) return; |
| this.showTask.cancel(); |
| this.isScheduledToShow = false; |
| this.showTask = undefined; |
| } |
| |
| /** |
| * Shows/opens the hovercard. This occurs when the user triggers the |
| * `mousenter` event on the hovercard's `target` element or when a user |
| * presses enter/space on the hovercard's `target` element. If event is not |
| * specified in props, code assumes mouseEvent |
| */ |
| readonly show = async (props: MouseKeyboardOrFocusEvent) => { |
| this.cancelHideTask(); |
| this.cancelShowTask(); |
| // If we are calling show again because of a mouse reason, then keep |
| // the keyboard valuable set. |
| this.openedByKeyboard = this.openedByKeyboard || !!props?.keyboardEvent; |
| if (this._isShowing || !this.container) { |
| return; |
| } |
| |
| // Mark that the hovercard is now visible |
| this._isShowing = true; |
| |
| // 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(). |
| await new Promise<void>(r => { |
| setTimeout(r, 0); |
| }); |
| this.updatePosition(); |
| this.classList.remove(HIDE_CLASS); |
| if (props?.keyboardEvent) { |
| this.focus(); |
| } |
| this.container.addEventListener('click', this.containerClickListener); |
| document.addEventListener('click', this.documentClickListener); |
| this.reportingTimer = this.reporting.getTimer('Show Hovercard'); |
| }; |
| |
| 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; |
| } |
| this.updatePositionTo(this.position); |
| } |
| |
| _isInsideViewport() { |
| const thisRect = this.getBoundingClientRect(); |
| const hostRect = this.getHost().getBoundingClientRect(); |
| if (thisRect.top < hostRect.top) return false; |
| if (thisRect.left < hostRect.left) return false; |
| if (thisRect.bottom > hostRect.bottom) return false; |
| if (thisRect.right > hostRect.right) 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 hostRect = this.getHost().getBoundingClientRect(); |
| const targetRect = this._target.getBoundingClientRect(); |
| const thisRect = this.getBoundingClientRect(); |
| |
| const targetLeft = targetRect.left - hostRect.left; |
| const targetTop = targetRect.top - hostRect.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`; |
| } |
| } |
| |
| return Mixin as T & Constructor<HovercardMixinInterface>; |
| }; |
| |
| export interface HovercardMixinInterface { |
| for?: string; |
| offset: number; |
| _target: HTMLElement | null; |
| _isShowing: boolean; |
| dispatchEventThroughTarget(eventName: string, detail?: unknown): void; |
| show(props: MouseKeyboardOrFocusEvent): Promise<void>; |
| forceHide(): void; |
| |
| // Used for tests |
| mouseHide(e: MouseEvent): void; |
| getHost(): HTMLElement; |
| hide(props: MouseKeyboardOrFocusEvent): void; |
| container: HTMLElement | null; |
| hideTask?: DelayedTask; |
| showTask?: DelayedTask; |
| position: string; |
| debounceShowBy(delayMs: number, props: MouseKeyboardOrFocusEvent): void; |
| updatePosition(): void; |
| isScheduledToShow?: boolean; |
| isScheduledToHide?: boolean; |
| } |