modal_stack 0.3.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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/README.md +113 -32
  4. data/app/assets/javascripts/modal_stack.js +488 -50
  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 +50 -0
  11. data/app/javascript/modal_stack/install.js +7 -1
  12. data/app/javascript/modal_stack/orchestrator.js +53 -7
  13. data/app/javascript/modal_stack/orchestrator.test.js +96 -0
  14. data/app/javascript/modal_stack/runtime.js +167 -5
  15. data/app/javascript/modal_stack/runtime.test.js +83 -0
  16. data/app/javascript/modal_stack/state.js +319 -34
  17. data/app/javascript/modal_stack/state.test.js +394 -9
  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 +1 -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,26 +171,134 @@ 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) {
131
292
  commands.push({ type: "unmountTopLayer" });
132
- commands.push({ type: "historyBack", n: 1 });
293
+ commands.push({ type: "clearFrameCache", layerId: popped.id });
294
+ commands.push({ type: "historyBack", n: framesToWalkBack });
133
295
  commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
134
296
  commands.push({ type: "persistSnapshot" });
135
297
  } else {
136
298
  commands.push({ type: "closeDialog" });
137
299
  commands.push({ type: "unmountTopLayer" });
138
- commands.push({ type: "historyBack", n: 1 });
300
+ commands.push({ type: "clearFrameCache", layerId: popped.id });
301
+ commands.push({ type: "historyBack", n: framesToWalkBack });
139
302
  commands.push({ type: "unlockScroll" });
140
303
  commands.push({ type: "clearSnapshot" });
141
304
  }
@@ -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,44 +321,56 @@ 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" },
197
372
  { type: "unmountAllLayers" },
373
+ ...cacheClears,
198
374
  { type: "unlockScroll" },
199
375
  { type: "historyBack", n },
200
376
  { type: "clearSnapshot" }
@@ -206,11 +382,16 @@ 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" },
213
393
  { type: "unmountAllLayers" },
394
+ ...cacheClears,
214
395
  { type: "unlockScroll" },
215
396
  { type: "clearSnapshot" }
216
397
  ]
