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 +4 -4
- data/app/views/studio/modals/_host.html.erb +148 -1
- data/app/views/studio/modals/_scoped_host.html.erb +93 -1
- data/lib/studio/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8407df81b6cb7059899c1f78c85cc36f0187e37ea0c5e0a2b47b7f611dbd1471
|
|
4
|
+
data.tar.gz: d28c8cf45183f8ae08f66e3b57950cfada513c6738a2dd3fce1a6c8bb81568e1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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>
|
data/lib/studio/version.rb
CHANGED