// Hover (and keyboard-focus) tooltips for the whole app, without touching every button: // - anything with data-tip="…" shows that text (used for shortcut hints like "Search (/)") // - anything with title="…" shows it too, in the app's style and without the browser's long delay // - an icon-only button (no visible words) shows its aria-label // Touch screens have no hover, so nothing appears there. import { useEffect, useRef, useState } from 'react'; import { themeVars } from './reader/readerThemes.js'; const SHOW_DELAY = 450; const SELECTOR = '[data-tip], [title], button[aria-label], a[aria-label], [role="button"][aria-label], [role="tab"][aria-label]'; const canHover = () => (typeof window.matchMedia === 'function' ? window.matchMedia('(hover: hover)').matches : true); // The words a tooltip should show for an element, or '' for none. A title is moved to data-tip the first // time (so the browser's own tooltip doesn't also appear); a title on a button with no other name becomes // its aria-label so screen readers keep it. export function tipFor(el) { if (!(el instanceof HTMLElement)) return ''; if (el.hasAttribute('title')) { const title = el.getAttribute('title').trim(); if (title) { if (!el.getAttribute('aria-label') && el.textContent.replace(/\s+/g, '').length <= 2) el.setAttribute('aria-label', title); if (!el.hasAttribute('data-tip')) el.setAttribute('data-tip', title); } el.removeAttribute('title'); } const tip = el.getAttribute('data-tip'); if (tip) return tip; // Icon-only controls (little or no text of their own) explain themselves with their label const label = el.getAttribute('aria-label'); const visible = el.textContent.replace(/\s+/g, '').length; return label && visible <= 2 ? label : ''; } export default function Tooltips({ theme }) { const [tip, setTip] = useState(null); // { text, x, y, below } const timer = useRef(null); const current = useRef(null); const box = useRef(null); useEffect(() => { const hide = () => { window.clearTimeout(timer.current); if (current.current) current.current.removeAttribute('aria-describedby'); current.current = null; setTip(null); }; const show = (el, text) => { const r = el.getBoundingClientRect(); current.current = el; el.setAttribute('aria-describedby', 'app-tooltip'); setTip({ text, x: r.left + r.width / 2, y: r.top, bottom: r.bottom }); }; const find = (node) => (node instanceof Element ? node.closest(SELECTOR) : null); const onOver = (e) => { if (!canHover()) return; const el = find(e.target); if (!el || el === current.current) return; hide(); const text = tipFor(el); if (!text || el.disabled) return; timer.current = window.setTimeout(() => show(el, text), SHOW_DELAY); }; const onOut = (e) => { const el = find(e.target); if (!el || (e.relatedTarget instanceof Node && el.contains(e.relatedTarget))) return; hide(); }; const onFocus = (e) => { const el = find(e.target); if (!el || !el.matches(':focus-visible')) return; // only keyboard focus, not a mouse click const text = tipFor(el); if (text) { window.clearTimeout(timer.current); show(el, text); } }; const onKey = (e) => { if (e.key === 'Escape') hide(); }; document.addEventListener('pointerover', onOver); document.addEventListener('pointerout', onOut); document.addEventListener('pointerdown', hide, true); document.addEventListener('focusin', onFocus); document.addEventListener('focusout', hide); document.addEventListener('keydown', onKey); window.addEventListener('scroll', hide, true); window.addEventListener('resize', hide); return () => { window.clearTimeout(timer.current); document.removeEventListener('pointerover', onOver); document.removeEventListener('pointerout', onOut); document.removeEventListener('pointerdown', hide, true); document.removeEventListener('focusin', onFocus); document.removeEventListener('focusout', hide); document.removeEventListener('keydown', onKey); window.removeEventListener('scroll', hide, true); window.removeEventListener('resize', hide); }; }, []); if (!tip) return null; // Centred over the control, kept inside the window, and flipped below it when there's no room above const width = box.current?.offsetWidth ?? 0; const left = Math.max(8, Math.min(tip.x - width / 2, window.innerWidth - width - 8)); const flip = tip.y < 40; return (