@@ -219,15 +400,20 @@ function handlePopstate(state, { historyState, locationHref }) {
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
409
  if (!newTop)
227
410
  commands.push({ type: "closeDialog" });
228
- for (let i = 0;i < currentDepth - targetDepth; i++) {
411
+ for (let i = 0;i < droppedLayers.length; i++) {
229
412
  commands.push({ type: "unmountTopLayer" });
230
413
  }
414
+ for (const dropped of droppedLayers) {
415
+ commands.push({ type: "clearFrameCache", layerId: dropped.id });
416
+ }
231
417
  if (newTop) {
232
418
  commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
233
419
  commands.push({ type: "persistSnapshot" });
@@ -246,6 +432,51 @@ function handlePopstate(state, { historyState, locationHref }) {
246
432
  };
247
433
  }
248
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
+ }
249
480
  if (top && targetLayerId && top.id !== targetLayerId) {
250
481
  const updatedTop = freezeLayer({
251
482
  id: targetLayerId,
@@ -264,6 +495,7 @@ function handlePopstate(state, { historyState, locationHref }) {
264
495
  return {
265
496
  state: { ...state, layers: newLayers },
266
497
  commands: [
498
+ { type: "clearFrameCache", layerId: top.id },
267
499
  {
268
500
  type: "morphTopLayer",
269
501
  layerId: targetLayerId,
@@ -287,10 +519,23 @@ function snapshot(state, { now = Date.now } = {}) {
287
519
  v: SNAPSHOT_VERSION,
288
520
  stackId: state.stackId,
289
521
  baseUrl: state.baseUrl,
290
- layers: state.layers,
522
+ layers: state.layers.map(serializeLayer),
291
523
  savedAt: now()
292
524
  });
293
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
+ }
294
539
  function restore(serialized, { stackId, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Date.now } = {}) {
295
540
  if (typeof serialized !== "string" || serialized.length === 0)
296
541
  return null;
@@ -300,7 +545,7 @@ function restore(serialized, { stackId, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Dat
300
545
  } catch {
301
546
  return null;
302
547
  }
303
- if (parsed?.v !== SNAPSHOT_VERSION)
548
+ if (parsed?.v !== 1 && parsed?.v !== SNAPSHOT_VERSION)
304
549
  return null;
305
550
  if (typeof parsed.stackId !== "string")
306
551
  return null;
@@ -319,6 +564,14 @@ function restore(serialized, { stackId, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Dat
319
564
  return null;
320
565
  if (!VARIANTS.includes(l.variant))
321
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
+ }
322
575
  }
323
576
  return Object.freeze({
324
577
  stackId: parsed.stackId,
@@ -383,21 +636,41 @@ class Orchestrator {
383
636
  }
384
637
  return this.#dispatch(replaceTop(this.state, patch, opts), { html, fragment });
385
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
+ }
386
652
  async#prefetch(url) {
387
- if (typeof this.runtime.fetchFragment !== "function")
388
- return null;
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
+ }
389
660
  const cached = this.#fragmentCache.get(url);
390
661
  if (cached && Date.now() - cached.ts < this.prefetchTtlMs) {
391
- return cloneFragment(cached.fragment);
662
+ return { fragment: cloneFragment(cached.fragment), stale: cached.stale === true };
392
663
  }
393
664
  const existing = this.#inflight.get(url);
394
665
  if (existing) {
395
666
  const entry2 = await existing.promise;
396
- return cloneFragment(entry2.fragment);
667
+ return { fragment: cloneFragment(entry2.fragment), stale: entry2.stale === true };
397
668
  }
398
669
  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() };
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() };
401
674
  this.#fragmentCache.set(url, entry2);
402
675
  return entry2;
403
676
  }).finally(() => {
@@ -405,7 +678,7 @@ class Orchestrator {
405
678
  });
406
679
  this.#inflight.set(url, { controller, promise: fetchPromise });
407
680
  const entry = await fetchPromise;
408
- return cloneFragment(entry.fragment);
681
+ return { fragment: cloneFragment(entry.fragment), stale: entry.stale === true };
409
682
  }
410
683
  #invalidatePrefetch() {
411
684
  for (const { controller } of this.#inflight.values()) {
@@ -437,7 +710,7 @@ class Orchestrator {
437
710
  async#dispatch({ state, commands }, payload = {}) {
438
711
  this.state = state;
439
712
  for (const cmd of commands) {
440
- if (cmd.type === "mountLayer" || cmd.type === "morphTopLayer") {
713
+ if (cmd.type === "mountLayer" || cmd.type === "morphTopLayer" || cmd.type === "mountFrame") {
441
714
  if (payload.html != null)
442
715
  cmd.html = payload.html;
443
716
  if (payload.fragment != null)
@@ -477,8 +750,10 @@ function supportsAbort() {
477
750
  // app/javascript/modal_stack/runtime.js
478
751
  var SNAPSHOT_KEY = "modalStackSnapshot";
479
752
  var FRAGMENT_HEADER = "X-Modal-Stack-Request";
753
+ var STALE_HEADER = "X-Modal-Stack-Stale";
480
754
  var SCROLLBAR_WIDTH_VAR = "--modal-stack-scrollbar-width";
481
755
  var LAYER_SELECTOR = '[data-modal-stack-target="layer"]';
756
+ var FRAME_SELECTOR = "[data-modal-stack-frame]";
482
757
  var DURATION_CSS_VAR = "--modal-stack-duration";
483
758
  var LEAVE_TIMEOUT_FLOOR_MS = 300;
484
759
  var LEAVE_TIMEOUT_FALLBACK_MS = 600;
@@ -504,6 +779,7 @@ class BrowserRuntime {
504
779
  this.fetcher = fetcher;
505
780
  this.store = store;
506
781
  this.document = documentRef;
782
+ this._frameCache = new Map;
507
783
  }
508
784
  showDialog() {
509
785
  if (!this.dialog.open)
@@ -544,7 +820,10 @@ class BrowserRuntime {
544
820
  const frag = await this.#resolveFragment({ url, html, fragment });
545
821
  const layer = this.document.createElement("div");
546
822
  this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
547
- layer.append(...frag.childNodes);
823
+ this.#applyFrameDepth(layer, 0);
824
+ const wrapper = this.#createFrameWrapper({ frameIndex: 0 });
825
+ wrapper.append(...frag.childNodes);
826
+ layer.appendChild(wrapper);
548
827
  this.dialog.appendChild(layer);
549
828
  }
550
829
  async morphTopLayer({ layerId, url, depth, variant, dismissible, size, side, width, height, html, fragment }) {
@@ -553,7 +832,61 @@ class BrowserRuntime {
553
832
  if (!layer)
554
833
  return;
555
834
  this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
556
- layer.replaceChildren(...frag.childNodes);
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
+ }
557
890
  }
558
891
  async unmountTopLayer() {
559
892
  const layer = this.#topLayer();
@@ -620,6 +953,49 @@ class BrowserRuntime {
620
953
  #findLayer(layerId) {
621
954
  return this.dialog.querySelector(`${LAYER_SELECTOR}[data-layer-id="${escapeAttr(layerId)}"]`);
622
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
+ }
623
999
  #topLayer() {
624
1000
  const layers = this.dialog.querySelectorAll(LAYER_SELECTOR);
625
1001
  return layers[layers.length - 1] ?? null;
@@ -666,14 +1042,16 @@ class BrowserRuntime {
666
1042
  throw new Error(`modal_stack: fetch ${url} → ${resp.status}`);
667
1043
  }
668
1044
  const html = await resp.text();
669
- return parseFragment(html, this.document);
1045
+ const stale = parseStaleHeader(resp);
1046
+ return { fragment: parseFragment(html, this.document), stale };
670
1047
  }
671
1048
  async#resolveFragment({ url, html, fragment }) {
672
1049
  if (fragment)
673
1050
  return fragment;
674
1051
  if (html != null)
675
1052
  return parseFragment(html, this.document);
676
- return this.fetchFragment(url);
1053
+ const result = await this.fetchFragment(url);
1054
+ return result.fragment;
677
1055
  }
678
1056
  }
679
1057
  function parseFragment(html, doc) {
@@ -683,6 +1061,25 @@ function parseFragment(html, doc) {
683
1061
  fragment.append(...parsed.body.childNodes);
684
1062
  return fragment;
685
1063
  }
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
+ }
686
1083
  function animateOut(layer, timeoutMs = LEAVE_TIMEOUT_FALLBACK_MS) {
687
1084
  return new Promise((resolve) => {
688
1085
  let done = false;
@@ -714,11 +1111,11 @@ function escapeAttr(value) {
714
1111
  if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
715
1112
  return CSS.escape(value);
716
1113
  }
717
- return String(value).replace(/["\\]/g, "\\$&");
1114
+ return String(value).replace(/["\\[\]]/g, "\\$&");
718
1115
  }
719
1116
 
720
1117
  // app/javascript/modal_stack/controllers/modal_stack_controller.js
721
- class ModalStackController extends Controller {
1118
+ class ModalStackController extends Controller2 {
722
1119
  static values = {
723
1120
  stackId: String,
724
1121
  baseUrl: String,
@@ -783,6 +1180,14 @@ class ModalStackController extends Controller {
783
1180
  prefetch(url) {
784
1181
  return this.orchestrator.prefetch(url);
785
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
+ }
786
1191
  #topLayer() {
787
1192
  const layers = this.orchestrator.layers;
788
1193
  return layers[layers.length - 1] ?? null;
@@ -823,6 +1228,19 @@ class ModalStackController extends Controller {
823
1228
  StreamActions.modal_close_all = guarded("modal_close_all", function(orch) {
824
1229
  return orch.closeAll();
825
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
+ });
826
1244
  }
827
1245
  }
828
1246
  function emitStreamError(dialog, action, error) {
@@ -874,11 +1292,29 @@ function generateLayerId() {
874
1292
  }
875
1293
  return `ms-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
876
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
+ }
877
1313
 
878
1314
  // app/javascript/modal_stack/controllers/modal_stack_link_controller.js
879
- import { Controller as Controller2 } from "@hotwired/stimulus";
1315
+ import { Controller as Controller3 } from "@hotwired/stimulus";
880
1316
 
881
- class ModalStackLinkController extends Controller2 {
1317
+ class ModalStackLinkController extends Controller3 {
882
1318
  connect() {
883
1319
  if (this.element.dataset.modalStackLinkPrefetch === "false")
884
1320
  return;
@@ -936,10 +1372,12 @@ function install(application) {
936
1372
  }
937
1373
  application.register("modal-stack", ModalStackController);
938
1374
  application.register("modal-stack-link", ModalStackLinkController);
1375
+ application.register("modal-stack-back-link", ModalStackBackLinkController);
939
1376
  return application;
940
1377
  }
941
1378
  export {
942
1379
  install,
943
1380
  ModalStackLinkController,
944
- ModalStackController
1381
+ ModalStackController,
1382
+ ModalStackBackLinkController
945
1383
  };