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
@@ -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,10 +492,13 @@ describe("pop", () => {
306
492
  const first = pushed(freshStack()).state;
307
493
  const { state, commands } = pop(first);
308
494
  expect(state.layers).toEqual([]);
495
+ // closeDialog comes first so its exit transition runs in parallel
496
+ // with the layer's [data-leaving] transition.
309
497
  expect(commands).toEqual([
498
+ { type: "closeDialog" },
310
499
  { type: "unmountTopLayer" },
500
+ { type: "clearFrameCache", layerId: "L1" },
311
501
  { type: "historyBack", n: 1 },
312
- { type: "closeDialog" },
313
502
  { type: "unlockScroll" },
314
503
  { type: "clearSnapshot" },
315
504
  ]);
@@ -322,11 +511,28 @@ describe("pop", () => {
322
511
  expect(state.layers).toHaveLength(1);
323
512
  expect(commands).toEqual([
324
513
  { type: "unmountTopLayer" },
514
+ { type: "clearFrameCache", layerId: "L2" },
325
515
  { type: "historyBack", n: 1 },
326
516
  { type: "inertLayer", layerId: "L1", value: false },
327
517
  { type: "persistSnapshot" },
328
518
  ]);
329
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
+ });
330
536
  });
331
537
 
332
538
  describe("replaceTop", () => {
@@ -353,7 +559,7 @@ describe("replaceTop", () => {
353
559
  {
354
560
  type: "replaceHistory",
355
561
  url: "/projects/42/edit/billing",
356
- historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
562
+ historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
357
563
  },
358
564
  { type: "persistSnapshot" },
359
565
  ]);
@@ -398,10 +604,38 @@ describe("replaceTop", () => {
398
604
  expect(commands[1]).toEqual({
399
605
  type: "pushHistory",
400
606
  url: "/onboarding/step2",
401
- historyState: { stackId: STACK_ID, layerId: "L1b", depth: 1 },
607
+ historyState: { stackId: STACK_ID, layerId: "L1b", depth: 1, frameIndex: 0 },
402
608
  });
403
609
  });
404
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
+
405
639
  test("rejects unknown historyMode", () => {
406
640
  const s = pushed(freshStack()).state;
407
641
  expect(() => replaceTop(s, { url: "/x" }, { historyMode: "wat" })).toThrow(
@@ -423,13 +657,25 @@ describe("closeAll", () => {
423
657
  const { state, commands } = closeAll(s);
424
658
  expect(state.layers).toEqual([]);
425
659
  expect(commands).toEqual([
426
- { type: "unmountAllLayers" },
427
660
  { type: "closeDialog" },
661
+ { type: "unmountAllLayers" },
662
+ { type: "clearFrameCache", layerId: "L1" },
663
+ { type: "clearFrameCache", layerId: "L2" },
664
+ { type: "clearFrameCache", layerId: "L3" },
428
665
  { type: "unlockScroll" },
429
666
  { type: "historyBack", n: 3 },
430
667
  { type: "clearSnapshot" },
431
668
  ]);
432
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
+ });
433
679
  });
434
680
 
435
681
  describe("handlePopstate", () => {
@@ -447,8 +693,10 @@ describe("handlePopstate", () => {
447
693
  });
448
694
  expect(state.layers).toEqual([]);
449
695
  expect(commands).toEqual([
450
- { type: "unmountAllLayers" },
451
696
  { type: "closeDialog" },
697
+ { type: "unmountAllLayers" },
698
+ { type: "clearFrameCache", layerId: "L1" },
699
+ { type: "clearFrameCache", layerId: "L2" },
452
700
  { type: "unlockScroll" },
453
701
  { type: "clearSnapshot" },
454
702
  ]);
@@ -465,12 +713,13 @@ describe("handlePopstate", () => {
465
713
  test("back: targetDepth < current pops layers and un-inerts new top (no historyBack)", () => {
466
714
  const s = buildTwoLayer();
467
715
  const { state, commands } = handlePopstate(s, {
468
- historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
716
+ historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
469
717
  locationHref: "/projects/42/edit",
470
718
  });
471
719
  expect(state.layers.map((l) => l.id)).toEqual(["L1"]);
472
720
  expect(commands).toEqual([
473
721
  { type: "unmountTopLayer" },
722
+ { type: "clearFrameCache", layerId: "L2" },
474
723
  { type: "inertLayer", layerId: "L1", value: false },
475
724
  { type: "persistSnapshot" },
476
725
  ]);
@@ -485,9 +734,11 @@ describe("handlePopstate", () => {
485
734
  });
486
735
  expect(state.layers).toEqual([]);
487
736
  expect(commands).toEqual([
737
+ { type: "closeDialog" },
488
738
  { type: "unmountTopLayer" },
489
739
  { type: "unmountTopLayer" },
490
- { type: "closeDialog" },
740
+ { type: "clearFrameCache", layerId: "L1" },
741
+ { type: "clearFrameCache", layerId: "L2" },
491
742
  { type: "unlockScroll" },
492
743
  { type: "clearSnapshot" },
493
744
  ]);
@@ -512,7 +763,7 @@ describe("handlePopstate", () => {
512
763
  { historyMode: "push" },
513
764
  ).state;
514
765
  const { state, commands } = handlePopstate(after, {
515
- historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
766
+ historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
516
767
  locationHref: "/onboarding/step1",
517
768
  });
518
769
  expect(state.layers[0]).toMatchObject({
@@ -520,6 +771,7 @@ describe("handlePopstate", () => {
520
771
  url: "/onboarding/step1",
521
772
  });
522
773
  expect(commands).toEqual([
774
+ { type: "clearFrameCache", layerId: "L1b" },
523
775
  {
524
776
  type: "morphTopLayer",
525
777
  layerId: "L1",
@@ -535,12 +787,56 @@ describe("handlePopstate", () => {
535
787
  test("same depth, same layerId is a noop", () => {
536
788
  const s = pushed(freshStack()).state;
537
789
  const { state, commands } = handlePopstate(s, {
538
- historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
790
+ historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
539
791
  locationHref: "/projects/42/edit",
540
792
  });
541
793
  expect(state).toEqual(s);
542
794
  expect(commands).toEqual([]);
543
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
+ });
544
840
  });
545
841
 
546
842
  describe("snapshot / restore", () => {
@@ -573,13 +869,13 @@ describe("snapshot / restore", () => {
573
869
  expect(restore("not-json", { stackId: STACK_ID })).toBeNull();
574
870
  expect(restore("", { stackId: STACK_ID })).toBeNull();
575
871
  expect(restore(null, { stackId: STACK_ID })).toBeNull();
576
- expect(restore('{"v":2}', { stackId: STACK_ID })).toBeNull();
872
+ expect(restore('{"v":3}', { stackId: STACK_ID })).toBeNull();
577
873
  expect(restore('{"v":1}', { stackId: STACK_ID })).toBeNull();
578
874
  });
579
875
 
580
876
  test("returns null when a layer has unknown variant", () => {
581
877
  const malicious = JSON.stringify({
582
- v: 1,
878
+ v: 2,
583
879
  stackId: STACK_ID,
584
880
  baseUrl: "/",
585
881
  layers: [{ id: "L1", url: "/", variant: "popover", dismissible: true }],
@@ -587,4 +883,95 @@ describe("snapshot / restore", () => {
587
883
  });
588
884
  expect(restore(malicious, { stackId: STACK_ID })).toBeNull();
589
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
+ });
590
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 %>
@@ -9,11 +9,16 @@ module ModalStack
9
9
  source_root File.expand_path("templates", __dir__)
10
10
 
11
11
  ASSETS_MODES = ModalStack::Configuration::ASSETS_MODES.map(&:to_s).freeze
12
- CSS_PROVIDERS = ModalStack::Configuration::CSS_PROVIDERS.map(&:to_s).freeze
12
+ # The CLI accepts the canonical providers plus the legacy `tailwind`
13
+ # alias (normalized to `tailwind_v3` by Configuration). New installs
14
+ # default to `tailwind_v4` — Tailwind v4 is the modern default and
15
+ # the preset's fallbacks make it safe even without v4 installed.
16
+ CSS_PROVIDERS = (ModalStack::Configuration::CSS_PROVIDERS +
17
+ ModalStack::Configuration::CSS_PROVIDER_ALIASES.keys).map(&:to_s).freeze
13
18
 
14
19
  class_option :mode, type: :string, default: "auto", enum: ASSETS_MODES,
15
20
  desc: "JS asset strategy"
16
- class_option :css_provider, type: :string, default: "tailwind",
21
+ class_option :css_provider, type: :string, default: "tailwind_v4",
17
22
  enum: CSS_PROVIDERS,
18
23
  desc: "CSS preset bundled with the install"
19
24
  class_option :skip_layout, type: :boolean, default: false,
@@ -55,7 +60,7 @@ module ModalStack
55
60
  modal_stack installed.
56
61
 
57
62
  Mode: #{resolved_mode}
58
- CSS provider: #{options[:css_provider]}
63
+ CSS provider: #{resolved_css_provider}
59
64
 
60
65
  Next steps:
61
66
  1. Confirm config/initializers/modal_stack.rb matches your needs.
@@ -82,6 +87,15 @@ module ModalStack
82
87
  @resolved_mode ||= detect_mode
83
88
  end
84
89
 
90
+ # Normalize the legacy `tailwind` alias to the canonical `tailwind_v3`
91
+ # string so the initializer file and sprockets manifest line both
92
+ # reference a stylesheet that actually exists in the gem.
93
+ def resolved_css_provider
94
+ provider = options[:css_provider].to_s
95
+ aliased = ModalStack::Configuration::CSS_PROVIDER_ALIASES[provider.to_sym]
96
+ aliased ? aliased.to_s : provider
97
+ end
98
+
85
99
  def detect_mode
86
100
  mode = options[:mode].to_s
87
101
  return mode unless mode == "auto"
@@ -146,7 +160,7 @@ module ModalStack
146
160
  manifest = "app/assets/config/manifest.js"
147
161
  if file_exists?(manifest)
148
162
  append_unique manifest, "//= link modal_stack.js"
149
- append_unique manifest, "//= link modal_stack/#{options[:css_provider]}.css" unless options[:css_provider] == "none"
163
+ append_unique manifest, "//= link modal_stack/#{resolved_css_provider}.css" unless resolved_css_provider == "none"
150
164
  else
151
165
  say_status :warn, "#{manifest} not found; add `//= link modal_stack.js` manually", :yellow
152
166
  end
@@ -11,11 +11,18 @@ ModalStack.configure do |config|
11
11
  # CSS provider. Determines which stylesheet
12
12
  # `modal_stack_stylesheet_link_tag` resolves to.
13
13
  #
14
- # :tailwind — Tailwind-aligned tokens (default)
15
- # :bootstrap — picks up Bootstrap 5 CSS variables
16
- # :vanilla — neutral defaults, framework-free
17
- # :none — emit no <link>; provide your own CSS
18
- config.css_provider = :<%= options[:css_provider] %>
14
+ # :tailwind_v4 Chains on Tailwind v4 @theme tokens (--color-*,
15
+ # --radius-*, --shadow-*, --container-*). Default for
16
+ # new installs. Falls back to Tailwind defaults when
17
+ # @theme isn't redefined, so it's safe even without v4.
18
+ # :tailwind_v3 Static values aligned with Tailwind v3 defaults
19
+ # (Tailwind v3 doesn't expose tokens as CSS variables).
20
+ # `:tailwind` is accepted as an alias for backwards
21
+ # compatibility.
22
+ # :bootstrap — Picks up Bootstrap 5 CSS variables.
23
+ # :vanilla — Neutral defaults, framework-free.
24
+ # :none — Emit no <link>; provide your own CSS.
25
+ config.css_provider = :<%= resolved_css_provider %>
19
26
 
20
27
  # JS asset strategy used by the install generator and by the
21
28
  # `modal_stack_javascript_tag` helper.
@@ -49,6 +56,15 @@ ModalStack.configure do |config|
49
56
  # :silent — drop the push, no warning
50
57
  config.max_depth_strategy = :warn
51
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
+
52
68
  # Replace `data-turbo-confirm` window.confirm with a modal_stack
53
69
  # confirmation layer (cf. RFC §15.Q7). Off by default — opt-in.
54
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 %>