/** * Copyright Aquera Inc 2023 * * This source code is licensed under the BSD-3-Clause license found in the * LICENSE file in the root directory of this source tree. */ import { LitElement, CSSResultArray, TemplateResult } from 'lit'; import { styles } from './nile-tooltip.css'; import '../nile-popup/nile-popup'; import { animateTo, parseDuration, stopAnimations } from '../internal/animate'; import { classMap } from 'lit/directives/class-map.js'; import { customElement, property, query } from 'lit/decorators.js'; import { getAnimation, setDefaultAnimation, } from '../utilities/animation-registry'; import { html } from 'lit'; // import { LocalizeController } from '../utilities/localize'; import { waitForEvent } from '../internal/event'; import { watch } from '../internal/watch'; import NileElement from '../internal/nile-element'; import type { CSSResultGroup } from 'lit'; import type NilePopup from '../nile-popup/nile-popup'; import { VisibilityManager } from '../utilities/visibility-manager.js'; /** * Nile icon component. * * @tag nile-tooltip * */ @customElement('nile-tooltip') export class NileTooltip extends NileElement { /** * The styles for Tooltip * @remarks If you are extending this class you can extend the base styles with super. Eg `return [super(), myCustomStyles]` */ public static get styles(): CSSResultArray { return [styles]; } private hoverTimeout: number; // private readonly localize = new LocalizeController(this); @query('slot:not([name])') defaultSlot: HTMLSlotElement; @query('.tooltip__body') body: HTMLElement; @query('nile-popup') popup: NilePopup; /** The tooltip's content. If you need to display HTML, use the `content` slot instead. */ @property({ type: String, reflect: true }) content = ''; /** Size Property to decide the tool tip size */ @property({ reflect: true }) size: 'small' | 'large' = 'small'; /** * The preferred placement of the tooltip. Note that the actual placement may vary as needed to keep the tooltip * inside of the viewport. */ @property() placement: | 'top' | 'top-start' | 'top-end' | 'right' | 'right-start' | 'right-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end' = 'top'; /** Disables the tooltip so it won't show when triggered. */ @property({ type: Boolean, reflect: true }) disabled = false; /** The distance in pixels from which to offset the tooltip away from its target. */ @property({ type: Number }) distance = 8; /** Indicates whether or not the tooltip is open. You can use this in lieu of the show/hide methods. */ @property({ type: Boolean, reflect: true }) open = false; /** The distance in pixels from which to offset the tooltip along its target. */ @property({ type: Number }) skidding = 0; /** * Controls how the tooltip is activated. Possible options include `click`, `hover`, `focus`, and `manual`. Multiple * options can be passed by separating them with a space. When manual is used, the tooltip must be activated * programmatically. */ @property() trigger = 'hover focus'; /** * Enable this option to prevent the tooltip from being clipped when the component is placed inside a container with * `overflow: auto|hidden|scroll`. Hoisting uses a fixed positioning strategy that works in many, but not all, * scenarios. */ @property({ type: Boolean }) hoist = false; private visibilityManager?: VisibilityManager; @property({ type: Boolean, reflect: true }) enableVisibilityEffect = false; @property({ type: Boolean, reflect: true }) enableTabClose = false; connectedCallback() { super.connectedCallback(); this.handleBlur = this.handleBlur.bind(this); this.handleClick = this.handleClick.bind(this); this.handleFocus = this.handleFocus.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); this.handleMouseOver = this.handleMouseOver.bind(this); this.handleMouseOut = this.handleMouseOut.bind(this); this.updateComplete.then(() => { this.addEventListener('blur', this.handleBlur, true); this.addEventListener('focus', this.handleFocus, true); this.addEventListener('click', this.handleClick); this.addEventListener('keydown', this.handleKeyDown); this.addEventListener('mouseover', this.handleMouseOver); this.addEventListener('mouseout', this.handleMouseOut); }); } firstUpdated() { this.body.hidden = !this.open; // If the tooltip is visible on init, update its position if (this.open) { this.popup.active = true; this.popup.reposition(); } const anchorSlot = this.renderRoot.querySelector('slot:not([name])') as HTMLSlotElement | null; const anchorEl = anchorSlot?.assignedElements({ flatten: true })[0] as HTMLElement | undefined; this.visibilityManager = new VisibilityManager({ host: this, target: anchorEl || null, enableVisibilityEffect: this.enableVisibilityEffect, enableTabClose: this.enableTabClose, isOpen: () => this.open, onAnchorOutOfView: () => { this.hide(); this.emit('nile-visibility-change', { visible: false, reason: 'anchor-out-of-view', }); }, onDocumentHidden: () => { this.hide(); this.emit('nile-visibility-change', { visible: false, reason: 'document-hidden', }); }, emit: (event, detail) => this.emit(`nile-${event}`, detail), }); } disconnectedCallback() { super.disconnectedCallback(); this.visibilityManager?.cleanup(); this.removeEventListener('blur', this.handleBlur, true); this.removeEventListener('focus', this.handleFocus, true); this.removeEventListener('click', this.handleClick); this.removeEventListener('keydown', this.handleKeyDown); this.removeEventListener('mouseover', this.handleMouseOver); this.removeEventListener('mouseout', this.handleMouseOut); } private handleBlur() { if (this.hasTrigger('focus')) { this.hide(); } } private handleClick() { if (this.hasTrigger('click')) { if (this.open) { this.hide(); } else { this.show(); } } } private handleFocus() { if (this.hasTrigger('focus')) { this.show(); } } private handleKeyDown(event: KeyboardEvent) { // Pressing escape when the target element has focus should dismiss the tooltip if (this.open && event.key === 'Escape') { event.stopPropagation(); this.hide(); } } private handleMouseOver() { if (this.hasTrigger('hover')) { const delay = parseDuration( getComputedStyle(this).getPropertyValue('--show-delay') ); clearTimeout(this.hoverTimeout); this.hoverTimeout = window.setTimeout(() => this.show(), delay); } } private handleMouseOut() { if (this.hasTrigger('hover')) { const delay = parseDuration( getComputedStyle(this).getPropertyValue('--hide-delay') ); clearTimeout(this.hoverTimeout); this.hoverTimeout = window.setTimeout(() => this.hide(), delay); } } private hasTrigger(triggerType: string) { const triggers = this.trigger.split(' '); return triggers.includes(triggerType); } @watch('open', { waitUntilFirstUpdate: true }) async handleOpenChange() { if (this.open) { this.visibilityManager?.setup(); if (this.disabled) { return; } // Show this.emit('nile-show'); await stopAnimations(this.body); this.body.hidden = false; this.popup.active = true; const { keyframes, options } = getAnimation(this, 'tooltip.show', { dir: '', }); await animateTo(this.popup.popup, keyframes, options); this.emit('nile-after-show'); } else { this.visibilityManager?.cleanup(); // Hide this.emit('nile-hide'); await stopAnimations(this.body); const { keyframes, options } = getAnimation(this, 'tooltip.hide', { dir: '', }); await animateTo(this.popup.popup, keyframes, options); this.popup.active = false; this.body.hidden = true; this.emit('nile-after-hide'); } } @watch(['content', 'distance', 'hoist', 'placement', 'skidding']) async handleOptionsChange() { if (this.hasUpdated) { await this.updateComplete; this.popup.reposition(); } } @watch('disabled') handleDisabledChange() { if (this.disabled && this.open) { this.hide(); } } /** Shows the tooltip. */ async show() { if (this.open || !this.content?.trim().length) { return undefined; } this.open = true; return waitForEvent(this, 'nile-after-show'); } /** Hides the tooltip */ async hide() { if (!this.open) { return undefined; } this.open = false; return waitForEvent(this, 'nile-after-hide'); } render() { return html` ${this.content} `; } } setDefaultAnimation('tooltip.show', { keyframes: [ { opacity: 0, scale: 0.8 }, { opacity: 1, scale: 1 }, ], options: { duration: 150, easing: 'ease' }, }); setDefaultAnimation('tooltip.hide', { keyframes: [ { opacity: 1, scale: 1 }, { opacity: 0, scale: 0.8 }, ], options: { duration: 150, easing: 'ease' }, }); export default NileTooltip; declare global { interface HTMLElementTagNameMap { 'nile-tooltip': NileTooltip; } }