modal_stack 0.3.0 → 0.4.2
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 +48 -0
- data/README.md +187 -36
- data/app/assets/javascripts/modal_stack.js +693 -73
- data/app/assets/stylesheets/modal_stack/bootstrap.css +113 -3
- data/app/assets/stylesheets/modal_stack/tailwind_v3.css +63 -2
- data/app/assets/stylesheets/modal_stack/tailwind_v4.css +63 -2
- data/app/assets/stylesheets/modal_stack/vanilla.css +121 -3
- data/app/javascript/modal_stack/controllers/modal_stack_back_link_controller.js +32 -0
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +161 -8
- data/app/javascript/modal_stack/install.js +7 -1
- data/app/javascript/modal_stack/orchestrator.js +70 -10
- data/app/javascript/modal_stack/orchestrator.test.js +98 -2
- data/app/javascript/modal_stack/runtime.js +316 -9
- data/app/javascript/modal_stack/runtime.test.js +90 -6
- data/app/javascript/modal_stack/state.js +343 -45
- data/app/javascript/modal_stack/state.test.js +404 -17
- data/app/views/modal_stack/_dialog.html.erb +1 -0
- data/app/views/modal_stack/_panel.html.erb +4 -0
- data/lib/generators/modal_stack/install/templates/initializer.rb +9 -0
- data/lib/generators/modal_stack/views/templates/_dialog.html.erb +9 -0
- data/lib/generators/modal_stack/views/templates/_panel.html.erb +18 -0
- data/lib/generators/modal_stack/views/views_generator.rb +50 -0
- data/lib/modal_stack/capybara.rb +21 -0
- data/lib/modal_stack/configuration.rb +37 -16
- data/lib/modal_stack/engine.rb +2 -0
- data/lib/modal_stack/helpers/modal_back_link_helper.rb +48 -0
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +7 -1
- data/lib/modal_stack/helpers/modal_stack_container_helper.rb +42 -15
- data/lib/modal_stack/turbo_streams_extension.rb +56 -0
- data/lib/modal_stack/version.rb +1 -1
- data/lib/modal_stack.rb +5 -1
- metadata +9 -2
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
export const SNAPSHOT_KEY = "modalStackSnapshot";
|
|
2
|
+
export const FRAME_HTML_KEY = "modalStackFrameHtml";
|
|
2
3
|
export const FRAGMENT_HEADER = "X-Modal-Stack-Request";
|
|
4
|
+
// Server response header that flags the just-rendered frame as stale —
|
|
5
|
+
// the runtime will refetch instead of restoring from the in-memory cache
|
|
6
|
+
// when the user steps back to it.
|
|
7
|
+
export const STALE_HEADER = "X-Modal-Stack-Stale";
|
|
3
8
|
export const SCROLLBAR_WIDTH_VAR = "--modal-stack-scrollbar-width";
|
|
4
9
|
|
|
5
10
|
const LAYER_SELECTOR = '[data-modal-stack-target="layer"]';
|
|
11
|
+
const FRAME_SELECTOR = "[data-modal-stack-frame]";
|
|
6
12
|
// CSS variable host stylesheets set to declare their leave-transition
|
|
7
13
|
// duration (e.g. "220ms"). When present, the runtime sizes its safety
|
|
8
14
|
// timeout from this value; otherwise it falls back to a conservative cap.
|
|
@@ -22,11 +28,17 @@ const LEAVE_TIMEOUT_FALLBACK_MS = 600;
|
|
|
22
28
|
* `orchestrator.test.js` for an in-memory fake).
|
|
23
29
|
*/
|
|
24
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
|
+
|
|
25
36
|
/**
|
|
26
37
|
* @param {Object} options
|
|
27
38
|
* @param {HTMLDialogElement} options.dialog
|
|
28
39
|
* @param {HTMLElement} [options.body]
|
|
29
40
|
* @param {History} [options.history]
|
|
41
|
+
* @param {Location} [options.location]
|
|
30
42
|
* @param {typeof fetch} [options.fetcher]
|
|
31
43
|
* @param {Storage} [options.store]
|
|
32
44
|
* @param {Document} [options.documentRef]
|
|
@@ -35,6 +47,7 @@ export class BrowserRuntime {
|
|
|
35
47
|
dialog,
|
|
36
48
|
body = globalThis.document?.body,
|
|
37
49
|
history = globalThis.history,
|
|
50
|
+
location = globalThis.location,
|
|
38
51
|
fetcher = globalThis.fetch?.bind(globalThis),
|
|
39
52
|
store = globalThis.sessionStorage,
|
|
40
53
|
documentRef = globalThis.document,
|
|
@@ -45,9 +58,52 @@ export class BrowserRuntime {
|
|
|
45
58
|
this.dialog = dialog;
|
|
46
59
|
this.body = body;
|
|
47
60
|
this.history = history;
|
|
61
|
+
this.location = location;
|
|
48
62
|
this.fetcher = fetcher;
|
|
49
63
|
this.store = store;
|
|
50
64
|
this.document = documentRef;
|
|
65
|
+
// Cached DocumentFragments for path frames, keyed by `${layerId}#${frameIndex}`.
|
|
66
|
+
// Populated by mountFrame on the way forward, drained by unmountFrame on
|
|
67
|
+
// the way back, purged on layer teardown via clearFrameCache.
|
|
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);
|
|
51
107
|
}
|
|
52
108
|
|
|
53
109
|
showDialog() {
|
|
@@ -93,7 +149,10 @@ export class BrowserRuntime {
|
|
|
93
149
|
const frag = await this.#resolveFragment({ url, html, fragment });
|
|
94
150
|
const layer = this.document.createElement("div");
|
|
95
151
|
this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
|
|
96
|
-
layer
|
|
152
|
+
this.#applyFrameDepth(layer, 0);
|
|
153
|
+
const wrapper = this.#createFrameWrapper({ frameIndex: 0 });
|
|
154
|
+
wrapper.append(...frag.childNodes);
|
|
155
|
+
layer.appendChild(wrapper);
|
|
97
156
|
this.dialog.appendChild(layer);
|
|
98
157
|
}
|
|
99
158
|
|
|
@@ -102,7 +161,108 @@ export class BrowserRuntime {
|
|
|
102
161
|
const layer = this.#topLayer();
|
|
103
162
|
if (!layer) return;
|
|
104
163
|
this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
|
|
105
|
-
layer
|
|
164
|
+
this.#applyFrameDepth(layer, 0);
|
|
165
|
+
const wrapper = this.#createFrameWrapper({ frameIndex: 0 });
|
|
166
|
+
wrapper.append(...frag.childNodes);
|
|
167
|
+
layer.replaceChildren(wrapper);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async mountFrame({ layerId, fromFrameIndex, toFrameIndex, url, html, fragment, transition }) {
|
|
171
|
+
const layer = this.#findLayer(layerId);
|
|
172
|
+
if (!layer) return;
|
|
173
|
+
const frag = await this.#resolveFragment({ url, html, fragment });
|
|
174
|
+
|
|
175
|
+
// Snapshot the outgoing frame's children for back navigation. We cache
|
|
176
|
+
// the original nodes (not clones) and detach them from the DOM — the
|
|
177
|
+
// animateOut below operates on the wrapper, which we'll throw away.
|
|
178
|
+
const oldFrame = this.#findFrame(layer, fromFrameIndex);
|
|
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
|
+
});
|
|
184
|
+
const cached = this.document.createDocumentFragment();
|
|
185
|
+
cached.append(...oldFrame.childNodes);
|
|
186
|
+
this._frameCache.set(this.#frameKey(layerId, fromFrameIndex), cached);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const newFrame = this.#createFrameWrapper({ frameIndex: toFrameIndex, transition, direction: "forward" });
|
|
190
|
+
newFrame.append(...frag.childNodes);
|
|
191
|
+
layer.appendChild(newFrame);
|
|
192
|
+
this.#applyFrameDepth(layer, toFrameIndex);
|
|
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
|
+
|
|
201
|
+
// Old frame is removed synchronously. Entering frames carry
|
|
202
|
+
// data-transition + data-direction so host CSS can drive the *enter*
|
|
203
|
+
// animation via @starting-style; the leaving frame would need its own
|
|
204
|
+
// overlapping layout (e.g. position: absolute) to also animate out,
|
|
205
|
+
// which we leave to the host CSS preset.
|
|
206
|
+
if (oldFrame) oldFrame.remove();
|
|
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
|
+
|
|
213
|
+
// Remove transition attrs once the animation completes so the :has()
|
|
214
|
+
// rule that clips overflow doesn't persist indefinitely.
|
|
215
|
+
if (transition) this.#cleanupFrameTransition(newFrame);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async unmountFrame({ layerId, fromFrameIndex, toFrameIndex, url, stale, transition }) {
|
|
219
|
+
const layer = this.#findLayer(layerId);
|
|
220
|
+
if (!layer) return;
|
|
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
|
+
|
|
227
|
+
const cacheKey = this.#frameKey(layerId, toFrameIndex);
|
|
228
|
+
let restored = stale ? null : this._frameCache.get(cacheKey) ?? null;
|
|
229
|
+
if (!restored) {
|
|
230
|
+
const result = await this.fetchFragment(url);
|
|
231
|
+
restored = result.fragment;
|
|
232
|
+
this._frameCache.set(cacheKey, cloneFragment(restored, this.document));
|
|
233
|
+
} else {
|
|
234
|
+
// Clone so the cache entry remains usable if we step back here again
|
|
235
|
+
// after another forward.
|
|
236
|
+
restored = cloneFragment(restored, this.document);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const newFrame = this.#createFrameWrapper({ frameIndex: toFrameIndex, transition, direction: "back" });
|
|
240
|
+
newFrame.append(...restored.childNodes);
|
|
241
|
+
layer.appendChild(newFrame);
|
|
242
|
+
this.#applyFrameDepth(layer, toFrameIndex);
|
|
243
|
+
|
|
244
|
+
// Drop cache entries for frames that are now gone (anything past the
|
|
245
|
+
// restored index — we only keep entries for frames still tracked in
|
|
246
|
+
// state).
|
|
247
|
+
this.#purgeFrameCacheAbove(layerId, toFrameIndex);
|
|
248
|
+
|
|
249
|
+
const oldFrame = this.#findFrame(layer, fromFrameIndex);
|
|
250
|
+
if (oldFrame) oldFrame.remove();
|
|
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
|
+
|
|
257
|
+
if (transition) this.#cleanupFrameTransition(newFrame);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
clearFrameCache({ layerId }) {
|
|
261
|
+
const prefix = `${layerId}#`;
|
|
262
|
+
for (const key of [...this._frameCache.keys()]) {
|
|
263
|
+
if (key.startsWith(prefix)) this._frameCache.delete(key);
|
|
264
|
+
}
|
|
265
|
+
this.#removeFrameHtmlForLayer(layerId);
|
|
106
266
|
}
|
|
107
267
|
|
|
108
268
|
async unmountTopLayer() {
|
|
@@ -117,15 +277,25 @@ export class BrowserRuntime {
|
|
|
117
277
|
await Promise.all(layers.map((l) => animateOut(l, timeout)));
|
|
118
278
|
}
|
|
119
279
|
|
|
120
|
-
pushHistory({
|
|
121
|
-
this.history.pushState(historyState, "",
|
|
280
|
+
pushHistory({ historyState }) {
|
|
281
|
+
this.history.pushState(historyState, "", this.location?.href ?? "");
|
|
122
282
|
}
|
|
123
283
|
|
|
124
|
-
replaceHistory({
|
|
125
|
-
this.history.replaceState(historyState, "",
|
|
284
|
+
replaceHistory({ historyState }) {
|
|
285
|
+
this.history.replaceState(historyState, "", this.location?.href ?? "");
|
|
126
286
|
}
|
|
127
287
|
|
|
128
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);
|
|
129
299
|
this.history.go(-n);
|
|
130
300
|
}
|
|
131
301
|
|
|
@@ -149,6 +319,7 @@ export class BrowserRuntime {
|
|
|
149
319
|
if (!this.store) return;
|
|
150
320
|
try {
|
|
151
321
|
this.store.removeItem(SNAPSHOT_KEY);
|
|
322
|
+
this.store.removeItem(FRAME_HTML_KEY);
|
|
152
323
|
} catch {
|
|
153
324
|
// ignore
|
|
154
325
|
}
|
|
@@ -163,6 +334,60 @@ export class BrowserRuntime {
|
|
|
163
334
|
}
|
|
164
335
|
}
|
|
165
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
|
+
|
|
166
391
|
// Reads --modal-stack-duration from the dialog's computed style and
|
|
167
392
|
// returns 1.5× that as the safety timeout (in ms). Cached after the
|
|
168
393
|
// first successful read since the variable is host-CSS-defined and
|
|
@@ -197,6 +422,63 @@ export class BrowserRuntime {
|
|
|
197
422
|
);
|
|
198
423
|
}
|
|
199
424
|
|
|
425
|
+
#findFrame(layer, frameIndex) {
|
|
426
|
+
return layer.querySelector(
|
|
427
|
+
`${FRAME_SELECTOR}[data-frame-index="${escapeAttr(String(frameIndex))}"]`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
#frameKey(layerId, frameIndex) {
|
|
432
|
+
return `${layerId}#${frameIndex}`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
#dispatchFrameEvent(name, detail) {
|
|
436
|
+
this.dialog.dispatchEvent(
|
|
437
|
+
new CustomEvent(name, { bubbles: true, detail }),
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
#purgeFrameCacheAbove(layerId, frameIndex) {
|
|
442
|
+
const prefix = `${layerId}#`;
|
|
443
|
+
for (const key of [...this._frameCache.keys()]) {
|
|
444
|
+
if (!key.startsWith(prefix)) continue;
|
|
445
|
+
const idx = Number(key.slice(prefix.length));
|
|
446
|
+
if (Number.isFinite(idx) && idx > frameIndex) {
|
|
447
|
+
this._frameCache.delete(key);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Removes [data-transition] and [data-direction] from a frame once its
|
|
453
|
+
// enter animation ends. This restores overflow-y:auto on the layer (the
|
|
454
|
+
// :has([data-transition]) rule in the CSS preset keeps overflow:hidden
|
|
455
|
+
// while the animation runs to clip off-screen slide frames).
|
|
456
|
+
#cleanupFrameTransition(frameEl) {
|
|
457
|
+
let done = false;
|
|
458
|
+
const cleanup = () => {
|
|
459
|
+
if (done) return;
|
|
460
|
+
done = true;
|
|
461
|
+
frameEl.removeAttribute("data-transition");
|
|
462
|
+
frameEl.removeAttribute("data-direction");
|
|
463
|
+
};
|
|
464
|
+
frameEl.addEventListener("transitionend", cleanup, { once: true });
|
|
465
|
+
setTimeout(cleanup, this.#leaveTimeoutMs());
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
#createFrameWrapper({ frameIndex, transition = null, direction = null }) {
|
|
469
|
+
const el = this.document.createElement("div");
|
|
470
|
+
el.dataset.modalStackFrame = "";
|
|
471
|
+
el.dataset.frameIndex = String(frameIndex);
|
|
472
|
+
if (transition) el.dataset.transition = transition;
|
|
473
|
+
if (direction) el.dataset.direction = direction;
|
|
474
|
+
return el;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
#applyFrameDepth(layer, topFrameIndex) {
|
|
478
|
+
layer.dataset.frameIndex = String(topFrameIndex);
|
|
479
|
+
layer.dataset.frameDepth = String(topFrameIndex + 1);
|
|
480
|
+
}
|
|
481
|
+
|
|
200
482
|
#topLayer() {
|
|
201
483
|
const layers = this.dialog.querySelectorAll(LAYER_SELECTOR);
|
|
202
484
|
return layers[layers.length - 1] ?? null;
|
|
@@ -241,13 +523,15 @@ export class BrowserRuntime {
|
|
|
241
523
|
throw new Error(`modal_stack: fetch ${url} → ${resp.status}`);
|
|
242
524
|
}
|
|
243
525
|
const html = await resp.text();
|
|
244
|
-
|
|
526
|
+
const stale = parseStaleHeader(resp);
|
|
527
|
+
return { fragment: parseFragment(html, this.document), stale };
|
|
245
528
|
}
|
|
246
529
|
|
|
247
530
|
async #resolveFragment({ url, html, fragment }) {
|
|
248
531
|
if (fragment) return fragment;
|
|
249
532
|
if (html != null) return parseFragment(html, this.document);
|
|
250
|
-
|
|
533
|
+
const result = await this.fetchFragment(url);
|
|
534
|
+
return result.fragment;
|
|
251
535
|
}
|
|
252
536
|
}
|
|
253
537
|
|
|
@@ -259,6 +543,28 @@ function parseFragment(html, doc) {
|
|
|
259
543
|
return fragment;
|
|
260
544
|
}
|
|
261
545
|
|
|
546
|
+
function parseStaleHeader(resp) {
|
|
547
|
+
const headers = resp?.headers;
|
|
548
|
+
const value =
|
|
549
|
+
typeof headers?.get === "function"
|
|
550
|
+
? headers.get(STALE_HEADER) ?? headers.get(STALE_HEADER.toLowerCase())
|
|
551
|
+
: null;
|
|
552
|
+
if (!value) return false;
|
|
553
|
+
const normalized = String(value).trim().toLowerCase();
|
|
554
|
+
return normalized === "true" || normalized === "1";
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function cloneFragment(fragment, doc) {
|
|
558
|
+
if (typeof fragment?.cloneNode === "function") {
|
|
559
|
+
return fragment.cloneNode(true);
|
|
560
|
+
}
|
|
561
|
+
const clone = doc.createDocumentFragment();
|
|
562
|
+
if (fragment?.childNodes) {
|
|
563
|
+
for (const node of fragment.childNodes) clone.appendChild(node.cloneNode(true));
|
|
564
|
+
}
|
|
565
|
+
return clone;
|
|
566
|
+
}
|
|
567
|
+
|
|
262
568
|
// Marks the layer with [data-leaving] so the host CSS can transition it
|
|
263
569
|
// out, then awaits transitionend (with a hard timeout) before removing
|
|
264
570
|
// the element from the DOM. If the host CSS doesn't define an exit
|
|
@@ -294,5 +600,6 @@ function escapeAttr(value) {
|
|
|
294
600
|
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
295
601
|
return CSS.escape(value);
|
|
296
602
|
}
|
|
297
|
-
|
|
603
|
+
// Fallback: escape chars that break CSS attribute selectors ([attr="val"])
|
|
604
|
+
return String(value).replace(/["\\[\]]/g, "\\$&");
|
|
298
605
|
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
FRAGMENT_HEADER,
|
|
5
5
|
SCROLLBAR_WIDTH_VAR,
|
|
6
6
|
SNAPSHOT_KEY,
|
|
7
|
+
STALE_HEADER,
|
|
7
8
|
} from "./runtime.js";
|
|
8
9
|
|
|
9
10
|
function fakeStore() {
|
|
@@ -96,20 +97,21 @@ describe("snapshot storage", () => {
|
|
|
96
97
|
});
|
|
97
98
|
|
|
98
99
|
describe("history wiring", () => {
|
|
99
|
-
test("pushHistory / replaceHistory
|
|
100
|
+
test("pushHistory / replaceHistory always use current location.href, ignoring the modal url", () => {
|
|
100
101
|
const calls = [];
|
|
101
102
|
const history = {
|
|
102
103
|
pushState: (s, t, u) => calls.push(["push", s, t, u]),
|
|
103
104
|
replaceState: (s, t, u) => calls.push(["replace", s, t, u]),
|
|
104
105
|
go: (n) => calls.push(["go", n]),
|
|
105
106
|
};
|
|
106
|
-
const
|
|
107
|
-
rt
|
|
108
|
-
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 } });
|
|
109
111
|
rt.historyBack({ n: 3 });
|
|
110
112
|
expect(calls).toEqual([
|
|
111
|
-
["push", { x: 1 }, "", "/
|
|
112
|
-
["replace", { y: 2 }, "", "/
|
|
113
|
+
["push", { x: 1 }, "", "http://example.com/origin"],
|
|
114
|
+
["replace", { y: 2 }, "", "http://example.com/origin"],
|
|
113
115
|
["go", -3],
|
|
114
116
|
]);
|
|
115
117
|
});
|
|
@@ -268,4 +270,86 @@ describe("fetch headers", () => {
|
|
|
268
270
|
expect(captured.opts.headers.Accept).toContain("text/html");
|
|
269
271
|
expect(captured.opts.credentials).toBe("same-origin");
|
|
270
272
|
});
|
|
273
|
+
|
|
274
|
+
test("fetchFragment returns { fragment, stale } from response header", async () => {
|
|
275
|
+
withFakeDOMParser(async () => {
|
|
276
|
+
const fetcher = () =>
|
|
277
|
+
Promise.resolve(
|
|
278
|
+
new Response("<p>frame</p>", {
|
|
279
|
+
status: 200,
|
|
280
|
+
headers: { [STALE_HEADER]: "true" },
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
const documentRef = fakeDocument();
|
|
284
|
+
const rt = new BrowserRuntime(noopRuntimeArgs({ fetcher, documentRef }));
|
|
285
|
+
const result = await rt.fetchFragment("/x");
|
|
286
|
+
expect(result.stale).toBe(true);
|
|
287
|
+
expect(result.fragment).toBeTruthy();
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("fetchFragment.stale is false when header is missing or '0'", async () => {
|
|
292
|
+
await withFakeDOMParser(async () => {
|
|
293
|
+
const cases = [
|
|
294
|
+
new Response("", { status: 200 }),
|
|
295
|
+
new Response("", { status: 200, headers: { [STALE_HEADER]: "0" } }),
|
|
296
|
+
new Response("", { status: 200, headers: { [STALE_HEADER]: "false" } }),
|
|
297
|
+
];
|
|
298
|
+
for (const resp of cases) {
|
|
299
|
+
const documentRef = fakeDocument();
|
|
300
|
+
const rt = new BrowserRuntime(
|
|
301
|
+
noopRuntimeArgs({ fetcher: () => Promise.resolve(resp), documentRef }),
|
|
302
|
+
);
|
|
303
|
+
const result = await rt.fetchFragment("/x");
|
|
304
|
+
expect(result.stale).toBe(false);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
function fakeDocument() {
|
|
311
|
+
return {
|
|
312
|
+
createDocumentFragment: () => {
|
|
313
|
+
const children = [];
|
|
314
|
+
return {
|
|
315
|
+
childNodes: children,
|
|
316
|
+
append: (...nodes) => children.push(...nodes),
|
|
317
|
+
appendChild: (n) => children.push(n),
|
|
318
|
+
};
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function withFakeDOMParser(fn) {
|
|
324
|
+
const original = globalThis.DOMParser;
|
|
325
|
+
globalThis.DOMParser = class {
|
|
326
|
+
parseFromString() {
|
|
327
|
+
return { body: { childNodes: [] } };
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
try {
|
|
331
|
+
return await fn();
|
|
332
|
+
} finally {
|
|
333
|
+
if (original) globalThis.DOMParser = original;
|
|
334
|
+
else delete globalThis.DOMParser;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
describe("frame cache", () => {
|
|
339
|
+
test("clearFrameCache removes only entries for the given layerId", () => {
|
|
340
|
+
const rt = new BrowserRuntime(noopRuntimeArgs());
|
|
341
|
+
rt._frameCache.set("L1#0", "a");
|
|
342
|
+
rt._frameCache.set("L1#1", "b");
|
|
343
|
+
rt._frameCache.set("L2#0", "c");
|
|
344
|
+
rt._frameCache.set("L11#0", "d"); // verify prefix is anchored on `#`
|
|
345
|
+
rt.clearFrameCache({ layerId: "L1" });
|
|
346
|
+
expect([...rt._frameCache.keys()].sort()).toEqual(["L11#0", "L2#0"]);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("clearFrameCache is a no-op for unknown layerId", () => {
|
|
350
|
+
const rt = new BrowserRuntime(noopRuntimeArgs());
|
|
351
|
+
rt._frameCache.set("L1#0", "a");
|
|
352
|
+
rt.clearFrameCache({ layerId: "Lz" });
|
|
353
|
+
expect([...rt._frameCache.keys()]).toEqual(["L1#0"]);
|
|
354
|
+
});
|
|
271
355
|
});
|