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
@@ -20,6 +20,7 @@ module ModalStack
20
20
  VARIANTS = %i[modal drawer bottom_sheet confirmation].freeze
21
21
  SIZES = %i[sm md lg xl].freeze
22
22
  MAX_DEPTH_STRATEGIES = %i[raise warn silent].freeze
23
+ PATH_TRANSITIONS = %i[slide fade none].freeze
23
24
 
24
25
  attr_accessor :default_classes,
25
26
  :request_header,
@@ -37,24 +38,12 @@ module ModalStack
37
38
  :default_size,
38
39
  :default_dismissible,
39
40
  :max_depth,
40
- :max_depth_strategy
41
+ :max_depth_strategy,
42
+ :default_path_transition
41
43
 
42
44
  def initialize
43
- @css_provider = :tailwind_v3
44
- @assets_mode = :auto
45
- @default_variant = :modal
46
- @default_size = :md
47
- @default_dismissible = true
48
- @max_depth = 5
49
- @max_depth_strategy = :warn
50
- @request_header = "X-Modal-Stack-Request"
51
- @dialog_id = "modal-stack-root"
52
- @stack_root_data_attribute = "modal-stack"
53
- @respect_reduced_motion = true
54
- @replace_turbo_confirm = false
55
- @i18n_scope = "modal_stack"
56
- @initializer_version = nil
57
- @silence_initializer_warning = false
45
+ apply_behavior_defaults
46
+ apply_naming_defaults
58
47
  @default_classes = default_classes_hash
59
48
  end
60
49
 
@@ -115,8 +104,40 @@ module ModalStack
115
104
  @max_depth_strategy = value
116
105
  end
117
106
 
107
+ def default_path_transition=(value)
108
+ value = value.to_sym
109
+ unless PATH_TRANSITIONS.include?(value)
110
+ raise ArgumentError,
111
+ "default_path_transition must be one of #{PATH_TRANSITIONS.inspect}, got #{value.inspect}"
112
+ end
113
+
114
+ @default_path_transition = value
115
+ end
116
+
118
117
  private
119
118
 
119
+ def apply_behavior_defaults
120
+ @css_provider = :tailwind_v3
121
+ @assets_mode = :auto
122
+ @default_variant = :modal
123
+ @default_size = :md
124
+ @default_dismissible = true
125
+ @max_depth = 5
126
+ @max_depth_strategy = :warn
127
+ @default_path_transition = :slide
128
+ @respect_reduced_motion = true
129
+ @replace_turbo_confirm = false
130
+ end
131
+
132
+ def apply_naming_defaults
133
+ @request_header = "X-Modal-Stack-Request"
134
+ @dialog_id = "modal-stack-root"
135
+ @stack_root_data_attribute = "modal-stack"
136
+ @i18n_scope = "modal_stack"
137
+ @initializer_version = nil
138
+ @silence_initializer_warning = false
139
+ end
140
+
120
141
  def default_classes_hash
