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 +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE.txt +21 -0
- data/README.md +144 -0
- data/VERSION +1 -0
- data/lib/generators/ultimate_static_modal/install_generator.rb +87 -0
- data/lib/generators/ultimate_static_modal/templates/modal_controller.js +302 -0
- data/lib/generators/ultimate_static_modal/templates/static_modal_controller.js +17 -0
- data/lib/ultimate_static_modal/helpers/view_helper.rb +38 -0
- data/lib/ultimate_static_modal/railtie.rb +14 -0
- data/lib/ultimate_static_modal/version.rb +5 -0
- data/lib/ultimate_static_modal.rb +34 -0
- metadata +71 -0
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,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: []
|