openvibers/openvibe.shared appbar.js

appbar.js

main 13.5 KB · 165 lines · javascript

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 'use strict';
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