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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 629e819d3b8b6a9c92140a69829e75a3d6d6c3c8e9ff4faeede098ffe7394a1e
|
|
4
|
+
data.tar.gz: 562d9d5caffa68f7c282cc91cb91369fe132a8e90c63257b97f43bf4d328669b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/studio/version.rb
CHANGED