openvibers/openvibe.shared panels.js
panels.js
| 1 | /** |
| 2 | * openvibe-shared/panels.js — one coordinator for every dropdown, drawer and popover on a page. |
| 3 | * |
| 4 | * Sites keep their own markup and their own open/close toggles. They register each panel here and get the |
| 5 | * behaviour that should be identical everywhere: |
| 6 | * • one panel open at a time (opening one closes the others) |
| 7 | * • Escape and a device rotation close everything |
| 8 | * • <body class="ov-panel-open"> while any panel is open (floating buttons step aside) |
| 9 | * • a freshly opened panel starts scrolled to the top |
| 10 | * |
| 11 | * OpenVibePanels.register({ el, openClass: 'show', close: () => …, id?: 'user-menu', signal? }) → unregister() |
| 12 | * OpenVibePanels.unregister(el) · OpenVibePanels.closeAll(exceptEl?) · OpenVibePanels.anyOpen() |
| 13 | * |
| 14 | * It watches the panel's class instead of wrapping the site's click handlers, so it cannot fight them: a |
| 15 | * panel is "open" exactly when its element has `openClass`, however that came about. |
| 16 | * |
| 17 | * A registration holds its element until it is unregistered: markup that is thrown away and rebuilt (the navbar |
| 18 | * re-renders on every openvibe-shared/boost page move) passes an AbortSignal, or calls unregister, so the old |
| 19 | * element can be collected. |
| 20 | */ |
| 21 | (function (root) { |
| 22 | ; |
| 23 | if (root.OpenVibePanels) return; |
| 24 | if (typeof document === 'undefined') { const stub = { register: () => () => {}, unregister() {}, closeAll() {}, anyOpen: () => false }; if (typeof module !== 'undefined') module.exports = stub; return; } |
| 25 | |
| 26 | const panels = []; // { el, openClass, close, id, unregister } |
| 27 | const isOpen = (p) => p.el.isConnected && p.el.classList.contains(p.openClass); |
| 28 | const anyOpen = () => panels.some(isOpen); |
| 29 | const syncBody = () => document.body && document.body.classList.toggle('ov-panel-open', anyOpen()); |
| 30 | |
| 31 | function closeAll(except) { |
| 32 | // A panel nested in another (a submenu inside the mobile drawer) never closes its container, or vice versa. |
| 33 | const related = (a, b) => a && b && (a.contains(b) || b.contains(a)); |
| 34 | for (const p of panels) if (p.el !== except && !related(p.el, except) && isOpen(p)) { try { p.close(); } catch { p.el.classList.remove(p.openClass); } } |
| 35 | syncBody(); |
| 36 | } |
| 37 | |
| 38 | function register(opts) { |
| 39 | const el = opts && opts.el, signal = opts && opts.signal; |
| 40 | if (!el || (signal && signal.aborted)) return () => {}; |
| 41 | const known = panels.find(p => p.el === el); if (known) return known.unregister; |
| 42 | const p = { el, trigger: opts.trigger, openClass: opts.openClass || 'open', id: opts.id || '', close: typeof opts.close === 'function' ? opts.close : () => el.classList.remove(opts.openClass || 'open') }; |
| 43 | let was = isOpen(p); |
| 44 | const watch = new MutationObserver(() => { |
| 45 | const now = isOpen(p); if (now === was) return; was = now; |
| 46 | if (now) { closeAll(el); if (root.OVSurfaces) root.OVSurfaces.closePopover(); try { el.scrollTop = 0; } catch { /* */ } } |
| 47 | syncBody(); |
| 48 | }); |
| 49 | p.unregister = () => { |
| 50 | const i = panels.indexOf(p); if (i < 0) return; |
| 51 | panels.splice(i, 1); |
| 52 | watch.disconnect(); |
| 53 | syncBody(); |
| 54 | }; |
| 55 | panels.push(p); |
| 56 | watch.observe(el, { attributes: true, attributeFilter: ['class'] }); |
| 57 | if (signal && typeof signal.addEventListener === 'function') signal.addEventListener('abort', p.unregister, { once: true }); |
| 58 | syncBody(); |
| 59 | return p.unregister; |
| 60 | } |
| 61 | |
| 62 | function unregister(el) { const p = panels.find(x => x.el === el); if (p) p.unregister(); } |
| 63 | |
| 64 | document.addEventListener('pointerdown', (e) => { |
| 65 | for (const p of panels) if (isOpen(p) && !p.el.contains(e.target) && !(p.trigger && p.trigger.contains(e.target))) p.close(); |
| 66 | syncBody(); |
| 67 | }, true); |
| 68 | document.addEventListener('keydown', (e) => { |
| 69 | if (e.key !== 'Escape' || !anyOpen()) return; |
| 70 | if (typeof e.preventDefault === 'function') e.preventDefault(); |
| 71 | const trigger = panels.find(p => isOpen(p) && p.trigger)?.trigger; |
| 72 | closeAll(); |
| 73 | if (trigger && trigger.isConnected) trigger.focus({ preventScroll: true }); |
| 74 | }); |
| 75 | root.addEventListener('orientationchange', () => closeAll(), { passive: true }); |
| 76 | |
| 77 | const api = { register, unregister, closeAll, anyOpen }; |
| 78 | root.OpenVibePanels = api; |
| 79 | if (typeof module !== 'undefined' && module.exports) module.exports = api; |
| 80 | })(typeof window !== 'undefined' ? window : globalThis); |
| 81 |