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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/README.md +187 -36
  4. data/app/assets/javascripts/modal_stack.js +693 -73
  5. data/app/assets/stylesheets/modal_stack/bootstrap.css +113 -3
  6. data/app/assets/stylesheets/modal_stack/tailwind_v3.css +63 -2
  7. data/app/assets/stylesheets/modal_stack/tailwind_v4.css +63 -2
  8. data/app/assets/stylesheets/modal_stack/vanilla.css +121 -3
  9. data/app/javascript/modal_stack/controllers/modal_stack_back_link_controller.js +32 -0
  10. data/app/javascript/modal_stack/controllers/modal_stack_controller.js +161 -8
  11. data/app/javascript/modal_stack/install.js +7 -1
  12. data/app/javascript/modal_stack/orchestrator.js +70 -10
  13. data/app/javascript/modal_stack/orchestrator.test.js +98 -2
  14. data/app/javascript/modal_stack/runtime.js +316 -9
  15. data/app/javascript/modal_stack/runtime.test.js +90 -6
  16. data/app/javascript/modal_stack/state.js +343 -45
  17. data/app/javascript/modal_stack/state.test.js +404 -17
  18. data/app/views/modal_stack/_dialog.html.erb +1 -0
  19. data/app/views/modal_stack/_panel.html.erb +4 -0
  20. data/lib/generators/modal_stack/install/templates/initializer.rb +9 -0
  21. data/lib/generators/modal_stack/views/templates/_dialog.html.erb +9 -0
  22. data/lib/generators/modal_stack/views/templates/_panel.html.erb +18 -0
  23. data/lib/generators/modal_stack/views/views_generator.rb +50 -0
  24. data/lib/modal_stack/capybara.rb +21 -0
  25. data/lib/modal_stack/configuration.rb +37 -16
  26. data/lib/modal_stack/engine.rb +2 -0
  27. data/lib/modal_stack/helpers/modal_back_link_helper.rb +48 -0
  28. data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +7 -1
  29. data/lib/modal_stack/helpers/modal_stack_container_helper.rb +42 -15
  30. data/lib/modal_stack/turbo_streams_extension.rb +56 -0
  31. data/lib/modal_stack/version.rb +1 -1
  32. data/lib/modal_stack.rb +5 -1
  33. metadata +9 -2
@@ -1,6 +1,33 @@
1
- // app/javascript/modal_stack/controllers/modal_stack_controller.js
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 SNAPSHOT_VERSION = 1;
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 freezeLayer({ id, url, variant, dismissible, size, side, width, height }) {
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: { stackId: state.stackId, layerId: newLayer.id, depth }
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: "historyBack", n: 1 });
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: "historyBack", n: 1 });
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: { stackId: state.stackId, layerId: next.id, depth }
168
- };
169
- return {
170
- state: { ...state, layers: newLayers },
171
- commands: [
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.length;
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
- { type: "unlockScroll" },
215
- { type: "clearSnapshot" }
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 (!newTop)
409
+ if (newTop) {
410
+ commands.push({ type: "persistSnapshot" });
411
+ } else {
227
412
  commands.push({ type: "closeDialog" });
228
- for (let i = 0;i < currentDepth - targetDepth; i++) {
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
- if (typeof this.runtime.fetchFragment !== "function")
388
- return null;
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((fragment) => {
400
- const entry2 = { fragment, ts: Date.now() };
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.append(...frag.childNodes);
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.replaceChildren(...frag.childNodes);
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({ url, historyState }) {
570
- this.history.pushState(historyState, "", url);
968
+ pushHistory({ historyState }) {
969
+ this.history.pushState(historyState, "", this.location?.href ?? "");
571
970
  }
572
- replaceHistory({ url, historyState }) {
573
- this.history.replaceState(historyState, "", url);
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
- return parseFragment(html, this.document);
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
- return this.fetchFragment(url);
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 Controller {
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
- const snapshot2 = this.runtime.readSnapshot();
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: snapshot2,
1260
+ restoreFrom: null,
738
1261
  maxDepth: this.maxDepthValue > 0 ? this.maxDepthValue : null,
739
1262
  maxDepthStrategy: this.maxDepthStrategyValue || "warn"
740
1263
  });
741
- this._onPopstate = (event) => this.orchestrator.onPopstate({
742
- historyState: event.state,
743
- locationHref: window.location.href
744
- });
745
- window.addEventListener("popstate", this._onPopstate);
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
- this.element.dispatchEvent(new CustomEvent("modal_stack:ready", { bubbles: true, detail: { stackId } }));
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 Controller2 } from "@hotwired/stimulus";
1497
+ import { Controller as Controller3 } from "@hotwired/stimulus";
880
1498
 
881
- class ModalStackLinkController extends Controller2 {
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
  };