studio-engine 0.62.3 → 0.62.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 158600e252830f127db7e080529137d7082fa9b403c45f3a9f2231ab57c6c65f
4
- data.tar.gz: 3f52e2bc6cc7c0cf6fdced63b9eac292bfd52c798548d20a8ba099cb641f3759
3
+ metadata.gz: 629e819d3b8b6a9c92140a69829e75a3d6d6c3c8e9ff4faeede098ffe7394a1e
4
+ data.tar.gz: 562d9d5caffa68f7c282cc91cb91369fe132a8e90c63257b97f43bf4d328669b
5
5
  SHA512:
6
- metadata.gz: fb1378d6c1aa7515925277eb359664daf6bb15d16f5fcec271dd5381dfe86a10bd85ac9224a2df2b0a57bdff1973d976ba204ae4596c156335102600cb7e867a
7
- data.tar.gz: 7c9c62ab705a93b178bf32a14bcd9392ea9c97cc7877f8d5defa1a49ced902e9dab8944744250601e15d062ffca2119305cc058958f25784c7802623d06cbcc6
6
+ metadata.gz: 3bc2df617a2f90f9c46efde0d73f7f39f7b2f063147971303d004b080c80af4710cb4cce605cbb7a608e87c37bcdd58dceaa4a2d0088c791b9024625c3da1fb3
7
+ data.tar.gz: 5a69fabcce40e7e93c5b8d0a3b88431a74d4047e100854393be1fb5ead4b4bacb0229b7182953e148655fe64a1faa60eacbb6aca426531fb20e34bff30116686
@@ -206,6 +206,15 @@
206
206
  return table[key] || table.pop || animDefaults[channel].pop;
207
207
  }
208
208
 
209
+ // Where focus returns when the last modal closes. A module-level closure, NOT a
210
+ // store property — see captureFocus below for why a DOM node must never live on
211
+ // a reactive store.
212
+ var _returnFocusTo = null;
213
+ // The backdrop node, kept in the SAME closure and for the same reason: a DOM
214
+ // node on a reactive store reads back as a proxy and every identity check
215
+ // against it is false forever. refocus() needs this node after a swap.
216
+ var _backdropEl = null;
217
+
209
218
  // === Alpine.store('modals') — the stack ============================
210
219
  //
211
220
  // Idempotent registration (return early if already registered) so
@@ -238,10 +247,127 @@
238
247
  // SWAP_IN_MS must match the modal-card-swap-in animation duration in
239
248
  // the inline style block above; we clear the flag after that so a
240
249
  // fresh open() doesn't re-trigger the slide.
