components_tooltip.js


/**
 * Shared tooltip utilities — used by InfoWidget, panel buttons, and anywhere else
 * a styled .info-widget-tooltip needs to appear on hover.
 */

let _activeTip = null;

/** Removes the currently active tooltip from the DOM. */
function _dismissActive() {
    _activeTip?.remove();
    _activeTip = null;
}

/**
 * Creates and positions a styled tooltip near an anchor element.
 * Returns the tooltip element so the caller can remove it later.
 *
 * @param {HTMLElement} anchor - Element to anchor the tooltip to.
 * @param {string}      text   - Tooltip content (plain text, or HTML when html=true).
 * @param {{html: boolean, extraClass: string}} [opts] - Rendering options.
 * @returns {HTMLElement}
 */
export function showTooltip(anchor, text, { html = false, extraClass = null } = {}) {
    _dismissActive();
    const tip = document.createElement('div');
    tip.className = 'info-widget-tooltip';
    if (extraClass) tip.classList.add(extraClass);
    if (html) tip.innerHTML = text;
    else tip.textContent = text;
    document.body.appendChild(tip);

    const rect = anchor.getBoundingClientRect();
    const tipW = tip.offsetWidth;
    const tipH = tip.offsetHeight;
    const gap = 6;
    const top = (rect.bottom + tipH + gap <= window.innerHeight)
        ? rect.bottom + gap
        : rect.top - tipH - gap;
    const left = Math.min(rect.left, window.innerWidth - tipW - 8);
    tip.style.top = `${top}px`;
    tip.style.left = `${left}px`;

    _activeTip = tip;
    return tip;
}

/**
 * Appends a pre-built tooltip element to the body and positions it near the
 * cursor, clamping to the viewport. The element must already have the
 * `info-widget-tooltip` class (and any extra classes) set by the caller.
 *
 * @param {HTMLElement} el - The tooltip element to position.
 * @param {number}      mouseX  - current cursor clientX
 * @param {number}      mouseY  - current cursor clientY
 */
export function positionTooltipAtCursor(el, mouseX, mouseY) {
    document.body.appendChild(el);
    el.style.left = `${mouseX + 12}px`;
    el.style.top  = `${mouseY + 16}px`;
    requestAnimationFrame(() => {
        const r = el.getBoundingClientRect();
        if (r.right  > window.innerWidth)  el.style.left = `${window.innerWidth  - r.width  - 8}px`;
        if (r.bottom > window.innerHeight) el.style.top  = `${mouseY - r.height - 4}px`;
    });
}

/**
 * Attaches a styled tooltip to any element with a `title` attribute.
 * Suppresses the native browser tooltip on hover. Safe to call multiple times.
 *
 * @param {HTMLElement} el - Element with a title attribute to receive a styled tooltip.
 */
export function attachTooltip(el) {
    if (el._styledTooltipAttached) return;
    el._styledTooltipAttached = true;

    let tip = null;

    el.addEventListener('mouseenter', () => {
        const text = el.title;
        if (!text) return;
        el._savedTitle = text;
        el.removeAttribute('title'); // suppress native browser tooltip
        tip = showTooltip(el, text);
    });

    el.addEventListener('mouseleave', () => {
        // Prefer any title set during the hover (e.g. by a click handler) over
        // the snapshot taken at mouseenter; fall back to the snapshot if needed.
        el.title = el.title || el._savedTitle || '';
        el._savedTitle = null;
        if (tip) {
            tip.remove();
            if (_activeTip === tip) _activeTip = null;
            tip = null;
        }
    });
}

/**
 * Globally replaces all native title-attribute tooltips with styled ones.
 * Attaches to every current [title] element in the DOM, then watches for new
 * elements (or title attributes being set) via MutationObserver.
 * Call once at app startup.
 */
export function initTooltips() {
    document.querySelectorAll('[title]').forEach(attachTooltip);

    new MutationObserver(mutations => {
        for (const mutation of mutations) {
            if (mutation.type === 'childList') {
                for (const node of mutation.addedNodes) {
                    if (node.nodeType !== 1) continue;
                    if (node.title) attachTooltip(node);
                    node.querySelectorAll('[title]').forEach(attachTooltip);
                }
            } else if (mutation.type === 'attributes') {
                if (mutation.target.title) attachTooltip(mutation.target);
            }
        }
    }).observe(document.body, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ['title'],
    });
}