modal_stack 0.4.1 → 0.4.3

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.
@@ -371,11 +371,13 @@ export function pop(state) {
371
371
  const newTop = newLayers[newLayers.length - 1] ?? null;
372
372
  const commands = [];
373
373
  if (newTop) {
374
+ // Persist early so a page reload during the animation restores the
375
+ // correct (already-popped) stack rather than the stale one.
376
+ commands.push({ type: "persistSnapshot" });
374
377
  commands.push({ type: "unmountTopLayer" });
375
378
  commands.push({ type: "clearFrameCache", layerId: popped.id });
376
379
  commands.push({ type: "historyBack", n: framesToWalkBack });
377
380
  commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
378
- commands.push({ type: "persistSnapshot" });
379
381
  } else {
380
382
  // closeDialog first so the dialog's exit transition (opacity +
381
383
  // backdrop background + display/overlay allow-discrete) starts
@@ -385,11 +387,13 @@ export function pop(state) {
385
387
  // fade kicks in for *another* 220ms — visually the backdrop fades
386
388
  // after the modal is gone.
387
389
  commands.push({ type: "closeDialog" });
390
+ // Clear early so a page reload during the animation does not restore
391
+ // the modal that is already being dismissed.
392
+ commands.push({ type: "clearSnapshot" });
388
393
  commands.push({ type: "unmountTopLayer" });
389
394
  commands.push({ type: "clearFrameCache", layerId: popped.id });
390
395
  commands.push({ type: "historyBack", n: framesToWalkBack });
391
396
  commands.push({ type: "unlockScroll" });
392
- commands.push({ type: "clearSnapshot" });
393
397
  }
394
398
  return { state: { ...state, layers: newLayers }, commands };
395
399
  }
@@ -480,13 +484,15 @@ export function closeAll(state) {
480
484
  state: { ...state, layers: Object.freeze([]) },
481
485
  // closeDialog first so the dialog's exit transition runs in
482
486
  // parallel with the layers' [data-leaving] transitions.
487
+ // clearSnapshot comes before unmountAllLayers so a reload during
488
+ // the animation does not restore a stack that is already closing.
483
489
  commands: [
484
490
  { type: "closeDialog" },
491
+ { type: "clearSnapshot" },
485
492
  { type: "unmountAllLayers" },
486
493
  ...cacheClears,
487
494
  { type: "unlockScroll" },
488
495
  { type: "historyBack", n },
489
- { type: "clearSnapshot" },
490
496
  ],
491
497
  };
492
498
  }
@@ -511,13 +517,13 @@ export function handlePopstate(state, { historyState, locationHref }) {
511
517
  }));
512
518
  return {
513
519
  state: { ...state, layers: Object.freeze([]) },
514
- // closeDialog first — see closeAll() for rationale.
520
+ // closeDialog and clearSnapshot first — see closeAll() for rationale.
515
521
  commands: [
516
522
  { type: "closeDialog" },
523
+ { type: "clearSnapshot" },
517
524
  { type: "unmountAllLayers" },
518
525
  ...cacheClears,
519
526
  { type: "unlockScroll" },
520
- { type: "clearSnapshot" },
521
527
  ],
522
528
  };
523
529
  }
@@ -532,10 +538,19 @@ export function handlePopstate(state, { historyState, locationHref }) {
532
538
  const newLayers = Object.freeze(state.layers.slice(0, targetDepth));
533
539
  const newTop = newLayers[newLayers.length - 1] ?? null;
534
540
  const commands = [];
535
- // When popping back to the root via popstate, fire closeDialog
536
- // first so the dialog's exit transition runs alongside the
537
- // sequential unmountTopLayer cascade.
538
- if (!newTop) commands.push({ type: "closeDialog" });
541
+ if (newTop) {
542
+ // Persist before animation so a reload during the transition
543
+ // restores the correct remaining stack.
544
+ commands.push({ type: "persistSnapshot" });
545
+ } else {
546
+ // When popping back to the root via popstate, fire closeDialog
547
+ // first so the dialog's exit transition runs alongside the
548
+ // sequential unmountTopLayer cascade.
549
+ commands.push({ type: "closeDialog" });
550
+ // Clear before animation so a reload during the transition does
551
+ // not restore the stack that is already being dismissed.
552
+ commands.push({ type: "clearSnapshot" });
553
+ }
539
554
  for (let i = 0; i < droppedLayers.length; i++) {
540
555
  commands.push({ type: "unmountTopLayer" });
541
556
  }
@@ -544,10 +559,8 @@ export function handlePopstate(state, { historyState, locationHref }) {
544
559
  }
545
560
  if (newTop) {
546
561
  commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
547
- commands.push({ type: "persistSnapshot" });
548
562
  } else {
549
563
  commands.push({ type: "unlockScroll" });
550
- commands.push({ type: "clearSnapshot" });
551
564
  }
552
565
  return { state: { ...state, layers: newLayers }, commands };
553
566
  }
