modal_stack 0.4.1 → 0.4.3
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/CHANGELOG.md +23 -0
- data/README.md +98 -7
- data/app/assets/javascripts/modal_stack.js +205 -23
- data/app/assets/stylesheets/modal_stack/bootstrap.css +35 -0
- data/app/assets/stylesheets/modal_stack/tailwind_v3.css +35 -0
- data/app/assets/stylesheets/modal_stack/tailwind_v4.css +35 -0
- data/app/assets/stylesheets/modal_stack/vanilla.css +35 -0
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +111 -8
- data/app/javascript/modal_stack/orchestrator.js +17 -3
- data/app/javascript/modal_stack/orchestrator.test.js +2 -2
- data/app/javascript/modal_stack/runtime.js +149 -4
- data/app/javascript/modal_stack/runtime.test.js +7 -6
- data/app/javascript/modal_stack/state.js +24 -11
- data/app/javascript/modal_stack/state.test.js +11 -9
- data/app/views/modal_stack/_panel.html.erb +13 -0
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +6 -0
- data/lib/modal_stack/helpers/modal_stack_container_helper.rb +4 -1
- data/lib/modal_stack/version.rb +1 -1
- metadata +2 -2
|
@@ -359,6 +359,41 @@ body[data-modal-stack-locked] {
|
|
|
359
359
|
display: none;
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
+
/* --- Header slot ------------------------------------------------- */
|
|
363
|
+
|
|
364
|
+
.modal-stack__panel-header {
|
|
365
|
+
display: flex;
|
|
366
|
+
align-items: center;
|
|
367
|
+
justify-content: space-between;
|
|
368
|
+
gap: 0.75rem;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.modal-stack__panel-title {
|
|
372
|
+
margin: 0;
|
|
373
|
+
flex: 1;
|
|
374
|
+
min-width: 0;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.modal-stack__panel-close {
|
|
378
|
+
font: inherit;
|
|
379
|
+
background: transparent;
|
|
380
|
+
border: 0;
|
|
381
|
+
padding: 0.25rem;
|
|
382
|
+
cursor: pointer;
|
|
383
|
+
color: inherit;
|
|
384
|
+
opacity: 0.55;
|
|
385
|
+
line-height: 1;
|
|
386
|
+
font-size: 1.4em;
|
|
387
|
+
flex-shrink: 0;
|
|
388
|
+
transition: opacity 150ms ease;
|
|
389
|
+
}
|
|
390
|
+
.modal-stack__panel-close:hover { opacity: 1; }
|
|
391
|
+
.modal-stack__panel-close:focus-visible {
|
|
392
|
+
outline: 2px solid currentColor;
|
|
393
|
+
outline-offset: 2px;
|
|
394
|
+
border-radius: 2px;
|
|
395
|
+
}
|
|
396
|
+
|
|
362
397
|
/* --- Reduced motion ---------------------------------------------- */
|
|
363
398
|
|
|
364
399
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -360,6 +360,41 @@ body[data-modal-stack-locked] {
|
|
|
360
360
|
display: none;
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
+
/* --- Header slot ------------------------------------------------- */
|
|
364
|
+
|
|
365
|
+
.modal-stack__panel-header {
|
|
366
|
+
display: flex;
|
|
367
|
+
align-items: center;
|
|
368
|
+
justify-content: space-between;
|
|
369
|
+
gap: 0.75rem;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.modal-stack__panel-title {
|
|
373
|
+
margin: 0;
|
|
374
|
+
flex: 1;
|
|
375
|
+
min-width: 0;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.modal-stack__panel-close {
|
|
379
|
+
font: inherit;
|
|
380
|
+
background: transparent;
|
|
381
|
+
border: 0;
|
|
382
|
+
padding: 0.25rem;
|
|
383
|
+
cursor: pointer;
|
|
384
|
+
color: inherit;
|
|
385
|
+
opacity: 0.55;
|
|
386
|
+
line-height: 1;
|
|
387
|
+
font-size: 1.4em;
|
|
388
|
+
flex-shrink: 0;
|
|
389
|
+
transition: opacity 150ms ease;
|
|
390
|
+
}
|
|
391
|
+
.modal-stack__panel-close:hover { opacity: 1; }
|
|
392
|
+
.modal-stack__panel-close:focus-visible {
|
|
393
|
+
outline: 2px solid currentColor;
|
|
394
|
+
outline-offset: 2px;
|
|
395
|
+
border-radius: 2px;
|
|
396
|
+
}
|
|
397
|
+
|
|
363
398
|
/* --- Reduced motion ---------------------------------------------- */
|
|
364
399
|
|
|
365
400
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -326,6 +326,41 @@ body[data-modal-stack-locked] {
|
|
|
326
326
|
display: none;
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
+
/* --- Header slot ------------------------------------------------- */
|
|
330
|
+
|
|
331
|
+
.modal-stack__panel-header {
|
|
332
|
+
display: flex;
|
|
333
|
+
align-items: center;
|
|
334
|
+
justify-content: space-between;
|
|
335
|
+
gap: 0.75rem;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.modal-stack__panel-title {
|
|
339
|
+
margin: 0;
|
|
340
|
+
flex: 1;
|
|
341
|
+
min-width: 0;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.modal-stack__panel-close {
|
|
345
|
+
font: inherit;
|
|
346
|
+
background: transparent;
|
|
347
|
+
border: 0;
|
|
348
|
+
padding: 0.25rem;
|
|
349
|
+
cursor: pointer;
|
|
350
|
+
color: inherit;
|
|
351
|
+
opacity: 0.55;
|
|
352
|
+
line-height: 1;
|
|
353
|
+
font-size: 1.4em;
|
|
354
|
+
flex-shrink: 0;
|
|
355
|
+
transition: opacity 150ms ease;
|
|
356
|
+
}
|
|
357
|
+
.modal-stack__panel-close:hover { opacity: 1; }
|
|
358
|
+
.modal-stack__panel-close:focus-visible {
|
|
359
|
+
outline: 2px solid currentColor;
|
|
360
|
+
outline-offset: 2px;
|
|
361
|
+
border-radius: 2px;
|
|
362
|
+
}
|
|
363
|
+
|
|
329
364
|
@media (prefers-reduced-motion: reduce) {
|
|
330
365
|
#modal-stack-root,
|
|
331
366
|
#modal-stack-root::backdrop,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus";
|
|
2
2
|
import { Orchestrator } from "../orchestrator.js";
|
|
3
3
|
import { BrowserRuntime } from "../runtime.js";
|
|
4
|
+
import { restore } from "../state.js";
|
|
4
5
|
|
|
5
6
|
export class ModalStackController extends Controller {
|
|
6
7
|
static values = {
|
|
@@ -10,33 +11,56 @@ export class ModalStackController extends Controller {
|
|
|
10
11
|
maxDepthStrategy: { type: String, default: "warn" },
|
|
11
12
|
};
|
|
12
13
|
|
|
14
|
+
#restoring = false;
|
|
15
|
+
|
|
13
16
|
connect() {
|
|
14
|
-
const stackId = this.stackIdValue || generateLayerId();
|
|
15
17
|
const baseUrl = this.baseUrlValue || window.location.href;
|
|
16
18
|
|
|
17
19
|
this.runtime = new BrowserRuntime({ dialog: this.element });
|
|
18
|
-
|
|
20
|
+
// Restore frame HTML cache before reading snapshot so wizard frames
|
|
21
|
+
// saved in sessionStorage are available during #restoreSnapshot.
|
|
22
|
+
this.runtime.restoreFrameCacheFromStorage();
|
|
23
|
+
const savedSnapshot = this.runtime.readSnapshot();
|
|
24
|
+
|
|
25
|
+
// Peek at the snapshot (without stackId filter) to reuse the saved
|
|
26
|
+
// stackId across page reloads — otherwise a randomly generated stackId
|
|
27
|
+
// would never match the one saved in sessionStorage.
|
|
28
|
+
const snapshotState = savedSnapshot ? restore(savedSnapshot) : null;
|
|
29
|
+
const stackId =
|
|
30
|
+
this.stackIdValue || snapshotState?.stackId || generateLayerId();
|
|
19
31
|
|
|
20
32
|
this.orchestrator = new Orchestrator({
|
|
21
33
|
runtime: this.runtime,
|
|
22
34
|
stackId,
|
|
23
35
|
baseUrl,
|
|
24
|
-
|
|
36
|
+
// Restoration is handled below via push() so each layer gets a
|
|
37
|
+
// phantom history entry and the back button closes them one by one.
|
|
38
|
+
restoreFrom: null,
|
|
25
39
|
// Stimulus Number values default to 0, but state.js treats null as
|
|
26
40
|
// "no cap" — so map 0/missing to null here.
|
|
27
41
|
maxDepth: this.maxDepthValue > 0 ? this.maxDepthValue : null,
|
|
28
42
|
maxDepthStrategy: this.maxDepthStrategyValue || "warn",
|
|
29
43
|
});
|
|
30
44
|
|
|
31
|
-
this._onPopstate = (event) =>
|
|
45
|
+
this._onPopstate = (event) => {
|
|
46
|
+
// Run in capture phase so we fire before Turbo's bubble-phase popstate
|
|
47
|
+
// handler. When the popstate was triggered by our own historyBack
|
|
48
|
+
// (expectedPopstates > 0), stop propagation immediately after processing
|
|
49
|
+
// so Turbo never sees the event and cannot start a restoration visit
|
|
50
|
+
// (which shows the loading bar and replaces the body).
|
|
51
|
+
const isOwn = this.orchestrator.expectedPopstates > 0;
|
|
32
52
|
this.orchestrator.onPopstate({
|
|
33
53
|
historyState: event.state,
|
|
34
54
|
locationHref: window.location.href,
|
|
35
55
|
});
|
|
36
|
-
|
|
56
|
+
if (isOwn) event.stopImmediatePropagation();
|
|
57
|
+
};
|
|
58
|
+
window.addEventListener("popstate", this._onPopstate, true);
|
|
37
59
|
|
|
38
60
|
this._onCancel = (event) => {
|
|
39
61
|
event.preventDefault();
|
|
62
|
+
if (!this.element.open) return;
|
|
63
|
+
if (this.#restoring) return;
|
|
40
64
|
const top = this.#topLayer();
|
|
41
65
|
if (!top || top.dismissible === false) return;
|
|
42
66
|
this.orchestrator.pop();
|
|
@@ -45,22 +69,98 @@ export class ModalStackController extends Controller {
|
|
|
45
69
|
|
|
46
70
|
this._onBackdropClick = (event) => {
|
|
47
71
|
if (event.target !== this.element) return;
|
|
72
|
+
if (!this.element.open) return;
|
|
73
|
+
if (this.#restoring) return;
|
|
48
74
|
const top = this.#topLayer();
|
|
49
75
|
if (!top || top.dismissible === false) return;
|
|
50
76
|
this.orchestrator.pop();
|
|
51
77
|
};
|
|
52
78
|
this.element.addEventListener("click", this._onBackdropClick);
|
|
53
79
|
|
|
80
|
+
// After any Turbo render (restoration, morph, stream-driven page update),
|
|
81
|
+
// re-check scroll lock. A snapshot cached while a modal was open can
|
|
82
|
+
// restore data-modal-stack-locked on body even after the modal has closed.
|
|
83
|
+
// turbo:before-cache strips the attribute before caching; this is the
|
|
84
|
+
// safety net for renders that fire from an already-stale cache.
|
|
85
|
+
this._onTurboRender = () => {
|
|
86
|
+
if (this.orchestrator.depth === 0) this.runtime.unlockScroll();
|
|
87
|
+
};
|
|
88
|
+
document.addEventListener("turbo:render", this._onTurboRender);
|
|
89
|
+
|
|
54
90
|
this.#registerStreamActions();
|
|
91
|
+
|
|
92
|
+
if (snapshotState?.layers?.length > 0) {
|
|
93
|
+
this.#restoring = true;
|
|
94
|
+
this.#restoreSnapshot(snapshotState.layers)
|
|
95
|
+
.catch((err) =>
|
|
96
|
+
console.warn("[modal_stack] snapshot restore failed:", err),
|
|
97
|
+
)
|
|
98
|
+
.finally(() => {
|
|
99
|
+
this.#restoring = false;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
55
103
|
this.element.dispatchEvent(
|
|
56
|
-
new CustomEvent("modal_stack:ready", {
|
|
104
|
+
new CustomEvent("modal_stack:ready", {
|
|
105
|
+
bubbles: true,
|
|
106
|
+
detail: { stackId },
|
|
107
|
+
}),
|
|
57
108
|
);
|
|
58
109
|
}
|
|
59
110
|
|
|
111
|
+
async #restoreSnapshot(layers) {
|
|
112
|
+
// Always open each layer from its first frame URL (accessible via GET).
|
|
113
|
+
const baseUrls = layers.map((l) => l.frames?.[0]?.url ?? l.url);
|
|
114
|
+
|
|
115
|
+
// Pre-fetch base frames in parallel so the push loop runs without any
|
|
116
|
+
// network await between iterations, eliminating the race window where
|
|
117
|
+
// Escape fires while this.state lags behind (only partial stack).
|
|
118
|
+
const baseFragments = await Promise.all(
|
|
119
|
+
baseUrls.map((url) => this.orchestrator.prefetch(url).catch(() => null)),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
for (let i = 0; i < layers.length; i++) {
|
|
123
|
+
const layer = layers[i];
|
|
124
|
+
await this.orchestrator.push(
|
|
125
|
+
{
|
|
126
|
+
id: layer.id,
|
|
127
|
+
url: baseUrls[i],
|
|
128
|
+
variant: layer.variant,
|
|
129
|
+
dismissible: layer.dismissible,
|
|
130
|
+
size: layer.size,
|
|
131
|
+
side: layer.side,
|
|
132
|
+
width: layer.width,
|
|
133
|
+
height: layer.height,
|
|
134
|
+
},
|
|
135
|
+
{ fragment: baseFragments[i] },
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Restore additional wizard frames using HTML saved to sessionStorage
|
|
139
|
+
// on the previous visit. Each frame may be a POST-only step that 404s
|
|
140
|
+
// on a direct GET — we use the cached HTML instead of re-fetching.
|
|
141
|
+
const extraFrames = (layer.frames ?? []).slice(1);
|
|
142
|
+
for (let fi = 0; fi < extraFrames.length; fi++) {
|
|
143
|
+
const frame = extraFrames[fi];
|
|
144
|
+
const frameIndex = fi + 1;
|
|
145
|
+
const cached = this.runtime.getFrameFragment(layer.id, frameIndex);
|
|
146
|
+
if (!cached) break; // Can't restore beyond this frame — stop here
|
|
147
|
+
// Warm the orchestrator's fragment cache so forward re-navigation
|
|
148
|
+
// after a back doesn't attempt a failing GET for this URL.
|
|
149
|
+
this.orchestrator.setFragmentCache(frame.url, cached.cloneNode(true));
|
|
150
|
+
await this.orchestrator.pathTo(
|
|
151
|
+
{ url: frame.url, stale: frame.stale },
|
|
152
|
+
{ fragment: cached.cloneNode(true) },
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
60
158
|
disconnect() {
|
|
61
|
-
window.removeEventListener("popstate", this._onPopstate);
|
|
159
|
+
window.removeEventListener("popstate", this._onPopstate, true);
|
|
62
160
|
this.element.removeEventListener("cancel", this._onCancel);
|
|
63
161
|
this.element.removeEventListener("click", this._onBackdropClick);
|
|
162
|
+
document.removeEventListener("turbo:render", this._onTurboRender);
|
|
163
|
+
this.runtime.destroy?.();
|
|
64
164
|
}
|
|
65
165
|
|
|
66
166
|
push(layer, opts) {
|
|
@@ -207,7 +307,10 @@ function layerPatchFromStreamElement(el) {
|
|
|
207
307
|
}
|
|
208
308
|
|
|
209
309
|
function generateLayerId() {
|
|
210
|
-
if (
|
|
310
|
+
if (
|
|
311
|
+
typeof crypto !== "undefined" &&
|
|
312
|
+
typeof crypto.randomUUID === "function"
|
|
313
|
+
) {
|
|
211
314
|
return crypto.randomUUID();
|
|
212
315
|
}
|
|
213
316
|
return `ms-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
@@ -71,6 +71,10 @@ export class Orchestrator {
|
|
|
71
71
|
return this.state.layers.length;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
get expectedPopstates() {
|
|
75
|
+
return this.#expectedPopstates;
|
|
76
|
+
}
|
|
77
|
+
|
|
74
78
|
/**
|
|
75
79
|
* Push a layer. When `html`/`fragment` are absent, the orchestrator
|
|
76
80
|
* pre-fetches the URL so `mountLayer` is a sync DOM append (no flash).
|
|
@@ -194,6 +198,18 @@ export class Orchestrator {
|
|
|
194
198
|
this.#fragmentCache.clear();
|
|
195
199
|
}
|
|
196
200
|
|
|
201
|
+
// Seed the fragment cache with a known fragment for a URL. Used during
|
|
202
|
+
// snapshot restore to ensure forward re-navigation to a POST-only wizard
|
|
203
|
+
// step reuses the cached HTML rather than attempting a failing GET fetch.
|
|
204
|
+
setFragmentCache(url, fragment) {
|
|
205
|
+
if (!url || !fragment) return;
|
|
206
|
+
this.#fragmentCache.set(url, {
|
|
207
|
+
fragment: cloneFragment(fragment),
|
|
208
|
+
stale: false,
|
|
209
|
+
ts: Date.now(),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
197
213
|
// Warm the prefetch cache for `url` without mutating the stack. Safe
|
|
198
214
|
// to call repeatedly for the same URL (deduped via #inflight) and from
|
|
199
215
|
// hover/focus handlers; failures are swallowed since this is best-effort.
|
|
@@ -217,9 +233,7 @@ export class Orchestrator {
|
|
|
217
233
|
// A popstate arriving while we have prefetches in flight means the
|
|
218
234
|
// user navigated away from any URL we were preloading; drop them.
|
|
219
235
|
this.#invalidatePrefetch();
|
|
220
|
-
return this.#dispatch(
|
|
221
|
-
handlePopstate(this.state, { historyState, locationHref }),
|
|
222
|
-
);
|
|
236
|
+
return this.#dispatch(handlePopstate(this.state, { historyState, locationHref }));
|
|
223
237
|
}
|
|
224
238
|
|
|
225
239
|
async #dispatch({ state, commands }, payload = {}) {
|
|
@@ -299,12 +299,12 @@ describe("closeAll", () => {
|
|
|
299
299
|
const types = runtime._calls.map((c) => c.type);
|
|
300
300
|
expect(types).toEqual([
|
|
301
301
|
"closeDialog",
|
|
302
|
+
"clearSnapshot",
|
|
302
303
|
"unmountAllLayers",
|
|
303
304
|
"clearFrameCache",
|
|
304
305
|
"clearFrameCache",
|
|
305
306
|
"unlockScroll",
|
|
306
307
|
"historyBack",
|
|
307
|
-
"clearSnapshot",
|
|
308
308
|
]);
|
|
309
309
|
|
|
310
310
|
runtime._calls.length = 0;
|
|
@@ -561,10 +561,10 @@ describe("onPopstate", () => {
|
|
|
561
561
|
const types = runtime._calls.map((c) => c.type);
|
|
562
562
|
expect(types).toEqual([
|
|
563
563
|
"closeDialog",
|
|
564
|
+
"clearSnapshot",
|
|
564
565
|
"unmountAllLayers",
|
|
565
566
|
"clearFrameCache",
|
|
566
567
|
"unlockScroll",
|
|
567
|
-
"clearSnapshot",
|
|
568
568
|
]);
|
|
569
569
|
expect(types).not.toContain("historyBack");
|
|
570
570
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export const SNAPSHOT_KEY = "modalStackSnapshot";
|
|
2
|
+
export const FRAME_HTML_KEY = "modalStackFrameHtml";
|
|
2
3
|
export const FRAGMENT_HEADER = "X-Modal-Stack-Request";
|
|
3
4
|
// Server response header that flags the just-rendered frame as stale —
|
|
4
5
|
// the runtime will refetch instead of restoring from the in-memory cache
|
|
@@ -27,11 +28,17 @@ const LEAVE_TIMEOUT_FALLBACK_MS = 600;
|
|
|
27
28
|
* `orchestrator.test.js` for an in-memory fake).
|
|
28
29
|
*/
|
|
29
30
|
export class BrowserRuntime {
|
|
31
|
+
// Counter: incremented per historyBack call, decremented per Turbo visit
|
|
32
|
+
// cancelled. Guards every popstate that lands on a Turbo-owned entry.
|
|
33
|
+
#suppressTurboVisitCount = 0;
|
|
34
|
+
#suppressTurboVisitTimer = null;
|
|
35
|
+
|
|
30
36
|
/**
|
|
31
37
|
* @param {Object} options
|
|
32
38
|
* @param {HTMLDialogElement} options.dialog
|
|
33
39
|
* @param {HTMLElement} [options.body]
|
|
34
40
|
* @param {History} [options.history]
|
|
41
|
+
* @param {Location} [options.location]
|
|
35
42
|
* @param {typeof fetch} [options.fetcher]
|
|
36
43
|
* @param {Storage} [options.store]
|
|
37
44
|
* @param {Document} [options.documentRef]
|
|
@@ -40,6 +47,7 @@ export class BrowserRuntime {
|
|
|
40
47
|
dialog,
|
|
41
48
|
body = globalThis.document?.body,
|
|
42
49
|
history = globalThis.history,
|
|
50
|
+
location = globalThis.location,
|
|
43
51
|
fetcher = globalThis.fetch?.bind(globalThis),
|
|
44
52
|
store = globalThis.sessionStorage,
|
|
45
53
|
documentRef = globalThis.document,
|
|
@@ -50,6 +58,7 @@ export class BrowserRuntime {
|
|
|
50
58
|
this.dialog = dialog;
|
|
51
59
|
this.body = body;
|
|
52
60
|
this.history = history;
|
|
61
|
+
this.location = location;
|
|
53
62
|
this.fetcher = fetcher;
|
|
54
63
|
this.store = store;
|
|
55
64
|
this.document = documentRef;
|
|
@@ -57,6 +66,44 @@ export class BrowserRuntime {
|
|
|
57
66
|
// Populated by mountFrame on the way forward, drained by unmountFrame on
|
|
58
67
|
// the way back, purged on layer teardown via clearFrameCache.
|
|
59
68
|
this._frameCache = new Map();
|
|
69
|
+
|
|
70
|
+
// When our historyBack() navigates back to the original page-load history
|
|
71
|
+
// entry, that entry carries Turbo's restorationIdentifier. Turbo sees it on
|
|
72
|
+
// popstate and starts a restoration visit which replaces the body — including
|
|
73
|
+
// restoring a cached snapshot that had data-modal-stack-locked baked in,
|
|
74
|
+
// leaving the page scroll-locked after the modal closes.
|
|
75
|
+
//
|
|
76
|
+
// We intercept turbo:before-visit and cancel the *one* restoration triggered
|
|
77
|
+
// by our own historyBack. The flag is armed in historyBack and consumed (or
|
|
78
|
+
// timed out) immediately so it cannot suppress a user-initiated navigation.
|
|
79
|
+
this.#suppressTurboVisitCount = 0;
|
|
80
|
+
this._turboVisitGuard = (event) => {
|
|
81
|
+
if (this.#suppressTurboVisitCount <= 0) return;
|
|
82
|
+
this.#suppressTurboVisitCount -= 1;
|
|
83
|
+
if (this.#suppressTurboVisitCount === 0) clearTimeout(this.#suppressTurboVisitTimer);
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
};
|
|
86
|
+
documentRef.addEventListener?.("turbo:before-visit", this._turboVisitGuard);
|
|
87
|
+
|
|
88
|
+
// Turbo caches a page snapshot before navigating away. If the modal is
|
|
89
|
+
// open at cache time, data-modal-stack-locked is baked into the snapshot.
|
|
90
|
+
// When Turbo later restores or morphs from that snapshot, the attribute
|
|
91
|
+
// is re-applied to body, leaving the page scroll-locked even though no
|
|
92
|
+
// modal is open. Stripping the lock before cache keeps snapshots clean.
|
|
93
|
+
this._turboBeforeCache = () => {
|
|
94
|
+
if (!this.body) return;
|
|
95
|
+
delete this.body.dataset.modalStackLocked;
|
|
96
|
+
const root = this.document?.documentElement;
|
|
97
|
+
if (root) root.style.removeProperty(SCROLLBAR_WIDTH_VAR);
|
|
98
|
+
};
|
|
99
|
+
documentRef.addEventListener?.("turbo:before-cache", this._turboBeforeCache);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Called by the Stimulus controller on disconnect so the global listener
|
|
103
|
+
// is cleaned up if the element leaves the DOM (e.g. full page navigation).
|
|
104
|
+
destroy() {
|
|
105
|
+
this.document?.removeEventListener?.("turbo:before-visit", this._turboVisitGuard);
|
|
106
|
+
this.document?.removeEventListener?.("turbo:before-cache", this._turboBeforeCache);
|
|
60
107
|
}
|
|
61
108
|
|
|
62
109
|
showDialog() {
|
|
@@ -130,6 +177,10 @@ export class BrowserRuntime {
|
|
|
130
177
|
// animateOut below operates on the wrapper, which we'll throw away.
|
|
131
178
|
const oldFrame = this.#findFrame(layer, fromFrameIndex);
|
|
132
179
|
if (oldFrame) {
|
|
180
|
+
// Fire before detach so listeners can still read live form values.
|
|
181
|
+
this.#dispatchFrameEvent("modal_stack:frame-leave", {
|
|
182
|
+
layerId, frameIndex: fromFrameIndex, direction: "forward",
|
|
183
|
+
});
|
|
133
184
|
const cached = this.document.createDocumentFragment();
|
|
134
185
|
cached.append(...oldFrame.childNodes);
|
|
135
186
|
this._frameCache.set(this.#frameKey(layerId, fromFrameIndex), cached);
|
|
@@ -140,6 +191,13 @@ export class BrowserRuntime {
|
|
|
140
191
|
layer.appendChild(newFrame);
|
|
141
192
|
this.#applyFrameDepth(layer, toFrameIndex);
|
|
142
193
|
|
|
194
|
+
// Persist the incoming frame's HTML so it can be restored across page
|
|
195
|
+
// reloads. Frame 0 is always the layer's initial mount (accessible via
|
|
196
|
+
// GET); frames 1+ may be POST-only wizard steps that 404 on direct fetch.
|
|
197
|
+
if (toFrameIndex > 0) {
|
|
198
|
+
this.#persistFrameHtml(layerId, toFrameIndex, newFrame.innerHTML);
|
|
199
|
+
}
|
|
200
|
+
|
|
143
201
|
// Old frame is removed synchronously. Entering frames carry
|
|
144
202
|
// data-transition + data-direction so host CSS can drive the *enter*
|
|
145
203
|
// animation via @starting-style; the leaving frame would need its own
|
|
@@ -147,6 +205,11 @@ export class BrowserRuntime {
|
|
|
147
205
|
// which we leave to the host CSS preset.
|
|
148
206
|
if (oldFrame) oldFrame.remove();
|
|
149
207
|
|
|
208
|
+
// Fire after mount so listeners can read/populate the new frame's DOM.
|
|
209
|
+
this.#dispatchFrameEvent("modal_stack:frame-enter", {
|
|
210
|
+
layerId, frameIndex: toFrameIndex, direction: "forward",
|
|
211
|
+
});
|
|
212
|
+
|
|
150
213
|
// Remove transition attrs once the animation completes so the :has()
|
|
151
214
|
// rule that clips overflow doesn't persist indefinitely.
|
|
152
215
|
if (transition) this.#cleanupFrameTransition(newFrame);
|
|
@@ -156,6 +219,11 @@ export class BrowserRuntime {
|
|
|
156
219
|
const layer = this.#findLayer(layerId);
|
|
157
220
|
if (!layer) return;
|
|
158
221
|
|
|
222
|
+
// Fire before detach so listeners can still read live form values.
|
|
223
|
+
this.#dispatchFrameEvent("modal_stack:frame-leave", {
|
|
224
|
+
layerId, frameIndex: fromFrameIndex, direction: "back",
|
|
225
|
+
});
|
|
226
|
+
|
|
159
227
|
const cacheKey = this.#frameKey(layerId, toFrameIndex);
|
|
160
228
|
let restored = stale ? null : this._frameCache.get(cacheKey) ?? null;
|
|
161
229
|
if (!restored) {
|
|
@@ -181,6 +249,11 @@ export class BrowserRuntime {
|
|
|
181
249
|
const oldFrame = this.#findFrame(layer, fromFrameIndex);
|
|
182
250
|
if (oldFrame) oldFrame.remove();
|
|
183
251
|
|
|
252
|
+
// Fire after mount so listeners can restore data into the returned frame.
|
|
253
|
+
this.#dispatchFrameEvent("modal_stack:frame-enter", {
|
|
254
|
+
layerId, frameIndex: toFrameIndex, direction: "back",
|
|
255
|
+
});
|
|
256
|
+
|
|
184
257
|
if (transition) this.#cleanupFrameTransition(newFrame);
|
|
185
258
|
}
|
|
186
259
|
|
|
@@ -189,6 +262,7 @@ export class BrowserRuntime {
|
|
|
189
262
|
for (const key of [...this._frameCache.keys()]) {
|
|
190
263
|
if (key.startsWith(prefix)) this._frameCache.delete(key);
|
|
191
264
|
}
|
|
265
|
+
this.#removeFrameHtmlForLayer(layerId);
|
|
192
266
|
}
|
|
193
267
|
|
|
194
268
|
async unmountTopLayer() {
|
|
@@ -203,15 +277,25 @@ export class BrowserRuntime {
|
|
|
203
277
|
await Promise.all(layers.map((l) => animateOut(l, timeout)));
|
|
204
278
|
}
|
|
205
279
|
|
|
206
|
-
pushHistory({
|
|
207
|
-
this.history.pushState(historyState, "",
|
|
280
|
+
pushHistory({ historyState }) {
|
|
281
|
+
this.history.pushState(historyState, "", this.location?.href ?? "");
|
|
208
282
|
}
|
|
209
283
|
|
|
210
|
-
replaceHistory({
|
|
211
|
-
this.history.replaceState(historyState, "",
|
|
284
|
+
replaceHistory({ historyState }) {
|
|
285
|
+
this.history.replaceState(historyState, "", this.location?.href ?? "");
|
|
212
286
|
}
|
|
213
287
|
|
|
214
288
|
historyBack({ n }) {
|
|
289
|
+
// Arm the guard before calling history.go so turbo:before-visit (which
|
|
290
|
+
// fires synchronously inside Turbo's popstate handler) can cancel the
|
|
291
|
+
// restoration visit. A safety timeout clears the flag if the popstate
|
|
292
|
+
// never triggers a Turbo visit (i.e. we landed on one of our own phantom
|
|
293
|
+
// entries that lack Turbo's restorationIdentifier).
|
|
294
|
+
this.#suppressTurboVisitCount += 1;
|
|
295
|
+
clearTimeout(this.#suppressTurboVisitTimer);
|
|
296
|
+
this.#suppressTurboVisitTimer = setTimeout(() => {
|
|
297
|
+
this.#suppressTurboVisitCount = 0;
|
|
298
|
+
}, 1000);
|
|
215
299
|
this.history.go(-n);
|
|
216
300
|
}
|
|
217
301
|
|
|
@@ -235,6 +319,7 @@ export class BrowserRuntime {
|
|
|
235
319
|
if (!this.store) return;
|
|
236
320
|
try {
|
|
237
321
|
this.store.removeItem(SNAPSHOT_KEY);
|
|
322
|
+
this.store.removeItem(FRAME_HTML_KEY);
|
|
238
323
|
} catch {
|
|
239
324
|
// ignore
|
|
240
325
|
}
|
|
@@ -249,6 +334,60 @@ export class BrowserRuntime {
|
|
|
249
334
|
}
|
|
250
335
|
}
|
|
251
336
|
|
|
337
|
+
// Preloads _frameCache from sessionStorage so wizard frames saved across
|
|
338
|
+
// reloads can be restored via pathTo without re-fetching their URLs.
|
|
339
|
+
restoreFrameCacheFromStorage() {
|
|
340
|
+
const map = this.#readFrameHtmlMap();
|
|
341
|
+
for (const [key, html] of Object.entries(map)) {
|
|
342
|
+
this._frameCache.set(key, parseFragment(html, this.document));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Returns the cached fragment for a specific frame (used during restoration).
|
|
347
|
+
getFrameFragment(layerId, frameIndex) {
|
|
348
|
+
return this._frameCache.get(this.#frameKey(layerId, frameIndex)) ?? null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#persistFrameHtml(layerId, frameIndex, html) {
|
|
352
|
+
if (!this.store) return;
|
|
353
|
+
try {
|
|
354
|
+
const map = this.#readFrameHtmlMap();
|
|
355
|
+
map[`${layerId}#${frameIndex}`] = html;
|
|
356
|
+
this.store.setItem(FRAME_HTML_KEY, JSON.stringify(map));
|
|
357
|
+
} catch {
|
|
358
|
+
// sessionStorage full or unavailable — best effort
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
#readFrameHtmlMap() {
|
|
363
|
+
try {
|
|
364
|
+
const raw = this.store?.getItem(FRAME_HTML_KEY);
|
|
365
|
+
return raw ? JSON.parse(raw) : {};
|
|
366
|
+
} catch {
|
|
367
|
+
return {};
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
#removeFrameHtmlForLayer(layerId) {
|
|
372
|
+
if (!this.store) return;
|
|
373
|
+
try {
|
|
374
|
+
const map = this.#readFrameHtmlMap();
|
|
375
|
+
const prefix = `${layerId}#`;
|
|
376
|
+
let changed = false;
|
|
377
|
+
for (const key of Object.keys(map)) {
|
|
378
|
+
if (key.startsWith(prefix)) { delete map[key]; changed = true; }
|
|
379
|
+
}
|
|
380
|
+
if (!changed) return;
|
|
381
|
+
if (Object.keys(map).length === 0) {
|
|
382
|
+
this.store.removeItem(FRAME_HTML_KEY);
|
|
383
|
+
} else {
|
|
384
|
+
this.store.setItem(FRAME_HTML_KEY, JSON.stringify(map));
|
|
385
|
+
}
|
|
386
|
+
} catch {
|
|
387
|
+
// ignore
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
252
391
|
// Reads --modal-stack-duration from the dialog's computed style and
|
|
253
392
|
// returns 1.5× that as the safety timeout (in ms). Cached after the
|
|
254
393
|
// first successful read since the variable is host-CSS-defined and
|
|
@@ -293,6 +432,12 @@ export class BrowserRuntime {
|
|
|
293
432
|
return `${layerId}#${frameIndex}`;
|
|
294
433
|
}
|
|
295
434
|
|
|
435
|
+
#dispatchFrameEvent(name, detail) {
|
|
436
|
+
this.dialog.dispatchEvent(
|
|
437
|
+
new CustomEvent(name, { bubbles: true, detail }),
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
296
441
|
#purgeFrameCacheAbove(layerId, frameIndex) {
|
|
297
442
|
const prefix = `${layerId}#`;
|
|
298
443
|
for (const key of [...this._frameCache.keys()]) {
|
|
@@ -97,20 +97,21 @@ describe("snapshot storage", () => {
|
|
|
97
97
|
});
|
|
98
98
|
|
|
99
99
|
describe("history wiring", () => {
|
|
100
|
-
test("pushHistory / replaceHistory
|
|
100
|
+
test("pushHistory / replaceHistory always use current location.href, ignoring the modal url", () => {
|
|
101
101
|
const calls = [];
|
|
102
102
|
const history = {
|
|
103
103
|
pushState: (s, t, u) => calls.push(["push", s, t, u]),
|
|
104
104
|
replaceState: (s, t, u) => calls.push(["replace", s, t, u]),
|
|
105
105
|
go: (n) => calls.push(["go", n]),
|
|
106
106
|
};
|
|
107
|
-
const
|
|
108
|
-
rt
|
|
109
|
-
rt.
|
|
107
|
+
const location = { href: "http://example.com/origin" };
|
|
108
|
+
const rt = new BrowserRuntime(noopRuntimeArgs({ history, location }));
|
|
109
|
+
rt.pushHistory({ url: "/modal-a", historyState: { x: 1 } });
|
|
110
|
+
rt.replaceHistory({ url: "/modal-b", historyState: { y: 2 } });
|
|
110
111
|
rt.historyBack({ n: 3 });
|
|
111
112
|
expect(calls).toEqual([
|
|
112
|
-
["push", { x: 1 }, "", "/
|
|
113
|
-
["replace", { y: 2 }, "", "/
|
|
113
|
+
["push", { x: 1 }, "", "http://example.com/origin"],
|
|
114
|
+
["replace", { y: 2 }, "", "http://example.com/origin"],
|
|
114
115
|
["go", -3],
|
|
115
116
|
]);
|
|
116
117
|
});
|