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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +131 -16
- data/README.md +193 -12
- data/app/javascript/studio/application.js +2 -0
- data/app/javascript/studio/controllers/modal_host_controller.js +40 -0
- data/app/javascript/studio/modal_host.js +574 -0
- data/app/models/concerns/sluggable.rb +11 -1
- data/app/models/studio/knowledge_doc/recording.rb +214 -0
- data/app/models/studio/knowledge_doc.rb +3 -0
- data/app/views/layouts/studio/_head.html.erb +10 -2
- data/app/views/studio/emails/index.html.erb +13 -0
- data/app/views/studio/modals/_host.html.erb +33 -597
- data/app/views/studio/modals/_load_convention.html.erb +6 -27
- data/app/views/studio/modals/_scoped_host.html.erb +14 -257
- data/app/views/studio/modals/auth/_resend_footer.html.erb +2 -2
- data/app/views/studio/modals/blocks/_processing_card.html.erb +1 -1
- data/app/views/style/_modals.html.erb +12 -15
- data/db/migrate/20261008120000_add_recording_to_studio_knowledge_docs.rb +19 -0
- data/lib/studio/component_gallery.rb +13 -3
- data/lib/studio/email_image_generator.rb +125 -0
- data/lib/studio/engine.rb +2 -5
- data/lib/studio/knowledge_recording.rb +579 -0
- data/lib/studio/knowledge_transcript.rb +185 -0
- data/lib/studio/s3/multipart.rb +193 -0
- data/lib/studio/theme_resolver.rb +11 -22
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +33 -1
- data/lib/tasks/studio_knowledge.rake +89 -0
- metadata +10 -1
|
@@ -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.
|
|
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
|