openvibers/openvibe.shared tooltip.js
tooltip.js
| 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 | ; |
| 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 |