/** * openvibe-shared/kit-web.js — the OpenVibe Kit's behaviour in the page, on its own: what a server-rendered site needs * (kit.js's builders already made its markup on the server). About 5 KB; it runs by itself when it loads and again for * markup added later through OpenVibeKitWeb.enhance(container) (kit.js's OpenVibeKit.enhance() calls it too). * * copy buttons [data-ovk-copy="#id"] are shown where the clipboard works and copy the target's text * menus details.ovk-more: one open at a time; a click elsewhere or Escape closes them * the head html.ovk-scrolled once the page scrolls (the sticky head's shadow) * the composer textarea[data-ovk-enter]: Enter sends (Shift+Enter, or a phone's own Enter, is a new line); it grows * confirms form[data-ovk-confirm]: asked first (OpenVibeUI.confirm when the page has it) * sliders input[type=range][data-ovk-unit] say their value * sheets [data-ovk-sheet="#id"] opens dialog.ovk-sheet; the backdrop, Close and Escape close it * tabs a sideways-scrolling tab strip keeps the open tab in view * rails .ovk-rail: ‹ › scroll a page of cards on devices with a pointer, hidden at either end * share [data-ovk-share]: the network's Share sheet (ov-share.js, beside this file); without it, the system * share sheet, else the link copied (the menu item says "Link copied") * sidebars kit.sidebar on a phone: [data-ovk-sidebar-open] opens the drawer; the scrim, its close button, Escape * and a page move close it (document-level, so boost-swapped pages need no wiring) * card previews .ovk-card video[data-src]: a muted loop while the card is hovered or focused (pointers only, never * under reduced motion; the file loads only then) * hold .ovk-btn[data-hold="1200"]: press and hold to confirm (the fill runs; letting go early cancels; Enter or * Space held works the same); the click goes through only after the hold * busy(button, work) shows the work on the button: busy (the words stay, the wait beside them, aria-busy), then a * check for a moment, or a shake and red when it failed; work is a promise or a function returning one * * Idempotent and safe on a page that uses none of it. docs/kit.md describes the pieces. */ (function (root) { 'use strict'; // ov-share.js beside this file (its query, so a release's copy goes with this one's), for [data-ovk-share]. const SHARE_SRC = (() => { try { const me = document.currentScript; return me && me.src ? me.src.replace(/kit-web\.js(\?[^#]*)?.*$/, 'ov-share.js$1') : ''; } catch (_) { return ''; } })(); const CHECK = ''; /** Opens or closes a sidebar drawer: its scrim, its buttons' aria-expanded, and the focus into it. */ function setSidebar(sb, on) { if (!sb) return; sb.classList.toggle('is-open', on); // The page behind a phone's drawer stays put (11-09, the owner: a swipe on the drawer "scrolls the actual page"). document.documentElement.classList.toggle('ovk-drawer-open', !!on); const wrap = sb.closest('.ovk-with-sidebar') || document; const scrim = wrap.querySelector('[data-ovk-sidebar-scrim]'); if (scrim) scrim.hidden = !on; 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'); }); if (on) { const first = sb.querySelector('.ovk-sidebar-link.is-here, .ovk-sidebar-action, a, input'); if (first && first.focus) first.focus({ preventScroll: true }); } } function enhance(rootEl) { if (typeof document === 'undefined') return; const scope = rootEl || document; const all = (sel) => Array.prototype.slice.call(scope.querySelectorAll ? scope.querySelectorAll(sel) : []); if (scope.querySelector && scope.querySelector('dialog.ovk-sheet, details.ovk-more') && !root.OVSurfaces && !document.getElementById('ov-surfaces-loader')) { 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); } // Marks an element as wired. The mark has its own name (…Wired): `data-ovk-copy="#id"` is dataset.ovkCopy, so a mark // under that same name was already set by the markup, and copy, sheets, confirms and titled shares never wired. const once = (el, key) => { const k = `${key}Wired`; if (el.dataset[k]) return false; el.dataset[k] = '1'; return true; }; // Copy buttons: shown only where the clipboard works; the icon turns into a check for a moment. all('[data-ovk-copy]').forEach((b) => { if (!once(b, 'ovkCopy') || !navigator.clipboard) return; b.hidden = false; b.addEventListener('click', () => { const t = document.querySelector(b.getAttribute('data-ovk-copy')); if (!t) return; navigator.clipboard.writeText(t.value != null && t.tagName !== 'PRE' ? t.value : t.textContent).then(() => { const was = b.innerHTML; b.innerHTML = CHECK; b.classList.add('is-done'); setTimeout(() => { b.innerHTML = was; b.classList.remove('is-done'); }, 1400); }, () => {}); }); }); // Menus: one open at a time; a click elsewhere or Escape closes them. all('details.ovk-more').forEach((d) => { if (!once(d, 'ovkMore')) return; d.addEventListener('toggle', () => { if (d.open) all('details.ovk-more[open]').forEach((o) => { if (o !== d) o.open = false; }); // The layer (surfaces.js 4.235.0): the menu is anchored to its button, in the top layer, placed on the screen, // flipped above it when there is no room below, everywhere (a phone's clipped live layout, a rail, a sheet). if (root.OVSurfaces) { if (!d.__ovSurface) d.__ovSurface = root.OVSurfaces.popover({ el: d, trigger: d.querySelector('summary'), pop: d.querySelector('.ovk-pop'), close: () => { d.open = false; } }); if (d.open) d.__ovSurface.open(); else d.__ovSurface.close(); if (typeof root.OVSurfaces.anchor === 'function') return; } // 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 // even a fixed menu outside its box: Community's paste cards, 11-11) the menu opens in the top layer, placed on // the screen. Without the Popover API it stays fixed, as before. const pop = d.querySelector('.ovk-pop'); if (!pop || !d.closest('.ovk-rail-track, [data-ovk-scroll]')) return; const topLayer = typeof pop.showPopover === 'function'; pop.classList.toggle('is-fixed', d.open); if (!d.open) { if (topLayer && pop.hasAttribute('popover')) { try { pop.hidePopover(); } catch { /* already closed */ } pop.removeAttribute('popover'); } pop.style.left = pop.style.top = ''; return; } if (topLayer) { pop.setAttribute('popover', 'manual'); try { pop.showPopover(); } catch { /* stays fixed */ } } const r = d.getBoundingClientRect(); const w = pop.offsetWidth, h = pop.offsetHeight; pop.style.left = `${Math.max(8, Math.min(r.right - w, innerWidth - w - 8))}px`; pop.style.top = `${r.bottom + 4 + h > innerHeight - 8 ? Math.max(8, r.top - h - 4) : r.bottom + 4}px`; const shut = () => { d.open = false; }; const track = d.closest('.ovk-rail-track'); track.addEventListener('scroll', shut, { once: true, passive: true }); window.addEventListener('scroll', shut, { once: true, passive: true }); }); }); if (!document.documentElement.dataset.ovkDoc) { document.documentElement.dataset.ovkDoc = '1'; document.addEventListener('pointerdown', (e) => { Array.prototype.forEach.call(document.querySelectorAll('details.ovk-more[open]'), (d) => { if (!d.contains(e.target)) d.open = false; }); }, true); document.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; Array.prototype.forEach.call(document.querySelectorAll('details.ovk-more[open]'), (d) => { d.open = false; const s = d.querySelector('summary'); if (s) s.focus(); }); Array.prototype.forEach.call(document.querySelectorAll('.ovk-sidebar.is-open'), (sb) => setSidebar(sb, false)); }); // The left sidebar on a phone (kit.sidebar): its button opens it; the scrim, its close button, Escape and a page // move close it. Handled on the document, so a page boost swapped in works without being wired again. document.addEventListener('click', (e) => { const t = e.target && e.target.closest ? e.target : null; if (!t) return; const open = t.closest('[data-ovk-sidebar-open]'); if (open) { setSidebar(document.getElementById(open.getAttribute('aria-controls') || '') || document.querySelector('[data-ovk-sidebar]'), true); return; } 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)); }); document.addEventListener('ov:boost:before', () => Array.prototype.forEach.call(document.querySelectorAll('.ovk-sidebar.is-open'), (sb) => setSidebar(sb, false))); // The head casts a shadow once the page has scrolled under it. let ticking = false; const mark = () => { ticking = false; document.documentElement.classList.toggle('ovk-scrolled', (window.scrollY || 0) > 4); }; window.addEventListener('scroll', () => { if (!ticking) { ticking = true; requestAnimationFrame(mark); } }, { passive: true }); mark(); } // The composer: Enter sends (Shift+Enter, or a phone's own Enter key, is a new line); the box grows with the text. const coarse = typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches; all('textarea[data-ovk-enter]').forEach((ta) => { if (!once(ta, 'ovkEnter')) return; 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`; }; ta.addEventListener('input', grow); ta.addEventListener('keydown', (e) => { if (e.key !== 'Enter' || e.shiftKey || e.isComposing || coarse) return; e.preventDefault(); const f = ta.form; if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); } }); }); // A form that asks first (delete, leave): the network's confirm dialog when the page has it. all('form[data-ovk-confirm]').forEach((f) => { if (!once(f, 'ovkConfirm')) return; f.addEventListener('submit', (e) => { if (f.dataset.ovkConfirmed) { delete f.dataset.ovkConfirmed; return; } e.preventDefault(); const msg = f.getAttribute('data-ovk-confirm'); const ask = window.OpenVibeUI && window.OpenVibeUI.confirm ? window.OpenVibeUI.confirm({ title: msg, danger: true }) : Promise.resolve(window.confirm(msg)); ask.then((ok) => { if (ok) { f.dataset.ovkConfirmed = '1'; if (f.requestSubmit) f.requestSubmit(e.submitter || undefined); else f.submit(); } }); }); }); // Sliders say their value. all('input[type=range][data-ovk-unit]').forEach((r) => { if (!once(r, 'ovkRange')) return; const u = r.getAttribute('data-ovk-unit'); r.addEventListener('input', () => r.setAttribute('aria-valuetext', `${r.value}${u === '%' ? ' percent' : ` ${u}`}`)); }); // Sheets: a button opens its dialog; the backdrop, Close and Escape close it. all('[data-ovk-sheet]').forEach((b) => { if (!once(b, 'ovkSheet')) return; b.addEventListener('click', () => { const d = document.querySelector(b.getAttribute('data-ovk-sheet')); if (d && d.showModal && !d.open) d.showModal(); }); }); all('dialog.ovk-sheet').forEach((d) => { if (!once(d, 'ovkDialog')) return; d.addEventListener('click', (e) => { if (e.target === d || (e.target.closest && e.target.closest('[data-ovk-close]'))) d.close(); }); }); // Tabs that scroll sideways keep the open one in view. // Only the strip moves, sideways: scrollIntoView would also scroll the page to a strip below the fold. all('.ovk-tabs [aria-current="page"]').forEach((a) => { if (!once(a, 'ovkTab')) return; const strip = a.closest('.ovk-tabs'); if (!strip || strip.scrollWidth <= strip.clientWidth) return; const ar = a.getBoundingClientRect(), sr = strip.getBoundingClientRect(); strip.scrollLeft += (ar.left + ar.width / 2) - (sr.left + sr.width / 2); }); // Rails: ‹ › move a page of cards where there is a pointer to click them; each hides at its end. const fine = typeof matchMedia === 'function' && matchMedia('(hover: hover) and (pointer: fine)').matches; // Motion (4.183.0): only Off keeps a strip still. Calm glides, nudges half as far and drifts at half the speed // (, theme-loader.js; a page without the loader asks the device). const motionAttr = () => document.documentElement.getAttribute('data-ov-motion'); const still = () => motionAttr() === 'off'; const gentle = () => { const m = motionAttr(); return m === 'reduced' || (!m && typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) ? 0.5 : 1; }; all('.ovk-rail').forEach((r) => { if (!once(r, 'ovkRail')) return; const track = r.querySelector('.ovk-rail-track'); const prev = r.querySelector('.ovk-rail-prev'); const next = r.querySelector('.ovk-rail-next'); if (!track || !prev || !next || !fine) return; const update = () => { const max = track.scrollWidth - track.clientWidth; prev.hidden = track.scrollLeft <= 4; next.hidden = track.scrollLeft >= max - 4; }; const go = (dir) => track.scrollBy({ left: dir * Math.max(track.clientWidth * 0.85, 200), behavior: still() ? 'auto' : 'smooth' }); prev.addEventListener('click', () => go(-1)); next.addEventListener('click', () => go(1)); track.addEventListener('scroll', () => requestAnimationFrame(update), { passive: true }); if (typeof ResizeObserver === 'function') new ResizeObserver(update).observe(track); else window.addEventListener('resize', update); update(); }); // Every sideways strip says it scrolls (11-09, the owner: "it's not very apparent you can horizontal scroll… a little // bounce… or auto scroll until you tap"): a kit rail's track, or any element marked data-ovk-scroll. Its edges fade // where more is hidden (is-more-start / is-more-end); on a touch screen it nudges once when it first comes into view; // data-ovk-scroll="drift" drifts slowly while in view (back and forth) and stops for good at the first touch, // wheel or keyboard focus. Reduced motion: fades only. all('.ovk-rail-track, [data-ovk-scroll]').forEach((track) => { if (!once(track, 'ovkScroll')) return; // A thin position bar under the strip (a thumb as wide as the share in view), shown while there is more. let bar = null, thumb = null; if (track.getAttribute('data-ovk-scroll-bar') !== 'off') { bar = document.createElement('span'); bar.className = 'ovk-scroll-bar'; bar.setAttribute('aria-hidden', 'true'); bar.hidden = true; thumb = document.createElement('i'); bar.appendChild(thumb); track.insertAdjacentElement('afterend', bar); } const edges = () => { const max = track.scrollWidth - track.clientWidth; track.classList.toggle('is-more-start', max > 4 && track.scrollLeft > 4); track.classList.toggle('is-more-end', max > 4 && track.scrollLeft < max - 4); if (bar) { bar.hidden = max <= 4; 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}%)`; } } }; track.addEventListener('scroll', () => requestAnimationFrame(edges), { passive: true }); if (typeof ResizeObserver === 'function') new ResizeObserver(edges).observe(track); edges(); if (still() || typeof IntersectionObserver !== 'function') return; const drift = track.getAttribute('data-ovk-scroll') === 'drift'; let touched = false, inView = false, raf = 0, dir = 1, last = 0, pos = -1; const stop = () => { touched = true; cancelAnimationFrame(raf); raf = 0; }; ['pointerdown', 'wheel', 'focusin', 'touchstart'].forEach((ev) => track.addEventListener(ev, stop, { passive: true, once: true })); const step = (t) => { raf = 0; if (touched || !inView || document.hidden) return; const dt = last ? Math.min(64, t - last) : 16; last = t; const max = track.scrollWidth - track.clientWidth; if (max <= 4) return; // A float of its own: a browser rounds scrollLeft, and under a pixel a frame would never move. if (pos < 0 || Math.abs(pos - track.scrollLeft) > 2) pos = track.scrollLeft; pos = Math.max(0, Math.min(max, pos + dir * dt * 0.028 * gentle())); track.scrollLeft = pos; if (pos >= max - 1) dir = -1; else if (pos <= 0) dir = 1; raf = requestAnimationFrame(step); }; let nudged = false; new IntersectionObserver((entries) => { for (const e of entries) { inView = e.isIntersecting && e.intersectionRatio >= 0.6; if (!inView || touched) continue; const max = track.scrollWidth - track.clientWidth; if (max <= 4) continue; if (drift) { last = 0; if (!raf) raf = requestAnimationFrame(step); continue; } // One nudge, phones only (a computer has the ‹ › buttons): 48 px along and back (24 under Calm). if (!nudged && !fine && track.scrollLeft < 4) { nudged = true; 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); } } }, { threshold: [0, 0.6] }).observe(track); }); // Share: the system sheet where there is one, else the link copied, said in the item itself. all('[data-ovk-share]').forEach((a) => { if (!once(a, 'ovkShare')) return; a.addEventListener('click', (e) => { e.preventDefault(); const link = new URL(a.getAttribute('href'), location.href).href; const title = a.getAttribute('data-ovk-share') || document.title; const done = () => { const d = a.closest('details'); if (d) d.open = false; }; // The network's one Share sheet (ov-share.js, 4.114.0) when it can load; the device's own sheet, or the // link copied, when it cannot. const sheet = () => { done(); root.OVShare.open(Object.assign(root.OVShare.fromButton(a), { url: link, title })); }; if (root.OVShare) { sheet(); return; } if (SHARE_SRC && !a.dataset.ovkShareTried) { a.dataset.ovkShareTried = '1'; 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); } const sc = document.createElement('script'); sc.src = SHARE_SRC; sc.async = true; sc.onload = () => { if (root.OVShare) sheet(); else a.click(); }; sc.onerror = () => a.click(); document.head.appendChild(sc); return; } if (navigator.share) { navigator.share({ title, url: link }).then(done, () => {}); return; } if (!navigator.clipboard) { location.href = link; return; } navigator.clipboard.writeText(link).then(() => { const span = a.querySelector('span'); const was = span ? span.textContent : ''; if (span) span.textContent = 'Link copied'; setTimeout(() => { if (span) span.textContent = was; done(); }, 1200); }, () => {}); }); }); // Card previews: a muted loop while hovered or focused, on computers only, never under reduced motion. if (fine) all('.ovk-card').forEach((c) => { const v = c.querySelector('video.ovk-card-video[data-src]'); if (!v || !once(c, 'ovkPeek')) return; 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(() => {}); }; const off = () => { c.classList.remove('is-playing'); try { v.pause(); } catch (e) { /* not started */ } }; c.addEventListener('mouseenter', on); c.addEventListener('mouseleave', off); c.addEventListener('focusin', on); c.addEventListener('focusout', off); }); } /** The work a button started, shown on the button. Resolves with the work's value; rethrows its error. */ async function busy(btn, work) { if (!btn) return typeof work === 'function' ? work() : work; const wait = document.createElement('span'); wait.className = 'ovk-btn-wait'; wait.setAttribute('aria-hidden', 'true'); wait.innerHTML = ''; const wasDisabled = btn.disabled; btn.dataset.state = 'busy'; btn.setAttribute('aria-busy', 'true'); btn.appendChild(wait); if ('disabled' in btn) btn.disabled = true; const settle = (state) => { wait.remove(); btn.removeAttribute('aria-busy'); if ('disabled' in btn) btn.disabled = wasDisabled; btn.dataset.state = state; setTimeout(() => { if (btn.dataset.state === state) delete btn.dataset.state; }, state === 'done' ? 1200 : 1600); }; try { const out = await (typeof work === 'function' ? work() : work); settle('done'); return out; } catch (e) { settle('error'); throw e; } } // Hold to confirm: document-level, so buttons added later need no wiring. function bindHold() { if (typeof document === 'undefined' || root.__ovkHoldBound) return; root.__ovkHoldBound = true; let held = null; const stop = (fire) => { if (!held) return; const { btn, raf, ok } = held; held = null; cancelAnimationFrame(raf); btn.classList.remove('is-holding'); btn.style.removeProperty('--ovk-hold'); if (fire && ok) { btn.dataset.ovkHeld = '1'; btn.click(); } }; const start = (btn) => { const ms = Math.max(400, Math.min(4000, Number(btn.getAttribute('data-hold')) || 1200)); const t0 = performance.now(); held = { btn, raf: 0, ok: false }; btn.classList.add('is-holding'); const tick = (t) => { if (!held || held.btn !== btn) return; const f = Math.min(1, (t - t0) / ms); btn.style.setProperty('--ovk-hold', `${Math.round(f * 100)}%`); if (f >= 1) { held.ok = true; stop(true); return; } held.raf = requestAnimationFrame(tick); }; held.raf = requestAnimationFrame(tick); }; 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); }); document.addEventListener('pointerup', () => stop(false)); document.addEventListener('pointercancel', () => stop(false)); 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); } }); document.addEventListener('keyup', (e) => { if (e.key === 'Enter' || e.key === ' ') stop(false); }); // A plain click on a hold button does nothing until the hold has run. document.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('.ovk-btn[data-hold]'); if (!b) return; if (b.dataset.ovkHeld === '1') { delete b.dataset.ovkHeld; return; } e.preventDefault(); e.stopImmediatePropagation(); }, true); } bindHold(); const api = { enhance, busy }; root.OpenVibeKitWeb = api; if (typeof module === 'object' && module.exports) { module.exports = api; return; } if (typeof document !== 'undefined') { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => enhance(document)); else enhance(document); // A boosted page swap (boost.js) brings new markup without a load: enhance it too (enhance is idempotent). document.addEventListener('ov:boost:load', () => enhance(document)); } })(typeof globalThis !== 'undefined' ? globalThis : this);