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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +37 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +748 -0
  6. data/Rakefile +12 -0
  7. data/app/assets/javascripts/modal_stack.js +756 -0
  8. data/app/assets/stylesheets/modal_stack/bootstrap.css +232 -0
  9. data/app/assets/stylesheets/modal_stack/tailwind.css +303 -0
  10. data/app/assets/stylesheets/modal_stack/vanilla.css +219 -0
  11. data/app/javascript/modal_stack/controllers/modal_stack_controller.js +149 -0
  12. data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +34 -0
  13. data/app/javascript/modal_stack/index.js +15 -0
  14. data/app/javascript/modal_stack/install.js +15 -0
  15. data/app/javascript/modal_stack/orchestrator.js +98 -0
  16. data/app/javascript/modal_stack/orchestrator.test.js +260 -0
  17. data/app/javascript/modal_stack/runtime.js +217 -0
  18. data/app/javascript/modal_stack/runtime.test.js +134 -0
  19. data/app/javascript/modal_stack/state.js +315 -0
  20. data/app/javascript/modal_stack/state.test.js +508 -0
  21. data/app/views/layouts/modal.html.erb +6 -0
  22. data/lib/generators/modal_stack/install/install_generator.rb +224 -0
  23. data/lib/generators/modal_stack/install/templates/initializer.rb +57 -0
  24. data/lib/modal_stack/capybara/minitest.rb +9 -0
  25. data/lib/modal_stack/capybara/rspec.rb +9 -0
  26. data/lib/modal_stack/capybara.rb +85 -0
  27. data/lib/modal_stack/configuration.rb +90 -0
  28. data/lib/modal_stack/controller_extensions.rb +73 -0
  29. data/lib/modal_stack/engine.rb +44 -0
  30. data/lib/modal_stack/helpers/modal_link_helper.rb +65 -0
  31. data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +45 -0
  32. data/lib/modal_stack/helpers/modal_stack_container_helper.rb +36 -0
  33. data/lib/modal_stack/initializer_version_check.rb +33 -0
  34. data/lib/modal_stack/turbo_streams_extension.rb +73 -0
  35. data/lib/modal_stack/version.rb +5 -0
  36. data/lib/modal_stack.rb +36 -0
  37. 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
+ [![CI](https://github.com/Metalzoid/modal_stack/actions/workflows/main.yml/badge.svg)](https://github.com/Metalzoid/modal_stack/actions)
12
+ [![Gem Version](https://badge.fury.io/rb/modal_stack.svg)](https://rubygems.org/gems/modal_stack)
13
+ [![Ruby](https://img.shields.io/gem/ruby-version/modal_stack?label=ruby)](https://www.ruby-lang.org/)
14
+ [![Rails](https://img.shields.io/gem/dv/modal_stack/railties?label=rails)](https://rubyonrails.org/)
15
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](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>