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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +136 -52
  4. data/app/assets/javascripts/modal_stack.js +612 -63
  5. data/app/assets/stylesheets/modal_stack/bootstrap.css +120 -11
  6. data/app/assets/stylesheets/modal_stack/{tailwind.css → tailwind_v3.css} +82 -14
  7. data/app/assets/stylesheets/modal_stack/tailwind_v4.css +372 -0
  8. data/app/assets/stylesheets/modal_stack/vanilla.css +128 -11
  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 +54 -0
  11. data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +29 -7
  12. data/app/javascript/modal_stack/install.js +7 -1
  13. data/app/javascript/modal_stack/orchestrator.js +132 -3
  14. data/app/javascript/modal_stack/orchestrator.test.js +264 -2
  15. data/app/javascript/modal_stack/runtime.js +222 -13
  16. data/app/javascript/modal_stack/runtime.test.js +151 -0
  17. data/app/javascript/modal_stack/state.js +338 -39
  18. data/app/javascript/modal_stack/state.test.js +400 -13
  19. data/app/views/modal_stack/_dialog.html.erb +1 -0
  20. data/app/views/modal_stack/_panel.html.erb +4 -0
  21. data/lib/generators/modal_stack/install/install_generator.rb +18 -4
  22. data/lib/generators/modal_stack/install/templates/initializer.rb +21 -5
  23. data/lib/generators/modal_stack/views/templates/_dialog.html.erb +9 -0
  24. data/lib/generators/modal_stack/views/templates/_panel.html.erb +18 -0
  25. data/lib/generators/modal_stack/views/views_generator.rb +50 -0
  26. data/lib/modal_stack/capybara.rb +21 -0
  27. data/lib/modal_stack/configuration.rb +43 -17
  28. data/lib/modal_stack/controller_extensions.rb +8 -1
  29. data/lib/modal_stack/engine.rb +2 -0
  30. data/lib/modal_stack/helpers/modal_back_link_helper.rb +48 -0
  31. data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +15 -3
  32. data/lib/modal_stack/helpers/modal_stack_container_helper.rb +42 -15
  33. data/lib/modal_stack/turbo_streams_extension.rb +56 -0
  34. data/lib/modal_stack/version.rb +1 -1
  35. data/lib/modal_stack.rb +5 -1
  36. metadata +11 -3
@@ -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,25 +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
- 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: { stackId: state.stackId, layerId: next.id, depth }
167
- };
168
- return {
169
- state: { ...state, layers: newLayers },
170
- commands: [
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.length;
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
- for (let i = 0;i < currentDepth - targetDepth; i++) {
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
- if (typeof this.runtime.fetchFragment !== "function")
380
- return null;
381
- return this.runtime.fetchFragment(url);
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 LEAVE_TIMEOUT_MS = 600;
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.append(...frag.childNodes);
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.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
+ }
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
- await Promise.all(layers.map(animateOut));
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
- return parseFragment(html, this.document);
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
- return this.fetchFragment(url);
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 animateOut(layer) {
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, LEAVE_TIMEOUT_MS);
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 Controller {
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 Controller2 } from "@hotwired/stimulus";
1315
+ import { Controller as Controller3 } from "@hotwired/stimulus";
791
1316
 
792
- class ModalStackLinkController extends Controller2 {
793
- open(event) {
794
- const stack = document.querySelector('[data-controller~="modal-stack"]');
795
- if (!stack)
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
- const controller = this.application.getControllerForElementAndIdentifier(stack, "modal-stack");
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
  };