1// /js/stores/slideout.js 2// 3// $store.slideout â the shared right-hand slide-out panel. Do not build a 4// bespoke slideout; this store and its `form` type cover both read-only and 5// editable cases. Shell lives in _default/_foot.htm (#slideout-panel-root), 6// form behaviour in slideoutForm.js. Mirrors the modals pattern. 7// 8// Types: 9// content (default) â read-only / light panels. open({ url }) or 10// open({ template }) â htmx into #slideout-htmx-body. Scrim/Escape â close(). 11// form â editable side panel (dialog semantics). Footer chrome, 12// dirty guard, optional iframe submit. Scrim/Escape/header-close â requestClose(). 13// 14// Form slideout open: 15// $store.slideout.open({ 16// type: 'form', 17// url: '/module/admin/workflows/foo.php?_fragment=1', 18// title: 'Configure: â¦', 19// panelClass: '-below-topbar -form -w-medium', // or -w-wide (960px) 20// submitTarget: 'iframe', 21// hostId: 'page-root-id', 22// requiredFields: ['hour', 'minute'], 23// onSaved: 'handleSlideoutSaved', 24// onDiscard: 'confirmDiscardSlideoutEdits', 25// }); 26// 27// - Unsaved dismiss â $store.modals.open({ type: 'confirm', targetId, onOk: onDiscard }) 28// (never page-local confirm overlays).
29// - Host callbacks use hostId + method name (same as modals targetId + onOk). 30// - Page-specific chrome (Object Map toggle, Trigger Run, delete) â x-teleport into 31// #slideout-head-slot / #slideout-foot-left, not a domain-specific slideout type. 32// - Never embed page-local .wfm-slideout shells in templates. 33 34window.appStores = window.appStores || {}; 35 36window.appStores.slideout = { 37 isOpen: false, 38 type: 'content', 39 currentWidth: '50%', 40 contentUrl: '', 41 templateId: '', 42 title: 'Details', 43 subtitle: '', 44 panelClass: '', 45 dirty: false, 46 dirtyGuard: false, 47 submitTarget: '', 48 hostId: '', 49 requiredFields: [], 50 onSaved: '', 51 onClose: '', 52 onDiscard: '', 53 onContentLoaded: '', 54 hideFooter: false, 55 loadMethod: 'get', 56 loadBody: '', 57 shellOnly: false, 58 _pendingSave: false, 59 _saveInFlight: false, 60 _dirtyArmed: false, 61 _dirtyArmTimer: null, 62 _closeTimer: null, 63 64 // Close guard. Content loaded into the panel may register a guard via setCloseGuard(fn). 65 // On an accidental close (overlay / Ã / Escape) close() calls fn(); if it returns true the 66 // panel stays open â the guard is responsible for showing its own confirm UI (the loaded 67 // content owns that, e.g. a local Alpine confirm dialog). close(true) bypasses the guard 68 // (used by the content's explicit save / discard action once it's ready to close for real). 69 closeGuard: null, 70 71 /** HTMX swap target â inner child of #slideout-content so teleports are not wiped. */ 72 htmxTarget() { 73 return document.getElementById('slideout-htmx-body') 74 || document.getElementById('slideout-content'); 75 }, 76 77 /** Register a close guard for the currently-open content. fn() => true means "block the close". */ 78 setCloseGuard(fn) { 79 this.closeGuard = (typeof fn === 'function') ? fn : null; 80 }, 81 82 open(config = {}) { 83 // fresh panel â drop any guard left over from a previous occupant 84 this.closeGuard = null; 85 this.type = config.type || 'content'; 86 this.title = config.title || 'Details'; 87 this.subtitle = config.subtitle || ''; 88 this.panelClass = config.panelClass || ''; 89 // Width preset classes (-w-medium / -w-wide) own the width via CSS â don't fight them with 50%. 90 var presetW = this.panelClass.indexOf('-w-wide') !== -1 || this.panelClass.indexOf('-w-medium') !== -1; 91 this.currentWidth = config.width || (presetW ? '' : '50%'); 92 this.dirty = false; 93 this._dirtyArmed = false; 94 if (this._dirtyArmTimer) { 95 clearTimeout(this._dirtyArmTimer); 96 this._dirtyArmTimer = null; 97 } 98 if (this._closeTimer) { 99 clearTimeout(this._closeTimer); 100 this._closeTimer = null; 101 } 102 this.dirtyGuard = this.type === 'form' ? (config.dirtyGuard !== false) : false; 103 this.submitTarget = config.submitTarget || (this.type === 'form' ? 'htmx' : ''); 104 this.hostId = config.hostId || ''; 105 this.requiredFields = config.requiredFields || []; 106 this.onSaved = config.onSaved || ''; 107 this.onClose = config.onClose || ''; 108 this.onDiscard = config.onDiscard || 'confirmDiscardSlideoutEdits'; 109 this.onContentLoaded = config.onContentLoaded || ''; 110 this.hideFooter = !!config.hideFooter; 111 this.loadMethod = config.loadMethod || 'get'; 112 this.loadBody = config.loadBody || ''; 113 this.shellOnly = !!config.shellOnly; 114 this._pendingSave = false; 115 this._saveInFlight = false; 116 117 if (config.url) { 118 this.contentUrl = config.url; 119 this.templateId = ''; 120 this.isOpen = true; 121 Alpine.nextTick(() => this._triggerHtmxLoad()); 122 } else if (config.template) { 123 this.contentUrl = ''; 124 this.templateId = config.template; 125 this.isOpen = true; 126 Alpine.nextTick(() => { 127 const template = document.getElementById(this.templateId); 128 const contentTarget = this.htmxTarget(); 129 if (template && contentTarget) { 130 contentTarget.innerHTML = ''; 131 contentTarget.appendChild(template.content.cloneNode(true)); 132 const initialData = config.initialData || {}; 133 if (Object.keys(initialData).length > 0) { 134 const formInSlideout = contentTarget.querySelector('form'); 135 if (formInSlideout) { 136 for (const [name, value] of Object.entries(initialData)) { 137 // Multi-value field (searchTags holds it under the 138 // bare name, the markup is name="x[]"): tick exactly 139 // the values in the list, clear the rest. 140 if (Array.isArray(value)) { 141 const boxes = formInSlideout.querySelectorAll(`[name="${name}[]"]`); 142 if (boxes.length) {
143 boxes.forEach(box => { box.checked = value.includes(box.value); }); 144 continue; 145 } 146 } 147 const input = formInSlideout.querySelector(`[name="${name}"]`); 148 if (input) { 149 // Never assign a <select> a value none of its 150 // options carry: that sets selectedIndex to -1 151 // and the control renders blank. Leave the 152 // server-rendered selection standing instead. 153 if (input.tagName === 'SELECT' 154 && !input.querySelector(`option[value="${CSS.escape(String(value))}"]`)) { 155 continue; 156 } 157 input.value = value; 158 if ('checkbox' == input.type && 1 == value) { 159 input.checked = true; 160 } 161 } 162 } 163 } 164 } 165 htmx.process(contentTarget); 166 if (typeof window.ajaxLoad === 'function' && typeof window.jQuery !== 'undefined') { 167 window.jQuery(contentTarget).find('.ajax-load').each(function () { 168 window.ajaxLoad(window.jQuery(this)); 169 }); 170 } 171 if (this.type === 'form' && window.appComponents && window.appComponents.slideoutForm) { 172 const root = document.getElementById('slideout-panel-root'); 173 if (root && Alpine.$data) { 174 const cmp = Alpine.$data(root); 175 if (cmp && typeof cmp.onContentLoaded === 'function') { 176 cmp.onContentLoaded(contentTarget); 177 } 178 } 179 } 180 } else { 181 console.error(`[Slideout] Could not find template #${this.templateId} or target #slideout-htmx-body.`); 182 } 183 }); 184 } else if (config.loadMethod === 'post' && config.loadUrl) { 185 this.isOpen = true; 186 Alpine.nextTick(() => { 187 const root = document.getElementById('slideout-panel-root'); 188 if (root && Alpine.$data) { 189 const cmp = Alpine.$data(root); 190 if (cmp && typeof cmp.loadPostFragment === 'function') { 191 cmp.loadPostFragment(config.loadUrl, config.loadBody || '', (target) => { 192 if (typeof cmp.onContentLoaded === 'function') { 193 cmp.onContentLoaded(target); 194 } 195 }); 196 } 197 } 198 }); 199 } else if (config.shellOnly) { 200 this.contentUrl = ''; 201 this.templateId = ''; 202 this.isOpen = true; 203 } else { 204 console.error("[Slideout] 'open' called without a 'url', 'template', 'loadUrl', or 'shellOnly' config."); 205 } 206 }, 207 208 _triggerHtmxLoad() { 209 const contentEl = this.htmxTarget(); 210 if (!contentEl) { 211 console.error('[Slideout] Critical: slideout htmx target not found in DOM after opening.'); 212 return; 213 } 214 contentEl.removeAttribute('hx-post'); 215 contentEl.removeAttribute('hx-vals'); 216 if (this.loadMethod === 'post') { 217 contentEl.setAttribute('hx-post', this.contentUrl); 218 if (this.loadBody) { 219 contentEl.setAttribute('hx-vals', this.loadBody); 220 } 221 contentEl.removeAttribute('hx-get'); 222 } else { 223 contentEl.setAttribute('hx-get', this.contentUrl); 224 contentEl.removeAttribute('hx-post'); 225 contentEl.removeAttribute('hx-vals'); 226 } 227 htmx.process(contentEl); 228 htmx.trigger(contentEl, 'load-slideout-content'); 229 }, 230 231 isDirty() { 232 if (!this._dirtyArmed) return false;
233 if (this.dirty) return true; 234 try { 235 const ed = window.tinymce && window.tinymce.get('body'); 236 if (ed && ed.isDirty()) return true; 237 } catch (e) { /* ignore */ } 238 if (this.hostId) { 239 const el = document.getElementById(this.hostId); 240 if (el && Alpine.$data) { 241 const host = Alpine.$data(el); 242 if (host && typeof host.isEditorDirty === 'function' && host.isEditorDirty()) { 243 return true; 244 } 245 } 246 } 247 return false; 248 }, 249 250 requestClose() { 251 if (this.type === 'form' && this.dirtyGuard && this.isDirty()) { 252 const self = this; 253 const hostId = this.hostId; 254 const onDiscard = this.onDiscard; 255 const modals = (typeof Alpine !== 'undefined' && Alpine.store) 256 ? Alpine.store('modals') 257 : (window.appStores && window.appStores.modals); 258 if (!modals || typeof modals.open !== 'function') { 259 this.close(); 260 return; 261 } 262 // Function onOk so a form panel with no hostId still discards (string 263 // onDiscard only resolves when targetId points at a page component). 264 const discard = function () { 265 // $store.modals accepts a function onOk, and callers relied on that before this 266 // wrapper existed â resolving only string method names would drop theirs silently. 267 if (typeof onDiscard === 'function') { 268 onDiscard(); 269 return; 270 } 271 if (hostId && onDiscard && typeof Alpine !== 'undefined' && Alpine.$data) { 272 const el = document.getElementById(hostId); 273 const host = el ? Alpine.$data(el) : null; 274 if (host && typeof host[onDiscard] === 'function') { 275 host[onDiscard](); 276 return; 277 } 278 } 279 self.close(true); 280 }; 281 modals.open({ 282 type: 'confirm', 283 title: 'Unsaved changes', 284 message: 'You have unsaved changes. Close without saving?', 285 okText: 'Discard Changes', 286 cancelText: 'Continue Editing', 287 targetId: hostId, 288 onOk: discard, 289 }); 290 return; 291 } 292 this.close(); 293 }, 294 295 /** 296 * Close the panel. If a registered closeGuard returns true (unsaved changes), the panel 297 * stays open and the guard surfaces its own confirm UI. Pass force=true (explicit save 298 * or discard from the loaded content) to bypass the guard. 299 */ 300 close(force = false) { 301 if (!force && typeof this.closeGuard === 'function' && this.closeGuard()) { 302 return; 303 } 304 this.closeGuard = null; 305 this.isOpen = false; 306 // Error tips are appended to <body>; the panel only slides away, so they 307 // must be cleared here rather than waiting for the content wipe below 308 // (which is skipped entirely for host-owned/teleported bodies). 309 const panelRoot = document.getElementById('slideout-panel-root'); 310 if (panelRoot && window.fieldValidator && window.fieldValidator.clearErrors) { 311 window.fieldValidator.clearErrors(panelRoot); 312 } 313 this.dirty = false; 314 this._dirtyArmed = false; 315 if (this._dirtyArmTimer) { 316 clearTimeout(this._dirtyArmTimer); 317 this._dirtyArmTimer = null; 318 } 319 this._pendingSave = false; 320 this._saveInFlight = false; 321 if (this._closeTimer) { 322 clearTimeout(this._closeTimer); 323 } 324 this._closeTimer = setTimeout(() => { 325 this._closeTimer = null; 326 if (this.isOpen) return; 327 // Host-owned bodies (x-teleport, etc.) must not be wiped â clearing #slideout-content 328 // breaks Alpine teleport and the panel stays empty on the next open. 329 const preserveContent = this.shellOnly 330 || (this.panelClass && this.panelClass.indexOf('-object-map-list') !== -1); 331 this.contentUrl = ''; 332 this.templateId = ''; 333 this.type = 'content'; 334 this.title = 'Details'; 335 this.subtitle = ''; 336 this.panelClass = ''; 337 this.shellOnly = false;
338 this.dirtyGuard = false; 339 this.submitTarget = ''; 340 this.hostId = ''; 341 this.requiredFields = []; 342 this.hideFooter = false; 343 this.loadMethod = 'get'; 344 this.loadBody = ''; 345 const htmxBody = document.getElementById('slideout-htmx-body'); 346 const contentTarget = document.getElementById('slideout-content'); 347 const foot = document.getElementById('slideout-foot-actions'); 348 if (foot) foot.querySelectorAll('[data-slideout-moved]').forEach((b) => b.remove()); 349 const swapTarget = htmxBody || contentTarget; 350 if (swapTarget && !preserveContent) { 351 if (window.fieldValidator) { 352 swapTarget.querySelectorAll('form').forEach((f) => window.fieldValidator.unbind(f)); 353 } 354 swapTarget.innerHTML = ''; 355 swapTarget.removeAttribute('hx-get'); 356 swapTarget.removeAttribute('hx-post'); 357 swapTarget.removeAttribute('hx-vals'); 358 } 359 }, 300); 360 }, 361};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.