PageSourceSearch

https://stguthlacs.org.uk/js/stores/slideout.js?v=5

js stguthlacs.org.uk collected 2026-09-24 12:52:16 UTC 16,304 bytes, 361 lines download raw bytes

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.