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
@@ -4,6 +4,8 @@ import {
4
4
  createStack,
5
5
  handlePopstate,
6
6
  ModalStackDepthError,
7
+ pathBack,
8
+ pathTo,
7
9
  pop,
8
10
  push,
9
11
  replaceTop,
@@ -80,12 +82,21 @@ describe("push", () => {
80
82
  {
81
83
  type: "pushHistory",
82
84
  url: "/projects/42/edit",
83
- historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
85
+ historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
84
86
  },
85
87
  { type: "persistSnapshot" },
86
88
  ]);
87
89
  });
88
90
 
91
+ test("first layer carries a single-frame array", () => {
92
+ const { state } = pushed(freshStack());
93
+ expect(state.layers[0].frames).toEqual([
94
+ { url: "/projects/42/edit", stale: false },
95
+ ]);
96
+ expect(Object.isFrozen(state.layers[0].frames)).toBe(true);
97
+ expect(Object.isFrozen(state.layers[0].frames[0])).toBe(true);
98
+ });
99
+
89
100
  test("preserves side and size for drawer layers", () => {
90
101
  const { state, commands } = push(freshStack(), {
91
102
  id: "L1",
@@ -270,7 +281,7 @@ describe("push", () => {
270
281
  expect(commands).toContainEqual({
271
282
  type: "pushHistory",
272
283
  url: "/clients/new",
273
- historyState: { stackId: STACK_ID, layerId: "L2", depth: 2 },
284
+ historyState: { stackId: STACK_ID, layerId: "L2", depth: 2, frameIndex: 0 },
274
285
  });
275
286
  });
276
287
 
@@ -296,6 +307,181 @@ describe("push", () => {
296
307
  });
297
308
  });
298
309
 
310
+ describe("pathTo", () => {
311
+ test("throws on empty stack", () => {
312
+ expect(() => pathTo(freshStack(), { url: "/x" })).toThrow(/at least one layer/);
313
+ });
314
+
315
+ test("requires a frame.url", () => {
316
+ const s = pushed(freshStack()).state;
317
+ expect(() => pathTo(s, {})).toThrow(/frame\.url/);
318
+ expect(() => pathTo(s, { url: "" })).toThrow(/frame\.url/);
319
+ });
320
+
321
+ test("appends a frame to the top layer and emits mountFrame + pushHistory", () => {
322
+ const s = pushed(freshStack()).state;
323
+ const { state, commands } = pathTo(s, { url: "/projects/42/edit/step2" });
324
+
325
+ expect(state.layers).toHaveLength(1);
326
+ expect(state.layers[0].id).toBe("L1");
327
+ expect(state.layers[0].url).toBe("/projects/42/edit/step2");
328
+ expect(state.layers[0].frames).toEqual([
329
+ { url: "/projects/42/edit", stale: false },
330
+ { url: "/projects/42/edit/step2", stale: false },
331
+ ]);
332
+
333
+ expect(commands).toEqual([
334
+ {
335
+ type: "mountFrame",
336
+ layerId: "L1",
337
+ fromFrameIndex: 0,
338
+ toFrameIndex: 1,
339
+ url: "/projects/42/edit/step2",
340
+ stale: false,
341
+ },
342
+ {
343
+ type: "pushHistory",
344
+ url: "/projects/42/edit/step2",
345
+ historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 1 },
346
+ },
347
+ { type: "persistSnapshot" },
348
+ ]);
349
+ });
350
+
351
+ test("propagates stale flag and transition option", () => {
352
+ const s = pushed(freshStack()).state;
353
+ const { state, commands } = pathTo(
354
+ s,
355
+ { url: "/projects/42/edit/step2", stale: true },
356
+ { transition: "fade" },
357
+ );
358
+ expect(state.layers[0].frames[1].stale).toBe(true);
359
+ expect(commands[0]).toMatchObject({
360
+ type: "mountFrame",
361
+ stale: true,
362
+ transition: "fade",
363
+ });
364
+ });
365
+
366
+ test("rejects unknown transition", () => {
367
+ const s = pushed(freshStack()).state;
368
+ expect(() => pathTo(s, { url: "/x" }, { transition: "warp" })).toThrow(
369
+ /unknown transition/,
370
+ );
371
+ });
372
+
373
+ test("targets the top layer when several layers are stacked", () => {
374
+ let s = pushed(freshStack()).state;
375
+ s = push(s, { id: "L2", url: "/clients/new" }).state;
376
+ const { state, commands } = pathTo(s, { url: "/clients/new/contact" });
377
+ expect(state.layers[0].frames).toEqual([
378
+ { url: "/projects/42/edit", stale: false },
379
+ ]);
380
+ expect(state.layers[1].frames).toEqual([
381
+ { url: "/clients/new", stale: false },
382
+ { url: "/clients/new/contact", stale: false },
383
+ ]);
384
+ expect(commands[0]).toMatchObject({ layerId: "L2", toFrameIndex: 1 });
385
+ });
386
+ });
387
+
388
+ describe("pathBack", () => {
389
+ test("throws on empty stack", () => {
390
+ expect(() => pathBack(freshStack())).toThrow(/at least one layer/);
391
+ });
392
+
393
+ test("rejects non-positive steps", () => {
394
+ const s = pushed(freshStack()).state;
395
+ expect(() => pathBack(s, { steps: 0 })).toThrow(/positive integer/);
396
+ expect(() => pathBack(s, { steps: -1 })).toThrow(/positive integer/);
397
+ expect(() => pathBack(s, { steps: NaN })).toThrow(/positive integer/);
398
+ });
399
+
400
+ test("on a single-frame layer is a noop (does not close the layer)", () => {
401
+ const s = pushed(freshStack()).state;
402
+ const result = pathBack(s);
403
+ expect(result.state).toBe(s);
404
+ expect(result.commands).toEqual([]);
405
+ });
406
+
407
+ test("steps back one frame by default and emits unmountFrame + historyBack", () => {
408
+ let s = pushed(freshStack()).state;
409
+ s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
410
+
411
+ const { state, commands } = pathBack(s);
412
+ expect(state.layers[0].frames).toEqual([
413
+ { url: "/projects/42/edit", stale: false },
414
+ ]);
415
+ expect(state.layers[0].url).toBe("/projects/42/edit");
416
+
417
+ expect(commands).toEqual([
418
+ {
419
+ type: "unmountFrame",
420
+ layerId: "L1",
421
+ fromFrameIndex: 1,
422
+ toFrameIndex: 0,
423
+ url: "/projects/42/edit",
424
+ stale: false,
425
+ },
426
+ { type: "historyBack", n: 1 },
427
+ { type: "persistSnapshot" },
428
+ ]);
429
+ });
430
+
431
+ test("steps back N frames in one shot", () => {
432
+ let s = pushed(freshStack()).state;
433
+ s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
434
+ s = pathTo(s, { url: "/projects/42/edit/step3" }).state;
435
+
436
+ const { state, commands } = pathBack(s, { steps: 2 });
437
+ expect(state.layers[0].frames).toHaveLength(1);
438
+ expect(state.layers[0].url).toBe("/projects/42/edit");
439
+ expect(commands).toEqual([
440
+ {
441
+ type: "unmountFrame",
442
+ layerId: "L1",
443
+ fromFrameIndex: 2,
444
+ toFrameIndex: 0,
445
+ url: "/projects/42/edit",
446
+ stale: false,
447
+ },
448
+ { type: "historyBack", n: 2 },
449
+ { type: "persistSnapshot" },
450
+ ]);
451
+ });
452
+
453
+ test("clamps steps to keep at least the first frame", () => {
454
+ let s = pushed(freshStack()).state;
455
+ s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
456
+ s = pathTo(s, { url: "/projects/42/edit/step3" }).state;
457
+
458
+ const { state, commands } = pathBack(s, { steps: 99 });
459
+ expect(state.layers[0].frames).toHaveLength(1);
460
+ expect(commands.find((c) => c.type === "historyBack")).toEqual({
461
+ type: "historyBack",
462
+ n: 2,
463
+ });
464
+ });
465
+
466
+ test("preserves the stale flag of the destination frame", () => {
467
+ let s = pushed(freshStack()).state;
468
+ s = pathTo(s, { url: "/step2", stale: true }).state;
469
+ s = pathTo(s, { url: "/step3" }).state;
470
+ const { commands } = pathBack(s);
471
+ expect(commands[0]).toMatchObject({
472
+ type: "unmountFrame",
473
+ stale: true,
474
+ url: "/step2",
475
+ });
476
+ });
477
+
478
+ test("rejects unknown transition", () => {
479
+ let s = pushed(freshStack()).state;
480
+ s = pathTo(s, { url: "/step2" }).state;
481
+ expect(() => pathBack(s, { transition: "warp" })).toThrow(/unknown transition/);
482
+ });
483
+ });
484
+
299
485
  describe("pop", () => {
300
486
  test("noop on empty stack", () => {
301
487
  const s = freshStack();
@@ -306,14 +492,15 @@ describe("pop", () => {
306
492
  const first = pushed(freshStack()).state;
307
493
  const { state, commands } = pop(first);
308
494
  expect(state.layers).toEqual([]);
309
- // closeDialog comes first so its exit transition runs in parallel
310
- // with the layer's [data-leaving] transition.
495
+ // closeDialog and clearSnapshot come first so a reload during the
496
+ // exit animation does not restore the modal that is already closing.
311
497
  expect(commands).toEqual([
312
498
  { type: "closeDialog" },
499
+ { type: "clearSnapshot" },
313
500
  { type: "unmountTopLayer" },
501
+ { type: "clearFrameCache", layerId: "L1" },
314
502
  { type: "historyBack", n: 1 },
315
503
  { type: "unlockScroll" },
316
- { type: "clearSnapshot" },
317
504
  ]);
318
505
  });
319
506
 
@@ -322,11 +509,30 @@ describe("pop", () => {
322
509
  s = push(s, { id: "L2", url: "/clients/new" }).state;
323
510
  const { state, commands } = pop(s);
324
511
  expect(state.layers).toHaveLength(1);
512
+ // persistSnapshot comes first so a reload during the animation
513
+ // restores the correct remaining stack (without the popped layer).
325
514
  expect(commands).toEqual([
515
+ { type: "persistSnapshot" },
326
516
  { type: "unmountTopLayer" },
517
+ { type: "clearFrameCache", layerId: "L2" },
327
518
  { type: "historyBack", n: 1 },
328
519
  { type: "inertLayer", layerId: "L1", value: false },
329
- { type: "persistSnapshot" },
520
+ ]);
521
+ });
522
+
523
+ test("popping a layer with a path walks history back across all its frames", () => {
524
+ let s = pushed(freshStack()).state;
525
+ s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
526
+ s = pathTo(s, { url: "/projects/42/edit/step3" }).state;
527
+ const { state, commands } = pop(s);
528
+ expect(state.layers).toEqual([]);
529
+ expect(commands).toEqual([
530
+ { type: "closeDialog" },
531
+ { type: "clearSnapshot" },
532
+ { type: "unmountTopLayer" },
533
+ { type: "clearFrameCache", layerId: "L1" },
534
+ { type: "historyBack", n: 3 },
535
+ { type: "unlockScroll" },
330
536
  ]);
331
537
  });
332
538
  });
@@ -355,7 +561,7 @@ describe("replaceTop", () => {
355
561
  {
356
562
  type: "replaceHistory",
357
563
  url: "/projects/42/edit/billing",
358
- historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
564
+ historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
359
565
  },
360
566
  { type: "persistSnapshot" },
361
567
  ]);
@@ -400,10 +606,38 @@ describe("replaceTop", () => {
400
606
  expect(commands[1]).toEqual({
401
607
  type: "pushHistory",
402
608
  url: "/onboarding/step2",
403
- historyState: { stackId: STACK_ID, layerId: "L1b", depth: 1 },
609
+ historyState: { stackId: STACK_ID, layerId: "L1b", depth: 1, frameIndex: 0 },
404
610
  });
405
611
  });
406
612
 
613
+ test("collapses path frames and walks history back the surplus", () => {
614
+ let s = pushed(freshStack()).state;
615
+ s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
616
+ s = pathTo(s, { url: "/projects/42/edit/step3" }).state;
617
+ const { state, commands } = replaceTop(s, { url: "/projects/42/edit/billing" });
618
+ expect(state.layers[0].frames).toEqual([
619
+ { url: "/projects/42/edit/billing", stale: false },
620
+ ]);
621
+ expect(commands).toEqual([
622
+ { type: "clearFrameCache", layerId: "L1" },
623
+ { type: "historyBack", n: 2 },
624
+ {
625
+ type: "morphTopLayer",
626
+ layerId: "L1",
627
+ url: "/projects/42/edit/billing",
628
+ depth: 1,
629
+ variant: "modal",
630
+ dismissible: true,
631
+ },
632
+ {
633
+ type: "replaceHistory",
634
+ url: "/projects/42/edit/billing",
635
+ historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
636
+ },
637
+ { type: "persistSnapshot" },
638
+ ]);
639
+ });
640
+
407
641
  test("rejects unknown historyMode", () => {
408
642
  const s = pushed(freshStack()).state;
409
643
  expect(() => replaceTop(s, { url: "/x" }, { historyMode: "wat" })).toThrow(
@@ -426,12 +660,24 @@ describe("closeAll", () => {
426
660
  expect(state.layers).toEqual([]);
427
661
  expect(commands).toEqual([
428
662
  { type: "closeDialog" },
663
+ { type: "clearSnapshot" },
429
664
  { type: "unmountAllLayers" },
665
+ { type: "clearFrameCache", layerId: "L1" },
666
+ { type: "clearFrameCache", layerId: "L2" },
667
+ { type: "clearFrameCache", layerId: "L3" },
430
668
  { type: "unlockScroll" },
431
669
  { type: "historyBack", n: 3 },
432
- { type: "clearSnapshot" },
433
670
  ]);
434
671
  });
672
+
673
+ test("counts every frame across every layer when walking history back", () => {
674
+ let s = pushed(freshStack()).state;
675
+ s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
676
+ s = push(s, { id: "L2", url: "/clients/new" }).state;
677
+ s = pathTo(s, { url: "/clients/new/contact" }).state;
678
+ const { commands } = closeAll(s);
679
+ expect(commands).toContainEqual({ type: "historyBack", n: 4 });
680
+ });
435
681
  });
436
682
 
437
683
  describe("handlePopstate", () => {
@@ -450,9 +696,11 @@ describe("handlePopstate", () => {
450
696
  expect(state.layers).toEqual([]);
451
697
  expect(commands).toEqual([
452
698
  { type: "closeDialog" },
699
+ { type: "clearSnapshot" },
453
700
  { type: "unmountAllLayers" },
701
+ { type: "clearFrameCache", layerId: "L1" },
702
+ { type: "clearFrameCache", layerId: "L2" },
454
703
  { type: "unlockScroll" },
455
- { type: "clearSnapshot" },
456
704
  ]);
457
705
  });
458
706
 
@@ -467,14 +715,15 @@ describe("handlePopstate", () => {
467
715
  test("back: targetDepth < current pops layers and un-inerts new top (no historyBack)", () => {
468
716
  const s = buildTwoLayer();
469
717
  const { state, commands } = handlePopstate(s, {
470
- historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
718
+ historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
471
719
  locationHref: "/projects/42/edit",
472
720
  });
473
721
  expect(state.layers.map((l) => l.id)).toEqual(["L1"]);
474
722
  expect(commands).toEqual([
723
+ { type: "persistSnapshot" },
475
724
  { type: "unmountTopLayer" },
725
+ { type: "clearFrameCache", layerId: "L2" },
476
726
  { type: "inertLayer", layerId: "L1", value: false },
477
- { type: "persistSnapshot" },
478
727
  ]);
479
728
  expect(commands).not.toContainEqual({ type: "historyBack", n: 1 });
480
729
  });
@@ -488,10 +737,12 @@ describe("handlePopstate", () => {
488
737
  expect(state.layers).toEqual([]);
489
738
  expect(commands).toEqual([
490
739
  { type: "closeDialog" },
740
+ { type: "clearSnapshot" },
491
741
  { type: "unmountTopLayer" },
492
742
  { type: "unmountTopLayer" },
743
+ { type: "clearFrameCache", layerId: "L1" },
744
+ { type: "clearFrameCache", layerId: "L2" },
493
745
  { type: "unlockScroll" },
494
- { type: "clearSnapshot" },
495
746
  ]);
496
747
  });
497
748
 
@@ -514,7 +765,7 @@ describe("handlePopstate", () => {
514
765
  { historyMode: "push" },
515
766
  ).state;
516
767
  const { state, commands } = handlePopstate(after, {
517
- historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
768
+ historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
518
769
  locationHref: "/onboarding/step1",
519
770
  });
520
771
  expect(state.layers[0]).toMatchObject({
@@ -522,6 +773,7 @@ describe("handlePopstate", () => {
522
773
  url: "/onboarding/step1",
523
774
  });
524
775
  expect(commands).toEqual([
776
+ { type: "clearFrameCache", layerId: "L1b" },
525
777
  {
526
778
  type: "morphTopLayer",
527
779
  layerId: "L1",
@@ -537,12 +789,56 @@ describe("handlePopstate", () => {
537
789
  test("same depth, same layerId is a noop", () => {
538
790
  const s = pushed(freshStack()).state;
539
791
  const { state, commands } = handlePopstate(s, {
540
- historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
792
+ historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
541
793
  locationHref: "/projects/42/edit",
542
794
  });
543
795
  expect(state).toEqual(s);
544
796
  expect(commands).toEqual([]);
545
797
  });
798
+
799
+ test("back through frames: same layer, lower frameIndex steps back in path", () => {
800
+ let s = pushed(freshStack()).state;
801
+ s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
802
+ s = pathTo(s, { url: "/projects/42/edit/step3" }).state;
803
+
804
+ const { state, commands } = handlePopstate(s, {
805
+ historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 1 },
806
+ locationHref: "/projects/42/edit/step2",
807
+ });
808
+
809
+ expect(state.layers[0].frames).toEqual([
810
+ { url: "/projects/42/edit", stale: false },
811
+ { url: "/projects/42/edit/step2", stale: false },
812
+ ]);
813
+ expect(state.layers[0].url).toBe("/projects/42/edit/step2");
814
+ expect(commands).toEqual([
815
+ {
816
+ type: "unmountFrame",
817
+ layerId: "L1",
818
+ fromFrameIndex: 2,
819
+ toFrameIndex: 1,
820
+ url: "/projects/42/edit/step2",
821
+ stale: false,
822
+ },
823
+ { type: "persistSnapshot" },
824
+ ]);
825
+ });
826
+
827
+ test("forward popstate to a frame we no longer track defers to snapshot rebuild", () => {
828
+ let s = pushed(freshStack()).state;
829
+ s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
830
+ // user pressed back, dropping the step2 frame from state
831
+ s = pathBack(s).state;
832
+
833
+ const { state, commands } = handlePopstate(s, {
834
+ historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 1 },
835
+ locationHref: "/projects/42/edit/step2",
836
+ });
837
+ expect(state).toEqual(s);
838
+ expect(commands).toEqual([
839
+ { type: "rebuildFromSnapshot", targetDepth: 1, targetLayerId: "L1" },
840
+ ]);
841
+ });
546
842
  });
547
843
 
548
844
  describe("snapshot / restore", () => {
@@ -575,13 +871,13 @@ describe("snapshot / restore", () => {
575
871
  expect(restore("not-json", { stackId: STACK_ID })).toBeNull();
576
872
  expect(restore("", { stackId: STACK_ID })).toBeNull();
577
873
  expect(restore(null, { stackId: STACK_ID })).toBeNull();
578
- expect(restore('{"v":2}', { stackId: STACK_ID })).toBeNull();
874
+ expect(restore('{"v":3}', { stackId: STACK_ID })).toBeNull();
579
875
  expect(restore('{"v":1}', { stackId: STACK_ID })).toBeNull();
580
876
  });
581
877
 
582
878
  test("returns null when a layer has unknown variant", () => {
583
879
  const malicious = JSON.stringify({
584
- v: 1,
880
+ v: 2,
585
881
  stackId: STACK_ID,
586
882
  baseUrl: "/",
587
883
  layers: [{ id: "L1", url: "/", variant: "popover", dismissible: true }],
@@ -589,4 +885,95 @@ describe("snapshot / restore", () => {
589
885
  });
590
886
  expect(restore(malicious, { stackId: STACK_ID })).toBeNull();
591
887
  });
888
+
889
+ test("serializes frames as { url, stale } only", () => {
890
+ let s = pushed(freshStack()).state;
891
+ s = pathTo(s, { url: "/projects/42/edit/step2", stale: true }).state;
892
+ const parsed = JSON.parse(snapshot(s));
893
+ expect(parsed.v).toBe(2);
894
+ expect(parsed.layers[0].frames).toEqual([
895
+ { url: "/projects/42/edit", stale: false },
896
+ { url: "/projects/42/edit/step2", stale: true },
897
+ ]);
898
+ });
899
+
900
+ test("round-trips a layer with multiple frames", () => {
901
+ let s = pushed(freshStack()).state;
902
+ s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
903
+ s = pathTo(s, { url: "/projects/42/edit/step3", stale: true }).state;
904
+ const restored = restore(snapshot(s), { stackId: STACK_ID });
905
+ expect(restored).toEqual(s);
906
+ expect(restored.layers[0].frames).toHaveLength(3);
907
+ expect(restored.layers[0].frames[2].stale).toBe(true);
908
+ });
909
+
910
+ test("accepts a v1 snapshot and synthesizes single-frame arrays", () => {
911
+ const v1 = JSON.stringify({
912
+ v: 1,
913
+ stackId: STACK_ID,
914
+ baseUrl: BASE_URL,
915
+ layers: [
916
+ {
917
+ id: "L1",
918
+ url: "/projects/42/edit",
919
+ variant: "modal",
920
+ dismissible: true,
921
+ size: null,
922
+ side: null,
923
+ width: null,
924
+ height: null,
925
+ },
926
+ ],
927
+ savedAt: Date.now(),
928
+ });
929
+ const restored = restore(v1, { stackId: STACK_ID });
930
+ expect(restored).not.toBeNull();
931
+ expect(restored.layers[0].frames).toEqual([
932
+ { url: "/projects/42/edit", stale: false },
933
+ ]);
934
+ });
935
+
936
+ test("returns null when a v2 layer has malformed frames", () => {
937
+ const malicious = JSON.stringify({
938
+ v: 2,
939
+ stackId: STACK_ID,
940
+ baseUrl: BASE_URL,
941
+ layers: [
942
+ {
943
+ id: "L1",
944
+ url: "/x",
945
+ variant: "modal",
946
+ dismissible: true,
947
+ size: null,
948
+ side: null,
949
+ width: null,
950
+ height: null,
951
+ frames: [{ stale: false }],
952
+ },
953
+ ],
954
+ savedAt: Date.now(),
955
+ });
956
+ expect(restore(malicious, { stackId: STACK_ID })).toBeNull();
957
+
958
+ const emptyFrames = JSON.stringify({
959
+ v: 2,
960
+ stackId: STACK_ID,
961
+ baseUrl: BASE_URL,
962
+ layers: [
963
+ {
964
+ id: "L1",
965
+ url: "/x",
966
+ variant: "modal",
967
+ dismissible: true,
968
+ size: null,
969
+ side: null,
970
+ width: null,
971
+ height: null,
972
+ frames: [],
973
+ },
974
+ ],
975
+ savedAt: Date.now(),
976
+ });
977
+ expect(restore(emptyFrames, { stackId: STACK_ID })).toBeNull();
978
+ });
592
979
  });
@@ -0,0 +1 @@
1
+ <%= content_tag(:dialog, dialog_attrs) do %><% end %>
@@ -0,0 +1,4 @@
1
+ <%= content_tag(:div, wrapper_attrs) do %>
2
+ <%= back_button %>
3
+ <%= content %>
4
+ <% end %>
@@ -56,6 +56,15 @@ ModalStack.configure do |config|
56
56
  # :silent — drop the push, no warning
57
57
  config.max_depth_strategy = :warn
58
58
 
59
+ # Default transition between frames in a modal path (the wizard-style
60
+ # `modal_path_to` / `modal_path_back` flow). Override per call with
61
+ # `transition:` on the stream action when a specific step needs a
62
+ # different feel.
63
+ # :slide — directional, current frame slides out, next slides in
64
+ # :fade — cross-fade, no direction
65
+ # :none — instantaneous swap
66
+ config.default_path_transition = :slide
67
+
59
68
  # Replace `data-turbo-confirm` window.confirm with a modal_stack
60
69
  # confirmation layer (cf. RFC §15.Q7). Off by default — opt-in.
61
70
  config.replace_turbo_confirm = false
@@ -0,0 +1,9 @@
1
+ <%# modal_stack dialog template — override this file to customise the HTML
2
+ structure of the singleton <dialog> element that acts as the modal root.
3
+ Available locals:
4
+ dialog_attrs — Hash { id:, data: { controller: "modal-stack", ... }, ...extras }
5
+ Must stay on the <dialog> element — the JS Stimulus controller
6
+ and layer injection depend on these attributes.
7
+ Note: the JS runtime appends panel layers as direct children of <dialog> via
8
+ appendChild. Static children you add here will be siblings of those layers. %>
9
+ <%= content_tag(:dialog, dialog_attrs) do %><% end %>
@@ -0,0 +1,18 @@
1
+ <%# modal_stack panel template — override this file to customise the HTML
2
+ structure that wraps every modal/drawer/bottom-sheet/confirmation panel.
3
+ Available locals:
4
+ content — SafeBuffer, the yielded view content
5
+ back_button — SafeBuffer | nil, pre-rendered back button (nil when back: false)
6
+ wrapper_attrs — Hash { class:, data:, ...extras } — must stay on the root element
7
+ so the JS runtime can read its data attributes
8
+ size — Symbol :sm | :md | :lg | :xl
9
+ variant — Symbol :modal | :drawer | :bottom_sheet | :confirmation
10
+ dismissible — Boolean
11
+ side — Symbol | nil :left | :right | :top | :bottom
12
+ width — String | nil CSS value (e.g. "42rem")
13
+ height — String | nil CSS value
14
+ transition — Symbol | nil :slide | :fade | :none %>
15
+ <%= content_tag(:div, wrapper_attrs) do %>
16
+ <%= back_button %>
17
+ <%= content %>
18
+ <% end %>
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/base"
5
+
6
+ module ModalStack
7
+ module Generators
8
+ class ViewsGenerator < Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ class_option :panel, type: :boolean, default: false,
12
+ desc: "Copy only the panel partial"
13
+ class_option :dialog, type: :boolean, default: false,
14
+ desc: "Copy only the dialog partial"
15
+
16
+ def copy_views
17
+ if options[:panel]
18
+ copy_panel
19
+ elsif options[:dialog]
20
+ copy_dialog
21
+ else
22
+ copy_panel
23
+ copy_dialog
24
+ end
25
+ end
26
+
27
+ def show_readme
28
+ say <<~TXT, :green
29
+
30
+ modal_stack views ejected to app/views/modal_stack/.
31
+
32
+ Edit the copied partials to override the default HTML structure.
33
+ The `wrapper_attrs` / `dialog_attrs` locals carry the required
34
+ data attributes — keep them on the root element so the JS runtime
35
+ continues to work.
36
+ TXT
37
+ end
38
+
39
+ private
40
+
41
+ def copy_panel
42
+ copy_file "_panel.html.erb", "app/views/modal_stack/_panel.html.erb"
43
+ end
44
+
45
+ def copy_dialog
46
+ copy_file "_dialog.html.erb", "app/views/modal_stack/_dialog.html.erb"
47
+ end
48
+ end
49
+ end
50
+ end