studio-engine 0.62.3 → 0.62.4

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: 8407df81b6cb7059899c1f78c85cc36f0187e37ea0c5e0a2b47b7f611dbd1471
4
+ data.tar.gz: d28c8cf45183f8ae08f66e3b57950cfada513c6738a2dd3fce1a6c8bb81568e1
5
5
  SHA512:
6
- metadata.gz: fb1378d6c1aa7515925277eb359664daf6bb15d16f5fcec271dd5381dfe86a10bd85ac9224a2df2b0a57bdff1973d976ba204ae4596c156335102600cb7e867a
7
- data.tar.gz: 7c9c62ab705a93b178bf32a14bcd9392ea9c97cc7877f8d5defa1a49ced902e9dab8944744250601e15d062ffca2119305cc058958f25784c7802623d06cbcc6
6
+ metadata.gz: 6e547a7ee0083346abd7977616fe43921991889c911639d5b72ff29196eb639dd7f880f2692688dd46e16aa3ab5e4e07223366646ad15a24c4c940111c0f6a93
7
+ data.tar.gz: c84c519a783dfc04cc63d95eef3f71f0f6f4d34c8e758d8adbd89ce292ed5b3553e21a5e1e4308c44237b82d4520dbec4e2dc029b1302c97cc7294735213486c
@@ -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,6 +247,111 @@
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 <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
+ return p.ariaLabel || p.title || String(entry.id).replace(/[-_]/g, ' ');
353
+ },
354
+
241
355
  open: function(id, props, opts) {
242
356
  props = props || {};
243
357
  opts = opts || {};
@@ -271,6 +385,9 @@
271
385
  var newEntry = { id: id, props: props, _swappingIn: true, _swapDir: dir };
272
386
  self.stack[idx] = newEntry;
273
387
  self._sync();
388
+ // The content template just re-mounted; put focus back on the
389
+ // backdrop or the trap releases. See refocus().
390
+ self.refocus();
274
391
  // Phase 3: clear the swap-in flag + direction after the
275
392
  // slide finishes so a future close() runs the unmount
276
393
  // keyframe cleanly. _settled latches so the host's
@@ -340,6 +457,9 @@
340
457
  if (propsPatch) Object.assign(cur.props, propsPatch);
341
458
  cur._swappingOut = false;
342
459
  cur._swappingIn = true;
460
+ // Same re-mount, same release. advance() patches props in place, so
461
+ // the outer template never re-runs x-init either.
462
+ self.refocus();
343
463
  setTimeout(function() {
344
464
  cur._swappingIn = false;
345
465
  cur._swapDir = null;
@@ -367,9 +487,17 @@
367
487
  self.stack.splice(idx, 1);
368
488
  self._sync();
369
489
  }
490
+ // Hand focus back only when the LAST modal leaves. A stacked flow
491
+ // (open → swap → close) unmounts one dialog and mounts the next, and
492
+ // restoring to the background page in between would yank focus out of
493
+ // a flow the user is still inside.
494
+ if (self.stack.length === 0) self.releaseFocus();
370
495
  }, modalAnim('exit', entry.props && entry.props.exitAnim).ms);
371
496
  },