121
142
  {
122
143
  modal_panel: nil,
@@ -9,9 +9,11 @@ module ModalStack
9
9
  require "modal_stack/helpers/modal_link_helper"
10
10
  require "modal_stack/helpers/modal_stack_container_helper"
11
11
  require "modal_stack/helpers/modal_stack_assets_helper"
12
+ require "modal_stack/helpers/modal_back_link_helper"
12
13
  include ModalStack::Helpers::ModalLinkHelper
13
14
  include ModalStack::Helpers::ModalStackContainerHelper
14
15
  include ModalStack::Helpers::ModalStackAssetsHelper
16
+ include ModalStack::Helpers::ModalBackLinkHelper
15
17
  end
16
18
  end
17
19
 
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ModalStack
4
+ module Helpers
5
+ # Renders a button (or link) that steps back through the top layer's
6
+ # path. Wired to the `modal-stack-back-link` Stimulus controller, which
7
+ # delegates to `orchestrator.pathBack`.
8
+ #
9
+ # <%= modal_back_link "Back" %>
10
+ # <%= modal_back_link "Back to step 1", steps: 2 %>
11
+ # <%= modal_back_link class: "btn btn-link" do %>
12
+ # <span aria-hidden="true">←</span> Back
13
+ # <% end %>
14
+ module ModalBackLinkHelper
15
+ LINK_CONTROLLER = "modal-stack-back-link"
16
+ LINK_CLICK_ACTION = "click->modal-stack-back-link#trigger"
17
+
18
+ def modal_back_link(name = nil, options = {}, &block) # rubocop:disable Metrics/CyclomaticComplexity
19
+ if block
20
+ options = name.is_a?(Hash) ? name : {}
21
+ name = capture(&block)
22
+ end
23
+ options = options.dup
24
+ steps = Integer(options.delete(:steps) || 1)
25
+ raise ArgumentError, "steps must be a positive integer, got #{steps.inspect}" if steps < 1
26
+
27
+ options[:data] = back_link_data(options.delete(:data) || {}, steps)
28
+ options[:type] ||= "button"
29
+
30
+ button_tag(name || I18n.t("modal_stack.back", default: "Back"), **options)
31
+ end
32
+
33
+ private
34
+
35
+ def back_link_data(existing, steps)
36
+ existing.merge(
37
+ controller: merged_token(existing[:controller], LINK_CONTROLLER),
38
+ action: merged_token(existing[:action], LINK_CLICK_ACTION),
39
+ modal_stack_back_link_steps_value: steps
40
+ )
41
+ end
42
+
43
+ def merged_token(existing, addition)
44
+ [existing, addition].compact.join(" ").strip
45
+ end
46
+ end
47
+ end
48
+ end
@@ -28,7 +28,7 @@ module ModalStack
28
28
  attrs[:id] ||= config.dialog_id
29
29
  attrs[:data] = build_dialog_data(attrs[:data], config)
30
30
 
31
- content_tag(:dialog, "".html_safe, attrs)
31
+ render partial: "modal_stack/dialog", locals: { dialog_attrs: attrs }
32
32
  end
33
33
 
34
34
  # Merges caller-provided data attrs with the gem-managed ones (controller,
@@ -12,24 +12,51 @@ module ModalStack
12
12
  module ModalStackContainerHelper
13
13
  DEFAULT_SIZE = :md
14
14
 
15
- def modal_stack_container(size: DEFAULT_SIZE, dismissible: true, variant: :modal, side: nil, width: nil, height: nil, html: {},
16
- &)
15
+ def modal_stack_container(size: DEFAULT_SIZE, dismissible: true, variant: :modal, side: nil, width: nil, height: nil,
16
+ back: false, transition: nil, html: {}, &)
17
+ attrs = build_panel_attrs(size: size, variant: variant, side: side,
18
+ dismissible: dismissible, width: width,
19
+ height: height, transition: transition, html: html)
20
+ body = capture(&)
21
+ back_html = back ? modal_stack_container_back_button : nil
22
+
23
+ render partial: "modal_stack/panel", locals: {
24
+ content: body, back_button: back_html, wrapper_attrs: attrs,
25
+ size: size, variant: variant, dismissible: dismissible,
26
+ side: side, width: width, height: height, transition: transition
27
+ }
28
+ end
29
+
30
+ private
31
+
32
+ def build_panel_attrs(size:, variant:, side:, dismissible:, width:, height:, transition:, html:)
17
33
  classes = ["modal-stack__panel", "modal-stack__panel--#{variant}", "modal-stack__panel--size-#{size}"]
18
34
  classes << "modal-stack__panel--side-#{side}" if side
19
35
 
20
- attrs = {
21
- class: [classes, html[:class]].compact.join(" "),
22
- data: {
23
- modal_stack_size: size,
24
- modal_stack_variant: variant,
25
- modal_stack_dismissible: dismissible.to_s,
26
- modal_stack_side: side,
27
- modal_stack_width: width,
28
- modal_stack_height: height
29
- }.merge(html.fetch(:data, {})).compact
30
- }.merge(html.except(:class, :data))
31
-
32
- content_tag(:div, capture(&), **attrs)
36
+ data = {
37
+ modal_stack_size: size, modal_stack_variant: variant,
38
+ modal_stack_dismissible: dismissible.to_s, modal_stack_side: side,
39
+ modal_stack_width: width, modal_stack_height: height,
40
+ modal_stack_transition: transition
41
+ }.merge(html.fetch(:data, {})).compact
42
+
43
+ { class: [classes, html[:class]].compact.join(" "), data: data }
44
+ .merge(html.except(:class, :data))
45
+ end
46
+
47
+ # The back button stays in the DOM at all depths and is hidden by CSS
48
+ # when the layer is on its first frame (`[data-frame-depth="1"]`).
49
+ # That keeps the wizard structure stable across path_to/path_back —
50
+ # no Stimulus state needed.
51
+ def modal_stack_container_back_button
52
+ label = (defined?(I18n) ? I18n.t("modal_stack.back", default: "Back") : "Back")
53
+ button_tag(
54
+ label,
55
+ type: "button",
56
+ class: "modal-stack__panel-back",
57
+ data: { action: "click->modal-stack#pathBack" },
58
+ aria: { label: label }
59
+ )
33
60
  end
34
61
  end
35
62
  end
@@ -6,6 +6,7 @@ module ModalStack
6
6
  # so the standard `turbo_stream.foo(...)` form keeps working alongside.
7
7
  module TurboStreamsExtension
8
8
  HISTORY_MODES = %i[push replace].freeze
9
+ PATH_TRANSITIONS = %i[slide fade none].freeze
9
10
 
10
11
  # Push a new layer on top of the stack. The content is rendered using
11
12
  # the same options as Turbo's standard stream actions
@@ -64,10 +65,65 @@ module ModalStack
64
65
  turbo_stream_action_tag(:modal_close_all, target: ModalStack::TARGET_ID)
65
66
  end
66
67
 
68
+ # Navigate forward inside the top layer's path. The current frame is
69
+ # cached in memory so a subsequent `modal_path_back` (or browser back)
70
+ # restores it without a network round-trip. Pass `stale: true` (or set
71
+ # `X-Modal-Stack-Stale: true` on the response) to force a refetch on
72
+ # back.
73
+ def modal_path_to(content = nil, url: nil, transition: nil, stale: false, layer_id: nil, **rendering, &)
74
+ template = render_template(ModalStack::TARGET_ID, content, **rendering, &)
75
+ turbo_stream_action_tag(
76
+ :modal_path_to,
77
+ target: ModalStack::TARGET_ID,
78
+ template: template,
79
+ data: modal_data(
80
+ url: url,
81
+ transition: validate_path_transition(resolved_path_transition(transition)),
82
+ stale: stale ? "true" : nil,
83
+ layer_id: layer_id
84
+ )
85
+ )
86
+ end
87
+
88
+ # Step back through the top layer's path. Defaults to one frame; pass
89
+ # `steps: N` to collapse multiple frames at once. Clamps at the first
90
+ # frame — does not close the layer.
91
+ def modal_path_back(steps: 1, transition: nil)
92
+ n = Integer(steps)
93
+ raise ArgumentError, "steps must be a positive integer, got #{steps.inspect}" if n < 1
94
+
95
+ turbo_stream_action_tag(
96
+ :modal_path_back,
97
+ target: ModalStack::TARGET_ID,
98
+ data: modal_data(
99
+ steps: n,
100
+ transition: validate_path_transition(resolved_path_transition(transition))
101
+ )
102
+ )
103
+ end
104
+
67
105
  private
68
106
 
69
107
  def modal_data(**attrs)
70
108
  attrs.compact
71
109
  end
110
+
111
+ def validate_path_transition(value)
112
+ return nil if value.nil?
113
+
114
+ sym = value.to_sym
115
+ unless PATH_TRANSITIONS.include?(sym)
116
+ raise ArgumentError, "transition must be one of #{PATH_TRANSITIONS.inspect}, got #{value.inspect}"
117
+ end
118
+
119
+ sym
120
+ end
121
+
122
+ # Falls back to the configured default when a call site doesn't
123
+ # specify a transition. Pass `transition: :none` to disable
124
+ # explicitly.
125
+ def resolved_path_transition(value)
126
+ value || ModalStack.configuration.default_path_transition
127
+ end
72
128
  end
73
129
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ModalStack
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.1"
5
5
  end
data/lib/modal_stack.rb CHANGED
@@ -10,11 +10,15 @@ module ModalStack
10
10
  # at the application level (config.dialog_id =, config.request_header =).
11
11
  TARGET_ID = "modal-stack-root"
12
12
  REQUEST_HEADER = "X-Modal-Stack-Request"
13
+ # Response header set by `path_to` controllers to mark the just-rendered
14
+ # frame as stale — the JS runtime refetches instead of restoring from its
15
+ # in-memory cache when the user steps back to it.
16
+ STALE_HEADER = "X-Modal-Stack-Stale"
13
17
 
14
18
  # Bumped when config/initializers/modal_stack.rb gains/loses an option,
15
19
  # so apps that haven't regenerated their initializer get a one-line
16
20
  # boot warning. Independent from the gem's VERSION.
17
- INITIALIZER_VERSION = "0.3.0"
21
+ INITIALIZER_VERSION = "0.4.0"
18
22
 
19
23
  class << self
20
24
  def configuration
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.3.0
4
+ version: 0.4.1
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-08 00:00:00.000000000 Z
11
+ date: 2026-05-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: railties
@@ -73,6 +73,7 @@ files:
73
73
  - app/assets/stylesheets/modal_stack/tailwind_v3.css
74
74
  - app/assets/stylesheets/modal_stack/tailwind_v4.css
75
75
  - app/assets/stylesheets/modal_stack/vanilla.css
76
+ - app/javascript/modal_stack/controllers/modal_stack_back_link_controller.js
76
77
  - app/javascript/modal_stack/controllers/modal_stack_controller.js
77
78
  - app/javascript/modal_stack/controllers/modal_stack_link_controller.js
78
79
  - app/javascript/modal_stack/index.js
@@ -84,8 +85,13 @@ files:
84
85
  - app/javascript/modal_stack/state.js
85
86
  - app/javascript/modal_stack/state.test.js
86
87
  - app/views/layouts/modal.html.erb
88
+ - app/views/modal_stack/_dialog.html.erb
89
+ - app/views/modal_stack/_panel.html.erb
87
90
  - lib/generators/modal_stack/install/install_generator.rb
88
91
  - lib/generators/modal_stack/install/templates/initializer.rb
92
+ - lib/generators/modal_stack/views/templates/_dialog.html.erb
93
+ - lib/generators/modal_stack/views/templates/_panel.html.erb
94
+ - lib/generators/modal_stack/views/views_generator.rb
89
95
  - lib/modal_stack.rb
90
96
  - lib/modal_stack/capybara.rb
91
97
  - lib/modal_stack/capybara/minitest.rb
@@ -93,6 +99,7 @@ files:
93
99
  - lib/modal_stack/configuration.rb
94
100
  - lib/modal_stack/controller_extensions.rb
95
101
  - lib/modal_stack/engine.rb
102
+ - lib/modal_stack/helpers/modal_back_link_helper.rb
96
103
  - lib/modal_stack/helpers/modal_link_helper.rb
97
104
  - lib/modal_stack/helpers/modal_stack_assets_helper.rb
98
105
  - lib/modal_stack/helpers/modal_stack_container_helper.rb