openvibers/openvibe.shared ov-combo.js

ov-combo.js

main 22.0 KB · 299 lines · javascript

1/**
2 * ov-combo.js (openvibe-shared 4.202.0): one search-as-you-type dropdown for a field. Ask 348, the owner, on Codes'
3 * repository field (a browser datalist): "a completely custom drop-down system … that works a little more intelligently
4 * … async or dynamic fetching or search … configurable … You should be able to just create the new repository … through
5 * this UI". It looks like the one dropdown (ov-drop.js) and works with the keyboard and a screen reader (a combobox
6 * with a listbox).
7 *
8 * Without script the field is a plain input. With it:
9 * <input name="repo" data-ov-combo="/api/v1/me/repos" data-ov-combo-create="/api/v1/repos">
10 * reads GET <source>?q=&limit=12 (same origin, the page's cookies) → { sections: [{ id, label, items: [{ label, url,
11 * host, private, sub }] }], create: { allowed, host, owner } }, as you type (160 ms after the last key, the one before
12 * aborted, answers kept a minute). Picking a row puts its url in the field. With create allowed, a last row offers
13 * "Create “name” on OpenVibe Git": POST <create> { name, private: true } → { url, label }, and the new one goes in.
14 * A site with its own data: OVCombo.enhance(input, { source: async (q, signal) => ({ sections }), create: async (name) => ({ url }) }).
15 * Events on the input: 'ov-combo:pick' { detail: item } after a pick or a create.
16 *
17 * 4.229.0, for the one search field (ov-searchbox.js) and anything else that is not a value picker:
18 * pick(item, q) a chosen row goes here instead of into the field (a search goes to the row's page);
19 * enter(q) Enter with no row chosen (a search searches what was typed); the list closes;
20 * onRows(items, q) the rows just drawn, in order (the search field's ghost reads the first);
21 * selectFirst false: the first row is not chosen ahead of the arrows (Enter is the typed text);
22 * none(q) the words when nothing matches; limit, the rows asked of a URL source (12);
23 * cacheMs how long an answer is kept per query (60000; 0 asks every time: the caller keeps its own);
24 * anchor the element the list lines up under (the search field's pill), else the field.
25 * A row may carry icon (search, recent, page, person, live, video, place) or image (a small picture's URL).
26 */
27(function (root) {
28 'use strict';
29 if (typeof document === 'undefined' || root.OVCombo) return;
30
31 const svg = (inner) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
32 const HOST_IC = {
33 'git.openvibe.codes': svg('<circle cx="7" cy="6" r="2.2"/><circle cx="7" cy="18" r="2.2"/><circle cx="17" cy="8" r="2.2"/><path d="M7 8.2v7.6M17 10.2c0 4-6 3-9.2 6"/>'),
34 'github.com': svg('<path d="M9 19c-4 1.3-4-2-5.6-2.5M14.6 21v-3.3a2.9 2.9 0 0 0-.8-2.2c2.7-.3 5.6-1.3 5.6-6a4.6 4.6 0 0 0-1.3-3.2 4.3 4.3 0 0 0-.1-3.2s-1-.3-3.4 1.3a11.5 11.5 0 0 0-6 0C6.2 2.8 5.2 3.1 5.2 3.1a4.3 4.3 0 0 0-.1 3.2A4.6 4.6 0 0 0 3.8 9.5c0 4.6 2.8 5.7 5.5 6a2.9 2.9 0 0 0-.8 2.2V21"/>'),
35 recent: svg('<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>'),
36 search: svg('<circle cx="11" cy="11" r="6.5"/><path d="m16 16 4 4"/>'),
37 page: svg('<path d="M7 3.5h7l4 4V20a.5.5 0 0 1-.5.5h-10A.5.5 0 0 1 7 20z"/><path d="M14 3.5V8h4"/>'),
38 person: svg('<circle cx="12" cy="8.5" r="3.5"/><path d="M5.5 20a6.5 6.5 0 0 1 13 0"/>'),
39 live: svg('<circle cx="12" cy="12" r="2.2"/><path d="M8.1 8.1a5.5 5.5 0 0 0 0 7.8M15.9 8.1a5.5 5.5 0 0 1 0 7.8M5.3 5.3a9.5 9.5 0 0 0 0 13.4M18.7 5.3a9.5 9.5 0 0 1 0 13.4"/>'),
40 video: svg('<rect x="3.5" y="6" width="12.5" height="12" rx="2"/><path d="m16 10.5 4.5-2.5v8l-4.5-2.5"/>'),
41 place: svg('<path d="M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0c0 5.4-6.5 11-6.5 11z"/><circle cx="12" cy="10" r="2.3"/>'),
42 repo: svg('<path d="M5 4.5h11.5a1.5 1.5 0 0 1 1.5 1.5v13H6.5A1.5 1.5 0 0 1 5 17.5z"/><path d="M5 17.5A1.5 1.5 0 0 1 6.5 16H18"/>'),
43 plus: svg('<path d="M12 5v14M5 12h14"/>'),
44 lock: '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><rect x="5.5" y="11" width="13" height="9" rx="2"/><path d="M8.5 11V8a3.5 3.5 0 0 1 7 0v3"/></svg>',
45 };
46 const CSS = `.ovcombo { position: fixed; z-index: 10030; box-sizing: border-box; max-height: min(60vh, 420px); overflow: auto; overscroll-behavior: contain; padding: 6px; border-radius: 16px;
47 background: color-mix(in srgb, var(--bg-elevated, var(--bg-secondary, #111826)) 96%, transparent); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 18px 44px rgba(0,0,0,.42), 0 2px 6px rgba(0,0,0,.2);
48 -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); color: var(--text-primary, #e6edf7); font: 600 14.5px/1.25 var(--font, system-ui, -apple-system, 'Segoe UI', sans-serif);
49 transform-origin: 24px 0; animation: ovcombo-in .2s cubic-bezier(.2, 1.2, .3, 1) both; }
50.ovcombo.is-up { transform-origin: 24px 100%; }
51.ovcombo[hidden] { display: none; }
52.ovcombo-sec { margin: 0; padding: 8px 10px 5px; font-size: 11.5px; font-weight: 700; letter-spacing: .02em; color: var(--text-muted, #93a0b4); }
53.ovcombo-sec:not(:first-child) { margin-top: 4px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); padding-top: 10px; }
54.ovcombo-i { display: flex; align-items: center; gap: 11px; min-height: 46px; padding: 4px 12px 4px 10px; border-radius: 11px; cursor: pointer; }
55.ovcombo-i[aria-selected="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }
56.ovcombo-i:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); }
57.ovcombo-i[aria-selected="true"]:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 20%, transparent); }
58.ovcombo-ic { flex: none; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }
59.ovcombo-ic svg { width: 18px; height: 18px; }
60.ovcombo-ic img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
61.ovcombo-i.is-create .ovcombo-ic { color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
62.ovcombo-t { flex: 1; min-width: 0; display: grid; gap: 2px; }
63.ovcombo-t b { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
64.ovcombo-t b mark { background: none; color: var(--accent-light, var(--accent, #60a5fa)); }
65.ovcombo-t small { display: flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 500; color: var(--text-muted, #93a0b4); overflow: hidden; white-space: nowrap; }
66.ovcombo-none { padding: 14px 12px; font-weight: 500; font-size: 13.5px; color: var(--text-muted, #93a0b4); }
67.ovcombo-skel { display: flex; align-items: center; gap: 11px; height: 46px; padding: 0 10px; }
68.ovcombo-skel i { flex: none; width: 30px; height: 30px; border-radius: 9px; }
69.ovcombo-skel b { height: 11px; border-radius: 6px; width: var(--w, 60%); }
70.ovcombo-skel i, .ovcombo-skel b { background: linear-gradient(90deg, color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent), color-mix(in srgb, var(--text-primary, #e6edf7) 12%, transparent), color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent)); background-size: 200% 100%; animation: ovcombo-shine 1.1s linear infinite; }
71.ovcombo-err { padding: 10px 12px; font-size: 13px; font-weight: 500; color: var(--live-red, #f87171); }
72@keyframes ovcombo-in { from { opacity: 0; transform: translateY(-4px) scale(.97); } }
73@keyframes ovcombo-shine { to { background-position: -200% 0; } }
74html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, html[data-ov-motion=off] .ovcombo-skel b { animation: none; }`;
75
76 let pop = null, cur = null, uid = 0;
77 function styles() { if (document.getElementById('ovcombo-styles')) return; const st = document.createElement('style'); st.id = 'ovcombo-styles'; st.textContent = CSS; document.head.appendChild(st); }
78 function el(tag, cls, text) { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }
79 const nameOf = (q) => { const s = String(q || '').trim().replace(/^https?:\/\/[^/]+\//, '').replace(/\.git$/, ''); const last = s.split('/').pop(); return /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(last || '') ? last : ''; };
80 const ago = (t) => { const s = (Date.now() - new Date(t).getTime()) / 1000; if (!isFinite(s) || s < 0) return ''; return s < 3600 ? `${Math.max(1, Math.floor(s / 60))} min ago` : s < 86400 ? `${Math.floor(s / 3600)} h ago` : `${Math.floor(s / 86400)} d ago`; };
81
82 function enhance(input, opts) {
83 if (!input || input.__ovcombo) return input && input.__ovcombo;
84 styles();
85 const o = Object.assign({}, opts);
86 const src = o.source || input.getAttribute('data-ov-combo');
87 const createAt = o.create || input.getAttribute('data-ov-combo-create');
88 const limit = Math.max(1, Math.min(25, Number(o.limit) || 12));
89 const keepMs = o.cacheMs == null ? 60000 : Math.max(0, Number(o.cacheMs) || 0);
90 const listId = `ovcombo-${++uid}`;
91 input.setAttribute('role', 'combobox');
92 input.setAttribute('aria-autocomplete', 'list');
93 input.setAttribute('aria-expanded', 'false');
94 input.setAttribute('aria-controls', listId);
95 input.setAttribute('autocomplete', 'off');
96 input.removeAttribute('list');
97 const cache = new Map();
98 let rows = [], at = -1, ctl = null, timer = 0, lastQ = null, creating = false;
99
100 async function fetchQ(q) {
101 const hit = cache.get(q);
102 if (hit && Date.now() - hit.at < keepMs) return hit.v;
103 if (ctl) ctl.abort();
104 ctl = typeof AbortController !== 'undefined' ? new AbortController() : null;
105 let v;
106 if (typeof src === 'function') v = await src(q, ctl && ctl.signal);
107 else {
108 const u = new URL(src, location.href); u.searchParams.set('q', q); u.searchParams.set('limit', String(limit));
109 const r = await fetch(u.href, { credentials: 'same-origin', headers: { accept: 'application/json' }, signal: ctl && ctl.signal });
110 if (!r.ok) throw new Error(`HTTP ${r.status}`);
111 v = await r.json();
112 }
113 v = { sections: Array.isArray(v && v.sections) ? v.sections : [], create: (v && v.create) || null };
114 cache.set(q, { at: Date.now(), v });
115 return v;
116 }
117 function place() {
118 if (!pop || pop.hidden) return;
119 const r = (o.anchor && o.anchor.isConnected ? o.anchor : input).getBoundingClientRect();
120 const vh = root.innerHeight, vw = root.innerWidth;
121 const below = vh - r.bottom - 12, above = r.top - 12;
122 const up = below < 220 && above > below;
123 pop.classList.toggle('is-up', up);
124 const w = Math.min(Math.max(r.width, 280), vw - 16);
125 pop.style.width = `${w}px`;
126 pop.style.left = `${Math.max(8, Math.min(r.left, vw - w - 8))}px`;
127 pop.style.maxHeight = `${Math.min(420, Math.max(160, up ? above : below))}px`;
128 if (up) { pop.style.top = ''; pop.style.bottom = `${vh - r.top + 6}px`; } else { pop.style.bottom = ''; pop.style.top = `${r.bottom + 6}px`; }
129 }
130 function open() {
131 if (!pop) { pop = el('div', 'ovcombo'); pop.setAttribute('role', 'listbox'); pop.hidden = true; document.body.appendChild(pop); pop.addEventListener('mousedown', (e) => e.preventDefault()); }
132 pop.id = listId;
133 pop.setAttribute('aria-label', input.getAttribute('aria-label') || input.placeholder || 'Choices');
134 cur = api;
135 pop.hidden = false;
136 input.setAttribute('aria-expanded', 'true');
137 place();
138 }
139 function close() {
140 // A closed list stays closed: the answer still on its way is dropped.
141 lastQ = null; clearTimeout(timer);
142 if (ctl) { ctl.abort(); ctl = null; }
143 if (cur === api && pop) { pop.hidden = true; pop.replaceChildren(); }
144 input.setAttribute('aria-expanded', 'false');
145 input.removeAttribute('aria-activedescendant');
146 rows = []; at = -1;
147 }
148 function mark(text, q) {
149 const b = el('b');
150 const i = q ? text.toLowerCase().indexOf(q.toLowerCase()) : -1;
151 if (i < 0) { b.textContent = text; return b; }
152 b.append(text.slice(0, i), el('mark', null, text.slice(i, i + q.length)), text.slice(i + q.length));
153 return b;
154 }
155 function select(i) {
156 at = i;
157 rows.forEach((r, k) => r.el.setAttribute('aria-selected', String(k === at)));
158 if (rows[at]) { input.setAttribute('aria-activedescendant', rows[at].el.id); rows[at].el.scrollIntoView({ block: 'nearest' }); } else input.removeAttribute('aria-activedescendant');
159 }
160 function row(item, q, kind) {
161 const d = el('div', `ovcombo-i${kind === 'create' ? ' is-create' : ''}`);
162 d.id = `${listId}-${rows.length}`;
163 d.setAttribute('role', 'option');
164 d.setAttribute('aria-selected', 'false');
165 const ic = el('span', 'ovcombo-ic');
166 if (kind !== 'create' && item.image && /^(https?:\/\/|\/)/.test(String(item.image))) {
167 const im = el('img'); im.alt = ''; im.loading = 'lazy'; im.decoding = 'async'; im.referrerPolicy = 'no-referrer'; im.src = String(item.image);
168 ic.appendChild(im);
169 } else ic.innerHTML = kind === 'create' ? HOST_IC.plus : HOST_IC[item.icon] || HOST_IC[item.host] || (kind === 'recent' ? HOST_IC.recent : HOST_IC.repo);
170 const t = el('span', 'ovcombo-t');
171 t.appendChild(kind === 'create' ? el('b', null, item.label) : mark(String(item.label || item.url || ''), q));
172 const subBits = kind === 'create' ? [item.sub] : [item.host && item.host !== 'git.openvibe.codes' ? item.host : item.host ? 'OpenVibe Git' : null, item.sub || (item.updated_at ? ago(item.updated_at) : null)].filter(Boolean);
173 if (subBits.length || item.private) {
174 const s = el('small');
175 if (item.private && kind !== 'create') { const l = el('span'); l.innerHTML = HOST_IC.lock; l.setAttribute('title', 'Private'); s.appendChild(l); }
176 s.append(subBits.join(' · '));
177 t.appendChild(s);
178 }
179 d.append(ic, t);
180 const k = rows.length;
181 d.addEventListener('click', () => choose(k));
182 d.addEventListener('mousemove', () => { if (at !== k) select(k); });
183 rows.push({ el: d, item, kind });
184 return d;
185 }
186 function skeleton() {
187 open();
188 pop.replaceChildren();
189 ['62%', '48%', '70%'].forEach((w) => { const s = el('div', 'ovcombo-skel'); s.style.setProperty('--w', w); s.append(el('i'), el('b')); pop.appendChild(s); });
190 pop.setAttribute('aria-busy', 'true');
191 }
192 function paint(v, q) {
193 open();
194 pop.removeAttribute('aria-busy');
195 pop.replaceChildren(); rows = []; at = -1;
196 let any = false;
197 for (const sec of v.sections) {
198 const items = (sec.items || []).filter((x) => x && (x.url || x.label));
199 if (!items.length) continue;
200 any = true;
201 const h = el('p', 'ovcombo-sec', sec.label || ''); h.setAttribute('role', 'presentation');
202 pop.appendChild(h);
203 for (const it of items) pop.appendChild(row(it, q, sec.id === 'recent' ? 'recent' : 'item'));
204 }
205 const name = nameOf(q);
206 const c = v.create;
207 const exists = rows.some((r) => String(r.item.label || '').split('/').pop().toLowerCase() === name.toLowerCase());
208 if (createAt && c && c.allowed && name && !exists) {
209 if (any) { const h = el('p', 'ovcombo-sec', 'New'); h.setAttribute('role', 'presentation'); pop.appendChild(h); }
210 pop.appendChild(row({ label: `Create “${name}” on ${c.host === 'git.openvibe.codes' || !c.host ? 'OpenVibe Git' : c.host}`, sub: `Private, under ${c.owner ? '@' + c.owner : 'your account'}. You can share it later.`, name }, q, 'create'));
211 }
212 if (!rows.length) {
213 const words = typeof o.none === 'function' ? o.none(q) : q ? `Nothing matches “${q}”. A full address works too.` : 'Nothing here yet. Paste an address, or type a name.';
214 if (words) pop.appendChild(el('div', 'ovcombo-none', words)); else { close(); return; }
215 }
216 place();
217 if (rows.length && q && o.selectFirst !== false) select(0);
218 if (typeof o.onRows === 'function') { try { o.onRows(rows.map((r) => r.item), q); } catch { /* the caller's */ } }
219 }
220 async function look(q) {
221 lastQ = q;
222 const hit = cache.get(q);
223 // Rows already showing stay while a quick answer comes (no flash of the skeleton on every key).
224 const sk = hit && Date.now() - hit.at < keepMs ? 0 : setTimeout(() => { if (lastQ === q) skeleton(); }, rows.length ? 180 : 0);
225 try {
226 const v = await fetchQ(q);
227 clearTimeout(sk);
228 if (lastQ !== q || document.activeElement !== input) return;
229 paint(v, q);
230 } catch (e) {
231 clearTimeout(sk);
232 if (e && e.name === 'AbortError') return;
233 if (lastQ !== q) return;
234 open(); pop.removeAttribute('aria-busy'); pop.replaceChildren(el('div', 'ovcombo-err', 'The list did not load. You can still paste an address.')); place();
235 }
236 }
237 async function choose(i) {
238 const r = rows[i]; if (!r || creating) return;
239 if (r.kind !== 'create') {
240 if (typeof o.pick === 'function') { const q = input.value.trim(); close(); o.pick(r.item, q); return; }
241 put(r.item); return;
242 }
243 creating = true;
244 r.el.querySelector('b').textContent = `Creating “${r.item.name}”…`;
245 try {
246 let made;
247 if (typeof createAt === 'function') made = await createAt(r.item.name);
248 else {
249 const res = await fetch(new URL(createAt, location.href).href, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ name: r.item.name, private: true }) });
250 made = await res.json().catch(() => ({}));
251 if (!res.ok) throw new Error(made.message || made.error || `HTTP ${res.status}`);
252 }
253 cache.clear();
254 put({ label: made.label || r.item.name, url: made.url, host: 'git.openvibe.codes', created: true });
255 } catch (e) {
256 const s = r.el.querySelector('small') || r.el.querySelector('.ovcombo-t').appendChild(el('small'));
257 r.el.querySelector('b').textContent = `Create “${r.item.name}”`;
258 s.textContent = String((e && e.message) || 'It was not created.').slice(0, 160);
259 s.style.color = 'var(--live-red, #f87171)';
260 } finally { creating = false; }
261 }
262 function put(item) {
263 input.value = item.url || item.label || '';
264 close();
265 input.dispatchEvent(new Event('input', { bubbles: true }));
266 input.dispatchEvent(new Event('change', { bubbles: true }));
267 input.dispatchEvent(new CustomEvent('ov-combo:pick', { bubbles: true, detail: item }));
268 input.__ovcomboPicked = input.value;
269 }
270
271 input.addEventListener('focus', () => { if (input.__ovcomboPicked !== input.value || !input.value) look(input.value.trim()); });
272 input.addEventListener('input', () => { if (input.__ovcomboPicked === input.value) return; input.__ovcomboPicked = null; clearTimeout(timer); timer = setTimeout(() => look(input.value.trim()), 160); });
273 input.addEventListener('blur', () => setTimeout(() => { if (document.activeElement !== input) close(); }, 120));
274 input.addEventListener('keydown', (e) => {
275 if (e.defaultPrevented) return; // a field built on this one handled the key (the search field's Tab)
276 const shown = pop && !pop.hidden && cur === api;
277 if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
278 e.preventDefault();
279 if (!shown) { look(input.value.trim()); return; }
280 if (!rows.length) return;
281 select(at < 0 ? (e.key === 'ArrowDown' ? 0 : rows.length - 1) : (at + (e.key === 'ArrowDown' ? 1 : rows.length - 1)) % rows.length);
282 } else if (e.key === 'Enter' && shown && at >= 0) { e.preventDefault(); e.stopPropagation(); choose(at); }
283 else if (e.key === 'Enter' && typeof o.enter === 'function' && !e.isComposing) { e.preventDefault(); const q = input.value.trim(); close(); o.enter(q); }
284 else if (e.key === 'Escape' && shown) { e.preventDefault(); e.stopPropagation(); close(); }
285 else if (e.key === 'Tab' && shown) close();
286 });
287 root.addEventListener('resize', place);
288 root.addEventListener('scroll', place, true);
289 const api = { input, open: () => look(input.value.trim()), close, get shown() { return !!(pop && !pop.hidden && cur === api); }, destroy() { close(); input.__ovcombo = null; root.removeEventListener('resize', place); root.removeEventListener('scroll', place, true); } };
290 input.__ovcombo = api;
291 return api;
292 }
293
294 function scan(scope) { (scope || document).querySelectorAll('input[data-ov-combo]').forEach((i) => enhance(i)); }
295 root.OVCombo = { enhance, scan };
296 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => scan(), { once: true }); else scan();
297 document.addEventListener('ov:boost:load', () => scan());
298})(typeof window !== 'undefined' ? window : globalThis);
299