ultimate_static_modal 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9c6d76149c3291f4bec643d1062bc5e938a71d3f6559c5bc7b1ca59eb0d1443e
4
+ data.tar.gz: e08791d480b1cd90d98cb331ef8a44f4656fe2b40d002c6b451c06aeada044bc
5
+ SHA512:
6
+ metadata.gz: 7a58f03583f0574c4047b46bd6762f07ec09da3ecf6f991a76e56344a973c8f383f4f69e60b2462ebcbb30bf6ab5dd7d28f410c171996da686908b7df26318a4
7
+ data.tar.gz: 89c2a1f36faf6bf8625ffc079cfab2c5781d0ce35abcae367fc4871625da05a2d6e06a51a015852b830ae5b89469202840aad186c2d1c570707e752b1e887ad7
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-04-24
4
+
5
+ Initial release.
6
+
7
+ - View helpers for static (non-Turbo) modals and drawers that reuse
8
+ `ultimate_turbo_modal`'s Phlex chrome and configured flavor classes:
9
+ `static_modal`, `static_drawer`, `static_modal_template`,
10
+ `static_drawer_template`, `static_modal_trigger`.
11
+ - Install generator (`rails g ultimate_static_modal:install`) that copies a
12
+ `static-modal` Stimulus controller (template-cloner) into the host app and
13
+ registers it.
14
+ - Ships a forked copy of UTMR's `modal_controller.js` so dialogs that have no
15
+ enclosing `<turbo-frame>` can dispatch close events on `this.element` and
16
+ redirected form submissions can navigate via `Turbo.visit` instead of
17
+ dead-ending in a `turbo:frame-missing` handler that never fires. The host
18
+ app's UTMR npm package is still imported for its
19
+ `turbo:frame-missing` / `before-frame-render` / `before-cache` side-effects.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Raghu Betina
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # ultimate_static_modal
2
+
3
+ Static-content companion to [ultimate_turbo_modal](https://github.com/cmer/ultimate_turbo_modal) (UTMR).
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.
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ # Gemfile
11
+ gem "ultimate_turbo_modal" # follow its install instructions first
12
+ gem "ultimate_static_modal"
13
+ ```
14
+
15
+ After installing UTMR (`rails g ultimate_turbo_modal:install`), run our installer:
16
+
17
+ ```sh
18
+ bin/rails generate ultimate_static_modal:install
19
+ ```
20
+
21
+ This:
22
+
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.
26
+
27
+ Rebuild your JS bundle and restart Rails.
28
+
29
+ ## Usage
30
+
31
+ Three view helpers cover the common pattern: parking the modal markup inside a `<template>`, plus a button that clones it on click.
32
+
33
+ ```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>
42
+ </dl>
43
+ <% end %>
44
+
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 %>
52
+ Keyboard shortcuts
53
+ <% end %>
54
+ ```
55
+
56
+ The trigger emits a `<button>` wired to the `static-modal` Stimulus controller:
57
+
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>
65
+ ```
66
+
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.
68
+
69
+ ### Lower-level helpers
70
+
71
+ If you need to render a dialog directly (no `<template>` wrap, no trigger), use:
72
+
73
+ ```erb
74
+ <%= static_modal(title: "…") do |m| %>…<% end %>
75
+ <%= static_drawer(position: :right, size: :md, title: "…") do |m| %>…<% end %>
76
+ ```
77
+
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.
79
+
80
+ ### Drawer sizes and positions
81
+
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.
83
+
84
+ ### Header, footer, dividers, overlay
85
+
86
+ All of UTMR's options pass through:
87
+
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
94
+
95
+ ## How it works
96
+
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
+ ```
108
+
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.
110
+
111
+ ## Why we ship a forked controller
112
+
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:
114
+
115
+ ```js
116
+ this.turboFrame.dispatchEvent(event); // throws if turboFrame is null
117
+ ```
118
+
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.
120
+
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.
122
+
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.
124
+
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).
126
+
127
+ If UTMR adopts an equivalent change upstream, this fork goes away and the gem becomes a pure server-side add-on.
128
+
129
+ ## Status
130
+
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.
132
+
133
+ ## Development
134
+
135
+ ```sh
136
+ # JS unit tests for the forked controller
137
+ cd javascript && npm install && npm test
138
+ ```
139
+
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.
141
+
142
+ ## License
143
+
144
+ MIT — see [LICENSE.txt](LICENSE.txt).
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module UltimateStaticModal
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ desc "Installs UltimateStaticModal: copies Stimulus controllers (static_modal + forked modal) and wires them into controllers/index.js."
11
+
12
+ def copy_static_modal_controller
13
+ copy_file "static_modal_controller.js",
14
+ "app/javascript/controllers/static_modal_controller.js"
15
+ end
16
+
17
+ def copy_forked_modal_controller
18
+ copy_file "modal_controller.js",
19
+ "app/javascript/controllers/modal_controller.js"
20
+ end
21
+
22
+ def update_controllers_index
23
+ index_path = Rails.root.join("app", "javascript", "controllers", "index.js")
24
+
25
+ unless File.exist?(index_path)
26
+ say_manual_snippet(index_path)
27
+ return
28
+ end
29
+
30
+ content = File.read(index_path)
31
+
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"
35
+
36
+ if content.include?("StaticModalController")
37
+ say "⏩ static-modal controller already registered.", :blue
38
+ 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
47
+ end
48
+
49
+ # Re-read after static-modal insert
50
+ content = File.read(index_path)
51
+
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/
54
+
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
61
+
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
67
+ 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 }
71
+ end
72
+ end
73
+
74
+ def show_readme
75
+ say "\nUltimateStaticModal installation complete!", :magenta
76
+ say "Rebuild your JS bundle and restart Rails.", :yellow
77
+ end
78
+
79
+ private
80
+
81
+ def say_manual_snippet(index_path)
82
+ say "\n❌ Could not find #{index_path}.", :red
83
+ say " Register the controllers manually in your Stimulus setup.", :yellow
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,302 @@
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
+ }
20
+
21
+ 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);
40
+
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;
56
+ }
57
+
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();
68
+
69
+ if (this.advanceUrlValue && !this.#hasHistoryAdvanced()) {
70
+ this.#setHistoryAdvanced();
71
+ history.pushState({}, "", this.advanceUrlValue);
72
+ }
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
+
299
+ #resetHistoryAdvanced() {
300
+ document.body.removeAttribute("data-turbo-modal-history-advanced");
301
+ }
302
+ }
@@ -0,0 +1,17 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Clones the <template> referenced by data-static-modal-id-value into
4
+ // document.body. UTMR's "modal" Stimulus controller takes over once the
5
+ // cloned <dialog> is attached and animates the modal/drawer into view.
6
+ export default class extends Controller {
7
+ static values = { id: String }
8
+
9
+ open() {
10
+ const template = document.getElementById(this.idValue)
11
+ if (!template) {
12
+ console.warn(`[static-modal] No <template> found with id="${this.idValue}"`)
13
+ return
14
+ }
15
+ document.body.appendChild(template.content.cloneNode(true))
16
+ }
17
+ }
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UltimateStaticModal
4
+ module Helpers
5
+ module ViewHelper
6
+ def static_modal(**opts, &block)
7
+ render(UltimateStaticModal.new(**opts), &block)
8
+ end
9
+
10
+ def static_drawer(position: nil, size: nil, **options, &block)
11
+ cfg = UltimateTurboModal.configuration.drawer_config
12
+ position = UltimateTurboModal::Base.validate_drawer_position!(position || cfg.position)
13
+ size = UltimateTurboModal::Base.validate_drawer_size!(size || cfg.size)
14
+ static_modal(drawer_position: position, size: size, **options, &block)
15
+ end
16
+
17
+ def static_modal_template(id, **opts, &block)
18
+ content_tag(:template, id: id) { static_modal(**opts, &block) }
19
+ end
20
+
21
+ def static_drawer_template(id, **opts, &block)
22
+ content_tag(:template, id: id) { static_drawer(**opts, &block) }
23
+ end
24
+
25
+ def static_modal_trigger(template_id, **button_opts, &block)
26
+ data = button_opts.delete(:data) || {}
27
+ data = data.merge(
28
+ controller: [data[:controller], "static-modal"].compact.join(" "),
29
+ static_modal_id_value: template_id,
30
+ action: [data[:action], "click->static-modal#open"].compact.join(" ")
31
+ )
32
+
33
+ button_opts = {type: "button"}.merge(button_opts).merge(data: data)
34
+ button_tag(button_opts, &block)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+ require_relative "helpers/view_helper"
5
+
6
+ module UltimateStaticModal
7
+ class Railtie < Rails::Railtie
8
+ initializer "ultimate_static_modal.action_view" do
9
+ ActiveSupport.on_load(:action_view) do
10
+ include UltimateStaticModal::Helpers::ViewHelper
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UltimateStaticModal
4
+ VERSION = File.read(File.expand_path("../../VERSION", __dir__)).strip
5
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ultimate_turbo_modal"
4
+ require_relative "ultimate_static_modal/version"
5
+ require_relative "ultimate_static_modal/railtie" if defined?(Rails::Railtie)
6
+
7
+ module UltimateStaticModal
8
+ extend self
9
+
10
+ class Error < StandardError; end
11
+
12
+ def new(**opts)
13
+ static_modal_class.new(**opts)
14
+ end
15
+
16
+ def static_modal_class
17
+ flavor_class = UltimateTurboModal.modal_class
18
+ static_subclasses[flavor_class] ||= build_static_subclass(flavor_class)
19
+ end
20
+
21
+ private
22
+
23
+ def static_subclasses
24
+ @static_subclasses ||= {}
25
+ end
26
+
27
+ def build_static_subclass(flavor_class)
28
+ Class.new(flavor_class) do
29
+ def view_template(&block)
30
+ drawer? ? render_drawer(&block) : render_modal(&block)
31
+ end
32
+ end
33
+ end
34
+ end
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ultimate_static_modal
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Raghu Betina
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ultimate_turbo_modal
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '3.0'
26
+ description: View helpers and Stimulus controllers for rendering ultimate_turbo_modal's
27
+ <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.
30
+ email:
31
+ - raghu@firstdraft.com
32
+ executables: []
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - CHANGELOG.md
37
+ - LICENSE.txt
38
+ - README.md
39
+ - VERSION
40
+ - lib/generators/ultimate_static_modal/install_generator.rb
41
+ - lib/generators/ultimate_static_modal/templates/modal_controller.js
42
+ - lib/generators/ultimate_static_modal/templates/static_modal_controller.js
43
+ - lib/ultimate_static_modal.rb
44
+ - lib/ultimate_static_modal/helpers/view_helper.rb
45
+ - lib/ultimate_static_modal/railtie.rb
46
+ - lib/ultimate_static_modal/version.rb
47
+ homepage: https://github.com/firstdraft/ultimate_static_modal
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ homepage_uri: https://github.com/firstdraft/ultimate_static_modal
52
+ source_code_uri: https://github.com/firstdraft/ultimate_static_modal
53
+ changelog_uri: https://github.com/firstdraft/ultimate_static_modal/blob/main/CHANGELOG.md
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '3.2'
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 4.0.3
69
+ specification_version: 4
70
+ summary: Static-content companion to ultimate_turbo_modal.
71
+ test_files: []