openvibers/openvibe.shared panels.js

panels.js

main 4.4 KB · 81 lines · javascript

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