modal_stack 0.1.1
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 +37 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +748 -0
- data/Rakefile +12 -0
- data/app/assets/javascripts/modal_stack.js +756 -0
- data/app/assets/stylesheets/modal_stack/bootstrap.css +232 -0
- data/app/assets/stylesheets/modal_stack/tailwind.css +303 -0
- data/app/assets/stylesheets/modal_stack/vanilla.css +219 -0
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +149 -0
- data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +34 -0
- data/app/javascript/modal_stack/index.js +15 -0
- data/app/javascript/modal_stack/install.js +15 -0
- data/app/javascript/modal_stack/orchestrator.js +98 -0
- data/app/javascript/modal_stack/orchestrator.test.js +260 -0
- data/app/javascript/modal_stack/runtime.js +217 -0
- data/app/javascript/modal_stack/runtime.test.js +134 -0
- data/app/javascript/modal_stack/state.js +315 -0
- data/app/javascript/modal_stack/state.test.js +508 -0
- data/app/views/layouts/modal.html.erb +6 -0
- data/lib/generators/modal_stack/install/install_generator.rb +224 -0
- data/lib/generators/modal_stack/install/templates/initializer.rb +57 -0
- data/lib/modal_stack/capybara/minitest.rb +9 -0
- data/lib/modal_stack/capybara/rspec.rb +9 -0
- data/lib/modal_stack/capybara.rb +85 -0
- data/lib/modal_stack/configuration.rb +90 -0
- data/lib/modal_stack/controller_extensions.rb +73 -0
- data/lib/modal_stack/engine.rb +44 -0
- data/lib/modal_stack/helpers/modal_link_helper.rb +65 -0
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +45 -0
- data/lib/modal_stack/helpers/modal_stack_container_helper.rb +36 -0
- data/lib/modal_stack/initializer_version_check.rb +33 -0
- data/lib/modal_stack/turbo_streams_extension.rb +73 -0
- data/lib/modal_stack/version.rb +5 -0
- data/lib/modal_stack.rb +36 -0
- metadata +130 -0
data/README.md
ADDED
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# ๐ช modal_stack
|
|
4
|
+
|
|
5
|
+
**Stackable modals, drawers, bottom sheets, and confirmations for Hotwire-powered Rails apps.**
|
|
6
|
+
|
|
7
|
+
Push N layers, deep-link the top of the stack via native Rails URLs, get full
|
|
8
|
+
browser back/forward support, and drive everything from imperative Turbo
|
|
9
|
+
Stream actions (`modal_push`, `modal_pop`, `modal_replace`, `modal_close_all`).
|
|
10
|
+
|
|
11
|
+
[](https://github.com/Metalzoid/modal_stack/actions)
|
|
12
|
+
[](https://rubygems.org/gems/modal_stack)
|
|
13
|
+
[](https://www.ruby-lang.org/)
|
|
14
|
+
[](https://rubyonrails.org/)
|
|
15
|
+
[](LICENSE.txt)
|
|
16
|
+
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## ๐ Table of contents
|
|
22
|
+
|
|
23
|
+
- [Why modal_stack?](#-why-modal_stack)
|
|
24
|
+
- [Features](#-features)
|
|
25
|
+
- [Compatibility](#-compatibility)
|
|
26
|
+
- [Installation](#-installation)
|
|
27
|
+
- [Quick start](#-quick-start)
|
|
28
|
+
- [Configuration](#%EF%B8%8F-configuration)
|
|
29
|
+
- [Usage](#-usage)
|
|
30
|
+
- [Opening a modal from a link](#opening-a-modal-from-a-link)
|
|
31
|
+
- [The modal layout](#the-modal-layout)
|
|
32
|
+
- [Stack-aware controllers](#stack-aware-controllers)
|
|
33
|
+
- [Turbo Stream actions](#turbo-stream-actions)
|
|
34
|
+
- [Variants, sizes, custom dimensions](#variants-sizes-custom-dimensions)
|
|
35
|
+
- [Wizards & multi-step flows](#wizards--multi-step-flows)
|
|
36
|
+
- [Stack depth & inertness](#stack-depth--inertness)
|
|
37
|
+
- [Reference](#-reference)
|
|
38
|
+
- [`ModalStack.configure`](#modalstackconfigure)
|
|
39
|
+
- [View helpers](#view-helpers)
|
|
40
|
+
- [Controller extensions](#controller-extensions)
|
|
41
|
+
- [Turbo Stream actions reference](#turbo-stream-actions-reference)
|
|
42
|
+
- [Layer DOM contract](#layer-dom-contract)
|
|
43
|
+
- [Stimulus controllers](#stimulus-controllers)
|
|
44
|
+
- [JS runtime](#js-runtime)
|
|
45
|
+
- [Capybara helpers](#capybara-helpers)
|
|
46
|
+
- [Generator](#generator)
|
|
47
|
+
- [CSS presets & theming](#-css-presets--theming)
|
|
48
|
+
- [Asset pipelines](#-asset-pipelines)
|
|
49
|
+
- [Accessibility](#-accessibility)
|
|
50
|
+
- [Development](#-development)
|
|
51
|
+
- [Releasing](#-releasing)
|
|
52
|
+
- [Contributing](#-contributing)
|
|
53
|
+
- [License](#-license)
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## ๐ค Why modal_stack?
|
|
58
|
+
|
|
59
|
+
The Hotwire ecosystem has a few "single modal" libraries, but the moment your
|
|
60
|
+
app needs to open a modal **from inside** another modal โ picking a customer
|
|
61
|
+
while creating an invoice, running a 4-step wizard inside a drawer, or
|
|
62
|
+
browser-`back`-ing through nested confirmation steps โ they break down.
|
|
63
|
+
|
|
64
|
+
| | `ultimate_turbo_modal` | DIY Stimulus | **`modal_stack`** |
|
|
65
|
+
| -------------------------------------------- | :--------------------: | :----------: | :---------------: |
|
|
66
|
+
| 1 modal + history | โ
| โ
| โ
|
|
|
67
|
+
| Native `<dialog>` + focus trap | โ
| โ | โ
|
|
|
68
|
+
| Drawers (left/right/top/bottom) | partial | โ | โ
|
|
|
69
|
+
| Bottom sheets | โ | โ | โ
|
|
|
70
|
+
| **Stack of N layers** | โ | โ | โ
|
|
|
71
|
+
| **Wizard step-by-step inside a layer** | โ | โ | โ
|
|
|
72
|
+
| **Browser back pops one layer at a time** | โ | โ | โ
|
|
|
73
|
+
| **Imperative Turbo Stream actions** | partial | โ | โ
|
|
|
74
|
+
| Custom width/height per layer | โ | โ | โ
|
|
|
75
|
+
| `dismissible: false` (locked layers) | โ | โ | โ
|
|
|
76
|
+
| Tailwind / Bootstrap / vanilla CSS presets | โ | โ | โ
|
|
|
77
|
+
| Capybara matchers shipped | โ | โ | โ
|
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## โจ Features
|
|
82
|
+
|
|
83
|
+
- ๐ช **Stack of N layers** โ push modals on top of modals; the underlying ones become `inert` automatically.
|
|
84
|
+
- ๐ช **Native `<dialog>`** โ focus trap, ESC, accessible roles for free.
|
|
85
|
+
- ๐ **Deep-linking** โ the top of the stack lives in `window.location`. Bookmark it, share it, refresh it.
|
|
86
|
+
- โฉ๏ธ **Browser back = pop** โ one history entry per layer; `cmd`+`โ` does what users expect.
|
|
87
|
+
- ๐ฎ **Imperative Turbo Stream actions** โ `turbo_stream.modal_push / modal_pop / modal_replace / modal_close_all` from anywhere.
|
|
88
|
+
- ๐จ **Three CSS presets** โ Tailwind, Bootstrap, vanilla. All driven by `--modal-stack-*` CSS variables for easy retheming.
|
|
89
|
+
- ๐ช **Four variants** โ `modal`, `drawer` (with side), `bottom_sheet`, `confirmation`.
|
|
90
|
+
- ๐ **Sizes & custom dimensions** โ `:sm` / `:md` / `:lg` / `:xl`, or pass `width:` / `height:` strings (`"42rem"`, `"min(90vw, 56rem)"`).
|
|
91
|
+
- ๐ **Dismissible flag** โ `dismissible: false` for confirmations users must answer.
|
|
92
|
+
- โฟ **`prefers-reduced-motion`** โ animations collapse to 1ms when the OS asks.
|
|
93
|
+
- ๐งช **Capybara matchers** โ `within_modal`, `have_modal_open`, `have_modal_stack(depth: 2)`, `close_modal`, `close_all_modals`.
|
|
94
|
+
- โก **Three asset pipelines** โ Importmap (default), jsbundling, Sprockets.
|
|
95
|
+
- ๐งฑ **Engine-based** โ zero monkey-patching, pure Rails Engine + Stimulus + Turbo.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## ๐ง Compatibility
|
|
100
|
+
|
|
101
|
+
Tested on every combination of Ruby and Rails listed below via the
|
|
102
|
+
[Appraisal](https://github.com/thoughtbot/appraisal) gem:
|
|
103
|
+
|
|
104
|
+
| | Rails 7.2 | Rails 8.0 | Rails 8.1.3 | Rails 8.1.3 + Sprockets |
|
|
105
|
+
| ------- | :-------: | :-------: | :---------: | :---------------------: |
|
|
106
|
+
| Ruby 3.2| โ
| โ
| โ
| โ
|
|
|
107
|
+
| Ruby 3.3| โ
| โ
| โ
| โ
|
|
|
108
|
+
| Ruby 3.4| โ
| โ
| โ
| โ
|
|
|
109
|
+
| Ruby 3.5| โ
| โ
| โ
| โ
|
|
|
110
|
+
| Ruby 4.0| โ | โ
| โ
| โ
|
|
|
111
|
+
|
|
112
|
+
> **Requirements:** Ruby **โฅ 3.2**, Rails **โฅ 7.2** (`railties >= 7.2`),
|
|
113
|
+
> `turbo-rails >= 2.0`, Stimulus **โฅ 3.0**.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## ๐ฆ Installation
|
|
118
|
+
|
|
119
|
+
Add to your `Gemfile`:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
gem "modal_stack"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Then run:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
$ bundle install
|
|
129
|
+
$ bin/rails g modal_stack:install
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The generator **autodetects** your asset pipeline. You can force it:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
$ bin/rails g modal_stack:install --mode=importmap # default for new Rails apps
|
|
136
|
+
$ bin/rails g modal_stack:install --mode=jsbundling # esbuild, vite, bun
|
|
137
|
+
$ bin/rails g modal_stack:install --mode=sprockets # legacy apps
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Pick the CSS preset that matches your stack:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
$ bin/rails g modal_stack:install --css-provider=tailwind # default
|
|
144
|
+
$ bin/rails g modal_stack:install --css-provider=bootstrap # picks up Bootstrap 5 vars
|
|
145
|
+
$ bin/rails g modal_stack:install --css-provider=vanilla # framework-free
|
|
146
|
+
$ bin/rails g modal_stack:install --css-provider=none # bring your own CSS
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### What the generator does
|
|
150
|
+
|
|
151
|
+
- ๐ creates `config/initializers/modal_stack.rb`
|
|
152
|
+
- ๐ pins (Importmap) or installs (jsbundling) `@hotwired/stimulus` and `modal_stack`
|
|
153
|
+
- ๐จ wires the chosen CSS preset into the asset pipeline
|
|
154
|
+
- ๐ injects `<%= modal_stack_stylesheet_link_tag %>` and `<%= modal_stack_dialog_tag %>` into `app/views/layouts/application.html.erb`
|
|
155
|
+
- ๐ appends the `installModalStack(application)` call to your Stimulus entrypoint
|
|
156
|
+
|
|
157
|
+
In your JS entrypoint (e.g. `app/javascript/controllers/application.js`):
|
|
158
|
+
|
|
159
|
+
```js
|
|
160
|
+
import { Application } from "@hotwired/stimulus"
|
|
161
|
+
import { install as installModalStack } from "modal_stack"
|
|
162
|
+
|
|
163
|
+
const application = Application.start()
|
|
164
|
+
installModalStack(application)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## ๐ Quick start
|
|
170
|
+
|
|
171
|
+
```erb
|
|
172
|
+
<%# app/views/projects/index.html.erb %>
|
|
173
|
+
<%= modal_link_to "Edit", edit_project_path(@project) %>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
# app/controllers/projects_controller.rb
|
|
178
|
+
class ProjectsController < ApplicationController
|
|
179
|
+
modal_stack_layout
|
|
180
|
+
# ...
|
|
181
|
+
end
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
```erb
|
|
185
|
+
<%# app/views/projects/edit.html.erb %>
|
|
186
|
+
<%= modal_stack_container do %>
|
|
187
|
+
<%= form_with model: @project do |f| %>
|
|
188
|
+
<%= f.text_field :name %>
|
|
189
|
+
<%= f.submit %>
|
|
190
|
+
<% end %>
|
|
191
|
+
<% end %>
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
That's it. Click the link โ the form opens in a modal, the URL updates to
|
|
195
|
+
`/projects/42/edit`, browser back closes the modal, refresh re-opens it
|
|
196
|
+
right where it was.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## โ๏ธ Configuration
|
|
201
|
+
|
|
202
|
+
Everything lives in `config/initializers/modal_stack.rb`:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
ModalStack.configure do |config|
|
|
206
|
+
# โโโ Presentation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
207
|
+
config.css_provider = :tailwind # :tailwind | :bootstrap | :vanilla | :none
|
|
208
|
+
config.default_variant = :modal # :modal | :drawer | :bottom_sheet | :confirmation
|
|
209
|
+
config.default_size = :md # :sm | :md | :lg | :xl
|
|
210
|
+
config.default_dismissible = true # ESC + backdrop click close the layer
|
|
211
|
+
|
|
212
|
+
# โโโ Behavior โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
213
|
+
config.max_depth = 5 # hard cap on nested layers
|
|
214
|
+
config.respect_reduced_motion = true # honor prefers-reduced-motion
|
|
215
|
+
config.replace_turbo_confirm = false # use modal_stack confirmations for data-turbo-confirm
|
|
216
|
+
|
|
217
|
+
# โโโ Wiring (rarely changed) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
218
|
+
config.dialog_id = "modal-stack-root"
|
|
219
|
+
config.stack_root_data_attribute = "modal-stack"
|
|
220
|
+
config.request_header = "X-Modal-Stack-Request"
|
|
221
|
+
config.assets_mode = :auto # :importmap | :jsbundling | :sprockets | :auto
|
|
222
|
+
|
|
223
|
+
# โโโ i18n โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
224
|
+
config.i18n_scope = "modal_stack"
|
|
225
|
+
end
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
> ๐ก **`config.initializer_version`** is stamped automatically by the
|
|
229
|
+
> generator. When you upgrade `modal_stack`, a boot-time warning tells you if
|
|
230
|
+
> the installed gem ships a newer initializer template than what you have.
|
|
231
|
+
> Set `config.silence_initializer_warning = true` to mute it.
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## ๐ฏ Usage
|
|
236
|
+
|
|
237
|
+
### Opening a modal from a link
|
|
238
|
+
|
|
239
|
+
```erb
|
|
240
|
+
<%= modal_link_to "Edit", edit_project_path(@project) %>
|
|
241
|
+
<%= modal_link_to "Details", project_path(@project), as: :drawer, side: :right %>
|
|
242
|
+
<%= modal_link_to "Settings", settings_path, as: :bottom_sheet %>
|
|
243
|
+
<%= modal_link_to "Confirm", confirm_path, dismissible: false %>
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
`modal_link_to` accepts the same arguments as Rails' `link_to`, plus:
|
|
247
|
+
|
|
248
|
+
| Option | Type | Description |
|
|
249
|
+
| ------------- | --------- | ----------- |
|
|
250
|
+
| `as:` | Symbol | Variant โ `:modal` (default), `:drawer`, `:bottom_sheet`, `:confirmation` |
|
|
251
|
+
| `side:` | Symbol | Drawer side โ `:left`, `:right` (default), `:top`, `:bottom` |
|
|
252
|
+
| `size:` | Symbol | `:sm`, `:md`, `:lg`, `:xl` |
|
|
253
|
+
| `width:` | String | CSS length (e.g. `"42rem"`, `"min(90vw, 56rem)"`) |
|
|
254
|
+
| `height:` | String | CSS length |
|
|
255
|
+
| `dismissible:`| Boolean | When `false`, ESC and backdrop click are ignored |
|
|
256
|
+
|
|
257
|
+
> **Hotwire Native fallback:** when the request comes from a Hotwire Native
|
|
258
|
+
> shell (matched on User-Agent), `modal_link_to` quietly degrades to plain
|
|
259
|
+
> `link_to` so the platform's native navigation handles it.
|
|
260
|
+
|
|
261
|
+
### The modal layout
|
|
262
|
+
|
|
263
|
+
The gem ships a minimal `modal` layout (`app/views/layouts/modal.html.erb`)
|
|
264
|
+
that just `yield`s. Each panel view is responsible for wrapping itself in
|
|
265
|
+
`modal_stack_container`, which lets every action pick its own size/variant
|
|
266
|
+
options at the call site:
|
|
267
|
+
|
|
268
|
+
```erb
|
|
269
|
+
<%# app/views/projects/edit.html.erb %>
|
|
270
|
+
<%= modal_stack_container size: :lg do %>
|
|
271
|
+
<h2>Edit project</h2>
|
|
272
|
+
<%= render "form", project: @project %>
|
|
273
|
+
<% end %>
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`modal_stack_container` accepts `size:`, `variant:`, `side:`, `width:`,
|
|
277
|
+
`height:`, `dismissible:`, and an `html: { class:, data:, ... }` Hash for
|
|
278
|
+
extra attributes on the wrapping `<div>`.
|
|
279
|
+
|
|
280
|
+
### Stack-aware controllers
|
|
281
|
+
|
|
282
|
+
`modal_stack_layout` switches the controller's layout to `modal` **only**
|
|
283
|
+
when the request was issued by the modal_stack JS runtime (signaled by the
|
|
284
|
+
`X-Modal-Stack-Request` header). Direct visits / refreshes still get the
|
|
285
|
+
regular `application` layout, so deep-links keep rendering full pages.
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
class ProjectsController < ApplicationController
|
|
289
|
+
modal_stack_layout # all actions
|
|
290
|
+
modal_stack_layout except: [:index] # the standard Rails-style filter works
|
|
291
|
+
modal_stack_layout fallback: "admin" # fallback layout for non-stack requests
|
|
292
|
+
end
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
`render_modal` is a shortcut for re-rendering inside the modal layout โ
|
|
296
|
+
typically after a validation failure:
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
def update
|
|
300
|
+
if @project.update(project_params)
|
|
301
|
+
redirect_to @project
|
|
302
|
+
else
|
|
303
|
+
render_modal :edit, status: :unprocessable_entity
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
`modal_stack_request?` is exposed as both a controller method and a view
|
|
309
|
+
helper for branching on stack-vs-page requests.
|
|
310
|
+
|
|
311
|
+
### Turbo Stream actions
|
|
312
|
+
|
|
313
|
+
For programmatic stack manipulation from anywhere a Turbo Stream lands
|
|
314
|
+
(create/update/destroy, ActionCable broadcast, custom controller action):
|
|
315
|
+
|
|
316
|
+
```ruby
|
|
317
|
+
respond_to do |format|
|
|
318
|
+
format.turbo_stream do
|
|
319
|
+
render turbo_stream: turbo_stream.modal_push(
|
|
320
|
+
template: "items/new",
|
|
321
|
+
variant: :drawer,
|
|
322
|
+
side: :right,
|
|
323
|
+
size: :lg
|
|
324
|
+
)
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Available actions:
|
|
330
|
+
|
|
331
|
+
| Action | Effect |
|
|
332
|
+
| ----------------------------------------- | ------ |
|
|
333
|
+
| `turbo_stream.modal_push(content, **opts)` | Push a new layer on top of the stack. Same content options as Turbo's standard streams (`partial:`/`template:`/`locals:`/raw block). |
|
|
334
|
+
| `turbo_stream.modal_pop` | Pop the top layer. |
|
|
335
|
+
| `turbo_stream.modal_replace(content, **opts)` | Morph the top layer in place. Defaults to `history: :replace`; pass `history: :push` for wizard-step semantics where browser-back returns to the previous step. |
|
|
336
|
+
| `turbo_stream.modal_close_all` | Tear down the entire stack. |
|
|
337
|
+
|
|
338
|
+
### Variants, sizes, custom dimensions
|
|
339
|
+
|
|
340
|
+
Four variants:
|
|
341
|
+
|
|
342
|
+
- **`:modal`** (default) โ centered overlay panel
|
|
343
|
+
- **`:drawer`** โ slides in from a side; pass `side: :left | :right | :top | :bottom`
|
|
344
|
+
- **`:bottom_sheet`** โ full-width sheet that slides up from the bottom (mobile-first)
|
|
345
|
+
- **`:confirmation`** โ typically combined with `dismissible: false` for "are you sure?" flows
|
|
346
|
+
|
|
347
|
+
Sizes via the `size:` keyword pick from `:sm`, `:md`, `:lg`, `:xl`. The
|
|
348
|
+
preset CSS maps each to a `max-width` (and `max-height` for bottom sheets).
|
|
349
|
+
|
|
350
|
+
Need a one-off dimension? Pass `width:` and/or `height:` as CSS length
|
|
351
|
+
strings โ they're applied as inline styles, taking precedence over `size:`:
|
|
352
|
+
|
|
353
|
+
```erb
|
|
354
|
+
<%= modal_link_to "Print preview", preview_path,
|
|
355
|
+
width: "min(90vw, 56rem)", height: "85vh" %>
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Wizards & multi-step flows
|
|
359
|
+
|
|
360
|
+
For step-by-step flows inside a single layer (onboarding, multi-step forms),
|
|
361
|
+
combine `modal_push` (for the initial open) with `modal_replace` carrying
|
|
362
|
+
`history: :push` between steps. Each step gets its own URL and a real
|
|
363
|
+
history entry, so browser-back returns to the previous step (not the page
|
|
364
|
+
behind the wizard):
|
|
365
|
+
|
|
366
|
+
```ruby
|
|
367
|
+
class WizardController < ApplicationController
|
|
368
|
+
modal_stack_layout
|
|
369
|
+
|
|
370
|
+
def step_2
|
|
371
|
+
respond_to do |format|
|
|
372
|
+
format.html # full-page render for deep-links
|
|
373
|
+
format.turbo_stream do
|
|
374
|
+
render turbo_stream: turbo_stream.modal_replace(
|
|
375
|
+
template: "wizard/step_2",
|
|
376
|
+
history: :push,
|
|
377
|
+
url: wizard_step_2_path
|
|
378
|
+
)
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### Stack depth & inertness
|
|
386
|
+
|
|
387
|
+
When a layer is pushed on top of another, the bottom layer automatically
|
|
388
|
+
gets the `inert` HTML attribute, so screen-readers and pointer/keyboard
|
|
389
|
+
events skip it entirely. When the top layer is popped, `inert` is removed
|
|
390
|
+
from what becomes the new top.
|
|
391
|
+
|
|
392
|
+
The `<dialog>` itself is opened on first push, closed on last pop. Page
|
|
393
|
+
scroll is locked while any layer is open (`<body data-modal-stack-locked>`)
|
|
394
|
+
so the page beneath doesn't scroll under your finger on touch devices.
|
|
395
|
+
|
|
396
|
+
`max_depth` (default `5`) is a hard ceiling โ pushing past it raises a
|
|
397
|
+
runtime error, on the assumption that you have a state-machine bug.
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
## ๐ Reference
|
|
402
|
+
|
|
403
|
+
### `ModalStack.configure`
|
|
404
|
+
|
|
405
|
+
```ruby
|
|
406
|
+
ModalStack.configure { |config| ... }
|
|
407
|
+
ModalStack.configuration # reader, memoized
|
|
408
|
+
ModalStack.reset_configuration! # test-fixture helper
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
| Attribute | Type | Default | Description |
|
|
412
|
+
| ---------------------------- | ------- | ------------------------ | ----------- |
|
|
413
|
+
| `css_provider` | Symbol | `:tailwind` | One of `:tailwind`, `:bootstrap`, `:vanilla`, `:none`. Determines which stylesheet `modal_stack_stylesheet_link_tag` resolves to. Validated. |
|
|
414
|
+
| `assets_mode` | Symbol | `:auto` | One of `:importmap`, `:jsbundling`, `:sprockets`, `:auto`. Used by the generator. Validated. |
|
|
415
|
+
| `default_variant` | Symbol | `:modal` | `:modal`, `:drawer`, `:bottom_sheet`, or `:confirmation`. Validated. |
|
|
416
|
+
| `default_size` | Symbol | `:md` | `:sm`, `:md`, `:lg`, `:xl`. Validated. |
|
|
417
|
+
| `default_dismissible` | Boolean | `true` | Default for `dismissible:` when omitted. |
|
|
418
|
+
| `default_classes` | Hash | `{ ... }` | Hash of extra CSS class strings keyed by `:modal_panel`, `:drawer_panel`, `:bottom_sheet_panel`, `:confirmation_panel`. Useful for adding utility classes on top of the chosen preset. |
|
|
419
|
+
| `max_depth` | Integer | `5` | Hard cap on stack depth โ pushing past it raises. |
|
|
420
|
+
| `request_header` | String | `"X-Modal-Stack-Request"` | HTTP header used by the JS runtime to signal stack-originated fetches. Read by `modal_stack_request?`. |
|
|
421
|
+
| `dialog_id` | String | `"modal-stack-root"` | The id of the singleton `<dialog>`. Override only on name collision. |
|
|
422
|
+
| `stack_root_data_attribute` | String | `"modal-stack"` | The Stimulus `data-controller` value attached to the `<dialog>`. |
|
|
423
|
+
| `respect_reduced_motion` | Boolean | `true` | When the OS reports `prefers-reduced-motion: reduce`, presets collapse transitions to 1ms. |
|
|
424
|
+
| `replace_turbo_confirm` | Boolean | `false` | When `true`, replaces `data-turbo-confirm` window.confirm with a stack-rendered confirmation layer. |
|
|
425
|
+
| `i18n_scope` | String | `"modal_stack"` | I18n scope for user-facing strings (close button, swipe-down hint, โฆ). |
|
|
426
|
+
| `initializer_version` | String | `nil` (set by generator) | Stamped by the install generator; used to warn when an older template is in use after a gem upgrade. |
|
|
427
|
+
| `silence_initializer_warning`| Boolean | `false` | Mutes the boot-time warning when the stamped version differs from the gem's. |
|
|
428
|
+
|
|
429
|
+
### View helpers
|
|
430
|
+
|
|
431
|
+
Injected into `ActionView::Base` by the engine โ available in every view.
|
|
432
|
+
|
|
433
|
+
| Helper | Description |
|
|
434
|
+
| ------------------------------------------------- | ----------- |
|
|
435
|
+
| `modal_link_to(name, options, html_options)` | Renders a `link_to` wired to push a layer when clicked. Accepts the modal options (`as:`, `side:`, `size:`, `width:`, `height:`, `dismissible:`) on top of standard `link_to` arguments. Falls back to plain `link_to` for Hotwire Native requests. |
|
|
436
|
+
| `modal_stack_container(size:, variant:, side:, width:, height:, dismissible:, html: {}) { ... }` | Wraps a panel view with the markup the JS runtime expects. Renders a `<div>` carrying the size/variant/dismissible/dimension data attributes. |
|
|
437
|
+
| `modal_stack_stylesheet_link_tag(**options)` | Emits `<link rel="stylesheet">` for the configured preset (`modal_stack/tailwind.css`, etc.). Returns an empty SafeBuffer when `css_provider = :none`. |
|
|
438
|
+
| `modal_stack_dialog_tag(**html_options)` | Emits the singleton `<dialog id="modal-stack-root" data-controller="modal-stack">`. Drop just before `</body>`. |
|
|
439
|
+
| `modal_stack_javascript_tag` | Reserved hook for layouts; currently a no-op (JS is loaded via your bundler / importmap). |
|
|
440
|
+
|
|
441
|
+
### Controller extensions
|
|
442
|
+
|
|
443
|
+
Mixed into `ActionController::Base` by the engine.
|
|
444
|
+
|
|
445
|
+
| Method | Description |
|
|
446
|
+
| ----------------------------------------------- | ----------- |
|
|
447
|
+
| `modal_stack_layout(fallback: nil, **conditions)` *(class macro)* | Switches the layout to `"modal"` for stack-originated requests. `fallback:` accepts a layout name, `nil`, or a callable. `**conditions` forwards `only:` / `except:` to Rails' `layout` directive. |
|
|
448
|
+
| `render_modal(template_or_options = nil, **options)` | Convenience for re-rendering inside the `modal` layout โ useful after validation failures. |
|
|
449
|
+
| `modal_stack_request?` *(also a view helper)* | `true` when the request carries the `X-Modal-Stack-Request` header. |
|
|
450
|
+
|
|
451
|
+
### Turbo Stream actions reference
|
|
452
|
+
|
|
453
|
+
Mixed into `Turbo::Streams::TagBuilder`. All target the singleton dialog
|
|
454
|
+
(`ModalStack::TARGET_ID = "modal-stack-root"`) and accept the same content
|
|
455
|
+
options as Turbo's built-in stream actions (`partial:`, `template:`,
|
|
456
|
+
`locals:`, raw HTML block, โฆ).
|
|
457
|
+
|
|
458
|
+
| Action | Options |
|
|
459
|
+
| ----------------------------------------------------------- | ------- |
|
|
460
|
+
| `modal_push(content = nil, **opts, &block)` | `variant:`, `dismissible:`, `url:`, `side:`, `size:`, `width:`, `height:`, plus any rendering options |
|
|
461
|
+
| `modal_pop` | โ |
|
|
462
|
+
| `modal_replace(content = nil, **opts, &block)` | All `modal_push` options plus `history:` (`:replace` *(default)* or `:push`) and `layer_id:` |
|
|
463
|
+
| `modal_close_all` | โ |
|
|
464
|
+
|
|
465
|
+
`history: :push` raises `ArgumentError` if given any value other than
|
|
466
|
+
`:push` or `:replace`.
|
|
467
|
+
|
|
468
|
+
### Layer DOM contract
|
|
469
|
+
|
|
470
|
+
Each pushed layer is a `<div>` inside the dialog with:
|
|
471
|
+
|
|
472
|
+
```html
|
|
473
|
+
<div data-modal-stack-target="layer"
|
|
474
|
+
data-layer-id="ms-โฆ"
|
|
475
|
+
data-depth="2"
|
|
476
|
+
data-variant="drawer"
|
|
477
|
+
data-side="right"
|
|
478
|
+
data-dismissible="true"
|
|
479
|
+
data-modal-stack-size="lg"
|
|
480
|
+
data-modal-stack-width="42rem" style="width: 42rem;">
|
|
481
|
+
<!-- panel content -->
|
|
482
|
+
</div>
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
Underlying layers receive `inert`. A layer being unmounted gets
|
|
486
|
+
`data-leaving=""` for the duration of the exit transition (capped at
|
|
487
|
+
600ms even if the host CSS forgets to define one).
|
|
488
|
+
|
|
489
|
+
### Stimulus controllers
|
|
490
|
+
|
|
491
|
+
Both controllers are registered via `installModalStack(application)`.
|
|
492
|
+
|
|
493
|
+
| Identifier | Role |
|
|
494
|
+
| ---------------------- | ---- |
|
|
495
|
+
| `modal-stack` | Bound to the singleton `<dialog>`. Wires popstate / cancel / backdrop-click listeners, registers the `Turbo.StreamActions`, hosts the Orchestrator. |
|
|
496
|
+
| `modal-stack-link` | Attached to elements rendered by `modal_link_to`. On `click`, finds the `modal-stack` controller and calls `push({ url, variant, โฆ })` from the element's data attributes. |
|
|
497
|
+
|
|
498
|
+
### JS runtime
|
|
499
|
+
|
|
500
|
+
The package exports a small functional core + a browser adapter:
|
|
501
|
+
|
|
502
|
+
```js
|
|
503
|
+
import {
|
|
504
|
+
// pure reducer โ no IO, no DOM
|
|
505
|
+
createStack, push, pop, replaceTop, closeAll, handlePopstate,
|
|
506
|
+
snapshot, restore, topLayer, VARIANTS,
|
|
507
|
+
|
|
508
|
+
// orchestrator + browser runtime
|
|
509
|
+
Orchestrator, BrowserRuntime,
|
|
510
|
+
FRAGMENT_HEADER, SNAPSHOT_KEY,
|
|
511
|
+
} from "modal_stack"
|
|
512
|
+
|
|
513
|
+
import { install } from "modal_stack/install"
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
`install(application)` registers both Stimulus controllers โ that's the
|
|
517
|
+
entry point your `application.js` calls. The reducer is
|
|
518
|
+
side-effect-free and 100% covered; the browser adapter is the only
|
|
519
|
+
file that touches `<dialog>`, `history`, `fetch`, and `sessionStorage`.
|
|
520
|
+
|
|
521
|
+
### Capybara helpers
|
|
522
|
+
|
|
523
|
+
For system specs, opt in by requiring the RSpec entrypoint:
|
|
524
|
+
|
|
525
|
+
```ruby
|
|
526
|
+
# spec/rails_helper.rb
|
|
527
|
+
require "modal_stack/capybara/rspec"
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
This auto-includes the matchers in `type: :system` and `type: :feature`
|
|
531
|
+
specs. For Minitest, `require "modal_stack/capybara/minitest"`.
|
|
532
|
+
|
|
533
|
+
| Helper / matcher | Description |
|
|
534
|
+
| --------------------------------- | ----------- |
|
|
535
|
+
| `within_modal(depth: nil) { ... }`| Scopes Capybara matchers to a layer. Defaults to the topmost; `depth: 1` is the bottom. Raises `Capybara::ElementNotFound` when no such layer exists. |
|
|
536
|
+
| `have_modal_open` | Matcher: passes when the dialog has `[open]`. |
|
|
537
|
+
| `have_no_modal_open` | Negation. |
|
|
538
|
+
| `have_modal_stack(depth: nil)` | Matcher: asserts the live (non-leaving) layer count. |
|
|
539
|
+
| `have_no_modal_stack` | Negation. |
|
|
540
|
+
| `close_modal` | Sends `ESC` to the dialog. Honors `dismissible: false` (the layer stays). |
|
|
541
|
+
| `close_all_modals(max: 16)` | Pops every layer by sending `ESC` repeatedly. |
|
|
542
|
+
| `modal_stack_depth` | Reads the current depth from the live DOM. |
|
|
543
|
+
|
|
544
|
+
### Generator
|
|
545
|
+
|
|
546
|
+
```bash
|
|
547
|
+
$ bin/rails g modal_stack:install [flags]
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
| Flag | Type | Default | Values |
|
|
551
|
+
| --------------------- | ------- | ----------- | ------ |
|
|
552
|
+
| `--mode` | String | `auto` | `auto`, `importmap`, `jsbundling`, `sprockets` |
|
|
553
|
+
| `--css-provider` | String | `tailwind` | `tailwind`, `bootstrap`, `vanilla`, `none` |
|
|
554
|
+
| `--skip-layout` | Boolean | `false` | When set, doesn't inject the stylesheet/dialog helpers into `application.html.erb` |
|
|
555
|
+
| `--skip-js` | Boolean | `false` | When set, skips the Importmap pin / package install / Stimulus install wiring |
|
|
556
|
+
| `--skip-initializer` | Boolean | `false` | When set, doesn't generate `config/initializers/modal_stack.rb` |
|
|
557
|
+
|
|
558
|
+
`--mode=auto` detection order:
|
|
559
|
+
|
|
560
|
+
1. `config/importmap.rb` present โ `importmap`
|
|
561
|
+
2. Sprockets manifest present and no `config/importmap.rb` and no `package.json` โ `sprockets`
|
|
562
|
+
3. `package.json` present โ `jsbundling`
|
|
563
|
+
4. fallback โ `importmap`
|
|
564
|
+
|
|
565
|
+
All append operations are idempotent โ running the generator twice is
|
|
566
|
+
safe.
|
|
567
|
+
|
|
568
|
+
---
|
|
569
|
+
|
|
570
|
+
## ๐จ CSS presets & theming
|
|
571
|
+
|
|
572
|
+
Three opinionated stylesheets ship with the gem. Pick one with
|
|
573
|
+
`config.css_provider`:
|
|
574
|
+
|
|
575
|
+
| Preset | File | Best for |
|
|
576
|
+
| ------------ | ------------------------------------------ | -------- |
|
|
577
|
+
| `:tailwind` | `app/assets/stylesheets/modal_stack/tailwind.css` | Tailwind apps โ uses Tailwind tokens by default but overridable |
|
|
578
|
+
| `:bootstrap` | `app/assets/stylesheets/modal_stack/bootstrap.css` | Picks up Bootstrap 5 CSS variables |
|
|
579
|
+
| `:vanilla` | `app/assets/stylesheets/modal_stack/vanilla.css` | Framework-free, neutral defaults |
|
|
580
|
+
| `:none` | โ | Bring your own CSS |
|
|
581
|
+
|
|
582
|
+
All three presets are driven by the same `--modal-stack-*` CSS variables.
|
|
583
|
+
Override on `:root` to retheme without touching the gem:
|
|
584
|
+
|
|
585
|
+
```css
|
|
586
|
+
:root {
|
|
587
|
+
--modal-stack-radius: 16px;
|
|
588
|
+
--modal-stack-bg: #18181b;
|
|
589
|
+
--modal-stack-fg: #f4f4f5;
|
|
590
|
+
--modal-stack-shadow: 0 24px 60px -16px rgba(0, 0, 0, 0.6);
|
|
591
|
+
--modal-stack-backdrop: rgba(0, 0, 0, 0.7);
|
|
592
|
+
--modal-stack-duration: 180ms;
|
|
593
|
+
}
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
Variants and sizes are addressed via data attributes on the panel:
|
|
597
|
+
`[data-variant="drawer"][data-side="right"]`,
|
|
598
|
+
`[data-modal-stack-size="lg"]`, etc.
|
|
599
|
+
|
|
600
|
+
---
|
|
601
|
+
|
|
602
|
+
## โก Asset pipelines
|
|
603
|
+
|
|
604
|
+
`modal_stack` adapts to whichever pipeline you use โ the generator picks
|
|
605
|
+
the right setup automatically.
|
|
606
|
+
|
|
607
|
+
```
|
|
608
|
+
โโ Importmap (Rails 7+ default) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
609
|
+
โ config/importmap.rb โ
|
|
610
|
+
โ pin "modal_stack", to: "modal_stack.js" โ
|
|
611
|
+
โ app/javascript/controllers/application.js โ
|
|
612
|
+
โ import { install } from "modal_stack" โ
|
|
613
|
+
โ install(application) โ
|
|
614
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
615
|
+
|
|
616
|
+
โโ jsbundling (esbuild / vite / bun) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
617
|
+
โ package.json โ "@hotwired/stimulus": "^3" โ
|
|
618
|
+
โ app/javascript/controllers/application.js โ
|
|
619
|
+
โ import { install } from "modal_stack" โ
|
|
620
|
+
โ install(application) โ
|
|
621
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
622
|
+
|
|
623
|
+
โโ Sprockets (legacy) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
624
|
+
โ app/assets/config/manifest.js โ
|
|
625
|
+
โ //= link modal_stack.js โ
|
|
626
|
+
โ //= link modal_stack/<provider>.css โ
|
|
627
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
The Importmap-friendly bundle is pre-built and committed at
|
|
631
|
+
`app/assets/javascripts/modal_stack.js` (Stimulus + Turbo are externals,
|
|
632
|
+
provided by the host app).
|
|
633
|
+
|
|
634
|
+
---
|
|
635
|
+
|
|
636
|
+
## โฟ Accessibility
|
|
637
|
+
|
|
638
|
+
- **Native `<dialog>`** โ modern browsers handle focus trap, ESC, and `aria-modal` for free.
|
|
639
|
+
- **Inertness** โ underlying layers in a stack receive `inert`, so screen-readers and keyboard navigation skip them.
|
|
640
|
+
- **Reduced motion** โ when `prefers-reduced-motion: reduce` is set, presets collapse transitions to 1ms.
|
|
641
|
+
- **Focus restoration** โ when a layer is popped, focus returns to the trigger element (per `<dialog>` semantics).
|
|
642
|
+
- **Body scroll lock** โ `<body data-modal-stack-locked>` prevents background scroll while the dialog is open.
|
|
643
|
+
|
|
644
|
+
---
|
|
645
|
+
|
|
646
|
+
## ๐งช Development
|
|
647
|
+
|
|
648
|
+
```bash
|
|
649
|
+
$ git clone https://github.com/Metalzoid/modal_stack.git
|
|
650
|
+
$ cd modal_stack
|
|
651
|
+
$ bin/setup
|
|
652
|
+
$ bundle exec rake # rspec + rubocop
|
|
653
|
+
$ bundle exec rspec # Ruby specs (incl. system specs via Cuprite)
|
|
654
|
+
$ bun test # JS unit tests (state, orchestrator, runtime)
|
|
655
|
+
$ bin/build # rebuild app/assets/javascripts/modal_stack.js
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
System specs require Google Chrome locally:
|
|
659
|
+
|
|
660
|
+
```bash
|
|
661
|
+
$ brew install --cask google-chrome
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
Test against a specific Rails version:
|
|
665
|
+
|
|
666
|
+
```bash
|
|
667
|
+
$ bundle exec appraisal install
|
|
668
|
+
$ BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile bundle exec rake
|
|
669
|
+
$ BUNDLE_GEMFILE=gemfiles/rails_8_1_sprockets.gemfile bundle exec rake
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
### Repo layout
|
|
673
|
+
|
|
674
|
+
```
|
|
675
|
+
modal_stack/
|
|
676
|
+
โโโ app/
|
|
677
|
+
โ โโโ assets/
|
|
678
|
+
โ โ โโโ javascripts/modal_stack.js # pre-built importmap bundle (committed)
|
|
679
|
+
โ โ โโโ stylesheets/modal_stack/ # tailwind / bootstrap / vanilla presets
|
|
680
|
+
โ โโโ javascript/modal_stack/ # ES module sources + bun tests
|
|
681
|
+
โ โ โโโ state.js # pure reducer (100% coverage)
|
|
682
|
+
โ โ โโโ orchestrator.js # state โ command translator
|
|
683
|
+
โ โ โโโ runtime.js # BrowserRuntime IO adapter
|
|
684
|
+
โ โ โโโ install.js # Stimulus install hook
|
|
685
|
+
โ โ โโโ controllers/ # Stimulus controllers
|
|
686
|
+
โ โโโ views/layouts/modal.html.erb
|
|
687
|
+
โโโ lib/
|
|
688
|
+
โ โโโ modal_stack.rb # entry point + Engine
|
|
689
|
+
โ โโโ modal_stack/
|
|
690
|
+
โ โ โโโ configuration.rb
|
|
691
|
+
โ โ โโโ controller_extensions.rb
|
|
692
|
+
โ โ โโโ turbo_streams_extension.rb
|
|
693
|
+
โ โ โโโ helpers/ # ActionView helpers
|
|
694
|
+
โ โ โโโ capybara{.rb,/rspec.rb,/minitest.rb}
|
|
695
|
+
โ โโโ generators/modal_stack/install/
|
|
696
|
+
โโโ spec/
|
|
697
|
+
โ โโโ dummy/ # minimal Rails app for system specs
|
|
698
|
+
โ โโโ system/ # Capybara + Cuprite suite
|
|
699
|
+
โโโ Appraisals # Rails 7.2 โ 8.1 (+sprockets) variants
|
|
700
|
+
โโโ gemfiles/ # per-version gemfiles (generated)
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
---
|
|
704
|
+
|
|
705
|
+
## ๐ Releasing
|
|
706
|
+
|
|
707
|
+
1. Bump `lib/modal_stack/version.rb` to the next semantic version.
|
|
708
|
+
2. Move `[Unreleased]` items to a new dated section in `CHANGELOG.md`.
|
|
709
|
+
3. Push to `main`. The release workflow will:
|
|
710
|
+
- create and push the `vX.Y.Z` annotated tag,
|
|
711
|
+
- build the gem and create a GitHub Release with auto-generated notes,
|
|
712
|
+
- publish to RubyGems via OIDC trusted publishing.
|
|
713
|
+
|
|
714
|
+
To re-release an existing version, push the tag manually:
|
|
715
|
+
|
|
716
|
+
```bash
|
|
717
|
+
$ git tag -a v0.2.0 -m "Release v0.2.0" && git push origin v0.2.0
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
---
|
|
721
|
+
|
|
722
|
+
## ๐ค Contributing
|
|
723
|
+
|
|
724
|
+
Bug reports and pull requests welcome on GitHub at
|
|
725
|
+
<https://github.com/Metalzoid/modal_stack>.
|
|
726
|
+
|
|
727
|
+
1. Fork it
|
|
728
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
|
729
|
+
3. Make sure the full default task passes (`bundle exec rake`) and JS tests are green (`bun test`)
|
|
730
|
+
4. If you touched `app/javascript/`, rebuild the importmap bundle (`bin/build`) and commit the result
|
|
731
|
+
5. Push (`git push origin my-new-feature`)
|
|
732
|
+
6. Open a Pull Request
|
|
733
|
+
|
|
734
|
+
CI runs the full Ruby matrix (Ruby 3.2-4.0 ร Rails 7.2-8.1) plus the JS
|
|
735
|
+
suite, the build smoke test, and a bundle-freshness check that catches
|
|
736
|
+
PRs that edited the JS source without rebuilding the bundle.
|
|
737
|
+
|
|
738
|
+
---
|
|
739
|
+
|
|
740
|
+
## ๐ License
|
|
741
|
+
|
|
742
|
+
Released under the [MIT License](LICENSE.txt).
|
|
743
|
+
|
|
744
|
+
<div align="center">
|
|
745
|
+
|
|
746
|
+
Built with ๐ช by [Metalzoid](https://github.com/Metalzoid)
|
|
747
|
+
|
|
748
|
+
</div>
|