Create new hovercard-mixin in Lit
- Create new hovercard-mixin in Lit based on gr-hovercard-behavior
- Migrate gr-hovercard to use Lit and use this new mixin
Google-Bug-Id: b/202457138
Change-Id: Ic8aa7b3399ef2cb37644d78cd874b974d385e882
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
index 2e00034..4b1dba6 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
@@ -15,7 +15,8 @@
* limitations under the License.
*/
import '../../../styles/gr-font-styles';
-import '../../shared/gr-hovercard/gr-hovercard-shared-style';
+import '../../../styles/gr-hovercard-styles';
+import '../../../styles/shared-styles';
import '../../shared/gr-button/gr-button';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {customElement, property} from '@polymer/decorators';
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_html.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_html.ts
index b7b4d9c..5023895 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_html.ts
@@ -20,7 +20,10 @@
<style include="gr-font-styles">
/* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
</style>
- <style include="gr-hovercard-shared-style">
+ <style include="shared-styles">
+ /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+ </style>
+ <style include="gr-hovercard-styles">
#container {
min-width: 356px;
max-width: 356px;
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
index d26856c..57eac3b 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
@@ -16,6 +16,8 @@
*/
import './gr-checks-styles';
import '../../styles/gr-font-styles';
+import '../../styles/gr-hovercard-styles';
+import '../../styles/shared-styles';
import {HovercardBehaviorMixin} from '../shared/gr-hovercard/gr-hovercard-behavior';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-hovercard-run_html';
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts
index 49a1416..52dbb9c 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts
@@ -23,7 +23,10 @@
<style include="gr-checks-styles">
/* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
</style>
- <style include="gr-hovercard-shared-style">
+ <style include="shared-styles">
+ /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+ </style>
+ <style include="gr-hovercard-styles">
#container {
min-width: 356px;
max-width: 356px;
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index 06272ed..3988095 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -18,6 +18,7 @@
import '@polymer/iron-icon/iron-icon';
import '../../../styles/gr-font-styles';
import '../../../styles/shared-styles';
+import '../../../styles/gr-hovercard-styles';
import '../gr-avatar/gr-avatar';
import '../gr-button/gr-button';
import {HovercardBehaviorMixin} from '../gr-hovercard/gr-hovercard-behavior';
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
index adca888..cba7293 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
@@ -14,14 +14,17 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import '../gr-hovercard/gr-hovercard-shared-style';
+import '../../../styles/gr-hovercard-styles';
import {html} from '@polymer/polymer/lib/utils/html-tag';
export const htmlTemplate = html`
<style include="gr-font-styles">
/* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
</style>
- <style include="gr-hovercard-shared-style">
+ <style include="shared-styles">
+ /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+ </style>
+ <style include="gr-hovercard-styles">
.top,
.attention,
.status,
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.ts
deleted file mode 100644
index aa92654..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-/**
- * @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.
- */
-
-// Mark the file as a module. Otherwise typescript assumes this is a script
-// and $_documentContainer is a global variable.
-// See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
-
-/** The shared styles for all hover cards. */
-const GrHoverCardSharedStyle = document.createElement('dom-module');
-GrHoverCardSharedStyle.innerHTML = `<template>
- <style include="shared-styles">
- :host {
- position: absolute;
- display: none;
- z-index: 200;
- max-width: 600px;
- outline: none;
- }
- :host(.hovered) {
- display: block;
- }
- :host(.hide) {
- visibility: hidden;
- }
- /* You have to use a <div class="container"> in your hovercard in order
- to pick up this consistent styling. */
- #container {
- background: var(--dialog-background-color);
- border: 1px solid var(--border-color);
- border-radius: var(--border-radius);
- box-shadow: var(--elevation-level-5);
- }
- </style>
- </template>`;
-
-GrHoverCardSharedStyle.register('gr-hovercard-shared-style');
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
index acc5e15..ea81aae 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
@@ -15,20 +15,32 @@
* limitations under the License.
*/
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-hovercard_html';
-import {HovercardBehaviorMixin} from './gr-hovercard-behavior';
-import './gr-hovercard-shared-style';
-import {customElement} from '@polymer/decorators';
+import {customElement} from 'lit/decorators';
+import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
+import {css, html, LitElement} from 'lit';
// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = HovercardBehaviorMixin(PolymerElement);
+const base = HovercardMixin(LitElement);
@customElement('gr-hovercard')
export class GrHovercard extends base {
- static get template() {
- return htmlTemplate;
+ static override get styles() {
+ return [
+ super.styles || [],
+ css`
+ #container {
+ padding: var(--spacing-l);
+ }
+ `,
+ ];
+ }
+
+ override render() {
+ return html`
+ <div id="container" role="tooltip" tabindex="-1">
+ <slot></slot>
+ </div>
+ `;
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.ts
deleted file mode 100644
index 830cbd878..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * @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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
- <style include="gr-hovercard-shared-style">
- #container {
- padding: var(--spacing-l);
- }
- </style>
- <div id="container" role="tooltip" tabindex="-1">
- <slot></slot>
- </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js
deleted file mode 100644
index d5e0061..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js
+++ /dev/null
@@ -1,168 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 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 '../../../test/common-test-setup-karma.js';
-import './gr-hovercard.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-hovercard for="foo" id="bar"></gr-hovercard>
-`);
-
-suite('gr-hovercard tests', () => {
- let element;
-
- let button;
- let testResolve;
- let testPromise;
-
- setup(() => {
- testResolve = undefined;
- testPromise = new Promise(r => testResolve = r);
- button = document.createElement('button');
- button.innerHTML = 'Hello';
- button.setAttribute('id', 'foo');
- document.body.appendChild(button);
-
- element = basicFixture.instantiate();
- });
-
- teardown(() => {
- element.hide({});
- button.remove();
- });
-
- test('updatePosition', () => {
- // Test that the correct style properties have at least been set.
- element.position = 'bottom';
- element.updatePosition();
- assert.typeOf(element.style.getPropertyValue('left'), 'string');
- assert.typeOf(element.style.getPropertyValue('top'), 'string');
- assert.typeOf(element.style.getPropertyValue('paddingTop'), 'string');
- assert.typeOf(element.style.getPropertyValue('marginTop'), 'string');
-
- const parentRect = document.documentElement.getBoundingClientRect();
- const targetRect = element._target.getBoundingClientRect();
- const thisRect = element.getBoundingClientRect();
-
- const targetLeft = targetRect.left - parentRect.left;
- const targetTop = targetRect.top - parentRect.top;
-
- const pixelCompare = pixel =>
- Math.round(parseInt(pixel.substring(0, pixel.length - 1)), 10);
-
- assert.equal(
- pixelCompare(element.style.left),
- pixelCompare(
- (targetLeft + (targetRect.width - thisRect.width) / 2) + 'px'));
- assert.equal(
- pixelCompare(element.style.top),
- pixelCompare(
- (targetTop + targetRect.height + element.offset) + 'px'));
- });
-
- test('hide', () => {
- element.hide({});
- const style = getComputedStyle(element);
- assert.isFalse(element._isShowing);
- assert.isFalse(element.classList.contains('hovered'));
- assert.equal(style.display, 'none');
- assert.notEqual(element.container, element.parentNode);
- });
-
- test('show', async () => {
- await element.show({});
- const style = getComputedStyle(element);
- assert.isTrue(element._isShowing);
- assert.isTrue(element.classList.contains('hovered'));
- assert.equal(style.opacity, '1');
- assert.equal(style.visibility, 'visible');
- });
-
- test('debounceShow does not show immediately', async () => {
- element.debounceShowBy(100);
- setTimeout(testResolve, 0);
- await testPromise;
- assert.isFalse(element._isShowing);
- });
-
- test('debounceShow shows after delay', async () => {
- element.debounceShowBy(1);
- setTimeout(testResolve, 10);
- await testPromise;
- assert.isTrue(element._isShowing);
- });
-
- test('card is scheduled to show on enter and hides on leave', async () => {
- const button = document.querySelector('button');
- let enterResolve = undefined;
- const enterPromise = new Promise(r => enterResolve = r);
- button.addEventListener('mouseenter', enterResolve);
- let leaveResolve = undefined;
- const leavePromise = new Promise(r => leaveResolve = r);
- button.addEventListener('mouseleave', leaveResolve);
-
- assert.isFalse(element._isShowing);
- button.dispatchEvent(new CustomEvent('mouseenter'));
-
- await enterPromise;
- await flush();
- assert.isTrue(element.isScheduledToShow);
- element.showTask.flush();
- assert.isTrue(element._isShowing);
- assert.isFalse(element.isScheduledToShow);
-
- button.dispatchEvent(new CustomEvent('mouseleave'));
-
- await leavePromise;
- assert.isTrue(element.isScheduledToHide);
- assert.isTrue(element._isShowing);
- element.hideTask.flush();
- assert.isFalse(element.isScheduledToShow);
- assert.isFalse(element._isShowing);
-
- button.removeEventListener('mouseenter', enterResolve);
- button.removeEventListener('mouseleave', leaveResolve);
- });
-
- test('card should disappear on click', async () => {
- const button = document.querySelector('button');
- let enterResolve = undefined;
- const enterPromise = new Promise(r => enterResolve = r);
- button.addEventListener('mouseenter', enterResolve);
- let clickResolve = undefined;
- const clickPromise = new Promise(r => clickResolve = r);
- button.addEventListener('click', clickResolve);
-
- assert.isFalse(element._isShowing);
-
- button.dispatchEvent(new CustomEvent('mouseenter'));
-
- await enterPromise;
- await flush();
- assert.isTrue(element.isScheduledToShow);
- MockInteractions.tap(button);
-
- await clickPromise;
- assert.isFalse(element.isScheduledToShow);
- assert.isFalse(element._isShowing);
-
- button.removeEventListener('mouseenter', enterResolve);
- button.removeEventListener('click', clickResolve);
- });
-});
-
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
new file mode 100644
index 0000000..793e5d6
--- /dev/null
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
@@ -0,0 +1,488 @@
+/**
+ * @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 {getRootElement} from '../../scripts/rootElement';
+import {Constructor} from '../../utils/common-util';
+import {LitElement, PropertyValues} from 'lit';
+import {property, query} from 'lit/decorators';
+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';
+
+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 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;
+
+ 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.show);
+ // when leave hovercard, hide it immediately
+ this.addEventListener('mouseleave', this.hide);
+ }
+
+ 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 = getHovercardContainer({createIfNotExists: true});
+ }
+
+ override disconnectedCallback() {
+ this.cancelShowTask();
+ this.cancelHideTask();
+ super.disconnectedCallback();
+ }
+
+ private addTargetEventListeners() {
+ this._target?.addEventListener('mouseenter', this.debounceShow);
+ this._target?.addEventListener('focus', this.debounceShow);
+ this._target?.addEventListener('mouseleave', this.debounceHide);
+ this._target?.addEventListener('blur', this.debounceHide);
+ this._target?.addEventListener('click', this.hide);
+ }
+
+ private removeTargetEventListeners() {
+ 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);
+ }
+
+ /**
+ * 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 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) 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,
+ })
+ );
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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.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);
+ }
+ };
+
+ /**
+ * 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) 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.
+ */
+ readonly show = async () => {
+ 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().
+ await new Promise<void>(r => {
+ setTimeout(r, 0);
+ });
+ 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`;
+ }
+ }
+
+ 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(): void;
+
+ // Used for tests
+ hide(e: MouseEvent): void;
+ container: HTMLElement | null;
+ hideTask?: DelayedTask;
+ showTask?: DelayedTask;
+ position: string;
+ debounceShowBy(delayMs: number): void;
+ updatePosition(): void;
+ isScheduledToShow?: boolean;
+ isScheduledToHide?: boolean;
+}
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
new file mode 100644
index 0000000..bd12789
--- /dev/null
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
@@ -0,0 +1,177 @@
+/**
+ * @license
+ * Copyright (C) 2018 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 '../../test/common-test-setup-karma.js';
+import {HovercardMixin} from './hovercard-mixin.js';
+import {html, LitElement} from 'lit';
+import {customElement} from 'lit/decorators';
+import {MockPromise, mockPromise} from '../../test/test-utils.js';
+
+const base = HovercardMixin(LitElement);
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'hovercard-mixin-test': HovercardMixinTest;
+ }
+}
+
+@customElement('hovercard-mixin-test')
+class HovercardMixinTest extends base {
+ constructor() {
+ super();
+ this.for = 'foo';
+ }
+
+ override render() {
+ return html`<div id="container"><slot></slot></div>`;
+ }
+}
+
+const basicFixture = fixtureFromElement('hovercard-mixin-test');
+
+suite('gr-hovercard tests', () => {
+ let element: HovercardMixinTest;
+
+ let button: HTMLElement;
+ let testPromise: MockPromise;
+
+ setup(() => {
+ testPromise = mockPromise();
+ button = document.createElement('button');
+ button.innerHTML = 'Hello';
+ button.setAttribute('id', 'foo');
+ document.body.appendChild(button);
+
+ element = basicFixture.instantiate();
+ });
+
+ teardown(() => {
+ element.hide(new MouseEvent('click'));
+ button?.remove();
+ });
+
+ test('updatePosition', async () => {
+ // Test that the correct style properties have at least been set.
+ element.position = 'bottom';
+ element.updatePosition();
+ await element.updateComplete;
+ assert.typeOf(element.style.getPropertyValue('left'), 'string');
+ assert.typeOf(element.style.getPropertyValue('top'), 'string');
+ assert.typeOf(element.style.getPropertyValue('paddingTop'), 'string');
+ assert.typeOf(element.style.getPropertyValue('marginTop'), 'string');
+
+ const parentRect = document.documentElement.getBoundingClientRect();
+ const targetRect = element!._target!.getBoundingClientRect();
+ const thisRect = element.getBoundingClientRect();
+
+ const targetLeft = targetRect.left - parentRect.left;
+ const targetTop = targetRect.top - parentRect.top;
+
+ const pixelCompare = (pixel: string) =>
+ Math.round(parseInt(pixel.substring(0, pixel.length - 1), 10));
+
+ assert.equal(
+ pixelCompare(element.style.left),
+ pixelCompare(`${targetLeft + (targetRect.width - thisRect.width) / 2}px`)
+ );
+ assert.equal(
+ pixelCompare(element.style.top),
+ pixelCompare(`${targetTop + targetRect.height + element.offset}px`)
+ );
+ });
+
+ test('hide', () => {
+ element.hide(new MouseEvent('click'));
+ const style = getComputedStyle(element);
+ assert.isFalse(element._isShowing);
+ assert.isFalse(element.classList.contains('hovered'));
+ assert.equal(style.display, 'none');
+ assert.notEqual(element.container, element.parentNode);
+ });
+
+ test('show', async () => {
+ await element.show();
+ await element.updateComplete;
+ const style = getComputedStyle(element);
+ assert.isTrue(element._isShowing);
+ assert.isTrue(element.classList.contains('hovered'));
+ assert.equal(style.opacity, '1');
+ assert.equal(style.visibility, 'visible');
+ });
+
+ test('debounceShow does not show immediately', async () => {
+ element.debounceShowBy(100);
+ setTimeout(() => testPromise.resolve(), 0);
+ await testPromise;
+ assert.isFalse(element._isShowing);
+ });
+
+ test('debounceShow shows after delay', async () => {
+ element.debounceShowBy(1);
+ setTimeout(() => testPromise.resolve(), 10);
+ await testPromise;
+ assert.isTrue(element._isShowing);
+ });
+
+ test('card is scheduled to show on enter and hides on leave', async () => {
+ const button = document.querySelector('button');
+ const enterPromise = mockPromise();
+ button!.addEventListener('mouseenter', () => enterPromise.resolve());
+ const leavePromise = mockPromise();
+ button!.addEventListener('mouseleave', () => leavePromise.resolve());
+
+ assert.isFalse(element._isShowing);
+ button!.dispatchEvent(new CustomEvent('mouseenter'));
+
+ await enterPromise;
+ await flush();
+ assert.isTrue(element.isScheduledToShow);
+ element!.showTask!.flush();
+ assert.isTrue(element._isShowing);
+ assert.isFalse(element.isScheduledToShow);
+
+ button!.dispatchEvent(new CustomEvent('mouseleave'));
+
+ await leavePromise;
+ assert.isTrue(element.isScheduledToHide);
+ assert.isTrue(element._isShowing);
+ element!.hideTask!.flush();
+ assert.isFalse(element.isScheduledToShow);
+ assert.isFalse(element._isShowing);
+ });
+
+ test('card should disappear on click', async () => {
+ const button = document.querySelector('button');
+ const enterPromise = mockPromise();
+ const clickPromise = mockPromise();
+ button!.addEventListener('mouseenter', () => enterPromise.resolve());
+ button!.addEventListener('click', () => clickPromise.resolve());
+
+ assert.isFalse(element._isShowing);
+
+ button!.dispatchEvent(new CustomEvent('mouseenter'));
+
+ await enterPromise;
+ await flush();
+ assert.isTrue(element.isScheduledToShow);
+ button!.click();
+
+ await clickPromise;
+ assert.isFalse(element.isScheduledToShow);
+ assert.isFalse(element._isShowing);
+ });
+});
diff --git a/polygerrit-ui/app/styles/gr-hovercard-styles.ts b/polygerrit-ui/app/styles/gr-hovercard-styles.ts
new file mode 100644
index 0000000..f214a9c
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-hovercard-styles.ts
@@ -0,0 +1,51 @@
+/**
+ * @license
+ * Copyright (C) 2017 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 {css} from 'lit';
+
+export const hovercardStyles = css`
+ :host {
+ position: absolute;
+ display: none;
+ z-index: 200;
+ max-width: 600px;
+ outline: none;
+ }
+ :host(.hovered) {
+ display: block;
+ }
+ :host(.hide) {
+ visibility: hidden;
+ }
+ /* You have to use a <div class="container"> in your hovercard in order
+ to pick up this consistent styling. */
+ #container {
+ background: var(--dialog-background-color);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius);
+ box-shadow: var(--elevation-level-5);
+ }
+`;
+
+const $_documentContainer = document.createElement('template');
+$_documentContainer.innerHTML = `<dom-module id="gr-hovercard-styles">
+ <template>
+ <style>
+ ${hovercardStyles.cssText}
+ </style>
+ </template>
+</dom-module>`;
+document.head.appendChild($_documentContainer.content);