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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c6d76149c3291f4bec643d1062bc5e938a71d3f6559c5bc7b1ca59eb0d1443e
4
- data.tar.gz: e08791d480b1cd90d98cb331ef8a44f4656fe2b40d002c6b451c06aeada044bc
3
+ metadata.gz: 1c7878b97ecc68dd821faa210b908ba9aae901629b1ee512b2584b81e83fc87f
4
+ data.tar.gz: 8e4f833e1d104b5aeda473188934e1883800bdce41cf673416247bb412d164aa
5
5
  SHA512:
6
- metadata.gz: 7a58f03583f0574c4047b46bd6762f07ec09da3ecf6f991a76e56344a973c8f383f4f69e60b2462ebcbb30bf6ab5dd7d28f410c171996da686908b7df26318a4
7
- data.tar.gz: 89c2a1f36faf6bf8625ffc079cfab2c5781d0ce35abcae367fc4871625da05a2d6e06a51a015852b830ae5b89469202840aad186c2d1c570707e752b1e887ad7
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
- Static-content companion to [ultimate_turbo_modal](https://github.com/cmer/ultimate_turbo_modal) (UTMR).
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
- UTMR renders its `<dialog>` chrome only when the request carries a `Turbo-Frame` header. This gem adds view helpers that render the same chrome — and reuse UTMR's configured flavor classes — for modals and drawers whose content is *not* loaded via a Turbo Frame. Use it for help popovers, client-side confirms, navigation drawers, and other one-off modals that don't warrant their own route.
5
+ [![Gem Version](https://img.shields.io/gem/v/ultimate_static_modal.svg)](https://rubygems.org/gems/ultimate_static_modal)
6
6
 
7
- ## Installation
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
- This:
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
- 1. Copies a small `static-modal` Stimulus controller into `app/javascript/controllers/static_modal_controller.js` (clones a `<template>` into the DOM on click).
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
- Rebuild your JS bundle and restart Rails.
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
- ## Usage
52
+ ## Common scenarios
30
53
 
31
- Three view helpers cover the common pattern: parking the modal markup inside a `<template>`, plus a button that clones it on click.
54
+ ### Keyboard shortcuts / help dialog
32
55
 
33
56
  ```erb
34
- <%# Modal %>
35
- <%= static_modal_template("shortcuts", title: "Keyboard shortcuts") do |m| %>
36
- <% m.footer do %>
37
- <button type="button" data-action="modal#hideModal">Close</button>
38
- <% end %>
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
- <%# Drawer / offcanvas %>
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
- The trigger emits a `<button>` wired to the `static-modal` Stimulus controller:
70
+ ### Confirmation dialog
57
71
 
58
- ```html
59
- <button type="button" class="btn btn-primary"
60
- data-controller="static-modal"
61
- data-static-modal-id-value="shortcuts"
62
- data-action="click->static-modal#open">
63
- Keyboard shortcuts
64
- </button>
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
- When clicked, it clones the `<template>` content into `document.body`. UTMR's modal Stimulus controller (the forked copy) then connects to the cloned `<dialog>` and animates it open. ESC, the close button, and outside-clicks all dismiss as usual.
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
- ### Lower-level helpers
105
+ Because static drawers are not wrapped in a Turbo Frame, ordinary links navigate normally.
70
106
 
71
- If you need to render a dialog directly (no `<template>` wrap, no trigger), use:
107
+ ### Filter sidebar (no overlay)
72
108
 
73
109
  ```erb
74
- <%= static_modal(title: "") do |m| %>…<% end %>
75
- <%= static_drawer(position: :right, size: :md, title: "…") do |m| %>…<% end %>
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
- These accept every option UTMR's `modal()` / `drawer()` helpers accept, including the `m.title { … }` / `m.footer { … }` block DSL. The dialog opens immediately on connect, so they're typically only useful inside a `<template>` you'll clone yourself, or wrapped in your own conditional rendering.
115
+ ## Helpers
79
116
 
80
- ### Drawer sizes and positions
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
- `static_drawer` / `static_drawer_template` accept the same options UTMR supports: `position: :right` or `:left`, and `size: :xs`, `:sm`, `:md`, `:lg`, `:xl`, `:"2xl"`, `:full`, or any CSS length string.
125
+ All `**opts` accept everything UTMR's `modal()` / `drawer()` accept, including the block DSL (`m.title { }`, `m.footer { }`).
83
126
 
84
- ### Header, footer, dividers, overlay
127
+ ## Options
85
128
 
86
- All of UTMR's options pass through:
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
- - `title:` modal/drawer title text
89
- - `header: false` hide the header entirely
90
- - `header_divider: false` / `footer_divider: false` turn dividers off
91
- - `close_button: false` hide the close button
92
- - `overlay: false` undimmed backdrop
93
- - `padding: false` remove content padding
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
- ## How it works
143
+ ## Troubleshooting
96
144
 
97
- ```ruby
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
- That's the entire server-side mechanism: a runtime subclass of whatever flavor UTMR is configured with (Tailwind, vanilla, custom), with `view_template` overridden to skip UTMR's `turbo_frame?` early-return. Class constants, inline `<style>`, data attributes, and Phlex rendering all come straight from UTMR.
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
- ## Why we ship a forked controller
152
+ **The button has no background color (or text is invisible).**
112
153
 
113
- UTMR's Stimulus controller assumes every `<dialog>` it manages is wrapped in a `<turbo-frame>` (because every dialog UTMR itself renders *is* wrapped in one). Three places dispatch lifecycle events on that frame:
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
- ```js
116
- this.turboFrame.dispatchEvent(event); // throws if turboFrame is null
117
- ```
156
+ **`NoMethodError: undefined method 'static_modal_template' for #<...>`**
118
157
 
119
- For static modals, there is no enclosing frame. On the first close attempt, `hideModal` sets `this.hidingModal = true` and *then* throws on `dispatchEvent`. Every click after that is short-circuited by the `if (this.hidingModal) return false` guard, so the modal becomes un-closeable.
158
+ Restart Rails. The helper module is included into `ActionView::Base` by a Railtie at boot time.
120
159
 
121
- We could wrap our static dialogs in a sentinel `<turbo-frame id="modal">` that never navigates — and an earlier prototype did exactly that — but Turbo's frame routing then traps any `<a>` or `<button>` inside the modal and tries to swap their target into the layout's empty modal frame. Drawer nav links blanked out instead of navigating the page.
160
+ ## How it works (short version)
122
161
 
123
- Instead we ship a fork of `modal_controller.js` with a single substantive change: a `#eventTarget()` helper that returns `this.turboFrame ?? this.element`. All three dispatch sites route through it. We also added one branch to `submitEnd`: when a redirected form response comes back and there's no `<turbo-frame>` ancestor, close the modal with a promise and `Turbo.visit(response.url)` directly UTMR's normal flow defers this to a `turbo:frame-missing` handler that won't fire without a frame.
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
- The two patches are exercised by `javascript/modal_controller.test.js`, which also includes a regression guard ensuring the new `submitEnd` branch is correctly gated on `!this.turboFrame` (so UTMR's normal redirect flow is preserved when a frame *is* present).
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
- If UTMR adopts an equivalent change upstream, this fork goes away and the gem becomes a pure server-side add-on.
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. Used in production-adjacent projects and being dogfooded. The plan is to propose folding the null-safe controller behavior into UTMR itself; if that lands, this gem drops the forked controller and shrinks to just the view helpers.
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
- # JS unit tests for the forked controller
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
- The Ruby gem has no test suite. UTMR also has none, and the gem is small enough that the JS tests plus manual smoke-testing in a host app cover the load-bearing behavior.
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
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 Stimulus controllers (static_modal + forked modal) and wires them into controllers/index.js."
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 copy_forked_modal_controller
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 update_controllers_index
23
- index_path = Rails.root.join("app", "javascript", "controllers", "index.js")
22
+ def update_stimulus_controllers
23
+ target_path = stimulus_target_path
24
24
 
25
- unless File.exist?(index_path)
26
- say_manual_snippet(index_path)
25
+ unless target_path
26
+ say_manual_snippet
27
27
  return
28
28
  end
29
29
 
30
- content = File.read(index_path)
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
- # Add static-modal import + registration
33
- static_import = "import StaticModalController from \"./static_modal_controller\"\n"
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
- if content.include?("StaticModalController")
37
- say "⏩ static-modal controller already registered.", :blue
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
- if content.match?(/import .* from ["'](?:@hotwired\/stimulus|\.\/application)["']\n/)
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
- # Re-read after static-modal insert
50
- content = File.read(index_path)
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
- # Swap UTMR's modal registration for our forked controller
53
- utmr_pattern = /import\s*\{\s*UltimateTurboModalController\s*\}\s*from\s*["']ultimate_turbo_modal["']\s*\n\s*application\.register\(\s*["']modal["']\s*,\s*UltimateTurboModalController\s*\)\s*\n/
91
+ def insert_import(target_path, import_line, content)
92
+ import_anchor = /import .* from ["'](?:@hotwired\/stimulus|\.\/application)["']\n/
54
93
 
55
- fork_block = <<~JS
56
- import ModalController from "./modal_controller"
57
- // Side-effect import: turbo:frame-missing / before-frame-render / before-cache handlers
58
- import "ultimate_turbo_modal"
59
- application.register("modal", ModalController)
60
- JS
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
- if content.include?('import ModalController from "./modal_controller"')
63
- say "⏩ Forked modal controller already registered.", :blue
64
- elsif content.match?(utmr_pattern)
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
- say "⚠️ Could not find UTMR's modal registration to swap.", :yellow
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 show_readme
75
- say "\nUltimateStaticModal installation complete!", :magenta
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
- private
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(index_path)
82
- say "\n❌ Could not find #{index_path}.", :red
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
- // Forked from ultimate_turbo_modal's modal_controller.js.
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
- this.#cleanupStaleDialogs();
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.beforeCacheHandler = () => {
42
- this.containerTarget.remove();
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
- showModal() {
59
- this.containerTarget.removeAttribute('data-closing');
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.advanceUrlValue && !this.#hasHistoryAdvanced()) {
70
- this.#setHistoryAdvanced();
71
- history.pushState({}, "", this.advanceUrlValue);
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
- #resetHistoryAdvanced() {
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.1.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: '3.0'
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: '3.0'
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; ships a small null-safe fork of UTMR's modal controller via an install
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: