modal_stack 0.2.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 +59 -0
- data/README.md +136 -52
- data/app/assets/javascripts/modal_stack.js +612 -63
- data/app/assets/stylesheets/modal_stack/bootstrap.css +120 -11
- data/app/assets/stylesheets/modal_stack/{tailwind.css → tailwind_v3.css} +82 -14
- data/app/assets/stylesheets/modal_stack/tailwind_v4.css +372 -0
- data/app/assets/stylesheets/modal_stack/vanilla.css +128 -11
- data/app/javascript/modal_stack/controllers/modal_stack_back_link_controller.js +32 -0
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +54 -0
- data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +29 -7
- data/app/javascript/modal_stack/install.js +7 -1
- data/app/javascript/modal_stack/orchestrator.js +132 -3
- data/app/javascript/modal_stack/orchestrator.test.js +264 -2
- data/app/javascript/modal_stack/runtime.js +222 -13
- data/app/javascript/modal_stack/runtime.test.js +151 -0
- data/app/javascript/modal_stack/state.js +338 -39
- data/app/javascript/modal_stack/state.test.js +400 -13
- 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/install_generator.rb +18 -4
- data/lib/generators/modal_stack/install/templates/initializer.rb +21 -5
- 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 +43 -17
- data/lib/modal_stack/controller_extensions.rb +8 -1
- 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 +15 -3
- 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 +11 -3
|
@@ -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,24 +234,160 @@ 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
|
-
const commands = [
|
|
199
|
-
{ type: "unmountTopLayer" },
|
|
200
|
-
{ type: "historyBack", n: 1 },
|
|
201
|
-
];
|
|
372
|
+
const commands = [];
|
|
202
373
|
if (newTop) {
|
|
374
|
+
commands.push({ type: "unmountTopLayer" });
|
|
375
|
+
commands.push({ type: "clearFrameCache", layerId: popped.id });
|
|
376
|
+
commands.push({ type: "historyBack", n: framesToWalkBack });
|
|
203
377
|
commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
|
|
204
378
|
commands.push({ type: "persistSnapshot" });
|
|
205
379
|
} else {
|
|
380
|
+
// closeDialog first so the dialog's exit transition (opacity +
|
|
381
|
+
// backdrop background + display/overlay allow-discrete) starts
|
|
382
|
+
// immediately and runs in parallel with the layer's [data-leaving]
|
|
383
|
+
// transition. Without this order, the orchestrator awaits 220ms
|
|
384
|
+
// on unmountTopLayer before closing the dialog, then the backdrop
|
|
385
|
+
// fade kicks in for *another* 220ms — visually the backdrop fades
|
|
386
|
+
// after the modal is gone.
|
|
206
387
|
commands.push({ type: "closeDialog" });
|
|
388
|
+
commands.push({ type: "unmountTopLayer" });
|
|
389
|
+
commands.push({ type: "clearFrameCache", layerId: popped.id });
|
|
390
|
+
commands.push({ type: "historyBack", n: framesToWalkBack });
|
|
207
391
|
commands.push({ type: "unlockScroll" });
|
|
208
392
|
commands.push({ type: "clearSnapshot" });
|
|
209
393
|
}
|
|
@@ -226,6 +410,11 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
|
|
|
226
410
|
}
|
|
227
411
|
|
|
228
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;
|
|
229
418
|
const next = freezeLayer({
|
|
230
419
|
id: patch.id ?? top.id,
|
|
231
420
|
url: patch.url ?? top.url,
|
|
@@ -235,6 +424,8 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
|
|
|
235
424
|
side: patch.side ?? top.side,
|
|
236
425
|
width: patch.width ?? top.width,
|
|
237
426
|
height: patch.height ?? top.height,
|
|
427
|
+
// single-frame layer — drop any path that was on the previous layer
|
|
428
|
+
frames: undefined,
|
|
238
429
|
});
|
|
239
430
|
const newLayers = Object.freeze([...state.layers.slice(0, -1), next]);
|
|
240
431
|
const depth = newLayers.length;
|
|
@@ -242,28 +433,35 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
|
|
|
242
433
|
const historyCmd = {
|
|
243
434
|
type: historyMode === "push" ? "pushHistory" : "replaceHistory",
|
|
244
435
|
url: next.url,
|
|
245
|
-
historyState: {
|
|
436
|
+
historyState: {
|
|
437
|
+
stackId: state.stackId,
|
|
438
|
+
layerId: next.id,
|
|
439
|
+
depth,
|
|
440
|
+
frameIndex: 0,
|
|
441
|
+
},
|
|
246
442
|
};
|
|
247
443
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
commands:
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
};
|
|
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 };
|
|
267
465
|
}
|
|
268
466
|
|
|
269
467
|
/**
|
|
@@ -273,12 +471,19 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
|
|
|
273
471
|
*/
|
|
274
472
|
export function closeAll(state) {
|
|
275
473
|
if (state.layers.length === 0) return { state, commands: [] };
|
|
276
|
-
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
|
+
}));
|
|
277
479
|
return {
|
|
278
480
|
state: { ...state, layers: Object.freeze([]) },
|
|
481
|
+
// closeDialog first so the dialog's exit transition runs in
|
|
482
|
+
// parallel with the layers' [data-leaving] transitions.
|
|
279
483
|
commands: [
|
|
280
|
-
{ type: "unmountAllLayers" },
|
|
281
484
|
{ type: "closeDialog" },
|
|
485
|
+
{ type: "unmountAllLayers" },
|
|
486
|
+
...cacheClears,
|
|
282
487
|
{ type: "unlockScroll" },
|
|
283
488
|
{ type: "historyBack", n },
|
|
284
489
|
{ type: "clearSnapshot" },
|
|
@@ -287,8 +492,9 @@ export function closeAll(state) {
|
|
|
287
492
|
}
|
|
288
493
|
|
|
289
494
|
/**
|
|
290
|
-
* Reduce a browser `popstate` into a transition: pop layers,
|
|
291
|
-
* 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.
|
|
292
498
|
* @param {Stack} state
|
|
293
499
|
* @param {{ historyState: any, locationHref: string }} options
|
|
294
500
|
* @returns {Transition}
|
|
@@ -299,11 +505,17 @@ export function handlePopstate(state, { historyState, locationHref }) {
|
|
|
299
505
|
|
|
300
506
|
if (!isOurs) {
|
|
301
507
|
if (state.layers.length === 0) return { state, commands: [] };
|
|
508
|
+
const cacheClears = state.layers.map((l) => ({
|
|
509
|
+
type: "clearFrameCache",
|
|
510
|
+
layerId: l.id,
|
|
511
|
+
}));
|
|
302
512
|
return {
|
|
303
513
|
state: { ...state, layers: Object.freeze([]) },
|
|
514
|
+
// closeDialog first — see closeAll() for rationale.
|
|
304
515
|
commands: [
|
|
305
|
-
{ type: "unmountAllLayers" },
|
|
306
516
|
{ type: "closeDialog" },
|
|
517
|
+
{ type: "unmountAllLayers" },
|
|
518
|
+
...cacheClears,
|
|
307
519
|
{ type: "unlockScroll" },
|
|
308
520
|
{ type: "clearSnapshot" },
|
|
309
521
|
],
|
|
@@ -313,19 +525,27 @@ export function handlePopstate(state, { historyState, locationHref }) {
|
|
|
313
525
|
const targetDepth = historyState.depth ?? 0;
|
|
314
526
|
const currentDepth = state.layers.length;
|
|
315
527
|
const targetLayerId = historyState.layerId ?? null;
|
|
528
|
+
const targetFrameIndex = historyState.frameIndex ?? 0;
|
|
316
529
|
|
|
317
530
|
if (targetDepth < currentDepth) {
|
|
531
|
+
const droppedLayers = state.layers.slice(targetDepth);
|
|
318
532
|
const newLayers = Object.freeze(state.layers.slice(0, targetDepth));
|
|
319
533
|
const newTop = newLayers[newLayers.length - 1] ?? null;
|
|
320
534
|
const commands = [];
|
|
321
|
-
|
|
535
|
+
// When popping back to the root via popstate, fire closeDialog
|
|
536
|
+
// first so the dialog's exit transition runs alongside the
|
|
537
|
+
// sequential unmountTopLayer cascade.
|
|
538
|
+
if (!newTop) commands.push({ type: "closeDialog" });
|
|
539
|
+
for (let i = 0; i < droppedLayers.length; i++) {
|
|
322
540
|
commands.push({ type: "unmountTopLayer" });
|
|
323
541
|
}
|
|
542
|
+
for (const dropped of droppedLayers) {
|
|
543
|
+
commands.push({ type: "clearFrameCache", layerId: dropped.id });
|
|
544
|
+
}
|
|
324
545
|
if (newTop) {
|
|
325
546
|
commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
|
|
326
547
|
commands.push({ type: "persistSnapshot" });
|
|
327
548
|
} else {
|
|
328
|
-
commands.push({ type: "closeDialog" });
|
|
329
549
|
commands.push({ type: "unlockScroll" });
|
|
330
550
|
commands.push({ type: "clearSnapshot" });
|
|
331
551
|
}
|
|
@@ -342,6 +562,55 @@ export function handlePopstate(state, { historyState, locationHref }) {
|
|
|
342
562
|
}
|
|
343
563
|
|
|
344
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
|
+
|
|
345
614
|
if (top && targetLayerId && top.id !== targetLayerId) {
|
|
346
615
|
const updatedTop = freezeLayer({
|
|
347
616
|
id: targetLayerId,
|
|
@@ -360,6 +629,7 @@ export function handlePopstate(state, { historyState, locationHref }) {
|
|
|
360
629
|
return {
|
|
361
630
|
state: { ...state, layers: newLayers },
|
|
362
631
|
commands: [
|
|
632
|
+
{ type: "clearFrameCache", layerId: top.id },
|
|
363
633
|
{
|
|
364
634
|
type: "morphTopLayer",
|
|
365
635
|
layerId: targetLayerId,
|
|
@@ -382,6 +652,11 @@ export function handlePopstate(state, { historyState, locationHref }) {
|
|
|
382
652
|
|
|
383
653
|
/**
|
|
384
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
|
+
*
|
|
385
660
|
* @param {Stack} state
|
|
386
661
|
* @param {{ now?: () => number }} [options]
|
|
387
662
|
* @returns {string}
|
|
@@ -391,14 +666,32 @@ export function snapshot(state, { now = Date.now } = {}) {
|
|
|
391
666
|
v: SNAPSHOT_VERSION,
|
|
392
667
|
stackId: state.stackId,
|
|
393
668
|
baseUrl: state.baseUrl,
|
|
394
|
-
layers: state.layers,
|
|
669
|
+
layers: state.layers.map(serializeLayer),
|
|
395
670
|
savedAt: now(),
|
|
396
671
|
});
|
|
397
672
|
}
|
|
398
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
|
+
|
|
399
688
|
/**
|
|
400
689
|
* Restore a stack from a serialized snapshot. Returns null on any validation
|
|
401
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
|
+
*
|
|
402
695
|
* @param {string} serialized
|
|
403
696
|
* @param {{ stackId?: string, maxAgeMs?: number, now?: () => number }} [options]
|
|
404
697
|
* @returns {Stack|null}
|
|
@@ -414,7 +707,7 @@ export function restore(
|
|
|
414
707
|
} catch {
|
|
415
708
|
return null;
|
|
416
709
|
}
|
|
417
|
-
if (parsed?.v !== SNAPSHOT_VERSION) return null;
|
|
710
|
+
if (parsed?.v !== 1 && parsed?.v !== SNAPSHOT_VERSION) return null;
|
|
418
711
|
if (typeof parsed.stackId !== "string") return null;
|
|
419
712
|
if (typeof parsed.baseUrl !== "string") return null;
|
|
420
713
|
if (!Array.isArray(parsed.layers)) return null;
|
|
@@ -425,6 +718,12 @@ export function restore(
|
|
|
425
718
|
for (const l of parsed.layers) {
|
|
426
719
|
if (!l || typeof l.id !== "string" || typeof l.url !== "string") return null;
|
|
427
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
|
+
}
|
|
428
727
|
}
|
|
429
728
|
|
|
430
729
|
return Object.freeze({
|