hotwire-astra-ui 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: 1d6d9fe4802aac692c58a1dad36a6613cf42220f13636c01183dff8ab09c4d60
4
+ data.tar.gz: 2f45ead1f195c0dfa698fb34cc50e763b067243c0dcb17d04271933b8b26b8b9
5
+ SHA512:
6
+ metadata.gz: 03beaf5ee10b1cc94703c64526eaad38ead87dc8349a6bff927908e624e75380da5b21e8296997de4fdb544d790ac549cad4101ac62b771a24872918a25bebdc
7
+ data.tar.gz: a56a0cd7cef6920f6e71779321a9897234285ce3f2b1672e846fadc2f30841544a01edec0f80cdf3fa3cd8d582a4d2af0ff7335e67b106ecf50c50fbce85d39c
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Fernand Arioja
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,360 @@
1
+ # Hotwire Astra UI 🌌
2
+
3
+ **Hotwire Astra UI** is a **production-ready, accessible modal system for Rails** built with **Hotwire (Turbo + Stimulus)** and **Importmap**.
4
+
5
+ It enables **fully server-rendered modals** using Turbo Frames, with **zero client-side configuration** and **no JavaScript frameworks**.
6
+
7
+ If you are building Rails 7+ applications and want predictable, scalable modal behavior without SPA complexity, Astra UI is designed for you.
8
+
9
+ ---
10
+
11
+ ## ✨ Features
12
+
13
+ ### 🔌 Zero-JavaScript Setup
14
+ Designed for **Rails 7+**, **Hotwire**, and **Importmap**.
15
+ Install the gem, run the generator, and start rendering modals immediately.
16
+
17
+ No client state. No bundlers. No framework lock-in.
18
+
19
+ ---
20
+
21
+ ### 🪜 Recursive (Stacked) Modals
22
+ Open modals **on top of modals** using nested Turbo Frames.
23
+
24
+ Each modal layer:
25
+ - is independently closable
26
+ - maintains its own lifecycle
27
+ - works with normal Rails controllers
28
+
29
+ Infinite stacking, zero configuration.
30
+
31
+ ---
32
+
33
+ ### 🎞️ CSS-Driven Animations
34
+ Entry and exit transitions are powered by **CSS variables**, not JavaScript.
35
+
36
+ - Works with Tailwind or plain CSS
37
+ - Respects `prefers-reduced-motion`
38
+ - Fully overrideable
39
+
40
+ ---
41
+
42
+ ### 🔄 Automatic Close on Turbo Success
43
+ Modals close automatically after successful Turbo form submissions.
44
+
45
+ No callbacks. No client logic.
46
+ Just follow the Turbo lifecycle.
47
+
48
+ ---
49
+
50
+ ### 🎨 Headless UI Design
51
+ Astra UI ships with safe defaults, but **nothing is opinionated**.
52
+
53
+ - Override everything with CSS variables
54
+ - Compatible with design systems
55
+ - No forced colors, spacing, or typography
56
+
57
+ ---
58
+
59
+ ### ♿ Accessibility-First
60
+ - Proper dialog semantics
61
+ - Keyboard navigation
62
+ - Focus management
63
+ - ESC and backdrop handling
64
+
65
+ ---
66
+
67
+ ## 📦 Installation
68
+
69
+ Add the gem to your `Gemfile`:
70
+
71
+ ```ruby
72
+ gem "hotwire-astra-ui", "0.1.0"
73
+ ```
74
+
75
+ Install and run the generator:
76
+
77
+ ```bash
78
+ bundle install
79
+ bin/rails generate hotwire_astra_ui:install
80
+ ```
81
+
82
+ The installer registers the Stimulus controller for you. If your app does not have `app/javascript/controllers/index.js`, add this registration manually:
83
+
84
+ ```js
85
+ import HotwireAstraUiModalController from "hotwire_astra_ui/modal_controller"
86
+ application.register("hotwire-astra-ui-modal", HotwireAstraUiModalController)
87
+ ```
88
+
89
+ The installer adds a persistent Turbo Frame to your layout:
90
+
91
+ ```erb
92
+ <%= turbo_frame_tag "astra_modal" %>
93
+ ```
94
+
95
+ This frame is used to render modal content.
96
+
97
+ ## 📦 npm Package (JS + CSS)
98
+
99
+ The JS-only shell is also published to npm:
100
+
101
+ ```bash
102
+ npm install @digi-archive/hotwire-astra-ui
103
+ ```
104
+
105
+ If you want to pin via Importmap (using the jspm CDN):
106
+
107
+ ```ruby
108
+ # config/importmap.rb
109
+ pin "@digi-archive/hotwire-astra-ui", to: "https://ga.jspm.io/npm:@digi-archive/hotwire-astra-ui@0.1.0/app/javascript/hotwire-astra-ui.js"
110
+ ```
111
+
112
+ Register the controller:
113
+
114
+ ```js
115
+ import HotwireAstraUiModalController from "@digi-archive/hotwire-astra-ui"
116
+ application.register("hotwire-astra-ui-modal", HotwireAstraUiModalController)
117
+ ```
118
+
119
+ Load the CSS from the same CDN:
120
+
121
+ ```erb
122
+ <link rel="stylesheet" href="https://ga.jspm.io/npm:@digi-archive/hotwire-astra-ui@0.1.0/hotwire-astra-ui.css">
123
+ ```
124
+
125
+ ## 🧩 JS-Only (Importmap Pin)
126
+
127
+ If you only want the **Stimulus controller** (and will provide your own HTML/CSS), you can pin the JS directly with Importmap from the gem:
128
+
129
+ ```ruby
130
+ # config/importmap.rb
131
+ pin "hotwire_astra_ui/modal_controller", to: ""
132
+ ```
133
+
134
+ Register the controller:
135
+
136
+ ```js
137
+ import HotwireAstraUiModalController from "hotwire_astra_ui/modal_controller"
138
+ application.register("hotwire-astra-ui-modal", HotwireAstraUiModalController)
139
+ ```
140
+
141
+ You must also provide:
142
+
143
+ - The modal HTML structure with the correct `data-controller` and target attributes
144
+ - The CSS (either copy `app/assets/stylesheets/hotwire/astra_ui/astra_ui.css` or reimplement it)
145
+
146
+ Example HTML structure (JS-only):
147
+
148
+ ```erb
149
+ <div data-controller="hotwire-astra-ui-modal"
150
+ data-hotwire-astra-ui-modal-target="backdrop"
151
+ data-action="click->hotwire-astra-ui-modal#backdropClick"
152
+ class="astra-modal-backdrop astra-default">
153
+
154
+ <div class="astra-modal-container astra-modal-size-md"
155
+ data-hotwire-astra-ui-modal-target="container"
156
+ data-action="click->hotwire-astra-ui-modal#stopPropagation"
157
+ role="dialog"
158
+ aria-modal="true"
159
+ aria-label="Example Dialog"
160
+ tabindex="-1">
161
+ <div class="astra-modal-header">
162
+ <h2>Example Dialog</h2>
163
+ <button type="button" data-action="hotwire-astra-ui-modal#close" class="astra-close-btn" aria-label="Close dialog">&times;</button>
164
+ </div>
165
+
166
+ <div class="astra-modal-body">
167
+ Your content goes here.
168
+ </div>
169
+ </div>
170
+ </div>
171
+ ```
172
+
173
+ ### 📦 npm + Importmap (JS + CSS)
174
+
175
+ If you publish the npm package `@digi-archive/hotwire-astra-ui`, you can pin it via Importmap using the jspm CDN:
176
+
177
+ ```ruby
178
+ # config/importmap.rb
179
+ pin "@digi-archive/hotwire-astra-ui", to: "https://ga.jspm.io/npm:@digi-archive/hotwire-astra-ui@0.1.0/app/javascript/hotwire-astra-ui.js"
180
+ ```
181
+
182
+ Register the controller:
183
+
184
+ ```js
185
+ import HotwireAstraUiModalController from "@digi-archive/hotwire-astra-ui"
186
+ application.register("hotwire-astra-ui-modal", HotwireAstraUiModalController)
187
+ ```
188
+
189
+ To load the CSS from npm, include the stylesheet from the same CDN:
190
+
191
+ ```erb
192
+ <link rel="stylesheet" href="https://ga.jspm.io/npm:@digi-archive/hotwire-astra-ui@0.1.0/hotwire-astra-ui.css">
193
+ ```
194
+
195
+ ## 🎨 Styles
196
+
197
+ Import the base stylesheet:
198
+
199
+ ```css
200
+ /*
201
+ *= require hotwire_astra_ui/astra_ui
202
+ */
203
+ ```
204
+
205
+ All visual customization is done via CSS variables.
206
+
207
+ ## 🚀 Basic Usage
208
+
209
+ 1. Trigger the Modal
210
+
211
+ Target the modal Turbo Frame:
212
+
213
+ ```erb
214
+ <%= link_to "New Post", new_post_path, data: { turbo_frame: "astra_modal" } %>
215
+ ```
216
+
217
+ 2. Render the Modal in a View Template
218
+ ```ruby
219
+ <%= astra_modal(title: "Create a New Post") do %>
220
+ <%= render partial: "form" %>
221
+ <% end %>
222
+ ```
223
+
224
+ That’s it.
225
+ No JavaScript. No client state.
226
+
227
+ ## 🧩 Advanced Usage
228
+
229
+ ### 🧰 Component Parameters
230
+
231
+ `Hotwire::AstraUi::ModalComponent.new` accepts:
232
+
233
+ - `title:` string or nil. If present, renders the header and provides `aria-labelledby`.
234
+ - `id:` Turbo Frame id (default: `"astra_modal"`).
235
+ - `theme_class:` CSS class applied to the backdrop (default: `"astra-default"`).
236
+ - `aria_label:` used when `title` is nil (default: `"Dialog"`).
237
+ - `size:` one of `:sm`, `:md`, `:lg`, `:xl` (default: `:md`).
238
+ - `backdrop_close:` boolean for backdrop click to close (default: `true`).
239
+ - `return_focus:` CSS selector to focus after close (default: `nil`).
240
+
241
+ ### 🪄 Convenience Helper
242
+
243
+ Render from a view:
244
+
245
+ ```erb
246
+ <%= astra_modal(title: "Create a New Post") do %>
247
+ <%= render partial: "form" %>
248
+ <% end %>
249
+ ```
250
+
251
+ ### 🔁 Stacked (Nested) Modals
252
+
253
+ Open a modal from inside another modal by targeting the next frame:
254
+
255
+ ```erb
256
+ <%= link_to "Confirm Delete",
257
+ confirm_delete_path,
258
+ data: { turbo_frame: "astra_modal_next" } %>
259
+ ```
260
+
261
+ Template:
262
+
263
+ ```erb
264
+ <%= astra_modal(title: "Are you sure?", id: "astra_modal_next") do %>
265
+ This action cannot be undone.
266
+ <% end %>
267
+ ```
268
+
269
+ Each modal layer remains isolated and predictable.
270
+
271
+ ### 🔒 Close Modals from the Server (Turbo Streams)
272
+
273
+ Close the modal directly from the server:
274
+
275
+ ```erb
276
+ <%= close_astra_modal_tag %>
277
+ ```
278
+
279
+ Close a specific frame by id:
280
+
281
+ ```erb
282
+ <%= close_astra_modal_tag(id: "astra_modal_next") %>
283
+ ```
284
+
285
+ Useful for:
286
+
287
+ - form submissions
288
+ - background jobs
289
+ - multi-step workflows
290
+
291
+ ### 🧱 Size Variants
292
+
293
+ Set a modal size with `size:`:
294
+
295
+ ```ruby
296
+ render Hotwire::AstraUi::ModalComponent.new(title: "Large Modal", size: :lg)
297
+ ```
298
+
299
+ Available sizes: `:sm`, `:md`, `:lg`, `:xl`.
300
+
301
+ ### 🧲 Backdrop Click & Focus Return
302
+
303
+ Disable backdrop click to close:
304
+
305
+ ```ruby
306
+ render Hotwire::AstraUi::ModalComponent.new(title: "Locked", backdrop_close: false)
307
+ ```
308
+
309
+ Return focus to a specific element after close:
310
+
311
+ ```ruby
312
+ render Hotwire::AstraUi::ModalComponent.new(title: "Edit", return_focus: "#edit-button")
313
+ ```
314
+
315
+ ## 🎨 Customization (CSS Variables)
316
+
317
+ Override the look without touching gem code:
318
+
319
+ ```css
320
+ :root {
321
+ --astra-modal-bg: #1e293b;
322
+ --astra-modal-radius: 0px;
323
+ --astra-backdrop-blur: 10px;
324
+ --astra-transition-duration: 500ms;
325
+ }
326
+ ```
327
+
328
+ Works with:
329
+
330
+ - Tailwind CSS
331
+ - Dark mode
332
+ - Design tokens
333
+ - Enterprise UI standards
334
+
335
+ ## 🧪 Development
336
+
337
+ Run the dummy app locally:
338
+
339
+ ```bash
340
+ cd test/dummy
341
+ bin/rails s
342
+ ```
343
+
344
+ ## ✅ Testing
345
+
346
+ Run the test suite:
347
+
348
+ ```bash
349
+ bin/test
350
+ ```
351
+
352
+ Or directly via Rake:
353
+
354
+ ```bash
355
+ bundle exec rake test
356
+ ```
357
+
358
+
359
+ ## License
360
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,14 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
7
+ require "rake/testtask"
8
+
9
+ Rake::TestTask.new(:test) do |t|
10
+ t.libs << "test"
11
+ t.pattern = "test/**/*_test.rb"
12
+ end
13
+
14
+ task default: :test
@@ -0,0 +1,104 @@
1
+ /* Hotwire Astra UI - Core Styles
2
+ Customization: Override these variables in your application.css
3
+ */
4
+ :root {
5
+ /* Layout & Depth */
6
+ --astra-modal-z: 1000;
7
+ --astra-modal-max-width: 500px;
8
+ --astra-modal-radius: 0.75rem;
9
+ --astra-modal-bg: #ffffff;
10
+ --astra-backdrop-color: rgba(15, 23, 42, 0.6);
11
+ --astra-backdrop-blur: 4px;
12
+
13
+ /* Animation Timings */
14
+ --astra-transition-timing: cubic-bezier(0.4, 0, 0.2, 1);
15
+ --astra-transition-duration: 300ms;
16
+ }
17
+
18
+ /* --- Backdrop --- */
19
+ .astra-modal-backdrop {
20
+ position: fixed;
21
+ inset: 0;
22
+ z-index: var(--astra-modal-z);
23
+ background-color: var(--astra-backdrop-color);
24
+ backdrop-filter: blur(var(--astra-backdrop-blur));
25
+ display: flex;
26
+ align-items: center;
27
+ justify-content: center;
28
+ padding: 1rem;
29
+ width: 100%;
30
+
31
+ /* Initial state for animation */
32
+ opacity: 0;
33
+ transition: opacity var(--astra-transition-duration) var(--astra-transition-timing);
34
+ }
35
+
36
+ .astra-backdrop-enter-active {
37
+ opacity: 1;
38
+ }
39
+
40
+ .astra-modal-container {
41
+ width: 100%;
42
+ max-width: var(--astra-modal-max-width);
43
+ border-radius: var(--astra-modal-radius);
44
+ background: var(--astra-modal-bg);
45
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
46
+ overflow: hidden;
47
+
48
+ /* Initial state for animation */
49
+ opacity: 0;
50
+ transform: translateY(20px) scale(0.95);
51
+ transition:
52
+ opacity var(--astra-transition-duration) var(--astra-transition-timing),
53
+ transform var(--astra-transition-duration) var(--astra-transition-timing);
54
+ }
55
+
56
+ .astra-modal-size-sm { --astra-modal-max-width: 360px; }
57
+ .astra-modal-size-md { --astra-modal-max-width: 520px; }
58
+ .astra-modal-size-lg { --astra-modal-max-width: 720px; }
59
+ .astra-modal-size-xl { --astra-modal-max-width: 960px; }
60
+
61
+ .astra-enter-active {
62
+ opacity: 1;
63
+ transform: translateY(0) scale(1);
64
+ }
65
+
66
+ /* --- Content Areas --- */
67
+ .astra-modal-header {
68
+ padding: 1.25rem;
69
+ border-bottom: 1px solid #e2e8f0;
70
+ display: flex;
71
+ align-items: center;
72
+ justify-content: space-between;
73
+ }
74
+
75
+ .astra-modal-header h2 {
76
+ margin: 0;
77
+ font-size: 1.25rem;
78
+ font-weight: 600;
79
+ color: #1e293b;
80
+ }
81
+
82
+ .astra-modal-body {
83
+ padding: 1.5rem;
84
+ }
85
+
86
+ .astra-close-btn {
87
+ cursor: pointer;
88
+ background: transparent;
89
+ border: none;
90
+ font-size: 1.5rem;
91
+ color: #94a3b8;
92
+ line-height: 1;
93
+ }
94
+
95
+ .astra-close-btn:hover {
96
+ color: #475569;
97
+ }
98
+
99
+ @media (prefers-reduced-motion: reduce) {
100
+ .astra-modal-backdrop,
101
+ .astra-modal-container {
102
+ transition: none;
103
+ }
104
+ }
@@ -0,0 +1,31 @@
1
+ <%= helpers.turbo_frame_tag @id, data: { turbo_action: "none" } do %>
2
+ <div data-controller="hotwire-astra-ui-modal"
3
+ data-hotwire-astra-ui-modal-backdrop-close-value="<%= backdrop_close_value %>"
4
+ <%= return_focus.present? ? "data-hotwire-astra-ui-modal-return-focus-value=\"#{return_focus}\"" : "" %>
5
+ data-hotwire-astra-ui-modal-target="backdrop"
6
+ data-action="click->hotwire-astra-ui-modal#backdropClick"
7
+ class="astra-modal-backdrop <%= @theme_class %>">
8
+
9
+ <div class="astra-modal-container <%= size_class %>"
10
+ data-hotwire-astra-ui-modal-target="container"
11
+ data-action="click->hotwire-astra-ui-modal#stopPropagation"
12
+ role="dialog"
13
+ aria-modal="true"
14
+ <%= @title.present? ? "aria-labelledby=\"#{title_id}\"" : "aria-label=\"#{aria_label}\"" %>
15
+ tabindex="-1">
16
+ <div class="astra-modal-header">
17
+ <% if @title.present? %>
18
+ <h2 id="<%= title_id %>"><%= @title %></h2>
19
+ <% end %>
20
+ <button type="button" data-action="hotwire-astra-ui-modal#close" class="astra-close-btn" aria-label="Close dialog">&times;</button>
21
+ </div>
22
+
23
+ <div class="astra-modal-body">
24
+ <%= content %>
25
+
26
+ <%# This is the "landing zone" for a potential second modal %>
27
+ <%= helpers.turbo_frame_tag next_modal_id %>
28
+ </div>
29
+ </div>
30
+ </div>
31
+ <% end %>
@@ -0,0 +1,48 @@
1
+ module Hotwire::AstraUi
2
+ class ModalComponent < ViewComponent::Base
3
+ def initialize(
4
+ title: nil,
5
+ id: nil,
6
+ theme_class: "astra-default",
7
+ aria_label: "Dialog",
8
+ size: :md,
9
+ backdrop_close: true,
10
+ return_focus: nil
11
+ )
12
+ @title = title
13
+ # Default to the base 'astra_modal' if no specific ID is provided
14
+ @id = id || "astra_modal"
15
+ @theme_class = theme_class
16
+ @aria_label = aria_label
17
+ @size = size
18
+ @backdrop_close = backdrop_close
19
+ @return_focus = return_focus
20
+ end
21
+
22
+ def next_modal_id
23
+ "#{@id}_next"
24
+ end
25
+
26
+ def title_id
27
+ "#{@id}_title"
28
+ end
29
+
30
+ def aria_label
31
+ @aria_label
32
+ end
33
+
34
+ def size_class
35
+ return nil if @size.nil?
36
+
37
+ "astra-modal-size-#{@size}"
38
+ end
39
+
40
+ def backdrop_close_value
41
+ @backdrop_close ? "true" : "false"
42
+ end
43
+
44
+ def return_focus
45
+ @return_focus
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,19 @@
1
+ module Hotwire
2
+ module AstraUi
3
+ module ModalHelper
4
+ def astra_modal_frame_tag
5
+ turbo_frame_tag "astra_modal"
6
+ end
7
+
8
+ def astra_modal(title:, **options, &block)
9
+ render Hotwire::AstraUi::ModalComponent.new(title: title, **options) do
10
+ capture(&block)
11
+ end
12
+ end
13
+
14
+ def close_astra_modal_tag(id: "astra_modal")
15
+ turbo_stream.replace id, html: ""
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,68 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["container", "backdrop", "body"]
5
+ static values = {
6
+ title: String,
7
+ size: { type: String, default: "md" },
8
+ backdropClose: { type: Boolean, default: true }
9
+ }
10
+
11
+ connect() {
12
+ this.buildModal()
13
+ this.animateIn()
14
+ document.body.style.overflow = "hidden"
15
+ }
16
+
17
+ disconnect() {
18
+ document.body.style.overflow = "auto"
19
+ }
20
+
21
+ buildModal() {
22
+ const originalContent = this.element.innerHTML
23
+
24
+ this.element.innerHTML = `
25
+ <div class="astra-modal-backdrop" data-astra-ui-shell-target="backdrop" data-action="click->astra-ui-shell#backdropClick">
26
+ <div class="astra-modal-container astra-modal-size-${this.sizeValue}" data-astra-ui-shell-target="container" data-action="click->astra-ui-shell#stopPropagation">
27
+ <div class="astra-header">
28
+ <h3>${this.titleValue || ""}</h3>
29
+ <button type="button" data-action="astra-ui-shell#close" style="cursor:pointer;border:none;background:none;font-size:1.5rem;">&times;</button>
30
+ </div>
31
+ <div class="astra-body" data-astra-ui-shell-target="body">
32
+ ${originalContent}
33
+ </div>
34
+ </div>
35
+ </div>
36
+ `
37
+ }
38
+
39
+ animateIn() {
40
+ requestAnimationFrame(() => {
41
+ this.backdropTarget.classList.add("astra-enter-active")
42
+ this.containerTarget.classList.add("astra-enter-active")
43
+ })
44
+ }
45
+
46
+ backdropClick(e) {
47
+ if (!this.backdropCloseValue) return
48
+ if (e.target === this.backdropTarget) this.close()
49
+ }
50
+
51
+ stopPropagation(e) {
52
+ e.stopPropagation()
53
+ }
54
+
55
+ async close() {
56
+ this.backdropTarget.classList.remove("astra-enter-active")
57
+ this.containerTarget.classList.remove("astra-enter-active")
58
+ await new Promise(res => setTimeout(res, 300))
59
+
60
+ const frame = this.element.closest("turbo-frame")
61
+ if (frame) {
62
+ frame.src = null
63
+ frame.innerHTML = ""
64
+ } else {
65
+ this.element.innerHTML = ""
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,163 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["container", "backdrop"]
5
+ static values = {
6
+ backdropClose: { type: Boolean, default: true },
7
+ returnFocus: String
8
+ }
9
+
10
+ connect() {
11
+ this.previouslyFocused = document.activeElement
12
+ this.handleKeydown = this.handleKeydown.bind(this)
13
+ document.addEventListener("keydown", this.handleKeydown)
14
+
15
+ this.adjustZIndex()
16
+ this.animateIn()
17
+ this.lockScroll()
18
+ this.focusFirst()
19
+ }
20
+
21
+ disconnect() {
22
+ document.removeEventListener("keydown", this.handleKeydown)
23
+ this.restoreFocus()
24
+ }
25
+
26
+ adjustZIndex() {
27
+ // Check how many modals are currently open
28
+ const openModals = this.openModals().length
29
+ if (openModals > 1) {
30
+ // Offset the Z-index so the new one is on top
31
+ const baseZ = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--astra-modal-z')) || 1000
32
+ this.element.style.zIndex = baseZ + (openModals * 10)
33
+ }
34
+ }
35
+
36
+ lockScroll() {
37
+ if (this.openModals().length === 1) {
38
+ document.body.style.overflow = "hidden"
39
+ }
40
+ }
41
+
42
+ animateIn() {
43
+ this.backdropTarget.classList.add("astra-backdrop-enter")
44
+
45
+ requestAnimationFrame(() => {
46
+ this.containerTarget.classList.add("astra-enter-active")
47
+ this.backdropTarget.classList.add("astra-backdrop-enter-active")
48
+ })
49
+ }
50
+
51
+ async close(e = null) {
52
+ if (e) e.preventDefault()
53
+ if (!this.isTopmost()) return
54
+
55
+ this.containerTarget.classList.remove("astra-enter-active")
56
+ this.backdropTarget.classList.remove("astra-backdrop-enter-active")
57
+
58
+ await new Promise(res => setTimeout(res, this.transitionDurationMs()))
59
+
60
+ // Important: Only clear the specific frame this modal lives in
61
+ const frame = this.element.closest("turbo-frame")
62
+ frame.src = null
63
+ frame.innerHTML = ""
64
+
65
+ // Only restore scrolling if this was the last modal
66
+ if (this.openModals().length === 0) {
67
+ document.body.style.overflow = "auto"
68
+ }
69
+ }
70
+
71
+ backdropClick(e) {
72
+ if (!this.backdropCloseValue) return
73
+ if (e.target === this.backdropTarget) {
74
+ this.close(e)
75
+ }
76
+ }
77
+
78
+ stopPropagation(e) {
79
+ e.stopPropagation()
80
+ }
81
+
82
+ handleKeydown(e) {
83
+ if (!this.isTopmost()) return
84
+
85
+ if (e.key === "Escape") {
86
+ e.preventDefault()
87
+ this.close()
88
+ return
89
+ }
90
+
91
+ if (e.key === "Tab") {
92
+ this.trapFocus(e)
93
+ }
94
+ }
95
+
96
+ isTopmost() {
97
+ const modals = this.openModals()
98
+ return modals.length > 0 && modals[modals.length - 1] === this.backdropTarget
99
+ }
100
+
101
+ openModals() {
102
+ return Array.from(document.querySelectorAll('.astra-modal-backdrop'))
103
+ }
104
+
105
+ focusableElements() {
106
+ return Array.from(
107
+ this.containerTarget.querySelectorAll(
108
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
109
+ )
110
+ )
111
+ }
112
+
113
+ focusFirst() {
114
+ const focusables = this.focusableElements()
115
+ if (focusables.length > 0) {
116
+ focusables[0].focus()
117
+ } else {
118
+ this.containerTarget.focus()
119
+ }
120
+ }
121
+
122
+ trapFocus(e) {
123
+ const focusables = this.focusableElements()
124
+ if (focusables.length === 0) {
125
+ e.preventDefault()
126
+ this.containerTarget.focus()
127
+ return
128
+ }
129
+
130
+ const first = focusables[0]
131
+ const last = focusables[focusables.length - 1]
132
+
133
+ if (e.shiftKey && document.activeElement === first) {
134
+ e.preventDefault()
135
+ last.focus()
136
+ } else if (!e.shiftKey && document.activeElement === last) {
137
+ e.preventDefault()
138
+ first.focus()
139
+ }
140
+ }
141
+
142
+ restoreFocus() {
143
+ if (this.hasReturnFocusValue) {
144
+ const target = document.querySelector(this.returnFocusValue)
145
+ if (target && target.focus) {
146
+ target.focus()
147
+ return
148
+ }
149
+ }
150
+
151
+ if (this.previouslyFocused && this.previouslyFocused.focus) {
152
+ this.previouslyFocused.focus()
153
+ }
154
+ }
155
+
156
+ transitionDurationMs() {
157
+ const raw = getComputedStyle(document.documentElement).getPropertyValue('--astra-transition-duration').trim()
158
+ if (raw.endsWith("ms")) return parseFloat(raw)
159
+ if (raw.endsWith("s")) return parseFloat(raw) * 1000
160
+ const parsed = parseFloat(raw)
161
+ return Number.isNaN(parsed) ? 300 : parsed
162
+ }
163
+ }
@@ -0,0 +1,3 @@
1
+ pin "hotwire_astra_ui/modal_controller", to: "hotwire_astra_ui/modal_controller.js"
2
+ # Back-compat alias
3
+ pin "hotwire_astra_ui_modal", to: "hotwire_astra_ui/modal_controller.js"
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ Rails.application.routes.draw do
2
+ end
@@ -0,0 +1,60 @@
1
+ module HotwireAstraUi
2
+ module Generators
3
+ class InstallGenerator < Rails::Generators::Base
4
+ source_root File.expand_path("templates", __dir__)
5
+
6
+ desc "Configures the application for Hotwire Astra UI"
7
+
8
+ def add_turbo_frame_to_layout
9
+ layout_file = "app/views/layouts/application.html.erb"
10
+
11
+ if File.exist?(layout_file)
12
+ # Check if it's already there to avoid duplicates
13
+ if File.read(layout_file).include?('turbo_frame_tag "astra_modal"')
14
+ say_status("skipped", "turbo_frame_tag already exists in application.html.erb", :yellow)
15
+ else
16
+ # Insert before the closing body tag
17
+ insert_into_file layout_file, "\n <%= turbo_frame_tag \"astra_modal\" %>", before: /^ <\/body>/
18
+ say_status("inserted", "turbo_frame_tag into application.html.erb", :green)
19
+ end
20
+ else
21
+ say_status("error", "application.html.erb not found. Please add <%= turbo_frame_tag 'astra_modal' %> manually.", :red)
22
+ end
23
+ end
24
+
25
+ def create_initializer
26
+ initializer "hotwire_astra_ui.rb" do
27
+ <<~RUBY
28
+ # Configuration for Hotwire Astra UI
29
+ # You can add global theme settings here later
30
+ RUBY
31
+ end
32
+ end
33
+
34
+ def register_stimulus_controller
35
+ controllers_index = "app/javascript/controllers/index.js"
36
+ import_line = 'import HotwireAstraUiModalController from "hotwire_astra_ui/modal_controller"'
37
+ register_line = 'application.register("hotwire-astra-ui-modal", HotwireAstraUiModalController)'
38
+
39
+ if File.exist?(controllers_index)
40
+ file_contents = File.read(controllers_index)
41
+
42
+ if file_contents.include?(import_line) || file_contents.include?(register_line)
43
+ say_status("skipped", "Stimulus controller already registered in controllers/index.js", :yellow)
44
+ else
45
+ append_to_file controllers_index, "\n#{import_line}\n#{register_line}\n"
46
+ say_status("inserted", "Registered Hotwire Astra UI Stimulus controller", :green)
47
+ end
48
+ else
49
+ say_status("warning", "controllers/index.js not found. Please register the controller manually.", :yellow)
50
+ end
51
+ end
52
+
53
+ def show_post_install_message
54
+ say "\n🚀 Hotwire Astra UI is installed!", :magenta
55
+ say "1. Use data-turbo-frame='astra_modal' on your links."
56
+ say "2. Render the component: render Hotwire::AstraUi::ModalComponent.new(title: 'Hello')"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,15 @@
1
+ module Hotwire
2
+ module AstraUi
3
+ class Engine < ::Rails::Engine
4
+ # This hook allows the host app to see the engine's assets
5
+ initializer "hotwire_astra_ui.assets" do |app|
6
+ app.config.assets.paths << root.join("app/javascript")
7
+ end
8
+
9
+ # This automatically pins the engine's JS in the host's importmap
10
+ initializer "hotwire_astra_ui.importmap", before: "importmap" do |app|
11
+ (app.config.importmap.paths ||= []) << root.join("config/importmap.rb")
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,12 @@
1
+ require "importmap-rails"
2
+ require "view_component"
3
+ require "hotwire/astra_ui/version"
4
+ require "hotwire/astra_ui/engine"
5
+
6
+ module Hotwire
7
+ module AstraUi
8
+ # Namespace module for the gem. Loading this file wires up the engine
9
+ # and version, which is enough for the host app to mount assets and
10
+ # importmap pins.
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ module Hotwire
2
+ module AstraUi
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :hotwire_astra_ui do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,137 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hotwire-astra-ui
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Fernand
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-02-04 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 8.1.2
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 8.1.2
27
+ - !ruby/object:Gem::Dependency
28
+ name: view_component
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: turbo-rails
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: stimulus-rails
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: importmap-rails
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ description: Hotwire Astra UI provides production-ready, accessible modal dialogs
84
+ for Rails apps using Turbo Frames and Stimulus. Importmap-friendly, server-rendered,
85
+ and framework-free, with stacked modals, CSS-driven animations, and full customization
86
+ via CSS variables.
87
+ email:
88
+ - dev.cloud.asm@gmail.com
89
+ executables: []
90
+ extensions: []
91
+ extra_rdoc_files: []
92
+ files:
93
+ - MIT-LICENSE
94
+ - README.md
95
+ - Rakefile
96
+ - app/assets/stylesheets/hotwire/astra_ui/astra_ui.css
97
+ - app/components/hotwire/astra_ui/modal_component.html.erb
98
+ - app/components/hotwire/astra_ui/modal_component.rb
99
+ - app/helpers/hotwire/astra_ui/modal_helper.rb
100
+ - app/javascript/hotwire-astra-ui.js
101
+ - app/javascript/hotwire_astra_ui/modal_controller.js
102
+ - config/importmap.rb
103
+ - config/routes.rb
104
+ - lib/generators/hotwire_astra_ui/install_generator.rb
105
+ - lib/hotwire/astra_ui/engine.rb
106
+ - lib/hotwire/astra_ui/ui.rb
107
+ - lib/hotwire/astra_ui/version.rb
108
+ - lib/tasks/hotwire/astra/ui_tasks.rake
109
+ homepage: https://www.digi-archive.com/articles/hotwire-astra-ui
110
+ licenses:
111
+ - MIT
112
+ metadata:
113
+ allowed_push_host: https://rubygems.org/
114
+ homepage_uri: https://www.digi-archive.com/articles/hotwire-astra-ui
115
+ source_code_uri: https://github.com/wwwfernand/hotwire-astra-ui
116
+ changelog_uri: https://github.com/wwwfernand/hotwire-astra-ui/blob/main/CHANGELOG.md
117
+ post_install_message:
118
+ rdoc_options: []
119
+ require_paths:
120
+ - lib
121
+ required_ruby_version: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ required_rubygems_version: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0'
131
+ requirements: []
132
+ rubygems_version: 3.5.11
133
+ signing_key:
134
+ specification_version: 4
135
+ summary: Accessible Hotwire modal component for Rails (Turbo + Stimulus) with Importmap
136
+ support
137
+ test_files: []