openvibers/openvibe.shared ov-searchbox.js

ov-searchbox.js

main 18.3 KB · 268 lines · javascript

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 'use strict';
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); } }
64html[data-ov-motion=calm] .ovsb, html[data-ov-motion=calm] .ovsb-mark { transition-duration: .3s; }
65html[data-ov-motion=off] .ovsb, html[data-ov-motion=off] .ovsb-mark, html[data-ov-motion=off] .ovsb-field > input::placeholder { transition: none; }
66html[data-ov-motion=off] .ovsb-clear, html[data-ov-motion=off] .ovsb.is-busy .ovsb-mark { animation: none; }
67html[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