openvibers/openvibe.shared ov-tip.js
ov-tip.js
| 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 | ; |
| 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); } |
| 31 | html[data-ov-motion=reduced] .ovtip { transform: none; transition: opacity .14s ease; } |
| 32 | html[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 |