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
|
@@ -2,33 +2,41 @@
|
|
|
2
2
|
Modal host. Single shared shell for any modal that opens through
|
|
3
3
|
Alpine.store('modals'). Owns the backdrop, scroll lock (via
|
|
4
4
|
modal-open on body), escape key, click-outside, ARIA dialog role,
|
|
5
|
-
mount and unmount animations
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
and the mount and unmount animations. The behaviour behind it (the
|
|
6
|
+
stack, directional swap() and advance() slides for multi-step flows,
|
|
7
|
+
the focus trap, the per-modal animation registry
|
|
8
|
+
window.ModalAnimations, bfcache and Turbo Drive snapshot cleanup,
|
|
8
9
|
store registration, and the window.StudioModals.holdAtLeast(ms)
|
|
9
|
-
helper
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
helper) is studio/modal_host, bound to this host by the modal-host
|
|
11
|
+
Stimulus controller on its template. This partial renders no script.
|
|
12
|
+
The animation keyframes ship inline in the style block below, so
|
|
13
|
+
consumers need no extra CSS to get the full behavior. A canonical
|
|
14
|
+
copy of the SAME keyframes also lives in the shared motion layer
|
|
15
|
+
(app/assets/tailwind/studio_engine/engine-motion.css) for pages that
|
|
16
|
+
drive a modal host WITHOUT rendering this partial, e.g. the living
|
|
17
|
+
style guide's page-scoped dsModals host. The two are byte-identical;
|
|
18
|
+
keep them in sync if you retune a curve or duration.
|
|
16
19
|
|
|
17
20
|
THREE CONSUMER EXTENSION POINTS, so an app never has to fork this file
|
|
18
21
|
to change an animation, a width, or register an app-wide modal:
|
|
19
22
|
|
|
20
23
|
window.ModalAnimations per-modal enter/exit animation, merged
|
|
21
|
-
over the engine defaults
|
|
24
|
+
over the engine defaults
|
|
25
|
+
(studio/modal_host ANIMATION_DEFAULTS)
|
|
22
26
|
window.StudioModals.CARD_WIDTHS per-modal card width by modal id,
|
|
23
27
|
merged the same way; DEFAULT_CARD_WIDTH
|
|
24
28
|
covers every id it does not name
|
|
25
29
|
app/views/modals/_host_extras OPTIONAL app partial, rendered inside
|
|
26
30
|
the card on every render path through
|
|
27
|
-
this host
|
|
31
|
+
this host, for a modal that belongs to
|
|
28
32
|
the app rather than to one call site
|
|
29
33
|
|
|
34
|
+
An app defines either registry in a script anywhere in the page (the
|
|
35
|
+
module merges it once the document has parsed) and may change it later:
|
|
36
|
+
the store reads both at call time.
|
|
37
|
+
|
|
30
38
|
Consumer integration example and API reference: the "Modal host"
|
|
31
|
-
section of the gem README
|
|
39
|
+
section of the gem README, which also carries the two rules this host
|
|
32
40
|
IMPOSES on the content partials registered in its block: a SINGLE ROOT
|
|
33
41
|
element (a template x-if clones only the first one; later siblings are
|
|
34
42
|
dropped) and NO double quote inside x-data (it closes the attribute and
|
|
@@ -39,7 +47,7 @@
|
|
|
39
47
|
you re-read the markup.
|
|
40
48
|
%>
|
|
41
49
|
<style>
|
|
42
|
-
/* Scroll lock applied by $store.modals._sync() when the stack is non-empty.
|
|
50
|
+
/* Scroll lock applied by $store.modals._sync() (studio/modal_host) when the stack is non-empty.
|
|
43
51
|
Combined with the fixed backdrop on the host below, this keeps the page
|
|
44
52
|
beneath any modal from scrolling on wheel / touch / spacebar — a claim that
|
|
45
53
|
was FALSE wherever this gem's link sidebar shipped, until the rule below
|
|
@@ -86,7 +94,7 @@
|
|
|
86
94
|
clean fade (a bouncing backdrop reads as a bug, not polish).
|
|
87
95
|
|
|
88
96
|
Durations: the modal-card-out keyframe stays at 220ms to match
|
|
89
|
-
CLOSE_ANIM_MS in
|
|
97
|
+
CLOSE_ANIM_MS in studio/modal_host — that's the timeout before the
|
|
90
98
|
entry is spliced off the stack. Bump CLOSE_ANIM_MS if you lengthen
|
|
91
99
|
this.
|
|
92
100
|
|
|
@@ -125,7 +133,7 @@
|
|
|
125
133
|
/* Named entrance animation: "shake" — a "not quite yet" nope. The card
|
|
126
134
|
scales/fades in fast (first ~22%) then does a damped horizontal
|
|
127
135
|
wobble that settles to center, reading as "you can't do that just
|
|
128
|
-
yet." Wired via the ModalAnimations registry
|
|
136
|
+
yet." Wired via the ModalAnimations registry (studio/modal_host): open a modal
|
|
129
137
|
with { enterAnim: 'shake' } to use it. Duration here MUST match
|
|
130
138
|
ModalAnimations.enter.shake.ms (600). */
|
|
131
139
|
@keyframes modal-card-shake-in {
|
|
@@ -198,587 +206,15 @@
|
|
|
198
206
|
}
|
|
199
207
|
</style>
|
|
200
208
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
// for the modal card. Each key maps to a CSS class (the defaults ship
|
|
207
|
-
// in the inline style block above) plus its duration in ms (the store
|
|
208
|
-
// waits this long before splicing a closing entry off the stack, so
|
|
209
|
-
// the exit keyframe plays to completion before the DOM unmounts).
|
|
210
|
-
//
|
|
211
|
-
// Pass a key into any open() call via props.enterAnim / props.exitAnim:
|
|
212
|
-
//
|
|
213
|
-
// $store.modals.open('foo', { enterAnim: 'shake' }) // shake in, default pop out
|
|
214
|
-
// $store.modals.open('bar', { exitAnim: 'slide' }) // default pop in, slide out
|
|
215
|
-
//
|
|
216
|
-
// Omitted → the modal uses 'pop' (spring bounce in / fall out).
|
|
217
|
-
//
|
|
218
|
-
// Consumer extension point: define window.ModalAnimations BEFORE this
|
|
219
|
-
// script runs (e.g. in a <script> above the host render) with extra
|
|
220
|
-
// keys per channel — app entries are merged OVER the engine defaults,
|
|
221
|
-
// so adding a custom animation never requires redefining pop/shake/
|
|
222
|
-
// slide. Each custom key needs one registry entry here + one CSS
|
|
223
|
-
// class in the app's stylesheet. To re-skin a modal's entrance, just
|
|
224
|
-
// change the enterAnim it's opened with — no host edits needed.
|
|
225
|
-
//
|
|
226
|
-
// NB: this is independent of the directional swap()/advance() slide,
|
|
227
|
-
// which is a separate in-flow transition mechanism (see below) and
|
|
228
|
-
// always uses the modal-card-swap-* classes regardless of these keys.
|
|
229
|
-
var animDefaults = {
|
|
230
|
-
enter: {
|
|
231
|
-
pop: { cls: 'modal-card-mount', ms: 320 }, // default — spring bounce in
|
|
232
|
-
shake: { cls: 'modal-card-shake-in', ms: 600 }, // "not quite yet" nope
|
|
233
|
-
slide: { cls: 'modal-card-swap-in', ms: 220 } // slide in from the left
|
|
234
|
-
},
|
|
235
|
-
exit: {
|
|
236
|
-
pop: { cls: 'modal-card-unmount', ms: 220 }, // default — anticipation + fall
|
|
237
|
-
slide: { cls: 'modal-card-swap-out', ms: 220 } // slide out to the right
|
|
238
|
-
}
|
|
239
|
-
};
|
|
240
|
-
var animOverrides = window.ModalAnimations || {};
|
|
241
|
-
window.ModalAnimations = {
|
|
242
|
-
enter: Object.assign({}, animDefaults.enter, animOverrides.enter || {}),
|
|
243
|
-
exit: Object.assign({}, animDefaults.exit, animOverrides.exit || {})
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
// Per-modal CARD WIDTH — the second consumer extension point, deliberately
|
|
247
|
-
// shaped like the animation one above so a consumer learns the pattern once.
|
|
248
|
-
// Define window.StudioModals.CARD_WIDTHS BEFORE this script runs, keyed by
|
|
249
|
-
// modal id, and app entries merge OVER the engine defaults (empty: the
|
|
250
|
-
// engine ships no per-id width, only the default below).
|
|
251
|
-
//
|
|
252
|
-
// window.StudioModals = window.StudioModals || {};
|
|
253
|
-
// window.StudioModals.CARD_WIDTHS = { 'wallet-setup': 'max-w-md' };
|
|
254
|
-
//
|
|
255
|
-
// BY MODAL ID, NOT BY PROP, and that is the whole reason this is a registry.
|
|
256
|
-
// A card opened from several places would have to carry the prop at every
|
|
257
|
-
// opener, and ONE miss renders the same card at two widths depending on how
|
|
258
|
-
// the user got there. The originating case is turf-monster's wallet-setup,
|
|
259
|
-
// opened from three (the board's entry gate, the onboarding chain driver,
|
|
260
|
-
// and the post-Connect reopen).
|
|
261
|
-
//
|
|
262
|
-
// Resolved in cardClasses(), NEVER written as a static class on the card
|
|
263
|
-
// element: exactly one max-w-* may land on that div, and a static class
|
|
264
|
-
// alongside a bound one leaves the winner to stylesheet source order, which
|
|
265
|
-
// is not something this file gets to decide.
|
|
266
|
-
window.StudioModals = window.StudioModals || {};
|
|
267
|
-
var widthOverrides = window.StudioModals.CARD_WIDTHS || {};
|
|
268
|
-
window.StudioModals.CARD_WIDTHS = Object.assign({}, widthOverrides);
|
|
269
|
-
window.StudioModals.DEFAULT_CARD_WIDTH =
|
|
270
|
-
window.StudioModals.DEFAULT_CARD_WIDTH || 'max-w-sm';
|
|
271
|
-
// Late-binding guard: read window.ModalAnimations at CALL time, and
|
|
272
|
-
// never return undefined. A consumer script that loads after this one
|
|
273
|
-
// (e.g. an importmap module) may REPLACE the merged registry object
|
|
274
|
-
// wholesale; without the animDefaults fallback a lookup miss would
|
|
275
|
-
// make close() throw on .ms and leave the modal stuck open. Unknown
|
|
276
|
-
// keys and gutted registries fall back to the built-in 'pop'.
|
|
277
|
-
function modalAnim(channel, key) {
|
|
278
|
-
var table = (window.ModalAnimations && window.ModalAnimations[channel]) || {};
|
|
279
|
-
return table[key] || table.pop || animDefaults[channel].pop;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// Same late-binding guard as modalAnim, for the same reason and with one
|
|
283
|
-
// extra: a width that fails to resolve does not throw, it renders a
|
|
284
|
-
// FULL-BLEED card. A silent failure gets a literal floor, not a chance.
|
|
285
|
-
var DEFAULT_CARD_WIDTH = 'max-w-sm';
|
|
286
|
-
function modalCardWidth(id) {
|
|
287
|
-
var sm = window.StudioModals || {};
|
|
288
|
-
return (sm.CARD_WIDTHS && sm.CARD_WIDTHS[id]) ||
|
|
289
|
-
sm.DEFAULT_CARD_WIDTH ||
|
|
290
|
-
DEFAULT_CARD_WIDTH;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Where focus returns when the last modal closes. A module-level closure, NOT a
|
|
294
|
-
// store property — see captureFocus below for why a DOM node must never live on
|
|
295
|
-
// a reactive store.
|
|
296
|
-
var _returnFocusTo = null;
|
|
297
|
-
// The backdrop node, kept in the SAME closure and for the same reason: a DOM
|
|
298
|
-
// node on a reactive store reads back as a proxy and every identity check
|
|
299
|
-
// against it is false forever. refocus() needs this node after a swap.
|
|
300
|
-
var _backdropEl = null;
|
|
301
|
-
|
|
302
|
-
// === Alpine.store('modals') — the stack ============================
|
|
303
|
-
//
|
|
304
|
-
// Idempotent registration (return early if already registered) so
|
|
305
|
-
// consumer apps can include the host more than once without
|
|
306
|
-
// double-init.
|
|
307
|
-
//
|
|
308
|
-
// CLOSE_ANIM_MS: keep in sync with the modal-card-unmount + modal-
|
|
309
|
-
// backdrop-unmount keyframes in the inline style block above. When
|
|
310
|
-
// close() is called, we set _closing on the entry so the host can
|
|
311
|
-
// swap in the unmount classes, then actually splice the stack after
|
|
312
|
-
// this many ms so the animation plays before the DOM unmounts. If
|
|
313
|
-
// you change the keyframe durations there, bump this to match.
|
|
314
|
-
var CLOSE_ANIM_MS = 220;
|
|
315
|
-
var SWAP_IN_MS = 220;
|
|
316
|
-
|
|
317
|
-
document.addEventListener('alpine:init', function() {
|
|
318
|
-
if (Alpine.store('modals')) return;
|
|
319
|
-
Alpine.store('modals', {
|
|
320
|
-
stack: [],
|
|
321
|
-
// open(id, props, opts)
|
|
322
|
-
// opts.replace: true → swap the top entry with this one. When a
|
|
323
|
-
// non-closing modal is currently visible, the swap is a directional
|
|
324
|
-
// slide — current modal exits stage-right (modal-card-swap-out)
|
|
325
|
-
// and the new one enters stage-left (modal-card-swap-in). Gives
|
|
326
|
-
// a "forward progress" feel for wizards / multi-step flows.
|
|
327
|
-
// Defaults to a plain mount animation when the stack is empty.
|
|
328
|
-
// opts.replace: false (default) → push on top of the stack. Stacking
|
|
329
|
-
// semantics — when the new one closes, the previous is revealed
|
|
330
|
-
// underneath (e.g. crop-photo on top of profile).
|
|
331
|
-
// SWAP_IN_MS must match the modal-card-swap-in animation duration in
|
|
332
|
-
// the inline style block above; we clear the flag after that so a
|
|
333
|
-
// fresh open() doesn't re-trigger the slide.
|
|
334
|
-
// === focus management ==============================================
|
|
335
|
-
//
|
|
336
|
-
// THE RETURN TARGET LIVES IN A CLOSURE, NOT ON THE STORE. Alpine.store()
|
|
337
|
-
// wraps its object in a reactive Proxy, so a DOM node assigned to a store
|
|
338
|
-
// property reads back as a PROXY of that node. Calling .focus() through the
|
|
339
|
-
// proxy happens to work, but any identity check against it (=== , contains(),
|
|
340
|
-
// Set membership) is false forever — the exact defect that made the wallet
|
|
341
|
-
// watcher discard every account-change event it ever received. Focus code is
|
|
342
|
-
// full of identity checks, so the node is kept out of the reactive object
|
|
343
|
-
// entirely.
|
|
344
|
-
captureFocus: function(el) {
|
|
345
|
-
_returnFocusTo = document.activeElement;
|
|
346
|
-
_backdropEl = el;
|
|
347
|
-
// Focus the backdrop itself rather than the first control: landing on a
|
|
348
|
-
// button means a stray Enter fires it, and a destructive card should not
|
|
349
|
-
// be one keystroke from confirming.
|
|
350
|
-
if (el && el.focus) el.focus();
|
|
351
|
-
},
|
|
352
|
-
|
|
353
|
-
// RE-FOCUS THE BACKDROP AFTER THE TOP ENTRY CHANGES, without re-capturing
|
|
354
|
-
// the return target.
|
|
355
|
-
//
|
|
356
|
-
// THE DEFECT THIS CLOSES. captureFocus runs from x-init on the backdrop,
|
|
357
|
-
// and that backdrop lives inside <template x-if="current()">. swap() is
|
|
358
|
-
// open(id, props, { replace: true }) and advance() only patches props, so
|
|
359
|
-
// current() stays truthy and the OUTER template never re-mounts —
|
|
360
|
-
// captureFocus never runs again. The INNER content template DOES re-mount
|
|
361
|
-
// and unmounts whatever was focused; document.activeElement falls back to
|
|
362
|
-
// the document body, which is not a descendant of the backdrop, so the handler
|
|
363
|
-
// bound there stops seeing the key and native tabbing resumes. Measured
|
|
364
|
-
// in a browser: open -> swap -> Shift+Tab x3 walked out to the page behind.
|
|
365
|
-
//
|
|
366
|
-
// _returnFocusTo IS DELIBERATELY NOT TOUCHED. Re-capturing here would
|
|
367
|
-
// overwrite the opener with a node inside the dialog, so closing would
|
|
368
|
-
// return focus into a card that no longer exists.
|
|
369
|
-
//
|
|
370
|
-
// Deferred a tick: the re-mount that unfocuses the old node happens during
|
|
371
|
-
// Alpine's update, so focusing before it lands would be undone immediately.
|
|
372
|
-
refocus: function() {
|
|
373
|
-
var run = function() {
|
|
374
|
-
if (_backdropEl && _backdropEl.focus && document.contains(_backdropEl)) {
|
|
375
|
-
_backdropEl.focus();
|
|
376
|
-
}
|
|
377
|
-
};
|
|
378
|
-
if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(run);
|
|
379
|
-
else setTimeout(run, 0);
|
|
380
|
-
},
|
|
381
|
-
|
|
382
|
-
releaseFocus: function() {
|
|
383
|
-
var target = _returnFocusTo;
|
|
384
|
-
_returnFocusTo = null;
|
|
385
|
-
_backdropEl = null;
|
|
386
|
-
// Only restore if the element is still in the document — a Turbo visit can
|
|
387
|
-
// replace the page under an open modal, and focusing a detached node
|
|
388
|
-
// silently moves focus to the document body, which is worse than leaving it alone.
|
|
389
|
-
if (target && target.focus && document.contains(target)) target.focus();
|
|
390
|
-
},
|
|
391
|
-
|
|
392
|
-
// Every tabbable node inside the dialog, in document order. Deliberately
|
|
393
|
-
// recomputed per keypress: modal content is Alpine-rendered and a card can
|
|
394
|
-
// add or remove controls (a spinner replacing a button) while open, so a
|
|
395
|
-
// list captured at mount goes stale.
|
|
396
|
-
focusables: function(el) {
|
|
397
|
-
if (!el) return [];
|
|
398
|
-
var sel = 'a[href], button:not([disabled]), input:not([disabled]), ' +
|
|
399
|
-
'select:not([disabled]), textarea:not([disabled]), [tabindex]';
|
|
400
|
-
return Array.prototype.slice.call(el.querySelectorAll(sel)).filter(function(n) {
|
|
401
|
-
return n.tabIndex >= 0 && n.offsetParent !== null;
|
|
402
|
-
});
|
|
403
|
-
},
|
|
404
|
-
|
|
405
|
-
// Tab is intercepted (.prevent on the binding) and re-dispatched here.
|
|
406
|
-
//
|
|
407
|
-
// THAT ONLY HOLDS WHILE FOCUS IS INSIDE THE BACKDROP, which is a real
|
|
408
|
-
// condition and not a formality. The binding lives ON the backdrop, so it
|
|
409
|
-
// sees the key only when activeElement is a descendant. If a re-mount
|
|
410
|
-
// drops focus to the document body — which is what a swap() or advance()
|
|
411
|
-
// does to the inner content template — this handler never fires, .prevent
|
|
412
|
-
// never runs, and native tabbing resumes straight out of the dialog. That
|
|
413
|
-
// is why refocus() exists and why both phase-2 seams call it. An earlier
|
|
414
|
-
// version of this comment claimed focus could never leave while open; it
|
|
415
|
-
// could, and it did.
|
|
416
|
-
cycleFocus: function(el, event) {
|
|
417
|
-
var items = this.focusables(el);
|
|
418
|
-
if (items.length === 0) { if (el && el.focus) el.focus(); return; }
|
|
419
|
-
|
|
420
|
-
var idx = items.indexOf(document.activeElement);
|
|
421
|
-
var next;
|
|
422
|
-
if (event && event.shiftKey) {
|
|
423
|
-
next = idx <= 0 ? items[items.length - 1] : items[idx - 1];
|
|
424
|
-
} else {
|
|
425
|
-
next = (idx === -1 || idx === items.length - 1) ? items[0] : items[idx + 1];
|
|
426
|
-
}
|
|
427
|
-
next.focus();
|
|
428
|
-
},
|
|
429
|
-
|
|
430
|
-
// A dialog with no accessible name announces as just "dialog". Props first
|
|
431
|
-
// so a card can name itself; the id is the honest fallback.
|
|
432
|
-
dialogLabel: function() {
|
|
433
|
-
var entry = this.current();
|
|
434
|
-
if (!entry) return 'Dialog';
|
|
435
|
-
var p = entry.props || {};
|
|
436
|
-
// TRIMMED, not merely truthy. `title: ' '` is a truthy string, so it used to
|
|
437
|
-
// pass straight through — and the accessible-name computation then trims it to
|
|
438
|
-
// empty and announces a bare "dialog", the exact outcome this fallback chain
|
|
439
|
-
// exists to prevent. An empty string already fell through (it is falsy); a
|
|
440
|
-
// whitespace-only one did not, which is the more likely typo of the two.
|
|
441
|
-
var named = function(v) {
|
|
442
|
-
return (typeof v === 'string' && v.trim() !== '') ? v : null;
|
|
443
|
-
};
|
|
444
|
-
return named(p.ariaLabel) || named(p.title) || String(entry.id).replace(/[-_]/g, ' ');
|
|
445
|
-
},
|
|
446
|
-
|
|
447
|
-
open: function(id, props, opts) {
|
|
448
|
-
props = props || {};
|
|
449
|
-
opts = opts || {};
|
|
450
|
-
var self = this;
|
|
451
|
-
// Set by any branch that re-mounts the INNER content template without
|
|
452
|
-
// re-mounting the OUTER one — see the push branch below. The replace
|
|
453
|
-
// branch returns early and calls refocus() from its own phase 2.
|
|
454
|
-
var remounted = false;
|
|
455
|
-
|
|
456
|
-
if (opts.replace && this.stack.length > 0) {
|
|
457
|
-
var current = this.current();
|
|
458
|
-
if (current && !current._closing) {
|
|
459
|
-
// Phase 1: mark leaving entry with _swappingOut + _closing so
|
|
460
|
-
// the :class binding swaps in the slide-out keyframe (instead
|
|
461
|
-
// of the default scale-pop modal-card-unmount) for CLOSE_ANIM_MS.
|
|
462
|
-
// _swapDir picks which slide direction (forward = exit right,
|
|
463
|
-
// back = exit left); the same flag carries onto the new entry
|
|
464
|
-
// below so slide-in mirrors slide-out.
|
|
465
|
-
var dir = (opts.direction === 'back') ? 'back' : 'forward';
|
|
466
|
-
current._swapDir = dir;
|
|
467
|
-
current._swappingOut = true;
|
|
468
|
-
current._closing = true;
|
|
469
|
-
setTimeout(function() {
|
|
470
|
-
// Phase 2: stack swap. New entry carries _swappingIn + the
|
|
471
|
-
// same _swapDir so the host's :class binding picks the
|
|
472
|
-
// matching slide-in keyframe. If the marked entry vanished
|
|
473
|
-
// during the slide-out window (another swap() hot-swapped
|
|
474
|
-
// it, or a close raced us), DROP this stale replacement —
|
|
475
|
-
// pushing it would resurrect a modal the user already
|
|
476
|
-
// navigated past (two swap() calls within CLOSE_ANIM_MS
|
|
477
|
-
// used to leave both on the stack). Mirrors close()'s
|
|
478
|
-
// stale-entry guard.
|
|
479
|
-
var idx = self.stack.indexOf(current);
|
|
480
|
-
if (idx < 0) return;
|
|
481
|
-
var newEntry = { id: id, props: props, _swappingIn: true, _swapDir: dir };
|
|
482
|
-
self.stack[idx] = newEntry;
|
|
483
|
-
self._sync();
|
|
484
|
-
// The content template just re-mounted; put focus back on the
|
|
485
|
-
// backdrop or the trap releases. See refocus().
|
|
486
|
-
self.refocus();
|
|
487
|
-
// Phase 3: clear the swap-in flag + direction after the
|
|
488
|
-
// slide finishes so a future close() runs the unmount
|
|
489
|
-
// keyframe cleanly. _settled latches so the host's
|
|
490
|
-
// :class binding doesn't snap modal-card-mount back on
|
|
491
|
-
// (which would re-fire the bounce-in keyframe from
|
|
492
|
-
// opacity:0 and look like a flash mid-settle).
|
|
493
|
-
setTimeout(function() {
|
|
494
|
-
newEntry._swappingIn = false;
|
|
495
|
-
newEntry._swapDir = null;
|
|
496
|
-
newEntry._settled = true;
|
|
497
|
-
}, SWAP_IN_MS);
|
|
498
|
-
}, CLOSE_ANIM_MS);
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
501
|
-
// Already mid-close — just hot-swap so we don't double up timers.
|
|
502
|
-
this.stack[this.stack.length - 1] = { id: id, props: props };
|
|
503
|
-
remounted = true;
|
|
504
|
-
} else {
|
|
505
|
-
// A push onto a NON-EMPTY stack is the third re-mount seam, and the one
|
|
506
|
-
// the trap missed longest. current() stays truthy, so the OUTER template
|
|
507
|
-
// never re-mounts and x-init never re-runs captureFocus — while the INNER
|
|
508
|
-
// content template DOES re-mount and unmounts whatever was focused.
|
|
509
|
-
// Focus lands on the document body, which is not a descendant of the backdrop, so the
|
|
510
|
-
// @keydown.tab handler bound there stops firing and native tabbing resumes.
|
|
511
|
-
// MEASURED on /lab/birthday_gate: open a card, Tab once, push a second
|
|
512
|
-
// card, then Shift+Tab three times and you walk onto three BACKGROUND-PAGE
|
|
513
|
-
// buttons behind a still-open dialog.
|
|
514
|
-
//
|
|
515
|
-
// Only the FIRST push is safe: current() goes falsy -> truthy, the outer
|
|
516
|
-
// template mounts, and x-init captures for us.
|
|
517
|
-
remounted = this.stack.length > 0;
|
|
518
|
-
this.stack.push({ id: id, props: props });
|
|
519
|
-
}
|
|
520
|
-
this._sync();
|
|
521
|
-
if (remounted) this.refocus();
|
|
522
|
-
},
|
|
523
|
-
// Sugar — `swap('foo', props)` ≡ `open('foo', props, { replace: true })`.
|
|
524
|
-
// Pass opts through so callers can request a back-direction slide
|
|
525
|
-
// on a cross-modal handoff if the new modal is semantically
|
|
526
|
-
// upstream of the current one (e.g. "return to home modal").
|
|
527
|
-
swap: function(id, props, opts) {
|
|
528
|
-
return this.open(id, props, Object.assign({ replace: true }, opts || {}));
|
|
529
|
-
},
|
|
530
|
-
// advance(propsPatch, opts) — patch the current entry's props
|
|
531
|
-
// WITH the same directional slide as swap(), but WITHOUT
|
|
532
|
-
// replacing the stack entry. Use this for in-flow step
|
|
533
|
-
// transitions inside one modal (e.g. picker → waiting →
|
|
534
|
-
// confirming) where the modal's outer x-data scope (form
|
|
535
|
-
// state, watchers, factories) needs to survive the transition.
|
|
536
|
-
// swap() drops a fresh entry into the stack which re-evaluates
|
|
537
|
-
// the registered <template x-if="id===..."> — fine for
|
|
538
|
-
// cross-modal handoffs, but heavy for "same modal, next step."
|
|
539
|
-
// advance() is the lightweight cousin.
|
|
540
|
-
//
|
|
541
|
-
// opts.direction: 'forward' (default — current exits right, next
|
|
542
|
-
// enters from left; reads as "moving ahead in the flow")
|
|
543
|
-
// or 'back' (mirrored — current exits left, next enters from
|
|
544
|
-
// right; reads as "returning to the previous step"). Pair
|
|
545
|
-
// forward calls with back calls on their inverse buttons so
|
|
546
|
-
// the user's spatial model of the wizard stays consistent.
|
|
547
|
-
//
|
|
548
|
-
// Timing mirrors swap():
|
|
549
|
-
// t=0 → flip _swappingOut, outer card slides off (CLOSE_ANIM_MS)
|
|
550
|
-
// t=220ms → patch props, flip _swappingIn, card slides in
|
|
551
|
-
// t=440ms → clear _swappingIn + _swapDir so the next open/
|
|
552
|
-
// close uses default mount/unmount keyframes
|
|
553
|
-
//
|
|
554
|
-
// Guards: no-op if no current entry, or if the entry is already
|
|
555
|
-
// closing or mid-swap (prevents double-clicks from stacking timers).
|
|
556
|
-
advance: function(propsPatch, opts) {
|
|
557
|
-
opts = opts || {};
|
|
558
|
-
var cur = this.current();
|
|
559
|
-
if (!cur || cur._closing || cur._swappingOut) return;
|
|
560
|
-
var self = this;
|
|
561
|
-
cur._swapDir = (opts.direction === 'back') ? 'back' : 'forward';
|
|
562
|
-
cur._swappingOut = true;
|
|
563
|
-
setTimeout(function() {
|
|
564
|
-
// Re-resolve in case the stack mutated during the slide-out
|
|
565
|
-
// window. If our entry got popped (e.g. user hit Escape mid-
|
|
566
|
-
// animation), bail rather than splat props onto a stale ref.
|
|
567
|
-
if (self.stack.indexOf(cur) < 0) return;
|
|
568
|
-
if (propsPatch) Object.assign(cur.props, propsPatch);
|
|
569
|
-
cur._swappingOut = false;
|
|
570
|
-
cur._swappingIn = true;
|
|
571
|
-
// Same re-mount, same release. advance() patches props in place, so
|
|
572
|
-
// the outer template never re-runs x-init either.
|
|
573
|
-
self.refocus();
|
|
574
|
-
setTimeout(function() {
|
|
575
|
-
cur._swappingIn = false;
|
|
576
|
-
cur._swapDir = null;
|
|
577
|
-
cur._settled = true;
|
|
578
|
-
}, SWAP_IN_MS);
|
|
579
|
-
}, CLOSE_ANIM_MS);
|
|
580
|
-
},
|
|
581
|
-
close: function() {
|
|
582
|
-
var self = this;
|
|
583
|
-
var entry = this.current();
|
|
584
|
-
if (!entry || entry._closing) return; // empty stack or already closing
|
|
585
|
-
// Flip _closing → Alpine reactivity adds the modal-card-unmount
|
|
586
|
-
// and modal-backdrop-unmount classes via the :class bindings on
|
|
587
|
-
// the host. Wait for the animation to play, then splice THIS
|
|
588
|
-
// specific entry from the stack (not pop() — the top might have
|
|
589
|
-
// changed if another open() ran during the close window). The
|
|
590
|
-
// splice delay tracks the entry's chosen exit animation
|
|
591
|
-
// (props.exitAnim → registry ms) so a longer custom exit
|
|
592
|
-
// keyframe plays fully before the DOM unmounts; defaults to
|
|
593
|
-
// the 'pop' fall (220ms).
|
|
594
|
-
entry._closing = true;
|
|
595
|
-
setTimeout(function() {
|
|
596
|
-
var idx = self.stack.indexOf(entry);
|
|
597
|
-
if (idx >= 0) {
|
|
598
|
-
self.stack.splice(idx, 1);
|
|
599
|
-
self._sync();
|
|
600
|
-
}
|
|
601
|
-
// Hand focus back only when the LAST modal leaves. A stacked flow
|
|
602
|
-
// (open → swap → close) unmounts one dialog and mounts the next, and
|
|
603
|
-
// restoring to the background page in between would yank focus out of
|
|
604
|
-
// a flow the user is still inside.
|
|
605
|
-
//
|
|
606
|
-
// BUT NOT RESTORING IS NOT THE SAME AS DOING NOTHING. Closing down to an
|
|
607
|
-
// underlying modal re-mounts the inner template, so focus falls to the document body
|
|
608
|
-
// with the dialog still open — the trap is off while the user is still
|
|
609
|
-
// inside it. releaseFocus() was correctly gated on an empty stack; the
|
|
610
|
-
// missing half was the else. MEASURED on both hosts.
|
|
611
|
-
if (self.stack.length === 0) self.releaseFocus();
|
|
612
|
-
else self.refocus();
|
|
613
|
-
}, modalAnim('exit', entry.props && entry.props.exitAnim).ms);
|
|
614
|
-
},
|
|
615
|
-
closeAll: function() {
|
|
616
|
-
// Turbo/bfcache teardown: the page is going away, so drop the return
|
|
617
|
-
// target rather than restoring into a document about to be replaced.
|
|
618
|
-
_returnFocusTo = null;
|
|
619
|
-
// No animation — used by Turbo before-cache + bfcache cleanup
|
|
620
|
-
// where the user is navigating away and we just need the DOM
|
|
621
|
-
// clean immediately.
|
|
622
|
-
this.stack = [];
|
|
623
|
-
this._sync();
|
|
624
|
-
},
|
|
625
|
-
// Drops every modal whose props don't explicitly opt out of dismissal
|
|
626
|
-
// (i.e. dismissible: false). Used by the bfcache + Turbo snapshot
|
|
627
|
-
// cleanup below so a modal guarding an in-flight operation (e.g. an
|
|
628
|
-
// on-chain TX) — which sets dismissible: false precisely to forbid
|
|
629
|
-
// losing its UI mid-flight — survives a back/forward navigation;
|
|
630
|
-
// the still-in-flight JS promise resolves against a non-empty
|
|
631
|
-
// stack. Celebratory modals (default dismissible: true) are still
|
|
632
|
-
// cleared so users don't return to a stale "Success!" card.
|
|
633
|
-
closeAllDismissible: function() {
|
|
634
|
-
this.stack = this.stack.filter(function(modal) {
|
|
635
|
-
return modal.props && modal.props.dismissible === false;
|
|
636
|
-
});
|
|
637
|
-
this._sync();
|
|
638
|
-
// Same re-mount, same release: a surviving non-dismissible card is still on
|
|
639
|
-
// screen, and the cards dropped from under it took focus with them. MEASURED:
|
|
640
|
-
// activeElement the document body behind a still-open non-dismissible dialog.
|
|
641
|
-
//
|
|
642
|
-
// Nothing is done when the stack empties. That is deliberate and different
|
|
643
|
-
// from close(): this runs on the Turbo/bfcache teardown path, where the page
|
|
644
|
-
// is going away and closeAll() already declines to restore into a document
|
|
645
|
-
// about to be replaced.
|
|
646
|
-
if (this.stack.length > 0) this.refocus();
|
|
647
|
-
},
|
|
648
|
-
// isOpen(id) — is a card with this id ON THE STACK, in any
|
|
649
|
-
// lifecycle state? A card mid-close still counts: close() flips
|
|
650
|
-
// _closing at once and splices only after the exit animation, so
|
|
651
|
-
// this answers TRUE for that whole exit window. Unchanged — callers
|
|
652
|
-
// asking "is the DOM node still mounted" depend on exactly this.
|
|
653
|
-
// If you mean "is it still up", you want isLive() below.
|
|
654
|
-
isOpen: function(id) {
|
|
655
|
-
for (var i = 0; i < this.stack.length; i++) {
|
|
656
|
-
if (this.stack[i].id === id) return true;
|
|
657
|
-
}
|
|
658
|
-
return false;
|
|
659
|
-
},
|
|
660
|
-
// isLive(id) / isLive([id, ...]) — is a card with this id on the
|
|
661
|
-
// stack and NOT on its way out? This is what a "don't open it
|
|
662
|
-
// twice" guard actually means, and it is the question isOpen
|
|
663
|
-
// cannot answer.
|
|
664
|
-
//
|
|
665
|
-
// The difference is a TIMING one, and the asymmetry is load-bearing:
|
|
666
|
-
//
|
|
667
|
-
// close() entry gets _closing, and is spliced
|
|
668
|
-
// CLOSE_ANIM_MS later. isOpen stays TRUE for
|
|
669
|
-
// that window; isLive is FALSE immediately.
|
|
670
|
-
// swap() / open(..., the LEAVING entry gets _swappingOut AND
|
|
671
|
-
// { replace: true }) _closing — a different card is taking the
|
|
672
|
-
// screen, so it is not live either.
|
|
673
|
-
// advance() the entry gets _swappingOut and NOTHING
|
|
674
|
-
// else. Same card, same id, sliding between
|
|
675
|
-
// steps of its own flow. It stays LIVE.
|
|
676
|
-
//
|
|
677
|
-
// So this tests _closing ALONE and deliberately never consults
|
|
678
|
-
// _swappingOut. Conflating the two reports a card as gone in the
|
|
679
|
-
// middle of a step transition, which is how an in-flow hand-off
|
|
680
|
-
// starts looking to a caller like a dismissal.
|
|
681
|
-
//
|
|
682
|
-
// Accepts an array so a caller can ask about a SET of ids ("is any
|
|
683
|
-
// card belonging to this flow still up?") without reaching into
|
|
684
|
-
// .stack and re-deriving the lifecycle rule for itself.
|
|
685
|
-
isLive: function(id) {
|
|
686
|
-
var ids = Array.isArray(id) ? id : [id];
|
|
687
|
-
for (var i = 0; i < this.stack.length; i++) {
|
|
688
|
-
var entry = this.stack[i];
|
|
689
|
-
if (entry && !entry._closing && ids.indexOf(entry.id) !== -1) return true;
|
|
690
|
-
}
|
|
691
|
-
return false;
|
|
692
|
-
},
|
|
693
|
-
current: function() {
|
|
694
|
-
return this.stack.length ? this.stack[this.stack.length - 1] : null;
|
|
695
|
-
},
|
|
696
|
-
// cardClasses() — the animation class set for the visible card.
|
|
697
|
-
// Resolves the entry's enter/exit animation keys (props.enterAnim
|
|
698
|
-
// / props.exitAnim) through the ModalAnimations registry, while
|
|
699
|
-
// keeping the directional swap-* classes (the in-flow slide) as
|
|
700
|
-
// fixed names. Lifecycle → class:
|
|
701
|
-
// fresh mount → enter key class (default 'pop' → modal-card-mount)
|
|
702
|
-
// closing → exit key class (default 'pop' → modal-card-unmount)
|
|
703
|
-
// swap/advance → modal-card-swap-{in,out}[-back]
|
|
704
|
-
// The mount class is gated on !_settled (so a completed swap doesn't
|
|
705
|
-
// re-fire the bounce) and !_closing (so close shows ONLY the exit
|
|
706
|
-
// keyframe, not both stacked).
|
|
707
|
-
cardClasses: function() {
|
|
708
|
-
var c = this.current();
|
|
709
|
-
if (!c) return {};
|
|
710
|
-
var o = {};
|
|
711
|
-
// The card element carries no static max-w-*; this is the only
|
|
712
|
-
// one it ever gets. Set FIRST so the animation keys below, which
|
|
713
|
-
// never collide with a max-w-* name, read as additions to it.
|
|
714
|
-
o[modalCardWidth(c.id)] = true;
|
|
715
|
-
if (!c._settled && !c._swappingIn && !c._swappingOut && !c._closing) {
|
|
716
|
-
o[modalAnim('enter', c.props && c.props.enterAnim).cls] = true;
|
|
717
|
-
}
|
|
718
|
-
if (c._closing && !c._swappingOut) {
|
|
719
|
-
o[modalAnim('exit', c.props && c.props.exitAnim).cls] = true;
|
|
720
|
-
}
|
|
721
|
-
// The registry's 'slide' entries resolve to the SAME class names
|
|
722
|
-
// as the fixed directional flags below (enter.slide is
|
|
723
|
-
// modal-card-swap-in, exit.slide is modal-card-swap-out). Never
|
|
724
|
-
// reassign a key the enter/exit resolution above already set —
|
|
725
|
-
// an unconditional assignment here clobbered those keys to
|
|
726
|
-
// false, so enterAnim/exitAnim 'slide' produced NO class and
|
|
727
|
-
// the exit sat motionless for the splice delay, then snapped.
|
|
728
|
-
var swapFlags = {
|
|
729
|
-
'modal-card-swap-in': !!(c._swappingIn && c._swapDir !== 'back'),
|
|
730
|
-
'modal-card-swap-out': !!(c._swappingOut && c._swapDir !== 'back'),
|
|
731
|
-
'modal-card-swap-in-back': !!(c._swappingIn && c._swapDir === 'back'),
|
|
732
|
-
'modal-card-swap-out-back': !!(c._swappingOut && c._swapDir === 'back')
|
|
733
|
-
};
|
|
734
|
-
for (var flag in swapFlags) {
|
|
735
|
-
if (!(flag in o)) o[flag] = swapFlags[flag];
|
|
736
|
-
}
|
|
737
|
-
return o;
|
|
738
|
-
},
|
|
739
|
-
_sync: function() {
|
|
740
|
-
if (this.stack.length) {
|
|
741
|
-
document.body.classList.add('modal-open');
|
|
742
|
-
} else {
|
|
743
|
-
document.body.classList.remove('modal-open');
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
});
|
|
747
|
-
});
|
|
748
|
-
|
|
749
|
-
// === bfcache + Turbo snapshot cleanup ==============================
|
|
750
|
-
//
|
|
751
|
-
// Without this, a user who lands on a celebratory modal and then
|
|
752
|
-
// navigates away (or hits the browser's bfcache on back) sees the
|
|
753
|
-
// modal again on their next visit. closeAllDismissible() keeps
|
|
754
|
-
// dismissible: false modals (e.g. pending on-chain TX) so the
|
|
755
|
-
// user returning via bfcache doesn't silently lose the modal that
|
|
756
|
-
// a still-in-flight JS promise will resolve against.
|
|
757
|
-
function clearStaleModals() {
|
|
758
|
-
if (window.Alpine && Alpine.store) {
|
|
759
|
-
var m = Alpine.store('modals');
|
|
760
|
-
if (m && typeof m.closeAllDismissible === 'function') {
|
|
761
|
-
m.closeAllDismissible();
|
|
762
|
-
} else if (m && typeof m.closeAll === 'function') {
|
|
763
|
-
// Fallback for older engine versions that registered the store first.
|
|
764
|
-
m.closeAll();
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
window.addEventListener('pageshow', function(e) { if (e.persisted) clearStaleModals(); });
|
|
769
|
-
document.addEventListener('turbo:before-cache', clearStaleModals);
|
|
770
|
-
})();
|
|
771
|
-
</script>
|
|
772
|
-
|
|
773
|
-
<%# The minimum-visible-duration convention (window.StudioModals.holdAtLeast +
|
|
774
|
-
MIN_LOAD_MS) — the SINGLE source, shared with the /admin/style demo page.
|
|
775
|
-
Pair it with the _processing_card block so a load spinner never flashes past
|
|
776
|
-
the user. Rendered AFTER the store <script> above so the store stays the
|
|
777
|
-
host's first <script> (a Node behavior harness extracts that first script).
|
|
778
|
-
See studio/modals/_load_convention for the full contract. %>
|
|
779
|
-
<%= render "studio/modals/load_convention" %>
|
|
209
|
+
<%# THE STORE IS NOT HERE. $store.modals, its focus trap, window.ModalAnimations,
|
|
210
|
+
window.StudioModals (CARD_WIDTHS, DEFAULT_CARD_WIDTH, MIN_LOAD_MS and
|
|
211
|
+
holdAtLeast) and the bfcache / Turbo snapshot cleanup are studio/modal_host,
|
|
212
|
+
bound to this host by the modal-host controller on the template below. The
|
|
213
|
+
store registers before Alpine starts; see that module's header. %>
|
|
780
214
|
|
|
781
|
-
<template x-if="$store.modals.current()"
|
|
215
|
+
<template x-if="$store.modals.current()"
|
|
216
|
+
data-studio-controller="modal-host"
|
|
217
|
+
data-modal-host-store-value="modals">
|
|
782
218
|
<%# Backdrop + card use CSS keyframe animations (NOT Alpine
|
|
783
219
|
x-transition), all shipped in the inline style block above. On
|
|
784
220
|
mount, the .modal-backdrop-mount + card mount classes fire fade-in
|
|
@@ -833,7 +269,7 @@
|
|
|
833
269
|
mobile browsers shrink the visual viewport when the URL bar is showing, and
|
|
834
270
|
vh ignores that. %>
|
|
835
271
|
<%# No max-w-* here — cardClasses() supplies exactly one, per modal id, from
|
|
836
|
-
the CARD_WIDTHS registry
|
|
272
|
+
the CARD_WIDTHS registry (studio/modal_host). max-h/overflow are unrelated and stay. %>
|
|
837
273
|
<div class="bg-surface rounded-xl border border-subtle shadow-2xl p-6 w-full max-h-[85dvh] overflow-y-auto"
|
|
838
274
|
:class="$store.modals.cardClasses()">
|
|
839
275
|
<%# Consumer-provided content registrations. Each block typically
|