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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +98 -7
- data/app/assets/javascripts/modal_stack.js +205 -23
- data/app/assets/stylesheets/modal_stack/bootstrap.css +35 -0
- data/app/assets/stylesheets/modal_stack/tailwind_v3.css +35 -0
- data/app/assets/stylesheets/modal_stack/tailwind_v4.css +35 -0
- data/app/assets/stylesheets/modal_stack/vanilla.css +35 -0
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +111 -8
- data/app/javascript/modal_stack/orchestrator.js +17 -3
- data/app/javascript/modal_stack/orchestrator.test.js +2 -2
- data/app/javascript/modal_stack/runtime.js +149 -4
- data/app/javascript/modal_stack/runtime.test.js +7 -6
- data/app/javascript/modal_stack/state.js +24 -11
- data/app/javascript/modal_stack/state.test.js +11 -9
- data/app/views/modal_stack/_panel.html.erb +13 -0
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +6 -0
- data/lib/modal_stack/helpers/modal_stack_container_helper.rb +4 -1
- data/lib/modal_stack/version.rb +1 -1
- metadata +2 -2
|
@@ -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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
|
496
|
-
//
|
|
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") %>">×</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
|
}
|
data/lib/modal_stack/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-05-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: railties
|