The change converts the following files to typescript: * elements/shared/gr-hovercard/gr-hovercard.ts Change-Id: I089692ab6746159432436679d7753d36377d4a78
450 lines
14 KiB
TypeScript
450 lines
14 KiB
TypeScript
/**
|
|
* @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 {Debouncer} from '@polymer/polymer/lib/utils/debounce';
|
|
import {timeOut} from '@polymer/polymer/lib/utils/async';
|
|
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 {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
|
|
|
|
const HOVER_CLASS = 'hovered';
|
|
const HIDE_CLASS = 'hide';
|
|
|
|
/**
|
|
* How long should we wait before showing the hovercard when the user hovers
|
|
* over the element?
|
|
*/
|
|
const SHOW_DELAY_MS = 500;
|
|
|
|
/**
|
|
* 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 = 300;
|
|
|
|
/**
|
|
* 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)
|
|
*
|
|
* @see gr-hovercard.ts
|
|
*
|
|
* // following annotations are required for polylint
|
|
* @polymer
|
|
* @mixinFunction
|
|
*/
|
|
export const hovercardBehaviorMixin = dedupingMixin(
|
|
<T extends Constructor<PolymerElement & LegacyElementMixin>>(
|
|
superClass: T
|
|
): T & Constructor<GrHovercardBehaviorInterface> => {
|
|
/**
|
|
* @polymer
|
|
* @mixinClass
|
|
*/
|
|
class Mixin extends superClass {
|
|
@property({type: Object})
|
|
_target: Element | 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;
|
|
|
|
/**
|
|
* ID for the container element.
|
|
*/
|
|
@property({type: String})
|
|
containerId = 'gr-hovercard-container';
|
|
|
|
private _hideDebouncer: Debouncer | null = null;
|
|
|
|
private _showDebouncer: Debouncer | null = null;
|
|
|
|
private _isScheduledToShow?: boolean;
|
|
|
|
private _isScheduledToHide?: boolean;
|
|
|
|
/** @override */
|
|
attached() {
|
|
super.attached();
|
|
if (!this._target) {
|
|
this._target = this.target;
|
|
}
|
|
this.listen(this._target, 'mouseenter', 'debounceShow');
|
|
this.listen(this._target, 'focus', 'debounceShow');
|
|
this.listen(this._target, 'mouseleave', 'debounceHide');
|
|
this.listen(this._target, 'blur', 'debounceHide');
|
|
|
|
// when click, dismiss immediately
|
|
this.listen(this._target, 'click', 'hide');
|
|
|
|
// show the hovercard if mouse moves to hovercard
|
|
// this will cancel pending hide as well
|
|
this.listen(this, 'mouseenter', 'show');
|
|
// when leave hovercard, hide it immediately
|
|
this.listen(this, 'mouseleave', 'hide');
|
|
}
|
|
|
|
/** @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', 'debounceShow');
|
|
this.unlisten(this._target, 'focus', 'debounceShow');
|
|
this.unlisten(this._target, 'mouseleave', 'debounceHide');
|
|
this.unlisten(this._target, 'blur', 'debounceHide');
|
|
this.unlisten(this._target, 'click', 'hide');
|
|
}
|
|
|
|
debounceHide() {
|
|
this.cancelShowDebouncer();
|
|
if (!this._isShowing || this._isScheduledToHide) return;
|
|
this._isScheduledToHide = true;
|
|
this._hideDebouncer = Debouncer.debounce(
|
|
this._hideDebouncer,
|
|
timeOut.after(HIDE_DELAY_MS),
|
|
() => {
|
|
// This happens when hide immediately through click or mouse leave
|
|
// on the hovercard
|
|
if (!this._isScheduledToHide) return;
|
|
this.hide();
|
|
}
|
|
);
|
|
}
|
|
|
|
cancelHideDebouncer() {
|
|
if (this._hideDebouncer) {
|
|
this._hideDebouncer.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) {
|
|
if (this._target)
|
|
this._target.dispatchEvent(
|
|
new CustomEvent(eventName, {
|
|
bubbles: true,
|
|
composed: true,
|
|
})
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns the target element that the hovercard is anchored to (the `id` of
|
|
* the `for` property).
|
|
*/
|
|
get target(): Element {
|
|
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 Element;
|
|
}
|
|
|
|
/**
|
|
* 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).
|
|
*
|
|
*/
|
|
hide(e?: MouseEvent) {
|
|
this.cancelHideDebouncer();
|
|
this.cancelShowDebouncer();
|
|
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 && this.container.contains(this)) {
|
|
this.container.removeChild(this);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shows/opens the hovercard with a fixed delay.
|
|
*/
|
|
debounceShow() {
|
|
this.debounceShowBy(SHOW_DELAY_MS);
|
|
}
|
|
|
|
/**
|
|
* Shows/opens the hovercard with the given delay.
|
|
*/
|
|
debounceShowBy(delayMs: number) {
|
|
this.cancelHideDebouncer();
|
|
if (this._isShowing || this._isScheduledToShow) return;
|
|
this._isScheduledToShow = true;
|
|
this._showDebouncer = Debouncer.debounce(
|
|
this._showDebouncer,
|
|
timeOut.after(delayMs),
|
|
() => {
|
|
// This happens when the mouse leaves the target before the delay is over.
|
|
if (!this._isScheduledToShow) return;
|
|
this.show();
|
|
}
|
|
);
|
|
}
|
|
|
|
cancelShowDebouncer() {
|
|
if (this._showDebouncer) {
|
|
this._showDebouncer.cancel();
|
|
this._isScheduledToShow = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shows/opens the hovercard. This occurs when the user triggers the
|
|
* `mousenter` event on the hovercard's `target` element.
|
|
*/
|
|
show() {
|
|
this.cancelHideDebouncer();
|
|
this.cancelShowDebouncer();
|
|
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 {
|
|
attached(): void;
|
|
ready(): void;
|
|
removeListeners(): void;
|
|
debounceHide(): void;
|
|
cancelHideDebouncer(): void;
|
|
dispatchEventThroughTarget(eventName: string): void;
|
|
hide(e?: MouseEvent): void;
|
|
debounceShow(): void;
|
|
debounceShowBy(delayMs: number): void;
|
|
cancelShowDebouncer(): void;
|
|
show(): void;
|
|
updatePosition(): void;
|
|
updatePositionTo(position: string): void;
|
|
}
|