@@ -492,15 +492,15 @@ describe("pop", () => {
492
492
  const first = pushed(freshStack()).state;
493
493
  const { state, commands } = pop(first);
494
494
  expect(state.layers).toEqual([]);
495
- // closeDialog comes first so its exit transition runs in parallel
496
- // 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.
497
497
  expect(commands).toEqual([
498
498
  { type: "closeDialog" },
499
+ { type: "clearSnapshot" },
499
500
  { type: "unmountTopLayer" },
500
501
  { type: "clearFrameCache", layerId: "L1" },
501
502
  { type: "historyBack", n: 1 },
502
503
  { type: "unlockScroll" },
503
- { type: "clearSnapshot" },
504
504
  ]);
505
505
  });
506
506
 
@@ -509,12 +509,14 @@ describe("pop", () => {
509
509
  s = push(s, { id: "L2", url: "/clients/new" }).state;
510
510
  const { state, commands } = pop(s);
511
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).
512
514
  expect(commands).toEqual([
515
+ { type: "persistSnapshot" },
513
516
  { type: "unmountTopLayer" },
514
517
  { type: "clearFrameCache", layerId: "L2" },
515
518
  { type: "historyBack", n: 1 },
516
519
  { type: "inertLayer", layerId: "L1", value: false },
517
- { type: "persistSnapshot" },
518
520
  ]);
519
521
  });
520
522
 
@@ -526,11 +528,11 @@ describe("pop", () => {
526
528
  expect(state.layers).toEqual([]);
527
529
  expect(commands).toEqual([
528
530
  { type: "closeDialog" },
531
+ { type: "clearSnapshot" },
529
532
  { type: "unmountTopLayer" },
530
533
  { type: "clearFrameCache", layerId: "L1" },
531
534
  { type: "historyBack", n: 3 },
532
535
  { type: "unlockScroll" },
533
- { type: "clearSnapshot" },
534
536
  ]);
535
537
  });
536
538
  });
@@ -658,13 +660,13 @@ describe("closeAll", () => {
658
660
  expect(state.layers).toEqual([]);
659
661
  expect(commands).toEqual([
660
662
  { type: "closeDialog" },
663
+ { type: "clearSnapshot" },
661
664
  { type: "unmountAllLayers" },
662
665
  { type: "clearFrameCache", layerId: "L1" },
663
666
  { type: "clearFrameCache", layerId: "L2" },
664
667
  { type: "clearFrameCache", layerId: "L3" },
665
668
  { type: "unlockScroll" },
666
669
  { type: "historyBack", n: 3 },
667
- { type: "clearSnapshot" },
668
670
  ]);
669
671
  });
670
672
 
@@ -694,11 +696,11 @@ describe("handlePopstate", () => {
694
696
  expect(state.layers).toEqual([]);
695
697
  expect(commands).toEqual([
696
698
  { type: "closeDialog" },
699
+ { type: "clearSnapshot" },
697
700
  { type: "unmountAllLayers" },
698
701
  { type: "clearFrameCache", layerId: "L1" },
699
702
  { type: "clearFrameCache", layerId: "L2" },
700
703
  { type: "unlockScroll" },
701
- { type: "clearSnapshot" },
702
704
  ]);
703
705
  });
704
706
 
@@ -718,10 +720,10 @@ describe("handlePopstate", () => {
718
720
  });
