openvibers/openvibe.shared ov-tip.js

ov-tip.js

main 6.4 KB · 98 lines · javascript

1/*
2 * openvibe-shared/ov-tip.js — the network's one small tooltip (the owner, 2026-11-10, on the bar's Sign In button:
3 * "Doesn't need the label really, and could have a tooltip … that looks really nice and custom animations"; and on the
4 * footer's count: "if you put your mouse over it, it says 'last 30 minutes' … little tidbits, extra info").
5 *
6 * <button aria-label="Sign in" data-ov-tip="Sign in" data-ov-tip-sub="Chat, follow, keep your things">…</button>
7 *
8 * Anything with data-ov-tip gets it: a small card that grows out of the thing it names, under it (above when there is
9 * no room), kept on the screen, with an optional quiet second line. It shows for a mouse after a short rest and for
10 * the keyboard's focus at once; a press, a scroll, Escape or leaving hides it. A finger never sees it (a touch goes
11 * straight to the action), so nothing may be said ONLY in a tooltip: the element keeps its own name (aria-label or
12 * text). Moving from one to the next within a moment shows the next at once. Calm fades; Off does not move.
13 *
14 * OVTip.show(el) OVTip.hide()
15 */
16(function (root) {
17 'use strict';
18 if (typeof document === 'undefined' || root.OVTip) return;
19 let tip = null, on = null, timer = 0, warmUntil = 0;
20
21 function style() {
22 if (document.getElementById('ov-tip-style')) return;
23 const st = document.createElement('style');
24 st.id = 'ov-tip-style';
25 st.textContent = `
26.ovtip { position: fixed; z-index: 10050; left: 0; top: 0; max-width: min(260px, calc(100vw - 16px)); box-sizing: border-box; padding: 7px 11px 8px; border-radius: 10px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-elevated, var(--bg-tertiary, #172238)); color: var(--text-primary, #e6edf7); font: 600 12.5px/1.3 var(--font-sans, system-ui, sans-serif); box-shadow: 0 10px 26px -10px rgba(0, 0, 0, .65), 0 1px 3px rgba(0, 0, 0, .3); pointer-events: none; opacity: 0; transform: translateY(var(--ovtip-from, -5px)) scale(.94); transform-origin: var(--ovtip-ox, 50%) var(--ovtip-oy, 0); transition: opacity .14s ease, transform .22s cubic-bezier(.2, 1.35, .3, 1); }
27.ovtip.is-on { opacity: 1; transform: none; }
28.ovtip-sub { display: block; margin-top: 2px; font-weight: 500; font-size: 11.5px; color: var(--text-secondary, #96a7c2); }
29.ovtip::before { content: ""; position: absolute; left: var(--ovtip-ax, 50%); top: -5px; width: 8px; height: 8px; margin-left: -4px; background: inherit; border: inherit; border-right: 0; border-bottom: 0; transform: rotate(45deg); border-radius: 2px 0 0 0; }
30.ovtip.is-above::before { top: auto; bottom: -5px; transform: rotate(225deg); }
31html[data-ov-motion=reduced] .ovtip { transform: none; transition: opacity .14s ease; }
32html[data-ov-motion=off] .ovtip { transform: none; transition: none; }
33@media (prefers-reduced-motion: reduce) { html:not([data-ov-motion]) .ovtip { transform: none; transition: opacity .14s ease; } }
34@media print { .ovtip { display: none; } }`;
35 document.head.appendChild(st);
36 }
37
38 function place(el) {
39 const r = el.getBoundingClientRect(), vw = root.innerWidth, vh = root.innerHeight;
40 // Measured from the corner: a fixed box takes its width from the room to its right, and where the last one stood
41 // (near the right edge) there is little.
42 tip.style.left = '0px'; tip.style.top = '0px';
43 const w = tip.offsetWidth, h = tip.offsetHeight, gap = 9;
44 const above = r.bottom + gap + h > vh - 8 && r.top - gap - h >= 8;
45 const cx = r.left + r.width / 2;
46 const left = Math.max(8, Math.min(vw - w - 8, cx - w / 2));
47 const top = above ? r.top - gap - h : r.bottom + gap;
48 const ax = Math.max(12, Math.min(w - 12, cx - left));
49 tip.classList.toggle('is-above', above);
50 tip.style.left = `${Math.round(left)}px`; tip.style.top = `${Math.round(top)}px`;
51 tip.style.setProperty('--ovtip-ax', `${Math.round(ax)}px`); tip.style.setProperty('--ovtip-ox', `${Math.round(ax)}px`);
52 tip.style.setProperty('--ovtip-oy', above ? '100%' : '0'); tip.style.setProperty('--ovtip-from', above ? '5px' : '-5px');
53 }
54
55 function show(el) {
56 if (!el || !el.getAttribute) return;
57 const text = el.getAttribute('data-ov-tip');
58 if (!text || el.getAttribute('aria-expanded') === 'true') return;
59 style();
60 if (!tip) { tip = document.createElement('div'); tip.className = 'ovtip'; tip.setAttribute('aria-hidden', 'true'); document.body.appendChild(tip); }
61 tip.classList.remove('is-on');
62 tip.textContent = text;
63 const sub = el.getAttribute('data-ov-tip-sub');
64 if (sub) { const s = document.createElement('span'); s.className = 'ovtip-sub'; s.textContent = sub; tip.appendChild(s); }
65 on = el;
66 place(el);
67 // The next frame, so the card is seen to grow out of what it names.
68 (root.requestAnimationFrame || setTimeout)(() => { if (on === el && tip) tip.classList.add('is-on'); });
69 }
70
71 function hide() {
72 clearTimeout(timer); timer = 0;
73 if (on) warmUntil = Date.now() + 500;
74 on = null;
75 if (tip) tip.classList.remove('is-on');
76 }
77
78 const named = (t) => (t && t.closest ? t.closest('[data-ov-tip]') : null);
79 document.addEventListener('pointerover', (e) => {
80 if (e.pointerType && e.pointerType !== 'mouse') return;
81 const el = named(e.target);
82 if (!el || el === on) return;
83 clearTimeout(timer);
84 if (Date.now() < warmUntil) show(el); else timer = setTimeout(() => show(el), 320);
85 }, true);
86 document.addEventListener('pointerout', (e) => { const el = named(e.target); if (el && !el.contains(e.relatedTarget)) hide(); }, true);
87 document.addEventListener('focusin', (e) => { const el = named(e.target); if (el && el.matches && el.matches(':focus-visible')) show(el); });
88 document.addEventListener('focusout', hide);
89 document.addEventListener('pointerdown', hide, true);
90 document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
91 root.addEventListener('scroll', hide, { passive: true, capture: true });
92 root.addEventListener('resize', hide, { passive: true });
93
94 root.OVTip = { show, hide };
95 // Fetched by the first pointer over the bar: what that pointer is resting on gets its tooltip.
96 try { const now = document.querySelector('[data-ov-tip]:hover'); if (now) timer = setTimeout(() => show(now), 240); } catch { /* an old engine without :hover here */ }
97}(typeof window !== 'undefined' ? window : globalThis));
98