| /** |
| * @license |
| * Copyright 2017 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import '../gr-icon/gr-icon'; |
| import '../gr-tooltip/gr-tooltip'; |
| import {getRootElement} from '../../../scripts/rootElement'; |
| import {GrTooltip} from '../gr-tooltip/gr-tooltip'; |
| import {css, html, LitElement, PropertyValues} from 'lit'; |
| import {customElement, property, state} from 'lit/decorators.js'; |
| |
| const BOTTOM_OFFSET = 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'; |
| getRootElement().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); |
| } |
| } |
| |
| _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 rect = this.getBoundingClientRect(); |
| const boxRect = tooltip.getBoundingClientRect(); |
| if (!tooltip.parentElement) { |
| return; |
| } |
| const parentRect = tooltip.parentElement.getBoundingClientRect(); |
| const top = rect.top - parentRect.top; |
| const left = rect.left - parentRect.left + (rect.width - boxRect.width) / 2; |
| const right = parentRect.width - left - boxRect.width; |
| if (left < 0) { |
| tooltip.arrowCenterOffset = `${left}px`; |
| } else if (right < 0) { |
| tooltip.arrowCenterOffset = `${-0.5 * right}px`; |
| } |
| tooltip.style.left = `${Math.max(0, left)}px`; |
| |
| if (!this.positionBelow) { |
| tooltip.style.top = `${Math.max(0, top)}px`; |
| tooltip.style.transform = `translateY(calc(-100% - ${BOTTOM_OFFSET}px))`; |
| } else { |
| tooltip.style.top = `${top + rect.height + BOTTOM_OFFSET}px`; |
| } |
| } |
| } |