openvibers/openvibe.shared ui.js
ui.js
| 1 | /** |
| 2 | * openvibe-shared/ui.js — one toast / confirm / alert implementation for every OpenVibe site. |
| 3 | * |
| 4 | * OpenVibeUI.toast('Saved', { type: 'success' }) |
| 5 | * OpenVibeUI.toast('Upload failed', { type: 'error', title: 'Images', action: { label: 'Retry', onClick } }) |
| 6 | * if (await OpenVibeUI.confirm({ title: 'Delete paste?', message: 'This cannot be undone.', danger: true })) … |
| 7 | * await OpenVibeUI.alert({ title: 'Heads up', message: '…' }) |
| 8 | * OpenVibeUI.notice({ id: 'yt-upstream', type: 'warning', title: '…', message: '…', links: [{ label, href }], dismissible: true }) |
| 9 | * a page-level notice that always sits directly BELOW the site's navbar (never above it), one |
| 10 | * design for service status, maintenance, read-only mode and similar. Returns { close, update }. |
| 11 | * |
| 12 | * Themed from the site's CSS variables, stacked bottom-right (bottom-centre on phones), announced |
| 13 | * to assistive tech (role=status, role=alert for errors), pause-on-hover, Escape closes dialogs, |
| 14 | * focus is trapped in dialogs and restored afterwards. Text is always set with textContent. |
| 15 | */ |
| 16 | (function (root) { |
| 17 | ; |
| 18 | if (root.OpenVibeUI) return; |
| 19 | if (typeof document === 'undefined') { const noop = { toast() { return { close() {} }; }, notice() { return { close() {}, update() {} }; }, confirm: async () => false, alert: async () => {} }; if (typeof module !== 'undefined') module.exports = noop; return; } |
| 20 | |
| 21 | const CSS = ` |
| 22 | .ovui-toasts{position:fixed;right:16px;bottom:16px;z-index:2147483000;display:flex;flex-direction:column;gap:8px;max-width:min(380px,calc(100vw - 24px));pointer-events:none} |
| 23 | .ovui-toast{pointer-events:auto;display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:start;padding:11px 12px;border-radius:12px;background:var(--bg-elevated,var(--bg-secondary,#131a26));color:var(--text-primary,#e6edf7);border:1px solid var(--border,rgba(255,255,255,.1));box-shadow:0 10px 30px rgba(0,0,0,.35);font:500 13.5px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;animation:ovuiIn .28s cubic-bezier(.2,1.2,.3,1);border-left:3px solid var(--ovui-c,var(--accent,#3b82f6))} |
| 24 | .ovui-toast[data-type=success]{--ovui-c:var(--success,#22c55e)} |
| 25 | .ovui-toast[data-type=error]{--ovui-c:var(--danger,#ef4444)} |
| 26 | .ovui-toast[data-type=warning]{--ovui-c:var(--warning,#f59e0b)} |
| 27 | .ovui-toast.is-out{animation:ovuiOut .2s ease forwards} |
| 28 | .ovui-dot{width:8px;height:8px;border-radius:50%;background:var(--ovui-c,var(--accent,#3b82f6));margin-top:6px} |
| 29 | .ovui-title{font-weight:700;margin-bottom:1px} |
| 30 | .ovui-msg{color:var(--text-secondary,#a8b3c4);overflow-wrap:anywhere} |
| 31 | .ovui-title+.ovui-msg{font-weight:400} |
| 32 | .ovui-toast .ovui-msg:first-child{color:inherit} |
| 33 | .ovui-x,.ovui-act{appearance:none;border:0;background:none;color:inherit;font:inherit;cursor:pointer;border-radius:8px} |
| 34 | .ovui-x{opacity:.55;padding:2px 6px;line-height:1;font-size:16px} |
| 35 | .ovui-x:hover{opacity:1} |
| 36 | .ovui-act{margin-top:6px;padding:4px 10px;font-weight:700;color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);text-decoration:none;display:inline-block} |
| 37 | .ovui-back{position:fixed;inset:0;z-index:2147483100;background:rgba(3,6,12,.6);backdrop-filter:blur(3px);display:grid;place-items:center;padding:16px;animation:ovuiFade .15s ease} |
| 38 | .ovui-dialog{width:min(420px,100%);background:var(--bg-elevated,var(--bg-secondary,#131a26));color:var(--text-primary,#e6edf7);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:16px;padding:20px;box-shadow:0 24px 60px rgba(0,0,0,.5);font:400 14.5px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;animation:ovuiIn .22s cubic-bezier(.2,1.2,.3,1)} |
| 39 | .ovui-dialog h2{margin:0 0 6px;font-size:17px;font-weight:700} |
| 40 | .ovui-dialog p{margin:0;color:var(--text-secondary,#a8b3c4);white-space:pre-wrap;overflow-wrap:anywhere} |
| 41 | .ovui-btns{display:flex;justify-content:flex-end;gap:8px;margin-top:18px} |
| 42 | .ovui-btn{appearance:none;border:1px solid var(--border,rgba(255,255,255,.14));background:transparent;color:inherit;font:600 14px/1 system-ui,sans-serif;padding:10px 16px;border-radius:10px;cursor:pointer} |
| 43 | .ovui-btn:hover{background:rgba(127,127,127,.12)} |
| 44 | .ovui-btn.is-primary{background:var(--accent-strong,var(--accent,#3472d8));border-color:transparent;color:var(--on-accent-strong,var(--on-accent,#fff))} |
| 45 | .ovui-btn.is-danger{background:var(--danger,#ef4444);border-color:transparent;color:#fff} |
| 46 | .ovui-btn:focus-visible,.ovui-x:focus-visible,.ovui-act:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px} |
| 47 | .ovui-notices{display:flex;flex-direction:column;gap:8px;max-width:1080px;margin:14px auto 0;padding:0 16px;box-sizing:border-box;width:100%} |
| 48 | .ovui-notices:empty{display:none} |
| 49 | .ovui-notice{--ovui-c:var(--accent,#3b82f6);display:grid;grid-template-columns:auto 1fr auto;gap:12px;align-items:start;padding:12px 14px;border-radius:14px;border:1px solid color-mix(in srgb,var(--ovui-c) 40%,transparent);background:color-mix(in srgb,var(--ovui-c) 9%,var(--bg-secondary,#111826));color:var(--text-primary,#e6edf7);font:400 14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;animation:ovuiIn .28s cubic-bezier(.2,1.2,.3,1)} |
| 50 | .ovui-notice[data-type=success]{--ovui-c:var(--success,#22c55e)}.ovui-notice[data-type=error]{--ovui-c:var(--danger,#ef4444)}.ovui-notice[data-type=warning]{--ovui-c:var(--warning,#f59e0b)} |
| 51 | .ovui-notice-ic{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;background:var(--ovui-c);color:#0b1220;font:800 13px/1 system-ui,sans-serif;margin-top:1px} |
| 52 | .ovui-notice b{font-weight:700}.ovui-notice-msg{color:var(--text-secondary,#a8b3c4)} |
| 53 | .ovui-notice-links{display:flex;flex-wrap:wrap;gap:6px 14px;margin-top:6px}.ovui-notice-links a{color:var(--accent-light,var(--accent,#60a5fa));font-weight:600;text-decoration:none}.ovui-notice-links a:hover{text-decoration:underline} |
| 54 | @media (max-width:560px){.ovui-notices{margin-top:10px;padding:0 12px}} |
| 55 | @keyframes ovuiIn{from{opacity:0;transform:translateY(10px) scale(.97)}to{opacity:1;transform:none}} |
| 56 | @keyframes ovuiOut{to{opacity:0;transform:translateX(16px)}} |
| 57 | @keyframes ovuiFade{from{opacity:0}to{opacity:1}} |
| 58 | @media (max-width:560px){.ovui-toasts{left:12px;right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px));max-width:none}} |
| 59 | `; |
| 60 | |
| 61 | function css() { if (document.getElementById('ovui-css')) return; const s = document.createElement('style'); s.id = 'ovui-css'; s.textContent = CSS; document.head.appendChild(s); } |
| 62 | function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = String(text); return e; } |
| 63 | function safeHref(h) { try { const u = new URL(h, location.href); return /^https?:$/.test(u.protocol) ? u.href : null; } catch { return null; } } |
| 64 | |
| 65 | let stack = null; |
| 66 | const MAX = 4; |
| 67 | |
| 68 | function toast(message, opts) { |
| 69 | const o = opts || {}; |
| 70 | css(); |
| 71 | if (!stack || !stack.isConnected) { stack = el('div', 'ovui-toasts'); stack.setAttribute('aria-live', 'polite'); document.body.appendChild(stack); } |
| 72 | const type = ['info', 'success', 'error', 'warning'].includes(o.type) ? o.type : 'info'; |
| 73 | const t = el('div', 'ovui-toast'); t.dataset.type = type; t.setAttribute('role', type === 'error' ? 'alert' : 'status'); |
| 74 | t.appendChild(el('span', 'ovui-dot')); |
| 75 | const body = el('div'); |
| 76 | if (o.title) body.appendChild(el('div', 'ovui-title', o.title)); |
| 77 | body.appendChild(el('div', 'ovui-msg', message)); |
| 78 | if (o.action && o.action.label) { |
| 79 | const href = o.action.href && safeHref(o.action.href); |
| 80 | const a = el(href ? 'a' : 'button', 'ovui-act', o.action.label); |
| 81 | if (href) a.href = href; else a.type = 'button'; |
| 82 | a.addEventListener('click', (ev) => { try { o.action.onClick && o.action.onClick(ev); } finally { close(); } }); |
| 83 | body.appendChild(a); |
| 84 | } |
| 85 | t.appendChild(body); |
| 86 | const x = el('button', 'ovui-x', '×'); x.type = 'button'; x.setAttribute('aria-label', 'Dismiss'); x.addEventListener('click', () => close()); t.appendChild(x); |
| 87 | stack.appendChild(t); |
| 88 | while (stack.children.length > MAX) stack.firstChild.remove(); |
| 89 | |
| 90 | const ttl = o.ttl === 0 ? 0 : (o.ttl || (type === 'error' ? 8000 : 4500)); |
| 91 | let timer = null, closed = false; |
| 92 | const arm = () => { if (ttl) timer = setTimeout(close, ttl); }; |
| 93 | function close() { if (closed) return; closed = true; clearTimeout(timer); t.classList.add('is-out'); setTimeout(() => t.remove(), 220); } |
| 94 | t.addEventListener('mouseenter', () => clearTimeout(timer)); t.addEventListener('mouseleave', arm); |
| 95 | arm(); |
| 96 | return { close, el: t }; |
| 97 | } |
| 98 | |
| 99 | function dialog(o, withCancel) { |
| 100 | css(); |
| 101 | return new Promise((resolve) => { |
| 102 | const prev = document.activeElement; |
| 103 | const back = el('div', 'ovui-back'); |
| 104 | const d = el('div', 'ovui-dialog'); d.setAttribute('role', withCancel ? 'alertdialog' : 'dialog'); d.setAttribute('aria-modal', 'true'); |
| 105 | const h = el('h2', null, o.title || (withCancel ? 'Are you sure?' : 'Notice')); h.id = 'ovui-h-' + Date.now(); d.setAttribute('aria-labelledby', h.id); d.appendChild(h); |
| 106 | if (o.message) d.appendChild(el('p', null, o.message)); |
| 107 | const btns = el('div', 'ovui-btns'); |
| 108 | let cancel = null; |
| 109 | if (withCancel) { cancel = el('button', 'ovui-btn', o.cancelLabel || 'Cancel'); cancel.type = 'button'; btns.appendChild(cancel); } |
| 110 | const ok = el('button', 'ovui-btn ' + (o.danger ? 'is-danger' : 'is-primary'), o.confirmLabel || (withCancel ? 'Confirm' : 'OK')); ok.type = 'button'; btns.appendChild(ok); |
| 111 | d.appendChild(btns); back.appendChild(d); document.body.appendChild(back); |
| 112 | function done(v) { document.removeEventListener('keydown', onKey, true); back.remove(); try { prev && prev.focus && prev.focus(); } catch { /* */ } resolve(v); } |
| 113 | function onKey(e) { |
| 114 | if (e.key === 'Escape') { e.preventDefault(); done(false); } |
| 115 | else if (e.key === 'Tab') { const f = [cancel, ok].filter(Boolean); const i = f.indexOf(document.activeElement); e.preventDefault(); f[(i + (e.shiftKey ? f.length - 1 : 1)) % f.length].focus(); } |
| 116 | } |
| 117 | document.addEventListener('keydown', onKey, true); |
| 118 | back.addEventListener('mousedown', (e) => { if (e.target === back) done(false); }); |
| 119 | ok.addEventListener('click', () => done(true)); |
| 120 | if (cancel) cancel.addEventListener('click', () => done(false)); |
| 121 | (o.danger && cancel ? cancel : ok).focus(); |
| 122 | }); |
| 123 | } |
| 124 | |
| 125 | // ── Page notices ───────────────────────────────────────── |
| 126 | const notices = new Map(); |
| 127 | let noticeHost = null; |
| 128 | function placeNoticeHost() { |
| 129 | if (!noticeHost) { noticeHost = el('div', 'ovui-notices'); noticeHost.setAttribute('aria-live', 'polite'); } |
| 130 | // Directly after the navbar, whichever flavour the site uses; otherwise at the top of the content. |
| 131 | const nav = document.querySelector('nav.openvibe-navbar, #navbar-mount, .navbar, header[role="banner"]'); |
| 132 | const anchor = nav ? (nav.id === 'navbar-mount' || nav.parentNode === document.body ? nav : nav) : null; |
| 133 | if (anchor && anchor.parentNode) { if (anchor.nextSibling !== noticeHost) anchor.parentNode.insertBefore(noticeHost, anchor.nextSibling); return true; } |
| 134 | const main = document.querySelector('main') || document.body.firstElementChild; |
| 135 | if (!noticeHost.isConnected && main && main.parentNode) main.parentNode.insertBefore(noticeHost, main); |
| 136 | return false; |
| 137 | } |
| 138 | function notice(o) { |
| 139 | o = o || {}; css(); |
| 140 | const id = String(o.id || 'n' + Date.now()); |
| 141 | try { if (o.dismissible !== false && sessionStorage.getItem('ovui_notice_' + id) === '1') return { close() {}, update() {} }; } catch { /* */ } |
| 142 | if (notices.has(id)) notices.get(id).close(true); |
| 143 | const type = ['info', 'success', 'error', 'warning'].includes(o.type) ? o.type : 'info'; |
| 144 | const n = el('div', 'ovui-notice'); n.dataset.type = type; n.setAttribute('role', type === 'error' ? 'alert' : 'status'); |
| 145 | n.appendChild(el('span', 'ovui-notice-ic', type === 'success' ? '✓' : type === 'info' ? 'i' : '!')); |
| 146 | const body = el('div'); |
| 147 | const fill = (p) => { |
| 148 | body.replaceChildren(); |
| 149 | const line = el('div'); if (p.title) { line.appendChild(el('b', null, p.title)); line.appendChild(document.createTextNode(' ')); } |
| 150 | if (p.message) line.appendChild(el('span', 'ovui-notice-msg', p.message)); body.appendChild(line); |
| 151 | const links = (p.links || []).map((l) => { const h = l && l.label && safeHref(l.href); if (!h) return null; const a = el('a', null, l.label); a.href = h; return a; }).filter(Boolean); |
| 152 | if (links.length) { const row = el('div', 'ovui-notice-links'); links.forEach((a) => row.appendChild(a)); body.appendChild(row); } |
| 153 | }; |
| 154 | fill(o); n.appendChild(body); |
| 155 | const close = (silent) => { n.remove(); notices.delete(id); if (!silent && o.dismissible !== false) { try { sessionStorage.setItem('ovui_notice_' + id, '1'); } catch { /* */ } } }; |
| 156 | if (o.dismissible !== false) { const x = el('button', 'ovui-x', '×'); x.type = 'button'; x.setAttribute('aria-label', 'Dismiss'); x.addEventListener('click', () => close(false)); n.appendChild(x); } |
| 157 | // The navbar may mount after us: keep trying briefly until the notice sits under it. |
| 158 | let tries = 0; const settle = () => { const ok = placeNoticeHost(); if (!ok && ++tries < 40) setTimeout(settle, 150); }; settle(); |
| 159 | noticeHost.appendChild(n); |
| 160 | const handle = { close: () => close(true), update: (p) => fill(Object.assign({}, o, p)), el: n }; |
| 161 | notices.set(id, handle); |
| 162 | return handle; |
| 163 | } |
| 164 | |
| 165 | const api = { |
| 166 | toast, notice, |
| 167 | success: (m, o) => toast(m, Object.assign({}, o, { type: 'success' })), |
| 168 | error: (m, o) => toast(m, Object.assign({}, o, { type: 'error' })), |
| 169 | warning: (m, o) => toast(m, Object.assign({}, o, { type: 'warning' })), |
| 170 | confirm: (o) => dialog(typeof o === 'string' ? { message: o } : (o || {}), true), |
| 171 | alert: (o) => dialog(typeof o === 'string' ? { message: o } : (o || {}), false).then(() => undefined), |
| 172 | }; |
| 173 | root.OpenVibeUI = api; |
| 174 | if (typeof module !== 'undefined' && module.exports) module.exports = api; |
| 175 | })(typeof window !== 'undefined' ? window : globalThis); |
| 176 |