Files
study-bible-project/src/components/Tooltips.jsx
T
nmemmertandClaude Sonnet 5.5 7f0256be06 Hover tooltips, clickable cross-references, easier highlighting
- Tooltips on hover and keyboard focus across the app: icon-only buttons show
  their label, existing titles show in the app's style without the browser's
  delay, and the reader's main buttons show shortcut hints ("Search (/)").
  Touch screens are unaffected.
- Reader: cross-references (under verses and in the Cross-refs tab) open the
  passage's text in the side panel without leaving the chapter; "Open chapter"
  goes there when wanted
- Reader: the verse panel now has a Highlight row of colours for one-tap
  whole-verse highlighting (tap again to remove, Clear for all, with Undo);
  selecting words still highlights just those
- Fix: a cross-reference preview was cleared when React ran the panel's
  effect twice (development builds)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:31:27 -04:00

113 lines
4.9 KiB
React

// 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 (
<div
ref={box} id="app-tooltip" role="tooltip" style={{ ...themeVars(theme), left, top: flip ? tip.bottom + 8 : tip.y - 8, transform: flip ? 'none' : 'translateY(-100%)' }}
className="pointer-events-none fixed z-[120] max-w-[16rem] rounded-lg bg-[var(--r-text)] px-2.5 py-1.5 font-sans text-xs leading-snug text-[var(--r-bg)] shadow-lg"
>
{tip.text}
</div>
);
}