250
+ // === focus management ==============================================
251
+ //
252
+ // THE RETURN TARGET LIVES IN A CLOSURE, NOT ON THE STORE. Alpine.store()
253
+ // wraps its object in a reactive Proxy, so a DOM node assigned to a store
254
+ // property reads back as a PROXY of that node. Calling .focus() through the
255
+ // proxy happens to work, but any identity check against it (=== , contains(),
256
+ // Set membership) is false forever — the exact defect that made the wallet
257
+ // watcher discard every account-change event it ever received. Focus code is
258
+ // full of identity checks, so the node is kept out of the reactive object
259
+ // entirely.
260
+ captureFocus: function(el) {
261
+ _returnFocusTo = document.activeElement;
262
+ _backdropEl = el;
263
+ // Focus the backdrop itself rather than the first control: landing on a
264
+ // button means a stray Enter fires it, and a destructive card should not
265
+ // be one keystroke from confirming.
266
+ if (el && el.focus) el.focus();
267
+ },
268
+
269
+ // RE-FOCUS THE BACKDROP AFTER THE TOP ENTRY CHANGES, without re-capturing
270
+ // the return target.
271
+ //
272
+ // THE DEFECT THIS CLOSES. captureFocus runs from x-init on the backdrop,
273
+ // and that backdrop lives inside <template x-if="current()">. swap() is
274
+ // open(id, props, { replace: true }) and advance() only patches props, so
275
+ // current() stays truthy and the OUTER template never re-mounts —
276
+ // captureFocus never runs again. The INNER content template DOES re-mount
277
+ // and unmounts whatever was focused; document.activeElement falls back to
278
+ // the document body, which is not a descendant of the backdrop, so the handler
279
+ // bound there stops seeing the key and native tabbing resumes. Measured
280
+ // in a browser: open -> swap -> Shift+Tab x3 walked out to the page behind.
281
+ //
282
+ // _returnFocusTo IS DELIBERATELY NOT TOUCHED. Re-capturing here would
283
+ // overwrite the opener with a node inside the dialog, so closing would
284
+ // return focus into a card that no longer exists.
285
+ //
286
+ // Deferred a tick: the re-mount that unfocuses the old node happens during
287
+ // Alpine's update, so focusing before it lands would be undone immediately.
288
+ refocus: function() {
289
+ var run = function() {
290
+ if (_backdropEl && _backdropEl.focus && document.contains(_backdropEl)) {
291
+ _backdropEl.focus();
292
+ }
293
+ };
294
+ if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(run);
295
+ else setTimeout(run, 0);
296
+ },
297
+
298
+ releaseFocus: function() {
299
+ var target = _returnFocusTo;
300
+ _returnFocusTo = null;
301
+ _backdropEl = null;
302
+ // Only restore if the element is still in the document — a Turbo visit can
303
+ // replace the page under an open modal, and focusing a detached node
304
+ // silently moves focus to the document body, which is worse than leaving it alone.
305
+ if (target && target.focus && document.contains(target)) target.focus();
306
+ },
307
+
308
+ // Every tabbable node inside the dialog, in document order. Deliberately
309
+ // recomputed per keypress: modal content is Alpine-rendered and a card can
310
+ // add or remove controls (a spinner replacing a button) while open, so a
311
+ // list captured at mount goes stale.
312
+ focusables: function(el) {
313
+ if (!el) return [];
314
+ var sel = 'a[href], button:not([disabled]), input:not([disabled]), ' +
315
+ 'select:not([disabled]), textarea:not([disabled]), [tabindex]';
316
+ return Array.prototype.slice.call(el.querySelectorAll(sel)).filter(function(n) {
317
+ return n.tabIndex >= 0 && n.offsetParent !== null;
318
+ });
319
+ },
320
+
321
+ // Tab is intercepted (.prevent on the binding) and re-dispatched here.
322
+ //
323
+ // THAT ONLY HOLDS WHILE FOCUS IS INSIDE THE BACKDROP, which is a real
324
+ // condition and not a formality. The binding lives ON the backdrop, so it
325
+ // sees the key only when activeElement is a descendant. If a re-mount
326
+ // drops focus to the document body — which is what a swap() or advance()
327
+ // does to the inner content template — this handler never fires, .prevent
328
+ // never runs, and native tabbing resumes straight out of the dialog. That
329
+ // is why refocus() exists and why both phase-2 seams call it. An earlier
330
+ // version of this comment claimed focus could never leave while open; it
331
+ // could, and it did.
332
+ cycleFocus: function(el, event) {
333
+ var items = this.focusables(el);
334
+ if (items.length === 0) { if (el && el.focus) el.focus(); return; }
335
+
336
+ var idx = items.indexOf(document.activeElement);
337
+ var next;
338
+ if (event && event.shiftKey) {
339
+ next = idx <= 0 ? items[items.length - 1] : items[idx - 1];
340
+ } else {
341
+ next = (idx === -1 || idx === items.length - 1) ? items[0] : items[idx + 1];
342
+ }
343
+ next.focus();
344
+ },
345
+
346
+ // A dialog with no accessible name announces as just "dialog". Props first
347
+ // so a card can name itself; the id is the honest fallback.
348
+ dialogLabel: function() {
349
+ var entry = this.current();
350
+ if (!entry) return 'Dialog';
351
+ var p = entry.props || {};
352
+ // TRIMMED, not merely truthy. `title: ' '` is a truthy string, so it used to
353
+ // pass straight through — and the accessible-name computation then trims it to
354
+ // empty and announces a bare "dialog", the exact outcome this fallback chain
355
+ // exists to prevent. An empty string already fell through (it is falsy); a
356
+ // whitespace-only one did not, which is the more likely typo of the two.
357
+ var named = function(v) {
358
+ return (typeof v === 'string' && v.trim() !== '') ? v : null;
359
+ };
360
+ return named(p.ariaLabel) || named(p.title) || String(entry.id).replace(/[-_]/g, ' ');
361
+ },
362
+
241
363
  open: function(id, props, opts) {
242
364
  props = props || {};
243
365
  opts = opts || {};
244
366
  var self = this;
367
+ // Set by any branch that re-mounts the INNER content template without
368
+ // re-mounting the OUTER one — see the push branch below. The replace
369
+ // branch returns early and calls refocus() from its own phase 2.
370
+ var remounted = false;
245
371
 
246
372
  if (opts.replace && this.stack.length > 0) {
247
373
  var current = this.current();
@@ -271,6 +397,9 @@
271
397
  var newEntry = { id: id, props: props, _swappingIn: true, _swapDir: dir };
272
398
  self.stack[idx] = newEntry;
273
399
  self._sync();
400
+ // The content template just re-mounted; put focus back on the
401
+ // backdrop or the trap releases. See refocus().
402
+ self.refocus();
274
403
  // Phase 3: clear the swap-in flag + direction after the
275
404
  // slide finishes so a future close() runs the unmount
276
405
  // keyframe cleanly. _settled latches so the host's
@@ -287,10 +416,25 @@
287
416
  }
288
417
  // Already mid-close — just hot-swap so we don't double up timers.
289
418
  this.stack[this.stack.length - 1] = { id: id, props: props };
419
+ remounted = true;
290
420
  } else {
421
+ // A push onto a NON-EMPTY stack is the third re-mount seam, and the one
422
+ // the trap missed longest. current() stays truthy, so the OUTER template
423
+ // never re-mounts and x-init never re-runs captureFocus — while the INNER
424
+ // content template DOES re-mount and unmounts whatever was focused.
425
+ // Focus lands on the document body, which is not a descendant of the backdrop, so the
426
+ // @keydown.tab handler bound there stops firing and native tabbing resumes.
427
+ // MEASURED on /lab/birthday_gate: open a card, Tab once, push a second
428
+ // card, then Shift+Tab three times and you walk onto three BACKGROUND-PAGE
429
+ // buttons behind a still-open dialog.
430
+ //
431
+ // Only the FIRST push is safe: current() goes falsy -> truthy, the outer
432
+ // template mounts, and x-init captures for us.
433
+ remounted = this.stack.length > 0;
291
434
  this.stack.push({ id: id, props: props });
292
435
  }
