modal_stack 0.3.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -0
- data/README.md +187 -36
- data/app/assets/javascripts/modal_stack.js +693 -73
- data/app/assets/stylesheets/modal_stack/bootstrap.css +113 -3
- data/app/assets/stylesheets/modal_stack/tailwind_v3.css +63 -2
- data/app/assets/stylesheets/modal_stack/tailwind_v4.css +63 -2
- data/app/assets/stylesheets/modal_stack/vanilla.css +121 -3
- data/app/javascript/modal_stack/controllers/modal_stack_back_link_controller.js +32 -0
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +161 -8
- data/app/javascript/modal_stack/install.js +7 -1
- data/app/javascript/modal_stack/orchestrator.js +70 -10
- data/app/javascript/modal_stack/orchestrator.test.js +98 -2
- data/app/javascript/modal_stack/runtime.js +316 -9
- data/app/javascript/modal_stack/runtime.test.js +90 -6
- data/app/javascript/modal_stack/state.js +343 -45
- data/app/javascript/modal_stack/state.test.js +404 -17
- data/app/views/modal_stack/_dialog.html.erb +1 -0
- data/app/views/modal_stack/_panel.html.erb +4 -0
- data/lib/generators/modal_stack/install/templates/initializer.rb +9 -0
- data/lib/generators/modal_stack/views/templates/_dialog.html.erb +9 -0
- data/lib/generators/modal_stack/views/templates/_panel.html.erb +18 -0
- data/lib/generators/modal_stack/views/views_generator.rb +50 -0
- data/lib/modal_stack/capybara.rb +21 -0
- data/lib/modal_stack/configuration.rb +37 -16
- data/lib/modal_stack/engine.rb +2 -0
- data/lib/modal_stack/helpers/modal_back_link_helper.rb +48 -0
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +7 -1
- data/lib/modal_stack/helpers/modal_stack_container_helper.rb +42 -15
- data/lib/modal_stack/turbo_streams_extension.rb +56 -0
- data/lib/modal_stack/version.rb +1 -1
- data/lib/modal_stack.rb +5 -1
- metadata +9 -2
|
@@ -1,6 +1,33 @@
|
|
|
1
|
-
// app/javascript/modal_stack/controllers/
|
|
1
|
+
// app/javascript/modal_stack/controllers/modal_stack_back_link_controller.js
|
|
2
2
|
import { Controller } from "@hotwired/stimulus";
|
|
3
3
|
|
|
4
|
+
class ModalStackBackLinkController extends Controller {
|
|
5
|
+
static values = {
|
|
6
|
+
steps: { type: Number, default: 1 }
|
|
7
|
+
};
|
|
8
|
+
trigger(event) {
|
|
9
|
+
const stackController = this.#stackController();
|
|
10
|
+
if (!stackController)
|
|
11
|
+
return;
|
|
12
|
+
if (event) {
|
|
13
|
+
event.preventDefault();
|
|
14
|
+
event.stopPropagation();
|
|
15
|
+
}
|
|
16
|
+
stackController.orchestrator.pathBack({
|
|
17
|
+
steps: this.stepsValue > 0 ? this.stepsValue : 1
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
#stackController() {
|
|
21
|
+
const stack = document.querySelector('[data-controller~="modal-stack"]');
|
|
22
|
+
if (!stack)
|
|
23
|
+
return null;
|
|
24
|
+
return this.application.getControllerForElementAndIdentifier(stack, "modal-stack");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// app/javascript/modal_stack/controllers/modal_stack_controller.js
|
|
29
|
+
import { Controller as Controller2 } from "@hotwired/stimulus";
|
|
30
|
+
|
|
4
31
|
// app/javascript/modal_stack/state.js
|
|
5
32
|
var VARIANTS = Object.freeze([
|
|
6
33
|
"modal",
|
|
@@ -8,7 +35,8 @@ var VARIANTS = Object.freeze([
|
|
|
8
35
|
"bottom_sheet",
|
|
9
36
|
"confirmation"
|
|
10
37
|
]);
|
|
11
|
-
var
|
|
38
|
+
var TRANSITIONS = Object.freeze(["slide", "fade", "none"]);
|
|
39
|
+
var SNAPSHOT_VERSION = 2;
|
|
12
40
|
var DEFAULT_MAX_AGE_MS = 30 * 60 * 1000;
|
|
13
41
|
var DRAWER_SIDES = Object.freeze(["left", "right", "top", "bottom"]);
|
|
14
42
|
var MAX_DEPTH_STRATEGIES = Object.freeze(["raise", "warn", "silent"]);
|
|
@@ -33,17 +61,30 @@ function normalizeLayerOptions({ variant, size, side, width, height }) {
|
|
|
33
61
|
height: height ?? null
|
|
34
62
|
};
|
|
35
63
|
}
|
|
36
|
-
function
|
|
64
|
+
function freezeFrame({ url, stale = false }) {
|
|
65
|
+
if (typeof url !== "string" || url.length === 0) {
|
|
66
|
+
throw new Error("frame.url required");
|
|
67
|
+
}
|
|
68
|
+
return Object.freeze({ url, stale: !!stale });
|
|
69
|
+
}
|
|
70
|
+
function freezeFrames(frames) {
|
|
71
|
+
return Object.freeze(frames.map(freezeFrame));
|
|
72
|
+
}
|
|
73
|
+
function freezeLayer({ id, url, variant, dismissible, size, side, width, height, frames }) {
|
|
37
74
|
const normalized = normalizeLayerOptions({ variant, size, side, width, height });
|
|
75
|
+
const framesArray = Array.isArray(frames) && frames.length > 0 ? frames : [{ url, stale: false }];
|
|
76
|
+
const frozenFrames = freezeFrames(framesArray);
|
|
77
|
+
const topUrl = frozenFrames[frozenFrames.length - 1].url;
|
|
38
78
|
return Object.freeze({
|
|
39
79
|
id,
|
|
40
|
-
url,
|
|
80
|
+
url: topUrl,
|
|
41
81
|
variant,
|
|
42
82
|
dismissible: !!dismissible,
|
|
43
83
|
size: normalized.size,
|
|
44
84
|
side: normalized.side,
|
|
45
85
|
width: normalized.width,
|
|
46
|
-
height: normalized.height
|
|
86
|
+
height: normalized.height,
|
|
87
|
+
frames: frozenFrames
|
|
47
88
|
});
|
|
48
89
|
}
|
|
49
90
|
function createStack({ stackId, baseUrl }) {
|
|
@@ -56,6 +97,20 @@ function createStack({ stackId, baseUrl }) {
|
|
|
56
97
|
function topLayer(state) {
|
|
57
98
|
return state.layers[state.layers.length - 1] ?? null;
|
|
58
99
|
}
|
|
100
|
+
function totalFrameCount(layers) {
|
|
101
|
+
let n = 0;
|
|
102
|
+
for (const l of layers)
|
|
103
|
+
n += l.frames.length;
|
|
104
|
+
return n;
|
|
105
|
+
}
|
|
106
|
+
function validateTransition(value) {
|
|
107
|
+
if (value == null)
|
|
108
|
+
return null;
|
|
109
|
+
if (!TRANSITIONS.includes(value)) {
|
|
110
|
+
throw new Error(`unknown transition: ${value}`);
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
59
114
|
function push(state, layer, options = {}) {
|
|
60
115
|
if (!layer?.id)
|
|
61
116
|
throw new Error("layer.id required");
|
|
@@ -116,28 +171,136 @@ function push(state, layer, options = {}) {
|
|
|
116
171
|
commands.push({
|
|
117
172
|
type: "pushHistory",
|
|
118
173
|
url: newLayer.url,
|
|
119
|
-
historyState: {
|
|
174
|
+
historyState: {
|
|
175
|
+
stackId: state.stackId,
|
|
176
|
+
layerId: newLayer.id,
|
|
177
|
+
depth,
|
|
178
|
+
frameIndex: 0
|
|
179
|
+
}
|
|
120
180
|
});
|
|
121
181
|
commands.push({ type: "persistSnapshot" });
|
|
122
182
|
return { state: { ...state, layers }, commands };
|
|
123
183
|
}
|
|
184
|
+
function pathTo(state, frame, options = {}) {
|
|
185
|
+
if (state.layers.length === 0) {
|
|
186
|
+
throw new Error("pathTo requires at least one layer");
|
|
187
|
+
}
|
|
188
|
+
if (typeof frame?.url !== "string" || frame.url.length === 0) {
|
|
189
|
+
throw new Error("pathTo requires a frame.url");
|
|
190
|
+
}
|
|
191
|
+
const transition = validateTransition(options.transition ?? null);
|
|
192
|
+
const top = topLayer(state);
|
|
193
|
+
const previousFrameIndex = top.frames.length - 1;
|
|
194
|
+
const newFrame = freezeFrame({ url: frame.url, stale: !!frame.stale });
|
|
195
|
+
const newFrames = [...top.frames, newFrame];
|
|
196
|
+
const newTop = freezeLayer({
|
|
197
|
+
id: top.id,
|
|
198
|
+
url: newFrame.url,
|
|
199
|
+
variant: top.variant,
|
|
200
|
+
dismissible: top.dismissible,
|
|
201
|
+
size: top.size,
|
|
202
|
+
side: top.side,
|
|
203
|
+
width: top.width,
|
|
204
|
+
height: top.height,
|
|
205
|
+
frames: newFrames
|
|
206
|
+
});
|
|
207
|
+
const newLayers = Object.freeze([...state.layers.slice(0, -1), newTop]);
|
|
208
|
+
const depth = newLayers.length;
|
|
209
|
+
const newFrameIndex = newFrames.length - 1;
|
|
210
|
+
return {
|
|
211
|
+
state: { ...state, layers: newLayers },
|
|
212
|
+
commands: [
|
|
213
|
+
{
|
|
214
|
+
type: "mountFrame",
|
|
215
|
+
layerId: newTop.id,
|
|
216
|
+
fromFrameIndex: previousFrameIndex,
|
|
217
|
+
toFrameIndex: newFrameIndex,
|
|
218
|
+
url: newFrame.url,
|
|
219
|
+
stale: newFrame.stale,
|
|
220
|
+
...transition ? { transition } : {}
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
type: "pushHistory",
|
|
224
|
+
url: newFrame.url,
|
|
225
|
+
historyState: {
|
|
226
|
+
stackId: state.stackId,
|
|
227
|
+
layerId: newTop.id,
|
|
228
|
+
depth,
|
|
229
|
+
frameIndex: newFrameIndex
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
{ type: "persistSnapshot" }
|
|
233
|
+
]
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function pathBack(state, options = {}) {
|
|
237
|
+
if (state.layers.length === 0) {
|
|
238
|
+
throw new Error("pathBack requires at least one layer");
|
|
239
|
+
}
|
|
240
|
+
const requestedSteps = options.steps == null ? 1 : Math.floor(options.steps);
|
|
241
|
+
if (!Number.isFinite(requestedSteps) || requestedSteps < 1) {
|
|
242
|
+
throw new Error("pathBack: steps must be a positive integer");
|
|
243
|
+
}
|
|
244
|
+
const transition = validateTransition(options.transition ?? null);
|
|
245
|
+
const top = topLayer(state);
|
|
246
|
+
const fromFrameIndex = top.frames.length - 1;
|
|
247
|
+
const maxSteps = top.frames.length - 1;
|
|
248
|
+
const effectiveSteps = Math.min(requestedSteps, maxSteps);
|
|
249
|
+
if (effectiveSteps === 0)
|
|
250
|
+
return { state, commands: [] };
|
|
251
|
+
const toFrameIndex = fromFrameIndex - effectiveSteps;
|
|
252
|
+
const newFrames = top.frames.slice(0, toFrameIndex + 1);
|
|
253
|
+
const targetFrame = newFrames[newFrames.length - 1];
|
|
254
|
+
const newTop = freezeLayer({
|
|
255
|
+
id: top.id,
|
|
256
|
+
url: targetFrame.url,
|
|
257
|
+
variant: top.variant,
|
|
258
|
+
dismissible: top.dismissible,
|
|
259
|
+
size: top.size,
|
|
260
|
+
side: top.side,
|
|
261
|
+
width: top.width,
|
|
262
|
+
height: top.height,
|
|
263
|
+
frames: newFrames
|
|
264
|
+
});
|
|
265
|
+
const newLayers = Object.freeze([...state.layers.slice(0, -1), newTop]);
|
|
266
|
+
return {
|
|
267
|
+
state: { ...state, layers: newLayers },
|
|
268
|
+
commands: [
|
|
269
|
+
{
|
|
270
|
+
type: "unmountFrame",
|
|
271
|
+
layerId: newTop.id,
|
|
272
|
+
fromFrameIndex,
|
|
273
|
+
toFrameIndex,
|
|
274
|
+
url: targetFrame.url,
|
|
275
|
+
stale: targetFrame.stale,
|
|
276
|
+
...transition ? { transition } : {}
|
|
277
|
+
},
|
|
278
|
+
{ type: "historyBack", n: effectiveSteps },
|
|
279
|
+
{ type: "persistSnapshot" }
|
|
280
|
+
]
|
|
281
|
+
};
|
|
282
|
+
}
|
|
124
283
|
function pop(state) {
|
|
125
284
|
if (state.layers.length === 0)
|
|
126
285
|
return { state, commands: [] };
|
|
286
|
+
const popped = topLayer(state);
|
|
287
|
+
const framesToWalkBack = popped.frames.length;
|
|
127
288
|
const newLayers = Object.freeze(state.layers.slice(0, -1));
|
|
128
289
|
const newTop = newLayers[newLayers.length - 1] ?? null;
|
|
129
290
|
const commands = [];
|
|
130
291
|
if (newTop) {
|
|
292
|
+
commands.push({ type: "persistSnapshot" });
|
|
131
293
|
commands.push({ type: "unmountTopLayer" });
|
|
132
|
-
commands.push({ type: "
|
|
294
|
+
commands.push({ type: "clearFrameCache", layerId: popped.id });
|
|
295
|
+
commands.push({ type: "historyBack", n: framesToWalkBack });
|
|
133
296
|
commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
|
|
134
|
-
commands.push({ type: "persistSnapshot" });
|
|
135
297
|
} else {
|
|
136
298
|
commands.push({ type: "closeDialog" });
|
|
299
|
+
commands.push({ type: "clearSnapshot" });
|
|
137
300
|
commands.push({ type: "unmountTopLayer" });
|
|
138
|
-
commands.push({ type: "
|
|
301
|
+
commands.push({ type: "clearFrameCache", layerId: popped.id });
|
|
302
|
+
commands.push({ type: "historyBack", n: framesToWalkBack });
|
|
139
303
|
commands.push({ type: "unlockScroll" });
|
|
140
|
-
commands.push({ type: "clearSnapshot" });
|
|
141
304
|
}
|
|
142
305
|
return { state: { ...state, layers: newLayers }, commands };
|
|
143
306
|
}
|
|
@@ -149,6 +312,7 @@ function replaceTop(state, patch, { historyMode = "replace" } = {}) {
|
|
|
149
312
|
throw new Error(`unknown historyMode: ${historyMode}`);
|
|
150
313
|
}
|
|
151
314
|
const top = topLayer(state);
|
|
315
|
+
const framesToCollapse = top.frames.length - 1;
|
|
152
316
|
const next = freezeLayer({
|
|
153
317
|
id: patch.id ?? top.id,
|
|
154
318
|
url: patch.url ?? top.url,
|
|
@@ -157,47 +321,59 @@ function replaceTop(state, patch, { historyMode = "replace" } = {}) {
|
|
|
157
321
|
size: patch.size ?? top.size,
|
|
158
322
|
side: patch.side ?? top.side,
|
|
159
323
|
width: patch.width ?? top.width,
|
|
160
|
-
height: patch.height ?? top.height
|
|
324
|
+
height: patch.height ?? top.height,
|
|
325
|
+
frames: undefined
|
|
161
326
|
});
|
|
162
327
|
const newLayers = Object.freeze([...state.layers.slice(0, -1), next]);
|
|
163
328
|
const depth = newLayers.length;
|
|
164
329
|
const historyCmd = {
|
|
165
330
|
type: historyMode === "push" ? "pushHistory" : "replaceHistory",
|
|
166
331
|
url: next.url,
|
|
167
|
-
historyState: {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
type: "morphTopLayer",
|
|
174
|
-
layerId: next.id,
|
|
175
|
-
url: next.url,
|
|
176
|
-
depth,
|
|
177
|
-
variant: next.variant,
|
|
178
|
-
dismissible: next.dismissible,
|
|
179
|
-
...next.size ? { size: next.size } : {},
|
|
180
|
-
...next.side ? { side: next.side } : {},
|
|
181
|
-
...next.width ? { width: next.width } : {},
|
|
182
|
-
...next.height ? { height: next.height } : {}
|
|
183
|
-
},
|
|
184
|
-
historyCmd,
|
|
185
|
-
{ type: "persistSnapshot" }
|
|
186
|
-
]
|
|
332
|
+
historyState: {
|
|
333
|
+
stackId: state.stackId,
|
|
334
|
+
layerId: next.id,
|
|
335
|
+
depth,
|
|
336
|
+
frameIndex: 0
|
|
337
|
+
}
|
|
187
338
|
};
|
|
339
|
+
const commands = [];
|
|
340
|
+
if (framesToCollapse > 0) {
|
|
341
|
+
commands.push({ type: "clearFrameCache", layerId: top.id });
|
|
342
|
+
commands.push({ type: "historyBack", n: framesToCollapse });
|
|
343
|
+
}
|
|
344
|
+
commands.push({
|
|
345
|
+
type: "morphTopLayer",
|
|
346
|
+
layerId: next.id,
|
|
347
|
+
url: next.url,
|
|
348
|
+
depth,
|
|
349
|
+
variant: next.variant,
|
|
350
|
+
dismissible: next.dismissible,
|
|
351
|
+
...next.size ? { size: next.size } : {},
|
|
352
|
+
...next.side ? { side: next.side } : {},
|
|
353
|
+
...next.width ? { width: next.width } : {},
|
|
354
|
+
...next.height ? { height: next.height } : {}
|
|
355
|
+
});
|
|
356
|
+
commands.push(historyCmd);
|
|
357
|
+
commands.push({ type: "persistSnapshot" });
|
|
358
|
+
return { state: { ...state, layers: newLayers }, commands };
|
|
188
359
|
}
|
|
189
360
|
function closeAll(state) {
|
|
190
361
|
if (state.layers.length === 0)
|
|
191
362
|
return { state, commands: [] };
|
|
192
|
-
const n = state.layers
|
|
363
|
+
const n = totalFrameCount(state.layers);
|
|
364
|
+
const cacheClears = state.layers.map((l) => ({
|
|
365
|
+
type: "clearFrameCache",
|
|
366
|
+
layerId: l.id
|
|
367
|
+
}));
|
|
193
368
|
return {
|
|
194
369
|
state: { ...state, layers: Object.freeze([]) },
|
|
195
370
|
commands: [
|
|
196
371
|
{ type: "closeDialog" },
|
|
372
|
+
{ type: "clearSnapshot" },
|
|
197
373
|
{ type: "unmountAllLayers" },
|
|
374
|
+
...cacheClears,
|
|
198
375
|
{ type: "unlockScroll" },
|
|
199
|
-
{ type: "historyBack", n }
|
|
200
|
-
{ type: "clearSnapshot" }
|
|
376
|
+
{ type: "historyBack", n }
|
|
201
377
|
]
|
|
202
378
|
};
|
|
203
379
|
}
|
|
@@ -206,34 +382,46 @@ function handlePopstate(state, { historyState, locationHref }) {
|
|
|
206
382
|
if (!isOurs) {
|
|
207
383
|
if (state.layers.length === 0)
|
|
208
384
|
return { state, commands: [] };
|
|
385
|
+
const cacheClears = state.layers.map((l) => ({
|
|
386
|
+
type: "clearFrameCache",
|
|
387
|
+
layerId: l.id
|
|
388
|
+
}));
|
|
209
389
|
return {
|
|
210
390
|
state: { ...state, layers: Object.freeze([]) },
|
|
211
391
|
commands: [
|
|
212
392
|
{ type: "closeDialog" },
|
|
393
|
+
{ type: "clearSnapshot" },
|
|
213
394
|
{ type: "unmountAllLayers" },
|
|
214
|
-
|
|
215
|
-
{ type: "
|
|
395
|
+
...cacheClears,
|
|
396
|
+
{ type: "unlockScroll" }
|
|
216
397
|
]
|
|
217
398
|
};
|
|
218
399
|
}
|
|
219
400
|
const targetDepth = historyState.depth ?? 0;
|
|
220
401
|
const currentDepth = state.layers.length;
|
|
221
402
|
const targetLayerId = historyState.layerId ?? null;
|
|
403
|
+
const targetFrameIndex = historyState.frameIndex ?? 0;
|
|
222
404
|
if (targetDepth < currentDepth) {
|
|
405
|
+
const droppedLayers = state.layers.slice(targetDepth);
|
|
223
406
|
const newLayers = Object.freeze(state.layers.slice(0, targetDepth));
|
|
224
407
|
const newTop = newLayers[newLayers.length - 1] ?? null;
|
|
225
408
|
const commands = [];
|
|
226
|
-
if (
|
|
409
|
+
if (newTop) {
|
|
410
|
+
commands.push({ type: "persistSnapshot" });
|
|
411
|
+
} else {
|
|
227
412
|
commands.push({ type: "closeDialog" });
|
|
228
|
-
|
|
413
|
+
commands.push({ type: "clearSnapshot" });
|
|
414
|
+
}
|
|
415
|
+
for (let i = 0;i < droppedLayers.length; i++) {
|
|
229
416
|
commands.push({ type: "unmountTopLayer" });
|
|
230
417
|
}
|
|
418
|
+
for (const dropped of droppedLayers) {
|
|
419
|
+
commands.push({ type: "clearFrameCache", layerId: dropped.id });
|
|
420
|
+
}
|
|
231
421
|
if (newTop) {
|
|
232
422
|
commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
|
|
233
|
-
commands.push({ type: "persistSnapshot" });
|
|
234
423
|
} else {
|
|
235
424
|
commands.push({ type: "unlockScroll" });
|
|
236
|
-
commands.push({ type: "clearSnapshot" });
|
|
237
425
|
}
|
|
238
426
|
return { state: { ...state, layers: newLayers }, commands };
|
|
239
427
|
}
|
|
@@ -246,6 +434,51 @@ function handlePopstate(state, { historyState, locationHref }) {
|
|
|
246
434
|
};
|
|
247
435
|
}
|
|
248
436
|
const top = topLayer(state);
|
|
437
|
+
if (top && targetLayerId && top.id === targetLayerId) {
|
|
438
|
+
const currentFrameIndex = top.frames.length - 1;
|
|
439
|
+
if (targetFrameIndex === currentFrameIndex) {
|
|
440
|
+
return { state, commands: [] };
|
|
441
|
+
}
|
|
442
|
+
if (targetFrameIndex < currentFrameIndex) {
|
|
443
|
+
const newFrames = top.frames.slice(0, targetFrameIndex + 1);
|
|
444
|
+
const targetFrame = newFrames[newFrames.length - 1];
|
|
445
|
+
const updatedTop = freezeLayer({
|
|
446
|
+
id: top.id,
|
|
447
|
+
url: targetFrame.url,
|
|
448
|
+
variant: top.variant,
|
|
449
|
+
dismissible: top.dismissible,
|
|
450
|
+
size: top.size,
|
|
451
|
+
side: top.side,
|
|
452
|
+
width: top.width,
|
|
453
|
+
height: top.height,
|
|
454
|
+
frames: newFrames
|
|
455
|
+
});
|
|
456
|
+
const newLayers = Object.freeze([
|
|
457
|
+
...state.layers.slice(0, -1),
|
|
458
|
+
updatedTop
|
|
459
|
+
]);
|
|
460
|
+
return {
|
|
461
|
+
state: { ...state, layers: newLayers },
|
|
462
|
+
commands: [
|
|
463
|
+
{
|
|
464
|
+
type: "unmountFrame",
|
|
465
|
+
layerId: top.id,
|
|
466
|
+
fromFrameIndex: currentFrameIndex,
|
|
467
|
+
toFrameIndex: targetFrameIndex,
|
|
468
|
+
url: targetFrame.url,
|
|
469
|
+
stale: targetFrame.stale
|
|
470
|
+
},
|
|
471
|
+
{ type: "persistSnapshot" }
|
|
472
|
+
]
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
return {
|
|
476
|
+
state,
|
|
477
|
+
commands: [
|
|
478
|
+
{ type: "rebuildFromSnapshot", targetDepth, targetLayerId }
|
|
479
|
+
]
|
|
480
|
+
};
|
|
481
|
+
}
|
|
249
482
|
if (top && targetLayerId && top.id !== targetLayerId) {
|
|
250
483
|
const updatedTop = freezeLayer({
|
|
251
484
|
id: targetLayerId,
|
|
@@ -264,6 +497,7 @@ function handlePopstate(state, { historyState, locationHref }) {
|
|
|
264
497
|
return {
|
|
265
498
|
state: { ...state, layers: newLayers },
|
|
266
499
|
commands: [
|
|
500
|
+
{ type: "clearFrameCache", layerId: top.id },
|
|
267
501
|
{
|
|
268
502
|
type: "morphTopLayer",
|
|
269
503
|
layerId: targetLayerId,
|
|
@@ -287,10 +521,23 @@ function snapshot(state, { now = Date.now } = {}) {
|
|
|
287
521
|
v: SNAPSHOT_VERSION,
|
|
288
522
|
stackId: state.stackId,
|
|
289
523
|
baseUrl: state.baseUrl,
|
|
290
|
-
layers: state.layers,
|
|
524
|
+
layers: state.layers.map(serializeLayer),
|
|
291
525
|
savedAt: now()
|
|
292
526
|
});
|
|
293
527
|
}
|
|
528
|
+
function serializeLayer(layer) {
|
|
529
|
+
return {
|
|
530
|
+
id: layer.id,
|
|
531
|
+
url: layer.url,
|
|
532
|
+
variant: layer.variant,
|
|
533
|
+
dismissible: layer.dismissible,
|
|
534
|
+
size: layer.size,
|
|
535
|
+
side: layer.side,
|
|
536
|
+
width: layer.width,
|
|
537
|
+
height: layer.height,
|
|
538
|
+
frames: layer.frames.map((f) => ({ url: f.url, stale: f.stale }))
|
|
539
|
+
};
|
|
540
|
+
}
|
|
294
541
|
function restore(serialized, { stackId, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Date.now } = {}) {
|
|
295
542
|
if (typeof serialized !== "string" || serialized.length === 0)
|
|
296
543
|
return null;
|
|
@@ -300,7 +547,7 @@ function restore(serialized, { stackId, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Dat
|
|
|
300
547
|
} catch {
|
|
301
548
|
return null;
|
|
302
549
|
}
|
|
303
|
-
if (parsed?.v !== SNAPSHOT_VERSION)
|
|
550
|
+
if (parsed?.v !== 1 && parsed?.v !== SNAPSHOT_VERSION)
|
|
304
551
|
return null;
|
|
305
552
|
if (typeof parsed.stackId !== "string")
|
|
306
553
|
return null;
|
|
@@ -319,6 +566,14 @@ function restore(serialized, { stackId, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Dat
|
|
|
319
566
|
return null;
|
|
320
567
|
if (!VARIANTS.includes(l.variant))
|
|
321
568
|
return null;
|
|
569
|
+
if (l.frames !== undefined) {
|
|
570
|
+
if (!Array.isArray(l.frames) || l.frames.length === 0)
|
|
571
|
+
return null;
|
|
572
|
+
for (const f of l.frames) {
|
|
573
|
+
if (!f || typeof f.url !== "string")
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
322
577
|
}
|
|
323
578
|
return Object.freeze({
|
|
324
579
|
stackId: parsed.stackId,
|
|
@@ -362,6 +617,9 @@ class Orchestrator {
|
|
|
362
617
|
get depth() {
|
|
363
618
|
return this.state.layers.length;
|
|
364
619
|
}
|
|
620
|
+
get expectedPopstates() {
|
|
621
|
+
return this.#expectedPopstates;
|
|
622
|
+
}
|
|
365
623
|
async push(layer, { html = null, fragment = null } = {}) {
|
|
366
624
|
const transition = push(this.state, layer, {
|
|
367
625
|
maxDepth: this.maxDepth,
|
|
@@ -383,21 +641,41 @@ class Orchestrator {
|
|
|
383
641
|
}
|
|
384
642
|
return this.#dispatch(replaceTop(this.state, patch, opts), { html, fragment });
|
|
385
643
|
}
|
|
644
|
+
async pathTo(frame, { html = null, fragment = null, transition = null } = {}) {
|
|
645
|
+
let resolvedStale = frame?.stale === true;
|
|
646
|
+
if (fragment == null && html == null && frame?.url) {
|
|
647
|
+
const meta = await this.#prefetchWithMeta(frame.url);
|
|
648
|
+
fragment = meta.fragment;
|
|
649
|
+
if (frame.stale !== true && meta.stale === true)
|
|
650
|
+
resolvedStale = true;
|
|
651
|
+
}
|
|
652
|
+
return this.#dispatch(pathTo(this.state, { url: frame.url, stale: resolvedStale }, { transition }), { html, fragment });
|
|
653
|
+
}
|
|
654
|
+
pathBack({ steps = 1, transition = null } = {}) {
|
|
655
|
+
return this.#dispatch(pathBack(this.state, { steps, transition }));
|
|
656
|
+
}
|
|
386
657
|
async#prefetch(url) {
|
|
387
|
-
|
|
388
|
-
|
|
658
|
+
const meta = await this.#prefetchWithMeta(url);
|
|
659
|
+
return meta.fragment;
|
|
660
|
+
}
|
|
661
|
+
async#prefetchWithMeta(url) {
|
|
662
|
+
if (typeof this.runtime.fetchFragment !== "function") {
|
|
663
|
+
return { fragment: null, stale: false };
|
|
664
|
+
}
|
|
389
665
|
const cached = this.#fragmentCache.get(url);
|
|
390
666
|
if (cached && Date.now() - cached.ts < this.prefetchTtlMs) {
|
|
391
|
-
return cloneFragment(cached.fragment);
|
|
667
|
+
return { fragment: cloneFragment(cached.fragment), stale: cached.stale === true };
|
|
392
668
|
}
|
|
393
669
|
const existing = this.#inflight.get(url);
|
|
394
670
|
if (existing) {
|
|
395
671
|
const entry2 = await existing.promise;
|
|
396
|
-
return cloneFragment(entry2.fragment);
|
|
672
|
+
return { fragment: cloneFragment(entry2.fragment), stale: entry2.stale === true };
|
|
397
673
|
}
|
|
398
674
|
const controller = supportsAbort() ? new AbortController : null;
|
|
399
|
-
const fetchPromise = this.runtime.fetchFragment(url, controller ? { signal: controller.signal } : undefined).then((
|
|
400
|
-
const
|
|
675
|
+
const fetchPromise = this.runtime.fetchFragment(url, controller ? { signal: controller.signal } : undefined).then((result) => {
|
|
676
|
+
const fragment = result?.fragment ?? result;
|
|
677
|
+
const stale = result?.stale === true;
|
|
678
|
+
const entry2 = { fragment, stale, ts: Date.now() };
|
|
401
679
|
this.#fragmentCache.set(url, entry2);
|
|
402
680
|
return entry2;
|
|
403
681
|
}).finally(() => {
|
|
@@ -405,7 +683,7 @@ class Orchestrator {
|
|
|
405
683
|
});
|
|
406
684
|
this.#inflight.set(url, { controller, promise: fetchPromise });
|
|
407
685
|
const entry = await fetchPromise;
|
|
408
|
-
return cloneFragment(entry.fragment);
|
|
686
|
+
return { fragment: cloneFragment(entry.fragment), stale: entry.stale === true };
|
|
409
687
|
}
|
|
410
688
|
#invalidatePrefetch() {
|
|
411
689
|
for (const { controller } of this.#inflight.values()) {
|
|
@@ -416,6 +694,15 @@ class Orchestrator {
|
|
|
416
694
|
this.#inflight.clear();
|
|
417
695
|
this.#fragmentCache.clear();
|
|
418
696
|
}
|
|
697
|
+
setFragmentCache(url, fragment) {
|
|
698
|
+
if (!url || !fragment)
|
|
699
|
+
return;
|
|
700
|
+
this.#fragmentCache.set(url, {
|
|
701
|
+
fragment: cloneFragment(fragment),
|
|
702
|
+
stale: false,
|
|
703
|
+
ts: Date.now()
|
|
704
|
+
});
|
|
705
|
+
}
|
|
419
706
|
prefetch(url) {
|
|
420
707
|
if (!url || typeof this.runtime.fetchFragment !== "function") {
|
|
421
708
|
return Promise.resolve(null);
|
|
@@ -437,7 +724,7 @@ class Orchestrator {
|
|
|
437
724
|
async#dispatch({ state, commands }, payload = {}) {
|
|
438
725
|
this.state = state;
|
|
439
726
|
for (const cmd of commands) {
|
|
440
|
-
if (cmd.type === "mountLayer" || cmd.type === "morphTopLayer") {
|
|
727
|
+
if (cmd.type === "mountLayer" || cmd.type === "morphTopLayer" || cmd.type === "mountFrame") {
|
|
441
728
|
if (payload.html != null)
|
|
442
729
|
cmd.html = payload.html;
|
|
443
730
|
if (payload.fragment != null)
|
|
@@ -476,18 +763,24 @@ function supportsAbort() {
|
|
|
476
763
|
|
|
477
764
|
// app/javascript/modal_stack/runtime.js
|
|
478
765
|
var SNAPSHOT_KEY = "modalStackSnapshot";
|
|
766
|
+
var FRAME_HTML_KEY = "modalStackFrameHtml";
|
|
479
767
|
var FRAGMENT_HEADER = "X-Modal-Stack-Request";
|
|
768
|
+
var STALE_HEADER = "X-Modal-Stack-Stale";
|
|
480
769
|
var SCROLLBAR_WIDTH_VAR = "--modal-stack-scrollbar-width";
|
|
481
770
|
var LAYER_SELECTOR = '[data-modal-stack-target="layer"]';
|
|
771
|
+
var FRAME_SELECTOR = "[data-modal-stack-frame]";
|
|
482
772
|
var DURATION_CSS_VAR = "--modal-stack-duration";
|
|
483
773
|
var LEAVE_TIMEOUT_FLOOR_MS = 300;
|
|
484
774
|
var LEAVE_TIMEOUT_FALLBACK_MS = 600;
|
|
485
775
|
|
|
486
776
|
class BrowserRuntime {
|
|
777
|
+
#suppressTurboVisitCount = 0;
|
|
778
|
+
#suppressTurboVisitTimer = null;
|
|
487
779
|
constructor({
|
|
488
780
|
dialog,
|
|
489
781
|
body = globalThis.document?.body,
|
|
490
782
|
history = globalThis.history,
|
|
783
|
+
location = globalThis.location,
|
|
491
784
|
fetcher = globalThis.fetch?.bind(globalThis),
|
|
492
785
|
store = globalThis.sessionStorage,
|
|
493
786
|
documentRef = globalThis.document
|
|
@@ -501,9 +794,34 @@ class BrowserRuntime {
|
|
|
501
794
|
this.dialog = dialog;
|
|
502
795
|
this.body = body;
|
|
503
796
|
this.history = history;
|
|
797
|
+
this.location = location;
|
|
504
798
|
this.fetcher = fetcher;
|
|
505
799
|
this.store = store;
|
|
506
800
|
this.document = documentRef;
|
|
801
|
+
this._frameCache = new Map;
|
|
802
|
+
this.#suppressTurboVisitCount = 0;
|
|
803
|
+
this._turboVisitGuard = (event) => {
|
|
804
|
+
if (this.#suppressTurboVisitCount <= 0)
|
|
805
|
+
return;
|
|
806
|
+
this.#suppressTurboVisitCount -= 1;
|
|
807
|
+
if (this.#suppressTurboVisitCount === 0)
|
|
808
|
+
clearTimeout(this.#suppressTurboVisitTimer);
|
|
809
|
+
event.preventDefault();
|
|
810
|
+
};
|
|
811
|
+
documentRef.addEventListener?.("turbo:before-visit", this._turboVisitGuard);
|
|
812
|
+
this._turboBeforeCache = () => {
|
|
813
|
+
if (!this.body)
|
|
814
|
+
return;
|
|
815
|
+
delete this.body.dataset.modalStackLocked;
|
|
816
|
+
const root = this.document?.documentElement;
|
|
817
|
+
if (root)
|
|
818
|
+
root.style.removeProperty(SCROLLBAR_WIDTH_VAR);
|
|
819
|
+
};
|
|
820
|
+
documentRef.addEventListener?.("turbo:before-cache", this._turboBeforeCache);
|
|
821
|
+
}
|
|
822
|
+
destroy() {
|
|
823
|
+
this.document?.removeEventListener?.("turbo:before-visit", this._turboVisitGuard);
|
|
824
|
+
this.document?.removeEventListener?.("turbo:before-cache", this._turboBeforeCache);
|
|
507
825
|
}
|
|
508
826
|
showDialog() {
|
|
509
827
|
if (!this.dialog.open)
|
|
@@ -544,7 +862,10 @@ class BrowserRuntime {
|
|
|
544
862
|
const frag = await this.#resolveFragment({ url, html, fragment });
|
|
545
863
|
const layer = this.document.createElement("div");
|
|
546
864
|
this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
|
|
547
|
-
layer
|
|
865
|
+
this.#applyFrameDepth(layer, 0);
|
|
866
|
+
const wrapper = this.#createFrameWrapper({ frameIndex: 0 });
|
|
867
|
+
wrapper.append(...frag.childNodes);
|
|
868
|
+
layer.appendChild(wrapper);
|
|
548
869
|
this.dialog.appendChild(layer);
|
|
549
870
|
}
|
|
550
871
|
async morphTopLayer({ layerId, url, depth, variant, dismissible, size, side, width, height, html, fragment }) {
|
|
@@ -553,7 +874,85 @@ class BrowserRuntime {
|
|
|
553
874
|
if (!layer)
|
|
554
875
|
return;
|
|
555
876
|
this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
|
|
556
|
-
layer
|
|
877
|
+
this.#applyFrameDepth(layer, 0);
|
|
878
|
+
const wrapper = this.#createFrameWrapper({ frameIndex: 0 });
|
|
879
|
+
wrapper.append(...frag.childNodes);
|
|
880
|
+
layer.replaceChildren(wrapper);
|
|
881
|
+
}
|
|
882
|
+
async mountFrame({ layerId, fromFrameIndex, toFrameIndex, url, html, fragment, transition }) {
|
|
883
|
+
const layer = this.#findLayer(layerId);
|
|
884
|
+
if (!layer)
|
|
885
|
+
return;
|
|
886
|
+
const frag = await this.#resolveFragment({ url, html, fragment });
|
|
887
|
+
const oldFrame = this.#findFrame(layer, fromFrameIndex);
|
|
888
|
+
if (oldFrame) {
|
|
889
|
+
this.#dispatchFrameEvent("modal_stack:frame-leave", {
|
|
890
|
+
layerId,
|
|
891
|
+
frameIndex: fromFrameIndex,
|
|
892
|
+
direction: "forward"
|
|
893
|
+
});
|
|
894
|
+
const cached = this.document.createDocumentFragment();
|
|
895
|
+
cached.append(...oldFrame.childNodes);
|
|
896
|
+
this._frameCache.set(this.#frameKey(layerId, fromFrameIndex), cached);
|
|
897
|
+
}
|
|
898
|
+
const newFrame = this.#createFrameWrapper({ frameIndex: toFrameIndex, transition, direction: "forward" });
|
|
899
|
+
newFrame.append(...frag.childNodes);
|
|
900
|
+
layer.appendChild(newFrame);
|
|
901
|
+
this.#applyFrameDepth(layer, toFrameIndex);
|
|
902
|
+
if (toFrameIndex > 0) {
|
|
903
|
+
this.#persistFrameHtml(layerId, toFrameIndex, newFrame.innerHTML);
|
|
904
|
+
}
|
|
905
|
+
if (oldFrame)
|
|
906
|
+
oldFrame.remove();
|
|
907
|
+
this.#dispatchFrameEvent("modal_stack:frame-enter", {
|
|
908
|
+
layerId,
|
|
909
|
+
frameIndex: toFrameIndex,
|
|
910
|
+
direction: "forward"
|
|
911
|
+
});
|
|
912
|
+
if (transition)
|
|
913
|
+
this.#cleanupFrameTransition(newFrame);
|
|
914
|
+
}
|
|
915
|
+
async unmountFrame({ layerId, fromFrameIndex, toFrameIndex, url, stale, transition }) {
|
|
916
|
+
const layer = this.#findLayer(layerId);
|
|
917
|
+
if (!layer)
|
|
918
|
+
return;
|
|
919
|
+
this.#dispatchFrameEvent("modal_stack:frame-leave", {
|
|
920
|
+
layerId,
|
|
921
|
+
frameIndex: fromFrameIndex,
|
|
922
|
+
direction: "back"
|
|
923
|
+
});
|
|
924
|
+
const cacheKey = this.#frameKey(layerId, toFrameIndex);
|
|
925
|
+
let restored = stale ? null : this._frameCache.get(cacheKey) ?? null;
|
|
926
|
+
if (!restored) {
|
|
927
|
+
const result = await this.fetchFragment(url);
|
|
928
|
+
restored = result.fragment;
|
|
929
|
+
this._frameCache.set(cacheKey, cloneFragment2(restored, this.document));
|
|
930
|
+
} else {
|
|
931
|
+
restored = cloneFragment2(restored, this.document);
|
|
932
|
+
}
|
|
933
|
+
const newFrame = this.#createFrameWrapper({ frameIndex: toFrameIndex, transition, direction: "back" });
|
|
934
|
+
newFrame.append(...restored.childNodes);
|
|
935
|
+
layer.appendChild(newFrame);
|
|
936
|
+
this.#applyFrameDepth(layer, toFrameIndex);
|
|
937
|
+
this.#purgeFrameCacheAbove(layerId, toFrameIndex);
|
|
938
|
+
const oldFrame = this.#findFrame(layer, fromFrameIndex);
|
|
939
|
+
if (oldFrame)
|
|
940
|
+
oldFrame.remove();
|
|
941
|
+
this.#dispatchFrameEvent("modal_stack:frame-enter", {
|
|
942
|
+
layerId,
|
|
943
|
+
frameIndex: toFrameIndex,
|
|
944
|
+
direction: "back"
|
|
945
|
+
});
|
|
946
|
+
if (transition)
|
|
947
|
+
this.#cleanupFrameTransition(newFrame);
|
|
948
|
+
}
|
|
949
|
+
clearFrameCache({ layerId }) {
|
|
950
|
+
const prefix = `${layerId}#`;
|
|
951
|
+
for (const key of [...this._frameCache.keys()]) {
|
|
952
|
+
if (key.startsWith(prefix))
|
|
953
|
+
this._frameCache.delete(key);
|
|
954
|
+
}
|
|
955
|
+
this.#removeFrameHtmlForLayer(layerId);
|
|
557
956
|
}
|
|
558
957
|
async unmountTopLayer() {
|
|
559
958
|
const layer = this.#topLayer();
|
|
@@ -566,13 +965,18 @@ class BrowserRuntime {
|
|
|
566
965
|
const timeout = this.#leaveTimeoutMs();
|
|
567
966
|
await Promise.all(layers.map((l) => animateOut(l, timeout)));
|
|
568
967
|
}
|
|
569
|
-
pushHistory({
|
|
570
|
-
this.history.pushState(historyState, "",
|
|
968
|
+
pushHistory({ historyState }) {
|
|
969
|
+
this.history.pushState(historyState, "", this.location?.href ?? "");
|
|
571
970
|
}
|
|
572
|
-
replaceHistory({
|
|
573
|
-
this.history.replaceState(historyState, "",
|
|
971
|
+
replaceHistory({ historyState }) {
|
|
972
|
+
this.history.replaceState(historyState, "", this.location?.href ?? "");
|
|
574
973
|
}
|
|
575
974
|
historyBack({ n }) {
|
|
975
|
+
this.#suppressTurboVisitCount += 1;
|
|
976
|
+
clearTimeout(this.#suppressTurboVisitTimer);
|
|
977
|
+
this.#suppressTurboVisitTimer = setTimeout(() => {
|
|
978
|
+
this.#suppressTurboVisitCount = 0;
|
|
979
|
+
}, 1000);
|
|
576
980
|
this.history.go(-n);
|
|
577
981
|
}
|
|
578
982
|
rebuildFromSnapshot() {
|
|
@@ -590,6 +994,7 @@ class BrowserRuntime {
|
|
|
590
994
|
return;
|
|
591
995
|
try {
|
|
592
996
|
this.store.removeItem(SNAPSHOT_KEY);
|
|
997
|
+
this.store.removeItem(FRAME_HTML_KEY);
|
|
593
998
|
} catch {}
|
|
594
999
|
}
|
|
595
1000
|
readSnapshot() {
|
|
@@ -601,6 +1006,54 @@ class BrowserRuntime {
|
|
|
601
1006
|
return null;
|
|
602
1007
|
}
|
|
603
1008
|
}
|
|
1009
|
+
restoreFrameCacheFromStorage() {
|
|
1010
|
+
const map = this.#readFrameHtmlMap();
|
|
1011
|
+
for (const [key, html] of Object.entries(map)) {
|
|
1012
|
+
this._frameCache.set(key, parseFragment(html, this.document));
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
getFrameFragment(layerId, frameIndex) {
|
|
1016
|
+
return this._frameCache.get(this.#frameKey(layerId, frameIndex)) ?? null;
|
|
1017
|
+
}
|
|
1018
|
+
#persistFrameHtml(layerId, frameIndex, html) {
|
|
1019
|
+
if (!this.store)
|
|
1020
|
+
return;
|
|
1021
|
+
try {
|
|
1022
|
+
const map = this.#readFrameHtmlMap();
|
|
1023
|
+
map[`${layerId}#${frameIndex}`] = html;
|
|
1024
|
+
this.store.setItem(FRAME_HTML_KEY, JSON.stringify(map));
|
|
1025
|
+
} catch {}
|
|
1026
|
+
}
|
|
1027
|
+
#readFrameHtmlMap() {
|
|
1028
|
+
try {
|
|
1029
|
+
const raw = this.store?.getItem(FRAME_HTML_KEY);
|
|
1030
|
+
return raw ? JSON.parse(raw) : {};
|
|
1031
|
+
} catch {
|
|
1032
|
+
return {};
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
#removeFrameHtmlForLayer(layerId) {
|
|
1036
|
+
if (!this.store)
|
|
1037
|
+
return;
|
|
1038
|
+
try {
|
|
1039
|
+
const map = this.#readFrameHtmlMap();
|
|
1040
|
+
const prefix = `${layerId}#`;
|
|
1041
|
+
let changed = false;
|
|
1042
|
+
for (const key of Object.keys(map)) {
|
|
1043
|
+
if (key.startsWith(prefix)) {
|
|
1044
|
+
delete map[key];
|
|
1045
|
+
changed = true;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
if (!changed)
|
|
1049
|
+
return;
|
|
1050
|
+
if (Object.keys(map).length === 0) {
|
|
1051
|
+
this.store.removeItem(FRAME_HTML_KEY);
|
|
1052
|
+
} else {
|
|
1053
|
+
this.store.setItem(FRAME_HTML_KEY, JSON.stringify(map));
|
|
1054
|
+
}
|
|
1055
|
+
} catch {}
|
|
1056
|
+
}
|
|
604
1057
|
#leaveTimeoutMs() {
|
|
605
1058
|
if (this._cachedLeaveTimeoutMs != null)
|
|
606
1059
|
return this._cachedLeaveTimeoutMs;
|
|
@@ -620,6 +1073,52 @@ class BrowserRuntime {
|
|
|
620
1073
|
#findLayer(layerId) {
|
|
621
1074
|
return this.dialog.querySelector(`${LAYER_SELECTOR}[data-layer-id="${escapeAttr(layerId)}"]`);
|
|
622
1075
|
}
|
|
1076
|
+
#findFrame(layer, frameIndex) {
|
|
1077
|
+
return layer.querySelector(`${FRAME_SELECTOR}[data-frame-index="${escapeAttr(String(frameIndex))}"]`);
|
|
1078
|
+
}
|
|
1079
|
+
#frameKey(layerId, frameIndex) {
|
|
1080
|
+
return `${layerId}#${frameIndex}`;
|
|
1081
|
+
}
|
|
1082
|
+
#dispatchFrameEvent(name, detail) {
|
|
1083
|
+
this.dialog.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
|
|
1084
|
+
}
|
|
1085
|
+
#purgeFrameCacheAbove(layerId, frameIndex) {
|
|
1086
|
+
const prefix = `${layerId}#`;
|
|
1087
|
+
for (const key of [...this._frameCache.keys()]) {
|
|
1088
|
+
if (!key.startsWith(prefix))
|
|
1089
|
+
continue;
|
|
1090
|
+
const idx = Number(key.slice(prefix.length));
|
|
1091
|
+
if (Number.isFinite(idx) && idx > frameIndex) {
|
|
1092
|
+
this._frameCache.delete(key);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
#cleanupFrameTransition(frameEl) {
|
|
1097
|
+
let done = false;
|
|
1098
|
+
const cleanup = () => {
|
|
1099
|
+
if (done)
|
|
1100
|
+
return;
|
|
1101
|
+
done = true;
|
|
1102
|
+
frameEl.removeAttribute("data-transition");
|
|
1103
|
+
frameEl.removeAttribute("data-direction");
|
|
1104
|
+
};
|
|
1105
|
+
frameEl.addEventListener("transitionend", cleanup, { once: true });
|
|
1106
|
+
setTimeout(cleanup, this.#leaveTimeoutMs());
|
|
1107
|
+
}
|
|
1108
|
+
#createFrameWrapper({ frameIndex, transition = null, direction = null }) {
|
|
1109
|
+
const el = this.document.createElement("div");
|
|
1110
|
+
el.dataset.modalStackFrame = "";
|
|
1111
|
+
el.dataset.frameIndex = String(frameIndex);
|
|
1112
|
+
if (transition)
|
|
1113
|
+
el.dataset.transition = transition;
|
|
1114
|
+
if (direction)
|
|
1115
|
+
el.dataset.direction = direction;
|
|
1116
|
+
return el;
|
|
1117
|
+
}
|
|
1118
|
+
#applyFrameDepth(layer, topFrameIndex) {
|
|
1119
|
+
layer.dataset.frameIndex = String(topFrameIndex);
|
|
1120
|
+
layer.dataset.frameDepth = String(topFrameIndex + 1);
|
|
1121
|
+
}
|
|
623
1122
|
#topLayer() {
|
|
624
1123
|
const layers = this.dialog.querySelectorAll(LAYER_SELECTOR);
|
|
625
1124
|
return layers[layers.length - 1] ?? null;
|
|
@@ -666,14 +1165,16 @@ class BrowserRuntime {
|
|
|
666
1165
|
throw new Error(`modal_stack: fetch ${url} → ${resp.status}`);
|
|
667
1166
|
}
|
|
668
1167
|
const html = await resp.text();
|
|
669
|
-
|
|
1168
|
+
const stale = parseStaleHeader(resp);
|
|
1169
|
+
return { fragment: parseFragment(html, this.document), stale };
|
|
670
1170
|
}
|
|
671
1171
|
async#resolveFragment({ url, html, fragment }) {
|
|
672
1172
|
if (fragment)
|
|
673
1173
|
return fragment;
|
|
674
1174
|
if (html != null)
|
|
675
1175
|
return parseFragment(html, this.document);
|
|
676
|
-
|
|
1176
|
+
const result = await this.fetchFragment(url);
|
|
1177
|
+
return result.fragment;
|
|
677
1178
|
}
|
|
678
1179
|
}
|
|
679
1180
|
function parseFragment(html, doc) {
|
|
@@ -683,6 +1184,25 @@ function parseFragment(html, doc) {
|
|
|
683
1184
|
fragment.append(...parsed.body.childNodes);
|
|
684
1185
|
return fragment;
|
|
685
1186
|
}
|
|
1187
|
+
function parseStaleHeader(resp) {
|
|
1188
|
+
const headers = resp?.headers;
|
|
1189
|
+
const value = typeof headers?.get === "function" ? headers.get(STALE_HEADER) ?? headers.get(STALE_HEADER.toLowerCase()) : null;
|
|
1190
|
+
if (!value)
|
|
1191
|
+
return false;
|
|
1192
|
+
const normalized = String(value).trim().toLowerCase();
|
|
1193
|
+
return normalized === "true" || normalized === "1";
|
|
1194
|
+
}
|
|
1195
|
+
function cloneFragment2(fragment, doc) {
|
|
1196
|
+
if (typeof fragment?.cloneNode === "function") {
|
|
1197
|
+
return fragment.cloneNode(true);
|
|
1198
|
+
}
|
|
1199
|
+
const clone = doc.createDocumentFragment();
|
|
1200
|
+
if (fragment?.childNodes) {
|
|
1201
|
+
for (const node of fragment.childNodes)
|
|
1202
|
+
clone.appendChild(node.cloneNode(true));
|
|
1203
|
+
}
|
|
1204
|
+
return clone;
|
|
1205
|
+
}
|
|
686
1206
|
function animateOut(layer, timeoutMs = LEAVE_TIMEOUT_FALLBACK_MS) {
|
|
687
1207
|
return new Promise((resolve) => {
|
|
688
1208
|
let done = false;
|
|
@@ -714,37 +1234,49 @@ function escapeAttr(value) {
|
|
|
714
1234
|
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
715
1235
|
return CSS.escape(value);
|
|
716
1236
|
}
|
|
717
|
-
return String(value).replace(/["\\]/g, "\\$&");
|
|
1237
|
+
return String(value).replace(/["\\[\]]/g, "\\$&");
|
|
718
1238
|
}
|
|
719
1239
|
|
|
720
1240
|
// app/javascript/modal_stack/controllers/modal_stack_controller.js
|
|
721
|
-
class ModalStackController extends
|
|
1241
|
+
class ModalStackController extends Controller2 {
|
|
722
1242
|
static values = {
|
|
723
1243
|
stackId: String,
|
|
724
1244
|
baseUrl: String,
|
|
725
1245
|
maxDepth: { type: Number, default: 0 },
|
|
726
1246
|
maxDepthStrategy: { type: String, default: "warn" }
|
|
727
1247
|
};
|
|
1248
|
+
#restoring = false;
|
|
728
1249
|
connect() {
|
|
729
|
-
const stackId = this.stackIdValue || generateLayerId();
|
|
730
1250
|
const baseUrl = this.baseUrlValue || window.location.href;
|
|
731
1251
|
this.runtime = new BrowserRuntime({ dialog: this.element });
|
|
732
|
-
|
|
1252
|
+
this.runtime.restoreFrameCacheFromStorage();
|
|
1253
|
+
const savedSnapshot = this.runtime.readSnapshot();
|
|
1254
|
+
const snapshotState = savedSnapshot ? restore(savedSnapshot) : null;
|
|
1255
|
+
const stackId = this.stackIdValue || snapshotState?.stackId || generateLayerId();
|
|
733
1256
|
this.orchestrator = new Orchestrator({
|
|
734
1257
|
runtime: this.runtime,
|
|
735
1258
|
stackId,
|
|
736
1259
|
baseUrl,
|
|
737
|
-
restoreFrom:
|
|
1260
|
+
restoreFrom: null,
|
|
738
1261
|
maxDepth: this.maxDepthValue > 0 ? this.maxDepthValue : null,
|
|
739
1262
|
maxDepthStrategy: this.maxDepthStrategyValue || "warn"
|
|
740
1263
|
});
|
|
741
|
-
this._onPopstate = (event) =>
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
1264
|
+
this._onPopstate = (event) => {
|
|
1265
|
+
const isOwn = this.orchestrator.expectedPopstates > 0;
|
|
1266
|
+
this.orchestrator.onPopstate({
|
|
1267
|
+
historyState: event.state,
|
|
1268
|
+
locationHref: window.location.href
|
|
1269
|
+
});
|
|
1270
|
+
if (isOwn)
|
|
1271
|
+
event.stopImmediatePropagation();
|
|
1272
|
+
};
|
|
1273
|
+
window.addEventListener("popstate", this._onPopstate, true);
|
|
746
1274
|
this._onCancel = (event) => {
|
|
747
1275
|
event.preventDefault();
|
|
1276
|
+
if (!this.element.open)
|
|
1277
|
+
return;
|
|
1278
|
+
if (this.#restoring)
|
|
1279
|
+
return;
|
|
748
1280
|
const top = this.#topLayer();
|
|
749
1281
|
if (!top || top.dismissible === false)
|
|
750
1282
|
return;
|
|
@@ -754,19 +1286,66 @@ class ModalStackController extends Controller {
|
|
|
754
1286
|
this._onBackdropClick = (event) => {
|
|
755
1287
|
if (event.target !== this.element)
|
|
756
1288
|
return;
|
|
1289
|
+
if (!this.element.open)
|
|
1290
|
+
return;
|
|
1291
|
+
if (this.#restoring)
|
|
1292
|
+
return;
|
|
757
1293
|
const top = this.#topLayer();
|
|
758
1294
|
if (!top || top.dismissible === false)
|
|
759
1295
|
return;
|
|
760
1296
|
this.orchestrator.pop();
|
|
761
1297
|
};
|
|
762
1298
|
this.element.addEventListener("click", this._onBackdropClick);
|
|
1299
|
+
this._onTurboRender = () => {
|
|
1300
|
+
if (this.orchestrator.depth === 0)
|
|
1301
|
+
this.runtime.unlockScroll();
|
|
1302
|
+
};
|
|
1303
|
+
document.addEventListener("turbo:render", this._onTurboRender);
|
|
763
1304
|
this.#registerStreamActions();
|
|
764
|
-
|
|
1305
|
+
if (snapshotState?.layers?.length > 0) {
|
|
1306
|
+
this.#restoring = true;
|
|
1307
|
+
this.#restoreSnapshot(snapshotState.layers).catch((err) => console.warn("[modal_stack] snapshot restore failed:", err)).finally(() => {
|
|
1308
|
+
this.#restoring = false;
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
this.element.dispatchEvent(new CustomEvent("modal_stack:ready", {
|
|
1312
|
+
bubbles: true,
|
|
1313
|
+
detail: { stackId }
|
|
1314
|
+
}));
|
|
1315
|
+
}
|
|
1316
|
+
async#restoreSnapshot(layers) {
|
|
1317
|
+
const baseUrls = layers.map((l) => l.frames?.[0]?.url ?? l.url);
|
|
1318
|
+
const baseFragments = await Promise.all(baseUrls.map((url) => this.orchestrator.prefetch(url).catch(() => null)));
|
|
1319
|
+
for (let i = 0;i < layers.length; i++) {
|
|
1320
|
+
const layer = layers[i];
|
|
1321
|
+
await this.orchestrator.push({
|
|
1322
|
+
id: layer.id,
|
|
1323
|
+
url: baseUrls[i],
|
|
1324
|
+
variant: layer.variant,
|
|
1325
|
+
dismissible: layer.dismissible,
|
|
1326
|
+
size: layer.size,
|
|
1327
|
+
side: layer.side,
|
|
1328
|
+
width: layer.width,
|
|
1329
|
+
height: layer.height
|
|
1330
|
+
}, { fragment: baseFragments[i] });
|
|
1331
|
+
const extraFrames = (layer.frames ?? []).slice(1);
|
|
1332
|
+
for (let fi = 0;fi < extraFrames.length; fi++) {
|
|
1333
|
+
const frame = extraFrames[fi];
|
|
1334
|
+
const frameIndex = fi + 1;
|
|
1335
|
+
const cached = this.runtime.getFrameFragment(layer.id, frameIndex);
|
|
1336
|
+
if (!cached)
|
|
1337
|
+
break;
|
|
1338
|
+
this.orchestrator.setFragmentCache(frame.url, cached.cloneNode(true));
|
|
1339
|
+
await this.orchestrator.pathTo({ url: frame.url, stale: frame.stale }, { fragment: cached.cloneNode(true) });
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
765
1342
|
}
|
|
766
1343
|
disconnect() {
|
|
767
|
-
window.removeEventListener("popstate", this._onPopstate);
|
|
1344
|
+
window.removeEventListener("popstate", this._onPopstate, true);
|
|
768
1345
|
this.element.removeEventListener("cancel", this._onCancel);
|
|
769
1346
|
this.element.removeEventListener("click", this._onBackdropClick);
|
|
1347
|
+
document.removeEventListener("turbo:render", this._onTurboRender);
|
|
1348
|
+
this.runtime.destroy?.();
|
|
770
1349
|
}
|
|
771
1350
|
push(layer, opts) {
|
|
772
1351
|
return this.orchestrator.push(layer, opts);
|
|
@@ -783,6 +1362,14 @@ class ModalStackController extends Controller {
|
|
|
783
1362
|
prefetch(url) {
|
|
784
1363
|
return this.orchestrator.prefetch(url);
|
|
785
1364
|
}
|
|
1365
|
+
pathBack(event) {
|
|
1366
|
+
if (event) {
|
|
1367
|
+
event.preventDefault();
|
|
1368
|
+
event.stopPropagation();
|
|
1369
|
+
}
|
|
1370
|
+
const steps = readSteps(event);
|
|
1371
|
+
return this.orchestrator.pathBack({ steps });
|
|
1372
|
+
}
|
|
786
1373
|
#topLayer() {
|
|
787
1374
|
const layers = this.orchestrator.layers;
|
|
788
1375
|
return layers[layers.length - 1] ?? null;
|
|
@@ -823,6 +1410,19 @@ class ModalStackController extends Controller {
|
|
|
823
1410
|
StreamActions.modal_close_all = guarded("modal_close_all", function(orch) {
|
|
824
1411
|
return orch.closeAll();
|
|
825
1412
|
});
|
|
1413
|
+
StreamActions.modal_path_to = guarded("modal_path_to", function(orch) {
|
|
1414
|
+
return orch.pathTo(frameFromStreamElement(this), {
|
|
1415
|
+
fragment: this.templateContent.cloneNode(true),
|
|
1416
|
+
transition: this.dataset.transition || null
|
|
1417
|
+
});
|
|
1418
|
+
});
|
|
1419
|
+
StreamActions.modal_path_back = guarded("modal_path_back", function(orch) {
|
|
1420
|
+
const steps = parsePositiveInt(this.dataset.steps, 1);
|
|
1421
|
+
return orch.pathBack({
|
|
1422
|
+
steps,
|
|
1423
|
+
transition: this.dataset.transition || null
|
|
1424
|
+
});
|
|
1425
|
+
});
|
|
826
1426
|
}
|
|
827
1427
|
}
|
|
828
1428
|
function emitStreamError(dialog, action, error) {
|
|
@@ -874,11 +1474,29 @@ function generateLayerId() {
|
|
|
874
1474
|
}
|
|
875
1475
|
return `ms-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
876
1476
|
}
|
|
1477
|
+
function frameFromStreamElement(el) {
|
|
1478
|
+
return {
|
|
1479
|
+
url: el.dataset.url || window.location.href,
|
|
1480
|
+
stale: el.dataset.stale === "true" || el.dataset.stale === "1"
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
function parsePositiveInt(raw, fallback) {
|
|
1484
|
+
const n = Number.parseInt(raw, 10);
|
|
1485
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
1486
|
+
}
|
|
1487
|
+
function readSteps(event) {
|
|
1488
|
+
const params = event?.params;
|
|
1489
|
+
if (params && Number.isFinite(params.steps) && params.steps > 0) {
|
|
1490
|
+
return params.steps;
|
|
1491
|
+
}
|
|
1492
|
+
const target = event?.currentTarget ?? event?.target;
|
|
1493
|
+
return parsePositiveInt(target?.dataset?.steps, 1);
|
|
1494
|
+
}
|
|
877
1495
|
|
|
878
1496
|
// app/javascript/modal_stack/controllers/modal_stack_link_controller.js
|
|
879
|
-
import { Controller as
|
|
1497
|
+
import { Controller as Controller3 } from "@hotwired/stimulus";
|
|
880
1498
|
|
|
881
|
-
class ModalStackLinkController extends
|
|
1499
|
+
class ModalStackLinkController extends Controller3 {
|
|
882
1500
|
connect() {
|
|
883
1501
|
if (this.element.dataset.modalStackLinkPrefetch === "false")
|
|
884
1502
|
return;
|
|
@@ -936,10 +1554,12 @@ function install(application) {
|
|
|
936
1554
|
}
|
|
937
1555
|
application.register("modal-stack", ModalStackController);
|
|
938
1556
|
application.register("modal-stack-link", ModalStackLinkController);
|
|
1557
|
+
application.register("modal-stack-back-link", ModalStackBackLinkController);
|
|
939
1558
|
return application;
|
|
940
1559
|
}
|
|
941
1560
|
export {
|
|
942
1561
|
install,
|
|
943
1562
|
ModalStackLinkController,
|
|
944
|
-
ModalStackController
|
|
1563
|
+
ModalStackController,
|
|
1564
|
+
ModalStackBackLinkController
|
|
945
1565
|
};
|