114 lines
4.3 KiB
TypeScript
114 lines
4.3 KiB
TypeScript
/**
|
|
* One floating tooltip, shared by every page.
|
|
*
|
|
* Reference detail — what a card does, why an action is offered, what a limit means — is needed
|
|
* occasionally and read once. Printing all of it costs the space that the board, the hand and the
|
|
* action list actually need. So anything carrying `data-tip` shows its text on hover or focus and
|
|
* nothing the rest of the time.
|
|
*
|
|
* Not the browser's native `title`: that waits about a second, cannot be styled, wraps badly at
|
|
* this length, and never appears for keyboard users.
|
|
*/
|
|
|
|
let tip: HTMLElement | null = null;
|
|
|
|
function ensure(): HTMLElement {
|
|
if (tip) return tip;
|
|
const el = document.createElement('div');
|
|
el.id = 'tip';
|
|
el.setAttribute('role', 'tooltip');
|
|
document.body.appendChild(el);
|
|
tip = el;
|
|
return el;
|
|
}
|
|
|
|
function place(el: HTMLElement, text: string): void {
|
|
const t = ensure();
|
|
/**
|
|
* A PICTURE WHERE ONE HELPS, otherwise text.
|
|
*
|
|
* `data-tip-html` carries pre-built markup — a drawing of the track piece a placement would lay,
|
|
* rendered by the board's own renderer so it cannot show a shape the placement will not produce.
|
|
* Only ever set from markup this app built; nothing here interpolates anything a player typed.
|
|
*/
|
|
const html = el.getAttribute('data-tip-html');
|
|
if (html) {
|
|
t.innerHTML = `<div class="tip-fig">${html}</div>`;
|
|
if (text) {
|
|
const p = document.createElement('div');
|
|
p.className = 'tip-txt';
|
|
p.textContent = text;
|
|
t.appendChild(p);
|
|
}
|
|
} else {
|
|
t.textContent = text;
|
|
}
|
|
t.style.display = 'block';
|
|
|
|
// Measure, then keep it on screen: flip above when it would fall off the bottom, and pull back
|
|
// from the right edge rather than letting the text run off it.
|
|
const r = el.getBoundingClientRect();
|
|
const box = t.getBoundingClientRect();
|
|
const margin = 8;
|
|
let left = r.left + r.width / 2 - box.width / 2;
|
|
left = Math.max(margin, Math.min(left, window.innerWidth - box.width - margin));
|
|
let top = r.bottom + 6;
|
|
if (top + box.height > window.innerHeight - margin) top = r.top - box.height - 6;
|
|
t.style.left = `${Math.round(left + window.scrollX)}px`;
|
|
t.style.top = `${Math.round(top + window.scrollY)}px`;
|
|
}
|
|
|
|
function hide(): void {
|
|
if (tip) tip.style.display = 'none';
|
|
}
|
|
|
|
/**
|
|
* Start listening. Delegated from the document, so markup replaced later — the board redraws on
|
|
* every action — needs no re-binding.
|
|
*/
|
|
export function installTooltips(): void {
|
|
const find = (node: EventTarget | null): HTMLElement | null => {
|
|
let el = node as HTMLElement | null;
|
|
while (el && el !== document.body) {
|
|
// SVG elements have no closest() in older engines, so walk manually.
|
|
if (el.getAttribute && el.getAttribute('data-tip')) return el;
|
|
el = (el.parentNode as HTMLElement) ?? null;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
document.addEventListener('mouseover', (e) => {
|
|
const el = find(e.target);
|
|
if (el) place(el, el.getAttribute('data-tip') ?? '');
|
|
else hide();
|
|
});
|
|
document.addEventListener('mouseout', (e) => {
|
|
if (!find((e as MouseEvent).relatedTarget)) hide();
|
|
});
|
|
document.addEventListener('focusin', (e) => {
|
|
const el = find(e.target);
|
|
if (el) place(el, el.getAttribute('data-tip') ?? '');
|
|
});
|
|
document.addEventListener('focusout', hide);
|
|
document.addEventListener('scroll', hide, true);
|
|
// Escape closes it, which matters when one is pinned open by focus.
|
|
document.addEventListener('keydown', (e) => {
|
|
if ((e as KeyboardEvent).key === 'Escape') hide();
|
|
});
|
|
}
|
|
|
|
/** Styling, kept here so every page that installs tooltips gets the same one. */
|
|
export const TOOLTIP_CSS = `
|
|
#tip{position:absolute;z-index:9999;display:none;max-width:340px;
|
|
background:#0b0e12;color:#e6e9ee;border:1px solid #4d6fa8;border-radius:6px;
|
|
padding:7px 10px;font:12px/1.45 ui-monospace,Menlo,Consolas,monospace;
|
|
box-shadow:0 6px 22px rgba(0,0,0,.55);pointer-events:none;white-space:pre-wrap}
|
|
[data-tip]{cursor:help}
|
|
button[data-tip],a[data-tip]{cursor:pointer}
|
|
/* A drawn piece inside a tooltip. The figure sits above its caption, and the caption keeps the
|
|
tooltip's own text treatment — the drawing is the answer, the words are the confirmation. */
|
|
#tip .tip-fig{display:flex;gap:6px;justify-content:center;margin:1px 0 6px}
|
|
#tip .tip-fig svg{width:132px;height:auto;display:block}
|
|
#tip .tip-txt{white-space:pre-wrap}
|
|
`;
|