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,16 +2,22 @@
|
|
|
2
2
|
* @typedef {"modal" | "drawer" | "bottom_sheet" | "confirmation"} Variant
|
|
3
3
|
* @typedef {"left" | "right" | "top" | "bottom"} DrawerSide
|
|
4
4
|
* @typedef {"sm" | "md" | "lg" | "xl"} Size
|
|
5
|
+
* @typedef {"slide" | "fade" | "none"} Transition
|
|
6
|
+
*
|
|
7
|
+
* @typedef {Object} Frame
|
|
8
|
+
* @property {string} url Frame URL — written to history when this frame is on top
|
|
9
|
+
* @property {boolean} stale When true, runtime refetches before restoring on back
|
|
5
10
|
*
|
|
6
11
|
* @typedef {Object} Layer
|
|
7
12
|
* @property {string} id Stable layer identifier (used for inertness + DOM lookup)
|
|
8
|
-
* @property {string} url
|
|
13
|
+
* @property {string} url Top frame URL — kept for back-compat with the existing API
|
|
9
14
|
* @property {Variant} variant
|
|
10
15
|
* @property {boolean} dismissible
|
|
11
16
|
* @property {Size|null} size
|
|
12
17
|
* @property {DrawerSide|null} side Required for drawers; null otherwise
|
|
13
18
|
* @property {string|null} width Free-form CSS width (e.g. "42rem")
|
|
14
19
|
* @property {string|null} height
|
|
20
|
+
* @property {readonly Frame[]} frames Path frames; layer.url === frames[last].url
|
|
15
21
|
*
|
|
16
22
|
* @typedef {Object} Stack
|
|
17
23
|
* @property {string} stackId
|
|
@@ -29,7 +35,11 @@ export const VARIANTS = Object.freeze([
|
|
|
29
35
|
"confirmation",
|
|
30
36
|
]);
|
|
31
37
|
|
|
32
|
-
const
|
|
38
|
+
export const TRANSITIONS = Object.freeze(["slide", "fade", "none"]);
|
|
39
|
+
|
|
40
|
+
// v2 introduced `frames` per layer (modal path feature). v1 snapshots are
|
|
41
|
+
// rehydrated by synthesising a single-frame array — see restore().
|
|
42
|
+
const SNAPSHOT_VERSION = 2;
|
|
33
43
|
const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000;
|
|
34
44
|
const DRAWER_SIDES = Object.freeze(["left", "right", "top", "bottom"]);
|
|
35
45
|
const MAX_DEPTH_STRATEGIES = Object.freeze(["raise", "warn", "silent"]);
|
|
@@ -66,17 +76,36 @@ function normalizeLayerOptions({ variant, size, side, width, height }) {
|
|
|
66
76
|
};
|
|
67
77
|
}
|
|
68
78
|
|
|
69
|
-
function
|
|
79
|
+
function freezeFrame({ url, stale = false }) {
|
|
80
|
+
if (typeof url !== "string" || url.length === 0) {
|
|
81
|
+
throw new Error("frame.url required");
|
|
82
|
+
}
|
|
83
|
+
return Object.freeze({ url, stale: !!stale });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function freezeFrames(frames) {
|
|
87
|
+
return Object.freeze(frames.map(freezeFrame));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function freezeLayer({ id, url, variant, dismissible, size, side, width, height, frames }) {
|
|
70
91
|
const normalized = normalizeLayerOptions({ variant, size, side, width, height });
|
|
92
|
+
// The layer's url is always the top frame's url; if frames isn't supplied
|
|
93
|
+
// (e.g. fresh push or restoring a v1 snapshot) we synthesize a single frame.
|
|
94
|
+
const framesArray = Array.isArray(frames) && frames.length > 0
|
|
95
|
+
? frames
|
|
96
|
+
: [{ url, stale: false }];
|
|
97
|
+
const frozenFrames = freezeFrames(framesArray);
|
|
98
|
+
const topUrl = frozenFrames[frozenFrames.length - 1].url;
|
|
71
99
|
return Object.freeze({
|
|
72
100
|
id,
|
|
73
|
-
url,
|
|
101
|
+
url: topUrl,
|
|
74
102
|
variant,
|
|
75
103
|
dismissible: !!dismissible,
|
|
76
104
|
size: normalized.size,
|
|
77
105
|
side: normalized.side,
|
|
78
106
|
width: normalized.width,
|
|
79
107
|
height: normalized.height,
|
|
108
|
+
frames: frozenFrames,
|
|
80
109
|
});
|
|
81
110
|
}
|
|
82
111
|
|
|
@@ -99,6 +128,20 @@ export function topLayer(state) {
|
|
|
99
128
|
return state.layers[state.layers.length - 1] ?? null;
|
|
100
129
|
}
|
|
101
130
|
|
|
131
|
+
function totalFrameCount(layers) {
|
|
132
|
+
let n = 0;
|
|
133
|
+
for (const l of layers) n += l.frames.length;
|
|
134
|
+
return n;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function validateTransition(value) {
|
|
138
|
+
if (value == null) return null;
|
|
139
|
+
if (!TRANSITIONS.includes(value)) {
|
|
140
|
+
throw new Error(`unknown transition: ${value}`);
|
|
141
|
+
}
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
|
|
102
145
|
/**
|
|
103
146
|
* Push a new layer on top of the stack.
|
|
104
147
|
*
|
|
@@ -178,7 +221,12 @@ export function push(state, layer, options = {}) {
|
|
|
178
221
|
commands.push({
|
|
179
222
|
type: "pushHistory",
|
|
180
223
|
url: newLayer.url,
|
|
181
|
-
historyState: {
|
|
224
|
+
historyState: {
|
|
225
|
+
stackId: state.stackId,
|
|
226
|
+
layerId: newLayer.id,
|
|
227
|
+
depth,
|
|
228
|
+
frameIndex: 0,
|
|
229
|
+
},
|
|
182
230
|
});
|
|
183
231
|
commands.push({ type: "persistSnapshot" });
|
|
184
232
|
|
|
@@ -186,19 +234,146 @@ export function push(state, layer, options = {}) {
|
|
|
186
234
|
}
|
|
187
235
|
|
|
188
236
|
/**
|
|
189
|
-
*
|
|
237
|
+
* Append a frame to the top layer's path. Forward navigation in a wizard
|
|
238
|
+
* that retains a back-history.
|
|
239
|
+
*
|
|
240
|
+
* @param {Stack} state
|
|
241
|
+
* @param {{ url: string, stale?: boolean }} frame
|
|
242
|
+
* @param {{ transition?: Transition|null }} [options]
|
|
243
|
+
* @returns {Transition}
|
|
244
|
+
*/
|
|
245
|
+
export function pathTo(state, frame, options = {}) {
|
|
246
|
+
if (state.layers.length === 0) {
|
|
247
|
+
throw new Error("pathTo requires at least one layer");
|
|
248
|
+
}
|
|
249
|
+
if (typeof frame?.url !== "string" || frame.url.length === 0) {
|
|
250
|
+
throw new Error("pathTo requires a frame.url");
|
|
251
|
+
}
|
|
252
|
+
const transition = validateTransition(options.transition ?? null);
|
|
253
|
+
|
|
254
|
+
const top = topLayer(state);
|
|
255
|
+
const previousFrameIndex = top.frames.length - 1;
|
|
256
|
+
const newFrame = freezeFrame({ url: frame.url, stale: !!frame.stale });
|
|
257
|
+
const newFrames = [...top.frames, newFrame];
|
|
258
|
+
const newTop = freezeLayer({
|
|
259
|
+
id: top.id,
|
|
260
|
+
url: newFrame.url,
|
|
261
|
+
variant: top.variant,
|
|
262
|
+
dismissible: top.dismissible,
|
|
263
|
+
size: top.size,
|
|
264
|
+
side: top.side,
|
|
265
|
+
width: top.width,
|
|
266
|
+
height: top.height,
|
|
267
|
+
frames: newFrames,
|
|
268
|
+
});
|
|
269
|
+
const newLayers = Object.freeze([...state.layers.slice(0, -1), newTop]);
|
|
270
|
+
const depth = newLayers.length;
|
|
271
|
+
const newFrameIndex = newFrames.length - 1;
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
state: { ...state, layers: newLayers },
|
|
275
|
+
commands: [
|
|
276
|
+
{
|
|
277
|
+
type: "mountFrame",
|
|
278
|
+
layerId: newTop.id,
|
|
279
|
+
fromFrameIndex: previousFrameIndex,
|
|
280
|
+
toFrameIndex: newFrameIndex,
|
|
281
|
+
url: newFrame.url,
|
|
282
|
+
stale: newFrame.stale,
|
|
283
|
+
...(transition ? { transition } : {}),
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
type: "pushHistory",
|
|
287
|
+
url: newFrame.url,
|
|
288
|
+
historyState: {
|
|
289
|
+
stackId: state.stackId,
|
|
290
|
+
layerId: newTop.id,
|
|
291
|
+
depth,
|
|
292
|
+
frameIndex: newFrameIndex,
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
{ type: "persistSnapshot" },
|
|
296
|
+
],
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Step back through frames in the top layer's path. Clamps at the first
|
|
302
|
+
* frame: the layer is never closed by pathBack — use pop() / closeAll()
|
|
303
|
+
* for that.
|
|
304
|
+
*
|
|
305
|
+
* @param {Stack} state
|
|
306
|
+
* @param {{ steps?: number, transition?: Transition|null }} [options]
|
|
307
|
+
* @returns {Transition}
|
|
308
|
+
*/
|
|
309
|
+
export function pathBack(state, options = {}) {
|
|
310
|
+
if (state.layers.length === 0) {
|
|
311
|
+
throw new Error("pathBack requires at least one layer");
|
|
312
|
+
}
|
|
313
|
+
const requestedSteps = options.steps == null ? 1 : Math.floor(options.steps);
|
|
314
|
+
if (!Number.isFinite(requestedSteps) || requestedSteps < 1) {
|
|
315
|
+
throw new Error("pathBack: steps must be a positive integer");
|
|
316
|
+
}
|
|
317
|
+
const transition = validateTransition(options.transition ?? null);
|
|
318
|
+
|
|
319
|
+
const top = topLayer(state);
|
|
320
|
+
const fromFrameIndex = top.frames.length - 1;
|
|
321
|
+
const maxSteps = top.frames.length - 1;
|
|
322
|
+
const effectiveSteps = Math.min(requestedSteps, maxSteps);
|
|
323
|
+
if (effectiveSteps === 0) return { state, commands: [] };
|
|
324
|
+
|
|
325
|
+
const toFrameIndex = fromFrameIndex - effectiveSteps;
|
|
326
|
+
const newFrames = top.frames.slice(0, toFrameIndex + 1);
|
|
327
|
+
const targetFrame = newFrames[newFrames.length - 1];
|
|
328
|
+
const newTop = freezeLayer({
|
|
329
|
+
id: top.id,
|
|
330
|
+
url: targetFrame.url,
|
|
331
|
+
variant: top.variant,
|
|
332
|
+
dismissible: top.dismissible,
|
|
333
|
+
size: top.size,
|
|
334
|
+
side: top.side,
|
|
335
|
+
width: top.width,
|
|
336
|
+
height: top.height,
|
|
337
|
+
frames: newFrames,
|
|
338
|
+
});
|
|
339
|
+
const newLayers = Object.freeze([...state.layers.slice(0, -1), newTop]);
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
state: { ...state, layers: newLayers },
|
|
343
|
+
commands: [
|
|
344
|
+
{
|
|
345
|
+
type: "unmountFrame",
|
|
346
|
+
layerId: newTop.id,
|
|
347
|
+
fromFrameIndex,
|
|
348
|
+
toFrameIndex,
|
|
349
|
+
url: targetFrame.url,
|
|
350
|
+
stale: targetFrame.stale,
|
|
351
|
+
...(transition ? { transition } : {}),
|
|
352
|
+
},
|
|
353
|
+
{ type: "historyBack", n: effectiveSteps },
|
|
354
|
+
{ type: "persistSnapshot" },
|
|
355
|
+
],
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Pop the top layer (and all of its path frames). No-op when the stack
|
|
361
|
+
* is empty.
|
|
190
362
|
* @param {Stack} state
|
|
191
363
|
* @returns {Transition}
|
|
192
364
|
*/
|
|
193
365
|
export function pop(state) {
|
|
194
366
|
if (state.layers.length === 0) return { state, commands: [] };
|
|
195
367
|
|
|
368
|
+
const popped = topLayer(state);
|
|
369
|
+
const framesToWalkBack = popped.frames.length;
|
|
196
370
|
const newLayers = Object.freeze(state.layers.slice(0, -1));
|
|
197
371
|
const newTop = newLayers[newLayers.length - 1] ?? null;
|
|
198
372
|
const commands = [];
|
|
199
373
|
if (newTop) {
|
|
200
374
|
commands.push({ type: "unmountTopLayer" });
|
|
201
|
-
commands.push({ type: "
|
|
375
|
+
commands.push({ type: "clearFrameCache", layerId: popped.id });
|
|
376
|
+
commands.push({ type: "historyBack", n: framesToWalkBack });
|
|
202
377
|
commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
|
|
203
378
|
commands.push({ type: "persistSnapshot" });
|
|
204
379
|
} else {
|
|
@@ -211,7 +386,8 @@ export function pop(state) {
|
|
|
211
386
|
// after the modal is gone.
|
|
212
387
|
commands.push({ type: "closeDialog" });
|
|
213
388
|
commands.push({ type: "unmountTopLayer" });
|
|
214
|
-
commands.push({ type: "
|
|
389
|
+
commands.push({ type: "clearFrameCache", layerId: popped.id });
|
|
390
|
+
commands.push({ type: "historyBack", n: framesToWalkBack });
|
|
215
391
|
commands.push({ type: "unlockScroll" });
|
|
216
392
|
commands.push({ type: "clearSnapshot" });
|
|
217
393
|
}
|
|
@@ -234,6 +410,11 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
|
|
|
234
410
|
}
|
|
235
411
|
|
|
236
412
|
const top = topLayer(state);
|
|
413
|
+
// replaceTop collapses the top layer's path back to a single frame
|
|
414
|
+
// — the existing path is forgotten. Walk history back one step per
|
|
415
|
+
// dropped frame so the browser's back button doesn't land on stale
|
|
416
|
+
// frame entries.
|
|
417
|
+
const framesToCollapse = top.frames.length - 1;
|
|
237
418
|
const next = freezeLayer({
|
|
238
419
|
id: patch.id ?? top.id,
|
|
239
420
|
url: patch.url ?? top.url,
|
|
@@ -243,6 +424,8 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
|
|
|
243
424
|
side: patch.side ?? top.side,
|
|
244
425
|
width: patch.width ?? top.width,
|
|
245
426
|
height: patch.height ?? top.height,
|
|
427
|
+
// single-frame layer — drop any path that was on the previous layer
|
|
428
|
+
frames: undefined,
|
|
246
429
|
});
|
|
247
430
|
const newLayers = Object.freeze([...state.layers.slice(0, -1), next]);
|
|
248
431
|
const depth = newLayers.length;
|
|
@@ -250,28 +433,35 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
|
|
|
250
433
|
const historyCmd = {
|
|
251
434
|
type: historyMode === "push" ? "pushHistory" : "replaceHistory",
|
|
252
435
|
url: next.url,
|
|
253
|
-
historyState: {
|
|
436
|
+
historyState: {
|
|
437
|
+
stackId: state.stackId,
|
|
438
|
+
layerId: next.id,
|
|
439
|
+
depth,
|
|
440
|
+
frameIndex: 0,
|
|
441
|
+
},
|
|
254
442
|
};
|
|
255
443
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
commands:
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
};
|
|
444
|
+
const commands = [];
|
|
445
|
+
if (framesToCollapse > 0) {
|
|
446
|
+
commands.push({ type: "clearFrameCache", layerId: top.id });
|
|
447
|
+
commands.push({ type: "historyBack", n: framesToCollapse });
|
|
448
|
+
}
|
|
449
|
+
commands.push({
|
|
450
|
+
type: "morphTopLayer",
|
|
451
|
+
layerId: next.id,
|
|
452
|
+
url: next.url,
|
|
453
|
+
depth,
|
|
454
|
+
variant: next.variant,
|
|
455
|
+
dismissible: next.dismissible,
|
|
456
|
+
...(next.size ? { size: next.size } : {}),
|
|
457
|
+
...(next.side ? { side: next.side } : {}),
|
|
458
|
+
...(next.width ? { width: next.width } : {}),
|
|
459
|
+
...(next.height ? { height: next.height } : {}),
|
|
460
|
+
});
|
|
461
|
+
commands.push(historyCmd);
|
|
462
|
+
commands.push({ type: "persistSnapshot" });
|
|
463
|
+
|
|
464
|
+
return { state: { ...state, layers: newLayers }, commands };
|
|
275
465
|
}
|
|
276
466
|
|
|
277
467
|
/**
|
|
@@ -281,7 +471,11 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
|
|
|
281
471
|
*/
|
|
282
472
|
export function closeAll(state) {
|
|
283
473
|
if (state.layers.length === 0) return { state, commands: [] };
|
|
284
|
-
const n = state.layers
|
|
474
|
+
const n = totalFrameCount(state.layers);
|
|
475
|
+
const cacheClears = state.layers.map((l) => ({
|
|
476
|
+
type: "clearFrameCache",
|
|
477
|
+
layerId: l.id,
|
|
478
|
+
}));
|
|
285
479
|
return {
|
|
286
480
|
state: { ...state, layers: Object.freeze([]) },
|
|
287
481
|
// closeDialog first so the dialog's exit transition runs in
|
|
@@ -289,6 +483,7 @@ export function closeAll(state) {
|
|
|
289
483
|
commands: [
|
|
290
484
|
{ type: "closeDialog" },
|
|
291
485
|
{ type: "unmountAllLayers" },
|
|
486
|
+
...cacheClears,
|
|
292
487
|
{ type: "unlockScroll" },
|
|
293
488
|
{ type: "historyBack", n },
|
|
294
489
|
{ type: "clearSnapshot" },
|
|
@@ -297,8 +492,9 @@ export function closeAll(state) {
|
|
|
297
492
|
}
|
|
298
493
|
|
|
299
494
|
/**
|
|
300
|
-
* Reduce a browser `popstate` into a transition: pop layers,
|
|
301
|
-
* or request a rebuild from snapshot for
|
|
495
|
+
* Reduce a browser `popstate` into a transition: pop layers, step back
|
|
496
|
+
* through frames, morph the top, or request a rebuild from snapshot for
|
|
497
|
+
* forward navigation.
|
|
302
498
|
* @param {Stack} state
|
|
303
499
|
* @param {{ historyState: any, locationHref: string }} options
|
|
304
500
|
* @returns {Transition}
|
|
@@ -309,12 +505,17 @@ export function handlePopstate(state, { historyState, locationHref }) {
|
|
|
309
505
|
|
|
310
506
|
if (!isOurs) {
|
|
311
507
|
if (state.layers.length === 0) return { state, commands: [] };
|
|
508
|
+
const cacheClears = state.layers.map((l) => ({
|
|
509
|
+
type: "clearFrameCache",
|
|
510
|
+
layerId: l.id,
|
|
511
|
+
}));
|
|
312
512
|
return {
|
|
313
513
|
state: { ...state, layers: Object.freeze([]) },
|
|
314
514
|
// closeDialog first — see closeAll() for rationale.
|
|
315
515
|
commands: [
|
|
316
516
|
{ type: "closeDialog" },
|
|
317
517
|
{ type: "unmountAllLayers" },
|
|
518
|
+
...cacheClears,
|
|
318
519
|
{ type: "unlockScroll" },
|
|
319
520
|
{ type: "clearSnapshot" },
|
|
320
521
|
],
|
|
@@ -324,8 +525,10 @@ export function handlePopstate(state, { historyState, locationHref }) {
|
|
|
324
525
|
const targetDepth = historyState.depth ?? 0;
|
|
325
526
|
const currentDepth = state.layers.length;
|
|
326
527
|
const targetLayerId = historyState.layerId ?? null;
|
|
528
|
+
const targetFrameIndex = historyState.frameIndex ?? 0;
|
|
327
529
|
|
|
328
530
|
if (targetDepth < currentDepth) {
|
|
531
|
+
const droppedLayers = state.layers.slice(targetDepth);
|
|
329
532
|
const newLayers = Object.freeze(state.layers.slice(0, targetDepth));
|
|
330
533
|
const newTop = newLayers[newLayers.length - 1] ?? null;
|
|
331
534
|
const commands = [];
|
|
@@ -333,9 +536,12 @@ export function handlePopstate(state, { historyState, locationHref }) {
|
|
|
333
536
|
// first so the dialog's exit transition runs alongside the
|
|
334
537
|
// sequential unmountTopLayer cascade.
|
|
335
538
|
if (!newTop) commands.push({ type: "closeDialog" });
|
|
336
|
-
for (let i = 0; i <
|
|
539
|
+
for (let i = 0; i < droppedLayers.length; i++) {
|
|
337
540
|
commands.push({ type: "unmountTopLayer" });
|
|
338
541
|
}
|
|
542
|
+
for (const dropped of droppedLayers) {
|
|
543
|
+
commands.push({ type: "clearFrameCache", layerId: dropped.id });
|
|
544
|
+
}
|
|
339
545
|
if (newTop) {
|
|
340
546
|
commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
|
|
341
547
|
commands.push({ type: "persistSnapshot" });
|
|
@@ -356,6 +562,55 @@ export function handlePopstate(state, { historyState, locationHref }) {
|
|
|
356
562
|
}
|
|
357
563
|
|
|
358
564
|
const top = topLayer(state);
|
|
565
|
+
if (top && targetLayerId && top.id === targetLayerId) {
|
|
566
|
+
const currentFrameIndex = top.frames.length - 1;
|
|
567
|
+
if (targetFrameIndex === currentFrameIndex) {
|
|
568
|
+
return { state, commands: [] };
|
|
569
|
+
}
|
|
570
|
+
if (targetFrameIndex < currentFrameIndex) {
|
|
571
|
+
const newFrames = top.frames.slice(0, targetFrameIndex + 1);
|
|
572
|
+
const targetFrame = newFrames[newFrames.length - 1];
|
|
573
|
+
const updatedTop = freezeLayer({
|
|
574
|
+
id: top.id,
|
|
575
|
+
url: targetFrame.url,
|
|
576
|
+
variant: top.variant,
|
|
577
|
+
dismissible: top.dismissible,
|
|
578
|
+
size: top.size,
|
|
579
|
+
side: top.side,
|
|
580
|
+
width: top.width,
|
|
581
|
+
height: top.height,
|
|
582
|
+
frames: newFrames,
|
|
583
|
+
});
|
|
584
|
+
const newLayers = Object.freeze([
|
|
585
|
+
...state.layers.slice(0, -1),
|
|
586
|
+
updatedTop,
|
|
587
|
+
]);
|
|
588
|
+
return {
|
|
589
|
+
state: { ...state, layers: newLayers },
|
|
590
|
+
commands: [
|
|
591
|
+
{
|
|
592
|
+
type: "unmountFrame",
|
|
593
|
+
layerId: top.id,
|
|
594
|
+
fromFrameIndex: currentFrameIndex,
|
|
595
|
+
toFrameIndex: targetFrameIndex,
|
|
596
|
+
url: targetFrame.url,
|
|
597
|
+
stale: targetFrame.stale,
|
|
598
|
+
},
|
|
599
|
+
{ type: "persistSnapshot" },
|
|
600
|
+
],
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
// Forward popstate to a frame we no longer track — happens when the
|
|
604
|
+
// user pressed back, then forward, after the path frames were dropped
|
|
605
|
+
// from state. Defer to the controller (snapshot rebuild / fetch).
|
|
606
|
+
return {
|
|
607
|
+
state,
|
|
608
|
+
commands: [
|
|
609
|
+
{ type: "rebuildFromSnapshot", targetDepth, targetLayerId },
|
|
610
|
+
],
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
359
614
|
if (top && targetLayerId && top.id !== targetLayerId) {
|
|
360
615
|
const updatedTop = freezeLayer({
|
|
361
616
|
id: targetLayerId,
|
|
@@ -374,6 +629,7 @@ export function handlePopstate(state, { historyState, locationHref }) {
|
|
|
374
629
|
return {
|
|
375
630
|
state: { ...state, layers: newLayers },
|
|
376
631
|
commands: [
|
|
632
|
+
{ type: "clearFrameCache", layerId: top.id },
|
|
377
633
|
{
|
|
378
634
|
type: "morphTopLayer",
|
|
379
635
|
layerId: targetLayerId,
|
|
@@ -396,6 +652,11 @@ export function handlePopstate(state, { historyState, locationHref }) {
|
|
|
396
652
|
|
|
397
653
|
/**
|
|
398
654
|
* Serialize the stack for sessionStorage. Versioned + timestamped.
|
|
655
|
+
*
|
|
656
|
+
* Frames are serialized as `{ url, stale }` only — the cached HTML lives
|
|
657
|
+
* in the runtime, not the snapshot, so a refresh refetches the top frame
|
|
658
|
+
* and lazily refetches earlier frames if/when the user steps back.
|
|
659
|
+
*
|
|
399
660
|
* @param {Stack} state
|
|
400
661
|
* @param {{ now?: () => number }} [options]
|
|
401
662
|
* @returns {string}
|
|
@@ -405,14 +666,32 @@ export function snapshot(state, { now = Date.now } = {}) {
|
|
|
405
666
|
v: SNAPSHOT_VERSION,
|
|
406
667
|
stackId: state.stackId,
|
|
407
668
|
baseUrl: state.baseUrl,
|
|
408
|
-
layers: state.layers,
|
|
669
|
+
layers: state.layers.map(serializeLayer),
|
|
409
670
|
savedAt: now(),
|
|
410
671
|
});
|
|
411
672
|
}
|
|
412
673
|
|
|
674
|
+
function serializeLayer(layer) {
|
|
675
|
+
return {
|
|
676
|
+
id: layer.id,
|
|
677
|
+
url: layer.url,
|
|
678
|
+
variant: layer.variant,
|
|
679
|
+
dismissible: layer.dismissible,
|
|
680
|
+
size: layer.size,
|
|
681
|
+
side: layer.side,
|
|
682
|
+
width: layer.width,
|
|
683
|
+
height: layer.height,
|
|
684
|
+
frames: layer.frames.map((f) => ({ url: f.url, stale: f.stale })),
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
413
688
|
/**
|
|
414
689
|
* Restore a stack from a serialized snapshot. Returns null on any validation
|
|
415
690
|
* failure (wrong stackId, expired, malformed JSON, etc.).
|
|
691
|
+
*
|
|
692
|
+
* Accepts both v1 (pre-frames) and v2 snapshots: v1 layers are rehydrated
|
|
693
|
+
* with a synthetic single-frame array so existing tabs survive an upgrade.
|
|
694
|
+
*
|
|
416
695
|
* @param {string} serialized
|
|
417
696
|
* @param {{ stackId?: string, maxAgeMs?: number, now?: () => number }} [options]
|
|
418
697
|
* @returns {Stack|null}
|
|
@@ -428,7 +707,7 @@ export function restore(
|
|
|
428
707
|
} catch {
|
|
429
708
|
return null;
|
|
430
709
|
}
|
|
431
|
-
if (parsed?.v !== SNAPSHOT_VERSION) return null;
|
|
710
|
+
if (parsed?.v !== 1 && parsed?.v !== SNAPSHOT_VERSION) return null;
|
|
432
711
|
if (typeof parsed.stackId !== "string") return null;
|
|
433
712
|
if (typeof parsed.baseUrl !== "string") return null;
|
|
434
713
|
if (!Array.isArray(parsed.layers)) return null;
|
|
@@ -439,6 +718,12 @@ export function restore(
|
|
|
439
718
|
for (const l of parsed.layers) {
|
|
440
719
|
if (!l || typeof l.id !== "string" || typeof l.url !== "string") return null;
|
|
441
720
|
if (!VARIANTS.includes(l.variant)) return null;
|
|
721
|
+
if (l.frames !== undefined) {
|
|
722
|
+
if (!Array.isArray(l.frames) || l.frames.length === 0) return null;
|
|
723
|
+
for (const f of l.frames) {
|
|
724
|
+
if (!f || typeof f.url !== "string") return null;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
442
727
|
}
|
|
443
728
|
|
|
444
729
|
return Object.freeze({
|