openvibers/openvibe.shared ov-searchbox.js
ov-searchbox.js
| 1 | /** |
| 2 | * ov-searchbox.js (openvibe-shared 4.229.0): one search field for every site. The owner, 11-12: "improve the search |
| 3 | * field … autocomplete, dropdown, animated effects … a shared, universal, reusable search input component across all |
| 4 | * sites". Its list is the one dropdown (ov-combo.js, loaded from beside this file), so it reads and moves like every |
| 5 | * other list on OpenVibe. |
| 6 | * |
| 7 | * <form action="/search" role="search"> |
| 8 | * <input name="q" data-ov-searchbox="/api/search/suggest" data-ov-examples="streams|clips|people"> |
| 9 | * </form> |
| 10 | * |
| 11 | * Without script it is the site's own form. With it: |
| 12 | * - a pill: the search mark, the field, a clear button and, on a computer, a "/" key hint; its edge glows with focus; |
| 13 | * - the placeholder walks through the examples ("Search streams", "Search clips"…) while the field is empty and idle |
| 14 | * (Calm walks slower; motion Off keeps the first); |
| 15 | * - as you type, suggestions from the source: GET <source>?q=&limit=8 → { sections: [{ id, label, items: [{ label, url, |
| 16 | * sub, icon, image }] }] } (OVCombo's shape; same origin, the page's cookies); while the field is empty, the person's |
| 17 | * recent searches here (this browser only; data-ov-recent="off" keeps none); |
| 18 | * - the first suggestion that starts with what was typed shows the rest as a ghost: Tab, or → at the end, takes it; |
| 19 | * - Enter searches what was typed: the form's action with ?<name>=…, or the row the arrows chose (its url); "/" anywhere |
| 20 | * on the page (outside another field) puts the cursor in the first one; Esc closes the list, then clears the field. |
| 21 | * |
| 22 | * A site with its own data or its own search (an SPA): |
| 23 | * OVSearchBox.mount(input, { source: async (q, signal) => ({ sections }), examples: ['streams', 'clips'], scope, |
| 24 | * recent: true, onSearch(q, item) { … return false to stop the page from moving } }) → { input, set(q), focus(), destroy() } |
| 25 | * Events on the input: 'ov-search' { detail: { q, item } }, cancelable: a page that searches in place calls preventDefault. |
| 26 | */ |
| 27 | (function (root) { |
| 28 | ; |
| 29 | if (typeof document === 'undefined' || root.OVSearchBox) return; |
| 30 | |
| 31 | // The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs. |
| 32 | const SEARCHBOX_REV = { 'ov-combo.js': '8001a3b19c2f' }; |
| 33 | const self = document.currentScript && document.currentScript.src; |
| 34 | const base = (self && /^(.*\/)ov-searchbox\.js/.exec(self)) ? /^(.*\/)ov-searchbox\.js/.exec(self)[1] : 'https://openvibe.network/shared/'; |
| 35 | |
| 36 | const svg = (inner, w = 18) => `<svg viewBox="0 0 24 24" width="${w}" height="${w}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`; |
| 37 | const IC = { search: svg('<circle cx="11" cy="11" r="6.5"/><path d="m16 16 4 4"/>'), clear: svg('<path d="M7 7l10 10M17 7 7 17"/>', 16) }; |
| 38 | const CSS = `.ovsb { position: relative; display: flex; align-items: center; gap: 4px; box-sizing: border-box; min-height: 44px; padding: 0 6px 0 12px; border-radius: 999px; |
| 39 | background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); color: var(--text-primary, #e6edf7); |
| 40 | transition: border-color .18s ease, box-shadow .25s ease, background-color .18s ease; } |
| 41 | .ovsb:hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 35%, var(--border, rgba(255,255,255,.12))); } |
| 42 | .ovsb.is-focus { border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--text-primary, #e6edf7) 3%, var(--bg-secondary, #111826)); |
| 43 | box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent), 0 8px 26px color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); } |
| 44 | .ovsb-mark { flex: none; display: grid; place-items: center; width: 24px; height: 24px; color: var(--text-muted, #93a0b4); transition: color .18s ease, transform .3s cubic-bezier(.2, 1.4, .4, 1); } |
| 45 | .ovsb.is-focus .ovsb-mark { color: var(--accent-light, var(--accent, #60a5fa)); transform: scale(1.08) rotate(-8deg); } |
| 46 | .ovsb.is-busy .ovsb-mark { animation: ovsb-look 1s ease-in-out infinite; } |
| 47 | .ovsb-field { position: relative; flex: 1; min-width: 0; display: grid; } |
| 48 | .ovsb-field > input, .ovsb-ghost { grid-area: 1 / 1; box-sizing: border-box; width: 100%; min-width: 0; margin: 0; padding: 10px 4px; border: 0; font: inherit; font-size: 16px; line-height: 22px; letter-spacing: inherit; } |
| 49 | .ovsb-field > input { position: relative; z-index: 1; background: transparent; color: inherit; outline: none; -webkit-appearance: none; appearance: none; } |
| 50 | .ovsb-field > input::-webkit-search-cancel-button, .ovsb-field > input::-webkit-search-decoration { -webkit-appearance: none; } |
| 51 | .ovsb-field > input::placeholder { color: var(--text-muted, #93a0b4); opacity: 1; transition: opacity .22s ease, transform .22s ease; } |
| 52 | .ovsb.is-swap .ovsb-field > input::placeholder { opacity: 0; } |
| 53 | .ovsb-ghost { pointer-events: none; overflow: hidden; white-space: pre; color: transparent; } |
| 54 | .ovsb-ghost i { font-style: normal; color: var(--text-muted, #93a0b4); opacity: .7; } |
| 55 | .ovsb-clear, .ovsb-key { flex: none; display: grid; place-items: center; } |
| 56 | .ovsb-clear { width: 34px; height: 34px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--text-muted, #93a0b4); cursor: pointer; animation: ovsb-pop .18s cubic-bezier(.2, 1.4, .4, 1) both; } |
| 57 | .ovsb-clear:hover, .ovsb-clear:focus-visible { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 10%, transparent); outline: none; } |
| 58 | .ovsb-clear[hidden], .ovsb-key[hidden] { display: none; } |
| 59 | .ovsb-key { min-width: 22px; height: 22px; margin-right: 6px; padding: 0 6px; box-sizing: border-box; border-radius: 6px; border: 1px solid var(--border, rgba(255,255,255,.16)); |
| 60 | font: 700 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text-muted, #93a0b4); } |
| 61 | @media (hover: none), (pointer: coarse) { .ovsb-key { display: none; } } |
| 62 | @keyframes ovsb-pop { from { opacity: 0; transform: scale(.6); } } |
| 63 | @keyframes ovsb-look { 50% { transform: translateX(2px) rotate(-14deg); } } |
| 64 | html[data-ov-motion=calm] .ovsb, html[data-ov-motion=calm] .ovsb-mark { transition-duration: .3s; } |
| 65 | html[data-ov-motion=off] .ovsb, html[data-ov-motion=off] .ovsb-mark, html[data-ov-motion=off] .ovsb-field > input::placeholder { transition: none; } |
| 66 | html[data-ov-motion=off] .ovsb-clear, html[data-ov-motion=off] .ovsb.is-busy .ovsb-mark { animation: none; } |
| 67 | html[data-ov-motion=off] .ovsb.is-focus .ovsb-mark { transform: none; }`; |
| 68 | |
| 69 | function styles() { if (document.getElementById('ovsb-styles')) return; const st = document.createElement('style'); st.id = 'ovsb-styles'; st.textContent = CSS; document.head.appendChild(st); } |
| 70 | function el(tag, cls) { const n = document.createElement(tag); if (cls) n.className = cls; return n; } |
| 71 | const motion = () => document.documentElement.getAttribute('data-ov-motion') || ''; |
| 72 | const editable = (t) => !!(t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))); |
| 73 | |
| 74 | let comboWait = null; |
| 75 | function combo() { |
| 76 | if (root.OVCombo) return Promise.resolve(root.OVCombo); |
| 77 | if (comboWait) return comboWait; |
| 78 | comboWait = new Promise((resolve) => { |
| 79 | let s = document.querySelector('script[data-ov-combo-loader]'); |
| 80 | if (!s) { |
| 81 | s = document.createElement('script'); |
| 82 | s.src = `${base}ov-combo.js${SEARCHBOX_REV['ov-combo.js'] ? `?v=${SEARCHBOX_REV['ov-combo.js']}` : ''}`; |
| 83 | s.async = true; s.setAttribute('data-ov-combo-loader', ''); |
| 84 | document.head.appendChild(s); |
| 85 | } |
| 86 | s.addEventListener('load', () => resolve(root.OVCombo || null)); |
| 87 | s.addEventListener('error', () => resolve(null)); |
| 88 | }); |
| 89 | return comboWait; |
| 90 | } |
| 91 | |
| 92 | const mounted = []; |
| 93 | |
| 94 | function mount(input, opts) { |
| 95 | if (!input || input.__ovsb) return input && input.__ovsb; |
| 96 | styles(); |
| 97 | const o = Object.assign({}, opts); |
| 98 | const form = input.form || null; |
| 99 | const name = input.name || 'q'; |
| 100 | const srcAttr = input.getAttribute('data-ov-searchbox'); |
| 101 | const source = o.source || (srcAttr && srcAttr !== 'true' && srcAttr !== '' ? srcAttr : null); |
| 102 | const examples = (o.examples || String(input.getAttribute('data-ov-examples') || '').split('|')).map((x) => String(x).trim()).filter(Boolean).slice(0, 12); |
| 103 | const keepRecent = o.recent !== false && input.getAttribute('data-ov-recent') !== 'off'; |
| 104 | const scope = String(o.scope || input.getAttribute('data-ov-scope') || location.host).slice(0, 80); |
| 105 | const key = `ov-sb:${scope}`; |
| 106 | const words = input.getAttribute('placeholder') || 'Search'; |
| 107 | |
| 108 | // The pill around the field (the field itself stays where the page put it: its form, its name, its value). |
| 109 | const pill = el('div', 'ovsb'); |
| 110 | const mark = el('span', 'ovsb-mark'); mark.innerHTML = IC.search; |
| 111 | const fieldBox = el('span', 'ovsb-field'); |
| 112 | const ghost = el('span', 'ovsb-ghost'); ghost.setAttribute('aria-hidden', 'true'); |
| 113 | const clear = el('button', 'ovsb-clear'); clear.type = 'button'; clear.innerHTML = IC.clear; clear.setAttribute('aria-label', 'Clear the search'); clear.hidden = !input.value; |
| 114 | const hint = el('kbd', 'ovsb-key'); hint.textContent = '/'; hint.title = 'Press / to search'; |
| 115 | input.parentNode.insertBefore(pill, input); |
| 116 | fieldBox.append(input, ghost); |
| 117 | pill.append(mark, fieldBox, clear, hint); |
| 118 | if (input.type !== 'search') { try { input.type = 'search'; } catch { /* the type it had */ } } |
| 119 | input.setAttribute('autocomplete', 'off'); |
| 120 | input.setAttribute('enterkeyhint', 'search'); |
| 121 | input.setAttribute('spellcheck', 'false'); |
| 122 | if (!input.getAttribute('aria-label')) input.setAttribute('aria-label', words); |
| 123 | if (form && !form.getAttribute('role')) form.setAttribute('role', 'search'); |
| 124 | |
| 125 | // Recent searches, in this browser, per site (or per scope a site names). |
| 126 | function recents() { try { const v = JSON.parse(localStorage.getItem(key) || '[]'); return Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x).slice(0, 8) : []; } catch { return []; } } |
| 127 | function remember(q) { |
| 128 | if (!keepRecent || !q) return; |
| 129 | try { localStorage.setItem(key, JSON.stringify([q, ...recents().filter((x) => x.toLowerCase() !== q.toLowerCase())].slice(0, 8))); } catch { /* private mode */ } |
| 130 | } |
| 131 | function searchUrl(q) { |
| 132 | const action = form ? (form.getAttribute('action') || location.pathname) : (o.action || '/search'); |
| 133 | const u = new URL(action, location.href); |
| 134 | u.searchParams.set(name, q); |
| 135 | return u.href; |
| 136 | } |
| 137 | |
| 138 | // What the list shows: recent searches while empty; the source's answer while typing (recent ones that match lead). |
| 139 | const answers = new Map(); // the source's answers, a minute each (the recent searches are read fresh every time) |
| 140 | async function list(q, signal) { |
| 141 | const mine = recents(); |
| 142 | if (!q) return { sections: mine.length ? [{ id: 'recent', label: 'Recent searches', items: mine.map((x) => ({ label: x, icon: 'recent', recent: true })) }] : [] }; |
| 143 | const lq = q.toLowerCase(); |
| 144 | const again = mine.filter((x) => x.toLowerCase().startsWith(lq) && x.toLowerCase() !== lq).slice(0, 3).map((x) => ({ label: x, icon: 'recent', recent: true })); |
| 145 | let sections = []; |
| 146 | const kept = answers.get(q); |
| 147 | const fresh = !!(kept && Date.now() - kept.at < 60000); |
| 148 | if (fresh) sections = kept.sections; |
| 149 | else if (typeof source === 'function') sections = ((await source(q, signal)) || {}).sections || []; |
| 150 | else if (source) { |
| 151 | const u = new URL(source, location.href); u.searchParams.set('q', q); u.searchParams.set('limit', '8'); |
| 152 | pill.classList.add('is-busy'); |
| 153 | try { |
| 154 | const r = await fetch(u.href, { credentials: 'same-origin', headers: { accept: 'application/json' }, signal }); |
| 155 | if (r.ok) sections = ((await r.json()) || {}).sections || []; |
| 156 | } finally { pill.classList.remove('is-busy'); } |
| 157 | } |
| 158 | if (source && !fresh) { answers.set(q, { at: Date.now(), sections }); if (answers.size > 60) answers.delete(answers.keys().next().value); } |
| 159 | return { sections: [...(again.length ? [{ id: 'recent', label: 'Recent searches', items: again }] : []), ...(Array.isArray(sections) ? sections : [])] }; |
| 160 | } |
| 161 | |
| 162 | // The ghost: the rest of the first suggestion that starts with what was typed. |
| 163 | let rest = ''; |
| 164 | function paintGhost() { |
| 165 | const v = input.value; |
| 166 | ghost.textContent = ''; |
| 167 | if (!rest || !v || document.activeElement !== input || input.selectionEnd !== v.length) return; |
| 168 | ghost.append(v, Object.assign(el('i'), { textContent: rest })); |
| 169 | } |
| 170 | function suggest(items, q) { |
| 171 | const lq = q.toLowerCase(); |
| 172 | const first = q && items.map((it) => String(it.label || '')).find((l) => l.toLowerCase().startsWith(lq) && l.length > q.length); |
| 173 | rest = first ? first.slice(q.length) : ''; |
| 174 | paintGhost(); |
| 175 | } |
| 176 | function takeGhost() { |
| 177 | if (!rest) return false; |
| 178 | input.value += rest; rest = ''; paintGhost(); |
| 179 | input.dispatchEvent(new Event('input', { bubbles: true })); |
| 180 | return true; |
| 181 | } |
| 182 | |
| 183 | function go(q, item) { |
| 184 | q = String(q || '').trim(); |
| 185 | if (!q && !(item && item.url)) return; |
| 186 | if (q && !(item && item.url && !item.recent)) remember(q); |
| 187 | const ev = new CustomEvent('ov-search', { bubbles: true, cancelable: true, detail: { q, item: item || null } }); |
| 188 | const stay = input.dispatchEvent(ev) === false || (typeof o.onSearch === 'function' && o.onSearch(q, item || null) === false); |
| 189 | if (stay) return; |
| 190 | location.assign(item && item.url ? new URL(item.url, location.href).href : searchUrl(q)); |
| 191 | } |
| 192 | |
| 193 | // The examples walk: one every 2.8 s (5 s in Calm), only while the field is empty, idle and on screen. |
| 194 | let ex = 0, walk = 0; |
| 195 | function setWords(i) { input.setAttribute('placeholder', examples.length ? `Search ${examples[i % examples.length]}` : words); } |
| 196 | function walkOn() { |
| 197 | clearInterval(walk); |
| 198 | if (examples.length < 2 || motion() === 'off') return; |
| 199 | walk = setInterval(() => { |
| 200 | if (input.value || document.activeElement === input || document.hidden) return; |
| 201 | pill.classList.add('is-swap'); |
| 202 | setTimeout(() => { ex++; setWords(ex); pill.classList.remove('is-swap'); }, 220); |
| 203 | }, motion() === 'calm' ? 5000 : 2800); |
| 204 | } |
| 205 | if (examples.length) setWords(0); |
| 206 | walkOn(); |
| 207 | |
| 208 | let comboApi = null; |
| 209 | combo().then((C) => { |
| 210 | if (!C || !input.isConnected) return; |
| 211 | comboApi = C.enhance(input, { |
| 212 | source: list, limit: 8, selectFirst: false, cacheMs: 0, anchor: pill, |
| 213 | none: (q) => (q && source ? `Press Enter to search for “${q}”` : ''), |
| 214 | pick: (item, q) => { if (item.recent) { input.value = item.label; go(item.label, null); } else go(q || item.label, item); }, |
| 215 | enter: (q) => go(q, null), |
| 216 | onRows: suggest, |
| 217 | }); |
| 218 | }); |
| 219 | |
| 220 | input.addEventListener('focus', () => { pill.classList.add('is-focus'); hint.hidden = true; }); |
| 221 | input.addEventListener('blur', () => { pill.classList.remove('is-focus'); hint.hidden = !!input.value; rest = ''; paintGhost(); }); |
| 222 | input.addEventListener('input', () => { clear.hidden = !input.value; hint.hidden = !!input.value || document.activeElement === input; rest = rest && input.value ? rest : ''; paintGhost(); }); |
| 223 | input.addEventListener('keydown', (e) => { |
| 224 | if ((e.key === 'Tab' && !e.shiftKey && rest) || (e.key === 'ArrowRight' && rest && input.selectionEnd === input.value.length)) { |
| 225 | if (takeGhost()) e.preventDefault(); |
| 226 | } else if (e.key === 'Escape' && !(comboApi && comboApi.shown) && input.value) { |
| 227 | e.preventDefault(); input.value = ''; clear.hidden = true; rest = ''; paintGhost(); |
| 228 | input.dispatchEvent(new Event('input', { bubbles: true })); |
| 229 | } else if (e.key === 'Enter' && !comboApi) { e.preventDefault(); go(input.value, null); } |
| 230 | }); |
| 231 | input.addEventListener('keyup', paintGhost); |
| 232 | input.addEventListener('click', paintGhost); |
| 233 | clear.addEventListener('click', () => { input.value = ''; clear.hidden = true; rest = ''; paintGhost(); input.focus(); input.dispatchEvent(new Event('input', { bubbles: true })); }); |
| 234 | if (form) form.addEventListener('submit', (e) => { e.preventDefault(); go(input.value, null); }); |
| 235 | pill.addEventListener('mousedown', (e) => { if (e.target === pill || e.target === mark) { e.preventDefault(); input.focus(); } }); |
| 236 | |
| 237 | const api = { |
| 238 | input, |
| 239 | set(q) { input.value = String(q || ''); clear.hidden = !input.value; rest = ''; paintGhost(); }, |
| 240 | focus() { input.focus(); }, |
| 241 | destroy() { |
| 242 | clearInterval(walk); |
| 243 | if (comboApi) comboApi.destroy(); |
| 244 | pill.parentNode.insertBefore(input, pill); pill.remove(); |
| 245 | input.__ovsb = null; |
| 246 | const i = mounted.indexOf(api); if (i >= 0) mounted.splice(i, 1); |
| 247 | }, |
| 248 | }; |
| 249 | input.__ovsb = api; |
| 250 | mounted.push(api); |
| 251 | return api; |
| 252 | } |
| 253 | |
| 254 | // "/" puts the cursor in the page's first search field that shows (never while typing somewhere else). |
| 255 | document.addEventListener('keydown', (e) => { |
| 256 | if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey || editable(e.target)) return; |
| 257 | const box = mounted.find((m) => m.input.isConnected && m.input.getClientRects().length); |
| 258 | if (!box) return; |
| 259 | e.preventDefault(); |
| 260 | box.focus(); |
| 261 | }); |
| 262 | |
| 263 | function scan(scope) { (scope || document).querySelectorAll('input[data-ov-searchbox]').forEach((i) => mount(i)); } |
| 264 | root.OVSearchBox = { mount, scan }; |
| 265 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => scan(), { once: true }); else scan(); |
| 266 | document.addEventListener('ov:boost:load', () => scan()); |
| 267 | })(typeof window !== 'undefined' ? window : globalThis); |
| 268 |