modal_stack 0.1.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 (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +37 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +748 -0
  6. data/Rakefile +12 -0
  7. data/app/assets/javascripts/modal_stack.js +756 -0
  8. data/app/assets/stylesheets/modal_stack/bootstrap.css +232 -0
  9. data/app/assets/stylesheets/modal_stack/tailwind.css +303 -0
  10. data/app/assets/stylesheets/modal_stack/vanilla.css +219 -0
  11. data/app/javascript/modal_stack/controllers/modal_stack_controller.js +149 -0
  12. data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +34 -0
  13. data/app/javascript/modal_stack/index.js +15 -0
  14. data/app/javascript/modal_stack/install.js +15 -0
  15. data/app/javascript/modal_stack/orchestrator.js +98 -0
  16. data/app/javascript/modal_stack/orchestrator.test.js +260 -0
  17. data/app/javascript/modal_stack/runtime.js +217 -0
  18. data/app/javascript/modal_stack/runtime.test.js +134 -0
  19. data/app/javascript/modal_stack/state.js +315 -0
  20. data/app/javascript/modal_stack/state.test.js +508 -0
  21. data/app/views/layouts/modal.html.erb +6 -0
  22. data/lib/generators/modal_stack/install/install_generator.rb +224 -0
  23. data/lib/generators/modal_stack/install/templates/initializer.rb +57 -0
  24. data/lib/modal_stack/capybara/minitest.rb +9 -0
  25. data/lib/modal_stack/capybara/rspec.rb +9 -0
  26. data/lib/modal_stack/capybara.rb +85 -0
  27. data/lib/modal_stack/configuration.rb +90 -0
  28. data/lib/modal_stack/controller_extensions.rb +73 -0
  29. data/lib/modal_stack/engine.rb +44 -0
  30. data/lib/modal_stack/helpers/modal_link_helper.rb +65 -0
  31. data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +45 -0
  32. data/lib/modal_stack/helpers/modal_stack_container_helper.rb +36 -0
  33. data/lib/modal_stack/initializer_version_check.rb +33 -0
  34. data/lib/modal_stack/turbo_streams_extension.rb +73 -0
  35. data/lib/modal_stack/version.rb +5 -0
  36. data/lib/modal_stack.rb +36 -0
  37. metadata +130 -0
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/base"
5
+
6
+ module ModalStack
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ ASSETS_MODES = ModalStack::Configuration::ASSETS_MODES.map(&:to_s).freeze
12
+ CSS_PROVIDERS = ModalStack::Configuration::CSS_PROVIDERS.map(&:to_s).freeze
13
+
14
+ class_option :mode, type: :string, default: "auto", enum: ASSETS_MODES,
15
+ desc: "JS asset strategy"
16
+ class_option :css_provider, type: :string, default: "tailwind",
17
+ enum: CSS_PROVIDERS,
18
+ desc: "CSS preset bundled with the install"
19
+ class_option :skip_layout, type: :boolean, default: false,
20
+ desc: "Skip injecting helpers / dialog into application layout"
21
+ class_option :skip_js, type: :boolean, default: false,
22
+ desc: "Skip JS pin / install wiring"
23
+ class_option :skip_initializer, type: :boolean, default: false,
24
+ desc: "Skip generating config/initializers/modal_stack.rb"
25
+
26
+ def copy_initializer
27
+ return if options[:skip_initializer]
28
+
29
+ template "initializer.rb", "config/initializers/modal_stack.rb"
30
+ end
31
+
32
+ def configure_javascript
33
+ return if options[:skip_js]
34
+
35
+ case resolved_mode
36
+ when "importmap" then install_importmap
37
+ when "jsbundling" then install_jsbundling
38
+ when "sprockets" then install_sprockets
39
+ end
40
+ end
41
+
42
+ def inject_into_layout
43
+ return if options[:skip_layout]
44
+
45
+ layout = "app/views/layouts/application.html.erb"
46
+ return say_status(:skip, "#{layout} not found", :yellow) unless file_exists?(layout)
47
+
48
+ inject_stylesheet_helper(layout)
49
+ inject_dialog_helper(layout)
50
+ end
51
+
52
+ def show_readme
53
+ say <<~TXT, :green
54
+
55
+ modal_stack installed.
56
+
57
+ Mode: #{resolved_mode}
58
+ CSS provider: #{options[:css_provider]}
59
+
60
+ Next steps:
61
+ 1. Confirm config/initializers/modal_stack.rb matches your needs.
62
+ 2. Confirm <%= modal_stack_stylesheet_link_tag %> is in your <head>
63
+ and <%= modal_stack_dialog_tag %> is right before </body>.
64
+ 3. Add a modal_link_to in any view:
65
+
66
+ <%= modal_link_to "Edit", edit_thing_path(@thing) %>
67
+
68
+ 4. Use the modal layout in the controller behind that link:
69
+
70
+ class ThingsController < ApplicationController
71
+ modal_stack_layout
72
+ def edit; ...; end
73
+ end
74
+
75
+ Docs: https://github.com/Metalzoid/modal_stack
76
+ TXT
77
+ end
78
+
79
+ private
80
+
81
+ def resolved_mode
82
+ @resolved_mode ||= detect_mode
83
+ end
84
+
85
+ def detect_mode
86
+ mode = options[:mode].to_s
87
+ return mode unless mode == "auto"
88
+ return "importmap" if file_exists?("config/importmap.rb")
89
+ return "sprockets" if file_exists?("app/assets/config/manifest.js") &&
90
+ !file_exists?("config/importmap.rb") &&
91
+ !file_exists?("package.json")
92
+ return "jsbundling" if file_exists?("package.json")
93
+
94
+ "importmap"
95
+ end
96
+
97
+ def install_importmap
98
+ importmap = "config/importmap.rb"
99
+ if file_exists?(importmap)
100
+ append_unique importmap, %(pin "modal_stack", to: "modal_stack.js", preload: true)
101
+ else
102
+ say_status :warn, "#{importmap} not found; cannot pin modal_stack", :yellow
103
+ end
104
+
105
+ target = stimulus_install_target
106
+ if target
107
+ inject_install_call(target)
108
+ else
109
+ say_status :warn, "no Stimulus app entry point found; add the install call manually", :yellow
110
+ end
111
+ end
112
+
113
+ def install_jsbundling
114
+ if file_exists?("bun.lockb") || file_exists?("bun.lock")
115
+ run "bun add @hotwired/stimulus", abort_on_failure: false
116
+ elsif file_exists?("yarn.lock")
117
+ run "yarn add @hotwired/stimulus", abort_on_failure: false
118
+ elsif file_exists?("package-lock.json")
119
+ run "npm install @hotwired/stimulus", abort_on_failure: false
120
+ elsif file_exists?("package.json")
121
+ say_status :warn, "no JS lockfile detected; install @hotwired/stimulus manually", :yellow
122
+ end
123
+
124
+ say_status :info, "modal_stack JS bundle: app/assets/javascripts/modal_stack.js (gem-served)", :cyan
125
+ say_status :info, "Add it to your bundler entry, or pin via importmap.", :cyan
126
+
127
+ target = stimulus_install_target
128
+ inject_install_call(target) if target
129
+ end
130
+
131
+ # Where to drop `installModalStack(application)`. The Rails 7+
132
+ # importmap default puts the Stimulus Application instance in
133
+ # app/javascript/controllers/application.js (and exports it from
134
+ # there), so we prefer that. Falling back to
135
+ # app/javascript/application.js is best-effort — older or custom
136
+ # layouts usually wire Stimulus themselves.
137
+ def stimulus_install_target
138
+ candidates = [
139
+ "app/javascript/controllers/application.js",
140
+ "app/javascript/application.js"
141
+ ]
142
+ candidates.find { |path| file_exists?(path) }
143
+ end
144
+
145
+ def install_sprockets
146
+ manifest = "app/assets/config/manifest.js"
147
+ if file_exists?(manifest)
148
+ append_unique manifest, "//= link modal_stack.js"
149
+ append_unique manifest, "//= link modal_stack/#{options[:css_provider]}.css" unless options[:css_provider] == "none"
150
+ else
151
+ say_status :warn, "#{manifest} not found; add `//= link modal_stack.js` manually", :yellow
152
+ end
153
+
154
+ layout_inject_javascript_tag
155
+ end
156
+
157
+ def inject_install_call(app_js)
158
+ content = File.read(File.join(destination_root, app_js))
159
+ if content.include?(%(from "modal_stack")) || content.include?(%('modal_stack'))
160
+ return say_status(:skip, "modal_stack already imported in #{app_js}", :yellow)
161
+ end
162
+
163
+ append_to_file app_js, <<~JS
164
+
165
+ import { install as installModalStack } from "modal_stack"
166
+ installModalStack(application)
167
+ JS
168
+ end
169
+
170
+ def inject_stylesheet_helper(layout)
171
+ content = File.read(File.join(destination_root, layout))
172
+ if content.include?("modal_stack_stylesheet_link_tag")
173
+ say_status :skip, "modal_stack_stylesheet_link_tag already in #{layout}", :yellow
174
+ elsif content =~ %r{</head>}
175
+ inject_into_file layout, before: %r{</head>} do
176
+ " <%= modal_stack_stylesheet_link_tag %>\n "
177
+ end
178
+ else
179
+ say_status :warn, "no </head> in #{layout}; insert <%= modal_stack_stylesheet_link_tag %> manually", :yellow
180
+ end
181
+ end
182
+
183
+ def inject_dialog_helper(layout)
184
+ content = File.read(File.join(destination_root, layout))
185
+ if content.include?("modal_stack_dialog_tag") || content.include?(%(id="modal-stack-root"))
186
+ say_status :skip, "modal_stack_dialog_tag already in #{layout}", :yellow
187
+ elsif content =~ %r{</body>}
188
+ inject_into_file layout, before: %r{</body>} do
189
+ " <%= modal_stack_dialog_tag %>\n "
190
+ end
191
+ else
192
+ say_status :warn, "no </body> in #{layout}; insert <%= modal_stack_dialog_tag %> manually", :yellow
193
+ end
194
+ end
195
+
196
+ def layout_inject_javascript_tag
197
+ return if options[:skip_layout]
198
+
199
+ layout = "app/views/layouts/application.html.erb"
200
+ return unless file_exists?(layout)
201
+
202
+ content = File.read(File.join(destination_root, layout))
203
+ return if content.include?("javascript_include_tag \"modal_stack\"") ||
204
+ content.include?("javascript_include_tag 'modal_stack'")
205
+
206
+ inject_into_file layout, before: %r{</head>} do
207
+ " <%= javascript_include_tag \"modal_stack\" %>\n "
208
+ end
209
+ end
210
+
211
+ def append_unique(path, line)
212
+ full_path = File.join(destination_root, path)
213
+ content = File.read(full_path)
214
+ return if content.include?(line)
215
+
216
+ append_to_file path, "\n#{line}\n"
217
+ end
218
+
219
+ def file_exists?(path)
220
+ File.exist?(File.join(destination_root, path))
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ ModalStack.configure do |config|
4
+ # Stamps this initializer against the template version that shipped with
5
+ # the installed gem. The boot-time check warns if you upgrade modal_stack
6
+ # to a version with a newer template — regenerate with:
7
+ # bin/rails g modal_stack:install --skip-layout --skip-js --force
8
+ # Set `config.silence_initializer_warning = true` to silence.
9
+ config.initializer_version = "<%= ModalStack::INITIALIZER_VERSION %>"
10
+
11
+ # CSS provider. Determines which stylesheet
12
+ # `modal_stack_stylesheet_link_tag` resolves to.
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] %>
19
+
20
+ # JS asset strategy used by the install generator and by the
21
+ # `modal_stack_javascript_tag` helper.
22
+ # :auto — detect from importmap.rb / package.json (default)
23
+ # :importmap — pin in config/importmap.rb
24
+ # :jsbundling — esbuild / bun / yarn pipeline
25
+ # :sprockets — manifest-driven include
26
+ config.assets_mode = :<%= options[:mode] %>
27
+
28
+ # Defaults for modal_link_to / turbo_stream.modal_push when the call
29
+ # site doesn't specify them.
30
+ config.default_variant = :modal # :modal / :drawer / :bottom_sheet / :confirmation
31
+ config.default_size = :md # :sm / :md / :lg / :xl
32
+ config.default_dismissible = true
33
+
34
+ # The id of the singleton <dialog> root and the data-controller name.
35
+ # Override only if you have a name collision in your app.
36
+ config.dialog_id = "modal-stack-root"
37
+ config.stack_root_data_attribute = "modal-stack"
38
+
39
+ # Header sent on JS-initiated fetches so the controller can flip its
40
+ # layout to "modal" — read by `modal_stack_request?`.
41
+ config.request_header = "X-Modal-Stack-Request"
42
+
43
+ # Hard cap on stack depth (push past this is a runtime error).
44
+ config.max_depth = 5
45
+
46
+ # Replace `data-turbo-confirm` window.confirm with a modal_stack
47
+ # confirmation layer (cf. RFC §15.Q7). Off by default — opt-in.
48
+ config.replace_turbo_confirm = false
49
+
50
+ # Honor `prefers-reduced-motion`. The Tailwind / Bootstrap / vanilla
51
+ # presets already collapse transitions to 1ms when this OS preference
52
+ # is set; this flag is reserved for future JS-side opt-outs.
53
+ config.respect_reduced_motion = true
54
+
55
+ # I18n scope for user-facing strings (close, back, swipe-down hint, …).
56
+ config.i18n_scope = "modal_stack"
57
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "modal_stack/capybara"
4
+
5
+ # Minitest auto-include. Mirror the standard "include in
6
+ # ActionDispatch::SystemTestCase" pattern.
7
+ ActiveSupport.on_load(:action_dispatch_system_test_case) do
8
+ include ModalStack::Capybara
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "modal_stack/capybara"
4
+ require "rspec/core"
5
+
6
+ RSpec.configure do |config|
7
+ config.include ModalStack::Capybara, type: :system
8
+ config.include ModalStack::Capybara, type: :feature
9
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "capybara"
4
+
5
+ module ModalStack
6
+ # Capybara helpers for system / feature specs.
7
+ #
8
+ # Opt in by requiring this file and including the module in your test
9
+ # context. For RSpec, prefer the auto-wired entrypoint:
10
+ #
11
+ # # spec/spec_helper.rb (or rails_helper.rb)
12
+ # require "modal_stack/capybara/rspec"
13
+ #
14
+ module Capybara
15
+ DIALOG_SELECTOR = "#modal-stack-root"
16
+ LAYER_SELECTOR = '[data-modal-stack-target="layer"]:not([data-leaving])'
17
+
18
+ # Scope Capybara matchers to a specific layer of the stack.
19
+ #
20
+ # within_modal { expect(page).to have_content("Edit") }
21
+ # within_modal(depth: 1) { ... } # bottom-most layer
22
+ # within_modal(depth: 2) { ... } # second from the bottom
23
+ #
24
+ # When `depth:` is omitted the top-most layer is targeted.
25
+ def within_modal(depth: nil, **, &)
26
+ layers = ::Capybara.current_session.all(:css, LAYER_SELECTOR, minimum: 1, **)
27
+ target = depth ? layers[depth - 1] : layers.last
28
+ raise ::Capybara::ElementNotFound, "no modal_stack layer at depth #{depth}" unless target
29
+
30
+ ::Capybara.current_session.within(target, &)
31
+ end
32
+
33
+ # Capybara matcher: passes when the modal_stack <dialog> is open.
34
+ #
35
+ # expect(page).to have_modal_open
36
+ def have_modal_open(**)
37
+ have_css("#{DIALOG_SELECTOR}[open]", **)
38
+ end
39
+
40
+ def have_no_modal_open(**)
41
+ have_no_css("#{DIALOG_SELECTOR}[open]", **)
42
+ end
43
+
44
+ # Capybara matcher: assert the live (non-leaving) layer count.
45
+ #
46
+ # expect(page).to have_modal_stack(depth: 2)
47
+ # expect(page).to have_modal_stack # any open layer
48
+ def have_modal_stack(depth: nil, **)
49
+ if depth
50
+ have_css(LAYER_SELECTOR, count: depth, **)
51
+ else
52
+ have_css(LAYER_SELECTOR, **)
53
+ end
54
+ end
55
+
56
+ def have_no_modal_stack(**)
57
+ have_no_css(LAYER_SELECTOR, **)
58
+ end
59
+
60
+ # Send ESC to the dialog so the runtime pops the top layer (honors
61
+ # the layer's dismissible flag — a non-dismissible layer will not
62
+ # close).
63
+ def close_modal
64
+ ::Capybara.current_session.find(:css, DIALOG_SELECTOR).send_keys(:escape)
65
+ end
66
+
67
+ # Pop every layer by sending ESC repeatedly. Stops as soon as no
68
+ # live layer remains, or after `max` attempts as a safety net.
69
+ def close_all_modals(max: 16)
70
+ session = ::Capybara.current_session
71
+ max.times do
72
+ break unless session.has_css?(LAYER_SELECTOR, wait: 0)
73
+
74
+ close_modal
75
+ session.has_no_css?(LAYER_SELECTOR, wait: 1)
76
+ end
77
+ end
78
+
79
+ # Read the current stack depth from the live DOM. Useful when an
80
+ # explicit assertion would be clearer than `have_modal_stack`.
81
+ def modal_stack_depth
82
+ ::Capybara.current_session.all(:css, LAYER_SELECTOR, wait: 0).size
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ModalStack
4
+ # Runtime configuration for the gem. A default instance is created on
5
+ # first access; override via `config/initializers/modal_stack.rb`:
6
+ #
7
+ # ModalStack.configure do |config|
8
+ # config.css_provider = :bootstrap
9
+ # config.default_size = :lg
10
+ # config.replace_turbo_confirm = true
11
+ # end
12
+ #
13
+ class Configuration
14
+ CSS_PROVIDERS = %i[tailwind bootstrap vanilla none].freeze
15
+ ASSETS_MODES = %i[importmap jsbundling sprockets auto].freeze
16
+ VARIANTS = %i[modal drawer bottom_sheet confirmation].freeze
17
+ SIZES = %i[sm md lg xl].freeze
18
+
19
+ attr_accessor :default_classes,
20
+ :default_dismissible,
21
+ :max_depth,
22
+ :request_header,
23
+ :dialog_id,
24
+ :stack_root_data_attribute,
25
+ :respect_reduced_motion,
26
+ :replace_turbo_confirm,
27
+ :i18n_scope,
28
+ :initializer_version,
29
+ :silence_initializer_warning
30
+
31
+ attr_reader :css_provider, :assets_mode, :default_variant, :default_size
32
+
33
+ def initialize
34
+ @css_provider = :tailwind
35
+ @assets_mode = :auto
36
+ @default_variant = :modal
37
+ @default_size = :md
38
+ @default_dismissible = true
39
+ @max_depth = 5
40
+ @request_header = "X-Modal-Stack-Request"
41
+ @dialog_id = "modal-stack-root"
42
+ @stack_root_data_attribute = "modal-stack"
43
+ @respect_reduced_motion = true
44
+ @replace_turbo_confirm = false
45
+ @i18n_scope = "modal_stack"
46
+ @initializer_version = nil
47
+ @silence_initializer_warning = false
48
+ @default_classes = default_classes_hash
49
+ end
50
+
51
+ def css_provider=(value)
52
+ value = value.to_sym
53
+ raise ArgumentError, "css_provider must be one of #{CSS_PROVIDERS.inspect}, got #{value.inspect}" unless CSS_PROVIDERS.include?(value)
54
+
55
+ @css_provider = value
56
+ end
57
+
58
+ def assets_mode=(value)
59
+ value = value.to_sym
60
+ raise ArgumentError, "assets_mode must be one of #{ASSETS_MODES.inspect}, got #{value.inspect}" unless ASSETS_MODES.include?(value)
61
+
62
+ @assets_mode = value
63
+ end
64
+
65
+ def default_variant=(value)
66
+ value = value.to_sym
67
+ raise ArgumentError, "default_variant must be one of #{VARIANTS.inspect}, got #{value.inspect}" unless VARIANTS.include?(value)
68
+
69
+ @default_variant = value
70
+ end
71
+
72
+ def default_size=(value)
73
+ value = value.to_sym
74
+ raise ArgumentError, "default_size must be one of #{SIZES.inspect}, got #{value.inspect}" unless SIZES.include?(value)
75
+
76
+ @default_size = value
77
+ end
78
+
79
+ private
80
+
81
+ def default_classes_hash
82
+ {
83
+ modal_panel: nil,
84
+ drawer_panel: nil,
85
+ bottom_sheet_panel: nil,
86
+ confirmation_panel: nil
87
+ }
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ModalStack
4
+ module ControllerExtensions
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ helper_method :modal_stack_request?
9
+ end
10
+
11
+ class_methods do
12
+ # Switch to the `modal` layout for modal_stack-originated requests,
13
+ # fall back to the regular layout otherwise.
14
+ #
15
+ # class Projects::EditController < ApplicationController
16
+ # modal_stack_layout
17
+ # end
18
+ #
19
+ # Accepts the same `only:` / `except:` filters as Rails' `layout`,
20
+ # so a controller can host both a non-modal index and a set of
21
+ # modal panel actions:
22
+ #
23
+ # class ModalStack::DemosController < ApplicationController
24
+ # modal_stack_layout except: [:index]
25
+ # end
26
+ def modal_stack_layout(fallback: nil, **conditions)
27
+ layout(
28
+ lambda do
29
+ if modal_stack_request?
30
+ "modal"
31
+ elsif fallback.respond_to?(:call)
32
+ fallback.call
33
+ else
34
+ fallback
35
+ end
36
+ end,
37
+ conditions
38
+ )
39
+ end
40
+ end
41
+
42
+ # True when the current request was issued by the modal_stack JS runtime
43
+ # (signaled by the X-Modal-Stack-Request header on the fetch).
44
+ def modal_stack_request?
45
+ return false unless respond_to?(:request) && request
46
+
47
+ request.headers[ModalStack::REQUEST_HEADER] == "1"
48
+ end
49
+
50
+ # Convenience for re-rendering inside the modal layout, e.g. after a
51
+ # validation failure on update:
52
+ #
53
+ # def update
54
+ # if @project.update(project_params)
55
+ # redirect_to @project
56
+ # else
57
+ # render_modal :edit, status: :unprocessable_entity
58
+ # end
59
+ # end
60
+ def render_modal(template_or_options = nil, **options)
61
+ render_args =
62
+ if template_or_options.is_a?(Hash)
63
+ template_or_options.merge(options)
64
+ elsif template_or_options
65
+ { template_or_options => true, **options }
66
+ else
67
+ options
68
+ end
69
+ render_args[:layout] = "modal" unless render_args.key?(:layout)
70
+ render(**render_args)
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module ModalStack
6
+ class Engine < ::Rails::Engine
7
+ initializer "modal_stack.helpers" do
8
+ ActiveSupport.on_load(:action_view) do
9
+ require "modal_stack/helpers/modal_link_helper"
10
+ require "modal_stack/helpers/modal_stack_container_helper"
11
+ require "modal_stack/helpers/modal_stack_assets_helper"
12
+ include ModalStack::Helpers::ModalLinkHelper
13
+ include ModalStack::Helpers::ModalStackContainerHelper
14
+ include ModalStack::Helpers::ModalStackAssetsHelper
15
+ end
16
+ end
17
+
18
+ initializer "modal_stack.controller_extensions" do
19
+ ActiveSupport.on_load(:action_controller_base) do
20
+ require "modal_stack/controller_extensions"
21
+ include ModalStack::ControllerExtensions
22
+ end
23
+ end
24
+
25
+ initializer "modal_stack.turbo_streams" do
26
+ ActiveSupport.on_load(:turbo_streams_tag_builder) do
27
+ require "modal_stack/turbo_streams_extension"
28
+ include ModalStack::TurboStreamsExtension
29
+ end
30
+ end
31
+
32
+ initializer "modal_stack.assets" do |app|
33
+ next unless app.config.respond_to?(:assets)
34
+
35
+ app.config.assets.paths << root.join("app", "javascript").to_s
36
+ app.config.assets.paths << root.join("app", "assets", "javascripts").to_s
37
+ app.config.assets.paths << root.join("app", "assets", "stylesheets").to_s
38
+ end
39
+
40
+ config.after_initialize do
41
+ ModalStack::InitializerVersionCheck.perform
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ModalStack
4
+ module Helpers
5
+ # View helper that turns a regular link into a modal stack trigger.
6
+ # Falls back to plain link_to when the request comes from Hotwire Native
7
+ # (cf. RFC §15 Q3 — "don't break" policy on native shells).
8
+ #
9
+ # <%= modal_link_to "Edit", edit_project_path(@project) %>
10
+ # <%= modal_link_to "Details", project_path(@project), as: :drawer, side: :right %>
11
+ #
12
+ module ModalLinkHelper
13
+ LINK_CONTROLLER = "modal-stack-link"
14
+ LINK_CLICK_ACTION = "click->modal-stack-link#open"
15
+ MODAL_OPTION_KEYS = %i[as side size width height dismissible].freeze
16
+
17
+ def modal_link_to(name = nil, options = nil, html_options = nil, &block)
18
+ if block_given?
19
+ html_options = options
20
+ options = name
21
+ name = block
22
+ end
23
+ html_options = (html_options || {}).dup
24
+
25
+ return link_to(name, options, html_options, &block) if hotwire_native_request?
26
+
27
+ modal_options = html_options.extract!(*MODAL_OPTION_KEYS)
28
+ html_options[:data] = build_modal_link_data(html_options[:data] || {}, modal_options)
29
+
30
+ link_to(name, options, html_options, &block)
31
+ end
32
+
33
+ private
34
+
35
+ def build_modal_link_data(existing_data, modal_options)
36
+ existing_data.merge(
37
+ controller: merged_token(existing_data[:controller], LINK_CONTROLLER),
38
+ action: merged_token(existing_data[:action], LINK_CLICK_ACTION),
39
+ **modal_link_data_attrs(modal_options)
40
+ )
41
+ end
42
+
43
+ def modal_link_data_attrs(opts)
44
+ {
45
+ modal_stack_link_variant: opts[:as],
46
+ modal_stack_link_side: opts[:side],
47
+ modal_stack_link_size: opts[:size],
48
+ modal_stack_link_width: opts[:width],
49
+ modal_stack_link_height: opts[:height],
50
+ modal_stack_link_dismissible: opts[:dismissible]&.to_s
51
+ }.compact
52
+ end
53
+
54
+ def merged_token(existing, addition)
55
+ [existing, addition].compact.join(" ").strip
56
+ end
57
+
58
+ def hotwire_native_request?
59
+ return false unless respond_to?(:request) && request
60
+
61
+ request.user_agent.to_s.include?("Hotwire Native")
62
+ end
63
+ end
64
+ end
65
+ end