openvibers/openvibe.shared ov-drop.js
ov-drop.js
| 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 | ; |
| 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 |