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.
@@ -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, the per-modal animation registry
6
- (window.ModalAnimations), directional swap() and advance() slides
7
- for multi-step flows, bfcache and Turbo Drive snapshot cleanup,
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. The animation keyframes ship inline in the style block
10
- below, so consumers need no extra CSS to get the full behavior.
11
- A canonical copy of the SAME keyframes also lives in the shared
12
- motion layer (app/assets/tailwind/studio_engine/engine-motion.css)
13
- for pages that drive a modal host WITHOUT rendering this partial —
14
- e.g. the living style guide's page-scoped dsModals host. The two are
15
- byte-identical; keep them in sync if you retune a curve or duration.
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 (see below)
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 — for a modal that belongs to
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 — which also carries the two rules this host
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 the host's store — that's the timeout before the
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 below: open a modal
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
- <script>
202
- (function() {
203
- // === Modal animation registry ======================================
204
- //
205
- // The single place to define + tune named entrance / exit animations
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 above. max-h/overflow are unrelated and stay. %>
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