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
|
@@ -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,21 +234,150 @@ 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) {
|
|
374
|
+
// Persist early so a page reload during the animation restores the
|
|
375
|
+
// correct (already-popped) stack rather than the stale one.
|
|
376
|
+
commands.push({ type: "persistSnapshot" });
|
|
200
377
|
commands.push({ type: "unmountTopLayer" });
|
|
201
|
-
commands.push({ type: "
|
|
378
|
+
commands.push({ type: "clearFrameCache", layerId: popped.id });
|
|
379
|
+
commands.push({ type: "historyBack", n: framesToWalkBack });
|
|
202
380
|
commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
|
|
203
|
-
commands.push({ type: "persistSnapshot" });
|
|
204
381
|
} else {
|
|
205
382
|
// closeDialog first so the dialog's exit transition (opacity +
|
|
206
383
|
// backdrop background + display/overlay allow-discrete) starts
|
|
@@ -210,10 +387,13 @@ export function pop(state) {
|
|
|
210
387
|
// fade kicks in for *another* 220ms — visually the backdrop fades
|
|
211
388
|
// after the modal is gone.
|
|
212
389
|
commands.push({ type: "closeDialog" });
|
|
390
|
+
// Clear early so a page reload during the animation does not restore
|
|
391
|
+
// the modal that is already being dismissed.
|
|
392
|
+
commands.push({ type: "clearSnapshot" });
|
|
213
393
|
commands.push({ type: "unmountTopLayer" });
|
|
214
|
-
commands.push({ type: "
|
|
394
|
+
commands.push({ type: "clearFrameCache", layerId: popped.id });
|
|
395
|
+
commands.push({ type: "historyBack", n: framesToWalkBack });
|
|
215
396
|
commands.push({ type: "unlockScroll" });
|
|
216
|
-
commands.push({ type: "clearSnapshot" });
|
|
217
397
|
}
|
|
218
398
|
return { state: { ...state, layers: newLayers }, commands };
|
|
219
399
|
}
|
|
@@ -234,6 +414,11 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
|
|
|
234
414
|
}
|
|
235
415
|
|
|
236
416
|
const top = topLayer(state);
|
|
417
|
+
// replaceTop collapses the top layer's path back to a single frame
|
|
418
|
+
// — the existing path is forgotten. Walk history back one step per
|
|
419
|
+
// dropped frame so the browser's back button doesn't land on stale
|
|
420
|
+
// frame entries.
|
|
421
|
+
const framesToCollapse = top.frames.length - 1;
|
|
237
422
|
const next = freezeLayer({
|
|
238
423
|
id: patch.id ?? top.id,
|
|
239
424
|
url: patch.url ?? top.url,
|
|
@@ -243,6 +428,8 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
|
|
|
243
428
|
side: patch.side ?? top.side,
|
|
244
429
|
width: patch.width ?? top.width,
|
|
245
430
|
height: patch.height ?? top.height,
|
|
431
|
+
// single-frame layer — drop any path that was on the previous layer
|
|
432
|
+
frames: undefined,
|
|
246
433
|
});
|
|
247
434
|
const newLayers = Object.freeze([...state.layers.slice(0, -1), next]);
|
|
248
435
|
const depth = newLayers.length;
|
|
@@ -250,28 +437,35 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
|
|
|
250
437
|
const historyCmd = {
|
|
251
438
|
type: historyMode === "push" ? "pushHistory" : "replaceHistory",
|
|
252
439
|
url: next.url,
|
|
253
|
-
historyState: {
|
|
440
|
+
historyState: {
|
|
441
|
+
stackId: state.stackId,
|
|
442
|
+
layerId: next.id,
|
|
443
|
+
depth,
|
|
444
|
+
frameIndex: 0,
|
|
445
|
+
},
|
|
254
446
|
};
|
|
255
447
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
commands:
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
};
|
|
448
|
+
const commands = [];
|
|
449
|
+
if (framesToCollapse > 0) {
|
|
450
|
+
commands.push({ type: "clearFrameCache", layerId: top.id });
|
|
451
|
+
commands.push({ type: "historyBack", n: framesToCollapse });
|
|
452
|
+
}
|
|
453
|
+
commands.push({
|
|
454
|
+
type: "morphTopLayer",
|
|
455
|
+
layerId: next.id,
|
|
456
|
+
url: next.url,
|
|
457
|
+
depth,
|
|
458
|
+
variant: next.variant,
|
|
459
|
+
dismissible: next.dismissible,
|
|
460
|
+
...(next.size ? { size: next.size } : {}),
|
|
461
|
+
...(next.side ? { side: next.side } : {}),
|
|
462
|
+
...(next.width ? { width: next.width } : {}),
|
|
463
|
+
...(next.height ? { height: next.height } : {}),
|
|
464
|
+
});
|
|
465
|
+
commands.push(historyCmd);
|
|
466
|
+
commands.push({ type: "persistSnapshot" });
|
|
467
|
+
|
|
468
|
+
return { state: { ...state, layers: newLayers }, commands };
|
|
275
469
|
}
|
|
276
470
|
|
|
277
471
|
/**
|
|
@@ -281,24 +475,32 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
|
|
|
281
475
|
*/
|
|
282
476
|
export function closeAll(state) {
|
|
283
477
|
if (state.layers.length === 0) return { state, commands: [] };
|
|
284
|
-
const n = state.layers
|
|
478
|
+
const n = totalFrameCount(state.layers);
|
|
479
|
+
const cacheClears = state.layers.map((l) => ({
|
|
480
|
+
type: "clearFrameCache",
|
|
481
|
+
layerId: l.id,
|
|
482
|
+
}));
|
|
285
483
|
return {
|
|
286
484
|
state: { ...state, layers: Object.freeze([]) },
|
|
287
485
|
// closeDialog first so the dialog's exit transition runs in
|
|
288
486
|
// parallel with the layers' [data-leaving] transitions.
|
|
487
|
+
// clearSnapshot comes before unmountAllLayers so a reload during
|
|
488
|
+
// the animation does not restore a stack that is already closing.
|
|
289
489
|
commands: [
|
|
290
490
|
{ type: "closeDialog" },
|
|
491
|
+
{ type: "clearSnapshot" },
|
|
291
492
|
{ type: "unmountAllLayers" },
|
|
493
|
+
...cacheClears,
|
|
292
494
|
{ type: "unlockScroll" },
|
|
293
495
|
{ type: "historyBack", n },
|
|
294
|
-
{ type: "clearSnapshot" },
|
|
295
496
|
],
|
|
296
497
|
};
|
|
297
498
|
}
|
|
298
499
|
|
|
299
500
|
/**
|
|
300
|
-
* Reduce a browser `popstate` into a transition: pop layers,
|
|
301
|
-
* or request a rebuild from snapshot for
|
|
501
|
+
* Reduce a browser `popstate` into a transition: pop layers, step back
|
|
502
|
+
* through frames, morph the top, or request a rebuild from snapshot for
|
|
503
|
+
* forward navigation.
|
|
302
504
|
* @param {Stack} state
|
|
303
505
|
* @param {{ historyState: any, locationHref: string }} options
|
|
304
506
|
* @returns {Transition}
|
|
@@ -309,14 +511,19 @@ export function handlePopstate(state, { historyState, locationHref }) {
|
|
|
309
511
|
|
|
310
512
|
if (!isOurs) {
|
|
311
513
|
if (state.layers.length === 0) return { state, commands: [] };
|
|
514
|
+
const cacheClears = state.layers.map((l) => ({
|
|
515
|
+
type: "clearFrameCache",
|
|
516
|
+
layerId: l.id,
|
|
517
|
+
}));
|
|
312
518
|
return {
|
|
313
519
|
state: { ...state, layers: Object.freeze([]) },
|
|
314
|
-
// closeDialog first — see closeAll() for rationale.
|
|
520
|
+
// closeDialog and clearSnapshot first — see closeAll() for rationale.
|
|
315
521
|
commands: [
|
|
316
522
|
{ type: "closeDialog" },
|
|
523
|
+
{ type: "clearSnapshot" },
|
|
317
524
|
{ type: "unmountAllLayers" },
|
|
525
|
+
...cacheClears,
|
|
318
526
|
{ type: "unlockScroll" },
|
|
319
|
-
{ type: "clearSnapshot" },
|
|
320
527
|
],
|
|
321
528
|
};
|
|
322
529
|
}
|
|
@@ -324,24 +531,36 @@ export function handlePopstate(state, { historyState, locationHref }) {
|
|
|
324
531
|
const targetDepth = historyState.depth ?? 0;
|
|
325
532
|
const currentDepth = state.layers.length;
|
|
326
533
|
const targetLayerId = historyState.layerId ?? null;
|
|
534
|
+
const targetFrameIndex = historyState.frameIndex ?? 0;
|
|
327
535
|
|
|
328
536
|
if (targetDepth < currentDepth) {
|
|
537
|
+
const droppedLayers = state.layers.slice(targetDepth);
|
|
329
538
|
const newLayers = Object.freeze(state.layers.slice(0, targetDepth));
|
|
330
539
|
const newTop = newLayers[newLayers.length - 1] ?? null;
|
|
331
540
|
const commands = [];
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
541
|
+
if (newTop) {
|
|
542
|
+
// Persist before animation so a reload during the transition
|
|
543
|
+
// restores the correct remaining stack.
|
|
544
|
+
commands.push({ type: "persistSnapshot" });
|
|
545
|
+
} else {
|
|
546
|
+
// When popping back to the root via popstate, fire closeDialog
|
|
547
|
+
// first so the dialog's exit transition runs alongside the
|
|
548
|
+
// sequential unmountTopLayer cascade.
|
|
549
|
+
commands.push({ type: "closeDialog" });
|
|
550
|
+
// Clear before animation so a reload during the transition does
|
|
551
|
+
// not restore the stack that is already being dismissed.
|
|
552
|
+
commands.push({ type: "clearSnapshot" });
|
|
553
|
+
}
|
|
554
|
+
for (let i = 0; i < droppedLayers.length; i++) {
|
|
337
555
|
commands.push({ type: "unmountTopLayer" });
|
|
338
556
|
}
|
|
557
|
+
for (const dropped of droppedLayers) {
|
|
558
|
+
commands.push({ type: "clearFrameCache", layerId: dropped.id });
|
|
559
|
+
}
|
|
339
560
|
if (newTop) {
|
|
340
561
|
commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
|
|
341
|
-
commands.push({ type: "persistSnapshot" });
|
|
342
562
|
} else {
|
|
343
563
|
commands.push({ type: "unlockScroll" });
|
|
344
|
-
commands.push({ type: "clearSnapshot" });
|
|
345
564
|
}
|
|
346
565
|
return { state: { ...state, layers: newLayers }, commands };
|
|
347
566
|
}
|
|
@@ -356,6 +575,55 @@ export function handlePopstate(state, { historyState, locationHref }) {
|
|
|
356
575
|
}
|
|
357
576
|
|
|
358
577
|
const top = topLayer(state);
|
|
578
|
+
if (top && targetLayerId && top.id === targetLayerId) {
|
|
579
|
+
const currentFrameIndex = top.frames.length - 1;
|
|
580
|
+
if (targetFrameIndex === currentFrameIndex) {
|
|
581
|
+
return { state, commands: [] };
|
|
582
|
+
}
|
|
583
|
+
if (targetFrameIndex < currentFrameIndex) {
|
|
584
|
+
const newFrames = top.frames.slice(0, targetFrameIndex + 1);
|
|
585
|
+
const targetFrame = newFrames[newFrames.length - 1];
|
|
586
|
+
const updatedTop = freezeLayer({
|
|
587
|
+
id: top.id,
|
|
588
|
+
url: targetFrame.url,
|
|
589
|
+
variant: top.variant,
|
|
590
|
+
dismissible: top.dismissible,
|
|
591
|
+
size: top.size,
|
|
592
|
+
side: top.side,
|
|
593
|
+
width: top.width,
|
|
594
|
+
height: top.height,
|
|
595
|
+
frames: newFrames,
|
|
596
|
+
});
|
|
597
|
+
const newLayers = Object.freeze([
|
|
598
|
+
...state.layers.slice(0, -1),
|
|
599
|
+
updatedTop,
|
|
600
|
+
]);
|
|
601
|
+
return {
|
|
602
|
+
state: { ...state, layers: newLayers },
|
|
603
|
+
commands: [
|
|
604
|
+
{
|
|
605
|
+
type: "unmountFrame",
|
|
606
|
+
layerId: top.id,
|
|
607
|
+
fromFrameIndex: currentFrameIndex,
|
|
608
|
+
toFrameIndex: targetFrameIndex,
|
|
609
|
+
url: targetFrame.url,
|
|
610
|
+
stale: targetFrame.stale,
|
|
611
|
+
},
|
|
612
|
+
{ type: "persistSnapshot" },
|
|
613
|
+
],
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
// Forward popstate to a frame we no longer track — happens when the
|
|
617
|
+
// user pressed back, then forward, after the path frames were dropped
|
|
618
|
+
// from state. Defer to the controller (snapshot rebuild / fetch).
|
|
619
|
+
return {
|
|
620
|
+
state,
|
|
621
|
+
commands: [
|
|
622
|
+
{ type: "rebuildFromSnapshot", targetDepth, targetLayerId },
|
|
623
|
+
],
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
359
627
|
if (top && targetLayerId && top.id !== targetLayerId) {
|
|
360
628
|
const updatedTop = freezeLayer({
|
|
361
629
|
id: targetLayerId,
|
|
@@ -374,6 +642,7 @@ export function handlePopstate(state, { historyState, locationHref }) {
|
|
|
374
642
|
return {
|
|
375
643
|
state: { ...state, layers: newLayers },
|
|
376
644
|
commands: [
|
|
645
|
+
{ type: "clearFrameCache", layerId: top.id },
|
|
377
646
|
{
|
|
378
647
|
type: "morphTopLayer",
|
|
379
648
|
layerId: targetLayerId,
|
|
@@ -396,6 +665,11 @@ export function handlePopstate(state, { historyState, locationHref }) {
|
|
|
396
665
|
|
|
397
666
|
/**
|
|
398
667
|
* Serialize the stack for sessionStorage. Versioned + timestamped.
|
|
668
|
+
*
|
|
669
|
+
* Frames are serialized as `{ url, stale }` only — the cached HTML lives
|
|
670
|
+
* in the runtime, not the snapshot, so a refresh refetches the top frame
|
|
671
|
+
* and lazily refetches earlier frames if/when the user steps back.
|
|
672
|
+
*
|
|
399
673
|
* @param {Stack} state
|
|
400
674
|
* @param {{ now?: () => number }} [options]
|
|
401
675
|
* @returns {string}
|
|
@@ -405,14 +679,32 @@ export function snapshot(state, { now = Date.now } = {}) {
|
|
|
405
679
|
v: SNAPSHOT_VERSION,
|
|
406
680
|
stackId: state.stackId,
|
|
407
681
|
baseUrl: state.baseUrl,
|
|
408
|
-
layers: state.layers,
|
|
682
|
+
layers: state.layers.map(serializeLayer),
|
|
409
683
|
savedAt: now(),
|
|
410
684
|
});
|
|
411
685
|
}
|
|
412
686
|
|
|
687
|
+
function serializeLayer(layer) {
|
|
688
|
+
return {
|
|
689
|
+
id: layer.id,
|
|
690
|
+
url: layer.url,
|
|
691
|
+
variant: layer.variant,
|
|
692
|
+
dismissible: layer.dismissible,
|
|
693
|
+
size: layer.size,
|
|
694
|
+
side: layer.side,
|
|
695
|
+
width: layer.width,
|
|
696
|
+
height: layer.height,
|
|
697
|
+
frames: layer.frames.map((f) => ({ url: f.url, stale: f.stale })),
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
413
701
|
/**
|
|
414
702
|
* Restore a stack from a serialized snapshot. Returns null on any validation
|
|
415
703
|
* failure (wrong stackId, expired, malformed JSON, etc.).
|
|
704
|
+
*
|
|
705
|
+
* Accepts both v1 (pre-frames) and v2 snapshots: v1 layers are rehydrated
|
|
706
|
+
* with a synthetic single-frame array so existing tabs survive an upgrade.
|
|
707
|
+
*
|
|
416
708
|
* @param {string} serialized
|
|
417
709
|
* @param {{ stackId?: string, maxAgeMs?: number, now?: () => number }} [options]
|
|
418
710
|
* @returns {Stack|null}
|
|
@@ -428,7 +720,7 @@ export function restore(
|
|
|
428
720
|
} catch {
|
|
429
721
|
return null;
|
|
430
722
|
}
|
|
431
|
-
if (parsed?.v !== SNAPSHOT_VERSION) return null;
|
|
723
|
+
if (parsed?.v !== 1 && parsed?.v !== SNAPSHOT_VERSION) return null;
|
|
432
724
|
if (typeof parsed.stackId !== "string") return null;
|
|
433
725
|
if (typeof parsed.baseUrl !== "string") return null;
|
|
434
726
|
if (!Array.isArray(parsed.layers)) return null;
|
|
@@ -439,6 +731,12 @@ export function restore(
|
|
|
439
731
|
for (const l of parsed.layers) {
|
|
440
732
|
if (!l || typeof l.id !== "string" || typeof l.url !== "string") return null;
|
|
441
733
|
if (!VARIANTS.includes(l.variant)) return null;
|
|
734
|
+
if (l.frames !== undefined) {
|
|
735
|
+
if (!Array.isArray(l.frames) || l.frames.length === 0) return null;
|
|
736
|
+
for (const f of l.frames) {
|
|
737
|
+
if (!f || typeof f.url !== "string") return null;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
442
740
|
}
|
|
443
741
|
|
|
444
742
|
return Object.freeze({
|