// ── Global tooltip system ── // Replaces native title="" with styled tooltips. Supports: // data-tip="short text" — single-line tooltip (replaces title) // data-help="line1\nline2" — multi-line inline help (shown on hover) const SHOW_DELAY = 400; const HIDE_DELAY = 100; const MAX_LIFE = 5000; // watchdog: tooltip never outlives this const OFFSET = 8; let tipEl = null; let showTimer = null; let hideTimer = null; let lifeTimer = null; let currentTarget = null; function create() { if (tipEl) return; tipEl = document.createElement('div'); tipEl.id = 'tooltip'; tipEl.setAttribute('role', 'tooltip'); document.body.appendChild(tipEl); } function show(target) { clearTimeout(hideTimer); if (currentTarget === target) return; currentTarget = target; create(); const tip = target.dataset.tip || ''; const help = target.dataset.help || ''; if (!tip && !help) { hide(); return; } if (help) { tipEl.classList.add('tooltip-help'); tipEl.innerHTML = help.replace(/\\n/g, '
'); } else { tipEl.classList.remove('tooltip-help'); tipEl.textContent = tip; } tipEl.style.visibility = 'hidden'; tipEl.style.display = 'block'; const rect = target.getBoundingClientRect(); const tipRect = tipEl.getBoundingClientRect(); let top, left; const spaceBelow = window.innerHeight - rect.bottom; const spaceAbove = rect.top; if (spaceBelow >= tipRect.height + OFFSET || spaceBelow >= spaceAbove) { top = rect.bottom + OFFSET; tipEl.classList.remove('tooltip-above'); } else { top = rect.top - tipRect.height - OFFSET; tipEl.classList.add('tooltip-above'); } left = rect.left + rect.width / 2 - tipRect.width / 2; left = Math.max(8, Math.min(left, window.innerWidth - tipRect.width - 8)); top = Math.max(4, top); tipEl.style.top = top + 'px'; tipEl.style.left = left + 'px'; tipEl.style.visibility = ''; // Watchdog: if pointerleave never fires (fast flick off the // element, target removed from the DOM mid-hover, focus taken by // another window), the tooltip would sit forever. Force-hide // after MAX_LIFE regardless. clearTimeout(lifeTimer); lifeTimer = setTimeout(hide, MAX_LIFE); } function hide() { currentTarget = null; clearTimeout(lifeTimer); if (tipEl) tipEl.style.display = 'none'; } function onEnter(e) { const el = e.target?.nodeType === 1 ? e.target : e.target?.parentElement; const target = el?.closest?.('[data-tip],[data-help]'); if (!target) return; clearTimeout(hideTimer); showTimer = setTimeout(() => show(target), SHOW_DELAY); } function onLeave(e) { const el = e.target?.nodeType === 1 ? e.target : e.target?.parentElement; const target = el?.closest?.('[data-tip],[data-help]'); if (!target) return; clearTimeout(showTimer); hideTimer = setTimeout(hide, HIDE_DELAY); } document.addEventListener('pointerenter', onEnter, true); document.addEventListener('pointerleave', onLeave, true); document.addEventListener('pointerdown', () => { clearTimeout(showTimer); hide(); }, true); document.addEventListener('keydown', () => { clearTimeout(showTimer); hide(); }, true); document.addEventListener('scroll', () => { clearTimeout(showTimer); hide(); }, true); // Belt-and-suspenders sanity check on every move: if the pointer is // no longer over the tip-owning target (or the target has been // detached from the DOM since show time), hide. Catches fast flicks // that skip the expected pointerleave and DOM churn that yanks the // element out from under us. document.addEventListener('pointermove', (e) => { if (!currentTarget) return; if (!currentTarget.isConnected) { hide(); return; } const el = document.elementFromPoint(e.clientX, e.clientY); if (!el || !currentTarget.contains(el)) hide(); }, true); // Window losing focus / tab hidden — nothing to hover over while // we're not there, kill any lingering tip. window.addEventListener('blur', () => { clearTimeout(showTimer); hide(); }); document.addEventListener('visibilitychange', () => { if (document.hidden) { clearTimeout(showTimer); hide(); } });