372
497
  closeAll: function() {
498
+ // Turbo/bfcache teardown: the page is going away, so drop the return
499
+ // target rather than restoring into a document about to be replaced.
500
+ _returnFocusTo = null;
373
501
  // No animation — used by Turbo before-cache + bfcache cleanup
374
502
  // where the user is navigating away and we just need the DOM
375
503
  // clean immediately.
@@ -532,11 +660,24 @@
532
660
  dismissible: false on its props (e.g. processing an on-chain tx
533
661
  where an accidental click would orphan a signed but un-confirmed
534
662
  transaction). Defaults to dismissible. %>
663
+ <%# FOCUS AND NAME. Measured on a real page before this: document.activeElement
664
+ after opening a modal was still the BACKGROUND page's textarea, so a keyboard
665
+ user sat focused behind an overlay. On the non-dismissible cards that is a
666
+ genuine trap — escape and click-outside are deliberately gated off there, so
667
+ there was no way out at all.
668
+
669
+ tabindex="-1" makes the backdrop programmatically focusable without adding it
670
+ to the tab order. The name comes from props (ariaLabel, else title, else the
671
+ modal id) so a screen reader never announces a bare unnamed dialog. %>
535
672
  <div class="fixed inset-0 z-[120] flex items-center justify-center p-4 modal-backdrop-mount"
536
673
  :class="$store.modals.current()?._closing && 'modal-backdrop-unmount'"
537
674
  style="background:rgba(0,0,0,0.6)"
538
675
  role="dialog"
539
676
  aria-modal="true"
677
+ tabindex="-1"
678
+ x-init="$store.modals.captureFocus($el)"
679
+ :aria-label="$store.modals.dialogLabel()"
680
+ @keydown.tab.prevent="$store.modals.cycleFocus($el, $event)"
540
681
  @keydown.escape.window="$store.modals.current() && $store.modals.current().props.dismissible !== false && $store.modals.close()"
541
682
  @click.self="$store.modals.current() && $store.modals.current().props.dismissible !== false && $store.modals.close()">
542
683
  <%# Card animation is class-driven (cardClasses() in the store) so it
@@ -554,7 +695,13 @@
554
695
  slide finishes, which reads as a flash mid-transition. _settled
555
696
  stays true for the rest of the entry's lifetime so a stale Alpine
556
697
  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"
698
+ <%# max-h + overflow-y so a card taller than the viewport SCROLLS instead of
699
+ clipping its own actions off-screen. Without it the confirm button on a long
700
+ card is unreachable at small heights — and on a non-dismissible card that is
701
+ a dead end, since escape and click-outside are gated off. 100dvh, not 100vh:
702
+ mobile browsers shrink the visual viewport when the URL bar is showing, and
703
+ vh ignores that. %>
704
+ <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
705
  :class="$store.modals.cardClasses()">
559
706
  <%# Consumer-provided content registrations. Each block typically
560
707
  contains a <template x-if="$store.modals.current().id === 'X'">
@@ -83,12 +83,88 @@
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
+ return p.ariaLabel || p.title || String(entry.id).replace(/[-_]/g, ' ');
166
+ },
167
+
92
168
  // open(id, props, opts) — opts.replace swaps the top entry in place
93
169
  // (what submitFormWithProgress does to turn crop-photo into saving).
94
170
  open: function(id, props, opts) {
@@ -96,12 +172,17 @@
96
172
  opts = opts || {};
97
173
  var entry = { id: id, props: props };
98
174
 
175
+ var replaced = false;
99
176
  if (opts.replace && this.stack.length > 0) {
100
177
  this.stack.splice(this.stack.length - 1, 1, entry);
178
+ replaced = true;
101
179
  } else {
102
180
  this.stack.push(entry);
103
181
  }
104
182
  this._sync();
183
+ // Only on a REPLACE. A fresh push takes current() falsy -> truthy, so the
184
+ // outer template mounts and x-init calls captureFocus for us.
185
+ if (replaced) this.refocus();
105
186
  },
106
187
 
107
188
  swap: function(id, props, opts) {
@@ -121,10 +202,17 @@
121
202
  var index = self.stack.indexOf(entry);
122
203
  if (index !== -1) self.stack.splice(index, 1);
123
204
  self._sync();
205
+ // Only when the LAST modal leaves — a stacked flow unmounts one dialog
206
+ // and mounts the next, and restoring in between yanks focus out of a
207
+ // flow the user is still inside.
208
+ if (self.stack.length === 0) self.releaseFocus();
124
209
  }, CLOSE_ANIM_MS);
125
210
  },
126
211
 
127
212
  closeAll: function() {
213
+ // Teardown path (Turbo/bfcache): drop the target rather than restoring
214
+ // into a document that is about to be replaced.
215
+ _returnFocusTo = null;
128
216
  this.stack = [];
129
217
  this._sync();
130
218
  },
@@ -201,9 +289,13 @@
201
289
  style="background:rgba(0,0,0,0.6)"
202
290
  role="dialog"
203
291
  aria-modal="true"
292
+ tabindex="-1"
293
+ x-init="$store.<%= scoped_store %>.captureFocus($el)"
294
+ :aria-label="$store.<%= scoped_store %>.dialogLabel()"
295
+ @keydown.tab.prevent="$store.<%= scoped_store %>.cycleFocus($el, $event)"
204
296
  @keydown.escape.window="$store.<%= scoped_store %>.current() && $store.<%= scoped_store %>.current().props.dismissible !== false && $store.<%= scoped_store %>.close()"
205
297
  @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()">
298
+ <div class="<%= card_class %> max-h-[85dvh] overflow-y-auto" :class="$store.<%= scoped_store %>.cardClasses()">
207
299
  <%# Consumer-provided registrations — one <template x-if> per modal id. %>
208
300
  <%= yield if block_given? %>
209
301
  </div>
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.62.3"
2
+ VERSION = "0.62.4"
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.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie