studio-engine 0.95.0 → 0.96.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,574 @@
1
+ // studio/modal_host: the modal stack behind studio/modals/_host ($store.modals)
2
+ // and studio/modals/_scoped_host ($store.<name>), the focus trap both share, the
3
+ // animation and card-width registries, and the load-hold convention.
4
+ //
5
+ // WHAT USED TO BE HERE. Each partial carried this as an inline <script>: the
6
+ // shared host four of them (its store, its registry, its cleanup, and the
7
+ // load convention it rendered), the scoped host one more copy of the stack. An
8
+ // inline script carries no CSP nonce, and two copies of one focus trap drift.
9
+ // The partials now render markup only; each host's <template> carries
10
+ //
11
+ // data-studio-controller="modal-host"
12
+ // data-modal-host-store-value="modals" (the scoped host: its own name)
13
+ // data-modal-host-scoped-value="true" (the scoped host only)
14
+ //
15
+ // and studio/controllers/modal_host_controller binds the element's lifetime.
16
+ //
17
+ // WHEN A STORE REGISTERS, AND WHY NOT IN connect(). Alpine evaluates
18
+ // $store.<name>.current() as it starts, which is BEFORE Stimulus connects any
19
+ // controller (Alpine starts in a microtask after its deferred script; Stimulus
20
+ // waits for DOMContentLoaded). So the stores register from this module, by
21
+ // three paths, and each registers only a store Alpine does not already have
22
+ // (a host's own wins, and a host rendered twice registers once):
23
+ //
24
+ // 1. alpine:init, on a full load: every host element in the parsed document.
25
+ // The only path that is early enough there.
26
+ // 2. turbo:before-render and turbo:before-frame-render: every host element
27
+ // in the incoming body or frame, before Alpine sees it. Alpine fires
28
+ // alpine:init once per document, so a page that brings its own scoped
29
+ // host on a Turbo visit needs a later path.
30
+ // 3. the controller's connect(), for a Turbo visit too and for markup
31
+ // inserted any other way.
32
+ //
33
+ // MEASURED on a consumer (moms-app, a Turbo visit from /profile to
34
+ // /profile/edit, which brings profileModals): path 2 alone and path 3 alone
35
+ // each register the store before Alpine reads it; with both removed the visit
36
+ // throws "Cannot read properties of undefined (reading 'current')".
37
+ //
38
+ // THE WINDOW GLOBALS. window.ModalAnimations and window.StudioModals
39
+ // (CARD_WIDTHS, DEFAULT_CARD_WIDTH, MIN_LOAD_MS, holdAtLeast) are published
40
+ // here on every page, each merged over whatever a host defined first, so an
41
+ // app's own entries survive.
42
+ //
43
+ // WHY THIS IS ITS OWN ENTRY POINT. layouts/studio/_head imports this module by
44
+ // its own nonced module tag as well as through the boot graph (the controller
45
+ // imports it), like studio/alpine_stores, so a boot that fails to load does
46
+ // not take the modal stack with it. It imports nothing, and
47
+ // test/javascript/modal_host.test.mjs holds it to that.
48
+
49
+ // The exit keyframes' duration: a closing entry is spliced off the stack this
50
+ // long after close(). Keep it equal to .modal-card-unmount and
51
+ // .modal-card-swap-out[-back] in studio/modals/_host and engine-motion.css.
52
+ export const CLOSE_ANIM_MS = 220
53
+ // The slide-in keyframe's duration, .modal-card-swap-in[-back].
54
+ export const SWAP_IN_MS = 220
55
+
56
+ // The engine's named animations. Each key maps to a CSS class (the keyframes
57
+ // ship in studio/modals/_host and in engine-motion.css) and its duration in ms,
58
+ // which close() waits before splicing. Open a modal with
59
+ // { enterAnim: 'shake' } or { exitAnim: 'slide' }; omitted, it uses 'pop'.
60
+ // The directional swap()/advance() slide is separate and always uses the
61
+ // modal-card-swap-* classes.
62
+ export const ANIMATION_DEFAULTS = {
63
+ enter: {
64
+ pop: { cls: 'modal-card-mount', ms: 320 }, // spring bounce in
65
+ shake: { cls: 'modal-card-shake-in', ms: 600 }, // a "not quite yet" nope
66
+ slide: { cls: 'modal-card-swap-in', ms: 220 } // slide in from the left
67
+ },
68
+ exit: {
69
+ pop: { cls: 'modal-card-unmount', ms: 220 }, // anticipation, then fall
70
+ slide: { cls: 'modal-card-swap-out', ms: 220 } // slide out to the right
71
+ }
72
+ }
73
+
74
+ // The card width an id gets when CARD_WIDTHS names no width for it. A literal
75
+ // floor too: a width that fails to resolve renders a full-bleed card.
76
+ export const DEFAULT_CARD_WIDTH = 'max-w-sm'
77
+
78
+ // The standard minimum a load spinner stays up, in ms. Below about a second it
79
+ // flashes and reads as a glitch. See studio/modals/blocks/_processing_card.
80
+ export const MIN_LOAD_MS = 1400
81
+
82
+ // An app's animation entries merged OVER the engine defaults, per channel, so
83
+ // adding an animation never means redefining pop, shake or slide.
84
+ export function mergeAnimations(overrides) {
85
+ var o = overrides || {}
86
+ return {
87
+ enter: Object.assign({}, ANIMATION_DEFAULTS.enter, o.enter || {}),
88
+ exit: Object.assign({}, ANIMATION_DEFAULTS.exit, o.exit || {})
89
+ }
90
+ }
91
+
92
+ // The animation for a key, read from win.ModalAnimations at CALL time and never
93
+ // undefined. A script loading after this one may replace the registry
94
+ // wholesale; a miss would make close() throw on .ms and strand the modal open,
95
+ // so an unknown key, or a gutted registry, falls back to the built-in 'pop'.
96
+ export function modalAnim(win, channel, key) {
97
+ var table = (win.ModalAnimations && win.ModalAnimations[channel]) || {}
98
+ return table[key] || table.pop || ANIMATION_DEFAULTS[channel].pop
99
+ }
100
+
101
+ // The card width for a modal id: CARD_WIDTHS by id, else DEFAULT_CARD_WIDTH,
102
+ // else the literal floor. BY ID, NOT BY PROP: a card opened from several places
103
+ // would otherwise have to carry the prop at every opener (turf-monster's
104
+ // wallet-setup opens from three), and one miss renders it at two widths.
105
+ export function modalCardWidth(win, id) {
106
+ var sm = win.StudioModals || {}
107
+ return (sm.CARD_WIDTHS && sm.CARD_WIDTHS[id]) || sm.DEFAULT_CARD_WIDTH || DEFAULT_CARD_WIDTH
108
+ }
109
+
110
+ // holdAtLeast(minMs).then(cb) fires cb at max(minMs, time since the call):
111
+ // stamp it when a loading view appears, so a fast operation still shows its
112
+ // spinner and a slow one is never cut short.
113
+ export function holdAtLeast(minMs, clock) {
114
+ var now = (clock && clock.now) || Date.now
115
+ var later = (clock && clock.later) || function (fn, ms) { setTimeout(fn, ms) }
116
+ var startedAt = now()
117
+ return {
118
+ then: function (callback) {
119
+ var remaining = Math.max(0, minMs - (now() - startedAt))
120
+ if (remaining === 0) { callback(); return }
121
+ later(callback, remaining)
122
+ }
123
+ }
124
+ }
125
+
126
+ // Publishes the registries and the load-hold convention on win, each merged
127
+ // over (or kept from) what the host already defined.
128
+ export function installModalGlobals(win) {
129
+ win.ModalAnimations = mergeAnimations(win.ModalAnimations)
130
+
131
+ var sm = win.StudioModals = win.StudioModals || {}
132
+ sm.CARD_WIDTHS = Object.assign({}, sm.CARD_WIDTHS || {})
133
+ sm.DEFAULT_CARD_WIDTH = sm.DEFAULT_CARD_WIDTH || DEFAULT_CARD_WIDTH
134
+ sm.MIN_LOAD_MS = sm.MIN_LOAD_MS || MIN_LOAD_MS
135
+ sm.holdAtLeast = sm.holdAtLeast || function (minMs) { return holdAtLeast(minMs) }
136
+ }
137
+
138
+ // ---- the focus trap, shared by both stores --------------------------------
139
+ //
140
+ // THE RETURN TARGET AND THE BACKDROP LIVE IN A CLOSURE, NOT ON THE STORE.
141
+ // Alpine.store() wraps its object in a reactive Proxy, so a DOM node assigned
142
+ // to a store property reads back as a proxy of that node, and every identity
143
+ // check against it (===, contains(), Set membership) is false forever. Focus
144
+ // code is full of identity checks.
145
+ //
146
+ // THE RE-MOUNT SEAMS. captureFocus runs from x-init on the backdrop, which
147
+ // lives inside <template x-if="current()">. Any change that keeps current()
148
+ // truthy (a swap, an advance, a push onto a non-empty stack, a close down to
149
+ // an underlying card, a dismissible sweep that leaves a card) never re-mounts
150
+ // that outer template, so x-init never runs again, while the INNER content
151
+ // template does re-mount and unmounts whatever was focused. Focus falls to the
152
+ // document body, which is not a descendant of the backdrop, the Tab handler
153
+ // bound there stops seeing the key, and native tabbing walks out to the page
154
+ // behind. Each of those seams calls refocus().
155
+ function focusTrap(env, held) {
156
+ var doc = env.doc
157
+ var win = env.win
158
+ return {
159
+ // Focuses the backdrop itself rather than the first control: landing on a
160
+ // button means a stray Enter fires it, and a destructive card should not
161
+ // be one keystroke from confirming.
162
+ captureFocus: function (el) {
163
+ held.returnFocusTo = doc.activeElement
164
+ held.backdrop = el
165
+ if (el && el.focus) el.focus()
166
+ },
167
+
168
+ // Puts focus back on the backdrop after the top entry changes, without
169
+ // re-capturing the return target: re-capturing would overwrite the opener
170
+ // with a node inside the dialog. Deferred a tick, because the re-mount that
171
+ // unfocuses the old node happens during Alpine's update.
172
+ refocus: function () {
173
+ var run = function () {
174
+ if (held.backdrop && held.backdrop.focus && doc.contains(held.backdrop)) held.backdrop.focus()
175
+ }
176
+ if (win.Alpine && win.Alpine.nextTick) win.Alpine.nextTick(run)
177
+ else win.setTimeout(run, 0)
178
+ },
179
+
180
+ // Restores focus only to a node still in the document: a Turbo visit can
181
+ // replace the page under an open modal, and focusing a detached node moves
182
+ // focus to the body, which is worse than leaving it alone.
183
+ releaseFocus: function () {
184
+ var target = held.returnFocusTo
185
+ held.returnFocusTo = null
186
+ held.backdrop = null
187
+ if (target && target.focus && doc.contains(target)) target.focus()
188
+ },
189
+
190
+ // Every tabbable node inside the dialog, in document order, recomputed per
191
+ // keypress: a card can add or remove controls while open.
192
+ focusables: function (el) {
193
+ if (!el) return []
194
+ var sel = 'a[href], button:not([disabled]), input:not([disabled]), ' +
195
+ 'select:not([disabled]), textarea:not([disabled]), [tabindex]'
196
+ return Array.prototype.slice.call(el.querySelectorAll(sel)).filter(function (n) {
197
+ return n.tabIndex >= 0 && n.offsetParent !== null
198
+ })
199
+ },
200
+
201
+ // Tab is intercepted on the backdrop (.prevent) and re-dispatched here.
202
+ // That holds only while focus is inside the backdrop, which is why
203
+ // refocus() exists.
204
+ cycleFocus: function (el, event) {
205
+ var items = this.focusables(el)
206
+ if (items.length === 0) { if (el && el.focus) el.focus(); return }
207
+
208
+ var idx = items.indexOf(doc.activeElement)
209
+ var next
210
+ if (event && event.shiftKey) {
211
+ next = idx <= 0 ? items[items.length - 1] : items[idx - 1]
212
+ } else {
213
+ next = (idx === -1 || idx === items.length - 1) ? items[0] : items[idx + 1]
214
+ }
215
+ next.focus()
216
+ },
217
+
218
+ // The dialog's accessible name: props.ariaLabel, else props.title, else the
219
+ // id. TRIMMED, not merely truthy: a whitespace-only title trims to empty in
220
+ // the accessible-name computation and announces a bare "dialog".
221
+ dialogLabel: function () {
222
+ var entry = this.current()
223
+ if (!entry) return 'Dialog'
224
+ var p = entry.props || {}
225
+ var named = function (v) { return (typeof v === 'string' && v.trim() !== '') ? v : null }
226
+ return named(p.ariaLabel) || named(p.title) || String(entry.id).replace(/[-_]/g, ' ')
227
+ },
228
+
229
+ // isOpen(id): on the stack in ANY lifecycle state. A card mid-close still
230
+ // counts, for the whole exit window; callers asking "is the node still
231
+ // mounted" depend on that. isLive() is the "is it still up" question.
232
+ isOpen: function (id) {
233
+ for (var i = 0; i < this.stack.length; i++) {
234
+ if (this.stack[i].id === id) return true
235
+ }
236
+ return false
237
+ },
238
+
239
+ // isLive(id) / isLive([id, ...]): on the stack and NOT on its way out.
240
+ // Tests _closing ALONE. close() sets _closing; the entry a swap() replaces
241
+ // carries _swappingOut AND _closing; advance() sets only _swappingOut, and
242
+ // that card, sliding between steps of its own flow, stays live.
243
+ isLive: function (id) {
244
+ var ids = Array.isArray(id) ? id : [id]
245
+ for (var i = 0; i < this.stack.length; i++) {
246
+ var entry = this.stack[i]
247
+ if (entry && !entry._closing && ids.indexOf(entry.id) !== -1) return true
248
+ }
249
+ return false
250
+ },
251
+
252
+ current: function () {
253
+ return this.stack.length ? this.stack[this.stack.length - 1] : null
254
+ },
255
+
256
+ // No animation: the Turbo and bfcache teardown, where the page is going
257
+ // away, so the return target is dropped rather than restored into it.
258
+ closeAll: function () {
259
+ held.returnFocusTo = null
260
+ this.stack = []
261
+ this._sync()
262
+ },
263
+
264
+ // Drops every entry that does not opt out with dismissible: false. A card
265
+ // guarding an in-flight operation (an on-chain transaction) survives a
266
+ // back/forward navigation, so the promise still resolves against it; a
267
+ // celebratory card is cleared. A surviving card is refocused (the dropped
268
+ // cards took focus with them); nothing is restored when the stack empties,
269
+ // since this is the teardown path.
270
+ closeAllDismissible: function () {
271
+ this.stack = this.stack.filter(function (entry) {
272
+ return entry.props && entry.props.dismissible === false
273
+ })
274
+ this._sync()
275
+ if (this.stack.length > 0) this.refocus()
276
+ },
277
+
278
+ // The scroll lock: body.modal-open while the stack is non-empty.
279
+ _sync: function () {
280
+ if (this.stack.length) doc.body.classList.add('modal-open')
281
+ else doc.body.classList.remove('modal-open')
282
+ }
283
+ }
284
+ }
285
+
286
+ // ---- the shared host's store, $store.modals --------------------------------
287
+ //
288
+ // env is { doc, win }: the document, and the window that carries setTimeout,
289
+ // Alpine, ModalAnimations and StudioModals.
290
+ export function createModalStore(env) {
291
+ var win = env.win
292
+ var held = { returnFocusTo: null, backdrop: null }
293
+ var store = focusTrap(env, held)
294
+ store.stack = []
295
+
296
+ // open(id, props, opts)
297
+ // opts.replace: swap the top entry. With a visible card, a directional
298
+ // slide: it exits right (modal-card-swap-out) and the new one enters
299
+ // left; opts.direction 'back' mirrors both. A plain mount when empty.
300
+ // otherwise: push. Closing the new card reveals the one beneath.
301
+ store.open = function (id, props, opts) {
302
+ props = props || {}
303
+ opts = opts || {}
304
+ var self = this
305
+ var remounted = false
306
+
307
+ if (opts.replace && this.stack.length > 0) {
308
+ var current = this.current()
309
+ if (current && !current._closing) {
310
+ // Phase 1: the leaving entry slides out for CLOSE_ANIM_MS.
311
+ var dir = (opts.direction === 'back') ? 'back' : 'forward'
312
+ current._swapDir = dir
313
+ current._swappingOut = true
314
+ current._closing = true
315
+ win.setTimeout(function () {
316
+ // Phase 2: the swap. An entry that left the stack during the slide
317
+ // (a second swap() hot-swapped it, or a close raced) drops this stale
318
+ // replacement, which would otherwise resurrect a card the user
319
+ // already navigated past.
320
+ var idx = self.stack.indexOf(current)
321
+ if (idx < 0) return
322
+ var newEntry = { id: id, props: props, _swappingIn: true, _swapDir: dir }
323
+ self.stack[idx] = newEntry
324
+ self._sync()
325
+ self.refocus()
326
+ // Phase 3: clear the slide once it lands. _settled latches so the
327
+ // mount class never snaps back on and re-fires the bounce mid-settle.
328
+ win.setTimeout(function () {
329
+ newEntry._swappingIn = false
330
+ newEntry._swapDir = null
331
+ newEntry._settled = true
332
+ }, SWAP_IN_MS)
333
+ }, CLOSE_ANIM_MS)
334
+ return
335
+ }
336
+ // Already mid-close: hot-swap, so no second timer.
337
+ this.stack[this.stack.length - 1] = { id: id, props: props }
338
+ remounted = true
339
+ } else {
340
+ // A push onto a NON-EMPTY stack re-mounts the inner template; only the
341
+ // first push mounts the outer one and captures through x-init.
342
+ remounted = this.stack.length > 0
343
+ this.stack.push({ id: id, props: props })
344
+ }
345
+ this._sync()
346
+ if (remounted) this.refocus()
347
+ }
348
+
349
+ // swap(id, props, opts) is open(id, props, { replace: true, ...opts }).
350
+ store.swap = function (id, props, opts) {
351
+ return this.open(id, props, Object.assign({ replace: true }, opts || {}))
352
+ }
353
+
354
+ // advance(propsPatch, opts): patches the current entry's props with the same
355
+ // directional slide as swap(), WITHOUT replacing the entry, so the card's
356
+ // x-data scope (form state, watchers) survives a step change inside one
357
+ // modal. A no-op with no current entry, or one closing or mid-swap, so a
358
+ // double click stacks no timers.
359
+ store.advance = function (propsPatch, opts) {
360
+ opts = opts || {}
361
+ var cur = this.current()
362
+ if (!cur || cur._closing || cur._swappingOut) return
363
+ var self = this
364
+ cur._swapDir = (opts.direction === 'back') ? 'back' : 'forward'
365
+ cur._swappingOut = true
366
+ win.setTimeout(function () {
367
+ // The entry left the stack during the slide (Escape mid-animation).
368
+ if (self.stack.indexOf(cur) < 0) return
369
+ if (propsPatch) Object.assign(cur.props, propsPatch)
370
+ cur._swappingOut = false
371
+ cur._swappingIn = true
372
+ self.refocus()
373
+ win.setTimeout(function () {
374
+ cur._swappingIn = false
375
+ cur._swapDir = null
376
+ cur._settled = true
377
+ }, SWAP_IN_MS)
378
+ }, CLOSE_ANIM_MS)
379
+ }
380
+
381
+ // Flips _closing, so the exit classes bind, and splices THIS entry (not the
382
+ // top: another open() may have run meanwhile) after its exit animation's
383
+ // registry duration. Focus returns to the opener only when the LAST card
384
+ // leaves; closing down to an underlying card refocuses it.
385
+ store.close = function () {
386
+ var self = this
387
+ var entry = this.current()
388
+ if (!entry || entry._closing) return
389
+ entry._closing = true
390
+ win.setTimeout(function () {
391
+ var idx = self.stack.indexOf(entry)
392
+ if (idx >= 0) {
393
+ self.stack.splice(idx, 1)
394
+ self._sync()
395
+ }
396
+ if (self.stack.length === 0) self.releaseFocus()
397
+ else self.refocus()
398
+ }, modalAnim(win, 'exit', entry.props && entry.props.exitAnim).ms)
399
+ }
400
+
401
+ // The visible card's classes: exactly one max-w-* (CARD_WIDTHS by id), the
402
+ // enter class on a fresh mount, the exit class while closing, and the
403
+ // directional swap-* classes during a swap or advance. The mount class is
404
+ // gated on !_settled, so a finished swap never re-fires the bounce, and on
405
+ // !_closing, so a close shows only the exit keyframe.
406
+ store.cardClasses = function () {
407
+ var c = this.current()
408
+ if (!c) return {}
409
+ var o = {}
410
+ o[modalCardWidth(win, c.id)] = true
411
+ if (!c._settled && !c._swappingIn && !c._swappingOut && !c._closing) {
412
+ o[modalAnim(win, 'enter', c.props && c.props.enterAnim).cls] = true
413
+ }
414
+ if (c._closing && !c._swappingOut) {
415
+ o[modalAnim(win, 'exit', c.props && c.props.exitAnim).cls] = true
416
+ }
417
+ // enter.slide and exit.slide resolve to these same class names, so a key
418
+ // set above is never reassigned here (an unconditional false would leave
419
+ // enterAnim/exitAnim 'slide' with no class at all).
420
+ var swapFlags = {
421
+ 'modal-card-swap-in': !!(c._swappingIn && c._swapDir !== 'back'),
422
+ 'modal-card-swap-out': !!(c._swappingOut && c._swapDir !== 'back'),
423
+ 'modal-card-swap-in-back': !!(c._swappingIn && c._swapDir === 'back'),
424
+ 'modal-card-swap-out-back': !!(c._swappingOut && c._swapDir === 'back')
425
+ }
426
+ for (var flag in swapFlags) {
427
+ if (!(flag in o)) o[flag] = swapFlags[flag]
428
+ }
429
+ return o
430
+ }
431
+
432
+ return store
433
+ }
434
+
435
+ // ---- the scoped host's store, $store.<name> --------------------------------
436
+ //
437
+ // The same focus contract, a plainer stack: a replace swaps the top entry in
438
+ // place with no slide (what submitFormWithProgress does to turn crop-photo
439
+ // into saving), there is no advance(), and the card's classes come from the
440
+ // shared motion layer, engine-motion.css.
441
+ export function createScopedModalStore(env) {
442
+ var win = env.win
443
+ var held = { returnFocusTo: null, backdrop: null }
444
+ var store = focusTrap(env, held)
445
+ store.stack = []
446
+
447
+ store.open = function (id, props, opts) {
448
+ props = props || {}
449
+ opts = opts || {}
450
+ var entry = { id: id, props: props }
451
+ var remounted = false
452
+ if (opts.replace && this.stack.length > 0) {
453
+ this.stack.splice(this.stack.length - 1, 1, entry)
454
+ remounted = true
455
+ } else {
456
+ remounted = this.stack.length > 0
457
+ this.stack.push(entry)
458
+ }
459
+ this._sync()
460
+ if (remounted) this.refocus()
461
+ }
462
+
463
+ store.swap = function (id, props, opts) {
464
+ opts = opts || {}
465
+ opts.replace = true
466
+ this.open(id, props, opts)
467
+ }
468
+
469
+ // Flips _closing so the exit animation plays, then splices after it.
470
+ store.close = function () {
471
+ var entry = this.current()
472
+ if (!entry) return
473
+ var self = this
474
+ entry._closing = true
475
+ win.setTimeout(function () {
476
+ var index = self.stack.indexOf(entry)
477
+ if (index !== -1) self.stack.splice(index, 1)
478
+ self._sync()
479
+ if (self.stack.length === 0) self.releaseFocus()
480
+ else self.refocus()
481
+ }, CLOSE_ANIM_MS)
482
+ }
483
+
484
+ // Mount on open, unmount while closing. _settled pins the entry after its
485
+ // first render.
486
+ store.cardClasses = function () {
487
+ var entry = this.current()
488
+ if (!entry) return ''
489
+ if (entry._closing) return 'modal-card-unmount'
490
+ if (!entry._settled) entry._settled = true
491
+ return 'modal-card-mount'
492
+ }
493
+
494
+ return store
495
+ }
496
+
497
+ // ---- registration -----------------------------------------------------------
498
+
499
+ export const HOST_SELECTOR = '[data-studio-controller~="modal-host"]'
500
+
501
+ // Stimulus' own reading of a Boolean value attribute.
502
+ function booleanValue(raw) {
503
+ return raw !== null && raw !== undefined && raw !== '0' && raw !== 'false'
504
+ }
505
+
506
+ // The host a host element declares: { store, scoped }.
507
+ export function hostConfig(el) {
508
+ return {
509
+ store: el.getAttribute('data-modal-host-store-value') || 'modals',
510
+ scoped: booleanValue(el.getAttribute('data-modal-host-scoped-value'))
511
+ }
512
+ }
513
+
514
+ // Every host declared in root (a document, a body, a frame or a fragment),
515
+ // root itself included.
516
+ export function hostsIn(root) {
517
+ var found = []
518
+ if (!root) return found
519
+ if (root.matches && root.matches(HOST_SELECTOR)) found.push(hostConfig(root))
520
+ if (root.querySelectorAll) {
521
+ var nodes = root.querySelectorAll(HOST_SELECTOR)
522
+ for (var i = 0; i < nodes.length; i++) found.push(hostConfig(nodes[i]))
523
+ }
524
+ return found
525
+ }
526
+
527
+ // Registers a host's store unless Alpine already has one of that name.
528
+ // Answers whether it registered.
529
+ export function registerModalStore(Alpine, host, env) {
530
+ if (!Alpine || !Alpine.store) return false
531
+ if (Alpine.store(host.store)) return false
532
+ Alpine.store(host.store, host.scoped ? createScopedModalStore(env) : createModalStore(env))
533
+ return true
534
+ }
535
+
536
+ export function registerHostsIn(root, env) {
537
+ var hosts = hostsIn(root)
538
+ for (var i = 0; i < hosts.length; i++) registerModalStore(env.win.Alpine, hosts[i], env)
539
+ }
540
+
541
+ // The bfcache and Turbo snapshot cleanup: a modal left open does not reappear
542
+ // on the next visit, but a dismissible: false card survives (see
543
+ // closeAllDismissible). Bound per host by the controller.
544
+ export function clearStaleModals(win, name) {
545
+ if (!win.Alpine || !win.Alpine.store) return
546
+ var store = win.Alpine.store(name)
547
+ if (store && typeof store.closeAllDismissible === 'function') store.closeAllDismissible()
548
+ else if (store && typeof store.closeAll === 'function') store.closeAll()
549
+ }
550
+
551
+ // Installs the globals and the three registration paths, once per document.
552
+ var installedOn = new WeakSet()
553
+
554
+ export function installModalHost(doc, win) {
555
+ if (installedOn.has(doc)) return
556
+ installedOn.add(doc)
557
+ var env = { doc: doc, win: win }
558
+
559
+ installModalGlobals(win)
560
+
561
+ doc.addEventListener('alpine:init', function () { registerHostsIn(doc, env) })
562
+ doc.addEventListener('turbo:before-render', function (event) {
563
+ if (event.detail) registerHostsIn(event.detail.newBody, env)
564
+ })
565
+ doc.addEventListener('turbo:before-frame-render', function (event) {
566
+ if (event.detail) registerHostsIn(event.detail.newFrame, env)
567
+ })
568
+ // Alpine already running (a host that loads it ahead of the module tags).
569
+ if (win.Alpine && win.Alpine.version) registerHostsIn(doc, env)
570
+ }
571
+
572
+ if (typeof document !== 'undefined' && typeof window !== 'undefined') {
573
+ installModalHost(document, window)
574
+ }
@@ -10,7 +10,8 @@
10
10
  #
11
11
  # The one way to change a persisted slug is `rename_slug!` (or `rename_slug`),
12
12
  # which updates the row and every child column that references it in one
13
- # transaction. The children are found two ways:
13
+ # transaction. Any other write that changes a persisted slug makes the record
14
+ # invalid, with the reason on `errors[:slug]`. The children are found two ways:
14
15
  #
15
16
  # * every `has_many` / `has_one` on the model with `primary_key: :slug`;
16
17
  # * every pair declared with `has_slug_children`, for a table that has no
@@ -48,6 +49,7 @@ module Sluggable
48
49
 
49
50
  before_save :set_slug, if: :sluggable_unwritten?
50
51
  after_create :sluggable_settle_derived_slug
52
+ validate :sluggable_slug_unchanged, on: :update
51
53
  end
52
54
 
53
55
  class_methods do
@@ -82,6 +84,7 @@ module Sluggable
82
84
  # held the old one, all or nothing. Returns { "table.column" => rows updated }.
83
85
  # Raises Sluggable::SlugRefused, with the reason on errors[:slug], when
84
86
  # the slug is blank, badly formed, or already taken.
87
+ # Its writes skip validations, so sluggable_slug_unchanged never sees them.
85
88
  def rename_slug!(new_slug)
86
89
  raise ActiveRecord::RecordNotSaved.new("a slug can be renamed only on a saved record", self) unless persisted?
87
90
 
@@ -129,6 +132,13 @@ module Sluggable
129
132
  new_record? || slug.blank?
130
133
  end
131
134
 
135
+ # Filling a blank slug is the first write; any other change is a rename.
136
+ def sluggable_slug_unchanged
137
+ return unless will_save_change_to_slug? && slug_in_database.present?
138
+
139
+ errors.add(:slug, :readonly, message: "changes only through rename_slug!")
140
+ end
141
+
132
142
  # A model may override this; the override owns its slug, and the post-insert
133
143
  # settle below leaves it alone.
134
144
  def set_slug