Files
kokkoja-rel/client/js/tooltip.js
T

119 lines
4.3 KiB
JavaScript
Raw Normal View History

2026-10-06 15:58:02 +02:00
// @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.
*/
2026-10-11 17:34:24 +02:00
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;
2026-10-06 15:58:02 +02:00
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;
2026-10-06 15:58:02 +02:00
/**
* @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);
}
2026-10-11 18:05:49 +02:00
// 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);
2026-10-06 15:58:02 +02:00
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) {
2026-10-06 15:58:02 +02:00
if (timer !== null) clearTimeout(timer);
timer = null;
if (shownFor) warmUntil = performance.now() + WARM_MS;
2026-10-06 15:58:02 +02:00
shownFor = null;
if (cold) warmUntil = -Infinity;
2026-10-06 15:58:02 +02:00
if (tooltip) tooltip.hidden = true;
}
export function enableTooltips() {
2026-10-11 17:34:24 +02:00
// 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).
2026-10-06 15:58:02 +02:00
document.addEventListener('pointerover', (event) => {
const element = helpElement(event.target);
if (!element) {
hide();
return;
}
if (element === shownFor) return;
hide();
2026-10-11 17:34:24 +02:00
trace(`Hover: ${event.pointerType} over ${element.textContent?.trim() || element.id}`);
const wait = performance.now() < warmUntil ? 0 : DELAY_MS;
2026-10-11 17:34:24 +02:00
timer = setTimeout(() => {
timer = null;
trace(`Tooltip: ${element.textContent?.trim() || element.id}`);
show(element);
}, wait);
2026-10-11 17:34:24 +02:00
});
document.addEventListener('pointerleave', (event) => {
if (timer !== null || shownFor) trace(`Hover: ${event.pointerType} left the document`);
hide();
2026-10-06 15:58:02 +02:00
});
// 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);
});
2026-10-06 15:58:02 +02:00
document.addEventListener('focusin', (event) => {
if (performance.now() - pressedAt < 500) return;
2026-10-06 15:58:02 +02:00
const element = helpElement(event.target);
if (element) show(element);
});
document.addEventListener('focusout', () => hide());
2026-10-06 15:58:02 +02:00
// Typing means the user's attention is on the keys, not the tooltip.
document.addEventListener('keydown', () => hide(true));
2026-10-06 15:58:02 +02:00
}