Files
hyprcrd/public/js/modules/tooltip.js
T
thandClaude Opus 4.6 27cc3f98ac tooltip: kill stuck tips with watchdog + pointermove sanity
Tooltips could strand on screen when pointerleave didn't fire — fast
flicks off the target, re-renders that yanked the element out mid-
hover, or the tab losing focus. Three guards:

  - 5s max-life watchdog on every show, so the worst case is a late
    fade rather than a permanent overlay
  - pointermove check: if the pointer is no longer over the tip's
    owning target (or the target got detached from the DOM), hide
  - window blur + visibilitychange also hide immediately

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:43:12 +02:00

121 lines
4.1 KiB
JavaScript

// ── 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, '<br>');
} 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(); } });