719
721
  expect(state.layers.map((l) => l.id)).toEqual(["L1"]);
720
722
  expect(commands).toEqual([
723
+ { type: "persistSnapshot" },
721
724
  { type: "unmountTopLayer" },
722
725
  { type: "clearFrameCache", layerId: "L2" },
723
726
  { type: "inertLayer", layerId: "L1", value: false },
724
- { type: "persistSnapshot" },
725
727
  ]);
726
728
  expect(commands).not.toContainEqual({ type: "historyBack", n: 1 });
727
729
  });
@@ -735,12 +737,12 @@ describe("handlePopstate", () => {
735
737
  expect(state.layers).toEqual([]);
736
738
  expect(commands).toEqual([
737
739
  { type: "closeDialog" },
740
+ { type: "clearSnapshot" },
738
741
  { type: "unmountTopLayer" },
739
742
  { type: "unmountTopLayer" },
740
743
  { type: "clearFrameCache", layerId: "L1" },
741
744
  { type: "clearFrameCache", layerId: "L2" },
742
745
  { type: "unlockScroll" },
743
- { type: "clearSnapshot" },
744
746
  ]);
745
747
  });
746
748
 
@@ -1,4 +1,17 @@
1
1
  <%= content_tag(:div, wrapper_attrs) do %>
2
+ <% if local_assigns[:title].present? || local_assigns[:show_close] %>
3
+ <header class="modal-stack__panel-header">
4
+ <% if local_assigns[:title].present? %>
5
+ <h2 class="modal-stack__panel-title"><%= title %></h2>
6
+ <% end %>
7
+ <% if local_assigns[:show_close] %>
8
+ <button type="button"
9
+ class="modal-stack__panel-close"
10
+ data-action="click->modal-stack#pop"
11
+ aria-label="<%= I18n.t("modal_stack.close", default: "Close") %>">&#215;</button>
12
+ <% end %>
13
+ </header>
14
+ <% end %>
2
15
  <%= back_button %>
3
16
  <%= content %>
4
17
  <% end %>
@@ -39,6 +39,12 @@ module ModalStack
39
39
  data = existing.merge(controller: controllers)
40
40
  data[:modal_stack_max_depth_value] ||= config.max_depth if config.max_depth
41
41
  data[:modal_stack_max_depth_strategy_value] ||= config.max_depth_strategy.to_s
42
+ # Preserve dialog across Turbo Drive page-restores (which fire on
43
+ # popstate when the destination history entry was set by Turbo). Without
44
+ # this, the Stimulus controller disconnects mid-close, the runtime is
45
+ # rebuilt from scratch, and any in-flight state (animations, focused
46
+ # element, click-event propagation) is lost.
47
+ data[:turbo_permanent] = "" unless data.key?(:turbo_permanent)
42
48
  data
43
49
  end
44
50
 
@@ -13,15 +13,18 @@ module ModalStack
13
13
  DEFAULT_SIZE = :md
14
14
 
15
15
  def modal_stack_container(size: DEFAULT_SIZE, dismissible: true, variant: :modal, side: nil, width: nil, height: nil,
16
- back: false, transition: nil, html: {}, &)
16
+ back: false, transition: nil, html: {},
17
+ title: nil, close_button: nil, &)
17
18
  attrs = build_panel_attrs(size: size, variant: variant, side: side,
18
19
  dismissible: dismissible, width: width,
19
20
  height: height, transition: transition, html: html)
20
21
  body = capture(&)
21
22
  back_html = back ? modal_stack_container_back_button : nil
23
+ show_close = close_button.nil? ? dismissible : close_button
22
24
 
23
25
  render partial: "modal_stack/panel", locals: {
24
26
  content: body, back_button: back_html, wrapper_attrs: attrs,
27
+ title: title, show_close: show_close,
25
28
  size: size, variant: variant, dismissible: dismissible,
26
29
  side: side, width: width, height: height, transition: transition
27
30
  }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ModalStack
4
- VERSION = "0.4.1"
4
+ VERSION = "0.4.3"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: modal_stack
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.4.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Florian Gagnaire
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-15 00:00:00.000000000 Z
11
+ date: 2026-05-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: railties