openvibers/openvibe.shared ov-combo.js
ov-combo.js
| 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 | ; |
| 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; } } |
| 74 | html[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 |