blob: 4f79e4c22df291493ef90bf16c025c0849c7f1ba [file] [log] [blame]
/**
* @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;
}