/** * Copyright Aquera Inc 2025 * * 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 { CSSResultArray, PropertyValues } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { styles } from './nile-floating-panel.css'; import NileElement from '../internal/nile-element'; import tippy, { Instance, Props, roundArrow, followCursor as followCursorPlugin, } from 'tippy.js'; import { parseFollowCursor, parseDuration, } from '../nile-lite-tooltip/utils'; import { VisibilityManager } from '../utilities/visibility-manager.js'; /** * Nile floating-panel component. * * A popover that supports rich content (title, body, actions). * * **Wrapper mode** (default): first child element is the trigger. * **For mode**: set `for="elementId"` to attach to an external element. * * @tag nile-floating-panel * * @fires nile-init - Component initialized. * @fires nile-destroy - Component destroyed. * @fires nile-show - Panel opened. * @fires nile-hide - Panel closed. * @fires nile-after-show - Panel fully visible after animation. * @fires nile-after-hide - Panel fully hidden after animation. * @fires nile-toggle - Open/close transition (detail.open). * @fires nile-visibility-change - Hidden by scroll/tab change. */ @customElement('nile-floating-panel') export class NileFloatingPanel extends NileElement { private static _groups = new Map>(); private static _reducedMotionQuery: MediaQueryList | null = null; private static get prefersReducedMotion(): boolean { if (!NileFloatingPanel._reducedMotionQuery) { NileFloatingPanel._reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); } return NileFloatingPanel._reducedMotionQuery.matches; } public static get styles(): CSSResultArray { return [styles]; } protected createRenderRoot() { return this; } // ─── Tippy.js props ─── @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' = 'bottom'; @property({ type: String }) trigger: string = 'click'; @property({ type: Number }) distance = 12; @property({ type: Number }) skidding = 0; @property({ type: String, reflect: true }) arrow: 'round' | 'default' | 'none' = 'round'; @property({ type: String, reflect: true }) animation: string = 'fade'; @property({ type: String, reflect: true }) duration: | string | number | [number, number] = 200; @property({ type: String, reflect: true }) delay: | number | [number, number] = 0; @property({ type: Boolean, reflect: true }) interactive = true; @property({ type: Number, reflect: true }) interactiveBorder = 2; @property({ type: String, reflect: true }) maxWidth: string | number = 'none'; @property({ type: Number, reflect: true }) zIndex = 9999; @property({ type: String, reflect: true }) followCursor: | boolean | 'initial' | 'horizontal' | 'vertical' | 'true' | 'false' = false; @property({ type: Boolean, reflect: true }) hideOnClick: | boolean | 'toggle' = true; @property({ type: Boolean, reflect: true }) inertia = false; @property({ type: Boolean, reflect: true }) allowHTML = false; @property({ type: Boolean, reflect: true }) flip = true; // ─── Popover-like props ─── @property({ type: String, attribute: 'for' }) for: string | null = null; @property({ type: Boolean, reflect: true }) open = false; @property({ type: Boolean, reflect: true }) preventOverlayClose = false; @property({ type: String, reflect: true }) title = ''; @property({ type: Boolean, reflect: true }) disabled = false; @property({ type: String, reflect: true }) width?: string; @property({ type: String, reflect: true }) height?: string; /** When set, only one panel in the same group can be open at a time. */ @property({ type: String, reflect: true }) group: string | null = null; /** Close the panel when Escape is pressed. */ @property({ type: Boolean, reflect: true }) closeOnEscape = true; /** Custom CSS class(es) added to the Tippy popper element, allowing per-instance styling from the host. */ @property({ type: String, reflect: true, attribute: true}) panelClass: string = ''; // ─── Visibility manager props ─── @property({ type: Boolean, reflect: true }) enableVisibilityEffect = false; @property({ type: Boolean, reflect: true }) enableTabClose = false; // ─── Internal state ─── private tippyInstance: Instance | null = null; private visibilityManager?: VisibilityManager; private panelContainer: HTMLElement | null = null; private anchorEl: HTMLElement | null = null; private _suppressOpenWatch = false; private _panelId = `nile-fp-${Math.random().toString(36).slice(2, 9)}`; private _boundEscHandler = this._handleEscapeKey.bind(this); private _pendingShowListener: (() => void) | null = null; private _pendingHideListener: (() => void) | null = null; // ─── Lifecycle ─── protected firstUpdated(): void { this._buildDOM(); this._attachTippy(); this._joinGroup(); this.visibilityManager = new VisibilityManager({ host: this, target: this.anchorEl || null, enableVisibilityEffect: this.enableVisibilityEffect, enableTabClose: this.enableTabClose, isOpen: () => this.open, onAnchorOutOfView: () => { this._setOpen(false); this.tippyInstance?.hide(); this.emit('nile-visibility-change', { visible: false, reason: 'anchor-out-of-view', }); }, onDocumentHidden: () => { this._setOpen(false); this.tippyInstance?.hide(); this.emit('nile-visibility-change', { visible: false, reason: 'document-hidden', }); }, emit: (event, detail) => this.emit(`nile-${event}`, detail), }); this.emit('nile-init'); } disconnectedCallback(): void { super.disconnectedCallback(); this._cleanupPendingShowListener(); this._cleanupPendingHideListener(); this.visibilityManager?.cleanup(); this._leaveGroup(); this._removeEscListener(); this._destroyTippy(); this.emit('nile-destroy'); } updated(changed: PropertyValues): void { super.updated(changed); if (!this.panelContainer) return; if (changed.has('open') && !this._suppressOpenWatch) { if (this.open) { this.visibilityManager?.setup(); queueMicrotask(() => this.tippyInstance?.show()); } else { this.visibilityManager?.cleanup(); this.tippyInstance?.hide(); } } if (changed.has('group')) { this._leaveGroup(changed.get('group') as string | null); this._joinGroup(); } const rebuildProps: string[] = [ 'placement', 'trigger', 'distance', 'skidding', 'arrow', 'animation', 'duration', 'delay', 'interactive', 'interactiveBorder', 'maxWidth', 'zIndex', 'followCursor', 'hideOnClick', 'inertia', 'allowHTML', 'flip', 'preventOverlayClose', 'disabled', 'width', 'height', 'panelClass' ]; if (rebuildProps.some(p => changed.has(p))) { this._attachTippy(); } } // ─── Public API ─── /** Programmatically shows the panel. Returns a promise that resolves after the show animation. */ public show(): Promise { this.open = true; return new Promise(resolve => { this._cleanupPendingShowListener(); const handler = () => { this._pendingShowListener = null; resolve(); }; this._pendingShowListener = handler; this.addEventListener('nile-after-show', handler, { once: true }); }); } /** Programmatically hides the panel. Returns a promise that resolves after the hide animation. */ public hide(): Promise { this.open = false; return new Promise(resolve => { this._cleanupPendingHideListener(); const handler = () => { this._pendingHideListener = null; resolve(); }; this._pendingHideListener = handler; this.addEventListener('nile-after-hide', handler, { once: true }); }); } private _cleanupPendingShowListener(): void { if (this._pendingShowListener) { this.removeEventListener('nile-after-show', this._pendingShowListener); this._pendingShowListener = null; } } private _cleanupPendingHideListener(): void { if (this._pendingHideListener) { this.removeEventListener('nile-after-hide', this._pendingHideListener); this._pendingHideListener = null; } } public toggle(): void { this.open = !this.open; } public refresh(): void { this._attachTippy(); } /** Returns the current resolved placement from Tippy/Popper. */ public getCurrentPlacement(): string { const popper = this.tippyInstance?.popper; const box = popper?.querySelector('.tippy-box') as HTMLElement | null; return box?.dataset.placement ?? this.placement; } /** Returns true if the resolved placement matches the requested placement. */ public isPositioningOptimal(): boolean { return this.getCurrentPlacement() === this.placement; } // ─── Group management ─── private _joinGroup(): void { if (!this.group) return; let set = NileFloatingPanel._groups.get(this.group); if (!set) { set = new Set(); NileFloatingPanel._groups.set(this.group, set); } set.add(this); } private _leaveGroup(oldGroup?: string | null): void { const key = oldGroup ?? this.group; if (!key) return; const set = NileFloatingPanel._groups.get(key); if (set) { set.delete(this); if (set.size === 0) NileFloatingPanel._groups.delete(key); } } private _hideGroupSiblings(): void { if (!this.group) return; const set = NileFloatingPanel._groups.get(this.group); if (!set) return; set.forEach(panel => { if (panel !== this && panel.open) { panel._setOpen(false); panel.tippyInstance?.hide(); } }); } // ─── Escape key ─── private _addEscListener(): void { if (this.closeOnEscape) { document.addEventListener('keydown', this._boundEscHandler); } } private _removeEscListener(): void { document.removeEventListener('keydown', this._boundEscHandler); } private _handleEscapeKey(e: KeyboardEvent): void { if (e.key === 'Escape' && this.open) { this._setOpen(false); this.tippyInstance?.hide(); } } // ─── ARIA ─── private _applyAria(): void { if (!this.anchorEl || !this.panelContainer) return; this.panelContainer.setAttribute('role', 'dialog'); this.panelContainer.id = this._panelId; this.anchorEl.setAttribute('aria-haspopup', 'dialog'); this._syncAriaExpanded(); } private _syncAriaExpanded(): void { this.anchorEl?.setAttribute('aria-expanded', String(this.open)); if (this.open) { this.anchorEl?.setAttribute('aria-describedby', this._panelId); } else { this.anchorEl?.removeAttribute('aria-describedby'); } } // ─── DOM construction ─── private _buildDOM(): void { const children = Array.from(this.childNodes); this.anchorEl = null; const titleNodes: Node[] = []; const actionNodes: Node[] = []; const bodyNodes: Node[] = []; let firstElementSeen = false; for (const child of children) { if (child instanceof HTMLElement) { const slot = child.getAttribute('slot'); if (slot === 'title') { child.removeAttribute('slot'); titleNodes.push(child); continue; } if (slot === 'action') { child.removeAttribute('slot'); actionNodes.push(child); continue; } if (!firstElementSeen && !this.for) { this.anchorEl = child; firstElementSeen = true; continue; } } bodyNodes.push(child); } if (this.for) { const anchor = document.getElementById(this.for); if (anchor) { this.anchorEl = anchor; } } while (this.firstChild) { this.removeChild(this.firstChild); } if (this.anchorEl && !this.for) { this.appendChild(this.anchorEl); } this.panelContainer = document.createElement('div'); this.panelContainer.className = 'nile-floating-panel__content'; this.panelContainer.style.display = 'none'; const body = document.createElement('div'); body.className = 'nile-floating-panel__body'; if (titleNodes.length > 0 || this.title) { const titleDiv = document.createElement('div'); titleDiv.className = 'nile-floating-panel__title'; if (this.title) { titleDiv.textContent = this.title; } else { titleNodes.forEach(n => titleDiv.appendChild(n)); } body.appendChild(titleDiv); } if (bodyNodes.length > 0) { const mainDiv = document.createElement('div'); mainDiv.className = 'nile-floating-panel__main'; bodyNodes.forEach(n => mainDiv.appendChild(n)); body.appendChild(mainDiv); } if (actionNodes.length > 0) { const actionDiv = document.createElement('div'); actionDiv.className = 'nile-floating-panel__action'; actionNodes.forEach(n => actionDiv.appendChild(n)); body.appendChild(actionDiv); } this.panelContainer.appendChild(body); this.appendChild(this.panelContainer); this._applyAria(); } // ─── Tippy management ─── private _resolveArrow() { switch (this.arrow) { case 'round': return roundArrow; case 'none': return false as const; default: return true as const; } } private _setOpen(value: boolean): void { this._suppressOpenWatch = true; this.open = value; this._syncAriaExpanded(); this._suppressOpenWatch = false; } private _getEffectiveDuration(): number | [number, number] { if (NileFloatingPanel.prefersReducedMotion) return 0; return parseDuration(this.duration); } private _getEffectiveAnimation(): string | false { if (NileFloatingPanel.prefersReducedMotion) return false; return this.animation; } private _attachTippy(): void { this._destroyTippy(); if (this.disabled || !this.anchorEl || !this.panelContainer) return; const resolvedFollowCursor = parseFollowCursor(this.followCursor); const effectiveHideOnClick = this.preventOverlayClose ? false : this.hideOnClick; const options: Partial = { content: this.panelContainer, placement: this.placement, trigger: this.trigger, offset: [this.skidding, this.distance], theme: 'floating-panel', animation: this._getEffectiveAnimation(), interactive: this.interactive, arrow: this._resolveArrow(), duration: this._getEffectiveDuration(), allowHTML: this.allowHTML, delay: this.delay as any, maxWidth: this.maxWidth, zIndex: this.zIndex, hideOnClick: effectiveHideOnClick, inertia: NileFloatingPanel.prefersReducedMotion ? false : this.inertia, interactiveBorder: this.interactiveBorder, appendTo: document.body, followCursor: resolvedFollowCursor, plugins: resolvedFollowCursor ? [followCursorPlugin] : [], popperOptions: { modifiers: [{ name: 'flip', enabled: this.flip }], }, onMount: (instance) => { if (this.panelContainer) this.panelContainer.style.display = ''; if (this.panelClass) { this.panelClass.split(/\s+/).filter(Boolean).forEach(cls => { instance.popper.classList.add(cls); }); } }, onShow: (instance) => { if (this.panelContainer) this.panelContainer.style.display = ''; const tc = instance.popper.querySelector('.tippy-content') as HTMLElement | null; if (tc) { if (this.width) tc.style.width = this.width; if (this.height) { tc.style.height = this.height; tc.style.overflow = 'auto'; } } this._hideGroupSiblings(); this._setOpen(true); this._addEscListener(); 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; }, onShown: (instance) => { this.dispatchEvent(new CustomEvent('nile-after-show', { detail: { instance, target: instance.reference } })); }, onHide: (instance) => { this._setOpen(false); this._removeEscListener(); 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; }, onHidden: (instance) => { if (this.panelContainer) this.panelContainer.style.display = 'none'; this.dispatchEvent(new CustomEvent('nile-after-hide', { detail: { instance, target: instance.reference } })); }, }; this.tippyInstance = tippy(this.anchorEl, options); if (this.open) { queueMicrotask(() => this.tippyInstance?.show()); } } private _destroyTippy(): void { if (this.tippyInstance) { this.tippyInstance.destroy(); this.tippyInstance = null; } if (this.panelContainer) { this.panelContainer.style.display = 'none'; if (this.panelContainer.parentElement !== this) { this.appendChild(this.panelContainer); } } } } export default NileFloatingPanel; declare global { interface HTMLElementTagNameMap { 'nile-floating-panel': NileFloatingPanel; } }