blob: 343de2a5cb8001a4ec52798ab8329804a2588e21 [file] [log] [blame]
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../gr-icon/gr-icon';
import '../gr-tooltip/gr-tooltip';
import {GrTooltip} from '../gr-tooltip/gr-tooltip';
import {css, html, LitElement, PropertyValues} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
const ARROW_HEIGHT = 7.2; // Height of the arrow in tooltip.
declare global {
interface HTMLElementTagNameMap {
'gr-tooltip-content': GrTooltipContent;
}
}
@customElement('gr-tooltip-content')
export class GrTooltipContent extends LitElement {
@property({type: Boolean, attribute: 'has-tooltip', reflect: true})
hasTooltip = false;
// A light tooltip will disappear immediately when the original hovered
// over content is no longer hovered over.
@property({type: Boolean, attribute: 'light-tooltip', reflect: true})
lightTooltip = false;
@property({type: Boolean, attribute: 'position-below', reflect: true})
positionBelow = false;
@property({type: String, attribute: 'max-width', reflect: true})
maxWidth?: string;
@property({type: Boolean, attribute: 'show-icon'})
showIcon = false;
// Should be private but used in tests.
@state()
isTouchDevice = 'ontouchstart' in document.documentElement;
// Should be private but used in tests.
tooltip: GrTooltip | null = null;
@state()
private originalTitle = '';
private hasSetupTooltipListeners = false;
private readonly windowScrollHandler: () => void;
private readonly showHandler: () => void;
private readonly hideHandler: (e: Event) => void;
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
constructor() {
super();
this.windowScrollHandler = () => this._handleWindowScroll();
this.showHandler = () => this._handleShowTooltip();
this.hideHandler = (e: Event | undefined) => this._handleHideTooltip(e);
}
override disconnectedCallback() {
this._handleHideTooltip(undefined);
this.removeEventListener('mouseenter', this.showHandler);
window.removeEventListener('scroll', this.windowScrollHandler);
super.disconnectedCallback();
}
static override get styles() {
return [
css`
gr-icon {
font-size: var(--line-height-normal);
}
`,
];
}
override render() {
return html`
<slot></slot>
${this.renderIcon()}
`;
}
renderIcon() {
if (!this.showIcon) return;
return html`<gr-icon icon="info" filled></gr-icon>`;
}
override updated(changedProperties: PropertyValues) {
if (changedProperties.has('hasTooltip')) {
this.setupTooltipListeners();
}
}
private setupTooltipListeners() {
if (!this.hasTooltip) {
if (this.hasSetupTooltipListeners) {
// if attribute set to false, remove the listener
this.removeEventListener('mouseenter', this.showHandler);
this.hasSetupTooltipListeners = false;
}
return;
}
if (this.hasSetupTooltipListeners) {
return;
}
this.hasSetupTooltipListeners = true;
this.addEventListener('mouseenter', this.showHandler);
}
async _handleShowTooltip() {
if (this.isTouchDevice) {
return;
}
if (
!this.hasAttribute('title') ||
this.getAttribute('title') === '' ||
this.tooltip
) {
return;
}
// Store the title attribute text then set it to an empty string to
// prevent it from showing natively.
this.originalTitle = this.getAttribute('title') || '';
this.setAttribute('title', '');
const tooltip = document.createElement('gr-tooltip');
tooltip.text = this.originalTitle;
tooltip.maxWidth = this.getAttribute('max-width') || '';
tooltip.positionBelow = this.hasAttribute('position-below');
this.tooltip = tooltip;
// Set visibility to hidden before appending to the DOM so that
// calculations can be made based on the element’s size.
tooltip.style.visibility = 'hidden';
const parent = this.getTooltipParent(this);
parent.appendChild(tooltip);
await tooltip.updateComplete;
this._positionTooltip(tooltip);
tooltip.style.visibility = 'initial';
window.addEventListener('scroll', this.windowScrollHandler);
this.addEventListener('mouseleave', this.hideHandler);
this.addEventListener('click', this.hideHandler);
if (!this.lightTooltip) {
tooltip.addEventListener('mouseleave', this.hideHandler);
}
}
getTooltipParent(el: Node): Node {
if (el === document.body) {
return el;
}
if (el instanceof HTMLDialogElement) {
return el;
}
if (el instanceof ShadowRoot) {
return this.getTooltipParent(el.host);
}
if (el.parentNode) {
return this.getTooltipParent(el.parentNode);
}
return document.body;
}
_handleHideTooltip(e?: Event) {
if (this.isTouchDevice) {
return;
}
if (!this.hasAttribute('title') || !this.originalTitle) {
return;
}
// Do not hide if mouse left this or this.tooltip and came to this or
// this.tooltip
if (
(!this.lightTooltip &&
(e as MouseEvent)?.relatedTarget === this.tooltip) ||
(e as MouseEvent)?.relatedTarget === this
) {
return;
}
window.removeEventListener('scroll', this.windowScrollHandler);
this.removeEventListener('mouseleave', this.hideHandler);
this.removeEventListener('click', this.hideHandler);
this.setAttribute('title', this.originalTitle);
if (!this.lightTooltip) {
this.tooltip?.removeEventListener('mouseleave', this.hideHandler);
}
if (this.tooltip?.parentNode) {
this.tooltip.parentNode.removeChild(this.tooltip);
}
this.tooltip = null;
}
_handleWindowScroll() {
if (!this.tooltip) {
return;
}
// This wait is needed for tooltips to be positioned correctly in Firefox
// and Safari.
this.updateComplete.then(() => this._positionTooltip(this.tooltip));
}
// private but used in tests.
_positionTooltip(tooltip: GrTooltip | null) {
if (tooltip === null) return;
const hoveredRect = this.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
if (!tooltip.parentElement) {
return;
}
const parentRect = tooltip.parentElement.getBoundingClientRect();
// Use clientWidth to not include the scrollbars
const parentWidth = tooltip.parentElement.clientWidth;
const hoveredCenter =
0.5 * (hoveredRect.left + hoveredRect.right) - parentRect.left;
const left = this.computeLeft(tooltipRect, hoveredCenter, parentWidth);
const {isBelow, top} = this.computeTop(
tooltipRect,
hoveredRect,
parentRect
);
const tooltipCenter = left + 0.5 * tooltipRect.width;
tooltip.arrowCenterOffset = `${hoveredCenter - tooltipCenter}px`;
tooltip.positionBelow = isBelow;
tooltip.style.top = `${top}px`;
tooltip.style.left = `${left}px`;
}
private computeLeft(
tooltipRect: DOMRect,
hoveredCenter: number,
parentWidth: number
) {
let left = hoveredCenter - 0.5 * tooltipRect.width;
if (left + tooltipRect.width > parentWidth - 1) {
// Add 1px of extra padding. Without it on some browser zoom levels
// the hovercard is still considered going out of bounds and gets
// reshaped.
left = parentWidth - tooltipRect.width - 1;
}
return Math.max(0, left);
}
private computeTop(
tooltipRect: DOMRect,
hoveredRect: DOMRect,
parentRect: DOMRect
): {
isBelow: boolean;
top: number;
} {
const top =
hoveredRect.top - parentRect.top - tooltipRect.height - ARROW_HEIGHT;
if (this.positionBelow || top < 0) {
return {
isBelow: true,
top: hoveredRect.bottom - parentRect.top + ARROW_HEIGHT,
};
}
return {isBelow: false, top};
}
}