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
← All changes | js/admin-sidebar.js +186 -27 3.2.13trunk View file →
@@ -1,19 +1,109 @@
1 1 /**
2 2 * Shared admin shell JS for the MxChat admin design system.
3 3 *
4 - * Wires tab switching, mobile menu open/close, and copy-to-clipboard
5 - * inside every .mxch-admin-wrapper on the page. Scoped to each wrapper
6 - * so multiple admin shells could in theory coexist (today we have one
7 - * per page, but no global side effects).
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).
8 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 + *
9 27 * Source of truth for the inline-script logic previously duplicated in
10 - * mxchat-basic/includes/admin-api-page.php and
11 - * mxchat-mcp/includes/admin-mcp-page.php.
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.
12 30 *
13 31 * @package MxChat
14 32 */
15 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 +
16 106 function wire(wrapper) {
17 107 if (!wrapper || wrapper.dataset.mxchAdminWired === '1') {
18 108 return;
19 109 }
@@ -26,35 +116,98 @@
26 116 navButtons.forEach(function (btn) {
27 117 btn.addEventListener('click', function (e) {
28 118 e.preventDefault();
29 119 var targetId = btn.getAttribute('data-target');
30 - if (!targetId) {
31 - return;
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 + }
32 129 }
130 + });
131 + });
33 132
34 - wrapper.querySelectorAll('.mxch-section').forEach(function (s) {
35 - s.classList.remove('active');
36 - });
37 - var target = wrapper.querySelector('#' + targetId);
38 - if (target) {
39 - target.classList.add('active');
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 + }
40 155 }
156 + });
157 + });
41 158
42 - wrapper.querySelectorAll('.mxch-nav-link, .mxch-mobile-nav-link').forEach(function (l) {
43 - l.classList.remove('active');
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');
44 179 });
45 - wrapper.querySelectorAll('[data-target="' + targetId + '"]').forEach(function (l) {
46 - l.classList.add('active');
180 + wrapper.querySelectorAll('.mxch-mobile-nav-link').forEach(function (l) {
181 + l.classList.remove('expanded');
47 182 });
183 + if (!wasExpanded) {
184 + subNav.classList.add('expanded');
185 + btn.classList.add('expanded');
186 + }
187 + });
188 + });
48 189
49 - var mobileMenu = wrapper.querySelector('.mxch-mobile-menu');
50 - if (mobileMenu) { mobileMenu.classList.remove('open'); }
51 - var mobileOverlay = wrapper.querySelector('.mxch-mobile-overlay');
52 - if (mobileOverlay) { mobileOverlay.classList.remove('open'); }
53 - });
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));
54 206 });
55 207
56 - // Mobile menu open/close.
208 + // Mobile menu open/close. Opening locks body scroll; every close path
209 + // (button, overlay, Escape, tab activation) releases it.
57 210 var mobileBtn = wrapper.querySelector('.mxch-mobile-menu-btn');
58 211 var mobileMenu = wrapper.querySelector('.mxch-mobile-menu');
59 212 var mobileOverlay = wrapper.querySelector('.mxch-mobile-overlay');
60 213 var mobileClose = wrapper.querySelector('.mxch-mobile-menu-close');
@@ -59,18 +212,24 @@
59 212 var mobileOverlay = wrapper.querySelector('.mxch-mobile-overlay');
60 213 var mobileClose = wrapper.querySelector('.mxch-mobile-menu-close');
61 214
62 215 function openMenu() {
63 - if (mobileMenu) { mobileMenu.classList.add('open'); }
216 + if (!mobileMenu) { return; }
217 + mobileMenu.classList.add('open');
64 218 if (mobileOverlay) { mobileOverlay.classList.add('open'); }
219 + document.body.style.overflow = 'hidden';
65 220 }
66 221 function closeMenu() {
67 - if (mobileMenu) { mobileMenu.classList.remove('open'); }
68 - if (mobileOverlay) { mobileOverlay.classList.remove('open'); }
222 + closeMobileMenu(wrapper);
69 223 }
70 224 if (mobileBtn) { mobileBtn.addEventListener('click', openMenu); }
71 225 if (mobileClose) { mobileClose.addEventListener('click', closeMenu); }
72 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 + });
73 232
74 233 // Copy-to-clipboard.
75 234 wrapper.querySelectorAll('[data-mxch-copy]').forEach(function (btn) {
76 235 btn.addEventListener('click', function () {