// @ts-check /** * Tooltips: a full explanation of a button, shown below it after the * pointer (or a pen hovering above the screen) has rested on it for a * moment. The buttons themselves carry terse labels; this is where the * explanation lives. * * Any element with a `data-help` attribute gets one. Elements added later * are covered too, since the listeners are on the document. */ import { trace } from './log.js'; /** * How long the pointer must have been in a button before its tooltip * appears: the time in the button, movement inside it not counting * (Andy, 2026-10-11: one second; two until then, one until 2026-10-06). */ export const DELAY_MS = 1000; /** * The neighbour rule: within this long after a tooltip was shown, the * next button's tooltip appears at once, so that reading a row of * buttons costs one wait, not one per button. A press or a key ends it. */ export const WARM_MS = 1000; const GAP_PX = 6; /** @type {HTMLElement | null} */ let tooltip = null; /** @type {number | null} */ let timer = null; /** @type {Element | null} */ let shownFor = null; /** When a tooltip was last showing, for the neighbour rule. */ let warmUntil = -Infinity; /** * @param {EventTarget | null} target * @returns {HTMLElement | null} The element with help under the pointer. */ function helpElement(target) { return target instanceof Element ? target.closest('[data-help]') : null; } /** @param {HTMLElement} element */ function show(element) { if (!tooltip) { tooltip = document.createElement('div'); tooltip.className = 'tooltip'; tooltip.setAttribute('role', 'tooltip'); document.body.append(tooltip); } // A modal dialog is above everything in the body, its tooltips included, // so a tooltip for an element in one lives in the dialog. const home = element.closest('dialog[open]') ?? document.body; if (tooltip.parentElement !== home) home.append(tooltip); tooltip.textContent = element.getAttribute('data-help'); tooltip.hidden = false; shownFor = element; // Below the element, aligned with its left edge, kept within the window. const box = element.getBoundingClientRect(); const width = tooltip.offsetWidth; const left = Math.max(GAP_PX, Math.min(box.left, window.innerWidth - width - GAP_PX)); tooltip.style.left = `${left}px`; tooltip.style.top = `${box.bottom + GAP_PX}px`; } /** @param {boolean} [cold] Whether the neighbour rule ends here too. */ function hide(cold = false) { if (timer !== null) clearTimeout(timer); timer = null; if (shownFor) warmUntil = performance.now() + WARM_MS; shownFor = null; if (cold) warmUntil = -Infinity; if (tooltip) tooltip.hidden = true; } export function enableTooltips() { // Traced into the Session window's log (2026-10-11: Andy's Wacom pen // hovering over a button on Hollis showed no tooltip, and the log is // the way to see what the browser delivers for a hovering pen). document.addEventListener('pointerover', (event) => { const element = helpElement(event.target); if (!element) { hide(); return; } if (element === shownFor) return; hide(); trace(`Hover: ${event.pointerType} over ${element.textContent?.trim() || element.id}`); const wait = performance.now() < warmUntil ? 0 : DELAY_MS; timer = setTimeout(() => { timer = null; trace(`Tooltip: ${element.textContent?.trim() || element.id}`); show(element); }, wait); }); document.addEventListener('pointerleave', (event) => { if (timer !== null || shownFor) trace(`Hover: ${event.pointerType} left the document`); hide(); }); // Focus that comes from a click brings no tooltip: the user is already // acting on the element, and a list it opens would be covered (Andy, // 2026-10-07, the notebook list). Focus from the keyboard does. let pressedAt = -Infinity; document.addEventListener('pointerdown', () => { pressedAt = performance.now(); hide(true); }); document.addEventListener('focusin', (event) => { if (performance.now() - pressedAt < 500) return; const element = helpElement(event.target); if (element) show(element); }); document.addEventListener('focusout', () => hide()); // Typing means the user's attention is on the keys, not the tooltip. document.addEventListener('keydown', () => hide(true)); }