293
436
  this._sync();
437
+ if (remounted) this.refocus();
294
438
  },
295
439
  // Sugar — `swap('foo', props)` ≡ `open('foo', props, { replace: true })`.
296
440
  // Pass opts through so callers can request a back-direction slide
@@ -340,6 +484,9 @@
340
484
  if (propsPatch) Object.assign(cur.props, propsPatch);
341
485
  cur._swappingOut = false;
342
486
  cur._swappingIn = true;
487
+ // Same re-mount, same release. advance() patches props in place, so
488
+ // the outer template never re-runs x-init either.
489
+ self.refocus();
343
490
  setTimeout(function() {
344
491
  cur._swappingIn = false;
345
492
  cur._swapDir = null;
@@ -367,9 +514,24 @@
367
514
  self.stack.splice(idx, 1);
368
515
  self._sync();
369
516
  }
517
+ // Hand focus back only when the LAST modal leaves. A stacked flow
518
+ // (open → swap → close) unmounts one dialog and mounts the next, and
519
+ // restoring to the background page in between would yank focus out of
520
+ // a flow the user is still inside.
521
+ //
522
+ // BUT NOT RESTORING IS NOT THE SAME AS DOING NOTHING. Closing down to an
523
+ // underlying modal re-mounts the inner template, so focus falls to the document body
524
+ // with the dialog still open — the trap is off while the user is still
525
+ // inside it. releaseFocus() was correctly gated on an empty stack; the
526
+ // missing half was the else. MEASURED on both hosts.
527
+ if (self.stack.length === 0) self.releaseFocus();
528
+ else self.refocus();
370
529
  }, modalAnim('exit', entry.props && entry.props.exitAnim).ms);
