openvibers/openvibe.shared ov-drop.js

ov-drop.js

main 13.4 KB · 153 lines · javascript

1/* ov-drop.js — the one dropdown (4.191.0). The owner, 2026-11-10: "clicking the actual name of the domain should have a
2 sleek little minimalist drop down with nice animation effects that just shows the pages … a custom, animated system
3 for these drop downs and positioning of them, used across all sites."
4
5 A small list that opens out of the thing pressed: under it (above it when there is no room below), kept on screen,
6 growing from where the press was, its rows arriving one after another. Arrow keys move, Enter chooses, Escape and a
7 press outside close it and give the focus back. One is open at a time, and it closes with every other panel on the
8 page (panels.js).
9
10 OVDrop.open(anchor, items, opts) items: [{ label, href?, onClick?, icon? (SVG text), sub?, current?, danger? }]
11 or a function that returns them; a falsy item is a hairline between groups
12 opts: { label (what the list is, said to a screen reader and shown small on top),
13 align: 'start' | 'end', minWidth, onClose }
14 OVDrop.attach(button, items, opts) the button opens and closes it, with aria-expanded kept true to what is shown
15 OVDrop.pages(anchor, links, icon, more) a site's pages from its bar links, Home first, the present page marked;
16 more: { label, onClick } ends it with a way out (the navbar's: every OpenVibe site)
17 OVDrop.close() close the one that is open */
18(function (root) {
19 'use strict';
20 if (!root || !root.document || root.OVDrop) return;
21 const doc = root.document;
22 const CSS = `
23.ovdrop { position: fixed; z-index: 10030; left: 0; top: 0; box-sizing: border-box; min-width: 200px; max-width: min(320px, calc(100vw - 16px)); max-height: min(70vh, 460px); overflow: auto; overscroll-behavior: contain; padding: 6px; border-radius: 16px;
24 background: color-mix(in srgb, var(--bg-elevated, var(--bg-secondary, #111826)) 94%, transparent); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 18px 44px rgba(0,0,0,.42), 0 2px 6px rgba(0,0,0,.2); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px);
25 color: var(--text-primary, #e6edf7); font: 600 14.5px/1.25 var(--font, system-ui, -apple-system, 'Segoe UI', sans-serif); transform-origin: var(--ovdrop-x, 24px) var(--ovdrop-y, 0); opacity: 0; transform: translateY(calc(var(--ovdrop-from, -6px) * var(--ov-move, 1))) scale(.94); transition: opacity .14s ease-out, transform .16s ease-in; pointer-events: none; }
26.ovdrop.is-open { opacity: 1; transform: none; pointer-events: auto; transition: opacity .16s ease-out, transform .26s cubic-bezier(.2, 1.25, .3, 1); }
27.ovdrop-label { margin: 0; padding: 7px 10px 6px; font-size: 11.5px; font-weight: 700; color: var(--text-muted, #93a0b4); }
28.ovdrop-item { all: unset; box-sizing: border-box; display: flex; align-items: center; gap: 11px; width: 100%; min-height: 44px; padding: 0 12px 0 10px; border-radius: 11px; cursor: pointer; color: inherit; opacity: 0; transform: translateY(calc(-5px * var(--ov-move, 1))); transition: background-color .12s, opacity .2s ease-out, transform .28s cubic-bezier(.2, 1.2, .3, 1); transition-delay: 0s, calc(var(--i, 0) * 22ms + 30ms), calc(var(--i, 0) * 22ms + 30ms); }
29.ovdrop.is-open .ovdrop-item { opacity: 1; transform: none; }
30.ovdrop-item:hover, .ovdrop-item:focus-visible { background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); }
31.ovdrop-item:focus-visible { box-shadow: inset 0 0 0 2px var(--accent, #3b82f6); }
32.ovdrop-item:active { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); }
33.ovdrop-ic { flex: none; display: grid; place-items: center; width: 20px; height: 20px; color: var(--text-muted, #93a0b4); }
34.ovdrop-ic svg { width: 18px; height: 18px; display: block; }
35.ovdrop-t { flex: 1; min-width: 0; display: grid; gap: 1px; }
36.ovdrop-t b { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
37.ovdrop-t small { font-size: 12px; font-weight: 500; color: var(--text-secondary, #a8b3c4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
38.ovdrop-item[aria-current] { background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); }
39.ovdrop-item[aria-current] .ovdrop-ic { color: var(--accent-light, var(--accent, #60a5fa)); }
40.ovdrop-item[aria-current]::after { content: ""; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--accent-light, var(--accent, #60a5fa)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); }
41.ovdrop-item.is-danger { color: var(--danger-text, #f87171); }
42.ovdrop-sep { height: 1px; margin: 5px 8px; background: var(--border, rgba(255,255,255,.1)); }
43:root:root[data-ov-motion=off] .ovdrop, :root:root[data-ov-motion=off] .ovdrop-item { transition: none; }`;
44 function style() {
45 if (doc.getElementById('ovdrop-style')) return;
46 const st = doc.createElement('style'); st.id = 'ovdrop-style'; st.textContent = CSS; doc.head.appendChild(st);
47 }
48 const safeHref = (u) => { const s = String(u || ''); return /^(https?:\/\/|\/(?!\/)|#|\?|mailto:)/i.test(s) ? s : ''; };
49 let cur = null; // { el, anchor, opts, off: [fns] }
50
51 /** Where the list sits: under the anchor, or above it when there is more room there; never off the screen. */
52 function place() {
53 if (!cur) return;
54 const { el, anchor, opts } = cur;
55 const a = anchor.getBoundingClientRect(), w = el.offsetWidth, h = el.offsetHeight, vw = root.innerWidth, vh = root.innerHeight, gap = 8;
56 if (!a.width && !a.height) return close();
57 let left = opts.align === 'end' ? a.right - w : a.left - 6;
58 left = Math.max(8, Math.min(vw - w - 8, left));
59 const below = vh - a.bottom - gap - 8, above = a.top - gap - 8, up = h > below && above > below;
60 const top = up ? Math.max(8, a.top - gap - h) : a.bottom + gap;
61 el.style.left = `${Math.round(left)}px`; el.style.top = `${Math.round(top)}px`;
62 el.style.setProperty('--ovdrop-x', `${Math.round(Math.max(12, Math.min(w - 12, a.left + a.width / 2 - left)))}px`);
63 el.style.setProperty('--ovdrop-y', up ? '100%' : '0');
64 el.style.setProperty('--ovdrop-from', up ? '6px' : '-6px');
65 }
66 function close(refocus) {
67 if (!cur) return;
68 const { el, anchor, opts, off } = cur; cur = null;
69 off.forEach((fn) => { try { fn(); } catch { /* already gone */ } });
70 el.classList.remove('is-open');
71 if (anchor.setAttribute && anchor.hasAttribute('aria-expanded')) anchor.setAttribute('aria-expanded', 'false');
72 setTimeout(() => el.remove(), 180);
73 if (refocus && anchor.isConnected && anchor.focus) anchor.focus({ preventScroll: true });
74 if (typeof opts.onClose === 'function') { try { opts.onClose(); } catch { /* the caller's */ } }
75 }
76 function open(anchor, items, opts) {
77 if (!anchor || !anchor.getBoundingClientRect) return null;
78 if (cur && cur.anchor === anchor) { close(); return null; }
79 close(); style();
80 opts = opts || {};
81 const list = (typeof items === 'function' ? items() : items) || [];
82 const el = doc.createElement('div'); el.className = 'ovdrop'; el.setAttribute('role', 'menu');
83 if (opts.label) { el.setAttribute('aria-label', opts.label); const l = doc.createElement('p'); l.className = 'ovdrop-label'; l.textContent = opts.label; el.appendChild(l); }
84 if (opts.minWidth) el.style.minWidth = `${Number(opts.minWidth) || 200}px`;
85 let i = 0;
86 for (const it of list) {
87 if (!it) { const s = doc.createElement('div'); s.className = 'ovdrop-sep'; s.setAttribute('role', 'separator'); el.appendChild(s); continue; }
88 const href = safeHref(it.href), row = doc.createElement(href ? 'a' : 'button');
89 row.className = `ovdrop-item${it.danger ? ' is-danger' : ''}`; row.setAttribute('role', 'menuitem'); row.style.setProperty('--i', String(i)); i += 1;
90 if (href) row.href = href; else row.type = 'button';
91 if (it.current) row.setAttribute('aria-current', 'page');
92 if (it.icon) { const ic = doc.createElement('span'); ic.className = 'ovdrop-ic'; ic.setAttribute('aria-hidden', 'true'); ic.innerHTML = String(it.icon); row.appendChild(ic); }
93 const t = doc.createElement('span'); t.className = 'ovdrop-t'; const b = doc.createElement('b'); b.textContent = it.label || ''; t.appendChild(b);
94 if (it.sub) { const sm = doc.createElement('small'); sm.textContent = it.sub; t.appendChild(sm); }
95 row.appendChild(t);
96 row.addEventListener('click', (e) => { if (typeof it.onClick === 'function') { if (!href) e.preventDefault(); try { it.onClick(e); } catch { /* the caller's */ } } close(); });
97 el.appendChild(row);
98 }
99 doc.body.appendChild(el);
100 const rows = () => Array.from(el.querySelectorAll('.ovdrop-item'));
101 const onKey = (e) => {
102 if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); return close(true); }
103 if (e.key === 'Tab') return close();
104 const r = rows(), at = r.indexOf(doc.activeElement);
105 const go = (n) => { e.preventDefault(); if (r.length) r[(n + r.length) % r.length].focus(); };
106 if (e.key === 'ArrowDown') go(at + 1); else if (e.key === 'ArrowUp') go(at < 0 ? r.length - 1 : at - 1);
107 else if (e.key === 'Home') go(0); else if (e.key === 'End') go(r.length - 1);
108 };
109 const onDown = (e) => { if (!el.contains(e.target) && !anchor.contains(e.target)) close(); };
110 const onMove = () => place();
111 const onSize = () => close();
112 doc.addEventListener('keydown', onKey, true); doc.addEventListener('pointerdown', onDown, true);
113 root.addEventListener('scroll', onMove, { passive: true, capture: true }); root.addEventListener('resize', onSize);
114 const off = [() => doc.removeEventListener('keydown', onKey, true), () => doc.removeEventListener('pointerdown', onDown, true),
115 () => root.removeEventListener('scroll', onMove, { capture: true }), () => root.removeEventListener('resize', onSize)];
116 // One panel at a time, with the bar's menus and every other registered panel.
117 if (root.OpenVibePanels && root.OpenVibePanels.register) { try { root.OpenVibePanels.closeAll(); off.push(root.OpenVibePanels.register({ el, openClass: 'is-open', id: 'drop', close: () => close() })); } catch { /* optional */ } }
118 cur = { el, anchor, opts, off };
119 if (anchor.setAttribute) anchor.setAttribute('aria-expanded', 'true');
120 place();
121 void el.offsetWidth; el.classList.add('is-open');
122 // The keyboard lands on the current row (or the first); a touch or a click leaves the focus where it was.
123 if (opts.focus !== false && anchor.matches && anchor.matches(':focus-visible')) { const r = rows(); (r.find((x) => x.hasAttribute('aria-current')) || r[0] || el).focus({ preventScroll: true }); }
124 return { el, close: () => close() };
125 }
126 function attach(button, items, opts) {
127 if (!button || button.__ovdrop) return;
128 button.__ovdrop = true;
129 button.setAttribute('aria-haspopup', 'menu'); button.setAttribute('aria-expanded', 'false');
130 button.addEventListener('click', (e) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; e.preventDefault(); open(button, items, opts); });
131 }
132 /**
133 * A site's pages, from the bar's links (the navbar calls this when the site's name is pressed): Home first, the
134 * page you are on marked. links: [{ label, href, icon, hidden }]; icon(name) gives an icon's SVG text.
135 */
136 const GRID = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"><rect x="4" y="4" width="6.5" height="6.5" rx="1.6"/><rect x="13.5" y="4" width="6.5" height="6.5" rx="1.6"/><rect x="4" y="13.5" width="6.5" height="6.5" rx="1.6"/><rect x="13.5" y="13.5" width="6.5" height="6.5" rx="1.6"/></svg>';
137 function pages(anchor, links, icon, more) {
138 const path = (h) => String(h).split(/[?#]/)[0].replace(/\/+$/, '') || '/';
139 const list = (links || []).filter((l) => l && l.label && l.href && !l.hidden && !/^https?:/i.test(l.href));
140 const at = path(root.location.pathname);
141 let best = -1, len = -1;
142 list.forEach((l, i) => { const q = path(l.href); const n = q === at ? 1e4 : (q !== '/' && at.startsWith(`${q}/`) ? q.length : -1); if (n > len) { best = i; len = n; } });
143 const draw = (name) => { try { return typeof icon === 'function' ? icon(name) : ''; } catch { return ''; } };
144 const items = list.map((l, i) => ({ label: l.label, href: l.href, icon: draw(l.icon || 'fa-arrow-right'), current: i === best }));
145 if (!list.some((l) => path(l.href) === '/')) items.unshift({ label: 'Home', href: anchor.getAttribute('href') || '/', icon: draw('fa-house'), current: at === '/' });
146 // The way to every site (ask 338, the owner: "a button … to view all sites … past the list of pages … to the list
147 // of actual domains"): one row under a hairline.
148 if (more && typeof more.onClick === 'function') items.push(null, { label: more.label || 'All OpenVibe sites', icon: GRID, onClick: more.onClick });
149 return open(anchor, items, { label: `Pages on ${anchor.textContent.trim()}` });
150 }
151 root.OVDrop = { open, close: () => close(), attach, pages, get isOpen() { return !!cur; } };
152})(typeof window !== 'undefined' ? window : null);
153