openvibers/openvibe.shared appbar.js
appbar.js
| 1 | /** |
| 2 | * openvibe-shared/appbar.js — the phone app bar: a site's main places as a tab bar fixed at the bottom of the screen, |
| 3 | * as in an app (the owner, 2026-10-28: "on a phone it should act like an app… one app ecosystem across all our |
| 4 | * systems"). navbar.js loads this file only for a site that asks for it, and draws it again on every render, page move |
| 5 | * and setActive(); it needs nothing else. |
| 6 | * |
| 7 | * OpenVibeNavbar.init({ …, appBar: true }) the site's own links that carry an icon: the first four, and More |
| 8 | * for the rest (it opens the navbar's drawer); five fit without More |
| 9 | * OpenVibeNavbar.init({ …, appBar: [{ label, href, icon, id, page, active, dot }] }) five at most |
| 10 | * OpenVibeNavbar.setAppBar(spec | false) change it later (a single-page app's sections) |
| 11 | * |
| 12 | * It shows under 760 px only. html.ov-has-appbar and --ovnav-appbar-h keep the page's last lines above it (kit.css's |
| 13 | * dock and save bar sit on it), and it steps aside while the keyboard is up or a panel is open: all the way, without the |
| 14 | * lift (4.234.0; with the lift still applied it stopped half on the screen above a keyboard or Android's toolbar gap). A link's icon is a |
| 15 | * navbar icon name (fa-…), its dot a small red mark, the open page (its path, a path under it, or setActive's page) |
| 16 | * a filled pill. Clicks go through the link's onClick and the site's onNavigate, as the navbar's own links do. |
| 17 | * |
| 18 | * Plan 279: the bar does not sit on the layout viewport's bottom edge. bottom.js measures the visible viewport |
| 19 | * (--ovb-lift) and the bar moves up by it with `translate`, so the browser's toolbar sliding away or a keyboard |
| 20 | * never leaves the bar behind; its height is measured back for whatever docks above it (--ovb-bar, --ovb-dock). |
| 21 | */ |
| 22 | (function (root) { |
| 23 | ; |
| 24 | let barEl = null; |
| 25 | let resizeBound = false; |
| 26 | let pillWatch = null; |
| 27 | |
| 28 | function spec(ctx) { |
| 29 | const cfg = ctx.config.appBar; |
| 30 | if (!cfg) return null; |
| 31 | const path = (typeof location !== 'undefined' && location.pathname) || '/'; |
| 32 | const here = (l) => (l.active !== undefined ? !!l.active : ctx.activePage && l.page ? l.page === ctx.activePage |
| 33 | : l.href === path || (l.href.length > 1 && l.href.startsWith('/') && path.startsWith(`${l.href.replace(/\/$/, '')}/`))); |
| 34 | const usable = (l) => l && typeof l.href === 'string' && l.label && !l.hidden && !l.external; |
| 35 | if (Array.isArray(cfg)) return { items: cfg.filter(usable).slice(0, 5).map((l) => Object.assign({}, l, { active: here(l) })), more: false }; |
| 36 | const all = ctx.links.filter(usable); |
| 37 | const withIcon = all.filter((l) => l.icon); |
| 38 | const items = withIcon.slice(0, withIcon.length > 5 || all.length > withIcon.length ? 4 : 5); |
| 39 | return { items: items.map((l) => Object.assign({}, l, { active: here(l) })), more: all.length > items.length }; |
| 40 | } |
| 41 | |
| 42 | function styles() { |
| 43 | if (document.getElementById('openvibe-appbar-styles')) return; |
| 44 | const st = document.createElement('style'); |
| 45 | st.id = 'openvibe-appbar-styles'; |
| 46 | st.textContent = ` |
| 47 | .ovnav-appbar { display: none; } |
| 48 | @media (max-width: 760px) { |
| 49 | html.ov-has-appbar { --ovnav-appbar-h: calc(58px + env(safe-area-inset-bottom, 0px)); } |
| 50 | html.ov-has-appbar body { padding-bottom: var(--ovnav-appbar-h); } |
| 51 | .ovnav-appbar { position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--ovb-z-bar, 9990); translate: 0 calc(-1 * var(--ovb-lift, 0px)); display: flex; align-items: stretch; height: var(--ovnav-appbar-h); box-sizing: border-box; padding: 0 4px env(safe-area-inset-bottom, 0px); |
| 52 | background: color-mix(in srgb, var(--bg-secondary, #121826) 90%, transparent); -webkit-backdrop-filter: saturate(1.5) blur(16px); backdrop-filter: saturate(1.5) blur(16px); |
| 53 | border-top: 1px solid var(--border, rgba(255,255,255,.1)); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; transition: transform .2s ease, visibility 0s; } |
| 54 | .ovnav-appbar .ovnav-ab { all: unset; box-sizing: border-box; flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; padding: 6px 2px; cursor: pointer; |
| 55 | color: var(--text-muted, #93a0b4); font-size: 12px; font-weight: 650; line-height: 1.1; text-align: center; -webkit-tap-highlight-color: transparent; } |
| 56 | .ovnav-appbar .ovnav-ab:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -4px; border-radius: 12px; } |
| 57 | .ovnav-appbar .ovnav-ab.is-here { color: var(--text-primary, #e8ecf3); } |
| 58 | .ovnav-appbar .ovnav-ab-ic { position: relative; display: grid; place-items: center; width: 52px; height: 28px; border-radius: 999px; font-size: 18px; transition: background .15s; } |
| 59 | .ovnav-appbar .ovnav-ab.is-here .ovnav-ab-ic { color: var(--accent-light, var(--accent, #60a5fa)); } |
| 60 | /* 4.70.0 (the owner, 2026-11-01: "improve the mobile button so it feels seamless and smooth"): one pill slides to the |
| 61 | tab you are on (the bar is kept between pages, so it can), a tap lights its tab at once, a press gives and the new |
| 62 | tab's icon pops. */ |
| 63 | .ovnav-appbar .ovnav-ab-pill { position: absolute; left: 0; top: 0; width: 52px; height: 28px; border-radius: 999px; background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); transform: translate(var(--ab-x, 0px), var(--ab-y, 6px)); transition: transform .42s cubic-bezier(.2,1.25,.3,1), opacity .2s; pointer-events: none; } |
| 64 | .ovnav-appbar.ab-still .ovnav-ab-pill { transition: none; } |
| 65 | .ovnav-appbar:not(:has(.is-here)) .ovnav-ab-pill { opacity: 0; } |
| 66 | .ovnav-appbar .ovnav-ab { -webkit-tap-highlight-color: transparent; } |
| 67 | .ovnav-appbar .ovnav-ab-ic, .ovnav-appbar .ovnav-ab-l { position: relative; transition: transform .2s cubic-bezier(.2,1.4,.3,1), color .2s; } |
| 68 | .ovnav-appbar .ovnav-ab:active .ovnav-ab-ic { transform: scale(.88); } |
| 69 | .ovnav-appbar .ovnav-ab.is-here .ovnav-ab-ic svg { animation: ovAbPop .45s cubic-bezier(.2,1.5,.3,1); } |
| 70 | .ovnav-appbar .ovnav-ab.is-here .ovnav-ab-l { font-weight: 700; } |
| 71 | @keyframes ovAbPop { 0% { transform: scale(.7); } 60% { transform: scale(1.12); } 100% { transform: scale(1); } } |
| 72 | .ovnav-appbar .ovnav-ab-ic svg { width: 1em; height: 1em; } |
| 73 | .ovnav-appbar .ovnav-ab-l { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 74 | .ovnav-appbar .ovnav-ab-dot { position: absolute; top: 2px; right: 12px; width: 8px; height: 8px; border-radius: 50%; background: var(--danger, #ef4444); box-shadow: 0 0 0 2px var(--bg-secondary, #121826); } |
| 75 | html.ov-has-appbar:has(:is(input:not([type=checkbox]):not([type=radio]):not([type=range]), textarea, select, [contenteditable="true"]):focus) { --ovnav-appbar-h: 0px; } |
| 76 | html.ov-has-appbar:has(:is(input:not([type=checkbox]):not([type=radio]):not([type=range]), textarea, select, [contenteditable="true"]):focus) .ovnav-appbar, |
| 77 | body.ov-panel-open .ovnav-appbar { translate: none; transform: translateY(calc(100% + 24px)); visibility: hidden; pointer-events: none; transition: transform .2s ease, visibility 0s linear .2s; } |
| 78 | } |
| 79 | @media print { .ovnav-appbar { display: none !important; } }`; |
| 80 | document.head.appendChild(st); |
| 81 | } |
| 82 | |
| 83 | /** Slide the pill under the lit tab's icon (the bar is its offset parent). */ |
| 84 | function placePill(bar, instant) { |
| 85 | const pill = bar && bar.querySelector('.ovnav-ab-pill'); |
| 86 | const ic = bar && bar.querySelector('.ovnav-ab.is-here .ovnav-ab-ic'); |
| 87 | if (!pill || !ic) return; |
| 88 | if (instant) bar.classList.add('ab-still'); |
| 89 | // From where the icon and the bar are now (not offsetTop: a positioned tab or a late icon made that stale, and |
| 90 | // the pill sat above its icon), centred on the icon both ways. |
| 91 | const b = bar.getBoundingClientRect(), r = ic.getBoundingClientRect(); |
| 92 | if (!r.width) return; |
| 93 | bar.style.setProperty('--ab-x', `${Math.round(r.left - b.left - bar.clientLeft + (r.width - 52) / 2)}px`); |
| 94 | bar.style.setProperty('--ab-y', `${Math.round(r.top - b.top - bar.clientTop + (r.height - 28) / 2)}px`); |
| 95 | const raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (f) => setTimeout(f, 16); |
| 96 | if (instant) raf(() => raf(() => bar.classList.remove('ab-still'))); |
| 97 | } |
| 98 | /** Light one tab (and only it), as the page it opens. */ |
| 99 | function light(bar, a) { |
| 100 | bar.querySelectorAll('.ovnav-ab').forEach((t) => { const on = t === a; t.classList.toggle('is-here', on); if (on) t.setAttribute('aria-current', 'page'); else t.removeAttribute('aria-current'); }); |
| 101 | placePill(bar); |
| 102 | } |
| 103 | const keyOf = (s) => s.items.map((l) => `${l.href}|${l.label}|${l.icon || ''}|${l.dot ? 1 : 0}`).join(',') + (s.more ? '+more' : ''); |
| 104 | |
| 105 | /** Draw (or take away) the bar for the navbar's current state: { config, links, activePage, nav, navIcon, escapeAttr, escapeHtml, flatLinks }. */ |
| 106 | function render(ctx) { |
| 107 | if (typeof document === 'undefined' || !document.body) return; |
| 108 | const s = spec(ctx); |
| 109 | const de = document.documentElement; |
| 110 | // The same tabs as before: keep the bar (no flash, no jump) and move only what is lit, so the pill slides. |
| 111 | if (barEl && barEl.isConnected && s && s.items.length >= 2 && barEl.getAttribute('data-ab-key') === keyOf(s)) { |
| 112 | const tabs = barEl.querySelectorAll('a.ovnav-ab'); |
| 113 | const lit = s.items.findIndex((l) => l.active); |
| 114 | light(barEl, lit >= 0 ? tabs[lit] : null); |
| 115 | return; |
| 116 | } |
| 117 | if (barEl) { barEl.remove(); barEl = null; } |
| 118 | if (!s || s.items.length < 2) { if (de && de.classList) de.classList.remove('ov-has-appbar'); return; } |
| 119 | styles(); |
| 120 | const { navIcon, escapeAttr, escapeHtml } = ctx; |
| 121 | const bar = document.createElement('nav'); |
| 122 | bar.className = 'ovnav-appbar'; |
| 123 | bar.setAttribute('aria-label', 'App'); |
| 124 | bar.setAttribute('data-ab-key', keyOf(s)); |
| 125 | bar.innerHTML = '<span class="ovnav-ab-pill" aria-hidden="true"></span>' + s.items.map((l) => `<a href="${escapeAttr(l.href)}" class="ovnav-ab${l.active ? ' is-here' : ''}"${l.active ? ' aria-current="page"' : ''}${l.id ? ` data-link-id="${escapeAttr(l.id)}"` : ''}${l.page ? ` data-page="${escapeAttr(l.page)}"` : ''}><span class="ovnav-ab-ic">${navIcon(l.icon || 'fa-circle')}${l.dot ? '<span class="ovnav-ab-dot" aria-label="new"></span>' : ''}</span><span class="ovnav-ab-l">${escapeHtml(l.label)}</span></a>`).join('') |
| 126 | + (s.more ? `<button type="button" class="ovnav-ab" data-ab-more aria-haspopup="true"><span class="ovnav-ab-ic">${navIcon('fa-bars')}</span><span class="ovnav-ab-l">More</span></button>` : ''); |
| 127 | bar.addEventListener('click', (e) => { |
| 128 | if (e.target.closest && e.target.closest('[data-ab-more]')) { |
| 129 | e.preventDefault(); e.stopPropagation(); |
| 130 | const b = ctx.nav && ctx.nav.querySelector('#openvibe-burger'); if (b) b.click(); |
| 131 | return; |
| 132 | } |
| 133 | const a = e.target.closest && e.target.closest('a.ovnav-ab'); |
| 134 | if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; |
| 135 | light(bar, a); |
| 136 | const id = a.getAttribute('data-link-id'); |
| 137 | const item = id ? ctx.flatLinks(ctx.links).concat(Array.isArray(ctx.config.appBar) ? ctx.config.appBar : []).find((l) => l && l.id === id) : null; |
| 138 | if (item && typeof item.onClick === 'function' && item.onClick(e) === false) e.preventDefault(); |
| 139 | if (e.defaultPrevented) return; |
| 140 | if (typeof ctx.config.onNavigate === 'function') { |
| 141 | let same = false; try { same = new URL(a.href, location.href).origin === location.origin; } catch { /* */ } |
| 142 | if (same && ctx.config.onNavigate((() => { try { const u = new URL(a.href, location.href); return `${u.pathname}${u.search}${u.hash}`; } catch { return a.getAttribute('href'); } })(), e) === false) e.preventDefault(); |
| 143 | } |
| 144 | }); |
| 145 | document.body.appendChild(bar); |
| 146 | barEl = bar; |
| 147 | if (de && de.classList) de.classList.add('ov-has-appbar'); |
| 148 | if (root.OpenVibeBottom) root.OpenVibeBottom.refresh(); |
| 149 | placePill(bar, true); |
| 150 | if (!resizeBound && typeof window !== 'undefined' && typeof window.addEventListener === 'function') { resizeBound = true; window.addEventListener('resize', () => placePill(barEl, true)); } |
| 151 | // The icons and the brand font arrive after the bar is drawn, and a tab's label can wrap: place it again whenever |
| 152 | // the bar or the lit icon changes size, and once the fonts are in. |
| 153 | if (typeof ResizeObserver === 'function') { |
| 154 | if (pillWatch) pillWatch.disconnect(); |
| 155 | pillWatch = new ResizeObserver(() => placePill(barEl, true)); |
| 156 | pillWatch.observe(bar); bar.querySelectorAll('.ovnav-ab-ic').forEach((el) => pillWatch.observe(el)); |
| 157 | } |
| 158 | if (document.fonts && document.fonts.ready && typeof document.fonts.ready.then === 'function') document.fonts.ready.then(() => placePill(barEl, true)).catch(() => {}); |
| 159 | } |
| 160 | |
| 161 | const api = { render, spec }; |
| 162 | if (typeof module === 'object' && module.exports) module.exports = api; |
| 163 | root.OpenVibeAppBar = api; |
| 164 | })(typeof globalThis !== 'undefined' ? globalThis : this); |
| 165 |