371
530
  },
372
531
  closeAll: function() {
532
+ // Turbo/bfcache teardown: the page is going away, so drop the return
533
+ // target rather than restoring into a document about to be replaced.
534
+ _returnFocusTo = null;
373
535
  // No animation — used by Turbo before-cache + bfcache cleanup
374
536
  // where the user is navigating away and we just need the DOM
375
537
  // clean immediately.
@@ -389,6 +551,15 @@
389
551
  return modal.props && modal.props.dismissible === false;
390
552
  });
391
553
  this._sync();
554
+ // Same re-mount, same release: a surviving non-dismissible card is still on
555
+ // screen, and the cards dropped from under it took focus with them. MEASURED:
556
+ // activeElement the document body behind a still-open non-dismissible dialog.
557
+ //
558
+ // Nothing is done when the stack empties. That is deliberate and different
559
+ // from close(): this runs on the Turbo/bfcache teardown path, where the page
560
+ // is going away and closeAll() already declines to restore into a document
561
+ // about to be replaced.
562
+ if (this.stack.length > 0) this.refocus();
392
563
  },
393
564
  // isOpen(id) — is a card with this id ON THE STACK, in any
394
565
  // lifecycle state? A card mid-close still counts: close() flips
@@ -532,11 +703,24 @@
532
703
  dismissible: false on its props (e.g. processing an on-chain tx
533
704
  where an accidental click would orphan a signed but un-confirmed
534
705
  transaction). Defaults to dismissible. %>
706
+ <%# FOCUS AND NAME. Measured on a real page before this: document.activeElement
707
+ after opening a modal was still the BACKGROUND page's textarea, so a keyboard
708
+ user sat focused behind an overlay. On the non-dismissible cards that is a
709
+ genuine trap — escape and click-outside are deliberately gated off there, so
710
+ there was no way out at all.
711
+
712
+ tabindex="-1" makes the backdrop programmatically focusable without adding it
713
+ to the tab order. The name comes from props (ariaLabel, else title, else the
714
+ modal id) so a screen reader never announces a bare unnamed dialog. %>
535
715
  <div class="fixed inset-0 z-[120] flex items-center justify-center p-4 modal-backdrop-mount"
536
716
  :class="$store.modals.current()?._closing && 'modal-backdrop-unmount'"
537
717
  style="background:rgba(0,0,0,0.6)"
538
718
  role="dialog"
539
719
  aria-modal="true"
720
+ tabindex="-1"
721
+ x-init="$store.modals.captureFocus($el)"
722
+ :aria-label="$store.modals.dialogLabel()"
723
+ @keydown.tab.prevent="$store.modals.cycleFocus($el, $event)"
540
724
  @keydown.escape.window="$store.modals.current() && $store.modals.current().props.dismissible !== false && $store.modals.close()"
541
725
  @click.self="$store.modals.current() && $store.modals.current().props.dismissible !== false && $store.modals.close()">
542
726
  <%# Card animation is class-driven (cardClasses() in the store) so it
