openvibers/openvibe.shared surfaces.js
surfaces.js
| 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 | ; |
| 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%); } } |
| 115 | html[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 |