import { customElement, property } from 'lit/decorators.js'; import { CSSResultArray, PropertyValues } from 'lit'; import { styles } from './nile-lite-tooltip.css'; import NileElement from '../internal/nile-element'; import tippy, { Instance, Props, followCursor, roundArrow, createSingleton, } from 'tippy.js'; import { parseFollowCursor, parseDuration, } from './utils'; import { VisibilityManager } from '../utilities/visibility-manager.js'; /** * Nile lite-tooltip component. * * Supports wrapper, sibling (for), and singleton modes. * Aligns with Tippy.js props and Nile design system. * * @tag nile-lite-tooltip */ @customElement('nile-lite-tooltip') export class NileliteTooltip extends NileElement { public static get styles(): CSSResultArray { return [styles]; } protected createRenderRoot() { return this; // lite DOM } // ───────────── Props ───────────── /** ID of the target element (for sibling mode) */ @property({ type: String, attribute: 'for' }) for: string | null = null; /** Tooltip content text or HTML */ @property({ type: String, reflect: true }) content = ''; /** Tooltip size (applies CSS class) */ @property({ type: String, reflect: true }) size: 'small' | 'large' = 'small'; /** Animation duration for show/hide (ms). Can be a single value or [show, hide]. */ @property({ type: String, reflect: true }) duration: | string | number | [number, number] = 200; /** Interactive mode */ @property({ type: Boolean, reflect: true }) interactive = false; /** Placement of the tooltip */ @property({ type: String }) placement: | 'top' | 'top-start' | 'top-end' | 'right' | 'right-start' | 'right-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end' | 'auto' | 'auto-start' | 'auto-end' = 'top'; @property({ type: Boolean }) disabled = false; @property({ type: Number }) distance = 8; @property({ type: Number }) skidding = 0; @property({ type: Boolean, reflect: true }) open = false; @property({ type: String }) trigger: Props['trigger'] = 'mouseenter focus'; @property({ type: Boolean, reflect: true }) allowHTML = false; @property({ type: String, reflect: true, attribute: true }) followCursor: | boolean | 'initial' | 'horizontal' | 'vertical' | 'true' | 'false' = false; @property({ type: String, reflect: true, attribute: true }) arrow: 'default' | 'round' | 'large' | 'small' | 'wide' | 'narrow' | 'none' = 'default'; @property({ type: Boolean, reflect: true }) singleton = false; @property({ type: Boolean, reflect: true }) enableRecursiveMode = false; /** Not added in Doc */ @property({ type: String, reflect: true }) delay: | number | [number, number] = 0; @property({ type: String, reflect: true }) maxWidth: string | number = 'auto'; @property({ type: Number }) zIndex = 9999; @property({ type: [Boolean, String], reflect: true }) hideOnClick: | boolean | 'toggle' = true; @property({ type: Boolean, reflect: true }) inertia = false; @property({ type: Number }) interactiveBorder = 2; private visibilityManager?: VisibilityManager; @property({ type: Boolean, reflect: true }) enableVisibilityEffect = false; @property({ type: Boolean, reflect: true }) enableTabClose = false; @property({ type: String, reflect: true }) width?: string; @property({ type: String, reflect: true }) height?: string; private tooltipInstances?: Instance[]; private singleInstance?: Instance; private singletonInstance?: Instance; private targetEl?: HTMLElement | null; private generatedId?: string; private prevDescribedby?: string | null; constructor() { super(); } protected firstUpdated(): void { this.attachTooltip(); const targetEl = (this.for && document.getElementById(this.for)) || (this.firstElementChild as HTMLElement | null); this.visibilityManager = new VisibilityManager({ host: this, target: targetEl, enableVisibilityEffect: this.enableVisibilityEffect, enableTabClose: this.enableTabClose, isOpen: () => this.open, onAnchorOutOfView: () => { this.open = false; this.hideAllTooltips(); this.emit('nile-visibility-change', { visible: false, reason: 'anchor-out-of-view', }); }, onDocumentHidden: () => { this.open = false; this.hideAllTooltips(); this.emit('nile-visibility-change', { visible: false, reason: 'document-hidden', }); }, emit: (event, detail) => this.emit(`nile-${event}`, detail), }); } public refresh() { this.attachTooltip(); } private hasValidContent(): boolean { const content = this.content ?? this.getAttribute('data-tippy-content') ?? ''; return typeof content === 'string' && content.trim().length > 0; } private attachTooltip(): void { this.destroyTooltips(); if (this.disabled ||(!this.enableRecursiveMode && !this.singleton && !this.hasValidContent())) {return;} const options: Partial = { content: this.content || this.getAttribute('data-tippy-content') || undefined, placement: this.placement, trigger: this.trigger, offset: [this.skidding, this.distance], theme: 'lite', animation: 'fade', interactive: this.interactive, arrow: roundArrow, duration: parseDuration(this.duration), allowHTML: this.allowHTML, delay: this.delay, maxWidth: this.maxWidth, zIndex: this.zIndex, hideOnClick: false, inertia: this.inertia, interactiveBorder: this.interactiveBorder, appendTo: document.body, followCursor: parseFollowCursor(this.followCursor), plugins: parseFollowCursor(this.followCursor) ? [followCursor] : [], onShow: instance => { this.open = true; const content = instance.popper.querySelector( '.tippy-content' ) as HTMLElement | null; if (content) { if (this.width) content.style.width = this.width; if (this.height) { content.style.height = this.height; content.style.overflow = 'auto'; } } this.dispatchEvent( new CustomEvent('nile-show', { detail: { instance, target: instance.reference }, }) ); this.dispatchEvent( new CustomEvent('nile-toggle', { detail: { open: true, instance, target: instance.reference }, }) ); return undefined; }, onHide: instance => { this.open = false; this.dispatchEvent( new CustomEvent('nile-hide', { detail: { instance, target: instance.reference }, }) ); this.dispatchEvent( new CustomEvent('nile-toggle', { detail: { open: false, instance, target: instance.reference }, }) ); return undefined; }, }; if (this.for) { if (this.singleton && !this.for.startsWith('#') && !document.getElementById(this.for)) { const targetEls = Array.from(document.querySelectorAll(`.${this.for}`)); if (targetEls.length > 0) { this.tooltipInstances = targetEls.map(el => { const instance = tippy(el as HTMLElement, { ...options, content: el.getAttribute('content') || this.content, }); instance.popper.querySelector('.tippy-box')?.classList.add(this.size); return instance; }); this.singletonInstance = createSingleton(this.tooltipInstances, { delay: [75, 0], arrow: roundArrow, moveTransition: 'transform 0.15s ease-out', }); if (this.open) queueMicrotask(() => this.singletonInstance?.show()); return; } } const containerEl = document.getElementById(this.for); if (this.singleton && containerEl) { const childEls = Array.from(containerEl.querySelectorAll('[content]')); if (childEls.length > 0) { this.tooltipInstances = childEls.map(el => { const instance = tippy(el as HTMLElement, { ...options, content: el.getAttribute('content') || this.content, }); instance.popper.querySelector('.tippy-box')?.classList.add(this.size); return instance; }); this.singletonInstance = createSingleton(this.tooltipInstances, { delay: [75, 0], arrow: roundArrow, moveTransition: 'transform 0.15s ease-out', }); if (this.open) queueMicrotask(() => this.singletonInstance?.show()); return; } } this.targetEl = document.getElementById(this.for); if (!this.targetEl) return; if (!this.id) { this.generatedId = `nile-tooltip-${Math.random() .toString(36) .slice(2, 9)}`; this.id = this.generatedId; } this.prevDescribedby = this.targetEl.getAttribute('aria-describedby'); const describedby = this.prevDescribedby ? `${this.prevDescribedby} ${this.id}` : this.id; this.targetEl.setAttribute('aria-describedby', describedby); this.singleInstance = tippy(this.targetEl, options); if (this.size) this.singleInstance.popper .querySelector('.tippy-box') ?.classList.add(this.size); if (this.open) { queueMicrotask(() => this.singleInstance?.show()); } return; } if (this.enableRecursiveMode) { const children = Array.from( this.querySelectorAll('[content]')).filter(el => { const value = el.getAttribute('content'); return value !== null && value.trim().length > 0; }); if (children.length > 0) { this.tooltipInstances = children.map(child => { const el = child as HTMLElement; const localContent = el.getAttribute('content') || this.content; const instance = tippy(el, { ...options, content: localContent }); instance.popper.querySelector('.tippy-box')?.classList.add(this.size); return instance; }); if (this.singleton && this.tooltipInstances.length > 1) { this.singletonInstance = createSingleton(this.tooltipInstances, { delay: [75, 0], arrow: roundArrow, moveTransition: 'transform 0.15s ease-out', }); } if (this.open) { if (this.singletonInstance) this.singletonInstance.show(); else this.tooltipInstances.forEach(t => t.show()); } } } else { const firstChild = this.firstElementChild as HTMLElement | null; if (firstChild) { const localContent = firstChild.getAttribute('content') || this.content; const instance = tippy(firstChild, { ...options, content: localContent }); instance.popper.querySelector('.tippy-box')?.classList.add(this.size); this.tooltipInstances = [instance]; if (this.open) { instance.show(); } } } } private destroyTooltips(): void { this.tooltipInstances?.forEach(t => t.destroy()); this.singleInstance?.destroy(); this.singletonInstance?.destroy(); this.tooltipInstances = undefined; this.singleInstance = undefined; this.singletonInstance = undefined; if (this.targetEl && this.id) { const current = this.targetEl .getAttribute('aria-describedby') ?.split(' ') .filter(id => id !== this.id) .join(' ') .trim(); if (current) { this.targetEl.setAttribute('aria-describedby', current); } else { this.targetEl.removeAttribute('aria-describedby'); } } } private hideAllTooltips(): void { this.singleInstance?.hide(); this.singletonInstance?.hide(); this.tooltipInstances?.forEach(t => t.hide()); } disconnectedCallback(): void { super.disconnectedCallback(); this.visibilityManager?.cleanup(); this.destroyTooltips(); } updated(changed: PropertyValues): void { super.updated(changed); if ( [ 'for', 'content', 'placement', 'distance', 'skidding', 'trigger', 'disabled', 'hoist', 'size', 'arrow', 'singleton', ].some(p => changed.has(p)) ) { this.attachTooltip(); } if (changed.has('open')) { if (this.open) { this.visibilityManager?.setup(); this.singleInstance?.show(); this.singletonInstance?.show(); this.tooltipInstances?.forEach(t => t.show()); } else { this.visibilityManager?.cleanup(); this.singleInstance?.hide(); this.singletonInstance?.hide(); this.tooltipInstances?.forEach(t => t.hide()); } } } } export default NileliteTooltip; declare global { interface HTMLElementTagNameMap { 'nile-lite-tooltip': NileliteTooltip; } }