@@ -554,7 +738,13 @@
554
738
  slide finishes, which reads as a flash mid-transition. _settled
555
739
  stays true for the rest of the entry's lifetime so a stale Alpine
556
740
  re-evaluation can't retrigger the bounce. %>
557
- <div class="bg-surface rounded-xl border border-subtle shadow-2xl p-6 max-w-sm w-full"
741
+ <%# max-h + overflow-y so a card taller than the viewport SCROLLS instead of
742
+ clipping its own actions off-screen. Without it the confirm button on a long
743
+ card is unreachable at small heights — and on a non-dismissible card that is
744
+ a dead end, since escape and click-outside are gated off. 100dvh, not 100vh:
745
+ mobile browsers shrink the visual viewport when the URL bar is showing, and
746
+ vh ignores that. %>
747
+ <div class="bg-surface rounded-xl border border-subtle shadow-2xl p-6 max-w-sm w-full max-h-[85dvh] overflow-y-auto"
558
748
  :class="$store.modals.cardClasses()">
559
749
  <%# Consumer-provided content registrations. Each block typically
560
750
  contains a <template x-if="$store.modals.current().id === 'X'">
@@ -83,12 +83,96 @@
83
83
  // load; this registration lives in a PAGE BODY, so it was absent during that
84
84
  // one init. Without the guard, a Turbo Drive visit to this page leaves the
85
85
  // store undefined and every x-data on it throws.
