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
|
@@ -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,25 +171,134 @@ 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
|
-
const commands = [
|
|
130
|
-
{ type: "unmountTopLayer" },
|
|
131
|
-
{ type: "historyBack", n: 1 }
|
|
132
|
-
];
|
|
290
|
+
const commands = [];
|
|
133
291
|
if (newTop) {
|
|
292
|
+
commands.push({ type: "unmountTopLayer" });
|
|
293
|
+
commands.push({ type: "clearFrameCache", layerId: popped.id });
|
|
294
|
+
commands.push({ type: "historyBack", n: framesToWalkBack });
|
|
134
295
|
commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
|
|
135
296
|
commands.push({ type: "persistSnapshot" });
|
|
136
297
|
} else {
|
|
137
298
|
commands.push({ type: "closeDialog" });
|
|
299
|
+
commands.push({ type: "unmountTopLayer" });
|
|
300
|
+
commands.push({ type: "clearFrameCache", layerId: popped.id });
|
|
301
|
+
commands.push({ type: "historyBack", n: framesToWalkBack });
|
|
138
302
|
commands.push({ type: "unlockScroll" });
|
|
139
303
|
commands.push({ type: "clearSnapshot" });
|
|
140
304
|
}
|
|
@@ -148,6 +312,7 @@ function replaceTop(state, patch, { historyMode = "replace" } = {}) {
|
|
|
148
312
|
throw new Error(`unknown historyMode: ${historyMode}`);
|
|
149
313
|
}
|
|
150
314
|
const top = topLayer(state);
|
|
315
|
+
const framesToCollapse = top.frames.length - 1;
|
|
151
316
|
const next = freezeLayer({
|
|
152
317
|
id: patch.id ?? top.id,
|
|
153
318
|
url: patch.url ?? top.url,
|
|
@@ -156,44 +321,56 @@ function replaceTop(state, patch, { historyMode = "replace" } = {}) {
|
|
|
156
321
|
size: patch.size ?? top.size,
|
|
157
322
|
side: patch.side ?? top.side,
|
|
158
323
|
width: patch.width ?? top.width,
|
|
159
|
-
height: patch.height ?? top.height
|
|
324
|
+
height: patch.height ?? top.height,
|
|
325
|
+
frames: undefined
|
|
160
326
|
});
|
|
161
327
|
const newLayers = Object.freeze([...state.layers.slice(0, -1), next]);
|
|
162
328
|
const depth = newLayers.length;
|
|
163
329
|
const historyCmd = {
|
|
164
330
|
type: historyMode === "push" ? "pushHistory" : "replaceHistory",
|
|
165
331
|
url: next.url,
|
|
166
|
-
historyState: {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
type: "morphTopLayer",
|
|
173
|
-
layerId: next.id,
|
|
174
|
-
url: next.url,
|
|
175
|
-
depth,
|
|
176
|
-
variant: next.variant,
|
|
177
|
-
dismissible: next.dismissible,
|
|
178
|
-
...next.size ? { size: next.size } : {},
|
|
179
|
-
...next.side ? { side: next.side } : {},
|
|
180
|
-
...next.width ? { width: next.width } : {},
|
|
181
|
-
...next.height ? { height: next.height } : {}
|
|
182
|
-
},
|
|
183
|
-
historyCmd,
|
|
184
|
-
{ type: "persistSnapshot" }
|
|
185
|
-
]
|
|
332
|
+
historyState: {
|
|
333
|
+
stackId: state.stackId,
|
|
334
|
+
layerId: next.id,
|
|
335
|
+
depth,
|
|
336
|
+
frameIndex: 0
|
|
337
|
+
}
|
|
186
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 };
|
|
187
359
|
}
|
|
188
360
|
function closeAll(state) {
|
|
189
361
|
if (state.layers.length === 0)
|
|
190
362
|
return { state, commands: [] };
|
|
191
|
-
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
|
+
}));
|
|
192
368
|
return {
|
|
193
369
|
state: { ...state, layers: Object.freeze([]) },
|
|
194
370
|
commands: [
|
|
195
|
-
{ type: "unmountAllLayers" },
|
|
196
371
|
{ type: "closeDialog" },
|
|
372
|
+
{ type: "unmountAllLayers" },
|
|
373
|
+
...cacheClears,
|
|
197
374
|
{ type: "unlockScroll" },
|
|
198
375
|
{ type: "historyBack", n },
|
|
199
376
|
{ type: "clearSnapshot" }
|
|
@@ -205,11 +382,16 @@ function handlePopstate(state, { historyState, locationHref }) {
|
|
|
205
382
|
if (!isOurs) {
|
|
206
383
|
if (state.layers.length === 0)
|
|
207
384
|
return { state, commands: [] };
|
|
385
|
+
const cacheClears = state.layers.map((l) => ({
|
|
386
|
+
type: "clearFrameCache",
|
|
387
|
+
layerId: l.id
|
|
388
|
+
}));
|
|
208
389
|
return {
|
|
209
390
|
state: { ...state, layers: Object.freeze([]) },
|
|
210
391
|
commands: [
|
|
211
|
-
{ type: "unmountAllLayers" },
|
|
212
392
|
{ type: "closeDialog" },
|
|
393
|
+
{ type: "unmountAllLayers" },
|
|
394
|
+
...cacheClears,
|
|
213
395
|
{ type: "unlockScroll" },
|
|
214
396
|
{ type: "clearSnapshot" }
|
|
215
397
|
]
|
|
@@ -218,18 +400,24 @@ function handlePopstate(state, { historyState, locationHref }) {
|
|
|
218
400
|
const targetDepth = historyState.depth ?? 0;
|
|
219
401
|
const currentDepth = state.layers.length;
|
|
220
402
|
const targetLayerId = historyState.layerId ?? null;
|
|
403
|
+
const targetFrameIndex = historyState.frameIndex ?? 0;
|
|
221
404
|
if (targetDepth < currentDepth) {
|
|
405
|
+
const droppedLayers = state.layers.slice(targetDepth);
|
|
222
406
|
const newLayers = Object.freeze(state.layers.slice(0, targetDepth));
|
|
223
407
|
const newTop = newLayers[newLayers.length - 1] ?? null;
|
|
224
408
|
const commands = [];
|
|
225
|
-
|
|
409
|
+
if (!newTop)
|
|
410
|
+
commands.push({ type: "closeDialog" });
|
|
411
|
+
for (let i = 0;i < droppedLayers.length; i++) {
|
|
226
412
|
commands.push({ type: "unmountTopLayer" });
|
|
227
413
|
}
|
|
414
|
+
for (const dropped of droppedLayers) {
|
|
415
|
+
commands.push({ type: "clearFrameCache", layerId: dropped.id });
|
|
416
|
+
}
|
|
228
417
|
if (newTop) {
|
|
229
418
|
commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
|
|
230
419
|
commands.push({ type: "persistSnapshot" });
|
|
231
420
|
} else {
|
|
232
|
-
commands.push({ type: "closeDialog" });
|
|
233
421
|
commands.push({ type: "unlockScroll" });
|
|
234
422
|
commands.push({ type: "clearSnapshot" });
|
|
235
423
|
}
|
|
@@ -244,6 +432,51 @@ function handlePopstate(state, { historyState, locationHref }) {
|
|
|
244
432
|
};
|
|
245
433
|
}
|
|
246
434
|
const top = topLayer(state);
|
|
435
|
+
if (top && targetLayerId && top.id === targetLayerId) {
|
|
436
|
+
const currentFrameIndex = top.frames.length - 1;
|
|
437
|
+
if (targetFrameIndex === currentFrameIndex) {
|
|
438
|
+
return { state, commands: [] };
|
|
439
|
+
}
|
|
440
|
+
if (targetFrameIndex < currentFrameIndex) {
|
|
441
|
+
const newFrames = top.frames.slice(0, targetFrameIndex + 1);
|
|
442
|
+
const targetFrame = newFrames[newFrames.length - 1];
|
|
443
|
+
const updatedTop = freezeLayer({
|
|
444
|
+
id: top.id,
|
|
445
|
+
url: targetFrame.url,
|
|
446
|
+
variant: top.variant,
|
|
447
|
+
dismissible: top.dismissible,
|
|
448
|
+
size: top.size,
|
|
449
|
+
side: top.side,
|
|
450
|
+
width: top.width,
|
|
451
|
+
height: top.height,
|
|
452
|
+
frames: newFrames
|
|
453
|
+
});
|
|
454
|
+
const newLayers = Object.freeze([
|
|
455
|
+
...state.layers.slice(0, -1),
|
|
456
|
+
updatedTop
|
|
457
|
+
]);
|
|
458
|
+
return {
|
|
459
|
+
state: { ...state, layers: newLayers },
|
|
460
|
+
commands: [
|
|
461
|
+
{
|
|
462
|
+
type: "unmountFrame",
|
|
463
|
+
layerId: top.id,
|
|
464
|
+
fromFrameIndex: currentFrameIndex,
|
|
465
|
+
toFrameIndex: targetFrameIndex,
|
|
466
|
+
url: targetFrame.url,
|
|
467
|
+
stale: targetFrame.stale
|
|
468
|
+
},
|
|
469
|
+
{ type: "persistSnapshot" }
|
|
470
|
+
]
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
state,
|
|
475
|
+
commands: [
|
|
476
|
+
{ type: "rebuildFromSnapshot", targetDepth, targetLayerId }
|
|
477
|
+
]
|
|
478
|
+
};
|
|
479
|
+
}
|
|
247
480
|
if (top && targetLayerId && top.id !== targetLayerId) {
|
|
248
481
|
const updatedTop = freezeLayer({
|
|
249
482
|
id: targetLayerId,
|
|
@@ -262,6 +495,7 @@ function handlePopstate(state, { historyState, locationHref }) {
|
|
|
262
495
|
return {
|
|
263
496
|
state: { ...state, layers: newLayers },
|
|
264
497
|
commands: [
|
|
498
|
+
{ type: "clearFrameCache", layerId: top.id },
|
|
265
499
|
{
|
|
266
500
|
type: "morphTopLayer",
|
|
267
501
|
layerId: targetLayerId,
|
|
@@ -285,10 +519,23 @@ function snapshot(state, { now = Date.now } = {}) {
|
|
|
285
519
|
v: SNAPSHOT_VERSION,
|
|
286
520
|
stackId: state.stackId,
|
|
287
521
|
baseUrl: state.baseUrl,
|
|
288
|
-
layers: state.layers,
|
|
522
|
+
layers: state.layers.map(serializeLayer),
|
|
289
523
|
savedAt: now()
|
|
290
524
|
});
|
|
291
525
|
}
|
|
526
|
+
function serializeLayer(layer) {
|
|
527
|
+
return {
|
|
528
|
+
id: layer.id,
|
|
529
|
+
url: layer.url,
|
|
530
|
+
variant: layer.variant,
|
|
531
|
+
dismissible: layer.dismissible,
|
|
532
|
+
size: layer.size,
|
|
533
|
+
side: layer.side,
|
|
534
|
+
width: layer.width,
|
|
535
|
+
height: layer.height,
|
|
536
|
+
frames: layer.frames.map((f) => ({ url: f.url, stale: f.stale }))
|
|
537
|
+
};
|
|
538
|
+
}
|
|
292
539
|
function restore(serialized, { stackId, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Date.now } = {}) {
|
|
293
540
|
if (typeof serialized !== "string" || serialized.length === 0)
|
|
294
541
|
return null;
|
|
@@ -298,7 +545,7 @@ function restore(serialized, { stackId, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Dat
|
|
|
298
545
|
} catch {
|
|
299
546
|
return null;
|
|
300
547
|
}
|
|
301
|
-
if (parsed?.v !== SNAPSHOT_VERSION)
|
|
548
|
+
if (parsed?.v !== 1 && parsed?.v !== SNAPSHOT_VERSION)
|
|
302
549
|
return null;
|
|
303
550
|
if (typeof parsed.stackId !== "string")
|
|
304
551
|
return null;
|
|
@@ -317,6 +564,14 @@ function restore(serialized, { stackId, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Dat
|
|
|
317
564
|
return null;
|
|
318
565
|
if (!VARIANTS.includes(l.variant))
|
|
319
566
|
return null;
|
|
567
|
+
if (l.frames !== undefined) {
|
|
568
|
+
if (!Array.isArray(l.frames) || l.frames.length === 0)
|
|
569
|
+
return null;
|
|
570
|
+
for (const f of l.frames) {
|
|
571
|
+
if (!f || typeof f.url !== "string")
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
320
575
|
}
|
|
321
576
|
return Object.freeze({
|
|
322
577
|
stackId: parsed.stackId,
|
|
@@ -326,21 +581,27 @@ function restore(serialized, { stackId, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Dat
|
|
|
326
581
|
}
|
|
327
582
|
|
|
328
583
|
// app/javascript/modal_stack/orchestrator.js
|
|
584
|
+
var PREFETCH_TTL_MS = 30000;
|
|
585
|
+
|
|
329
586
|
class Orchestrator {
|
|
330
587
|
#expectedPopstates = 0;
|
|
588
|
+
#fragmentCache = new Map;
|
|
589
|
+
#inflight = new Map;
|
|
331
590
|
constructor({
|
|
332
591
|
runtime,
|
|
333
592
|
stackId,
|
|
334
593
|
baseUrl,
|
|
335
594
|
restoreFrom = null,
|
|
336
595
|
maxDepth = null,
|
|
337
|
-
maxDepthStrategy = "warn"
|
|
596
|
+
maxDepthStrategy = "warn",
|
|
597
|
+
prefetchTtlMs = PREFETCH_TTL_MS
|
|
338
598
|
}) {
|
|
339
599
|
if (!runtime)
|
|
340
600
|
throw new Error("runtime required");
|
|
341
601
|
this.runtime = runtime;
|
|
342
602
|
this.maxDepth = maxDepth;
|
|
343
603
|
this.maxDepthStrategy = maxDepthStrategy;
|
|
604
|
+
this.prefetchTtlMs = prefetchTtlMs;
|
|
344
605
|
this.state = createStack({ stackId, baseUrl });
|
|
345
606
|
if (restoreFrom) {
|
|
346
607
|
const restored = restore(restoreFrom, { stackId });
|
|
@@ -375,12 +636,67 @@ class Orchestrator {
|
|
|
375
636
|
}
|
|
376
637
|
return this.#dispatch(replaceTop(this.state, patch, opts), { html, fragment });
|
|
377
638
|
}
|
|
639
|
+
async pathTo(frame, { html = null, fragment = null, transition = null } = {}) {
|
|
640
|
+
let resolvedStale = frame?.stale === true;
|
|
641
|
+
if (fragment == null && html == null && frame?.url) {
|
|
642
|
+
const meta = await this.#prefetchWithMeta(frame.url);
|
|
643
|
+
fragment = meta.fragment;
|
|
644
|
+
if (frame.stale !== true && meta.stale === true)
|
|
645
|
+
resolvedStale = true;
|
|
646
|
+
}
|
|
647
|
+
return this.#dispatch(pathTo(this.state, { url: frame.url, stale: resolvedStale }, { transition }), { html, fragment });
|
|
648
|
+
}
|
|
649
|
+
pathBack({ steps = 1, transition = null } = {}) {
|
|
650
|
+
return this.#dispatch(pathBack(this.state, { steps, transition }));
|
|
651
|
+
}
|
|
378
652
|
async#prefetch(url) {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
653
|
+
const meta = await this.#prefetchWithMeta(url);
|
|
654
|
+
return meta.fragment;
|
|
655
|
+
}
|
|
656
|
+
async#prefetchWithMeta(url) {
|
|
657
|
+
if (typeof this.runtime.fetchFragment !== "function") {
|
|
658
|
+
return { fragment: null, stale: false };
|
|
659
|
+
}
|
|
660
|
+
const cached = this.#fragmentCache.get(url);
|
|
661
|
+
if (cached && Date.now() - cached.ts < this.prefetchTtlMs) {
|
|
662
|
+
return { fragment: cloneFragment(cached.fragment), stale: cached.stale === true };
|
|
663
|
+
}
|
|
664
|
+
const existing = this.#inflight.get(url);
|
|
665
|
+
if (existing) {
|
|
666
|
+
const entry2 = await existing.promise;
|
|
667
|
+
return { fragment: cloneFragment(entry2.fragment), stale: entry2.stale === true };
|
|
668
|
+
}
|
|
669
|
+
const controller = supportsAbort() ? new AbortController : null;
|
|
670
|
+
const fetchPromise = this.runtime.fetchFragment(url, controller ? { signal: controller.signal } : undefined).then((result) => {
|
|
671
|
+
const fragment = result?.fragment ?? result;
|
|
672
|
+
const stale = result?.stale === true;
|
|
673
|
+
const entry2 = { fragment, stale, ts: Date.now() };
|
|
674
|
+
this.#fragmentCache.set(url, entry2);
|
|
675
|
+
return entry2;
|
|
676
|
+
}).finally(() => {
|
|
677
|
+
this.#inflight.delete(url);
|
|
678
|
+
});
|
|
679
|
+
this.#inflight.set(url, { controller, promise: fetchPromise });
|
|
680
|
+
const entry = await fetchPromise;
|
|
681
|
+
return { fragment: cloneFragment(entry.fragment), stale: entry.stale === true };
|
|
682
|
+
}
|
|
683
|
+
#invalidatePrefetch() {
|
|
684
|
+
for (const { controller } of this.#inflight.values()) {
|
|
685
|
+
try {
|
|
686
|
+
controller?.abort();
|
|
687
|
+
} catch {}
|
|
688
|
+
}
|
|
689
|
+
this.#inflight.clear();
|
|
690
|
+
this.#fragmentCache.clear();
|
|
691
|
+
}
|
|
692
|
+
prefetch(url) {
|
|
693
|
+
if (!url || typeof this.runtime.fetchFragment !== "function") {
|
|
694
|
+
return Promise.resolve(null);
|
|
695
|
+
}
|
|
696
|
+
return this.#prefetch(url).catch(() => null);
|
|
382
697
|
}
|
|
383
698
|
closeAll() {
|
|
699
|
+
this.#invalidatePrefetch();
|
|
384
700
|
return this.#dispatch(closeAll(this.state));
|
|
385
701
|
}
|
|
386
702
|
onPopstate({ historyState, locationHref }) {
|
|
@@ -388,12 +704,13 @@ class Orchestrator {
|
|
|
388
704
|
this.#expectedPopstates -= 1;
|
|
389
705
|
return Promise.resolve();
|
|
390
706
|
}
|
|
707
|
+
this.#invalidatePrefetch();
|
|
391
708
|
return this.#dispatch(handlePopstate(this.state, { historyState, locationHref }));
|
|
392
709
|
}
|
|
393
710
|
async#dispatch({ state, commands }, payload = {}) {
|
|
394
711
|
this.state = state;
|
|
395
712
|
for (const cmd of commands) {
|
|
396
|
-
if (cmd.type === "mountLayer" || cmd.type === "morphTopLayer") {
|
|
713
|
+
if (cmd.type === "mountLayer" || cmd.type === "morphTopLayer" || cmd.type === "mountFrame") {
|
|
397
714
|
if (payload.html != null)
|
|
398
715
|
cmd.html = payload.html;
|
|
399
716
|
if (payload.fragment != null)
|
|
@@ -418,13 +735,28 @@ class Orchestrator {
|
|
|
418
735
|
await handler.call(this.runtime, cmd);
|
|
419
736
|
}
|
|
420
737
|
}
|
|
738
|
+
function cloneFragment(fragment) {
|
|
739
|
+
if (!fragment)
|
|
740
|
+
return fragment;
|
|
741
|
+
if (typeof fragment.cloneNode === "function") {
|
|
742
|
+
return fragment.cloneNode(true);
|
|
743
|
+
}
|
|
744
|
+
return fragment;
|
|
745
|
+
}
|
|
746
|
+
function supportsAbort() {
|
|
747
|
+
return typeof globalThis.AbortController === "function";
|
|
748
|
+
}
|
|
421
749
|
|
|
422
750
|
// app/javascript/modal_stack/runtime.js
|
|
423
751
|
var SNAPSHOT_KEY = "modalStackSnapshot";
|
|
424
752
|
var FRAGMENT_HEADER = "X-Modal-Stack-Request";
|
|
753
|
+
var STALE_HEADER = "X-Modal-Stack-Stale";
|
|
425
754
|
var SCROLLBAR_WIDTH_VAR = "--modal-stack-scrollbar-width";
|
|
426
755
|
var LAYER_SELECTOR = '[data-modal-stack-target="layer"]';
|
|
427
|
-
var
|
|
756
|
+
var FRAME_SELECTOR = "[data-modal-stack-frame]";
|
|
757
|
+
var DURATION_CSS_VAR = "--modal-stack-duration";
|
|
758
|
+
var LEAVE_TIMEOUT_FLOOR_MS = 300;
|
|
759
|
+
var LEAVE_TIMEOUT_FALLBACK_MS = 600;
|
|
428
760
|
|
|
429
761
|
class BrowserRuntime {
|
|
430
762
|
constructor({
|
|
@@ -447,6 +779,7 @@ class BrowserRuntime {
|
|
|
447
779
|
this.fetcher = fetcher;
|
|
448
780
|
this.store = store;
|
|
449
781
|
this.document = documentRef;
|
|
782
|
+
this._frameCache = new Map;
|
|
450
783
|
}
|
|
451
784
|
showDialog() {
|
|
452
785
|
if (!this.dialog.open)
|
|
@@ -487,7 +820,10 @@ class BrowserRuntime {
|
|
|
487
820
|
const frag = await this.#resolveFragment({ url, html, fragment });
|
|
488
821
|
const layer = this.document.createElement("div");
|
|
489
822
|
this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
|
|
490
|
-
layer
|
|
823
|
+
this.#applyFrameDepth(layer, 0);
|
|
824
|
+
const wrapper = this.#createFrameWrapper({ frameIndex: 0 });
|
|
825
|
+
wrapper.append(...frag.childNodes);
|
|
826
|
+
layer.appendChild(wrapper);
|
|
491
827
|
this.dialog.appendChild(layer);
|
|
492
828
|
}
|
|
493
829
|
async morphTopLayer({ layerId, url, depth, variant, dismissible, size, side, width, height, html, fragment }) {
|
|
@@ -496,17 +832,72 @@ class BrowserRuntime {
|
|
|
496
832
|
if (!layer)
|
|
497
833
|
return;
|
|
498
834
|
this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
|
|
499
|
-
layer
|
|
835
|
+
this.#applyFrameDepth(layer, 0);
|
|
836
|
+
const wrapper = this.#createFrameWrapper({ frameIndex: 0 });
|
|
837
|
+
wrapper.append(...frag.childNodes);
|
|
838
|
+
layer.replaceChildren(wrapper);
|
|
839
|
+
}
|
|
840
|
+
async mountFrame({ layerId, fromFrameIndex, toFrameIndex, url, html, fragment, transition }) {
|
|
841
|
+
const layer = this.#findLayer(layerId);
|
|
842
|
+
if (!layer)
|
|
843
|
+
return;
|
|
844
|
+
const frag = await this.#resolveFragment({ url, html, fragment });
|
|
845
|
+
const oldFrame = this.#findFrame(layer, fromFrameIndex);
|
|
846
|
+
if (oldFrame) {
|
|
847
|
+
const cached = this.document.createDocumentFragment();
|
|
848
|
+
cached.append(...oldFrame.childNodes);
|
|
849
|
+
this._frameCache.set(this.#frameKey(layerId, fromFrameIndex), cached);
|
|
850
|
+
}
|
|
851
|
+
const newFrame = this.#createFrameWrapper({ frameIndex: toFrameIndex, transition, direction: "forward" });
|
|
852
|
+
newFrame.append(...frag.childNodes);
|
|
853
|
+
layer.appendChild(newFrame);
|
|
854
|
+
this.#applyFrameDepth(layer, toFrameIndex);
|
|
855
|
+
if (oldFrame)
|
|
856
|
+
oldFrame.remove();
|
|
857
|
+
if (transition)
|
|
858
|
+
this.#cleanupFrameTransition(newFrame);
|
|
859
|
+
}
|
|
860
|
+
async unmountFrame({ layerId, fromFrameIndex, toFrameIndex, url, stale, transition }) {
|
|
861
|
+
const layer = this.#findLayer(layerId);
|
|
862
|
+
if (!layer)
|
|
863
|
+
return;
|
|
864
|
+
const cacheKey = this.#frameKey(layerId, toFrameIndex);
|
|
865
|
+
let restored = stale ? null : this._frameCache.get(cacheKey) ?? null;
|
|
866
|
+
if (!restored) {
|
|
867
|
+
const result = await this.fetchFragment(url);
|
|
868
|
+
restored = result.fragment;
|
|
869
|
+
this._frameCache.set(cacheKey, cloneFragment2(restored, this.document));
|
|
870
|
+
} else {
|
|
871
|
+
restored = cloneFragment2(restored, this.document);
|
|
872
|
+
}
|
|
873
|
+
const newFrame = this.#createFrameWrapper({ frameIndex: toFrameIndex, transition, direction: "back" });
|
|
874
|
+
newFrame.append(...restored.childNodes);
|
|
875
|
+
layer.appendChild(newFrame);
|
|
876
|
+
this.#applyFrameDepth(layer, toFrameIndex);
|
|
877
|
+
this.#purgeFrameCacheAbove(layerId, toFrameIndex);
|
|
878
|
+
const oldFrame = this.#findFrame(layer, fromFrameIndex);
|
|
879
|
+
if (oldFrame)
|
|
880
|
+
oldFrame.remove();
|
|
881
|
+
if (transition)
|
|
882
|
+
this.#cleanupFrameTransition(newFrame);
|
|
883
|
+
}
|
|
884
|
+
clearFrameCache({ layerId }) {
|
|
885
|
+
const prefix = `${layerId}#`;
|
|
886
|
+
for (const key of [...this._frameCache.keys()]) {
|
|
887
|
+
if (key.startsWith(prefix))
|
|
888
|
+
this._frameCache.delete(key);
|
|
889
|
+
}
|
|
500
890
|
}
|
|
501
891
|
async unmountTopLayer() {
|
|
502
892
|
const layer = this.#topLayer();
|
|
503
893
|
if (!layer)
|
|
504
894
|
return;
|
|
505
|
-
await animateOut(layer);
|
|
895
|
+
await animateOut(layer, this.#leaveTimeoutMs());
|
|
506
896
|
}
|
|
507
897
|
async unmountAllLayers() {
|
|
508
898
|
const layers = [...this.dialog.querySelectorAll(LAYER_SELECTOR)];
|
|
509
|
-
|
|
899
|
+
const timeout = this.#leaveTimeoutMs();
|
|
900
|
+
await Promise.all(layers.map((l) => animateOut(l, timeout)));
|
|
510
901
|
}
|
|
511
902
|
pushHistory({ url, historyState }) {
|
|
512
903
|
this.history.pushState(historyState, "", url);
|
|
@@ -543,9 +934,68 @@ class BrowserRuntime {
|
|
|
543
934
|
return null;
|
|
544
935
|
}
|
|
545
936
|
}
|
|
937
|
+
#leaveTimeoutMs() {
|
|
938
|
+
if (this._cachedLeaveTimeoutMs != null)
|
|
939
|
+
return this._cachedLeaveTimeoutMs;
|
|
940
|
+
const get = globalThis.getComputedStyle;
|
|
941
|
+
if (typeof get !== "function" || !this.dialog?.ownerDocument) {
|
|
942
|
+
return LEAVE_TIMEOUT_FALLBACK_MS;
|
|
943
|
+
}
|
|
944
|
+
let parsed = NaN;
|
|
945
|
+
try {
|
|
946
|
+
const raw = get(this.dialog).getPropertyValue(DURATION_CSS_VAR);
|
|
947
|
+
parsed = parseDurationMs(raw);
|
|
948
|
+
} catch {}
|
|
949
|
+
const ms = Number.isFinite(parsed) ? Math.max(Math.ceil(parsed * 1.5), LEAVE_TIMEOUT_FLOOR_MS) : LEAVE_TIMEOUT_FALLBACK_MS;
|
|
950
|
+
this._cachedLeaveTimeoutMs = ms;
|
|
951
|
+
return ms;
|
|
952
|
+
}
|
|
546
953
|
#findLayer(layerId) {
|
|
547
954
|
return this.dialog.querySelector(`${LAYER_SELECTOR}[data-layer-id="${escapeAttr(layerId)}"]`);
|
|
548
955
|
}
|
|
956
|
+
#findFrame(layer, frameIndex) {
|
|
957
|
+
return layer.querySelector(`${FRAME_SELECTOR}[data-frame-index="${escapeAttr(String(frameIndex))}"]`);
|
|
958
|
+
}
|
|
959
|
+
#frameKey(layerId, frameIndex) {
|
|
960
|
+
return `${layerId}#${frameIndex}`;
|
|
961
|
+
}
|
|
962
|
+
#purgeFrameCacheAbove(layerId, frameIndex) {
|
|
963
|
+
const prefix = `${layerId}#`;
|
|
964
|
+
for (const key of [...this._frameCache.keys()]) {
|
|
965
|
+
if (!key.startsWith(prefix))
|
|
966
|
+
continue;
|
|
967
|
+
const idx = Number(key.slice(prefix.length));
|
|
968
|
+
if (Number.isFinite(idx) && idx > frameIndex) {
|
|
969
|
+
this._frameCache.delete(key);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
#cleanupFrameTransition(frameEl) {
|
|
974
|
+
let done = false;
|
|
975
|
+
const cleanup = () => {
|
|
976
|
+
if (done)
|
|
977
|
+
return;
|
|
978
|
+
done = true;
|
|
979
|
+
frameEl.removeAttribute("data-transition");
|
|
980
|
+
frameEl.removeAttribute("data-direction");
|
|
981
|
+
};
|
|
982
|
+
frameEl.addEventListener("transitionend", cleanup, { once: true });
|
|
983
|
+
setTimeout(cleanup, this.#leaveTimeoutMs());
|
|
984
|
+
}
|
|
985
|
+
#createFrameWrapper({ frameIndex, transition = null, direction = null }) {
|
|
986
|
+
const el = this.document.createElement("div");
|
|
987
|
+
el.dataset.modalStackFrame = "";
|
|
988
|
+
el.dataset.frameIndex = String(frameIndex);
|
|
989
|
+
if (transition)
|
|
990
|
+
el.dataset.transition = transition;
|
|
991
|
+
if (direction)
|
|
992
|
+
el.dataset.direction = direction;
|
|
993
|
+
return el;
|
|
994
|
+
}
|
|
995
|
+
#applyFrameDepth(layer, topFrameIndex) {
|
|
996
|
+
layer.dataset.frameIndex = String(topFrameIndex);
|
|
997
|
+
layer.dataset.frameDepth = String(topFrameIndex + 1);
|
|
998
|
+
}
|
|
549
999
|
#topLayer() {
|
|
550
1000
|
const layers = this.dialog.querySelectorAll(LAYER_SELECTOR);
|
|
551
1001
|
return layers[layers.length - 1] ?? null;
|
|
@@ -579,26 +1029,29 @@ class BrowserRuntime {
|
|
|
579
1029
|
layer.style.removeProperty("height");
|
|
580
1030
|
}
|
|
581
1031
|
}
|
|
582
|
-
async fetchFragment(url) {
|
|
1032
|
+
async fetchFragment(url, { signal } = {}) {
|
|
583
1033
|
const resp = await this.fetcher(url, {
|
|
584
1034
|
headers: {
|
|
585
1035
|
Accept: "text/html, text/vnd.turbo-stream.html",
|
|
586
1036
|
[FRAGMENT_HEADER]: "1"
|
|
587
1037
|
},
|
|
588
|
-
credentials: "same-origin"
|
|
1038
|
+
credentials: "same-origin",
|
|
1039
|
+
signal
|
|
589
1040
|
});
|
|
590
1041
|
if (!resp.ok) {
|
|
591
1042
|
throw new Error(`modal_stack: fetch ${url} → ${resp.status}`);
|
|
592
1043
|
}
|
|
593
1044
|
const html = await resp.text();
|
|
594
|
-
|
|
1045
|
+
const stale = parseStaleHeader(resp);
|
|
1046
|
+
return { fragment: parseFragment(html, this.document), stale };
|
|
595
1047
|
}
|
|
596
1048
|
async#resolveFragment({ url, html, fragment }) {
|
|
597
1049
|
if (fragment)
|
|
598
1050
|
return fragment;
|
|
599
1051
|
if (html != null)
|
|
600
1052
|
return parseFragment(html, this.document);
|
|
601
|
-
|
|
1053
|
+
const result = await this.fetchFragment(url);
|
|
1054
|
+
return result.fragment;
|
|
602
1055
|
}
|
|
603
1056
|
}
|
|
604
1057
|
function parseFragment(html, doc) {
|
|
@@ -608,7 +1061,26 @@ function parseFragment(html, doc) {
|
|
|
608
1061
|
fragment.append(...parsed.body.childNodes);
|
|
609
1062
|
return fragment;
|
|
610
1063
|
}
|
|
611
|
-
function
|
|
1064
|
+
function parseStaleHeader(resp) {
|
|
1065
|
+
const headers = resp?.headers;
|
|
1066
|
+
const value = typeof headers?.get === "function" ? headers.get(STALE_HEADER) ?? headers.get(STALE_HEADER.toLowerCase()) : null;
|
|
1067
|
+
if (!value)
|
|
1068
|
+
return false;
|
|
1069
|
+
const normalized = String(value).trim().toLowerCase();
|
|
1070
|
+
return normalized === "true" || normalized === "1";
|
|
1071
|
+
}
|
|
1072
|
+
function cloneFragment2(fragment, doc) {
|
|
1073
|
+
if (typeof fragment?.cloneNode === "function") {
|
|
1074
|
+
return fragment.cloneNode(true);
|
|
1075
|
+
}
|
|
1076
|
+
const clone = doc.createDocumentFragment();
|
|
1077
|
+
if (fragment?.childNodes) {
|
|
1078
|
+
for (const node of fragment.childNodes)
|
|
1079
|
+
clone.appendChild(node.cloneNode(true));
|
|
1080
|
+
}
|
|
1081
|
+
return clone;
|
|
1082
|
+
}
|
|
1083
|
+
function animateOut(layer, timeoutMs = LEAVE_TIMEOUT_FALLBACK_MS) {
|
|
612
1084
|
return new Promise((resolve) => {
|
|
613
1085
|
let done = false;
|
|
614
1086
|
const finish = () => {
|
|
@@ -621,18 +1093,29 @@ function animateOut(layer) {
|
|
|
621
1093
|
};
|
|
622
1094
|
layer.addEventListener("transitionend", finish, { once: true });
|
|
623
1095
|
layer.dataset.leaving = "";
|
|
624
|
-
setTimeout(finish,
|
|
1096
|
+
setTimeout(finish, timeoutMs);
|
|
625
1097
|
});
|
|
626
1098
|
}
|
|
1099
|
+
function parseDurationMs(raw) {
|
|
1100
|
+
if (typeof raw !== "string")
|
|
1101
|
+
return NaN;
|
|
1102
|
+
const value = raw.trim();
|
|
1103
|
+
if (!value)
|
|
1104
|
+
return NaN;
|
|
1105
|
+
const num = parseFloat(value);
|
|
1106
|
+
if (!Number.isFinite(num))
|
|
1107
|
+
return NaN;
|
|
1108
|
+
return /m?s$/i.test(value) && !/ms$/i.test(value) ? num * 1000 : num;
|
|
1109
|
+
}
|
|
627
1110
|
function escapeAttr(value) {
|
|
628
1111
|
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
629
1112
|
return CSS.escape(value);
|
|
630
1113
|
}
|
|
631
|
-
return String(value).replace(/["\\]/g, "\\$&");
|
|
1114
|
+
return String(value).replace(/["\\[\]]/g, "\\$&");
|
|
632
1115
|
}
|
|
633
1116
|
|
|
634
1117
|
// app/javascript/modal_stack/controllers/modal_stack_controller.js
|
|
635
|
-
class ModalStackController extends
|
|
1118
|
+
class ModalStackController extends Controller2 {
|
|
636
1119
|
static values = {
|
|
637
1120
|
stackId: String,
|
|
638
1121
|
baseUrl: String,
|
|
@@ -694,6 +1177,17 @@ class ModalStackController extends Controller {
|
|
|
694
1177
|
closeAll() {
|
|
695
1178
|
return this.orchestrator.closeAll();
|
|
696
1179
|
}
|
|
1180
|
+
prefetch(url) {
|
|
1181
|
+
return this.orchestrator.prefetch(url);
|
|
1182
|
+
}
|
|
1183
|
+
pathBack(event) {
|
|
1184
|
+
if (event) {
|
|
1185
|
+
event.preventDefault();
|
|
1186
|
+
event.stopPropagation();
|
|
1187
|
+
}
|
|
1188
|
+
const steps = readSteps(event);
|
|
1189
|
+
return this.orchestrator.pathBack({ steps });
|
|
1190
|
+
}
|
|
697
1191
|
#topLayer() {
|
|
698
1192
|
const layers = this.orchestrator.layers;
|
|
699
1193
|
return layers[layers.length - 1] ?? null;
|
|
@@ -734,6 +1228,19 @@ class ModalStackController extends Controller {
|
|
|
734
1228
|
StreamActions.modal_close_all = guarded("modal_close_all", function(orch) {
|
|
735
1229
|
return orch.closeAll();
|
|
736
1230
|
});
|
|
1231
|
+
StreamActions.modal_path_to = guarded("modal_path_to", function(orch) {
|
|
1232
|
+
return orch.pathTo(frameFromStreamElement(this), {
|
|
1233
|
+
fragment: this.templateContent.cloneNode(true),
|
|
1234
|
+
transition: this.dataset.transition || null
|
|
1235
|
+
});
|
|
1236
|
+
});
|
|
1237
|
+
StreamActions.modal_path_back = guarded("modal_path_back", function(orch) {
|
|
1238
|
+
const steps = parsePositiveInt(this.dataset.steps, 1);
|
|
1239
|
+
return orch.pathBack({
|
|
1240
|
+
steps,
|
|
1241
|
+
transition: this.dataset.transition || null
|
|
1242
|
+
});
|
|
1243
|
+
});
|
|
737
1244
|
}
|
|
738
1245
|
}
|
|
739
1246
|
function emitStreamError(dialog, action, error) {
|
|
@@ -785,16 +1292,44 @@ function generateLayerId() {
|
|
|
785
1292
|
}
|
|
786
1293
|
return `ms-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
787
1294
|
}
|
|
1295
|
+
function frameFromStreamElement(el) {
|
|
1296
|
+
return {
|
|
1297
|
+
url: el.dataset.url || window.location.href,
|
|
1298
|
+
stale: el.dataset.stale === "true" || el.dataset.stale === "1"
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
function parsePositiveInt(raw, fallback) {
|
|
1302
|
+
const n = Number.parseInt(raw, 10);
|
|
1303
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
1304
|
+
}
|
|
1305
|
+
function readSteps(event) {
|
|
1306
|
+
const params = event?.params;
|
|
1307
|
+
if (params && Number.isFinite(params.steps) && params.steps > 0) {
|
|
1308
|
+
return params.steps;
|
|
1309
|
+
}
|
|
1310
|
+
const target = event?.currentTarget ?? event?.target;
|
|
1311
|
+
return parsePositiveInt(target?.dataset?.steps, 1);
|
|
1312
|
+
}
|
|
788
1313
|
|
|
789
1314
|
// app/javascript/modal_stack/controllers/modal_stack_link_controller.js
|
|
790
|
-
import { Controller as
|
|
1315
|
+
import { Controller as Controller3 } from "@hotwired/stimulus";
|
|
791
1316
|
|
|
792
|
-
class ModalStackLinkController extends
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
1317
|
+
class ModalStackLinkController extends Controller3 {
|
|
1318
|
+
connect() {
|
|
1319
|
+
if (this.element.dataset.modalStackLinkPrefetch === "false")
|
|
1320
|
+
return;
|
|
1321
|
+
this._onIntent = () => this.#warm();
|
|
1322
|
+
this.element.addEventListener("pointerenter", this._onIntent);
|
|
1323
|
+
this.element.addEventListener("focus", this._onIntent);
|
|
1324
|
+
}
|
|
1325
|
+
disconnect() {
|
|
1326
|
+
if (!this._onIntent)
|
|
796
1327
|
return;
|
|
797
|
-
|
|
1328
|
+
this.element.removeEventListener("pointerenter", this._onIntent);
|
|
1329
|
+
this.element.removeEventListener("focus", this._onIntent);
|
|
1330
|
+
}
|
|
1331
|
+
open(event) {
|
|
1332
|
+
const controller = this.#stackController();
|
|
798
1333
|
if (!controller)
|
|
799
1334
|
return;
|
|
800
1335
|
event.preventDefault();
|
|
@@ -810,6 +1345,18 @@ class ModalStackLinkController extends Controller2 {
|
|
|
810
1345
|
dismissible: ds.modalStackLinkDismissible !== "false"
|
|
811
1346
|
});
|
|
812
1347
|
}
|
|
1348
|
+
#warm() {
|
|
1349
|
+
const controller = this.#stackController();
|
|
1350
|
+
if (!controller || typeof controller.prefetch !== "function")
|
|
1351
|
+
return;
|
|
1352
|
+
controller.prefetch(this.element.href);
|
|
1353
|
+
}
|
|
1354
|
+
#stackController() {
|
|
1355
|
+
const stack = document.querySelector('[data-controller~="modal-stack"]');
|
|
1356
|
+
if (!stack)
|
|
1357
|
+
return null;
|
|
1358
|
+
return this.application.getControllerForElementAndIdentifier(stack, "modal-stack");
|
|
1359
|
+
}
|
|
813
1360
|
}
|
|
814
1361
|
function generateLayerId2() {
|
|
815
1362
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
@@ -825,10 +1372,12 @@ function install(application) {
|
|
|
825
1372
|
}
|
|
826
1373
|
application.register("modal-stack", ModalStackController);
|
|
827
1374
|
application.register("modal-stack-link", ModalStackLinkController);
|
|
1375
|
+
application.register("modal-stack-back-link", ModalStackBackLinkController);
|
|
828
1376
|
return application;
|
|
829
1377
|
}
|
|
830
1378
|
export {
|
|
831
1379
|
install,
|
|
832
1380
|
ModalStackLinkController,
|
|
833
|
-
ModalStackController
|
|
1381
|
+
ModalStackController,
|
|
1382
|
+
ModalStackBackLinkController
|
|
834
1383
|
};
|