openvibers/openvibe.shared kit-web.js

kit-web.js

main 26.6 KB · 380 lines · javascript

1/**
2 * openvibe-shared/kit-web.js — the OpenVibe Kit's behaviour in the page, on its own: what a server-rendered site needs
3 * (kit.js's builders already made its markup on the server). About 5 KB; it runs by itself when it loads and again for
4 * markup added later through OpenVibeKitWeb.enhance(container) (kit.js's OpenVibeKit.enhance() calls it too).
5 *
6 * copy buttons [data-ovk-copy="#id"] are shown where the clipboard works and copy the target's text
7 * menus details.ovk-more: one open at a time; a click elsewhere or Escape closes them
8 * the head html.ovk-scrolled once the page scrolls (the sticky head's shadow)
9 * the composer textarea[data-ovk-enter]: Enter sends (Shift+Enter, or a phone's own Enter, is a new line); it grows
10 * confirms form[data-ovk-confirm]: asked first (OpenVibeUI.confirm when the page has it)
11 * sliders input[type=range][data-ovk-unit] say their value
12 * sheets [data-ovk-sheet="#id"] opens dialog.ovk-sheet; the backdrop, Close and Escape close it
13 * tabs a sideways-scrolling tab strip keeps the open tab in view
14 * rails .ovk-rail: ‹ › scroll a page of cards on devices with a pointer, hidden at either end
15 * share [data-ovk-share]: the network's Share sheet (ov-share.js, beside this file); without it, the system
16 * share sheet, else the link copied (the menu item says "Link copied")
17 * sidebars kit.sidebar on a phone: [data-ovk-sidebar-open] opens the drawer; the scrim, its close button, Escape
18 * and a page move close it (document-level, so boost-swapped pages need no wiring)
19 * card previews .ovk-card video[data-src]: a muted loop while the card is hovered or focused (pointers only, never
20 * under reduced motion; the file loads only then)
21 * hold .ovk-btn[data-hold="1200"]: press and hold to confirm (the fill runs; letting go early cancels; Enter or
22 * Space held works the same); the click goes through only after the hold
23 * busy(button, work) shows the work on the button: busy (the words stay, the wait beside them, aria-busy), then a
24 * check for a moment, or a shake and red when it failed; work is a promise or a function returning one
25 *
26 * Idempotent and safe on a page that uses none of it. docs/kit.md describes the pieces.
27 */
28(function (root) {
29 'use strict';
30 // ov-share.js beside this file (its query, so a release's copy goes with this one's), for [data-ovk-share].
31 const SHARE_SRC = (() => { try { const me = document.currentScript; return me && me.src ? me.src.replace(/kit-web\.js(\?[^#]*)?.*$/, 'ov-share.js$1') : ''; } catch (_) { return ''; } })();
32 const CHECK = '<svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>';
33
34 /** Opens or closes a sidebar drawer: its scrim, its buttons' aria-expanded, and the focus into it. */
35 function setSidebar(sb, on) {
36 if (!sb) return;
37 sb.classList.toggle('is-open', on);
38 // The page behind a phone's drawer stays put (11-09, the owner: a swipe on the drawer "scrolls the actual page").
39 document.documentElement.classList.toggle('ovk-drawer-open', !!on);
40 const wrap = sb.closest('.ovk-with-sidebar') || document;
41 const scrim = wrap.querySelector('[data-ovk-sidebar-scrim]');
42 if (scrim) scrim.hidden = !on;
43 Array.prototype.forEach.call(document.querySelectorAll('[data-ovk-sidebar-open]'), (b) => { if (!b.getAttribute('aria-controls') || b.getAttribute('aria-controls') === sb.id) b.setAttribute('aria-expanded', on ? 'true' : 'false'); });
44 if (on) { const first = sb.querySelector('.ovk-sidebar-link.is-here, .ovk-sidebar-action, a, input'); if (first && first.focus) first.focus({ preventScroll: true }); }
45 }
46
47 function enhance(rootEl) {
48 if (typeof document === 'undefined') return;
49 const scope = rootEl || document;
50 const all = (sel) => Array.prototype.slice.call(scope.querySelectorAll ? scope.querySelectorAll(sel) : []);
51 if (scope.querySelector && scope.querySelector('dialog.ovk-sheet, details.ovk-more') && !root.OVSurfaces && !document.getElementById('ov-surfaces-loader')) {
52 const sc = document.createElement('script'); sc.id = 'ov-surfaces-loader'; sc.src = SHARE_SRC ? SHARE_SRC.replace('ov-share.js', 'surfaces.js') : '/shared/surfaces.js'; sc.async = true; document.head.appendChild(sc);
53 }
54 // Marks an element as wired. The mark has its own name (…Wired): `data-ovk-copy="#id"` is dataset.ovkCopy, so a mark
55 // under that same name was already set by the markup, and copy, sheets, confirms and titled shares never wired.
56 const once = (el, key) => { const k = `${key}Wired`; if (el.dataset[k]) return false; el.dataset[k] = '1'; return true; };
57 // Copy buttons: shown only where the clipboard works; the icon turns into a check for a moment.
58 all('[data-ovk-copy]').forEach((b) => {
59 if (!once(b, 'ovkCopy') || !navigator.clipboard) return;
60 b.hidden = false;
61 b.addEventListener('click', () => {
62 const t = document.querySelector(b.getAttribute('data-ovk-copy'));
63 if (!t) return;
64 navigator.clipboard.writeText(t.value != null && t.tagName !== 'PRE' ? t.value : t.textContent).then(() => {
65 const was = b.innerHTML; b.innerHTML = CHECK; b.classList.add('is-done');
66 setTimeout(() => { b.innerHTML = was; b.classList.remove('is-done'); }, 1400);
67 }, () => {});
68 });
69 });
70 // Menus: one open at a time; a click elsewhere or Escape closes them.
71 all('details.ovk-more').forEach((d) => {
72 if (!once(d, 'ovkMore')) return;
73 d.addEventListener('toggle', () => {
74 if (d.open) all('details.ovk-more[open]').forEach((o) => { if (o !== d) o.open = false; });
75 // The layer (surfaces.js 4.235.0): the menu is anchored to its button, in the top layer, placed on the screen,
76 // flipped above it when there is no room below, everywhere (a phone's clipped live layout, a rail, a sheet).
77 if (root.OVSurfaces) {
78 if (!d.__ovSurface) d.__ovSurface = root.OVSurfaces.popover({ el: d, trigger: d.querySelector('summary'), pop: d.querySelector('.ovk-pop'), close: () => { d.open = false; } });
79 if (d.open) d.__ovSurface.open(); else d.__ovSurface.close();
80 if (typeof root.OVSurfaces.anchor === 'function') return;
81 }
82 // Until surfaces.js has loaded: inside a rail (a sideways scroller clips anything that hangs out of it, and its edge fade is a mask that hides
83 // even a fixed menu outside its box: Community's paste cards, 11-11) the menu opens in the top layer, placed on
84 // the screen. Without the Popover API it stays fixed, as before.
85 const pop = d.querySelector('.ovk-pop');
86 if (!pop || !d.closest('.ovk-rail-track, [data-ovk-scroll]')) return;
87 const topLayer = typeof pop.showPopover === 'function';
88 pop.classList.toggle('is-fixed', d.open);
89 if (!d.open) {
90 if (topLayer && pop.hasAttribute('popover')) { try { pop.hidePopover(); } catch { /* already closed */ } pop.removeAttribute('popover'); }
91 pop.style.left = pop.style.top = '';
92 return;
93 }
94 if (topLayer) { pop.setAttribute('popover', 'manual'); try { pop.showPopover(); } catch { /* stays fixed */ } }
95 const r = d.getBoundingClientRect();
96 const w = pop.offsetWidth, h = pop.offsetHeight;
97 pop.style.left = `${Math.max(8, Math.min(r.right - w, innerWidth - w - 8))}px`;
98 pop.style.top = `${r.bottom + 4 + h > innerHeight - 8 ? Math.max(8, r.top - h - 4) : r.bottom + 4}px`;
99 const shut = () => { d.open = false; };
100 const track = d.closest('.ovk-rail-track');
101 track.addEventListener('scroll', shut, { once: true, passive: true });
102 window.addEventListener('scroll', shut, { once: true, passive: true });
103 });
104 });
105 if (!document.documentElement.dataset.ovkDoc) {
106 document.documentElement.dataset.ovkDoc = '1';
107 document.addEventListener('pointerdown', (e) => {
108 Array.prototype.forEach.call(document.querySelectorAll('details.ovk-more[open]'), (d) => { if (!d.contains(e.target)) d.open = false; });
109 }, true);
110 document.addEventListener('keydown', (e) => {
111 if (e.key !== 'Escape') return;
112 Array.prototype.forEach.call(document.querySelectorAll('details.ovk-more[open]'), (d) => { d.open = false; const s = d.querySelector('summary'); if (s) s.focus(); });
113 Array.prototype.forEach.call(document.querySelectorAll('.ovk-sidebar.is-open'), (sb) => setSidebar(sb, false));
114 });
115 // The left sidebar on a phone (kit.sidebar): its button opens it; the scrim, its close button, Escape and a page
116 // move close it. Handled on the document, so a page boost swapped in works without being wired again.
117 document.addEventListener('click', (e) => {
118 const t = e.target && e.target.closest ? e.target : null;
119 if (!t) return;
120 const open = t.closest('[data-ovk-sidebar-open]');
121 if (open) { setSidebar(document.getElementById(open.getAttribute('aria-controls') || '') || document.querySelector('[data-ovk-sidebar]'), true); return; }
122 if (t.closest('[data-ovk-sidebar-close], [data-ovk-sidebar-scrim]')) Array.prototype.forEach.call(document.querySelectorAll('.ovk-sidebar.is-open'), (sb) => setSidebar(sb, false));
123 });
124 document.addEventListener('ov:boost:before', () => Array.prototype.forEach.call(document.querySelectorAll('.ovk-sidebar.is-open'), (sb) => setSidebar(sb, false)));
125 // The head casts a shadow once the page has scrolled under it.
126 let ticking = false;
127 const mark = () => { ticking = false; document.documentElement.classList.toggle('ovk-scrolled', (window.scrollY || 0) > 4); };
128 window.addEventListener('scroll', () => { if (!ticking) { ticking = true; requestAnimationFrame(mark); } }, { passive: true });
129 mark();
130 }
131 // The composer: Enter sends (Shift+Enter, or a phone's own Enter key, is a new line); the box grows with the text.
132 const coarse = typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches;
133 all('textarea[data-ovk-enter]').forEach((ta) => {
134 if (!once(ta, 'ovkEnter')) return;
135 const grow = () => { if (CSS && CSS.supports && CSS.supports('field-sizing', 'content')) return; ta.style.height = 'auto'; ta.style.height = `${Math.min(ta.scrollHeight, Math.round(innerHeight * 0.4))}px`; };
136 ta.addEventListener('input', grow);
137 ta.addEventListener('keydown', (e) => {
138 if (e.key !== 'Enter' || e.shiftKey || e.isComposing || coarse) return;
139 e.preventDefault();
140 const f = ta.form; if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }
141 });
142 });
143 // A form that asks first (delete, leave): the network's confirm dialog when the page has it.
144 all('form[data-ovk-confirm]').forEach((f) => {
145 if (!once(f, 'ovkConfirm')) return;
146 f.addEventListener('submit', (e) => {
147 if (f.dataset.ovkConfirmed) { delete f.dataset.ovkConfirmed; return; }
148 e.preventDefault();
149 const msg = f.getAttribute('data-ovk-confirm');
150 const ask = window.OpenVibeUI && window.OpenVibeUI.confirm ? window.OpenVibeUI.confirm({ title: msg, danger: true }) : Promise.resolve(window.confirm(msg));
151 ask.then((ok) => { if (ok) { f.dataset.ovkConfirmed = '1'; if (f.requestSubmit) f.requestSubmit(e.submitter || undefined); else f.submit(); } });
152 });
153 });
154 // Sliders say their value.
155 all('input[type=range][data-ovk-unit]').forEach((r) => {
156 if (!once(r, 'ovkRange')) return;
157 const u = r.getAttribute('data-ovk-unit');
158 r.addEventListener('input', () => r.setAttribute('aria-valuetext', `${r.value}${u === '%' ? ' percent' : ` ${u}`}`));
159 });
160 // Sheets: a button opens its dialog; the backdrop, Close and Escape close it.
161 all('[data-ovk-sheet]').forEach((b) => {
162 if (!once(b, 'ovkSheet')) return;
163 b.addEventListener('click', () => {
164 const d = document.querySelector(b.getAttribute('data-ovk-sheet'));
165 if (d && d.showModal && !d.open) d.showModal();
166 });
167 });
168 all('dialog.ovk-sheet').forEach((d) => {
169 if (!once(d, 'ovkDialog')) return;
170 d.addEventListener('click', (e) => { if (e.target === d || (e.target.closest && e.target.closest('[data-ovk-close]'))) d.close(); });
171 });
172 // Tabs that scroll sideways keep the open one in view.
173 // Only the strip moves, sideways: scrollIntoView would also scroll the page to a strip below the fold.
174 all('.ovk-tabs [aria-current="page"]').forEach((a) => {
175 if (!once(a, 'ovkTab')) return;
176 const strip = a.closest('.ovk-tabs');
177 if (!strip || strip.scrollWidth <= strip.clientWidth) return;
178 const ar = a.getBoundingClientRect(), sr = strip.getBoundingClientRect();
179 strip.scrollLeft += (ar.left + ar.width / 2) - (sr.left + sr.width / 2);
180 });
181 // Rails: ‹ › move a page of cards where there is a pointer to click them; each hides at its end.
182 const fine = typeof matchMedia === 'function' && matchMedia('(hover: hover) and (pointer: fine)').matches;
183 // Motion (4.183.0): only Off keeps a strip still. Calm glides, nudges half as far and drifts at half the speed
184 // (<html data-ov-motion>, theme-loader.js; a page without the loader asks the device).
185 const motionAttr = () => document.documentElement.getAttribute('data-ov-motion');
186 const still = () => motionAttr() === 'off';
187 const gentle = () => { const m = motionAttr(); return m === 'reduced' || (!m && typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) ? 0.5 : 1; };
188 all('.ovk-rail').forEach((r) => {
189 if (!once(r, 'ovkRail')) return;
190 const track = r.querySelector('.ovk-rail-track');
191 const prev = r.querySelector('.ovk-rail-prev');
192 const next = r.querySelector('.ovk-rail-next');
193 if (!track || !prev || !next || !fine) return;
194 const update = () => {
195 const max = track.scrollWidth - track.clientWidth;
196 prev.hidden = track.scrollLeft <= 4;
197 next.hidden = track.scrollLeft >= max - 4;
198 };
199 const go = (dir) => track.scrollBy({ left: dir * Math.max(track.clientWidth * 0.85, 200), behavior: still() ? 'auto' : 'smooth' });
200 prev.addEventListener('click', () => go(-1));
201 next.addEventListener('click', () => go(1));
202 track.addEventListener('scroll', () => requestAnimationFrame(update), { passive: true });
203 if (typeof ResizeObserver === 'function') new ResizeObserver(update).observe(track); else window.addEventListener('resize', update);
204 update();
205 });
206 // Every sideways strip says it scrolls (11-09, the owner: "it's not very apparent you can horizontal scroll… a little
207 // bounce… or auto scroll until you tap"): a kit rail's track, or any element marked data-ovk-scroll. Its edges fade
208 // where more is hidden (is-more-start / is-more-end); on a touch screen it nudges once when it first comes into view;
209 // data-ovk-scroll="drift" drifts slowly while in view (back and forth) and stops for good at the first touch,
210 // wheel or keyboard focus. Reduced motion: fades only.
211 all('.ovk-rail-track, [data-ovk-scroll]').forEach((track) => {
212 if (!once(track, 'ovkScroll')) return;
213 // A thin position bar under the strip (a thumb as wide as the share in view), shown while there is more.
214 let bar = null, thumb = null;
215 if (track.getAttribute('data-ovk-scroll-bar') !== 'off') {
216 bar = document.createElement('span'); bar.className = 'ovk-scroll-bar'; bar.setAttribute('aria-hidden', 'true'); bar.hidden = true;
217 thumb = document.createElement('i'); bar.appendChild(thumb);
218 track.insertAdjacentElement('afterend', bar);
219 }
220 const edges = () => {
221 const max = track.scrollWidth - track.clientWidth;
222 track.classList.toggle('is-more-start', max > 4 && track.scrollLeft > 4);
223 track.classList.toggle('is-more-end', max > 4 && track.scrollLeft < max - 4);
224 if (bar) {
225 bar.hidden = max <= 4;
226 if (max > 4) { const share = track.clientWidth / track.scrollWidth; thumb.style.width = `${Math.max(14, share * 100)}%`; thumb.style.transform = `translateX(${(track.scrollLeft / max) * ((1 / Math.max(share, 0.14)) - 1) * 100}%)`; }
227 }
228 };
229 track.addEventListener('scroll', () => requestAnimationFrame(edges), { passive: true });
230 if (typeof ResizeObserver === 'function') new ResizeObserver(edges).observe(track);
231 edges();
232 if (still() || typeof IntersectionObserver !== 'function') return;
233 const drift = track.getAttribute('data-ovk-scroll') === 'drift';
234 let touched = false, inView = false, raf = 0, dir = 1, last = 0, pos = -1;
235 const stop = () => { touched = true; cancelAnimationFrame(raf); raf = 0; };
236 ['pointerdown', 'wheel', 'focusin', 'touchstart'].forEach((ev) => track.addEventListener(ev, stop, { passive: true, once: true }));
237 const step = (t) => {
238 raf = 0;
239 if (touched || !inView || document.hidden) return;
240 const dt = last ? Math.min(64, t - last) : 16; last = t;
241 const max = track.scrollWidth - track.clientWidth;
242 if (max <= 4) return;
243 // A float of its own: a browser rounds scrollLeft, and under a pixel a frame would never move.
244 if (pos < 0 || Math.abs(pos - track.scrollLeft) > 2) pos = track.scrollLeft;
245 pos = Math.max(0, Math.min(max, pos + dir * dt * 0.028 * gentle()));
246 track.scrollLeft = pos;
247 if (pos >= max - 1) dir = -1; else if (pos <= 0) dir = 1;
248 raf = requestAnimationFrame(step);
249 };
250 let nudged = false;
251 new IntersectionObserver((entries) => {
252 for (const e of entries) {
253 inView = e.isIntersecting && e.intersectionRatio >= 0.6;
254 if (!inView || touched) continue;
255 const max = track.scrollWidth - track.clientWidth;
256 if (max <= 4) continue;
257 if (drift) { last = 0; if (!raf) raf = requestAnimationFrame(step); continue; }
258 // One nudge, phones only (a computer has the ‹ › buttons): 48 px along and back (24 under Calm).
259 if (!nudged && !fine && track.scrollLeft < 4) {
260 nudged = true;
261 setTimeout(() => { if (touched) return; const far = 48 * gentle(); track.scrollBy({ left: far, behavior: 'smooth' }); setTimeout(() => { if (!touched) track.scrollBy({ left: -far, behavior: 'smooth' }); }, 420); }, 350);
262 }
263 }
264 }, { threshold: [0, 0.6] }).observe(track);
265 });
266 // Share: the system sheet where there is one, else the link copied, said in the item itself.
267 all('[data-ovk-share]').forEach((a) => {
268 if (!once(a, 'ovkShare')) return;
269 a.addEventListener('click', (e) => {
270 e.preventDefault();
271 const link = new URL(a.getAttribute('href'), location.href).href;
272 const title = a.getAttribute('data-ovk-share') || document.title;
273 const done = () => { const d = a.closest('details'); if (d) d.open = false; };
274 // The network's one Share sheet (ov-share.js, 4.114.0) when it can load; the device's own sheet, or the
275 // link copied, when it cannot.
276 const sheet = () => { done(); root.OVShare.open(Object.assign(root.OVShare.fromButton(a), { url: link, title })); };
277 if (root.OVShare) { sheet(); return; }
278 if (SHARE_SRC && !a.dataset.ovkShareTried) {
279 a.dataset.ovkShareTried = '1';
280 if (!document.querySelector('link[href*="ov-share.css"]')) { const ln = document.createElement('link'); ln.rel = 'stylesheet'; ln.href = SHARE_SRC.replace('ov-share.js', 'ov-share.css'); document.head.appendChild(ln); }
281 const sc = document.createElement('script'); sc.src = SHARE_SRC; sc.async = true;
282 sc.onload = () => { if (root.OVShare) sheet(); else a.click(); };
283 sc.onerror = () => a.click();
284 document.head.appendChild(sc);
285 return;
286 }
287 if (navigator.share) { navigator.share({ title, url: link }).then(done, () => {}); return; }
288 if (!navigator.clipboard) { location.href = link; return; }
289 navigator.clipboard.writeText(link).then(() => {
290 const span = a.querySelector('span');
291 const was = span ? span.textContent : '';
292 if (span) span.textContent = 'Link copied';
293 setTimeout(() => { if (span) span.textContent = was; done(); }, 1200);
294 }, () => {});
295 });
296 });
297 // Card previews: a muted loop while hovered or focused, on computers only, never under reduced motion.
298 if (fine) all('.ovk-card').forEach((c) => {
299 const v = c.querySelector('video.ovk-card-video[data-src]');
300 if (!v || !once(c, 'ovkPeek')) return;
301 const on = () => { if (still()) return; if (!v.src) v.src = v.getAttribute('data-src'); c.classList.add('is-playing'); const p = v.play(); if (p && p.catch) p.catch(() => {}); };
302 const off = () => { c.classList.remove('is-playing'); try { v.pause(); } catch (e) { /* not started */ } };
303 c.addEventListener('mouseenter', on); c.addEventListener('mouseleave', off);
304 c.addEventListener('focusin', on); c.addEventListener('focusout', off);
305 });
306 }
307
308
309 /** The work a button started, shown on the button. Resolves with the work's value; rethrows its error. */
310 async function busy(btn, work) {
311 if (!btn) return typeof work === 'function' ? work() : work;
312 const wait = document.createElement('span');
313 wait.className = 'ovk-btn-wait'; wait.setAttribute('aria-hidden', 'true'); wait.innerHTML = '<i></i><i></i><i></i>';
314 const wasDisabled = btn.disabled;
315 btn.dataset.state = 'busy'; btn.setAttribute('aria-busy', 'true'); btn.appendChild(wait);
316 if ('disabled' in btn) btn.disabled = true;
317 const settle = (state) => {
318 wait.remove(); btn.removeAttribute('aria-busy');
319 if ('disabled' in btn) btn.disabled = wasDisabled;
320 btn.dataset.state = state;
321 setTimeout(() => { if (btn.dataset.state === state) delete btn.dataset.state; }, state === 'done' ? 1200 : 1600);
322 };
323 try {
324 const out = await (typeof work === 'function' ? work() : work);
325 settle('done');
326 return out;
327 } catch (e) { settle('error'); throw e; }
328 }
329
330 // Hold to confirm: document-level, so buttons added later need no wiring.
331 function bindHold() {
332 if (typeof document === 'undefined' || root.__ovkHoldBound) return;
333 root.__ovkHoldBound = true;
334 let held = null;
335 const stop = (fire) => {
336 if (!held) return;
337 const { btn, raf, ok } = held; held = null;
338 cancelAnimationFrame(raf); btn.classList.remove('is-holding'); btn.style.removeProperty('--ovk-hold');
339 if (fire && ok) { btn.dataset.ovkHeld = '1'; btn.click(); }
340 };
341 const start = (btn) => {
342 const ms = Math.max(400, Math.min(4000, Number(btn.getAttribute('data-hold')) || 1200));
343 const t0 = performance.now();
344 held = { btn, raf: 0, ok: false };
345 btn.classList.add('is-holding');
346 const tick = (t) => {
347 if (!held || held.btn !== btn) return;
348 const f = Math.min(1, (t - t0) / ms);
349 btn.style.setProperty('--ovk-hold', `${Math.round(f * 100)}%`);
350 if (f >= 1) { held.ok = true; stop(true); return; }
351 held.raf = requestAnimationFrame(tick);
352 };
353 held.raf = requestAnimationFrame(tick);
354 };
355 document.addEventListener('pointerdown', (e) => { const b = e.target.closest && e.target.closest('.ovk-btn[data-hold]'); if (b && !b.disabled && e.button === 0) start(b); });
356 document.addEventListener('pointerup', () => stop(false));
357 document.addEventListener('pointercancel', () => stop(false));
358 document.addEventListener('keydown', (e) => { const b = e.target.closest && e.target.closest('.ovk-btn[data-hold]'); if (b && !e.repeat && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); start(b); } });
359 document.addEventListener('keyup', (e) => { if (e.key === 'Enter' || e.key === ' ') stop(false); });
360 // A plain click on a hold button does nothing until the hold has run.
361 document.addEventListener('click', (e) => {
362 const b = e.target.closest && e.target.closest('.ovk-btn[data-hold]');
363 if (!b) return;
364 if (b.dataset.ovkHeld === '1') { delete b.dataset.ovkHeld; return; }
365 e.preventDefault(); e.stopImmediatePropagation();
366 }, true);
367 }
368 bindHold();
369
370 const api = { enhance, busy };
371 root.OpenVibeKitWeb = api;
372 if (typeof module === 'object' && module.exports) { module.exports = api; return; }
373 if (typeof document !== 'undefined') {
374 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => enhance(document));
375 else enhance(document);
376 // A boosted page swap (boost.js) brings new markup without a load: enhance it too (enhance is idempotent).
377 document.addEventListener('ov:boost:load', () => enhance(document));
378 }
379})(typeof globalThis !== 'undefined' ? globalThis : this);
380