openvibers/openvibe.shared ov-forms.js
ov-forms.js
| 1 | /** |
| 2 | * openvibe-shared/ov-forms.js — OpenVibe's own select, on every site (the owner, 2026-11-03: "a custom system for those |
| 3 | * select inputs … shared across the network and all websites rather than using default stuff … mobile, desktop, etc. |
| 4 | * device specific support that looks amazing"). The look is ov-forms.css; docs/forms.md describes both. |
| 5 | * |
| 6 | * Every <select> becomes a button that opens OpenVibe's own list: a menu under it on a computer, a sheet from the |
| 7 | * bottom on a phone. The <select> stays in the form, hidden, and holds the value: a form posts as it did, a script that |
| 8 | * sets select.value or listens for change still works, and with no script the page has its plain select. |
| 9 | * |
| 10 | * An <option> may say more than its label: |
| 11 | * data-desc="…" a second line under it |
| 12 | * data-badge="…" a short tag on its right ("Signed in", "72% used") |
| 13 | * data-state="ok|warn|bad|off|new" the tag's tone, and a dot before the label |
| 14 | * data-icon="…" one or two characters before the label (an emoji, a letter) |
| 15 | * data-href="/setup" a link on the row ("Set up"), for a choice that is not ready: disabled options keep it |
| 16 | * data-href-label="…" the link's words |
| 17 | * A <select> may say: |
| 18 | * data-ov-native keep the browser's own select (also on any ancestor, or <html data-ov-forms="off">) |
| 19 | * data-ov-search="on|off" a search box (on by itself from 9 choices) |
| 20 | * data-ov-title="…" the phone sheet's title (else the select's label) |
| 21 | * data-ov-select="bare" no box of its own and no chevron: a select inside a chip or a toolbar keeps that look, |
| 22 | * and only its list is OpenVibe's (4.109.1) |
| 23 | * |
| 24 | * OVForms.enhance(root) every select under root (the navbar calls it once the page settles, and for new markup) |
| 25 | * OVForms.refresh(select) read the select again (its options changed in a way the page did not announce) |
| 26 | * OVForms.open(select) open its list |
| 27 | * |
| 28 | * Keyboard: Enter, Space, ↓ or ↑ open it; ↑ ↓ Home End move; typing searches (or jumps, without a search box); Enter |
| 29 | * chooses; Escape and Tab close. A multiple select keeps its list open and says how many are chosen. |
| 30 | */ |
| 31 | (function (root) { |
| 32 | ; |
| 33 | if (!root || !root.document || root.OVForms) return; |
| 34 | const doc = root.document; |
| 35 | const SEARCH_FROM = 9; |
| 36 | const PHONE = '(max-width: 640px), (pointer: coarse) and (max-width: 900px)'; |
| 37 | const done = new WeakMap(); // select → its state |
| 38 | let open = null; // the state whose list is open |
| 39 | let uid = 0; |
| 40 | // Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it. |
| 41 | const reduce = () => document.documentElement.getAttribute('data-ov-motion') === 'off'; |
| 42 | const phone = () => !!(root.matchMedia && root.matchMedia(PHONE).matches); |
| 43 | const el = (tag, cls, attrs) => { |
| 44 | const n = doc.createElement(tag); |
| 45 | if (cls) n.className = cls; |
| 46 | if (attrs) for (const k of Object.keys(attrs)) if (attrs[k] != null) n.setAttribute(k, attrs[k]); |
| 47 | return n; |
| 48 | }; |
| 49 | const CHEVRON = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>'; |
| 50 | const TICK = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>'; |
| 51 | const LOUPE = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="6.5"/><path d="m16 16 4 4"/></svg>'; |
| 52 | |
| 53 | // ── The look: ov-forms.css beside this file, once (the navbar adds it with its own ?v=; a page may have it already). |
| 54 | function stylesheet() { |
| 55 | const have = doc.querySelector('link[href*="ov-forms.css"]'); |
| 56 | if (have) return have; |
| 57 | const me = doc.currentScript || doc.querySelector('script[src*="ov-forms.js"]'); |
| 58 | const src = me && me.src ? me.src : '/shared/ov-forms.js'; |
| 59 | const link = el('link', null, { rel: 'stylesheet', href: src.replace(/ov-forms\.js(\?[^#]*)?.*$/, 'ov-forms.css$1') }); |
| 60 | doc.head.appendChild(link); |
| 61 | return link; |
| 62 | } |
| 63 | const sheetReady = new Promise((resolve) => { |
| 64 | const link = stylesheet(); |
| 65 | if (link.sheet) { resolve(); return; } |
| 66 | link.addEventListener('load', () => resolve(), { once: true }); |
| 67 | link.addEventListener('error', () => resolve(false), { once: true }); |
| 68 | setTimeout(resolve, 4000); |
| 69 | }); |
| 70 | |
| 71 | // A select drawn as a list box (a size above 1) stays the browser's: it is already a list on the page. |
| 72 | const skip = (s) => !s || (s.hasAttribute('size') && s.size > 1) |
| 73 | || s.closest('[data-ov-native]') || doc.documentElement.getAttribute('data-ov-forms') === 'off'; |
| 74 | |
| 75 | /** The words that name a select: its label(s), aria-label, or the title the page gave its sheet. */ |
| 76 | function nameOf(s) { |
| 77 | if (s.dataset.ovTitle) return s.dataset.ovTitle; |
| 78 | const labels = s.labels ? Array.prototype.slice.call(s.labels) : []; |
| 79 | for (const l of labels) { |
| 80 | const copy = l.cloneNode(true); |
| 81 | copy.querySelectorAll('select,.ov-sel,small,.ov-sel-native').forEach((n) => n.remove()); |
| 82 | const t = copy.textContent.replace(/\s+/g, ' ').trim(); |
| 83 | if (t) return t; |
| 84 | } |
| 85 | return s.getAttribute('aria-label') || s.title || 'Choose'; |
| 86 | } |
| 87 | |
| 88 | /** One option as the list draws it. */ |
| 89 | function itemOf(o, i) { |
| 90 | const d = o.dataset || {}; |
| 91 | return { |
| 92 | i, value: o.value, label: (o.label || o.textContent || '').replace(/\s+/g, ' ').trim(), |
| 93 | desc: d.desc || '', badge: d.badge || '', state: d.state || '', icon: d.icon || '', |
| 94 | href: d.href || '', hrefLabel: d.hrefLabel || 'Set up', |
| 95 | disabled: o.disabled || !!(o.parentNode && o.parentNode.disabled), group: o.parentNode && o.parentNode.tagName === 'OPTGROUP' ? o.parentNode.label : '', |
| 96 | hidden: o.hidden, |
| 97 | }; |
| 98 | } |
| 99 | |
| 100 | function enhanceOne(s) { |
| 101 | if (done.has(s) || skip(s)) return; |
| 102 | const st = { s, id: `ov-sel-${++uid}` }; |
| 103 | done.set(s, st); |
| 104 | const cs = root.getComputedStyle(s); |
| 105 | const parent = s.parentElement; |
| 106 | // The button takes the select's place in the layout: its box, its margins, its share of a row or a grid. |
| 107 | const btn = el('button', 'ov-sel', { type: 'button', id: `${st.id}-btn`, 'aria-haspopup': 'listbox', 'aria-expanded': 'false' }); |
| 108 | btn.innerHTML = `<span class="ov-sel-value"></span><span class="ov-sel-chev">${CHEVRON}</span>`; |
| 109 | const keep = ['margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'flex', 'flex-basis', 'flex-grow', 'flex-shrink', 'align-self', 'justify-self', |
| 110 | 'grid-column', 'grid-row', 'order', 'max-width', 'vertical-align', 'font-size', 'border-radius']; |
| 111 | for (const p of keep) { const v = cs.getPropertyValue(p); if (v && v !== 'auto' && v !== 'normal' && v !== '0px' && v !== 'none') btn.style.setProperty(p, v); } |
| 112 | if (cs.display === 'block' || cs.display === 'flex' || cs.display === 'grid') btn.classList.add('is-block'); |
| 113 | const bare = s.dataset.ovSelect === 'bare'; |
| 114 | if (bare) btn.classList.add('is-bare'); |
| 115 | const pw = parent ? parent.clientWidth - parseFloat(root.getComputedStyle(parent).paddingLeft || 0) - parseFloat(root.getComputedStyle(parent).paddingRight || 0) : 0; |
| 116 | if (s.offsetWidth && pw && s.offsetWidth >= pw - 1 && !bare) btn.classList.add('is-full'); |
| 117 | else if (s.offsetWidth && !bare) btn.style.minWidth = `${s.offsetWidth}px`; |
| 118 | // Inside an .ov-field the field's own height rules (44 px, the same as its text inputs); elsewhere the select's. |
| 119 | if (s.offsetHeight && !s.closest('.ov-field') && !bare) btn.style.minHeight = `${s.offsetHeight}px`; |
| 120 | if (s.className) btn.dataset.from = s.className.slice(0, 120); |
| 121 | s.insertAdjacentElement('afterend', btn); |
| 122 | s.classList.add('ov-sel-native'); |
| 123 | s.tabIndex = -1; |
| 124 | s.setAttribute('aria-hidden', 'true'); |
| 125 | st.btn = btn; |
| 126 | const ids = (s.labels ? Array.prototype.slice.call(s.labels) : []).map((l) => { if (!l.id) l.id = `${st.id}-l${Math.random().toString(36).slice(2, 6)}`; return l.id; }); |
| 127 | btn.setAttribute('aria-labelledby', [...ids, `${st.id}-btn`].join(' ')); |
| 128 | if (!ids.length) btn.setAttribute('aria-label', nameOf(s)); |
| 129 | |
| 130 | // A label's click and a script's focus() land on the hidden select: the button takes them. |
| 131 | s.addEventListener('focus', () => btn.focus()); |
| 132 | s.addEventListener('change', () => sync(st)); |
| 133 | s.addEventListener('input', () => sync(st)); |
| 134 | s.addEventListener('invalid', (e) => { e.preventDefault(); btn.classList.add('is-invalid'); btn.setAttribute('aria-invalid', 'true'); btn.title = s.validationMessage || ''; btn.focus(); }); |
| 135 | const form = s.form; |
| 136 | if (form) form.addEventListener('reset', () => setTimeout(() => sync(st), 0)); |
| 137 | // A script that sets the value or the index says nothing: these two are watched on this select only. |
| 138 | for (const prop of ['value', 'selectedIndex']) { |
| 139 | const d = Object.getOwnPropertyDescriptor(root.HTMLSelectElement.prototype, prop); |
| 140 | if (!d || !d.set) continue; |
| 141 | try { |
| 142 | Object.defineProperty(s, prop, { configurable: true, enumerable: true, get() { return d.get.call(this); }, set(v) { d.set.call(this, v); sync(st); } }); |
| 143 | } catch { /* a frozen element: the change events still sync */ } |
| 144 | } |
| 145 | if (typeof root.MutationObserver === 'function') { |
| 146 | st.watch = new root.MutationObserver(() => { if (!s.isConnected) { teardown(st); return; } sync(st); if (open === st) draw(st); }); |
| 147 | st.watch.observe(s, { childList: true, subtree: true, attributes: true, attributeFilter: ['disabled', 'hidden', 'selected', 'label', 'data-desc', 'data-badge', 'data-state', 'data-href', 'class', 'style', 'required'] }); |
| 148 | } |
| 149 | btn.addEventListener('click', () => (open === st ? close() : show(st))); |
| 150 | btn.addEventListener('keydown', (e) => { |
| 151 | if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key) && open !== st) { e.preventDefault(); show(st, e.key === 'ArrowUp' ? -1 : 0); return; } |
| 152 | if (!open && e.key.length === 1 && /\S/.test(e.key) && !s.multiple) jump(st, e.key); |
| 153 | }); |
| 154 | sync(st); |
| 155 | } |
| 156 | |
| 157 | function teardown(st) { |
| 158 | if (st.watch) st.watch.disconnect(); |
| 159 | if (open === st) close(); |
| 160 | if (st.btn && st.btn.isConnected) st.btn.remove(); |
| 161 | done.delete(st.s); |
| 162 | } |
| 163 | |
| 164 | /** The button says what is chosen, and mirrors the select's state. */ |
| 165 | function sync(st) { |
| 166 | const { s, btn } = st; |
| 167 | if (!btn) return; |
| 168 | const chosen = Array.prototype.filter.call(s.options, (o) => o.selected); |
| 169 | const value = btn.querySelector('.ov-sel-value'); |
| 170 | value.textContent = ''; |
| 171 | if (s.multiple) { |
| 172 | value.textContent = chosen.length === 0 ? (s.dataset.placeholder || 'None') : chosen.length <= 2 ? chosen.map((o) => itemOf(o).label).join(', ') : `${chosen.length} chosen`; |
| 173 | btn.classList.toggle('is-empty', !chosen.length); |
| 174 | } else { |
| 175 | const o = chosen[0] || s.options[s.selectedIndex] || null; |
| 176 | const it = o ? itemOf(o, o.index) : null; |
| 177 | if (it && it.state) value.appendChild(el('span', `ov-sel-dot is-${it.state}`, { 'aria-hidden': 'true' })); |
| 178 | else if (it && it.icon) { const ic = el('span', 'ov-sel-icon', { 'aria-hidden': 'true' }); ic.textContent = it.icon; value.appendChild(ic); } |
| 179 | const text = el('span', 'ov-sel-text'); |
| 180 | text.textContent = it ? it.label : (s.dataset.placeholder || ''); |
| 181 | value.appendChild(text); |
| 182 | if (it && it.badge) { const b = el('span', `ov-sel-badge${it.state ? ` is-${it.state}` : ''}`); b.textContent = it.badge; value.appendChild(b); } |
| 183 | btn.classList.toggle('is-empty', !it || it.value === ''); |
| 184 | } |
| 185 | btn.disabled = s.disabled; |
| 186 | btn.hidden = s.hidden || root.getComputedStyle(s).display === 'none'; |
| 187 | if (s.validity && s.validity.valid) { btn.classList.remove('is-invalid'); btn.removeAttribute('aria-invalid'); btn.removeAttribute('title'); } |
| 188 | if (s.required) btn.setAttribute('aria-required', 'true'); else btn.removeAttribute('aria-required'); |
| 189 | } |
| 190 | |
| 191 | /** Typing on the closed button: the next choice whose label starts with what was typed. */ |
| 192 | function jump(st, ch) { |
| 193 | st.typed = (Date.now() - (st.typedAt || 0) < 700 ? st.typed || '' : '') + ch.toLowerCase(); |
| 194 | st.typedAt = Date.now(); |
| 195 | const opts = Array.prototype.slice.call(st.s.options); |
| 196 | const from = st.s.selectedIndex + (st.typed.length === 1 ? 1 : 0); |
| 197 | const order = [...opts.slice(from), ...opts.slice(0, from)]; |
| 198 | const hit = order.find((o) => !o.disabled && !o.hidden && itemOf(o).label.toLowerCase().startsWith(st.typed)); |
| 199 | if (hit) choose(st, hit.index); |
| 200 | } |
| 201 | |
| 202 | function choose(st, index, keepOpen) { |
| 203 | const { s } = st; |
| 204 | const o = s.options[index]; |
| 205 | if (!o || o.disabled) return; |
| 206 | if (s.multiple) o.selected = !o.selected; |
| 207 | else { if (s.selectedIndex === index) { if (!keepOpen) close(true); return; } o.selected = true; } |
| 208 | s.dispatchEvent(new root.Event('input', { bubbles: true })); |
| 209 | s.dispatchEvent(new root.Event('change', { bubbles: true })); |
| 210 | sync(st); |
| 211 | if (s.multiple || keepOpen) draw(st); else close(true); |
| 212 | } |
| 213 | |
| 214 | // ── The list: a menu under the button, or a sheet from the bottom on a phone ── |
| 215 | function show(st, startAt) { |
| 216 | if (open) close(); |
| 217 | if (st.s.disabled) return; |
| 218 | open = st; |
| 219 | const sheet = phone(); |
| 220 | st.sheet = sheet; |
| 221 | const items = Array.prototype.map.call(st.s.options, itemOf).filter((x) => !x.hidden); |
| 222 | const searchy = st.s.dataset.ovSearch === 'on' || (st.s.dataset.ovSearch !== 'off' && items.length >= SEARCH_FROM); |
| 223 | const wrap = el('div', sheet ? 'ov-sel-sheet' : 'ov-sel-pop', { id: `${st.id}-pop`, role: sheet ? 'dialog' : null, 'aria-modal': sheet ? 'true' : null, 'aria-label': sheet ? nameOf(st.s) : null }); |
| 224 | if (sheet) { |
| 225 | const head = el('div', 'ov-sel-sheet-head'); |
| 226 | head.innerHTML = '<span class="ov-sel-grip" aria-hidden="true"></span>'; |
| 227 | const title = el('span', 'ov-sel-sheet-title'); |
| 228 | title.textContent = nameOf(st.s); |
| 229 | const doneBtn = el('button', 'ov-sel-done', { type: 'button' }); |
| 230 | doneBtn.textContent = st.s.multiple ? 'Done' : 'Close'; |
| 231 | doneBtn.addEventListener('click', () => close(true)); |
| 232 | head.append(title, doneBtn); |
| 233 | wrap.appendChild(head); |
| 234 | dragToClose(head, wrap); |
| 235 | } |
| 236 | if (searchy) { |
| 237 | const box = el('label', 'ov-sel-search'); |
| 238 | box.innerHTML = LOUPE; |
| 239 | const input = el('input', null, { type: 'search', placeholder: 'Search', autocomplete: 'off', spellcheck: 'false', 'aria-controls': `${st.id}-list`, 'aria-label': `Search ${nameOf(st.s)}` }); |
| 240 | input.addEventListener('input', () => { st.query = input.value.trim().toLowerCase(); st.active = null; draw(st); }); |
| 241 | box.appendChild(input); |
| 242 | wrap.appendChild(box); |
| 243 | st.input = input; |
| 244 | } else st.input = null; |
| 245 | const list = el('ul', 'ov-sel-list', { id: `${st.id}-list`, role: 'listbox', tabindex: searchy ? null : '-1', 'aria-multiselectable': st.s.multiple ? 'true' : null, 'aria-label': nameOf(st.s) }); |
| 246 | wrap.appendChild(list); |
| 247 | st.wrap = wrap; st.list = list; st.query = ''; |
| 248 | const current = st.s.selectedIndex; |
| 249 | st.active = startAt === -1 ? last(st, items) : (current >= 0 ? current : null); |
| 250 | if (sheet) { |
| 251 | st.scrim = el('div', 'ov-sel-scrim'); |
| 252 | st.scrim.addEventListener('click', () => close(true)); |
| 253 | raise(st.scrim, st.btn); |
| 254 | raise(wrap, st.btn); |
| 255 | doc.documentElement.classList.add('ov-sel-locked'); |
| 256 | } else raise(wrap, st.btn); |
| 257 | if (root.OVSurfaces) { |
| 258 | st.surface = sheet |
| 259 | ? root.OVSurfaces.sheet({ el: wrap, opener: st.btn, body: list, close: () => close(true) }) |
| 260 | : root.OVSurfaces.popover({ el: wrap, trigger: st.btn, close: () => close(false) }); |
| 261 | st.surface.open(); |
| 262 | } |
| 263 | draw(st); |
| 264 | st.btn.setAttribute('aria-expanded', 'true'); |
| 265 | st.btn.setAttribute('aria-controls', `${st.id}-pop`); |
| 266 | if (!sheet) place(st); |
| 267 | requestAnimationFrame(() => { |
| 268 | wrap.classList.add('is-open'); |
| 269 | if (st.scrim) st.scrim.classList.add('is-open'); |
| 270 | (st.input && !sheet ? st.input : list).focus({ preventScroll: true }); |
| 271 | const a = list.querySelector('[aria-selected="true"], .is-active'); |
| 272 | if (a && a.scrollIntoView) a.scrollIntoView({ block: 'nearest' }); |
| 273 | }); |
| 274 | wrap.addEventListener('keydown', (e) => keys(st, e)); |
| 275 | list.addEventListener('pointerdown', (e) => { if (e.target.closest('.ov-sel-opt')) e.preventDefault(); }); |
| 276 | list.addEventListener('click', (e) => { |
| 277 | if (e.target.closest('a')) return; // a row's own link goes where it says |
| 278 | const row = e.target.closest('.ov-sel-opt'); |
| 279 | if (row && row.getAttribute('aria-disabled') !== 'true') choose(st, Number(row.dataset.i)); |
| 280 | }); |
| 281 | } |
| 282 | |
| 283 | /** |
| 284 | * Put the list where it is seen and can be touched (4.109.2): inside the modal <dialog> or the fullscreen element |
| 285 | * the select is in (else the body), and in the top layer when the browser has popovers, so it opens over the |
| 286 | * dialog, a fullscreen player, and anything with a z-index. |
| 287 | */ |
| 288 | const POPOVER = typeof root.HTMLElement === 'function' && typeof root.HTMLElement.prototype.showPopover === 'function'; |
| 289 | function raise(node, from) { |
| 290 | // A modal dialog makes everything outside it inert, top layer or not: the list lives inside the dialog. |
| 291 | const host = (from && from.closest('dialog[open]')) || (doc.fullscreenElement && doc.fullscreenElement.contains(from) ? doc.fullscreenElement : null) || doc.body; |
| 292 | host.appendChild(node); |
| 293 | if (!POPOVER) return; |
| 294 | node.setAttribute('popover', 'manual'); |
| 295 | try { node.showPopover(); } catch { node.removeAttribute('popover'); } |
| 296 | } |
| 297 | |
| 298 | function last(st, items) { for (let k = items.length - 1; k >= 0; k--) if (!items[k].disabled) return items[k].i; return null; } |
| 299 | |
| 300 | /** The rows that match the search, in groups when the select has them. */ |
| 301 | function draw(st) { |
| 302 | const { s, list } = st; |
| 303 | if (!list) return; |
| 304 | const q = st.query || ''; |
| 305 | const items = Array.prototype.map.call(s.options, itemOf).filter((x) => !x.hidden && (!q || `${x.label} ${x.desc} ${x.badge}`.toLowerCase().includes(q))); |
| 306 | list.textContent = ''; |
| 307 | let group = null; |
| 308 | for (const it of items) { |
| 309 | if (it.group && it.group !== group) { |
| 310 | group = it.group; |
| 311 | const g = el('li', 'ov-sel-group', { role: 'presentation' }); |
| 312 | g.textContent = group; |
| 313 | list.appendChild(g); |
| 314 | } |
| 315 | const selected = s.options[it.i].selected; |
| 316 | const row = el('li', `ov-sel-opt${it.disabled ? ' is-disabled' : ''}${st.active === it.i ? ' is-active' : ''}`, { |
| 317 | id: `${st.id}-o${it.i}`, role: 'option', 'aria-selected': selected ? 'true' : 'false', 'aria-disabled': it.disabled ? 'true' : null, 'data-i': String(it.i), |
| 318 | }); |
| 319 | const mark = el('span', `ov-sel-mark${s.multiple ? ' is-box' : ''}`, { 'aria-hidden': 'true' }); |
| 320 | mark.innerHTML = TICK; |
| 321 | const lead = it.state ? el('span', `ov-sel-dot is-${it.state}`, { 'aria-hidden': 'true' }) : it.icon ? el('span', 'ov-sel-icon', { 'aria-hidden': 'true' }) : null; |
| 322 | if (lead && it.icon && !it.state) lead.textContent = it.icon; |
| 323 | const words = el('span', 'ov-sel-words'); |
| 324 | const label = el('span', 'ov-sel-label'); |
| 325 | label.textContent = it.label || ' '; |
| 326 | words.appendChild(label); |
| 327 | if (it.desc) { const d = el('span', 'ov-sel-desc'); d.textContent = it.desc; words.appendChild(d); } |
| 328 | row.append(...[s.multiple ? mark : null, lead, words].filter(Boolean)); |
| 329 | if (it.badge) { const b = el('span', `ov-sel-badge${it.state ? ` is-${it.state}` : ''}`); b.textContent = it.badge; row.appendChild(b); } |
| 330 | if (it.href) { const a = el('a', 'ov-sel-link', { href: it.href }); a.textContent = it.hrefLabel; row.appendChild(a); } |
| 331 | if (!s.multiple) row.appendChild(mark); |
| 332 | list.appendChild(row); |
| 333 | } |
| 334 | if (!items.length) { const none = el('li', 'ov-sel-none', { role: 'presentation' }); none.textContent = q ? `Nothing matches “${st.query}”` : 'Nothing to choose'; list.appendChild(none); } |
| 335 | const active = st.active != null && list.querySelector(`#${st.id}-o${st.active}`); |
| 336 | if (!active) { const first = list.querySelector('.ov-sel-opt:not(.is-disabled)'); st.active = first ? Number(first.dataset.i) : null; if (first) first.classList.add('is-active'); } |
| 337 | const target = st.input || list; |
| 338 | if (st.active != null) target.setAttribute('aria-activedescendant', `${st.id}-o${st.active}`); else target.removeAttribute('aria-activedescendant'); |
| 339 | } |
| 340 | |
| 341 | function move(st, step, to) { |
| 342 | const rows = Array.prototype.slice.call(st.list.querySelectorAll('.ov-sel-opt:not(.is-disabled)')); |
| 343 | if (!rows.length) return; |
| 344 | let k = rows.findIndex((r) => Number(r.dataset.i) === st.active); |
| 345 | if (to === 'first') k = 0; else if (to === 'last') k = rows.length - 1; else k = Math.max(0, Math.min(rows.length - 1, (k < 0 ? (step > 0 ? -1 : rows.length) : k) + step)); |
| 346 | st.list.querySelectorAll('.is-active').forEach((r) => r.classList.remove('is-active')); |
| 347 | rows[k].classList.add('is-active'); |
| 348 | st.active = Number(rows[k].dataset.i); |
| 349 | (st.input || st.list).setAttribute('aria-activedescendant', rows[k].id); |
| 350 | rows[k].scrollIntoView({ block: 'nearest' }); |
| 351 | } |
| 352 | |
| 353 | function keys(st, e) { |
| 354 | const k = e.key; |
| 355 | if (k === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true); return; } |
| 356 | if (k === 'Tab') { close(false); return; } |
| 357 | if (k === 'ArrowDown') { e.preventDefault(); move(st, 1); return; } |
| 358 | if (k === 'ArrowUp') { e.preventDefault(); move(st, -1); return; } |
| 359 | if (k === 'PageDown') { e.preventDefault(); move(st, 8); return; } |
| 360 | if (k === 'PageUp') { e.preventDefault(); move(st, -8); return; } |
| 361 | if ((k === 'Home' || k === 'End') && !st.input) { e.preventDefault(); move(st, 0, k === 'Home' ? 'first' : 'last'); return; } |
| 362 | if (k === 'Enter' || (k === ' ' && !st.input)) { e.preventDefault(); if (st.active != null) choose(st, st.active); return; } |
| 363 | if (!st.input && k.length === 1 && /\S/.test(k)) { |
| 364 | st.typed = (Date.now() - (st.typedAt || 0) < 700 ? st.typed || '' : '') + k.toLowerCase(); |
| 365 | st.typedAt = Date.now(); |
| 366 | const hit = Array.prototype.find.call(st.list.querySelectorAll('.ov-sel-opt:not(.is-disabled)'), (r) => r.textContent.toLowerCase().startsWith(st.typed)); |
| 367 | if (hit) { st.active = Number(hit.dataset.i); st.list.querySelectorAll('.is-active').forEach((r) => r.classList.remove('is-active')); hit.classList.add('is-active'); st.list.setAttribute('aria-activedescendant', hit.id); hit.scrollIntoView({ block: 'nearest' }); } |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | /** The menu sits under the button, or over it when there is more room above; never wider than the screen. */ |
| 372 | function place(st) { |
| 373 | const { btn, wrap } = st; |
| 374 | if (!wrap || st.sheet) return; |
| 375 | const r = btn.getBoundingClientRect(); |
| 376 | const vw = root.innerWidth; const vh = root.innerHeight; |
| 377 | const gap = 6; const edge = 8; |
| 378 | const below = vh - r.bottom - gap - edge; const above = r.top - gap - edge; |
| 379 | const up = below < 220 && above > below; |
| 380 | const room = Math.max(140, Math.min(420, up ? above : below)); |
| 381 | wrap.style.minWidth = `${Math.min(r.width, vw - 2 * edge)}px`; |
| 382 | wrap.style.maxHeight = `${room}px`; |
| 383 | const w = Math.min(Math.max(wrap.offsetWidth, r.width), vw - 2 * edge); |
| 384 | const left = Math.max(edge, Math.min(r.left, vw - edge - w)); |
| 385 | wrap.style.left = `${left}px`; |
| 386 | if (up) { wrap.style.top = ''; wrap.style.bottom = `${vh - r.top + gap}px`; wrap.classList.add('is-up'); } |
| 387 | else { wrap.style.bottom = ''; wrap.style.top = `${r.bottom + gap}px`; wrap.classList.remove('is-up'); } |
| 388 | } |
| 389 | |
| 390 | /** The phone sheet follows a finger down from its head, and closes past a third of its height. */ |
| 391 | function dragToClose(handle, sheet) { |
| 392 | let y0 = null; let dy = 0; |
| 393 | handle.addEventListener('pointerdown', (e) => { if (e.target.closest('button')) return; y0 = e.clientY; dy = 0; handle.setPointerCapture && handle.setPointerCapture(e.pointerId); sheet.style.transition = 'none'; }); |
| 394 | handle.addEventListener('pointermove', (e) => { if (y0 == null) return; dy = Math.max(0, e.clientY - y0); sheet.style.transform = `translateY(${dy}px)`; }); |
| 395 | const end = () => { if (y0 == null) return; y0 = null; sheet.style.transition = ''; sheet.style.transform = ''; if (dy > Math.min(160, sheet.offsetHeight / 3)) close(true); }; |
| 396 | handle.addEventListener('pointerup', end); |
| 397 | handle.addEventListener('pointercancel', end); |
| 398 | } |
| 399 | |
| 400 | function close(refocus) { |
| 401 | const st = open; |
| 402 | if (!st) return; |
| 403 | open = null; |
| 404 | if (st.surface) { st.surface.destroy(); st.surface = null; } |
| 405 | st.btn.setAttribute('aria-expanded', 'false'); |
| 406 | st.btn.removeAttribute('aria-controls'); |
| 407 | const wrap = st.wrap; const scrim = st.scrim; |
| 408 | st.wrap = null; st.list = null; st.scrim = null; st.input = null; |
| 409 | doc.documentElement.classList.remove('ov-sel-locked'); |
| 410 | const drop = (n) => { if (!n) return; try { if (n.matches(':popover-open')) n.hidePopover(); } catch { /* not a popover */ } n.remove(); }; |
| 411 | const gone = () => { drop(wrap); drop(scrim); }; |
| 412 | if (wrap) wrap.classList.remove('is-open'); |
| 413 | if (scrim) scrim.classList.remove('is-open'); |
| 414 | if (st.sheet && !reduce()) setTimeout(gone, 220); else gone(); |
| 415 | if (refocus) st.btn.focus({ preventScroll: true }); |
| 416 | } |
| 417 | |
| 418 | doc.addEventListener('pointerdown', (e) => { |
| 419 | if (!open || open.sheet) return; |
| 420 | if (open.wrap && open.wrap.contains(e.target)) return; |
| 421 | if (open.btn.contains(e.target)) return; |
| 422 | close(false); |
| 423 | }, true); |
| 424 | root.addEventListener('resize', () => { if (open && !open.sheet) place(open); if (open && open.sheet !== phone()) close(false); }); |
| 425 | doc.addEventListener('scroll', (e) => { if (open && !open.sheet && !(open.wrap && open.wrap.contains(e.target))) place(open); }, true); |
| 426 | |
| 427 | function enhance(scope) { |
| 428 | const base = scope && scope.querySelectorAll ? scope : doc; |
| 429 | if (base.tagName === 'SELECT') { enhanceOne(base); return; } |
| 430 | Array.prototype.forEach.call(base.querySelectorAll('select'), enhanceOne); |
| 431 | } |
| 432 | |
| 433 | // New markup (a boosted page move, a dialog, a list a script drew): looked at once the page is quiet again. |
| 434 | let soon = 0; |
| 435 | function watch() { |
| 436 | if (typeof root.MutationObserver !== 'function' || !doc.body) return; |
| 437 | new root.MutationObserver((records) => { |
| 438 | for (const r of records) for (const n of r.addedNodes) { |
| 439 | if (n.nodeType === 1 && (n.tagName === 'SELECT' || (n.querySelector && n.querySelector('select')))) { |
| 440 | if (!soon) soon = setTimeout(() => { soon = 0; enhance(doc); }, 60); |
| 441 | return; |
| 442 | } |
| 443 | } |
| 444 | }).observe(doc.body, { childList: true, subtree: true }); |
| 445 | } |
| 446 | |
| 447 | root.OVForms = { |
| 448 | enhance: (scope) => sheetReady.then((ok) => { if (ok !== false) enhance(scope); }), |
| 449 | refresh: (s) => { const st = done.get(s); if (st) { sync(st); if (open === st) draw(st); } }, |
| 450 | open: (s) => { const st = done.get(s); if (st) show(st); }, |
| 451 | close: () => close(true), |
| 452 | }; |
| 453 | const start = () => sheetReady.then((ok) => { if (ok === false) return; enhance(doc); watch(); }); |
| 454 | if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', start, { once: true }); |
| 455 | else start(); |
| 456 | })(typeof window !== 'undefined' ? window : undefined); |
| 457 |