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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +37 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +748 -0
- data/Rakefile +12 -0
- data/app/assets/javascripts/modal_stack.js +756 -0
- data/app/assets/stylesheets/modal_stack/bootstrap.css +232 -0
- data/app/assets/stylesheets/modal_stack/tailwind.css +303 -0
- data/app/assets/stylesheets/modal_stack/vanilla.css +219 -0
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +149 -0
- data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +34 -0
- data/app/javascript/modal_stack/index.js +15 -0
- data/app/javascript/modal_stack/install.js +15 -0
- data/app/javascript/modal_stack/orchestrator.js +98 -0
- data/app/javascript/modal_stack/orchestrator.test.js +260 -0
- data/app/javascript/modal_stack/runtime.js +217 -0
- data/app/javascript/modal_stack/runtime.test.js +134 -0
- data/app/javascript/modal_stack/state.js +315 -0
- data/app/javascript/modal_stack/state.test.js +508 -0
- data/app/views/layouts/modal.html.erb +6 -0
- data/lib/generators/modal_stack/install/install_generator.rb +224 -0
- data/lib/generators/modal_stack/install/templates/initializer.rb +57 -0
- data/lib/modal_stack/capybara/minitest.rb +9 -0
- data/lib/modal_stack/capybara/rspec.rb +9 -0
- data/lib/modal_stack/capybara.rb +85 -0
- data/lib/modal_stack/configuration.rb +90 -0
- data/lib/modal_stack/controller_extensions.rb +73 -0
- data/lib/modal_stack/engine.rb +44 -0
- data/lib/modal_stack/helpers/modal_link_helper.rb +65 -0
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +45 -0
- data/lib/modal_stack/helpers/modal_stack_container_helper.rb +36 -0
- data/lib/modal_stack/initializer_version_check.rb +33 -0
- data/lib/modal_stack/turbo_streams_extension.rb +73 -0
- data/lib/modal_stack/version.rb +5 -0
- data/lib/modal_stack.rb +36 -0
- 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,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
|