86
+ var _returnFocusTo = null;
87
+ // The backdrop node, kept in the same closure and for the same proxy reason.
88
+ var _backdropEl = null;
89
+
86
90
  function registerScopedStore() {
87
91
  if (Alpine.store(STORE_NAME)) return;
88
92
 
89
93
  Alpine.store(STORE_NAME, {
90
94
  stack: [],
91
95
 
96
+ // FOCUS — the same contract as the shared host (studio/modals/_host).
97
+ // Both hosts render [role=dialog]; a fix in only one of them leaves every
98
+ // page that brings its own store still trapping keyboard users behind an
99
+ // overlay, which is the "engine-wide" half of this defect.
100
+ //
101
+ // The return target is held in the closure below, NOT on the store: an
102
+ // Alpine store is a reactive Proxy, so a DOM node assigned to it reads back
103
+ // as a proxy and every identity check against it (===, contains) is false.
104
+ captureFocus: function(el) {
105
+ _returnFocusTo = document.activeElement;
106
+ _backdropEl = el;
107
+ if (el && el.focus) el.focus();
108
+ },
109
+
110
+ // RE-FOCUS AFTER A REPLACE, without re-capturing the return target. See
111
+ // the long note in _host.html.erb: captureFocus runs from x-init on the
112
+ // backdrop, and a replace keeps current() truthy, so the outer template
113
+ // never re-mounts and x-init never re-runs. The inner content DOES
114
+ // re-mount and unmounts the focused control, activeElement falls back to
115
+ // the document body, and the tab handler bound on the backdrop stops seeing
116
+ // the key. (Written out rather than as a literal tag: this comment ships
117
+ // inside the rendered script, and studio/emails asserts the page emits no
118
+ // body element of its own — a tag in prose trips it.)
119
+ // _returnFocusTo is untouched on purpose — re-capturing would overwrite
120
+ // the opener with a node inside the dialog.
121
+ refocus: function() {
122
+ var run = function() {
123
+ if (_backdropEl && _backdropEl.focus && document.contains(_backdropEl)) {
124
+ _backdropEl.focus();
125
+ }
126
+ };
127
+ if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(run);
128
+ else setTimeout(run, 0);
129
+ },
130
+
131
+ releaseFocus: function() {
132
+ var target = _returnFocusTo;
133
+ _returnFocusTo = null;
134
+ _backdropEl = null;
135
+ if (target && target.focus && document.contains(target)) target.focus();
136
+ },
137
+
138
+ focusables: function(el) {
139
+ if (!el) return [];
140
+ var sel = 'a[href], button:not([disabled]), input:not([disabled]), ' +
141
+ 'select:not([disabled]), textarea:not([disabled]), [tabindex]';
142
+ return Array.prototype.slice.call(el.querySelectorAll(sel)).filter(function(n) {
143
+ return n.tabIndex >= 0 && n.offsetParent !== null;
144
+ });
145
+ },
146
+
147
+ cycleFocus: function(el, event) {
148
+ var items = this.focusables(el);
149
+ if (items.length === 0) { if (el && el.focus) el.focus(); return; }
150
+
151
+ var idx = items.indexOf(document.activeElement);
152
+ var next;
153
+ if (event && event.shiftKey) {
154
+ next = idx <= 0 ? items[items.length - 1] : items[idx - 1];
155
+ } else {
156
+ next = (idx === -1 || idx === items.length - 1) ? items[0] : items[idx + 1];
157
+ }
158
+ next.focus();
159
+ },
160
+
161
+ dialogLabel: function() {
162
+ var entry = this.current();
163
+ if (!entry) return 'Dialog';
164
+ var p = entry.props || {};
165
+ // TRIMMED, not merely truthy. `title: ' '` is a truthy string, so it used to
166
+ // pass straight through — and the accessible-name computation then trims it to
167
+ // empty and announces a bare "dialog", the exact outcome this fallback chain
168
+ // exists to prevent. An empty string already fell through (it is falsy); a
169
+ // whitespace-only one did not, which is the more likely typo of the two.
170
+ var named = function(v) {
171
+ return (typeof v === 'string' && v.trim() !== '') ? v : null;
172
+ };
173
+ return named(p.ariaLabel) || named(p.title) || String(entry.id).replace(/[-_]/g, ' ');
174
+ },
175
+
92
176
  // open(id, props, opts) — opts.replace swaps the top entry in place
93
177
  // (what submitFormWithProgress does to turn crop-photo into saving).
94
178
  open: function(id, props, opts) {
@@ -96,12 +180,26 @@
96
180
  opts = opts || {};
97
181
  var entry = { id: id, props: props };
98
182
 
183
+ // Did this call re-mount the INNER content template without re-mounting the
184
+ // OUTER one? That is the whole question the focus trap turns on.
185
+ var remounted = false;
99
186
  if (opts.replace && this.stack.length > 0) {
100
187
  this.stack.splice(this.stack.length - 1, 1, entry);
188
+ remounted = true;
101
189
  } else {
190
+ // THE COMMENT THIS REPLACES SAID "only on a REPLACE", on the premise that a
191
+ // fresh push takes current() falsy -> truthy so the outer template mounts and
192
+ // x-init captures for us. That is TRUE only when the stack was EMPTY, and
193
+ // silently false for a stacked push: current() was already truthy, so the
194
+ // outer template does not re-mount, x-init never re-runs, and the inner
195
+ // template unmounts whatever was focused. Focus lands on the document body — not a
196
+ // descendant of the backdrop — so the @keydown.tab handler bound there stops
197
+ // firing and native tabbing walks straight onto the page behind.
198
+ remounted = this.stack.length > 0;
102
199
  this.stack.push(entry);
103
200
  }
104
201
  this._sync();
202
+ if (remounted) this.refocus();
105
203
  },
106
204
 
107
205
  swap: function(id, props, opts) {
@@ -121,10 +219,23 @@
121
219
  var index = self.stack.indexOf(entry);
122
220
  if (index !== -1) self.stack.splice(index, 1);
123
221
  self._sync();
222
+ // Only when the LAST modal leaves — a stacked flow unmounts one dialog
223
+ // and mounts the next, and restoring in between yanks focus out of a
224
+ // flow the user is still inside.
225
+ //
226
+ // NOT RESTORING IS NOT THE SAME AS DOING NOTHING. Closing down TO an
227
+ // underlying modal re-mounts the inner template, so focus falls to the document body
228
+ // with that dialog still open — the trap is off while the user is still
229
+ // inside it. The gate was right; the else was missing.
230
+ if (self.stack.length === 0) self.releaseFocus();
231
+ else self.refocus();
124
232
  }, CLOSE_ANIM_MS);
125
233
  },
126
234
 
127
235
  closeAll: function() {
236
+ // Teardown path (Turbo/bfcache): drop the target rather than restoring
237
+ // into a document that is about to be replaced.
238
+ _returnFocusTo = null;
128
239
  this.stack = [];
129
240
  this._sync();
130
241
  },
@@ -136,6 +247,12 @@
136
247
  return entry.props && entry.props.dismissible === false;
137
248
  });
