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
@@ -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();
@@ -311,6 +497,7 @@ describe("pop", () => {
311
497
  expect(commands).toEqual([
312
498
  { type: "closeDialog" },
313
499
  { type: "unmountTopLayer" },
500
+ { type: "clearFrameCache", layerId: "L1" },
314
501
  { type: "historyBack", n: 1 },
315
502
  { type: "unlockScroll" },
316
503
  { type: "clearSnapshot" },
@@ -324,11 +511,28 @@ describe("pop", () => {
324
511
  expect(state.layers).toHaveLength(1);
325
512
  expect(commands).toEqual([
326
513
  { type: "unmountTopLayer" },
514
+ { type: "clearFrameCache", layerId: "L2" },
327
515
  { type: "historyBack", n: 1 },
328
516
  { type: "inertLayer", layerId: "L1", value: false },
329
517
  { type: "persistSnapshot" },
330
518
  ]);
331
519
  });
520
+
521
+ test("popping a layer with a path walks history back across all its frames", () => {
522
+ let s = pushed(freshStack()).state;
523
+ s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
524
+ s = pathTo(s, { url: "/projects/42/edit/step3" }).state;
525
+ const { state, commands } = pop(s);
526
+ expect(state.layers).toEqual([]);
527
+ expect(commands).toEqual([
528
+ { type: "closeDialog" },
529
+ { type: "unmountTopLayer" },
530
+ { type: "clearFrameCache", layerId: "L1" },
531
+ { type: "historyBack", n: 3 },
532
+ { type: "unlockScroll" },
533
+ { type: "clearSnapshot" },
534
+ ]);
535
+ });
332
536
  });
333
537
 
334
538
  describe("replaceTop", () => {
@@ -355,7 +559,7 @@ describe("replaceTop", () => {
355
559
  {
356
560
  type: "replaceHistory",
357
561
  url: "/projects/42/edit/billing",
358
- historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
562
+ historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
359
563
  },
360
564
  { type: "persistSnapshot" },
361
565
  ]);
@@ -400,10 +604,38 @@ describe("replaceTop", () => {
400
604
  expect(commands[1]).toEqual({
401
605
  type: "pushHistory",
402
606
  url: "/onboarding/step2",
403
- historyState: { stackId: STACK_ID, layerId: "L1b", depth: 1 },
607
+ historyState: { stackId: STACK_ID, layerId: "L1b", depth: 1, frameIndex: 0 },
404
608
  });
405
609
  });
406
610
 
611
+ test("collapses path frames and walks history back the surplus", () => {
612
+ let s = pushed(freshStack()).state;
613
+ s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
614
+ s = pathTo(s, { url: "/projects/42/edit/step3" }).state;
615
+ const { state, commands } = replaceTop(s, { url: "/projects/42/edit/billing" });
616
+ expect(state.layers[0].frames).toEqual([
617
+ { url: "/projects/42/edit/billing", stale: false },
618
+ ]);
619
+ expect(commands).toEqual([
620
+ { type: "clearFrameCache", layerId: "L1" },
621
+ { type: "historyBack", n: 2 },
622
+ {
623
+ type: "morphTopLayer",
624
+ layerId: "L1",
625
+ url: "/projects/42/edit/billing",
626
+ depth: 1,
627
+ variant: "modal",
628
+ dismissible: true,
629
+ },
630
+ {
631
+ type: "replaceHistory",
632
+ url: "/projects/42/edit/billing",
633
+ historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
634
+ },
635
+ { type: "persistSnapshot" },
636
+ ]);
637
+ });
638
+
407
639
  test("rejects unknown historyMode", () => {
408
640
  const s = pushed(freshStack()).state;
409
641
  expect(() => replaceTop(s, { url: "/x" }, { historyMode: "wat" })).toThrow(
@@ -427,11 +659,23 @@ describe("closeAll", () => {
427
659
  expect(commands).toEqual([
428
660
  { type: "closeDialog" },
429
661
  { type: "unmountAllLayers" },
662
+ { type: "clearFrameCache", layerId: "L1" },
663
+ { type: "clearFrameCache", layerId: "L2" },
664
+ { type: "clearFrameCache", layerId: "L3" },
430
665
  { type: "unlockScroll" },
431
666
  { type: "historyBack", n: 3 },
432
667
  { type: "clearSnapshot" },
433
668
  ]);
434
669
  });
670
+
671
+ test("counts every frame across every layer when walking history back", () => {
672
+ let s = pushed(freshStack()).state;
673
+ s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
674
+ s = push(s, { id: "L2", url: "/clients/new" }).state;
675
+ s = pathTo(s, { url: "/clients/new/contact" }).state;
676
+ const { commands } = closeAll(s);
677
+ expect(commands).toContainEqual({ type: "historyBack", n: 4 });
678
+ });
435
679
  });
436
680
 
437
681
  describe("handlePopstate", () => {
@@ -451,6 +695,8 @@ describe("handlePopstate", () => {
451
695
  expect(commands).toEqual([
452
696
  { type: "closeDialog" },
453
697
  { type: "unmountAllLayers" },
698
+ { type: "clearFrameCache", layerId: "L1" },
699
+ { type: "clearFrameCache", layerId: "L2" },
454
700
  { type: "unlockScroll" },
455
701
  { type: "clearSnapshot" },
456
702
  ]);
@@ -467,12 +713,13 @@ describe("handlePopstate", () => {
467
713
  test("back: targetDepth < current pops layers and un-inerts new top (no historyBack)", () => {
468
714
  const s = buildTwoLayer();
469
715
  const { state, commands } = handlePopstate(s, {
470
- historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
716
+ historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
471
717
  locationHref: "/projects/42/edit",
472
718
  });
473
719
  expect(state.layers.map((l) => l.id)).toEqual(["L1"]);
474
720
  expect(commands).toEqual([
475
721
  { type: "unmountTopLayer" },
722
+ { type: "clearFrameCache", layerId: "L2" },
476
723
  { type: "inertLayer", layerId: "L1", value: false },
477
724
  { type: "persistSnapshot" },
478
725
  ]);
@@ -490,6 +737,8 @@ describe("handlePopstate", () => {
490
737
  { type: "closeDialog" },
491
738
  { type: "unmountTopLayer" },
492
739
  { type: "unmountTopLayer" },
740
+ { type: "clearFrameCache", layerId: "L1" },
741
+ { type: "clearFrameCache", layerId: "L2" },
493
742
  { type: "unlockScroll" },
494
743
  { type: "clearSnapshot" },
495
744
  ]);
@@ -514,7 +763,7 @@ describe("handlePopstate", () => {
514
763
  { historyMode: "push" },
515
764
  ).state;
516
765
  const { state, commands } = handlePopstate(after, {
517
- historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
766
+ historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
518
767
  locationHref: "/onboarding/step1",
519
768
  });
520
769
  expect(state.layers[0]).toMatchObject({
@@ -522,6 +771,7 @@ describe("handlePopstate", () => {
522
771
  url: "/onboarding/step1",
523
772
  });
524
773
  expect(commands).toEqual([
774
+ { type: "clearFrameCache", layerId: "L1b" },
525
775
  {
526
776
  type: "morphTopLayer",
527
777
  layerId: "L1",
@@ -537,12 +787,56 @@ describe("handlePopstate", () => {
537
787
  test("same depth, same layerId is a noop", () => {
538
788
  const s = pushed(freshStack()).state;
539
789
  const { state, commands } = handlePopstate(s, {
540
- historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
790
+ historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
541
791
  locationHref: "/projects/42/edit",
542
792
  });
543
793
  expect(state).toEqual(s);
544
794
  expect(commands).toEqual([]);
545
795
  });
796
+
797
+ test("back through frames: same layer, lower frameIndex steps back in path", () => {
798
+ let s = pushed(freshStack()).state;
799
+ s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
800
+ s = pathTo(s, { url: "/projects/42/edit/step3" }).state;
801
+
802
+ const { state, commands } = handlePopstate(s, {
803
+ historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 1 },
804
+ locationHref: "/projects/42/edit/step2",
805
+ });
806
+
807
+ expect(state.layers[0].frames).toEqual([
808
+ { url: "/projects/42/edit", stale: false },
809
+ { url: "/projects/42/edit/step2", stale: false },
810
+ ]);
811
+ expect(state.layers[0].url).toBe("/projects/42/edit/step2");
812
+ expect(commands).toEqual([
813
+ {
814
+ type: "unmountFrame",
815
+ layerId: "L1",
816
+ fromFrameIndex: 2,
817
+ toFrameIndex: 1,
818
+ url: "/projects/42/edit/step2",
819
+ stale: false,
820
+ },
821
+ { type: "persistSnapshot" },
822
+ ]);
823
+ });
824
+
825
+ test("forward popstate to a frame we no longer track defers to snapshot rebuild", () => {
826
+ let s = pushed(freshStack()).state;
827
+ s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
828
+ // user pressed back, dropping the step2 frame from state
829
+ s = pathBack(s).state;
830
+
831
+ const { state, commands } = handlePopstate(s, {
832
+ historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 1 },
833
+ locationHref: "/projects/42/edit/step2",
834
+ });
835
+ expect(state).toEqual(s);
836
+ expect(commands).toEqual([
837
+ { type: "rebuildFromSnapshot", targetDepth: 1, targetLayerId: "L1" },
838
+ ]);
839
+ });
546
840
  });
547
841
 
548
842
  describe("snapshot / restore", () => {
@@ -575,13 +869,13 @@ describe("snapshot / restore", () => {
575
869
  expect(restore("not-json", { stackId: STACK_ID })).toBeNull();
576
870
  expect(restore("", { stackId: STACK_ID })).toBeNull();
577
871
  expect(restore(null, { stackId: STACK_ID })).toBeNull();
578
- expect(restore('{"v":2}', { stackId: STACK_ID })).toBeNull();
872
+ expect(restore('{"v":3}', { stackId: STACK_ID })).toBeNull();
579
873
  expect(restore('{"v":1}', { stackId: STACK_ID })).toBeNull();
580
874
  });
581
875
 
582
876
  test("returns null when a layer has unknown variant", () => {
583
877
  const malicious = JSON.stringify({
584
- v: 1,
878
+ v: 2,
585
879
  stackId: STACK_ID,
586
880
  baseUrl: "/",
587
881
  layers: [{ id: "L1", url: "/", variant: "popover", dismissible: true }],
@@ -589,4 +883,95 @@ describe("snapshot / restore", () => {
589
883
  });
590
884
  expect(restore(malicious, { stackId: STACK_ID })).toBeNull();
591
885
  });
886
+
887
+ test("serializes frames as { url, stale } only", () => {
888
+ let s = pushed(freshStack()).state;
889
+ s = pathTo(s, { url: "/projects/42/edit/step2", stale: true }).state;
890
+ const parsed = JSON.parse(snapshot(s));
891
+ expect(parsed.v).toBe(2);
892
+ expect(parsed.layers[0].frames).toEqual([
893
+ { url: "/projects/42/edit", stale: false },
894
+ { url: "/projects/42/edit/step2", stale: true },
895
+ ]);
896
+ });
897
+
898
+ test("round-trips a layer with multiple frames", () => {
899
+ let s = pushed(freshStack()).state;
900
+ s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
901
+ s = pathTo(s, { url: "/projects/42/edit/step3", stale: true }).state;
902
+ const restored = restore(snapshot(s), { stackId: STACK_ID });
903
+ expect(restored).toEqual(s);
904
+ expect(restored.layers[0].frames).toHaveLength(3);
905
+ expect(restored.layers[0].frames[2].stale).toBe(true);
906
+ });
907
+
908
+ test("accepts a v1 snapshot and synthesizes single-frame arrays", () => {
909
+ const v1 = JSON.stringify({
910
+ v: 1,
911
+ stackId: STACK_ID,
912
+ baseUrl: BASE_URL,
913
+ layers: [
914
+ {
915
+ id: "L1",
916
+ url: "/projects/42/edit",
917
+ variant: "modal",
918
+ dismissible: true,
919
+ size: null,
920
+ side: null,
921
+ width: null,
922
+ height: null,
923
+ },
924
+ ],
925
+ savedAt: Date.now(),
926
+ });
927
+ const restored = restore(v1, { stackId: STACK_ID });
928
+ expect(restored).not.toBeNull();
929
+ expect(restored.layers[0].frames).toEqual([
930
+ { url: "/projects/42/edit", stale: false },
931
+ ]);
932
+ });
933
+
934
+ test("returns null when a v2 layer has malformed frames", () => {
935
+ const malicious = JSON.stringify({
936
+ v: 2,
937
+ stackId: STACK_ID,
938
+ baseUrl: BASE_URL,
939
+ layers: [
940
+ {
941
+ id: "L1",
942
+ url: "/x",
943
+ variant: "modal",
944
+ dismissible: true,
945
+ size: null,
946
+ side: null,
947
+ width: null,
948
+ height: null,
949
+ frames: [{ stale: false }],
950
+ },
951
+ ],
952
+ savedAt: Date.now(),
953
+ });
954
+ expect(restore(malicious, { stackId: STACK_ID })).toBeNull();
955
+
956
+ const emptyFrames = JSON.stringify({
957
+ v: 2,
958
+ stackId: STACK_ID,
959
+ baseUrl: BASE_URL,
960
+ layers: [
961
+ {
962
+ id: "L1",
963
+ url: "/x",
964
+ variant: "modal",
965
+ dismissible: true,
966
+ size: null,
967
+ side: null,
968
+ width: null,
969
+ height: null,
970
+ frames: [],
971
+ },
972
+ ],
973
+ savedAt: Date.now(),
974
+ });
975
+ expect(restore(emptyFrames, { stackId: STACK_ID })).toBeNull();
976
+ });
592
977
  });
@@ -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
@@ -14,6 +14,7 @@ module ModalStack
14
14
  module Capybara
15
15
  DIALOG_SELECTOR = "#modal-stack-root"
16
16
  LAYER_SELECTOR = '[data-modal-stack-target="layer"]:not([data-leaving])'
17
+ FRAME_SELECTOR = "[data-modal-stack-frame]:not([data-leaving])"
17
18
 
18
19
  # Scope Capybara matchers to a specific layer of the stack.
19
20
  #
@@ -81,5 +82,25 @@ module ModalStack
81
82
  def modal_stack_depth
82
83
  ::Capybara.current_session.all(:css, LAYER_SELECTOR, wait: 0).size
83
84
  end
85
+
86
+ # Capybara matcher: assert the top layer's path frame depth.
87
+ #
88
+ # expect(page).to have_modal_frames(2)
89
+ #
90
+ # Layers without a path read as a single frame.
91
+ def have_modal_frames(count, **)
92
+ have_css("#{LAYER_SELECTOR}[data-frame-depth=\"#{count}\"]", **)
93
+ end
94
+
95
+ # Scope Capybara to the *current* frame inside the top (or specified)
96
+ # layer — i.e. the frame that's not animating out.
97
+ def within_modal_frame(depth: nil, **, &)
98
+ layers = ::Capybara.current_session.all(:css, LAYER_SELECTOR, minimum: 1, **)
99
+ layer = depth ? layers[depth - 1] : layers.last
100
+ raise ::Capybara::ElementNotFound, "no modal_stack layer at depth #{depth}" unless layer
101
+
102
+ frame = layer.first(:css, FRAME_SELECTOR, minimum: 1, **)
103
+ ::Capybara.current_session.within(frame, &)
104
+ end
84
105
  end
85
106
  end