modal_stack 0.3.0 → 0.4.1
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 +33 -0
- data/README.md +113 -32
- data/app/assets/javascripts/modal_stack.js +488 -50
- 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 +50 -0
- data/app/javascript/modal_stack/install.js +7 -1
- data/app/javascript/modal_stack/orchestrator.js +53 -7
- data/app/javascript/modal_stack/orchestrator.test.js +96 -0
- data/app/javascript/modal_stack/runtime.js +167 -5
- data/app/javascript/modal_stack/runtime.test.js +83 -0
- data/app/javascript/modal_stack/state.js +319 -34
- data/app/javascript/modal_stack/state.test.js +394 -9
- 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 +1 -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
|
@@ -2,6 +2,8 @@ import {
|
|
|
2
2
|
closeAll,
|
|
3
3
|
createStack,
|
|
4
4
|
handlePopstate,
|
|
5
|
+
pathBack,
|
|
6
|
+
pathTo,
|
|
5
7
|
pop,
|
|
6
8
|
push,
|
|
7
9
|
replaceTop,
|
|
@@ -104,25 +106,65 @@ export class Orchestrator {
|
|
|
104
106
|
return this.#dispatch(replaceTop(this.state, patch, opts), { html, fragment });
|
|
105
107
|
}
|
|
106
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Append a frame to the top layer's path.
|
|
111
|
+
* @param {{ url: string, stale?: boolean }} frame
|
|
112
|
+
* @param {{ html?: string|null, fragment?: DocumentFragment|null, transition?: string|null }} [options]
|
|
113
|
+
*/
|
|
114
|
+
async pathTo(frame, { html = null, fragment = null, transition = null } = {}) {
|
|
115
|
+
let resolvedStale = frame?.stale === true;
|
|
116
|
+
if (fragment == null && html == null && frame?.url) {
|
|
117
|
+
const meta = await this.#prefetchWithMeta(frame.url);
|
|
118
|
+
fragment = meta.fragment;
|
|
119
|
+
// The caller's explicit `stale: true` always wins; if they didn't say,
|
|
120
|
+
// honor the X-Modal-Stack-Stale response header surfaced by the runtime.
|
|
121
|
+
if (frame.stale !== true && meta.stale === true) resolvedStale = true;
|
|
122
|
+
}
|
|
123
|
+
return this.#dispatch(
|
|
124
|
+
pathTo(this.state, { url: frame.url, stale: resolvedStale }, { transition }),
|
|
125
|
+
{ html, fragment },
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Step back through frames in the top layer's path.
|
|
131
|
+
* @param {{ steps?: number, transition?: string|null }} [options]
|
|
132
|
+
*/
|
|
133
|
+
pathBack({ steps = 1, transition = null } = {}) {
|
|
134
|
+
return this.#dispatch(pathBack(this.state, { steps, transition }));
|
|
135
|
+
}
|
|
136
|
+
|
|
107
137
|
async #prefetch(url) {
|
|
108
|
-
|
|
138
|
+
const meta = await this.#prefetchWithMeta(url);
|
|
139
|
+
return meta.fragment;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async #prefetchWithMeta(url) {
|
|
143
|
+
if (typeof this.runtime.fetchFragment !== "function") {
|
|
144
|
+
return { fragment: null, stale: false };
|
|
145
|
+
}
|
|
109
146
|
|
|
110
147
|
const cached = this.#fragmentCache.get(url);
|
|
111
148
|
if (cached && Date.now() - cached.ts < this.prefetchTtlMs) {
|
|
112
|
-
return cloneFragment(cached.fragment);
|
|
149
|
+
return { fragment: cloneFragment(cached.fragment), stale: cached.stale === true };
|
|
113
150
|
}
|
|
114
151
|
|
|
115
152
|
const existing = this.#inflight.get(url);
|
|
116
153
|
if (existing) {
|
|
117
154
|
const entry = await existing.promise;
|
|
118
|
-
return cloneFragment(entry.fragment);
|
|
155
|
+
return { fragment: cloneFragment(entry.fragment), stale: entry.stale === true };
|
|
119
156
|
}
|
|
120
157
|
|
|
121
158
|
const controller = supportsAbort() ? new AbortController() : null;
|
|
122
159
|
const fetchPromise = this.runtime
|
|
123
160
|
.fetchFragment(url, controller ? { signal: controller.signal } : undefined)
|
|
124
|
-
.then((
|
|
125
|
-
|
|
161
|
+
.then((result) => {
|
|
162
|
+
// BrowserRuntime returns { fragment, stale }; older test fakes
|
|
163
|
+
// (and prior behavior) returned a bare DocumentFragment — accept
|
|
164
|
+
// both so we don't lock the runtime contract too tightly.
|
|
165
|
+
const fragment = result?.fragment ?? result;
|
|
166
|
+
const stale = result?.stale === true;
|
|
167
|
+
const entry = { fragment, stale, ts: Date.now() };
|
|
126
168
|
this.#fragmentCache.set(url, entry);
|
|
127
169
|
return entry;
|
|
128
170
|
})
|
|
@@ -132,7 +174,7 @@ export class Orchestrator {
|
|
|
132
174
|
|
|
133
175
|
this.#inflight.set(url, { controller, promise: fetchPromise });
|
|
134
176
|
const entry = await fetchPromise;
|
|
135
|
-
return cloneFragment(entry.fragment);
|
|
177
|
+
return { fragment: cloneFragment(entry.fragment), stale: entry.stale === true };
|
|
136
178
|
}
|
|
137
179
|
|
|
138
180
|
// Aborts every in-flight prefetch and forgets any cached fragments.
|
|
@@ -183,7 +225,11 @@ export class Orchestrator {
|
|
|
183
225
|
async #dispatch({ state, commands }, payload = {}) {
|
|
184
226
|
this.state = state;
|
|
185
227
|
for (const cmd of commands) {
|
|
186
|
-
if (
|
|
228
|
+
if (
|
|
229
|
+
cmd.type === "mountLayer" ||
|
|
230
|
+
cmd.type === "morphTopLayer" ||
|
|
231
|
+
cmd.type === "mountFrame"
|
|
232
|
+
) {
|
|
187
233
|
if (payload.html != null) cmd.html = payload.html;
|
|
188
234
|
if (payload.fragment != null) cmd.fragment = payload.fragment;
|
|
189
235
|
}
|
|
@@ -23,6 +23,9 @@ function recordingRuntime() {
|
|
|
23
23
|
"rebuildFromSnapshot",
|
|
24
24
|
"persistSnapshot",
|
|
25
25
|
"clearSnapshot",
|
|
26
|
+
"mountFrame",
|
|
27
|
+
"unmountFrame",
|
|
28
|
+
"clearFrameCache",
|
|
26
29
|
];
|
|
27
30
|
const runtime = { _calls: calls };
|
|
28
31
|
for (const name of handlerNames) {
|
|
@@ -198,6 +201,93 @@ describe("replaceTop", () => {
|
|
|
198
201
|
});
|
|
199
202
|
});
|
|
200
203
|
|
|
204
|
+
describe("pathTo", () => {
|
|
205
|
+
test("appends a frame, dispatches mountFrame + pushHistory + persistSnapshot", async () => {
|
|
206
|
+
await orchestrator.push({ id: "L1", url: "/wizard/1" });
|
|
207
|
+
runtime._calls.length = 0;
|
|
208
|
+
|
|
209
|
+
await orchestrator.pathTo({ url: "/wizard/2" });
|
|
210
|
+
expect(orchestrator.layers[0].frames).toHaveLength(2);
|
|
211
|
+
const types = runtime._calls.map((c) => c.type);
|
|
212
|
+
expect(types).toEqual([
|
|
213
|
+
"mountFrame",
|
|
214
|
+
"pushHistory",
|
|
215
|
+
"persistSnapshot",
|
|
216
|
+
]);
|
|
217
|
+
const mount = runtime._calls.find((c) => c.type === "mountFrame");
|
|
218
|
+
expect(mount).toMatchObject({
|
|
219
|
+
layerId: "L1",
|
|
220
|
+
fromFrameIndex: 0,
|
|
221
|
+
toFrameIndex: 1,
|
|
222
|
+
url: "/wizard/2",
|
|
223
|
+
stale: false,
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("explicit stale: true wins over the response header", async () => {
|
|
228
|
+
await orchestrator.push({ id: "L1", url: "/wizard/1" });
|
|
229
|
+
runtime._calls.length = 0;
|
|
230
|
+
|
|
231
|
+
await orchestrator.pathTo({ url: "/wizard/2", stale: true });
|
|
232
|
+
const mount = runtime._calls.find((c) => c.type === "mountFrame");
|
|
233
|
+
expect(mount.stale).toBe(true);
|
|
234
|
+
expect(orchestrator.layers[0].frames[1].stale).toBe(true);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("threads transition through to the runtime", async () => {
|
|
238
|
+
await orchestrator.push({ id: "L1", url: "/wizard/1" });
|
|
239
|
+
runtime._calls.length = 0;
|
|
240
|
+
await orchestrator.pathTo({ url: "/wizard/2" }, { transition: "fade" });
|
|
241
|
+
expect(runtime._calls.find((c) => c.type === "mountFrame")).toMatchObject({
|
|
242
|
+
transition: "fade",
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("pathBack", () => {
|
|
248
|
+
test("on a single-frame layer is a noop (does not close the layer)", async () => {
|
|
249
|
+
await orchestrator.push({ id: "L1", url: "/wizard/1" });
|
|
250
|
+
runtime._calls.length = 0;
|
|
251
|
+
await orchestrator.pathBack();
|
|
252
|
+
expect(orchestrator.layers[0].frames).toHaveLength(1);
|
|
253
|
+
expect(runtime._calls).toEqual([]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("steps back through frames and walks history", async () => {
|
|
257
|
+
await orchestrator.push({ id: "L1", url: "/wizard/1" });
|
|
258
|
+
await orchestrator.pathTo({ url: "/wizard/2" });
|
|
259
|
+
await orchestrator.pathTo({ url: "/wizard/3" });
|
|
260
|
+
runtime._calls.length = 0;
|
|
261
|
+
|
|
262
|
+
await orchestrator.pathBack({ steps: 2 });
|
|
263
|
+
expect(orchestrator.layers[0].frames).toHaveLength(1);
|
|
264
|
+
const types = runtime._calls.map((c) => c.type);
|
|
265
|
+
expect(types).toEqual([
|
|
266
|
+
"unmountFrame",
|
|
267
|
+
"historyBack",
|
|
268
|
+
"persistSnapshot",
|
|
269
|
+
]);
|
|
270
|
+
expect(runtime._calls.find((c) => c.type === "historyBack")).toEqual({
|
|
271
|
+
type: "historyBack",
|
|
272
|
+
n: 2,
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("guards the popstate that history.go triggers", async () => {
|
|
277
|
+
await orchestrator.push({ id: "L1", url: "/wizard/1" });
|
|
278
|
+
await orchestrator.pathTo({ url: "/wizard/2" });
|
|
279
|
+
runtime._calls.length = 0;
|
|
280
|
+
|
|
281
|
+
await orchestrator.pathBack();
|
|
282
|
+
runtime._calls.length = 0;
|
|
283
|
+
await orchestrator.onPopstate({
|
|
284
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
|
|
285
|
+
locationHref: "/wizard/1",
|
|
286
|
+
});
|
|
287
|
+
expect(runtime._calls).toEqual([]);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
201
291
|
describe("closeAll", () => {
|
|
202
292
|
test("clears layers and increments guard once for the historyBack call", async () => {
|
|
203
293
|
await orchestrator.push({ id: "L1", url: "/x" });
|
|
@@ -210,6 +300,8 @@ describe("closeAll", () => {
|
|
|
210
300
|
expect(types).toEqual([
|
|
211
301
|
"closeDialog",
|
|
212
302
|
"unmountAllLayers",
|
|
303
|
+
"clearFrameCache",
|
|
304
|
+
"clearFrameCache",
|
|
213
305
|
"unlockScroll",
|
|
214
306
|
"historyBack",
|
|
215
307
|
"clearSnapshot",
|
|
@@ -256,6 +348,9 @@ describe("prefetch cache + abort", () => {
|
|
|
256
348
|
"rebuildFromSnapshot",
|
|
257
349
|
"persistSnapshot",
|
|
258
350
|
"clearSnapshot",
|
|
351
|
+
"mountFrame",
|
|
352
|
+
"unmountFrame",
|
|
353
|
+
"clearFrameCache",
|
|
259
354
|
];
|
|
260
355
|
const runtime = { _calls: calls, _fetches: [], _aborts: aborts };
|
|
261
356
|
for (const name of handlerNames) {
|
|
@@ -467,6 +562,7 @@ describe("onPopstate", () => {
|
|
|
467
562
|
expect(types).toEqual([
|
|
468
563
|
"closeDialog",
|
|
469
564
|
"unmountAllLayers",
|
|
565
|
+
"clearFrameCache",
|
|
470
566
|
"unlockScroll",
|
|
471
567
|
"clearSnapshot",
|
|
472
568
|
]);
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
export const SNAPSHOT_KEY = "modalStackSnapshot";
|
|
2
2
|
export const FRAGMENT_HEADER = "X-Modal-Stack-Request";
|
|
3
|
+
// Server response header that flags the just-rendered frame as stale —
|
|
4
|
+
// the runtime will refetch instead of restoring from the in-memory cache
|
|
5
|
+
// when the user steps back to it.
|
|
6
|
+
export const STALE_HEADER = "X-Modal-Stack-Stale";
|
|
3
7
|
export const SCROLLBAR_WIDTH_VAR = "--modal-stack-scrollbar-width";
|
|
4
8
|
|
|
5
9
|
const LAYER_SELECTOR = '[data-modal-stack-target="layer"]';
|
|
10
|
+
const FRAME_SELECTOR = "[data-modal-stack-frame]";
|
|
6
11
|
// CSS variable host stylesheets set to declare their leave-transition
|
|
7
12
|
// duration (e.g. "220ms"). When present, the runtime sizes its safety
|
|
8
13
|
// timeout from this value; otherwise it falls back to a conservative cap.
|
|
@@ -48,6 +53,10 @@ export class BrowserRuntime {
|
|
|
48
53
|
this.fetcher = fetcher;
|
|
49
54
|
this.store = store;
|
|
50
55
|
this.document = documentRef;
|
|
56
|
+
// Cached DocumentFragments for path frames, keyed by `${layerId}#${frameIndex}`.
|
|
57
|
+
// Populated by mountFrame on the way forward, drained by unmountFrame on
|
|
58
|
+
// the way back, purged on layer teardown via clearFrameCache.
|
|
59
|
+
this._frameCache = new Map();
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
showDialog() {
|
|
@@ -93,7 +102,10 @@ export class BrowserRuntime {
|
|
|
93
102
|
const frag = await this.#resolveFragment({ url, html, fragment });
|
|
94
103
|
const layer = this.document.createElement("div");
|
|
95
104
|
this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
|
|
96
|
-
layer
|
|
105
|
+
this.#applyFrameDepth(layer, 0);
|
|
106
|
+
const wrapper = this.#createFrameWrapper({ frameIndex: 0 });
|
|
107
|
+
wrapper.append(...frag.childNodes);
|
|
108
|
+
layer.appendChild(wrapper);
|
|
97
109
|
this.dialog.appendChild(layer);
|
|
98
110
|
}
|
|
99
111
|
|
|
@@ -102,7 +114,81 @@ export class BrowserRuntime {
|
|
|
102
114
|
const layer = this.#topLayer();
|
|
103
115
|
if (!layer) return;
|
|
104
116
|
this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
|
|
105
|
-
layer
|
|
117
|
+
this.#applyFrameDepth(layer, 0);
|
|
118
|
+
const wrapper = this.#createFrameWrapper({ frameIndex: 0 });
|
|
119
|
+
wrapper.append(...frag.childNodes);
|
|
120
|
+
layer.replaceChildren(wrapper);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async mountFrame({ layerId, fromFrameIndex, toFrameIndex, url, html, fragment, transition }) {
|
|
124
|
+
const layer = this.#findLayer(layerId);
|
|
125
|
+
if (!layer) return;
|
|
126
|
+
const frag = await this.#resolveFragment({ url, html, fragment });
|
|
127
|
+
|
|
128
|
+
// Snapshot the outgoing frame's children for back navigation. We cache
|
|
129
|
+
// the original nodes (not clones) and detach them from the DOM — the
|
|
130
|
+
// animateOut below operates on the wrapper, which we'll throw away.
|
|
131
|
+
const oldFrame = this.#findFrame(layer, fromFrameIndex);
|
|
132
|
+
if (oldFrame) {
|
|
133
|
+
const cached = this.document.createDocumentFragment();
|
|
134
|
+
cached.append(...oldFrame.childNodes);
|
|
135
|
+
this._frameCache.set(this.#frameKey(layerId, fromFrameIndex), cached);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const newFrame = this.#createFrameWrapper({ frameIndex: toFrameIndex, transition, direction: "forward" });
|
|
139
|
+
newFrame.append(...frag.childNodes);
|
|
140
|
+
layer.appendChild(newFrame);
|
|
141
|
+
this.#applyFrameDepth(layer, toFrameIndex);
|
|
142
|
+
|
|
143
|
+
// Old frame is removed synchronously. Entering frames carry
|
|
144
|
+
// data-transition + data-direction so host CSS can drive the *enter*
|
|
145
|
+
// animation via @starting-style; the leaving frame would need its own
|
|
146
|
+
// overlapping layout (e.g. position: absolute) to also animate out,
|
|
147
|
+
// which we leave to the host CSS preset.
|
|
148
|
+
if (oldFrame) oldFrame.remove();
|
|
149
|
+
|
|
150
|
+
// Remove transition attrs once the animation completes so the :has()
|
|
151
|
+
// rule that clips overflow doesn't persist indefinitely.
|
|
152
|
+
if (transition) this.#cleanupFrameTransition(newFrame);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async unmountFrame({ layerId, fromFrameIndex, toFrameIndex, url, stale, transition }) {
|
|
156
|
+
const layer = this.#findLayer(layerId);
|
|
157
|
+
if (!layer) return;
|
|
158
|
+
|
|
159
|
+
const cacheKey = this.#frameKey(layerId, toFrameIndex);
|
|
160
|
+
let restored = stale ? null : this._frameCache.get(cacheKey) ?? null;
|
|
161
|
+
if (!restored) {
|
|
162
|
+
const result = await this.fetchFragment(url);
|
|
163
|
+
restored = result.fragment;
|
|
164
|
+
this._frameCache.set(cacheKey, cloneFragment(restored, this.document));
|
|
165
|
+
} else {
|
|
166
|
+
// Clone so the cache entry remains usable if we step back here again
|
|
167
|
+
// after another forward.
|
|
168
|
+
restored = cloneFragment(restored, this.document);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const newFrame = this.#createFrameWrapper({ frameIndex: toFrameIndex, transition, direction: "back" });
|
|
172
|
+
newFrame.append(...restored.childNodes);
|
|
173
|
+
layer.appendChild(newFrame);
|
|
174
|
+
this.#applyFrameDepth(layer, toFrameIndex);
|
|
175
|
+
|
|
176
|
+
// Drop cache entries for frames that are now gone (anything past the
|
|
177
|
+
// restored index — we only keep entries for frames still tracked in
|
|
178
|
+
// state).
|
|
179
|
+
this.#purgeFrameCacheAbove(layerId, toFrameIndex);
|
|
180
|
+
|
|
181
|
+
const oldFrame = this.#findFrame(layer, fromFrameIndex);
|
|
182
|
+
if (oldFrame) oldFrame.remove();
|
|
183
|
+
|
|
184
|
+
if (transition) this.#cleanupFrameTransition(newFrame);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
clearFrameCache({ layerId }) {
|
|
188
|
+
const prefix = `${layerId}#`;
|
|
189
|
+
for (const key of [...this._frameCache.keys()]) {
|
|
190
|
+
if (key.startsWith(prefix)) this._frameCache.delete(key);
|
|
191
|
+
}
|
|
106
192
|
}
|
|
107
193
|
|
|
108
194
|
async unmountTopLayer() {
|
|
@@ -197,6 +283,57 @@ export class BrowserRuntime {
|
|
|
197
283
|
);
|
|
198
284
|
}
|
|
199
285
|
|
|
286
|
+
#findFrame(layer, frameIndex) {
|
|
287
|
+
return layer.querySelector(
|
|
288
|
+
`${FRAME_SELECTOR}[data-frame-index="${escapeAttr(String(frameIndex))}"]`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
#frameKey(layerId, frameIndex) {
|
|
293
|
+
return `${layerId}#${frameIndex}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
#purgeFrameCacheAbove(layerId, frameIndex) {
|
|
297
|
+
const prefix = `${layerId}#`;
|
|
298
|
+
for (const key of [...this._frameCache.keys()]) {
|
|
299
|
+
if (!key.startsWith(prefix)) continue;
|
|
300
|
+
const idx = Number(key.slice(prefix.length));
|
|
301
|
+
if (Number.isFinite(idx) && idx > frameIndex) {
|
|
302
|
+
this._frameCache.delete(key);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Removes [data-transition] and [data-direction] from a frame once its
|
|
308
|
+
// enter animation ends. This restores overflow-y:auto on the layer (the
|
|
309
|
+
// :has([data-transition]) rule in the CSS preset keeps overflow:hidden
|
|
310
|
+
// while the animation runs to clip off-screen slide frames).
|
|
311
|
+
#cleanupFrameTransition(frameEl) {
|
|
312
|
+
let done = false;
|
|
313
|
+
const cleanup = () => {
|
|
314
|
+
if (done) return;
|
|
315
|
+
done = true;
|
|
316
|
+
frameEl.removeAttribute("data-transition");
|
|
317
|
+
frameEl.removeAttribute("data-direction");
|
|
318
|
+
};
|
|
319
|
+
frameEl.addEventListener("transitionend", cleanup, { once: true });
|
|
320
|
+
setTimeout(cleanup, this.#leaveTimeoutMs());
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#createFrameWrapper({ frameIndex, transition = null, direction = null }) {
|
|
324
|
+
const el = this.document.createElement("div");
|
|
325
|
+
el.dataset.modalStackFrame = "";
|
|
326
|
+
el.dataset.frameIndex = String(frameIndex);
|
|
327
|
+
if (transition) el.dataset.transition = transition;
|
|
328
|
+
if (direction) el.dataset.direction = direction;
|
|
329
|
+
return el;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
#applyFrameDepth(layer, topFrameIndex) {
|
|
333
|
+
layer.dataset.frameIndex = String(topFrameIndex);
|
|
334
|
+
layer.dataset.frameDepth = String(topFrameIndex + 1);
|
|
335
|
+
}
|
|
336
|
+
|
|
200
337
|
#topLayer() {
|
|
201
338
|
const layers = this.dialog.querySelectorAll(LAYER_SELECTOR);
|
|
202
339
|
return layers[layers.length - 1] ?? null;
|
|
@@ -241,13 +378,15 @@ export class BrowserRuntime {
|
|
|
241
378
|
throw new Error(`modal_stack: fetch ${url} → ${resp.status}`);
|
|
242
379
|
}
|
|
243
380
|
const html = await resp.text();
|
|
244
|
-
|
|
381
|
+
const stale = parseStaleHeader(resp);
|
|
382
|
+
return { fragment: parseFragment(html, this.document), stale };
|
|
245
383
|
}
|
|
246
384
|
|
|
247
385
|
async #resolveFragment({ url, html, fragment }) {
|
|
248
386
|
if (fragment) return fragment;
|
|
249
387
|
if (html != null) return parseFragment(html, this.document);
|
|
250
|
-
|
|
388
|
+
const result = await this.fetchFragment(url);
|
|
389
|
+
return result.fragment;
|
|
251
390
|
}
|
|
252
391
|
}
|
|
253
392
|
|
|
@@ -259,6 +398,28 @@ function parseFragment(html, doc) {
|
|
|
259
398
|
return fragment;
|
|
260
399
|
}
|
|
261
400
|
|
|
401
|
+
function parseStaleHeader(resp) {
|
|
402
|
+
const headers = resp?.headers;
|
|
403
|
+
const value =
|
|
404
|
+
typeof headers?.get === "function"
|
|
405
|
+
? headers.get(STALE_HEADER) ?? headers.get(STALE_HEADER.toLowerCase())
|
|
406
|
+
: null;
|
|
407
|
+
if (!value) return false;
|
|
408
|
+
const normalized = String(value).trim().toLowerCase();
|
|
409
|
+
return normalized === "true" || normalized === "1";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function cloneFragment(fragment, doc) {
|
|
413
|
+
if (typeof fragment?.cloneNode === "function") {
|
|
414
|
+
return fragment.cloneNode(true);
|
|
415
|
+
}
|
|
416
|
+
const clone = doc.createDocumentFragment();
|
|
417
|
+
if (fragment?.childNodes) {
|
|
418
|
+
for (const node of fragment.childNodes) clone.appendChild(node.cloneNode(true));
|
|
419
|
+
}
|
|
420
|
+
return clone;
|
|
421
|
+
}
|
|
422
|
+
|
|
262
423
|
// Marks the layer with [data-leaving] so the host CSS can transition it
|
|
263
424
|
// out, then awaits transitionend (with a hard timeout) before removing
|
|
264
425
|
// the element from the DOM. If the host CSS doesn't define an exit
|
|
@@ -294,5 +455,6 @@ function escapeAttr(value) {
|
|
|
294
455
|
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
295
456
|
return CSS.escape(value);
|
|
296
457
|
}
|
|
297
|
-
|
|
458
|
+
// Fallback: escape chars that break CSS attribute selectors ([attr="val"])
|
|
459
|
+
return String(value).replace(/["\\[\]]/g, "\\$&");
|
|
298
460
|
}
|
|
@@ -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() {
|
|
@@ -268,4 +269,86 @@ describe("fetch headers", () => {
|
|
|
268
269
|
expect(captured.opts.headers.Accept).toContain("text/html");
|
|
269
270
|
expect(captured.opts.credentials).toBe("same-origin");
|
|
270
271
|
});
|
|
272
|
+
|
|
273
|
+
test("fetchFragment returns { fragment, stale } from response header", async () => {
|
|
274
|
+
withFakeDOMParser(async () => {
|
|
275
|
+
const fetcher = () =>
|
|
276
|
+
Promise.resolve(
|
|
277
|
+
new Response("<p>frame</p>", {
|
|
278
|
+
status: 200,
|
|
279
|
+
headers: { [STALE_HEADER]: "true" },
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
const documentRef = fakeDocument();
|
|
283
|
+
const rt = new BrowserRuntime(noopRuntimeArgs({ fetcher, documentRef }));
|
|
284
|
+
const result = await rt.fetchFragment("/x");
|
|
285
|
+
expect(result.stale).toBe(true);
|
|
286
|
+
expect(result.fragment).toBeTruthy();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("fetchFragment.stale is false when header is missing or '0'", async () => {
|
|
291
|
+
await withFakeDOMParser(async () => {
|
|
292
|
+
const cases = [
|
|
293
|
+
new Response("", { status: 200 }),
|
|
294
|
+
new Response("", { status: 200, headers: { [STALE_HEADER]: "0" } }),
|
|
295
|
+
new Response("", { status: 200, headers: { [STALE_HEADER]: "false" } }),
|
|
296
|
+
];
|
|
297
|
+
for (const resp of cases) {
|
|
298
|
+
const documentRef = fakeDocument();
|
|
299
|
+
const rt = new BrowserRuntime(
|
|
300
|
+
noopRuntimeArgs({ fetcher: () => Promise.resolve(resp), documentRef }),
|
|
301
|
+
);
|
|
302
|
+
const result = await rt.fetchFragment("/x");
|
|
303
|
+
expect(result.stale).toBe(false);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
function fakeDocument() {
|
|
310
|
+
return {
|
|
311
|
+
createDocumentFragment: () => {
|
|
312
|
+
const children = [];
|
|
313
|
+
return {
|
|
314
|
+
childNodes: children,
|
|
315
|
+
append: (...nodes) => children.push(...nodes),
|
|
316
|
+
appendChild: (n) => children.push(n),
|
|
317
|
+
};
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function withFakeDOMParser(fn) {
|
|
323
|
+
const original = globalThis.DOMParser;
|
|
324
|
+
globalThis.DOMParser = class {
|
|
325
|
+
parseFromString() {
|
|
326
|
+
return { body: { childNodes: [] } };
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
try {
|
|
330
|
+
return await fn();
|
|
331
|
+
} finally {
|
|
332
|
+
if (original) globalThis.DOMParser = original;
|
|
333
|
+
else delete globalThis.DOMParser;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
describe("frame cache", () => {
|
|
338
|
+
test("clearFrameCache removes only entries for the given layerId", () => {
|
|
339
|
+
const rt = new BrowserRuntime(noopRuntimeArgs());
|
|
340
|
+
rt._frameCache.set("L1#0", "a");
|
|
341
|
+
rt._frameCache.set("L1#1", "b");
|
|
342
|
+
rt._frameCache.set("L2#0", "c");
|
|
343
|
+
rt._frameCache.set("L11#0", "d"); // verify prefix is anchored on `#`
|
|
344
|
+
rt.clearFrameCache({ layerId: "L1" });
|
|
345
|
+
expect([...rt._frameCache.keys()].sort()).toEqual(["L11#0", "L2#0"]);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("clearFrameCache is a no-op for unknown layerId", () => {
|
|
349
|
+
const rt = new BrowserRuntime(noopRuntimeArgs());
|
|
350
|
+
rt._frameCache.set("L1#0", "a");
|
|
351
|
+
rt.clearFrameCache({ layerId: "Lz" });
|
|
352
|
+
expect([...rt._frameCache.keys()]).toEqual(["L1#0"]);
|
|
353
|
+
});
|
|
271
354
|
});
|