openvibers/openvibe.shared tooltip.js

tooltip.js

main 12.3 KB · 199 lines · javascript

1/**
2 * openvibe-shared/tooltip.js — rich tooltips for every OpenVibe property.
3 *
4 * Markup-driven, so a site never writes tooltip code:
5 *
6 * <a data-ovtip="Plain text tip">…</a>
7 * <a data-ovtip-title="API docs"
8 * data-ovtip="<b>Vibe code it.</b> Point an AI at these docs…"
9 * data-ovtip-icon="fa-wand-magic-sparkles"
10 * data-ovtip-place="top"
11 * data-ovtip-accent="#f472b6">…</a>
12 *
13 * One floating element is reused for every tooltip, positioned with fixed coordinates so it is
14 * never clipped by an overflow:hidden ancestor — the usual reason CSS-only tooltips get cut off
15 * inside cards and scroll containers. It flips to stay on screen, follows the trigger while the
16 * page scrolls, opens on hover, focus and long-press, and closes on Escape, scroll-away or tap
17 * elsewhere. Touch devices get a tap-to-open behaviour instead of hover, which is what makes
18 * these usable on a phone at all.
19 */
20(function () {
21 'use strict';
22 if (typeof window === 'undefined' || window.OpenVibeTooltip) return;
23
24 const CSS = `
25.ovtip{position:fixed;z-index:30000;max-width:min(330px,calc(100vw - 24px));padding:12px 14px;border-radius:14px;
26 background:linear-gradient(165deg,var(--bg-card,#171826) 0%,var(--bg-secondary,#12131c) 100%);
27 border:1px solid color-mix(in srgb, var(--ovtip-accent,var(--accent,#60a5fa)) 45%, var(--border,rgba(255,255,255,.1)));
28 box-shadow:0 20px 50px rgba(0,0,0,.55),0 0 0 1px rgba(255,255,255,.04),0 0 30px color-mix(in srgb, var(--ovtip-accent,var(--accent,#60a5fa)) 22%, transparent);
29 color:var(--text-secondary,#b6bdd2);font-size:.83rem;line-height:1.55;pointer-events:none;opacity:0;
30 transform:translateY(6px) scale(.97);transition:opacity .18s ease,transform .22s cubic-bezier(.2,1.3,.3,1);
31 overflow:hidden}
32.ovtip.is-on{opacity:1;transform:none}
33.ovtip.is-interactive{pointer-events:auto}
34.ovtip::before{content:'';position:absolute;inset:0;pointer-events:none;
35 background:radial-gradient(120% 90% at 0% 0%, color-mix(in srgb, var(--ovtip-accent,var(--accent,#60a5fa)) 16%, transparent), transparent 60%)}
36/* A light sweep on open — draws the eye without moving anything around it. */
37.ovtip::after{content:'';position:absolute;top:0;bottom:0;left:-60%;width:45%;pointer-events:none;
38 background:linear-gradient(100deg,transparent,color-mix(in srgb, var(--ovtip-accent,var(--accent,#60a5fa)) 26%, transparent),transparent)}
39.ovtip.is-on::after{animation:ovtipSweep .9s cubic-bezier(.3,.7,.3,1) .1s 1}
40@keyframes ovtipSweep{to{transform:translateX(420%)}}
41.ovtip-head{display:flex;align-items:center;gap:8px;margin-bottom:6px;position:relative}
42.ovtip-ico{width:26px;height:26px;border-radius:8px;display:grid;place-items:center;flex:none;font-size:.78rem;color:#fff;
43 background:linear-gradient(135deg,var(--ovtip-accent,var(--accent,#60a5fa)),color-mix(in srgb,var(--ovtip-accent,var(--accent,#60a5fa)) 55%,#000));
44 box-shadow:0 4px 12px color-mix(in srgb, var(--ovtip-accent,var(--accent,#60a5fa)) 45%, transparent)}
45.ovtip-title{font-size:.9rem;font-weight:800;color:var(--text-primary,#f1f4fb);letter-spacing:-.01em}
46.ovtip-body{position:relative}
47.ovtip-body b,.ovtip-body strong{color:var(--text-primary,#f1f4fb);font-weight:700}
48.ovtip-body em{color:var(--ovtip-accent,var(--accent,#60a5fa));font-style:normal;font-weight:600}
49.ovtip-body code{background:var(--bg-tertiary,#1a1b28);padding:1px 6px;border-radius:5px;font-size:.78rem}
50.ovtip-foot{margin-top:8px;padding-top:8px;border-top:1px dashed var(--border,rgba(255,255,255,.1));
51 font-size:.74rem;color:var(--text-muted,#8b93ad);display:flex;align-items:center;gap:6px;position:relative}
52.ovtip-arrow{position:fixed;z-index:30001;width:10px;height:10px;transform:rotate(45deg);opacity:0;transition:opacity .18s;
53 background:var(--bg-card,#171826);border:1px solid color-mix(in srgb, var(--ovtip-accent,var(--accent,#60a5fa)) 45%, var(--border,rgba(255,255,255,.1)))}
54.ovtip-arrow.is-on{opacity:1}
55.ovtip-arrow.from-top{border-top:0;border-left:0}
56.ovtip-arrow.from-bottom{border-bottom:0;border-right:0}
57.ovtip-actions{display:flex;gap:8px;margin-top:11px;padding-top:10px;border-top:1px solid var(--border,rgba(255,255,255,.1))}
58.ovtip-go,.ovtip-dismiss{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:7px;
59 padding:9px 12px;border-radius:10px;font:inherit;font-size:.8rem;font-weight:750;cursor:pointer;
60 transition:transform .15s,filter .15s}
61.ovtip-go{color:#fff;border:0;
62 background:linear-gradient(135deg,var(--ovtip-accent,var(--accent,#60a5fa)),color-mix(in srgb,var(--ovtip-accent,var(--accent,#60a5fa)) 60%,#000));
63 box-shadow:0 6px 16px color-mix(in srgb,var(--ovtip-accent,var(--accent,#60a5fa)) 40%,transparent)}
64.ovtip-dismiss{flex:0 0 auto;color:var(--text-muted,#8b93ad);background:none;border:1px solid var(--border,rgba(255,255,255,.12))}
65.ovtip-go:active,.ovtip-dismiss:active{transform:scale(.975)}
66.ovtip-go i{font-size:.72rem}
67/* On a phone a 330px tip pinned beside a button is unreadable and often half off-screen. Dock it
68 to the bottom of the viewport as a full-width card instead. */
69@media (hover:none){
70 .ovtip{position:fixed;left:12px!important;right:12px;top:auto!important;bottom:calc(14px + env(safe-area-inset-bottom,0px));
71 max-width:none;width:auto;padding:14px 16px;border-radius:18px;font-size:.88rem;
72 transform:translateY(14px) scale(1);box-shadow:0 -10px 40px rgba(0,0,0,.55),0 0 0 1px rgba(255,255,255,.05)}
73 .ovtip.is-on{transform:none}
74 .ovtip-arrow{display:none}
75 .ovtip-title{font-size:1rem}
76}
77`;
78
79 let tip = null, arrow = null, current = null, raf = 0, hideTimer = 0;
80 const isTouch = () => window.matchMedia('(hover: none)').matches;
81
82 function ensure() {
83 if (tip) return;
84 const st = document.createElement('style'); st.id = 'ovtip-css'; st.textContent = CSS; document.head.appendChild(st);
85 tip = document.createElement('div'); tip.className = 'ovtip'; tip.setAttribute('role', 'tooltip');
86 arrow = document.createElement('div'); arrow.className = 'ovtip-arrow';
87 document.body.append(arrow, tip);
88 tip.addEventListener('pointerenter', () => clearTimeout(hideTimer));
89 tip.addEventListener('pointerleave', () => hide());
90 }
91
92 function content(el) {
93 const title = el.getAttribute('data-ovtip-title');
94 const icon = el.getAttribute('data-ovtip-icon');
95 const body = el.getAttribute('data-ovtip') || '';
96 const foot = el.getAttribute('data-ovtip-foot');
97 // On touch the first tap opens the tip and swallows the click, so without an explicit
98 // action the visitor is left tapping a button that appears to do nothing. Give them the
99 // destination as a real button inside the tip — and a way out that isn't "tap elsewhere".
100 const act = isTouch() ? `<div class="ovtip-actions">
101 <button type="button" class="ovtip-dismiss" data-ovtip-dismiss>Got it</button>
102 <button type="button" class="ovtip-go" data-ovtip-go>${esc(el.getAttribute('data-ovtip-go') || 'Open')}<i class="fa-solid fa-arrow-right"></i></button>
103 </div>` : '';
104 return `${title ? `<div class="ovtip-head">${icon ? `<span class="ovtip-ico"><i class="fa-solid ${icon}"></i></span>` : ''}<span class="ovtip-title">${title}</span></div>` : ''}
105 <div class="ovtip-body">${body}</div>
106 ${foot ? `<div class="ovtip-foot">${foot}</div>` : ''}${act}`;
107 }
108 const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
109
110 function place() {
111 if (!current || !tip) return;
112 if (!current.isConnected) return hide();
113 // Docked to the bottom on touch — CSS owns the position, and following the trigger would
114 // fight it (and hide the tip the moment the page scrolls a little).
115 if (isTouch()) { arrow.classList.remove('is-on'); return; }
116 const r = current.getBoundingClientRect();
117 // Off-screen trigger: nothing to point at.
118 if (r.bottom < -40 || r.top > window.innerHeight + 40) return hide();
119 const tw = tip.offsetWidth, th = tip.offsetHeight, gap = 12;
120 const prefer = current.getAttribute('data-ovtip-place') || 'top';
121 let top, from;
122 const fitsAbove = r.top - th - gap > 8;
123 const fitsBelow = r.bottom + th + gap < window.innerHeight - 8;
124 if ((prefer === 'top' && fitsAbove) || (!fitsBelow && fitsAbove)) { top = r.top - th - gap; from = 'from-bottom'; }
125 else { top = r.bottom + gap; from = 'from-top'; }
126 let left = r.left + r.width / 2 - tw / 2;
127 left = Math.max(12, Math.min(left, window.innerWidth - tw - 12));
128 tip.style.top = `${Math.round(top)}px`;
129 tip.style.left = `${Math.round(left)}px`;
130 const ax = Math.max(left + 14, Math.min(r.left + r.width / 2 - 5, left + tw - 24));
131 arrow.className = `ovtip-arrow is-on ${from}`;
132 arrow.style.left = `${Math.round(ax)}px`;
133 arrow.style.top = `${Math.round(from === 'from-top' ? top - 5 : top + th - 5)}px`;
134 raf = requestAnimationFrame(place);
135 }
136
137 function show(el) {
138 ensure();
139 clearTimeout(hideTimer);
140 if (current === el && tip.classList.contains('is-on')) return;
141 current = el;
142 tip.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');
143 arrow.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');
144 tip.innerHTML = content(el);
145 tip.classList.toggle('is-interactive', el.hasAttribute('data-ovtip-interactive') || isTouch());
146 tip.classList.remove('is-on'); arrow.classList.remove('is-on');
147 cancelAnimationFrame(raf);
148 place();
149 requestAnimationFrame(() => { tip.classList.add('is-on'); arrow.classList.add('is-on'); });
150 el.setAttribute('aria-describedby', 'ovtip');
151 }
152
153 function hide() {
154 if (!tip) return;
155 cancelAnimationFrame(raf);
156 tip.classList.remove('is-on'); arrow.classList.remove('is-on');
157 if (current) current.removeAttribute('aria-describedby');
158 current = null;
159 }
160 const hideSoon = () => { clearTimeout(hideTimer); hideTimer = setTimeout(hide, 120); };
161
162 function trigger(e) { return e.target.closest && e.target.closest('[data-ovtip]'); }
163
164 function bind() {
165 const fromTouch = (e) => e.pointerType === 'touch' || (!e.pointerType && isTouch());
166 document.addEventListener('pointerover', (e) => { if (fromTouch(e)) return; const t = trigger(e); if (t) show(t); });
167 document.addEventListener('pointerout', (e) => { if (fromTouch(e)) return; if (trigger(e)) hideSoon(); });
168 document.addEventListener('focusin', (e) => { const t = trigger(e); if (t) show(t); });
169 document.addEventListener('focusout', (e) => { if (trigger(e)) hideSoon(); });
170 // Touch: first tap opens the tip, a tap elsewhere closes it. The trigger still works —
171 // we never swallow the click, so a tap on an already-open tip follows the link.
172 document.addEventListener('click', (e) => {
173 const t = trigger(e);
174 if (!t) return hide();
175 // On touch the first tap opens the tip; a second tap follows the link.
176 if (isTouch() && current !== t) { e.preventDefault(); show(t); }
177 }, true);
178 // The touch action buttons.
179 document.addEventListener('click', (e) => {
180 const dismiss = e.target.closest && e.target.closest('[data-ovtip-dismiss]');
181 if (dismiss) { e.preventDefault(); e.stopPropagation(); hide(); return; }
182 const go = e.target.closest && e.target.closest('[data-ovtip-go]');
183 if (go && current) {
184 e.preventDefault(); e.stopPropagation();
185 const target = current;
186 hide();
187 // Replay the trigger's own behaviour: its click handler, then its href.
188 if (typeof target.click === 'function') target.click();
189 }
190 }, true);
191 document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
192 window.addEventListener('scroll', () => { if (current && isTouch()) hide(); }, { passive: true });
193 window.addEventListener('resize', hide, { passive: true });
194 }
195
196 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bind); else bind();
197 window.OpenVibeTooltip = { show, hide, ensure };
198})();
199