openvibers/openvibe.shared surfaces.js

surfaces.js

main 17.3 KB · 278 lines · javascript

1/**
2 * Shared behavior for sheets and floating pop-overs. Load beside navbar.js or kit-web.js.
3 *
4 * The layers (4.235.0; the owner, 11-13: "the little three dots doesn't actually open a menu … the same unified …
5 * cross-site shared UI system"): what opens from a button is ANCHORED to it.
6 *
7 * const a = OVSurfaces.anchor(el, trigger, { place: 'bottom-end', gap: 6, margin: 8, sheet: false, animate: true });
8 * a.update(); a.release();
9 *
10 * - It goes in the browser's top layer (the Popover API): nothing clips it, no overflow: hidden, no rail's mask, no
11 * stacking context. Without the API it is position: fixed.
12 * - It is placed beside its button: `place` is side-align (bottom|top|left|right, start|center|end). It flips to the
13 * other side when there is no room, shifts along to stay inside the visible screen (the visual viewport: the
14 * keyboard and a pinch zoom count) and is kept no taller than the room on its side (max-height).
15 * - It is placed again on a scroll, a resize, a turn of the phone, and when it or its button changes size.
16 * - `sheet: 'phone'` opens it as a sheet at the bottom of a phone's screen (under 560 px wide), for long menus and
17 * settings; `sheet: true` always.
18 * - The element says where it went: data-ov-side="bottom|top|left|right|sheet", and the transform origin is the
19 * button's side, so the default entrance (a short scale and fade, none when motion is off) grows from the button.
20 *
21 * OVSurfaces.popover({ el, trigger, close, pop, place, sheet }) anchors `pop` (the menu inside `el`) while it is open,
22 * so a <details> menu needs nothing else: one open at a time, a press elsewhere or Escape closes it.
23 */
24(function (root) {
25 'use strict';
26 if (root.OVSurfaces || typeof document === 'undefined') return;
27
28 const sheets = new Set();
29 const dialogs = new Map();
30 let saved = null;
31 let pop = null;
32 let opener = null;
33 let mediaLock = false;
34 const blockBackdropScroll = (e) => {
35 const top = [...sheets].at(-1);
36 if (top && top.el && !top.el.contains(e.target)) e.preventDefault();
37 };
38
39 function lock() {
40 if (saved) return;
41 const html = document.documentElement, body = document.body;
42 const y = root.scrollY || html.scrollTop || 0;
43 saved = { y, htmlOverflow: html.style.overflow, htmlPadding: html.style.paddingRight,
44 bodyPosition: body.style.position, bodyTop: body.style.top, bodyLeft: body.style.left,
45 bodyRight: body.style.right, bodyWidth: body.style.width };
46 const gap = Math.max(0, root.innerWidth - html.clientWidth);
47 if (gap) html.style.paddingRight = `${(parseFloat(getComputedStyle(html).paddingRight) || 0) + gap}px`;
48 html.style.overflow = 'hidden';
49 // Moving a playing video into a fixed-position body interrupts playback on some
50 // mobile WebKit builds. Keep the media layer in its existing layout instead.
51 mediaLock = [...document.querySelectorAll('video, audio')].some((media) => !media.paused && !media.ended);
52 if (mediaLock) document.addEventListener('touchmove', blockBackdropScroll, { passive: false, capture: true });
53 else {
54 body.style.position = 'fixed'; body.style.top = `-${y}px`;
55 body.style.left = '0'; body.style.right = '0'; body.style.width = '100%';
56 }
57 }
58 function unlock() {
59 if (!saved || sheets.size) return;
60 const s = saved, html = document.documentElement, body = document.body;
61 saved = null;
62 if (mediaLock) document.removeEventListener('touchmove', blockBackdropScroll, true);
63 mediaLock = false;
64 html.style.overflow = s.htmlOverflow; html.style.paddingRight = s.htmlPadding;
65 body.style.position = s.bodyPosition; body.style.top = s.bodyTop;
66 body.style.left = s.bodyLeft; body.style.right = s.bodyRight; body.style.width = s.bodyWidth;
67 root.scrollTo(0, s.y);
68 }
69
70 function sheet({ el, opener, header, body, close, deferRelease = false }) {
71 if (!el) return null;
72 let active = false, start = null;
73 const scroll = body || el;
74 const oldOverscroll = scroll.style.overscrollBehavior;
75 scroll.style.overscrollBehavior = 'contain';
76 const head = header || el.querySelector('[data-ov-sheet-head]');
77 const oldTouch = head && head.style.touchAction;
78 if (head) head.style.touchAction = 'none';
79 const release = () => {
80 if (!active) return;
81 active = false; sheets.delete(api); el.style.translate = '';
82 unlock();
83 if (opener && opener.isConnected) try { opener.focus({ preventScroll: true }); } catch (_) { /* */ }
84 };
85 const dismiss = () => { if (close) close(); else if (el.close) el.close(); if (!deferRelease) release(); };
86 const onDown = (e) => {
87 if (!active || e.pointerType !== 'touch' || (e.target.closest && e.target.closest('button, a, input, textarea, select'))) return;
88 start = { id: e.pointerId, y: e.clientY };
89 if (head.setPointerCapture) try { head.setPointerCapture(e.pointerId); } catch (_) { /* */ }
90 };
91 const onMove = (e) => { if (start && e.pointerId === start.id) el.style.translate = `0 ${Math.max(0, e.clientY - start.y)}px`; };
92 const onUp = (e) => {
93 if (!start || e.pointerId !== start.id) return;
94 const dy = e.clientY - start.y; start = null;
95 el.style.translate = '';
96 if (dy > 90) dismiss();
97 };
98 if (head) { head.addEventListener('pointerdown', onDown); head.addEventListener('pointermove', onMove); head.addEventListener('pointerup', onUp); head.addEventListener('pointercancel', onUp); }
99 if (el instanceof HTMLDialogElement) el.addEventListener('close', release);
100 const api = { el, open() { if (active) return; active = true; sheets.add(api); lock(); }, close: release, dismiss,
101 destroy() { release(); scroll.style.overscrollBehavior = oldOverscroll; if (head) { head.style.touchAction = oldTouch; head.removeEventListener('pointerdown', onDown); head.removeEventListener('pointermove', onMove); head.removeEventListener('pointerup', onUp); head.removeEventListener('pointercancel', onUp); } if (el instanceof HTMLDialogElement) el.removeEventListener('close', release); } };
102 return api;
103 }
104
105 /* ── the anchored layer ──────────────────────────────────────────────────────────────────────────────── */
106
107 const ANCHOR_CSS = `:where([data-ov-anchored]) { position: fixed; inset: auto; margin: 0; color: inherit; box-sizing: border-box; overscroll-behavior: contain; overflow: auto;
108 padding: 0; border: 0; background: none; }
109[data-ov-anchored][data-ov-anim] { animation: ovAnchorIn var(--ov-dur-2, .16s) var(--ov-ease-out, cubic-bezier(.2, .9, .25, 1)); }
110[data-ov-anchored][data-ov-side="sheet"] { left: 0 !important; right: 0 !important; top: auto !important; bottom: 0 !important; width: 100% !important; min-width: 0 !important; max-width: none !important;
111 border-radius: 18px 18px 0 0 !important; padding-bottom: max(10px, env(safe-area-inset-bottom, 0px)) !important; }
112[data-ov-anchored][data-ov-side="sheet"][data-ov-anim] { animation: ovAnchorSheet var(--ov-dur-3, .26s) var(--ov-ease-out, cubic-bezier(.2, .9, .25, 1)); }
113@keyframes ovAnchorIn { from { opacity: 0; transform: scale(.96); } }
114@keyframes ovAnchorSheet { from { transform: translateY(100%); } }
115html[data-ov-motion="off"] [data-ov-anchored] { animation: none !important; }`;
116
117 function anchorStyles() {
118 if (document.getElementById('ov-anchor-styles')) return;
119 const st = document.createElement('style');
120 st.id = 'ov-anchor-styles';
121 st.textContent = ANCHOR_CSS;
122 (document.head || document.documentElement).appendChild(st);
123 }
124
125 /** The screen a person can see: the visual viewport (the keyboard and a pinch zoom count), in layout coordinates. */
126 function visible() {
127 const vv = root.visualViewport;
128 if (vv) return { left: vv.offsetLeft, top: vv.offsetTop, width: vv.width, height: vv.height };
129 return { left: 0, top: 0, width: root.innerWidth, height: root.innerHeight };
130 }
131
132 /**
133 * Where a box of `size` goes beside `at` (the button's rect) inside `view`: the asked side, or the other one when it
134 * does not fit there and fits better there; then shifted along to stay inside. Pure (the tests call it).
135 */
136 function placeBox(at, size, view, opts) {
137 const o = opts || {};
138 const gap = o.gap == null ? 6 : o.gap, m = o.margin == null ? 8 : o.margin;
139 const [side0, align = 'end'] = String(o.place || 'bottom-end').split('-');
140 const vertical = side0 === 'top' || side0 === 'bottom';
141 const room = {
142 bottom: view.top + view.height - m - (at.bottom + gap), top: at.top - gap - (view.top + m),
143 right: view.left + view.width - m - (at.right + gap), left: at.left - gap - (view.left + m),
144 };
145 const need = vertical ? size.height : size.width;
146 const other = { bottom: 'top', top: 'bottom', left: 'right', right: 'left' }[side0] || 'top';
147 const side = room[side0] >= need || room[side0] >= room[other] ? side0 : other;
148 const maxMain = Math.max(0, room[side]);
149 let left, top;
150 if (vertical) {
151 const w = Math.min(size.width, view.width - 2 * m);
152 left = align === 'start' ? at.left : align === 'center' ? at.left + at.width / 2 - w / 2 : at.right - w;
153 left = Math.min(Math.max(view.left + m, left), view.left + view.width - m - w);
154 const h = Math.min(size.height, maxMain);
155 top = side === 'bottom' ? at.bottom + gap : at.top - gap - h;
156 return { left, top, side, maxHeight: maxMain, maxWidth: view.width - 2 * m, origin: `${align === 'start' ? 'left' : align === 'center' ? 'center' : 'right'} ${side === 'bottom' ? 'top' : 'bottom'}` };
157 }
158 const h = Math.min(size.height, view.height - 2 * m);
159 top = align === 'start' ? at.top : align === 'center' ? at.top + at.height / 2 - h / 2 : at.bottom - h;
160 top = Math.min(Math.max(view.top + m, top), view.top + view.height - m - h);
161 const w = Math.min(size.width, maxMain);
162 left = side === 'right' ? at.right + gap : at.left - gap - w;
163 return { left, top, side, maxHeight: view.height - 2 * m, maxWidth: maxMain, origin: `${side === 'right' ? 'left' : 'right'} ${align === 'start' ? 'top' : align === 'center' ? 'center' : 'bottom'}` };
164 }
165
166 function anchor(el, trigger, opts) {
167 const o = opts || {};
168 if (!el || !trigger) return { update() {}, release() {} };
169 anchorStyles();
170 const topLayer = typeof el.showPopover === 'function';
171 const sheetMode = () => o.sheet === true || (o.sheet === 'phone' && visible().width < 560);
172 const align = String(o.place || 'bottom-end').split('-')[1] || 'end';
173 let queued = 0, released = false, ro = null;
174 el.setAttribute('data-ov-anchored', '');
175 if (o.animate !== false) el.setAttribute('data-ov-anim', '');
176 if (topLayer) {
177 if (!el.hasAttribute('popover')) el.setAttribute('popover', 'manual');
178 try { if (!el.matches(':popover-open')) el.showPopover(); } catch { /* stays fixed */ }
179 }
180 function update() {
181 queued = 0;
182 if (released) return;
183 if (!trigger.isConnected) { api.release(); if (typeof o.onLost === 'function') o.onLost(); return; }
184 const view = visible();
185 // A component's own placement (position: absolute; right: 0, the old way) gives way to the layer's.
186 el.style.position = 'fixed'; el.style.right = 'auto'; el.style.bottom = 'auto';
187 if (!topLayer) el.style.zIndex = '1000';
188 if (sheetMode()) {
189 el.setAttribute('data-ov-side', 'sheet');
190 el.style.left = el.style.top = '';
191 el.style.maxHeight = `${Math.round(view.height * 0.85)}px`;
192 el.style.transformOrigin = 'center bottom';
193 return;
194 }
195 const at = trigger.getBoundingClientRect();
196 // Its whole height (scrollHeight: a max-height from the last time does not count), the side with room for
197 // it, no taller than that room, then its place on that side at the height it is drawn.
198 const p = placeBox(at, { width: el.offsetWidth, height: Math.max(el.offsetHeight, el.scrollHeight) }, view, o);
199 el.setAttribute('data-ov-side', p.side);
200 el.style.maxHeight = `${Math.max(120, Math.floor(p.maxHeight))}px`;
201 el.style.maxWidth = `${Math.floor(p.maxWidth)}px`;
202 const q = placeBox(at, { width: el.offsetWidth, height: el.offsetHeight }, view, Object.assign({}, o, { place: `${p.side}-${align}` }));
203 el.style.left = `${Math.round(q.left)}px`;
204 el.style.top = `${Math.round(q.top)}px`;
205 el.style.transformOrigin = q.origin;
206 }
207 const later = () => { if (!queued) queued = requestAnimationFrame(update); };
208 const vv = root.visualViewport;
209 root.addEventListener('scroll', later, { capture: true, passive: true });
210 root.addEventListener('resize', later);
211 if (vv) { vv.addEventListener('resize', later); vv.addEventListener('scroll', later); }
212 if (typeof ResizeObserver === 'function') { ro = new ResizeObserver(later); ro.observe(el); ro.observe(trigger); }
213 const api = {
214 update,
215 release() {
216 if (released) return;
217 released = true;
218 if (queued) cancelAnimationFrame(queued);
219 root.removeEventListener('scroll', later, { capture: true });
220 root.removeEventListener('resize', later);
221 if (vv) { vv.removeEventListener('resize', later); vv.removeEventListener('scroll', later); }
222 try { ro && ro.disconnect(); } catch { /* */ }
223 if (topLayer && el.hasAttribute('popover')) { try { if (el.matches(':popover-open')) el.hidePopover(); } catch { /* closed */ } el.removeAttribute('popover'); }
224 for (const a of ['data-ov-anchored', 'data-ov-anim', 'data-ov-side']) el.removeAttribute(a);
225 for (const k of ['left', 'top', 'right', 'bottom', 'position', 'zIndex', 'maxHeight', 'maxWidth', 'transformOrigin']) el.style[k] = '';
226 },
227 };
228 update();
229 return api;
230 }
231
232 function popover({ el, trigger, close, pop: menu, place, sheet: asSheet, gap }) {
233 if (!el) return null;
234 let anchored = null;
235 const unanchor = () => { if (anchored) { anchored.release(); anchored = null; } };
236 const api = { open() {
237 if (pop && pop !== api) pop.close();
238 if (root.OpenVibePanels) root.OpenVibePanels.closeAll();
239 pop = api;
240 if (menu && trigger && !anchored) anchored = anchor(menu, trigger, { place, sheet: asSheet, gap, onLost: () => api.close() });
241 },
242 close() { unanchor(); if (pop !== api) return; pop = null; close(); },
243 update() { if (anchored) anchored.update(); },
244 destroy() { unanchor(); if (pop === api) pop = null; } };
245 api.el = el; api.trigger = trigger;
246 return api;
247 }
248 document.addEventListener('pointerdown', (e) => {
249 if (e.target && e.target.closest) opener = e.target.closest('button, a, [role="button"]');
250 if (pop && !pop.el.contains(e.target) && !(pop.trigger && pop.trigger.contains(e.target))) pop.close();
251 }, true);
252 document.addEventListener('keydown', (e) => {
253 if ((e.key === 'Enter' || e.key === ' ') && e.target && e.target.closest) opener = e.target.closest('button, a, [role="button"]');
254 if (e.key !== 'Escape') return;
255 if (pop) { e.preventDefault(); const trigger = pop.trigger; pop.close(); if (trigger && trigger.isConnected) trigger.focus({ preventScroll: true }); return; }
256 const top = [...sheets].at(-1); if (top) { e.preventDefault(); top.dismiss(); }
257 }, true);
258 function syncDialogs() {
259 document.querySelectorAll('dialog').forEach((el) => {
260 let api = dialogs.get(el);
261 if (el.open && el.matches(':modal')) {
262 if (!api) {
263 api = sheet({ el, opener, header: el.querySelector('[data-ov-sheet-head], .ovk-sheet-head, .ov-share-head, .sn-head'),
264 body: el.querySelector('.ovk-sheet-body, .ov-share-body, .sn-body') || el, deferRelease: true,
265 close: () => { if (el.dispatchEvent(new Event('cancel', { cancelable: true }))) el.close(); } });
266 dialogs.set(el, api);
267 }
268 api.open();
269 } else if (api) { api.destroy(); dialogs.delete(el); }
270 });
271 for (const [el, api] of dialogs) if (!el.isConnected) { api.destroy(); dialogs.delete(el); }
272 }
273 new MutationObserver(syncDialogs).observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ['open'] });
274 syncDialogs();
275 root.OVSurfaces = { sheet, popover, anchor, closePopover() { if (pop) pop.close(); }, _test: { placeBox } };
276 if (typeof module !== 'undefined' && module.exports) module.exports = root.OVSurfaces;
277})(typeof window !== 'undefined' ? window : globalThis);
278