PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / trunk
MxChat – AI Chatbot & Content Generation for WordPress vtrunk
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
mxchat-basic / js / admin-sidebar.js

admin-sidebar.js in MxChat – AI Chatbot & Content Generation for WordPress trunk, at js/admin-sidebar.js

273 lines 12.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Shared admin shell JS for the MxChat admin design system.
3 *
4 * Wires tab switching (with #hash deep-linking and ?tab= precedence),
5 * parent-group accordion navigation, mobile menu open/close (with body
6 * scroll lock + Escape), and copy-to-clipboard inside every
7 * .mxch-admin-wrapper on the page. Scoped to each wrapper so multiple
8 * admin shells could in theory coexist (today we have one per page, but
9 * no global side effects beyond the mobile body scroll lock).
10 *
11 * Deep-linking (plan 4ede16): a URL hash naming a .mxch-section id activates
12 * that tab on load and on hashchange, clicking a tab writes the hash via
13 * history.replaceState (replaceState, not pushState, so the browser Back
14 * button leaves the page rather than walking back through every tab), and
15 * plain href="#section-id" anchors work with no data-target attribute.
16 * A ?tab=<section-id> query param takes precedence over the #hash on load
17 * (the Onboarding setup-step CTAs land on settings sub-tabs this way).
18 *
19 * Accordion (plan c192a4): a .mxch-nav-item containing a .mxch-nav-sub whose
20 * .mxch-nav-link has no data-target is an expandable group — clicking it
21 * collapses sibling groups and toggles this one, activating the first
22 * sub-tab on expand. Mobile menus get the equivalent via
23 * .mxch-mobile-nav-link[data-parent] / .mxch-mobile-nav-sub[data-parent].
24 * All of it is feature-detected: pages with flat navs (API Access,
25 * Knowledge, mcp) never enter these branches.
26 *
27 * Source of truth for the inline-script logic previously duplicated in
28 * mxchat-basic/includes/admin-api-page.php, admin-settings-page.php,
29 * admin-knowledge-page.php and mxchat-mcp/includes/admin-mcp-page.php.
30 *
31 * @package MxChat
32 */
33 (function () {
34 // Section ids are plain slugs; anything else (junk hashes, selector
35 // metacharacters) is rejected before it reaches querySelector.
36 var SECTION_ID = /^[A-Za-z][A-Za-z0-9_-]*$/;
37
38 function closeMobileMenu(wrapper) {
39 var mobileMenu = wrapper.querySelector('.mxch-mobile-menu');
40 var mobileOverlay = wrapper.querySelector('.mxch-mobile-overlay');
41 if (mobileMenu && mobileMenu.classList.contains('open')) {
42 document.body.style.overflow = '';
43 }
44 if (mobileMenu) { mobileMenu.classList.remove('open'); }
45 if (mobileOverlay) { mobileOverlay.classList.remove('open'); }
46 }
47
48 // Collapse every expandable nav group in the wrapper except `keep`.
49 // No-op on pages without .mxch-nav-sub groups.
50 function collapseNavGroups(wrapper, keep) {
51 wrapper.querySelectorAll('.mxch-nav-item.expanded').forEach(function (item) {
52 if (item !== keep && item.querySelector('.mxch-nav-sub')) {
53 item.classList.remove('expanded');
54 }
55 });
56 }
57
58 /**
59 * Activate the tab whose .mxch-section id is targetId. Returns false when
60 * the id does not name a section in THIS wrapper — an unrelated hash (or a
61 * data-target pointing at a non-section element, e.g. the key-test
62 * buttons' input ids) leaves the current tab alone instead of blanking
63 * every section.
64 */
65 function activateTarget(wrapper, targetId) {
66 if (!targetId || !SECTION_ID.test(targetId)) {
67 return false;
68 }
69 var target = wrapper.querySelector('.mxch-section#' + targetId);
70 if (!target) {
71 return false;
72 }
73
74 wrapper.querySelectorAll('.mxch-section').forEach(function (s) {
75 s.classList.remove('active');
76 });
77 target.classList.add('active');
78
79 // Start the newly shown section at the top of the content area.
80 var content = wrapper.querySelector('.mxch-content');
81 if (content) { content.scrollTop = 0; }
82
83 wrapper.querySelectorAll('.mxch-nav-link, .mxch-nav-sub-link, .mxch-mobile-nav-link, .mxch-mobile-nav-sub-link').forEach(function (l) {
84 l.classList.remove('active');
85 });
86 wrapper.querySelectorAll('[data-target="' + targetId + '"]').forEach(function (l) {
87 l.classList.add('active');
88 // A sub-tab's parent nav group must open and highlight too, or the
89 // visible panel sits under a sidebar that looks unrelated.
90 if (l.classList.contains('mxch-nav-sub-link')) {
91 var parentItem = l.closest('.mxch-nav-item');
92 if (parentItem) {
93 parentItem.classList.add('expanded');
94 var parentLink = parentItem.querySelector('.mxch-nav-link');
95 if (parentLink) {
96 parentLink.classList.add('active');
97 }
98 }
99 }
100 });
101
102 closeMobileMenu(wrapper);
103 return true;
104 }
105
106 function wire(wrapper) {
107 if (!wrapper || wrapper.dataset.mxchAdminWired === '1') {
108 return;
109 }
110 wrapper.dataset.mxchAdminWired = '1';
111
112 var copiedLabel = (window.MxChatAdminSidebarI18n && window.MxChatAdminSidebarI18n.copied) || 'Copied';
113
114 // Tab switcher: clicking any [data-target] button toggles .mxch-section.active.
115 var navButtons = wrapper.querySelectorAll('[data-target]');
116 navButtons.forEach(function (btn) {
117 btn.addEventListener('click', function (e) {
118 e.preventDefault();
119 var targetId = btn.getAttribute('data-target');
120 if (activateTarget(wrapper, targetId)) {
121 // A direct (no-submenu) sidebar link closes any open
122 // accordion group; sub-links keep their parent open.
123 if (btn.classList.contains('mxch-nav-link')) {
124 collapseNavGroups(wrapper, null);
125 }
126 if (window.history && window.history.replaceState) {
127 window.history.replaceState(null, '', '#' + targetId);
128 }
129 }
130 });
131 });
132
133 // Accordion groups: a .mxch-nav-link with a .mxch-nav-sub sibling and
134 // no data-target toggles its group. Expanding activates the first
135 // sub-tab (so the panel always matches the highlighted group).
136 wrapper.querySelectorAll('.mxch-nav-item').forEach(function (item) {
137 if (!item.querySelector('.mxch-nav-sub')) {
138 return;
139 }
140 var parentLink = item.querySelector('.mxch-nav-link');
141 if (!parentLink || parentLink.hasAttribute('data-target')) {
142 return;
143 }
144 parentLink.addEventListener('click', function (e) {
145 e.preventDefault();
146 var wasExpanded = item.classList.contains('expanded');
147 collapseNavGroups(wrapper, item);
148 item.classList.toggle('expanded');
149 if (!wasExpanded) {
150 var firstSub = item.querySelector('.mxch-nav-sub-link');
151 var firstTarget = firstSub ? firstSub.getAttribute('data-target') : null;
152 if (firstTarget && activateTarget(wrapper, firstTarget) && window.history && window.history.replaceState) {
153 window.history.replaceState(null, '', '#' + firstTarget);
154 }
155 }
156 });
157 });
158
159 // Mobile accordion groups (settings-page pattern): a
160 // .mxch-mobile-nav-link[data-parent] with no data-target toggles the
161 // matching .mxch-mobile-nav-sub[data-parent], collapsing siblings.
162 wrapper.querySelectorAll('.mxch-mobile-nav-link[data-parent]').forEach(function (btn) {
163 if (btn.hasAttribute('data-target')) {
164 return;
165 }
166 var parentId = btn.getAttribute('data-parent');
167 if (!parentId || !SECTION_ID.test(parentId)) {
168 return;
169 }
170 btn.addEventListener('click', function (e) {
171 e.preventDefault();
172 var subNav = wrapper.querySelector('.mxch-mobile-nav-sub[data-parent="' + parentId + '"]');
173 if (!subNav) {
174 return;
175 }
176 var wasExpanded = subNav.classList.contains('expanded');
177 wrapper.querySelectorAll('.mxch-mobile-nav-sub').forEach(function (nav) {
178 nav.classList.remove('expanded');
179 });
180 wrapper.querySelectorAll('.mxch-mobile-nav-link').forEach(function (l) {
181 l.classList.remove('expanded');
182 });
183 if (!wasExpanded) {
184 subNav.classList.add('expanded');
185 btn.classList.add('expanded');
186 }
187 });
188 });
189
190 // Deep-link on load: ?tab=<section-id> wins over the #hash (Onboarding
191 // CTAs), then a section-naming hash. Neither writes the hash back.
192 var tabParam = null;
193 try {
194 tabParam = new URLSearchParams(window.location.search).get('tab');
195 } catch (err) {
196 tabParam = null;
197 }
198 var loadActivated = tabParam ? activateTarget(wrapper, tabParam) : false;
199 if (!loadActivated && window.location.hash) {
200 activateTarget(wrapper, window.location.hash.slice(1));
201 }
202 // …and on every hashchange, so plain href="#section-id" anchors (and a
203 // second link to a different tab) switch without a reload.
204 window.addEventListener('hashchange', function () {
205 activateTarget(wrapper, window.location.hash.slice(1));
206 });
207
208 // Mobile menu open/close. Opening locks body scroll; every close path
209 // (button, overlay, Escape, tab activation) releases it.
210 var mobileBtn = wrapper.querySelector('.mxch-mobile-menu-btn');
211 var mobileMenu = wrapper.querySelector('.mxch-mobile-menu');
212 var mobileOverlay = wrapper.querySelector('.mxch-mobile-overlay');
213 var mobileClose = wrapper.querySelector('.mxch-mobile-menu-close');
214
215 function openMenu() {
216 if (!mobileMenu) { return; }
217 mobileMenu.classList.add('open');
218 if (mobileOverlay) { mobileOverlay.classList.add('open'); }
219 document.body.style.overflow = 'hidden';
220 }
221 function closeMenu() {
222 closeMobileMenu(wrapper);
223 }
224 if (mobileBtn) { mobileBtn.addEventListener('click', openMenu); }
225 if (mobileClose) { mobileClose.addEventListener('click', closeMenu); }
226 if (mobileOverlay) { mobileOverlay.addEventListener('click', closeMenu); }
227 document.addEventListener('keydown', function (e) {
228 if (e.key === 'Escape' && mobileMenu && mobileMenu.classList.contains('open')) {
229 closeMenu();
230 }
231 });
232
233 // Copy-to-clipboard.
234 wrapper.querySelectorAll('[data-mxch-copy]').forEach(function (btn) {
235 btn.addEventListener('click', function () {
236 var val = btn.getAttribute('data-mxch-copy');
237 if (!val) {
238 return;
239 }
240 var label = btn.querySelector('span');
241 var original = label ? label.textContent : '';
242 var done = function () {
243 if (label) { label.textContent = copiedLabel; }
244 setTimeout(function () {
245 if (label) { label.textContent = original; }
246 }, 1500);
247 };
248 if (navigator.clipboard && navigator.clipboard.writeText) {
249 navigator.clipboard.writeText(val).then(done).catch(function () { done(); });
250 } else {
251 var ta = document.createElement('textarea');
252 ta.value = val;
253 document.body.appendChild(ta);
254 ta.select();
255 try { document.execCommand('copy'); } catch (e) {}
256 document.body.removeChild(ta);
257 done();
258 }
259 });
260 });
261 }
262
263 function init() {
264 document.querySelectorAll('.mxch-admin-wrapper').forEach(wire);
265 }
266
267 if (document.readyState === 'loading') {
268 document.addEventListener('DOMContentLoaded', init);
269 } else {
270 init();
271 }
272 })();
273