ultimate_static_modal 0.1.0 → 0.2.0
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 +11 -0
- data/README.md +124 -78
- data/VERSION +1 -1
- data/lib/generators/ultimate_static_modal/install_generator.rb +82 -44
- data/lib/generators/ultimate_static_modal/templates/modal_controller.js +13 -292
- metadata +39 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1c7878b97ecc68dd821faa210b908ba9aae901629b1ee512b2584b81e83fc87f
|
|
4
|
+
data.tar.gz: 8e4f833e1d104b5aeda473188934e1883800bdce41cf673416247bb412d164aa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: acd4c6af8bfa8b0fe7038936abb3c9e5d0945f63017307da912efefe6ab820f7eff367b1df02a3fe4378ba72a47f91095255efcea174b402460f4fc8948b4d04
|
|
7
|
+
data.tar.gz: 30a9fa6ef25d2c0cca0485a58c88dad25440ca5bcc2537c81a82afa8efe7b85327da2e54160cc34b87251a89fae35bc0bed0645fca57a5576254af2d20182bc1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 — 2026-07-22
|
|
4
|
+
|
|
5
|
+
- Replaced the copied UTMR Stimulus controller with a small subclass adapter,
|
|
6
|
+
so static and Turbo-backed modals inherit the installed UTMR controller's
|
|
7
|
+
fixes and features.
|
|
8
|
+
- Added integration coverage against the published UTMR JavaScript package for
|
|
9
|
+
both frameless and Turbo Frame-backed dialogs.
|
|
10
|
+
- Updated the install generator to follow UTMR's current `index.js` and
|
|
11
|
+
`application.js` Stimulus layouts, with generator integration coverage.
|
|
12
|
+
- Raised the supported UTMR range to 3.2.1 or newer within the 3.x series.
|
|
13
|
+
|
|
3
14
|
## 0.1.0 — 2026-04-24
|
|
4
15
|
|
|
5
16
|
Initial release.
|
data/README.md
CHANGED
|
@@ -1,143 +1,189 @@
|
|
|
1
1
|
# ultimate_static_modal
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Render [ultimate_turbo_modal](https://github.com/cmer/ultimate_turbo_modal)'s polished modal and drawer chrome on content that doesn't need to come from the server. Help popovers, confirmation dialogs, navigation drawers, on-page filter UIs — anything that doesn't deserve its own URL.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://rubygems.org/gems/ultimate_static_modal)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
> Visual examples of the modal and drawer chrome live in [UTMR's README](https://github.com/cmer/ultimate_turbo_modal#readme) — this gem renders identical chrome.
|
|
8
|
+
|
|
9
|
+
## Why this exists
|
|
10
|
+
|
|
11
|
+
UTMR is built for server-driven modals: you click a link, Rails renders the modal contents into a Turbo Frame, the dialog opens. That's a great pattern for forms, show pages, or anything else with real server state.
|
|
12
|
+
|
|
13
|
+
But sometimes you just want a modal — no route, no controller action, no Turbo Frame — and you want it to look exactly like the rest of your modals. UTMR's `modal()` helper short-circuits when there's no `Turbo-Frame` header on the request, so it can't render those. This gem fills the gap.
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
You need [ultimate_turbo_modal](https://github.com/cmer/ultimate_turbo_modal) 3.2.1 or newer within the 3.x series already installed and working in your Rails app. Keep its Ruby gem and npm package on the same version. If `link_to ..., data: { turbo_frame: "modal" }` already opens a working modal, you're good. If not, install UTMR first.
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
### 1. Install the gem
|
|
8
22
|
|
|
9
23
|
```ruby
|
|
10
24
|
# Gemfile
|
|
11
|
-
gem "ultimate_turbo_modal" # follow its install instructions first
|
|
12
25
|
gem "ultimate_static_modal"
|
|
13
26
|
```
|
|
14
27
|
|
|
15
|
-
After installing UTMR (`rails g ultimate_turbo_modal:install`), run our installer:
|
|
16
|
-
|
|
17
28
|
```sh
|
|
29
|
+
bundle install
|
|
18
30
|
bin/rails generate ultimate_static_modal:install
|
|
19
31
|
```
|
|
20
32
|
|
|
21
|
-
|
|
33
|
+
The generator copies two Stimulus controllers into `app/javascript/controllers/` and wires them into the same Stimulus module that registers UTMR (`controllers/index.js` or `controllers/application.js`). Rebuild your JS bundle and restart Rails.
|
|
34
|
+
|
|
35
|
+
### 2. Add a modal to any page
|
|
36
|
+
|
|
37
|
+
```erb
|
|
38
|
+
<%# Anywhere in a view %>
|
|
39
|
+
<%= static_modal_template("hello", title: "Hello there") do %>
|
|
40
|
+
<p>This is a static modal. No round-trip to the server.</p>
|
|
41
|
+
<% end %>
|
|
42
|
+
|
|
43
|
+
<%= static_modal_trigger("hello", class: "px-4 py-2 rounded bg-indigo-600 text-white") do %>
|
|
44
|
+
Open the modal
|
|
45
|
+
<% end %>
|
|
46
|
+
```
|
|
22
47
|
|
|
23
|
-
|
|
24
|
-
2. Copies a patched `modal_controller.js` next to it (a fork of UTMR's controller — see *Why we ship a forked controller* below).
|
|
25
|
-
3. Updates `app/javascript/controllers/index.js` to register both controllers and replace UTMR's modal registration with the forked version, while still importing UTMR's npm package for its Turbo event side-effects.
|
|
48
|
+
### 3. Click the button
|
|
26
49
|
|
|
27
|
-
|
|
50
|
+
The button clones the `<template>` into the page, the modal animates open, ESC / close button / outside-click all dismiss it. Done.
|
|
28
51
|
|
|
29
|
-
##
|
|
52
|
+
## Common scenarios
|
|
30
53
|
|
|
31
|
-
|
|
54
|
+
### Keyboard shortcuts / help dialog
|
|
32
55
|
|
|
33
56
|
```erb
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
<dl>
|
|
40
|
-
<dt>?</dt><dd>Show this dialog</dd>
|
|
41
|
-
<dt>g h</dt><dd>Go home</dd>
|
|
57
|
+
<%= static_modal_template("shortcuts", title: "Keyboard shortcuts") do %>
|
|
58
|
+
<dl class="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 text-sm">
|
|
59
|
+
<dt class="font-mono">?</dt><dd>Open this dialog</dd>
|
|
60
|
+
<dt class="font-mono">g h</dt><dd>Go home</dd>
|
|
61
|
+
<dt class="font-mono">/</dt><dd>Focus search</dd>
|
|
42
62
|
</dl>
|
|
43
63
|
<% end %>
|
|
44
64
|
|
|
45
|
-
|
|
46
|
-
<%= static_drawer_template("filters", position: :right, size: :lg, title: "Filters") do |m| %>
|
|
47
|
-
<p>Anything goes here.</p>
|
|
48
|
-
<% end %>
|
|
49
|
-
|
|
50
|
-
<%# Trigger button (works for both) %>
|
|
51
|
-
<%= static_modal_trigger("shortcuts", class: "btn btn-primary") do %>
|
|
65
|
+
<%= static_modal_trigger("shortcuts", class: "...") do %>
|
|
52
66
|
Keyboard shortcuts
|
|
53
67
|
<% end %>
|
|
54
68
|
```
|
|
55
69
|
|
|
56
|
-
|
|
70
|
+
### Confirmation dialog
|
|
57
71
|
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
</
|
|
72
|
+
```erb
|
|
73
|
+
<%= static_modal_template("confirm-delete", title: "Are you sure?") do |m| %>
|
|
74
|
+
<% m.footer do %>
|
|
75
|
+
<div class="flex justify-end gap-2 w-full">
|
|
76
|
+
<button type="button" data-action="modal#hideModal" class="...">Cancel</button>
|
|
77
|
+
<%= button_to "Delete", thing_path(@thing), method: :delete, class: "..." %>
|
|
78
|
+
</div>
|
|
79
|
+
<% end %>
|
|
80
|
+
<p>This will permanently delete <strong><%= @thing.name %></strong>. This cannot be undone.</p>
|
|
81
|
+
<% end %>
|
|
82
|
+
|
|
83
|
+
<%= static_modal_trigger("confirm-delete", class: "text-red-600") do %>
|
|
84
|
+
Delete…
|
|
85
|
+
<% end %>
|
|
65
86
|
```
|
|
66
87
|
|
|
67
|
-
|
|
88
|
+
`data-action="modal#hideModal"` on the Cancel button calls UTMR's modal Stimulus controller to dismiss the dialog. (Pulled in by the install generator — you don't need to wire it up.)
|
|
89
|
+
|
|
90
|
+
### Navigation drawer (offcanvas)
|
|
91
|
+
|
|
92
|
+
```erb
|
|
93
|
+
<%= static_drawer_template("nav", position: :left, size: :lg, title: "Navigation") do %>
|
|
94
|
+
<ul class="space-y-2">
|
|
95
|
+
<li><%= link_to "Home", root_path %></li>
|
|
96
|
+
<li><%= link_to "Settings", settings_path %></li>
|
|
97
|
+
</ul>
|
|
98
|
+
<% end %>
|
|
99
|
+
|
|
100
|
+
<%= static_modal_trigger("nav", class: "...") do %>
|
|
101
|
+
☰ Menu
|
|
102
|
+
<% end %>
|
|
103
|
+
```
|
|
68
104
|
|
|
69
|
-
|
|
105
|
+
Because static drawers are not wrapped in a Turbo Frame, ordinary links navigate normally.
|
|
70
106
|
|
|
71
|
-
|
|
107
|
+
### Filter sidebar (no overlay)
|
|
72
108
|
|
|
73
109
|
```erb
|
|
74
|
-
<%=
|
|
75
|
-
|
|
110
|
+
<%= static_drawer_template("filters", position: :right, title: "Filters", overlay: false) do %>
|
|
111
|
+
<%# Your filter form here. Stays visible without dimming the page behind it. %>
|
|
112
|
+
<% end %>
|
|
76
113
|
```
|
|
77
114
|
|
|
78
|
-
|
|
115
|
+
## Helpers
|
|
79
116
|
|
|
80
|
-
|
|
117
|
+
| Helper | What it does |
|
|
118
|
+
| --- | --- |
|
|
119
|
+
| `static_modal_template(id, **opts, &block)` | Emits a `<template id="…">` wrapping a static modal. |
|
|
120
|
+
| `static_drawer_template(id, **opts, &block)` | Emits a `<template id="…">` wrapping a static drawer. |
|
|
121
|
+
| `static_modal_trigger(template_id, **html_opts, &block)` | Emits a `<button>` that clones the template into the DOM on click. Works for both modals and drawers. |
|
|
122
|
+
| `static_modal(**opts, &block)` | Renders the bare `<dialog>` markup with no `<template>` wrap. Use only when you're wrapping it yourself. |
|
|
123
|
+
| `static_drawer(**opts, &block)` | Same, but for drawers. |
|
|
81
124
|
|
|
82
|
-
|
|
125
|
+
All `**opts` accept everything UTMR's `modal()` / `drawer()` accept, including the block DSL (`m.title { … }`, `m.footer { … }`).
|
|
83
126
|
|
|
84
|
-
|
|
127
|
+
## Options
|
|
85
128
|
|
|
86
|
-
|
|
129
|
+
These pass through to UTMR's flavor classes — see [UTMR's README](https://github.com/cmer/ultimate_turbo_modal#configuration-options) for the full list. The most useful ones:
|
|
87
130
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
131
|
+
| Option | Default | Description |
|
|
132
|
+
| --- | --- | --- |
|
|
133
|
+
| `title:` | `nil` | Title text rendered in the header |
|
|
134
|
+
| `header:` | `true` | Show the header bar at all |
|
|
135
|
+
| `header_divider:` | `true` (modal) / `false` (drawer) | Show a divider line under the header |
|
|
136
|
+
| `footer_divider:` | `true` | Show a divider line above the footer |
|
|
137
|
+
| `close_button:` | `true` | Show the X button |
|
|
138
|
+
| `padding:` | `true` | Pad the body content |
|
|
139
|
+
| `overlay:` | `true` | Dim the rest of the page when open |
|
|
140
|
+
| `position:` | `:right` (drawer only) | `:right` or `:left` |
|
|
141
|
+
| `size:` | `:md` (drawer only) | `:xs`, `:sm`, `:md`, `:lg`, `:xl`, `:"2xl"`, `:full`, or any CSS length string |
|
|
94
142
|
|
|
95
|
-
##
|
|
143
|
+
## Troubleshooting
|
|
96
144
|
|
|
97
|
-
|
|
98
|
-
module UltimateStaticModal
|
|
99
|
-
def build_static_subclass(flavor_class)
|
|
100
|
-
Class.new(flavor_class) do
|
|
101
|
-
def view_template(&block)
|
|
102
|
-
drawer? ? render_drawer(&block) : render_modal(&block)
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
```
|
|
145
|
+
**Nothing happens when I click the trigger button.**
|
|
108
146
|
|
|
109
|
-
|
|
147
|
+
Most likely you skipped a step after installing:
|
|
148
|
+
- Did the generator run? You should see `app/javascript/controllers/static_modal_controller.js` and `modal_controller.js` and a `register("static-modal", …)` line in `controllers/index.js` or `controllers/application.js`.
|
|
149
|
+
- Did you rebuild your JS bundle? (`npm run build`, or whatever your bundler command is.)
|
|
150
|
+
- Did you restart Rails? View helpers are loaded by a Railtie, which only runs on boot.
|
|
110
151
|
|
|
111
|
-
|
|
152
|
+
**The button has no background color (or text is invisible).**
|
|
112
153
|
|
|
113
|
-
|
|
154
|
+
If you used a Tailwind utility class that wasn't in any other file before, Tailwind hasn't compiled it yet. Rebuild your CSS bundle (`npm run build:css`).
|
|
114
155
|
|
|
115
|
-
|
|
116
|
-
this.turboFrame.dispatchEvent(event); // throws if turboFrame is null
|
|
117
|
-
```
|
|
156
|
+
**`NoMethodError: undefined method 'static_modal_template' for #<...>`**
|
|
118
157
|
|
|
119
|
-
|
|
158
|
+
Restart Rails. The helper module is included into `ActionView::Base` by a Railtie at boot time.
|
|
120
159
|
|
|
121
|
-
|
|
160
|
+
## How it works (short version)
|
|
122
161
|
|
|
123
|
-
|
|
162
|
+
Server side: a tiny Phlex subclass overrides UTMR's `view_template` to skip the `turbo_frame?` guard, so the dialog markup renders unconditionally. About 15 lines of Ruby.
|
|
124
163
|
|
|
125
|
-
|
|
164
|
+
Client side: the install generator ships a `static-modal` Stimulus controller that clones a `<template>` on click, plus a small subclass of UTMR's exported controller. The subclass treats a frameless dialog as its own lifecycle-event target and handles redirects from forms inside static dialogs. Everything else — opening, animation, stacking, scroll locking, Escape handling, and cleanup — comes from the installed UTMR package.
|
|
126
165
|
|
|
127
|
-
|
|
166
|
+
For the long version, including why we don't just wrap the dialog in a sentinel `<turbo-frame>`, see [ARCHITECTURE.md](ARCHITECTURE.md).
|
|
128
167
|
|
|
129
168
|
## Status
|
|
130
169
|
|
|
131
|
-
Experimental
|
|
170
|
+
Experimental but stable enough to use. This gem does not carry a source fork of UTMR; its controller adapter is tested against the published UTMR package so compatibility failures surface during development.
|
|
132
171
|
|
|
133
172
|
## Development
|
|
134
173
|
|
|
135
174
|
```sh
|
|
136
|
-
#
|
|
175
|
+
# Ruby generator tests
|
|
176
|
+
bundle install && bundle exec rake test
|
|
177
|
+
|
|
178
|
+
# JS integration tests for the UTMR controller adapter
|
|
137
179
|
cd javascript && npm install && npm test
|
|
138
180
|
```
|
|
139
181
|
|
|
140
|
-
|
|
182
|
+
There's no Ruby test suite. UTMR has none either, and the gem is small enough that the JS tests plus manual smoke-testing in a host app cover the load-bearing behavior.
|
|
183
|
+
|
|
184
|
+
## Contributing
|
|
185
|
+
|
|
186
|
+
Issues and PRs welcome at <https://github.com/firstdraft/ultimate_static_modal>.
|
|
141
187
|
|
|
142
188
|
## License
|
|
143
189
|
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.2.0
|
|
@@ -7,79 +7,117 @@ module UltimateStaticModal
|
|
|
7
7
|
class InstallGenerator < Rails::Generators::Base
|
|
8
8
|
source_root File.expand_path("templates", __dir__)
|
|
9
9
|
|
|
10
|
-
desc "Installs UltimateStaticModal: copies
|
|
10
|
+
desc "Installs UltimateStaticModal: copies the template controller and UTMR adapter, then wires them into Stimulus."
|
|
11
11
|
|
|
12
12
|
def copy_static_modal_controller
|
|
13
13
|
copy_file "static_modal_controller.js",
|
|
14
14
|
"app/javascript/controllers/static_modal_controller.js"
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def
|
|
17
|
+
def copy_modal_controller_adapter
|
|
18
18
|
copy_file "modal_controller.js",
|
|
19
19
|
"app/javascript/controllers/modal_controller.js"
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
def
|
|
23
|
-
|
|
22
|
+
def update_stimulus_controllers
|
|
23
|
+
target_path = stimulus_target_path
|
|
24
24
|
|
|
25
|
-
unless
|
|
26
|
-
say_manual_snippet
|
|
25
|
+
unless target_path
|
|
26
|
+
say_manual_snippet
|
|
27
27
|
return
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
register_static_modal_controller(target_path)
|
|
31
|
+
register_modal_adapter(target_path)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def show_readme
|
|
35
|
+
say "\nUltimateStaticModal installation complete!", :magenta
|
|
36
|
+
say "Rebuild your JS bundle and restart Rails.", :yellow
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def stimulus_target_path
|
|
42
|
+
candidates = %w[index.js application.js].filter_map do |filename|
|
|
43
|
+
path = File.join(destination_root, "app", "javascript", "controllers", filename)
|
|
44
|
+
[path, File.read(path)] if File.exist?(path)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
match = candidates.find { |_, content| content.match?(upstream_register_pattern) }
|
|
48
|
+
match ||= candidates.find { |_, content| content.match?(/Application\.start\(\)/) }
|
|
49
|
+
match ||= candidates.first
|
|
50
|
+
match&.first
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def register_static_modal_controller(target_path)
|
|
54
|
+
import_line = "import StaticModalController from \"./static_modal_controller\"\n"
|
|
55
|
+
register_line = "application.register(\"static-modal\", StaticModalController)\n"
|
|
56
|
+
content = File.read(target_path)
|
|
57
|
+
|
|
58
|
+
insert_import(target_path, import_line, content) unless content.include?(import_line.strip)
|
|
31
59
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
static_register = "application.register(\"static-modal\", StaticModalController)\n"
|
|
60
|
+
content = File.read(target_path)
|
|
61
|
+
insert_registration(target_path, register_line, content) unless content.include?(register_line.strip)
|
|
35
62
|
|
|
36
|
-
|
|
37
|
-
|
|
63
|
+
say "✅ Registered `static-modal` Stimulus controller.", :green
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def register_modal_adapter(target_path)
|
|
67
|
+
import_line = "import ModalController from \"./modal_controller\"\n"
|
|
68
|
+
register_line = "application.register(\"modal\", ModalController)\n"
|
|
69
|
+
content = File.read(target_path)
|
|
70
|
+
|
|
71
|
+
if content.include?(import_line.strip)
|
|
72
|
+
say "⏩ Frameless modal controller adapter already imported.", :blue
|
|
73
|
+
elsif content.match?(upstream_import_pattern)
|
|
74
|
+
gsub_file target_path, upstream_import_pattern, import_line
|
|
38
75
|
else
|
|
39
|
-
|
|
40
|
-
insert_into_file index_path.to_s, static_import,
|
|
41
|
-
after: /import .* from ["'](?:@hotwired\/stimulus|\.\/application)["']\n/
|
|
42
|
-
else
|
|
43
|
-
prepend_to_file index_path.to_s, static_import
|
|
44
|
-
end
|
|
45
|
-
append_to_file index_path.to_s, static_register
|
|
46
|
-
say "✅ Registered `static-modal` Stimulus controller.", :green
|
|
76
|
+
insert_import(target_path, import_line, content)
|
|
47
77
|
end
|
|
48
78
|
|
|
49
|
-
|
|
50
|
-
content
|
|
79
|
+
content = File.read(target_path)
|
|
80
|
+
if content.include?(register_line.strip)
|
|
81
|
+
say "⏩ Frameless modal controller adapter already registered.", :blue
|
|
82
|
+
elsif content.match?(upstream_register_pattern)
|
|
83
|
+
gsub_file target_path, upstream_register_pattern, register_line
|
|
84
|
+
say "✅ Swapped UTMR modal registration for the frameless controller adapter.", :green
|
|
85
|
+
else
|
|
86
|
+
insert_registration(target_path, register_line, content)
|
|
87
|
+
say "✅ Registered the frameless modal controller adapter.", :green
|
|
88
|
+
end
|
|
89
|
+
end
|
|
51
90
|
|
|
52
|
-
|
|
53
|
-
|
|
91
|
+
def insert_import(target_path, import_line, content)
|
|
92
|
+
import_anchor = /import .* from ["'](?:@hotwired\/stimulus|\.\/application)["']\n/
|
|
54
93
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
import
|
|
59
|
-
|
|
60
|
-
|
|
94
|
+
if content.match?(import_anchor)
|
|
95
|
+
insert_into_file target_path, import_line, after: import_anchor
|
|
96
|
+
elsif content.match?(/^import /)
|
|
97
|
+
insert_into_file target_path, import_line, before: /^import /
|
|
98
|
+
else
|
|
99
|
+
prepend_to_file target_path, import_line
|
|
100
|
+
end
|
|
101
|
+
end
|
|
61
102
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
gsub_file index_path.to_s, utmr_pattern, fork_block
|
|
66
|
-
say "✅ Swapped UTMR modal registration for the forked null-safe controller (UTMR npm package still imported for side-effects).", :green
|
|
103
|
+
def insert_registration(target_path, register_line, content)
|
|
104
|
+
if content.match?(/Application\.start\(\)\n/)
|
|
105
|
+
insert_into_file target_path, register_line, after: /Application\.start\(\)\n/
|
|
67
106
|
else
|
|
68
|
-
|
|
69
|
-
say " Add these lines to controllers/index.js manually, replacing any existing UTMR modal registration:", :yellow
|
|
70
|
-
fork_block.each_line { |line| say " #{line.rstrip}", :cyan }
|
|
107
|
+
append_to_file target_path, register_line
|
|
71
108
|
end
|
|
72
109
|
end
|
|
73
110
|
|
|
74
|
-
def
|
|
75
|
-
|
|
76
|
-
say "Rebuild your JS bundle and restart Rails.", :yellow
|
|
111
|
+
def upstream_import_pattern
|
|
112
|
+
/^import\s*\{\s*UltimateTurboModalController\s*\}\s*from\s*["']ultimate_turbo_modal["']\s*\n/
|
|
77
113
|
end
|
|
78
114
|
|
|
79
|
-
|
|
115
|
+
def upstream_register_pattern
|
|
116
|
+
/^application\.register\(\s*["']modal["']\s*,\s*UltimateTurboModalController\s*\)\s*\n?/
|
|
117
|
+
end
|
|
80
118
|
|
|
81
|
-
def say_manual_snippet
|
|
82
|
-
say "\n❌ Could not find
|
|
119
|
+
def say_manual_snippet
|
|
120
|
+
say "\n❌ Could not find a Stimulus controllers file.", :red
|
|
83
121
|
say " Register the controllers manually in your Stimulus setup.", :yellow
|
|
84
122
|
end
|
|
85
123
|
end
|
|
@@ -1,302 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// The only substantive change is that event dispatch now falls back to
|
|
4
|
-
// `this.element` when no ancestor <turbo-frame> is present, so the controller
|
|
5
|
-
// works for dialogs that are not rendered inside a Turbo Frame (e.g., static
|
|
6
|
-
// modals cloned from a <template>). Without this fallback, `hideModal`
|
|
7
|
-
// throws the first time and the `hidingModal` guard traps subsequent clicks.
|
|
8
|
-
//
|
|
9
|
-
// The version-mismatch console warning from UTMR is also removed here — our
|
|
10
|
-
// fork has its own version that won't match UTMR's, so the warning would
|
|
11
|
-
// fire on every open.
|
|
12
|
-
import { Controller } from "@hotwired/stimulus"
|
|
13
|
-
|
|
14
|
-
export default class extends Controller {
|
|
15
|
-
static targets = ["container", "content"]
|
|
16
|
-
static values = {
|
|
17
|
-
advanceUrl: String,
|
|
18
|
-
allowedClickOutsideSelector: String
|
|
19
|
-
}
|
|
1
|
+
import { UltimateTurboModalController } from "ultimate_turbo_modal"
|
|
20
2
|
|
|
3
|
+
export default class extends UltimateTurboModalController {
|
|
21
4
|
connect() {
|
|
22
|
-
|
|
23
|
-
this.turboFrame = this.element.closest('turbo-frame');
|
|
24
|
-
this.hidingModal = this.containerTarget.hasAttribute('data-closing');
|
|
25
|
-
this.originalUrl = window.location.href;
|
|
26
|
-
|
|
27
|
-
if (this.hidingModal) {
|
|
28
|
-
this.#resumeClosing();
|
|
29
|
-
} else if (!this.containerTarget.open) {
|
|
30
|
-
this.showModal();
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
this.popstateHandler = () => {
|
|
34
|
-
if (this.#hasHistoryAdvanced()) {
|
|
35
|
-
this.#resetHistoryAdvanced();
|
|
36
|
-
this.#immediateCleanup();
|
|
37
|
-
}
|
|
38
|
-
};
|
|
39
|
-
window.addEventListener('popstate', this.popstateHandler);
|
|
5
|
+
super.connect()
|
|
40
6
|
|
|
41
|
-
this.
|
|
42
|
-
|
|
43
|
-
};
|
|
44
|
-
document.addEventListener('turbo:before-cache', this.beforeCacheHandler);
|
|
45
|
-
|
|
46
|
-
window.modal = this;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
disconnect() {
|
|
50
|
-
this.#cancelEnter();
|
|
51
|
-
this.#cancelResumeClosing();
|
|
52
|
-
this.#cancelCloseCleanup();
|
|
53
|
-
window.removeEventListener('popstate', this.popstateHandler);
|
|
54
|
-
document.removeEventListener('turbo:before-cache', this.beforeCacheHandler);
|
|
55
|
-
window.modal = undefined;
|
|
7
|
+
this.frameless = !this.turboFrame
|
|
8
|
+
if (this.frameless) this.turboFrame = this.element
|
|
56
9
|
}
|
|
57
10
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
this.containerTarget.removeAttribute('data-enter-ready');
|
|
61
|
-
this.containerTarget.removeAttribute('data-entered');
|
|
62
|
-
if (this.containerTarget.open) this.containerTarget.close();
|
|
63
|
-
const scrollX = window.scrollX;
|
|
64
|
-
const scrollY = window.scrollY;
|
|
65
|
-
this.containerTarget.showModal();
|
|
66
|
-
window.scrollTo(scrollX, scrollY);
|
|
67
|
-
this.#queueEnter();
|
|
11
|
+
submitEnd(event) {
|
|
12
|
+
const response = event.detail.fetchResponse?.response
|
|
68
13
|
|
|
69
|
-
if (this.
|
|
70
|
-
this
|
|
71
|
-
|
|
14
|
+
if (this.frameless && event.detail.success && response?.redirected) {
|
|
15
|
+
this.hideModalWithPromise({ skipHistoryBack: true }).then(() => {
|
|
16
|
+
window.Turbo.visit(response.url)
|
|
17
|
+
})
|
|
18
|
+
return
|
|
72
19
|
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
hideModal({ skipHistoryBack = false } = {}) {
|
|
76
|
-
if (this.hidingModal) return false
|
|
77
|
-
this.hidingModal = true;
|
|
78
|
-
|
|
79
|
-
let event = new Event('modal:closing', { cancelable: true });
|
|
80
|
-
this.#eventTarget().dispatchEvent(event);
|
|
81
|
-
if (event.defaultPrevented) {
|
|
82
|
-
this.hidingModal = false;
|
|
83
|
-
return false
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
this._skipHistoryBack = skipHistoryBack;
|
|
87
|
-
this.#resetModalElement();
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
hideModalWithPromise(options = {}) {
|
|
91
|
-
return new Promise((resolve) => {
|
|
92
|
-
const target = this.#eventTarget();
|
|
93
|
-
const handler = () => {
|
|
94
|
-
target.removeEventListener('modal:closed', handler);
|
|
95
|
-
resolve();
|
|
96
|
-
};
|
|
97
|
-
target.addEventListener('modal:closed', handler);
|
|
98
|
-
if (this.hideModal(options) === false) {
|
|
99
|
-
target.removeEventListener('modal:closed', handler);
|
|
100
|
-
resolve();
|
|
101
|
-
}
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
hide() { this.hideModal(); }
|
|
106
|
-
close() { this.hideModal(); }
|
|
107
|
-
|
|
108
|
-
refreshPage() {
|
|
109
|
-
window.Turbo.visit(window.location.href, { action: "replace" });
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
submitEnd(e) {
|
|
113
|
-
if (e.detail.success) {
|
|
114
|
-
const response = e.detail.fetchResponse?.response;
|
|
115
|
-
if (response?.redirected) {
|
|
116
|
-
// With a <turbo-frame> ancestor, UTMR's turbo:frame-missing handler
|
|
117
|
-
// consumes this URL and performs the smooth redirect. Without one,
|
|
118
|
-
// that handler never fires — close the modal and navigate directly.
|
|
119
|
-
if (!this.turboFrame) {
|
|
120
|
-
this.hideModalWithPromise({ skipHistoryBack: true }).then(() => {
|
|
121
|
-
window.Turbo.visit(response.url);
|
|
122
|
-
});
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
this._pendingRedirectUrl = response.url;
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
this.hideModal();
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
cancelEvent(e) {
|
|
133
|
-
e.preventDefault();
|
|
134
|
-
this.hideModal();
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
dialogClicked(e) {
|
|
138
|
-
if (!this.hasContentTarget) return;
|
|
139
|
-
if (this.contentTarget.contains(e.target)) return;
|
|
140
|
-
if (this.#isAllowedOutsideClick(e.target)) return;
|
|
141
|
-
this.hideModal();
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
#eventTarget() {
|
|
145
|
-
return this.turboFrame ?? this.element;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
#isAllowedOutsideClick(target) {
|
|
149
|
-
if (!this.allowedClickOutsideSelectorValue) return false;
|
|
150
|
-
return target.closest(this.allowedClickOutsideSelectorValue) !== null;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
#resetModalElement() {
|
|
154
|
-
const historyWasAdvanced = this.#hasHistoryAdvanced();
|
|
155
|
-
this.containerTarget.dataset.utmrHistoryAdvanced = String(historyWasAdvanced);
|
|
156
|
-
this.containerTarget.dataset.utmrSkipHistoryBack = String(!!this._skipHistoryBack);
|
|
157
|
-
this.#applyClosingState();
|
|
158
|
-
this.#queueCloseCleanup(historyWasAdvanced);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
#resumeClosing() {
|
|
162
|
-
const historyWasAdvanced = this.containerTarget.dataset.utmrHistoryAdvanced == 'true';
|
|
163
|
-
this._skipHistoryBack = this.containerTarget.dataset.utmrSkipHistoryBack == 'true';
|
|
164
|
-
|
|
165
|
-
this.containerTarget.removeAttribute('data-closing');
|
|
166
|
-
this.containerTarget.setAttribute('data-enter-ready', '');
|
|
167
|
-
this.containerTarget.setAttribute('data-entered', '');
|
|
168
|
-
this.#cancelResumeClosing();
|
|
169
|
-
|
|
170
|
-
this.closeFrames = [];
|
|
171
|
-
const outerFrame = requestAnimationFrame(() => {
|
|
172
|
-
if (!this.containerTarget.isConnected) return;
|
|
173
|
-
|
|
174
|
-
const innerFrame = requestAnimationFrame(() => {
|
|
175
|
-
if (!this.containerTarget.isConnected) return;
|
|
176
|
-
this.#applyClosingState();
|
|
177
|
-
this.closeFrames = null;
|
|
178
|
-
this.#queueCloseCleanup(historyWasAdvanced);
|
|
179
|
-
});
|
|
180
|
-
this.closeFrames?.push(innerFrame);
|
|
181
|
-
});
|
|
182
|
-
this.closeFrames.push(outerFrame);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
#applyClosingState() {
|
|
186
|
-
this.containerTarget.setAttribute('data-closing', '');
|
|
187
|
-
this.containerTarget.setAttribute('data-enter-ready', '');
|
|
188
|
-
this.containerTarget.removeAttribute('data-entered');
|
|
189
|
-
this.#cancelEnter();
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
#queueCloseCleanup(historyWasAdvanced) {
|
|
193
|
-
const dialog = this.containerTarget;
|
|
194
|
-
const transitionTarget = this.#isDrawer()
|
|
195
|
-
? dialog.querySelector('#drawer-panel')
|
|
196
|
-
: dialog.querySelector('#modal-inner');
|
|
197
|
-
const closeTimeoutMs = this.#isDrawer() ? 750 : 300;
|
|
198
|
-
this.#cancelCloseCleanup();
|
|
199
|
-
|
|
200
|
-
let cleaned = false;
|
|
201
|
-
const cleanup = () => {
|
|
202
|
-
if (cleaned) return;
|
|
203
|
-
cleaned = true;
|
|
204
|
-
this.#cancelCloseCleanup();
|
|
205
|
-
window.removeEventListener('popstate', this.popstateHandler);
|
|
206
|
-
const target = this.#eventTarget();
|
|
207
|
-
try { dialog.close(); } catch (_) {}
|
|
208
|
-
try { target.removeAttribute("src"); } catch (_) {}
|
|
209
|
-
try { dialog.remove(); } catch (_) {}
|
|
210
|
-
delete dialog.dataset.utmrHistoryAdvanced;
|
|
211
|
-
delete dialog.dataset.utmrSkipHistoryBack;
|
|
212
|
-
this.#resetHistoryAdvanced();
|
|
213
|
-
try { target.dispatchEvent(new Event('modal:closed', { cancelable: false })); } catch (_) {}
|
|
214
|
-
|
|
215
|
-
if (historyWasAdvanced && !this._skipHistoryBack) history.back();
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
const onTransitionEnd = (e) => {
|
|
219
|
-
if (e.target === transitionTarget) cleanup();
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
this.closeTransitionHandler = onTransitionEnd;
|
|
223
|
-
dialog.addEventListener('transitionend', onTransitionEnd);
|
|
224
|
-
this.closeTimeout = setTimeout(cleanup, closeTimeoutMs);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
#immediateCleanup() {
|
|
228
|
-
this.#cancelEnter();
|
|
229
|
-
this.#cancelResumeClosing();
|
|
230
|
-
this.#cancelCloseCleanup();
|
|
231
|
-
const dialog = this.containerTarget;
|
|
232
|
-
const target = this.#eventTarget();
|
|
233
|
-
try { dialog.close(); } catch (_) {}
|
|
234
|
-
try { target.removeAttribute("src"); } catch (_) {}
|
|
235
|
-
try { dialog.remove(); } catch (_) {}
|
|
236
|
-
try { target.dispatchEvent(new Event('modal:closed', { cancelable: false })); } catch (_) {}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
#cleanupStaleDialogs() {
|
|
240
|
-
document.querySelectorAll('dialog#modal-container').forEach(d => {
|
|
241
|
-
if (d !== this.containerTarget) {
|
|
242
|
-
try { d.close(); } catch (_) {}
|
|
243
|
-
d.remove();
|
|
244
|
-
}
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
#isDrawer() {
|
|
249
|
-
return this.containerTarget.dataset.drawer !== undefined
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
#queueEnter() {
|
|
253
|
-
this.#cancelEnter();
|
|
254
|
-
|
|
255
|
-
this.enterFrames = [];
|
|
256
|
-
const outerFrame = requestAnimationFrame(() => {
|
|
257
|
-
if (!this.containerTarget.isConnected || this.containerTarget.hasAttribute('data-closing')) return;
|
|
258
|
-
this.containerTarget.setAttribute('data-enter-ready', '');
|
|
259
|
-
|
|
260
|
-
const innerFrame = requestAnimationFrame(() => {
|
|
261
|
-
if (!this.containerTarget.isConnected || this.containerTarget.hasAttribute('data-closing')) return;
|
|
262
|
-
this.containerTarget.setAttribute('data-entered', '');
|
|
263
|
-
this.enterFrames = null;
|
|
264
|
-
});
|
|
265
|
-
this.enterFrames?.push(innerFrame);
|
|
266
|
-
});
|
|
267
|
-
this.enterFrames.push(outerFrame);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
#cancelEnter() {
|
|
271
|
-
if (!this.enterFrames) return;
|
|
272
|
-
this.enterFrames.forEach(id => cancelAnimationFrame(id));
|
|
273
|
-
this.enterFrames = null;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
#cancelResumeClosing() {
|
|
277
|
-
if (!this.closeFrames) return;
|
|
278
|
-
this.closeFrames.forEach(id => cancelAnimationFrame(id));
|
|
279
|
-
this.closeFrames = null;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
#cancelCloseCleanup() {
|
|
283
|
-
clearTimeout(this.closeTimeout);
|
|
284
|
-
this.closeTimeout = null;
|
|
285
|
-
|
|
286
|
-
if (!this.closeTransitionHandler) return;
|
|
287
|
-
this.containerTarget.removeEventListener('transitionend', this.closeTransitionHandler);
|
|
288
|
-
this.closeTransitionHandler = null;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
#hasHistoryAdvanced() {
|
|
292
|
-
return document.body.getAttribute("data-turbo-modal-history-advanced") == "true"
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
#setHistoryAdvanced() {
|
|
296
|
-
return document.body.setAttribute("data-turbo-modal-history-advanced", "true")
|
|
297
|
-
}
|
|
298
20
|
|
|
299
|
-
|
|
300
|
-
document.body.removeAttribute("data-turbo-modal-history-advanced");
|
|
21
|
+
super.submitEnd(event)
|
|
301
22
|
}
|
|
302
23
|
}
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ultimate_static_modal
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Raghu Betina
|
|
@@ -15,18 +15,51 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version:
|
|
18
|
+
version: 3.2.1
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '4'
|
|
19
22
|
type: :runtime
|
|
20
23
|
prerelease: false
|
|
21
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
25
|
requirements:
|
|
23
26
|
- - ">="
|
|
24
27
|
- !ruby/object:Gem::Version
|
|
25
|
-
version:
|
|
28
|
+
version: 3.2.1
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '4'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: minitest
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - "~>"
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '6.0'
|
|
39
|
+
type: :development
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - "~>"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '6.0'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: rake
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - "~>"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '13.4'
|
|
53
|
+
type: :development
|
|
54
|
+
prerelease: false
|
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - "~>"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '13.4'
|
|
26
60
|
description: View helpers and Stimulus controllers for rendering ultimate_turbo_modal's
|
|
27
61
|
<dialog> chrome on content that is not loaded via a Turbo Frame. Reuses UTMR's flavor
|
|
28
|
-
classes
|
|
29
|
-
generator.
|
|
62
|
+
classes and extends its exported Stimulus controller with a small frameless adapter.
|
|
30
63
|
email:
|
|
31
64
|
- raghu@firstdraft.com
|
|
32
65
|
executables: []
|
|
@@ -49,7 +82,7 @@ licenses:
|
|
|
49
82
|
- MIT
|
|
50
83
|
metadata:
|
|
51
84
|
homepage_uri: https://github.com/firstdraft/ultimate_static_modal
|
|
52
|
-
source_code_uri: https://github.com/firstdraft/ultimate_static_modal
|
|
85
|
+
source_code_uri: https://github.com/firstdraft/ultimate_static_modal/tree/v0.2.0
|
|
53
86
|
changelog_uri: https://github.com/firstdraft/ultimate_static_modal/blob/main/CHANGELOG.md
|
|
54
87
|
rdoc_options: []
|
|
55
88
|
require_paths:
|