- 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>
113 lines
4.9 KiB
React
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>
|
|
);
|
|
}
|