openvibers/openvibe.shared ov-forms.js

ov-forms.js

main 28.0 KB · 457 lines · javascript

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 'use strict';
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