138
249
  this._sync();
250
+ // Same re-mount, same release: a surviving non-dismissible card is still on
251
+ // screen and the cards dropped from under it took focus with them. Nothing is
252
+ // done when the stack empties — deliberately unlike close(): this is the
253
+ // Turbo/bfcache teardown path, where closeAll() already declines to restore
254
+ // into a document about to be replaced.
255
+ if (this.stack.length > 0) this.refocus();
139
256
  },
140
257
 
141
258
  // isOpen(id) — on the stack in ANY lifecycle state, including a card
@@ -201,9 +318,13 @@
201
318
  style="background:rgba(0,0,0,0.6)"
202
319
  role="dialog"
203
320
  aria-modal="true"
321
+ tabindex="-1"
322
+ x-init="$store.<%= scoped_store %>.captureFocus($el)"
323
+ :aria-label="$store.<%= scoped_store %>.dialogLabel()"
324
+ @keydown.tab.prevent="$store.<%= scoped_store %>.cycleFocus($el, $event)"
204
325
  @keydown.escape.window="$store.<%= scoped_store %>.current() && $store.<%= scoped_store %>.current().props.dismissible !== false && $store.<%= scoped_store %>.close()"
205
326
  @click.self="$store.<%= scoped_store %>.current() && $store.<%= scoped_store %>.current().props.dismissible !== false && $store.<%= scoped_store %>.close()">
206
- <div class="<%= card_class %>" :class="$store.<%= scoped_store %>.cardClasses()">
327
+ <div class="<%= card_class %> max-h-[85dvh] overflow-y-auto" :class="$store.<%= scoped_store %>.cardClasses()">
207
328
  <%# Consumer-provided registrations — one <template x-if> per modal id. %>
208
329
  <%= yield if block_given? %>
209
330
  </div>
@@ -78,6 +78,7 @@
78
78
  _remaining: #{redirect_secs},
79
79
  _total: #{redirect_secs},
80
80
  _redirectTimer: null,
81
+ _confettiTimer: null,
81
82
  startCountdown(url) {
82
83
  if (!url) return;
83
84
  var self = this;
@@ -92,9 +93,16 @@
92
93
  }, 1000);
93
94
  },
94
95
  fireConfetti() {
95
- setTimeout(function() {
96
+ var self = this;
97
+ if (self._confettiTimer) clearTimeout(self._confettiTimer);
98
+ self._confettiTimer = setTimeout(function() {
99
+ self._confettiTimer = null;
96
100
  try { if (window.fireSuccessConfetti) window.fireSuccessConfetti(); } catch (_) {}
97
101
  }, 100);
102
+ },
103
+ destroy() {
104
+ if (this._redirectTimer) { clearInterval(this._redirectTimer); this._redirectTimer = null; }
105
+ if (this._confettiTimer) { clearTimeout(this._confettiTimer); this._confettiTimer = null; }
98
106
  }
99
107
  }".gsub(/\s+/, ' ').html_safe
100
108
 
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.62.3"
2
+ VERSION = "0.62.5"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.62.3
4
+ version: 0.62.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie