modal_stack 0.2.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +59 -0
- data/README.md +136 -52
- data/app/assets/javascripts/modal_stack.js +612 -63
- data/app/assets/stylesheets/modal_stack/bootstrap.css +120 -11
- data/app/assets/stylesheets/modal_stack/{tailwind.css → tailwind_v3.css} +82 -14
- data/app/assets/stylesheets/modal_stack/tailwind_v4.css +372 -0
- data/app/assets/stylesheets/modal_stack/vanilla.css +128 -11
- data/app/javascript/modal_stack/controllers/modal_stack_back_link_controller.js +32 -0
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +54 -0
- data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +29 -7
- data/app/javascript/modal_stack/install.js +7 -1
- data/app/javascript/modal_stack/orchestrator.js +132 -3
- data/app/javascript/modal_stack/orchestrator.test.js +264 -2
- data/app/javascript/modal_stack/runtime.js +222 -13
- data/app/javascript/modal_stack/runtime.test.js +151 -0
- data/app/javascript/modal_stack/state.js +338 -39
- data/app/javascript/modal_stack/state.test.js +400 -13
- data/app/views/modal_stack/_dialog.html.erb +1 -0
- data/app/views/modal_stack/_panel.html.erb +4 -0
- data/lib/generators/modal_stack/install/install_generator.rb +18 -4
- data/lib/generators/modal_stack/install/templates/initializer.rb +21 -5
- data/lib/generators/modal_stack/views/templates/_dialog.html.erb +9 -0
- data/lib/generators/modal_stack/views/templates/_panel.html.erb +18 -0
- data/lib/generators/modal_stack/views/views_generator.rb +50 -0
- data/lib/modal_stack/capybara.rb +21 -0
- data/lib/modal_stack/configuration.rb +43 -17
- data/lib/modal_stack/controller_extensions.rb +8 -1
- data/lib/modal_stack/engine.rb +2 -0
- data/lib/modal_stack/helpers/modal_back_link_helper.rb +48 -0
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +15 -3
- data/lib/modal_stack/helpers/modal_stack_container_helper.rb +42 -15
- data/lib/modal_stack/turbo_streams_extension.rb +56 -0
- data/lib/modal_stack/version.rb +1 -1
- data/lib/modal_stack.rb +5 -1
- metadata +11 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c6457e6ddbb7a769783e711fd3956cbefc390ec9ada62daa6aa4d5d8a2bb7896
|
|
4
|
+
data.tar.gz: bb59d60f0eea1bbfd57506b98eff623ed1cbea3f9cb2002406d32e0a6c22df36
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 69bdbb2322e3dad7984544f33b823a1ca62eb83993f51a8942a9321bfa7387978df9cc3c4ba3ee56dbfe49dd484a7a5b09f78fe811c7ebc636b8e51dd16e1e2a
|
|
7
|
+
data.tar.gz: b7c29d6355f7fb8f6fd15971037798eb22236c05bf19078d9a0cc7c58e080c09c30c0031ce98942b03fa183e8adcc301ac5f36186c77fa86443b63eafd417763
|
data/CHANGELOG.md
CHANGED
|
@@ -6,11 +6,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.1] - 2026-05-14
|
|
10
|
+
|
|
9
11
|
### Added
|
|
12
|
+
- **Frame transition CSS** fully implemented in all four presets (`tailwind_v4`, `tailwind_v3`, `bootstrap`, `vanilla`): `[data-transition="slide"]` animates the entering frame with `@starting-style translate` (slides in from the right on forward, from the left on back); `[data-transition="fade"]` cross-fades via `opacity`. The layer clips off-screen frames via a `:has([data-transition])` → `overflow: hidden` rule during animation, then the runtime removes `[data-transition]` / `[data-direction]` on `transitionend` (safety fallback: `#leaveTimeoutMs`-based timeout) so `overflow-y: auto` is fully restored afterward.
|
|
13
|
+
- **`top` / `bottom` drawer sides** in the `vanilla` and `bootstrap` presets (parity with `tailwind_v3` / `tailwind_v4`). Both presets gain the `--modal-stack-drawer-height` CSS token (default `22rem`), entry/exit `@starting-style` translate animations, `[data-leaving]` exit rules, and updated header comments.
|
|
14
|
+
- **`overscroll-behavior: contain`** on `[data-modal-stack-target="layer"]` in all four presets — prevents scroll chaining (Android pull-to-refresh, iOS bounce scrolling propagating to the page) when a modal's content is scrolled to its top or bottom boundary.
|
|
15
|
+
- **Safe-area inset** support: `padding-bottom: env(safe-area-inset-bottom, 0)` on `bottom_sheet` and `drawer[data-side="bottom"]` layers in all four presets — panel content is no longer obscured by the iOS home indicator.
|
|
16
|
+
- **`:focus-visible` ring** on `.modal-stack__panel-back` in all four presets — the back button now renders a keyboard-focus outline (`2px solid currentColor, offset 4px`) in full compliance with WCAG 2.4.7.
|
|
10
17
|
|
|
11
18
|
### Changed
|
|
19
|
+
- `modal_stack_container` helper: extracted `build_panel_attrs` private method — eliminates the `Metrics/MethodLength` RuboCop offense introduced by the `back:` / `transition:` parameters added in 0.4.0.
|
|
12
20
|
|
|
13
21
|
### Fixed
|
|
22
|
+
- `escapeAttr` fallback (used when `CSS.escape` is unavailable) now escapes `[` and `]` in addition to `"` and `\`. An unescaped `]` in a layer ID would prematurely close a CSS attribute selector such as `[data-layer-id="…"]`, producing either a silent no-op or a selector error depending on the browser.
|
|
23
|
+
|
|
24
|
+
## [0.4.0] - 2026-05-08
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- **Path navigation inside a single layer** — `turbo_stream.modal_path_to(...)` appends a frame to the top layer's path; `turbo_stream.modal_path_back(steps: N)` walks back. Each forward step pushes a real history entry, so browser-back walks frames one by one before closing the layer. `modal_pop` / X / ESC still close the whole layer at once and collapse all of its frames from history in a single jump.
|
|
28
|
+
- **`Layer.frames` in the reducer**: every layer now carries an immutable `frames: [{ url, stale }]` array. Layers without a path read as a single-frame array, so existing `push` / `pop` / `replaceTop` behavior is unchanged.
|
|
29
|
+
- **In-memory frame cache** in `BrowserRuntime`: forward frames cache their HTML so back-navigation is instant — no network round-trip. The cache is purged when the layer closes; `purgeFrameCacheAbove` evicts entries when the user steps back.
|
|
30
|
+
- **`X-Modal-Stack-Stale` response header**: controllers can mark a frame stale at render time (or pass `stale: true` on the stream action) so the runtime refetches instead of restoring from cache when the user steps back to it.
|
|
31
|
+
- **`modal_back_link` view helper**: renders a button wired to the new `modal-stack-back-link` Stimulus controller. Accepts `steps:` (default 1) to collapse multiple frames in a single click. Pairs with the new `back: true` option on `modal_stack_container`, which injects a back-button slot — hidden by CSS when the layer is on its first frame (`[data-frame-depth="1"]`).
|
|
32
|
+
- **`config.default_path_transition`** (default `:slide`, also `:fade` / `:none`): read by `modal_path_to` / `modal_path_back` when no per-call `transition:` is given.
|
|
33
|
+
- **New Capybara matchers**: `have_modal_frames(count)` and `within_modal_frame(depth: nil)` for testing path-based wizards.
|
|
34
|
+
- **`modal-stack#pathBack` Stimulus action** for direct wiring on custom UI (`data-action="click->modal-stack#pathBack"`, optional `data-modal-stack-steps-param`).
|
|
35
|
+
- **CSS preset hooks**: every preset (`tailwind_v3`, `tailwind_v4`, `bootstrap`, `vanilla`) gains `[data-modal-stack-frame] { display: contents }` so the runtime's frame wrapper is layout-invisible, plus a `.modal-stack__panel-back` reset.
|
|
36
|
+
- **`Orchestrator#pathTo` / `pathBack`** public methods, `pathTo` / `pathBack` reducer functions, `mountFrame` / `unmountFrame` / `clearFrameCache` runtime commands.
|
|
37
|
+
- New tests: 38 added across `state.test.js`, `orchestrator.test.js`, `runtime.test.js`, plus Ruby specs for the new helpers / config / stream actions and a 4-example system spec covering forward, back via helper, multi-step back, browser back through frames, and ESC mid-path.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
- **Snapshot format bumped to v2** to carry the new `frames` array. v1 snapshots from existing tabs are restored gracefully — each v1 layer is rehydrated with a synthesised single-frame array, so an in-flight upgrade doesn't break sessions.
|
|
41
|
+
- **Layer DOM contract**: each layer's content is now wrapped in `<div data-modal-stack-frame data-frame-index="N">`. With `display: contents`, host CSS is unaffected. The layer carries `data-frame-depth="N"` and `data-frame-index="N"` for matchers and CSS hooks (e.g. hide the back button at depth 1).
|
|
42
|
+
- **`replaceTop` collapses path frames**: when the top layer has multiple frames, `replaceTop` walks history back over the surplus, evicts the cache, and morphs to a single-frame layer. Documented as a deliberate trade-off.
|
|
43
|
+
- **`pop` / `closeAll`** walk history back across every frame of every popped layer in a single jump, so the back button doesn't land on stale frame entries pointing at a closed layer.
|
|
44
|
+
- **`handlePopstate`** routes on `frameIndex` as well as depth: lower frame-index in the same layer steps back via `unmountFrame`; forward to a frame the state no longer tracks (e.g. after a back) defers to `rebuildFromSnapshot`.
|
|
45
|
+
- **`fetchFragment` returns `{ fragment, stale }`**: the runtime surfaces the `X-Modal-Stack-Stale` header to the orchestrator. The orchestrator's prefetch accepts both the new shape and the legacy bare-fragment return for back-compat with custom test runtimes.
|
|
46
|
+
- `INITIALIZER_VERSION` bumped to `0.4.0`; the install template documents `default_path_transition`.
|
|
47
|
+
|
|
48
|
+
## [0.3.0] - 2026-05-03
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
- **Prefetch cache + dedupe** in `Orchestrator`: fragments fetched for `push` / `replaceTop` are cached per URL (TTL 30 s) and concurrent prefetches for the same URL share a single in-flight request. Aborts pending requests on `closeAll` and on stack-mismatching `popstate`.
|
|
52
|
+
- **Hover/focus prefetch** on `modal-stack-link`: links warm the cache on `pointerenter` / `focus` so the modal opens with no network latency on click. Opt out with `data-modal-stack-link-prefetch="false"`.
|
|
53
|
+
- **`Orchestrator#prefetch(url)`** public method (and matching `ModalStackController#prefetch`) for warming the cache from app code.
|
|
54
|
+
- **Request-scoped configuration** via `controller.modal_stack_config` (helper-method exposed). Rendering helpers now read configuration once per request instead of once per call.
|
|
55
|
+
- **`:tailwind_v4` CSS preset** that chains on Tailwind v4 `@theme` tokens (`--color-*`, `--radius-*`, `--shadow-*`, `--ease-*`, `--container-*`), so the modal stack inherits the host project's Tailwind theme automatically. Fallbacks match the Tailwind v4 defaults so the preset still renders correctly when `@theme` isn't redefined (or when Tailwind v4 isn't installed).
|
|
56
|
+
- **`:tailwind_v3` CSS preset** — the previous `:tailwind` preset, renamed for clarity. Static values aligned with Tailwind v3 defaults (Tailwind v3 doesn't expose tokens as CSS variables).
|
|
57
|
+
- `Configuration::CSS_PROVIDER_ALIASES` constant for legacy `:tailwind` → `:tailwind_v3` normalization.
|
|
58
|
+
- New tests: prefetch dedupe / cache hit / TTL / abort-on-closeAll / `prefetch` API; CSS-derived leave timeout; `:tailwind` alias normalization; generator default + alias mapping.
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
- **Animation safety timeout** is now derived from the `--modal-stack-duration` CSS variable on the `<dialog>` (1.5× the declared duration, floored at 300 ms). Falls back to 600 ms when the variable is unset, so existing host CSS keeps working.
|
|
62
|
+
- `BrowserRuntime#fetchFragment` accepts an `{ signal }` option to support `AbortController` cancellation.
|
|
63
|
+
- **CSS preset perf overhaul**: removed animated blur from all four presets — animating `backdrop-filter: blur(2px)` on a fullscreen surface costs ~190 ms/frame on Hi-DPI displays (Retina, 4K), which collapsed modal animations to ~5 fps.
|
|
64
|
+
- The old `--modal-stack-backdrop-blur` variable (radius only) is replaced with `--modal-stack-backdrop-filter` (full filter expression). Default is `none`, which lets Chrome skip the filter pass entirely — `backdrop-filter: blur(0)` still allocated a filter layer, so a `0` radius wasn't actually free. Apps that want a blurred backdrop now opt in with `:root { --modal-stack-backdrop-filter: blur(8px); }` (any filter expression accepted, not just `blur()`).
|
|
65
|
+
- `backdrop-filter` is no longer in the backdrop's `transition` list — when the user opts in, the filter is applied statically when the dialog opens, so the cost is paid once and the per-frame compositor work disappears.
|
|
66
|
+
- `filter: blur(0.5px)` on inert (underlying) layers has been dropped — only `opacity: 0.5` remains. The blur was visually negligible on screen but forced an extra GPU layer per stacked modal.
|
|
67
|
+
- The `filter` property has been removed from the layer's `transition` list since nothing animates it any more.
|
|
68
|
+
- **Backdrop fade now runs in parallel with the layer's leave animation** when closing the last modal (or `closeAll`). Previously the reducer emitted `[unmountTopLayer, …, closeDialog, …]` and the orchestrator awaited each command sequentially — `closeDialog` only fired after the layer's 220 ms `transitionend`, so the backdrop fade-out was perceived *after* the modal was gone (effective close time ≈ 440 ms). The reducer now emits `closeDialog` first; it's synchronous on the runtime, so the dialog's exit transition (opacity, backdrop background, `display`/`overlay` `allow-discrete`) starts immediately and runs alongside the layer's `[data-leaving]` transition. Total close time is now ≈ 220 ms. Fix applied to `pop`, `closeAll`, and the two `handlePopstate` branches that close the dialog.
|
|
69
|
+
- **`config.css_provider` default** is now `:tailwind_v3` (was `:tailwind`). Existing apps with `config.css_provider = :tailwind` keep working — the alias is normalized to `:tailwind_v3` on assignment, with no rendered CSS change.
|
|
70
|
+
- **Generator default `--css-provider`** is now `tailwind_v4` for new installs (was `tailwind`). Run `bin/rails g modal_stack:install --css-provider=tailwind_v3` to opt back in to the v3 preset, or pass the legacy `tailwind` flag — it's normalized to `tailwind_v3` in the generated initializer.
|
|
71
|
+
- The `app/assets/stylesheets/modal_stack/tailwind.css` file has been renamed to `tailwind_v3.css`. Sprockets manifests written by previous installs that contain `//= link modal_stack/tailwind.css` need the line updated to `tailwind_v3.css` (or `tailwind_v4.css`). Importmap / jsbundling installs aren't affected — they don't reference the stylesheet by filename.
|
|
72
|
+
- `INITIALIZER_VERSION` bumped to `0.3.0` because the generator template documents the new providers.
|
|
14
73
|
|
|
15
74
|
## [0.2.0] - 2026-05-03
|
|
16
75
|
|
data/README.md
CHANGED
|
@@ -9,9 +9,10 @@ browser back/forward support, and drive everything from imperative Turbo
|
|
|
9
9
|
Stream actions (`modal_push`, `modal_pop`, `modal_replace`, `modal_close_all`).
|
|
10
10
|
|
|
11
11
|
[](https://github.com/Metalzoid/modal_stack/actions)
|
|
12
|
-
[](https://rubygems.org/gems/modal_stack)
|
|
13
|
+
[](https://rubygems.org/gems/modal_stack)
|
|
14
|
+
[](https://www.ruby-lang.org/)
|
|
15
|
+
[](https://rubyonrails.org/)
|
|
15
16
|
[](LICENSE.txt)
|
|
16
17
|
|
|
17
18
|
</div>
|
|
@@ -81,16 +82,17 @@ browser-`back`-ing through nested confirmation steps — they break down.
|
|
|
81
82
|
## ✨ Features
|
|
82
83
|
|
|
83
84
|
- 🪜 **Stack of N layers** — push modals on top of modals; the underlying ones become `inert` automatically.
|
|
85
|
+
- 🛤️ **Path inside a layer** — `modal_path_to` / `modal_path_back` for wizards & flows: each step gets its own URL and history entry, browser-back walks frames one by one, the X button collapses the whole path in a single jump.
|
|
84
86
|
- 🪟 **Native `<dialog>`** — focus trap, ESC, accessible roles for free.
|
|
85
87
|
- 🔗 **Deep-linking** — the top of the stack lives in `window.location`. Bookmark it, share it, refresh it.
|
|
86
|
-
- ↩️ **Browser back = pop** —
|
|
87
|
-
- 🎮 **Imperative Turbo Stream actions** — `turbo_stream.modal_push / modal_pop / modal_replace / modal_close_all` from anywhere.
|
|
88
|
-
- 🎨 **
|
|
89
|
-
- 🪞 **Four variants** — `modal`, `drawer` (
|
|
88
|
+
- ↩️ **Browser back = pop or step-back** — frame paths collapse to single history jumps when the layer closes.
|
|
89
|
+
- 🎮 **Imperative Turbo Stream actions** — `turbo_stream.modal_push / modal_pop / modal_replace / modal_close_all / modal_path_to / modal_path_back` from anywhere.
|
|
90
|
+
- 🎨 **Four CSS presets** — Tailwind v4, Tailwind v3, Bootstrap, vanilla. All driven by `--modal-stack-*` CSS variables for easy retheming. Frame transitions (`slide`, `fade`) are fully implemented in every preset via `@starting-style`.
|
|
91
|
+
- 🪞 **Four variants** — `modal`, `drawer` (left/right/top/bottom), `bottom_sheet`, `confirmation`.
|
|
90
92
|
- 📏 **Sizes & custom dimensions** — `:sm` / `:md` / `:lg` / `:xl`, or pass `width:` / `height:` strings (`"42rem"`, `"min(90vw, 56rem)"`).
|
|
91
93
|
- 🔒 **Dismissible flag** — `dismissible: false` for confirmations users must answer.
|
|
92
94
|
- ♿ **`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`.
|
|
95
|
+
- 🧪 **Capybara matchers** — `within_modal`, `within_modal_frame`, `have_modal_open`, `have_modal_stack(depth: 2)`, `have_modal_frames(2)`, `close_modal`, `close_all_modals`.
|
|
94
96
|
- ⚡ **Three asset pipelines** — Importmap (default), jsbundling, Sprockets.
|
|
95
97
|
- 🧱 **Engine-based** — zero monkey-patching, pure Rails Engine + Stimulus + Turbo.
|
|
96
98
|
|
|
@@ -140,10 +142,11 @@ $ bin/rails g modal_stack:install --mode=sprockets # legacy apps
|
|
|
140
142
|
Pick the CSS preset that matches your stack:
|
|
141
143
|
|
|
142
144
|
```bash
|
|
143
|
-
$ bin/rails g modal_stack:install --css-provider=
|
|
144
|
-
$ bin/rails g modal_stack:install --css-provider=
|
|
145
|
-
$ bin/rails g modal_stack:install --css-provider=
|
|
146
|
-
$ bin/rails g modal_stack:install --css-provider=
|
|
145
|
+
$ bin/rails g modal_stack:install --css-provider=tailwind_v4 # default — chains on Tailwind v4 @theme tokens
|
|
146
|
+
$ bin/rails g modal_stack:install --css-provider=tailwind_v3 # static values aligned with Tailwind v3
|
|
147
|
+
$ bin/rails g modal_stack:install --css-provider=bootstrap # picks up Bootstrap 5 vars
|
|
148
|
+
$ bin/rails g modal_stack:install --css-provider=vanilla # framework-free
|
|
149
|
+
$ bin/rails g modal_stack:install --css-provider=none # bring your own CSS
|
|
147
150
|
```
|
|
148
151
|
|
|
149
152
|
### What the generator does
|
|
@@ -204,16 +207,17 @@ Everything lives in `config/initializers/modal_stack.rb`:
|
|
|
204
207
|
```ruby
|
|
205
208
|
ModalStack.configure do |config|
|
|
206
209
|
# ─── Presentation ─────────────────────────────────────────────────
|
|
207
|
-
config.css_provider = :
|
|
210
|
+
config.css_provider = :tailwind_v4 # :tailwind_v3 | :tailwind_v4 | :bootstrap | :vanilla | :none
|
|
208
211
|
config.default_variant = :modal # :modal | :drawer | :bottom_sheet | :confirmation
|
|
209
212
|
config.default_size = :md # :sm | :md | :lg | :xl
|
|
210
213
|
config.default_dismissible = true # ESC + backdrop click close the layer
|
|
211
214
|
|
|
212
215
|
# ─── Behavior ─────────────────────────────────────────────────────
|
|
213
|
-
config.max_depth = 5
|
|
214
|
-
config.max_depth_strategy = :warn
|
|
215
|
-
config.
|
|
216
|
-
config.
|
|
216
|
+
config.max_depth = 5 # hard cap on nested layers (nil to disable)
|
|
217
|
+
config.max_depth_strategy = :warn # :warn | :raise | :silent
|
|
218
|
+
config.default_path_transition = :slide # :slide | :fade | :none
|
|
219
|
+
config.respect_reduced_motion = true # honor prefers-reduced-motion
|
|
220
|
+
config.replace_turbo_confirm = false # use modal_stack confirmations for data-turbo-confirm
|
|
217
221
|
|
|
218
222
|
# ─── Wiring (rarely changed) ──────────────────────────────────────
|
|
219
223
|
config.dialog_id = "modal-stack-root"
|
|
@@ -359,10 +363,12 @@ strings — they're applied as inline styles, taking precedence over `size:`:
|
|
|
359
363
|
### Wizards & multi-step flows
|
|
360
364
|
|
|
361
365
|
For step-by-step flows inside a single layer (onboarding, multi-step forms),
|
|
362
|
-
|
|
363
|
-
`
|
|
364
|
-
|
|
365
|
-
|
|
366
|
+
use `modal_path_to` to advance and `modal_path_back` (or the
|
|
367
|
+
`modal_back_link` helper) to step back. The current frame's HTML is cached
|
|
368
|
+
in memory, so back-navigation is instant — no network round-trip — and
|
|
369
|
+
each forward step pushes a real history entry, so browser-back walks the
|
|
370
|
+
frames one by one. When the user closes the layer (X / ESC / backdrop /
|
|
371
|
+
`modal_pop`), every frame's history entry collapses in a single jump.
|
|
366
372
|
|
|
367
373
|
```ruby
|
|
368
374
|
class WizardController < ApplicationController
|
|
@@ -372,10 +378,11 @@ class WizardController < ApplicationController
|
|
|
372
378
|
respond_to do |format|
|
|
373
379
|
format.html # full-page render for deep-links
|
|
374
380
|
format.turbo_stream do
|
|
375
|
-
render turbo_stream: turbo_stream.
|
|
381
|
+
render turbo_stream: turbo_stream.modal_path_to(
|
|
376
382
|
template: "wizard/step_2",
|
|
377
|
-
history: :push,
|
|
378
383
|
url: wizard_step_2_path
|
|
384
|
+
# transition: :fade # :slide (default from config) | :fade | :none
|
|
385
|
+
# stale: true # force a refetch when the user steps back here
|
|
379
386
|
)
|
|
380
387
|
end
|
|
381
388
|
end
|
|
@@ -383,6 +390,36 @@ class WizardController < ApplicationController
|
|
|
383
390
|
end
|
|
384
391
|
```
|
|
385
392
|
|
|
393
|
+
```erb
|
|
394
|
+
<%# wizard/step_2.html.erb %>
|
|
395
|
+
<%= modal_stack_container(back: true) do %>
|
|
396
|
+
<h2>Step 2</h2>
|
|
397
|
+
<%# … form … %>
|
|
398
|
+
|
|
399
|
+
<%= modal_back_link "Back" %>
|
|
400
|
+
<%# or modal_back_link "Restart", steps: 99 # clamped at the first frame %>
|
|
401
|
+
<% end %>
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
`back: true` on `modal_stack_container` injects a back-button slot —
|
|
405
|
+
hidden by CSS at `[data-frame-depth="1"]`, visible from frame 2 onward.
|
|
406
|
+
|
|
407
|
+
To force a refresh when the user steps back to a frame (e.g. its data is
|
|
408
|
+
stale), either pass `stale: true` to `modal_path_to` or set the
|
|
409
|
+
`X-Modal-Stack-Stale: true` header on the response. The runtime will
|
|
410
|
+
refetch the URL instead of restoring from cache.
|
|
411
|
+
|
|
412
|
+
> ⚠️ **`replaceTop` collapses path frames.** When the top layer has a
|
|
413
|
+
> path and you call `turbo_stream.modal_replace`, the path is forgotten:
|
|
414
|
+
> the layer is morphed back to a single frame and the surplus history
|
|
415
|
+
> entries are walked back. Use `modal_path_back` if you want to step
|
|
416
|
+
> back, `modal_replace` if you want to swap the entire layer.
|
|
417
|
+
|
|
418
|
+
If you'd rather not keep a back-history (e.g. each step replaces the
|
|
419
|
+
previous and there's no going back), use the older `modal_replace`
|
|
420
|
+
mechanism with `history: :push` instead — the URL still changes per
|
|
421
|
+
step, but no in-memory cache is kept and `replaceTop` is the right tool.
|
|
422
|
+
|
|
386
423
|
### Stack depth & inertness
|
|
387
424
|
|
|
388
425
|
When a layer is pushed on top of another, the bottom layer automatically
|
|
@@ -420,7 +457,7 @@ ModalStack.reset_configuration! # test-fixture helper
|
|
|
420
457
|
|
|
421
458
|
| Attribute | Type | Default | Description |
|
|
422
459
|
| ---------------------------- | ------- | ------------------------ | ----------- |
|
|
423
|
-
| `css_provider` | Symbol | `:
|
|
460
|
+
| `css_provider` | Symbol | `:tailwind_v3` | One of `:tailwind_v3`, `:tailwind_v4`, `:bootstrap`, `:vanilla`, `:none`. Determines which stylesheet `modal_stack_stylesheet_link_tag` resolves to. The legacy `:tailwind` is accepted and normalized to `:tailwind_v3`. New installs default to `:tailwind_v4`. Validated. |
|
|
424
461
|
| `assets_mode` | Symbol | `:auto` | One of `:importmap`, `:jsbundling`, `:sprockets`, `:auto`. Used by the generator. Validated. |
|
|
425
462
|
| `default_variant` | Symbol | `:modal` | `:modal`, `:drawer`, `:bottom_sheet`, or `:confirmation`. Validated. |
|
|
426
463
|
| `default_size` | Symbol | `:md` | `:sm`, `:md`, `:lg`, `:xl`. Validated. |
|
|
@@ -428,6 +465,7 @@ ModalStack.reset_configuration! # test-fixture helper
|
|
|
428
465
|
| `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. |
|
|
429
466
|
| `max_depth` | Integer | `5` | Hard cap on stack depth. Coerced from strings; set to `nil` to disable. Validated. |
|
|
430
467
|
| `max_depth_strategy` | Symbol | `:warn` | One of `:warn`, `:raise`, `:silent`. See [Stack depth & inertness](#stack-depth--inertness). Validated. |
|
|
468
|
+
| `default_path_transition` | Symbol | `:slide` | Default transition for `modal_path_to` / `modal_path_back` when no per-call `transition:` is given. One of `:slide`, `:fade`, `:none`. Validated. |
|
|
431
469
|
| `request_header` | String | `"X-Modal-Stack-Request"` | HTTP header used by the JS runtime to signal stack-originated fetches. Read by `modal_stack_request?`. |
|
|
432
470
|
| `dialog_id` | String | `"modal-stack-root"` | The id of the singleton `<dialog>`. Override only on name collision. |
|
|
433
471
|
| `stack_root_data_attribute` | String | `"modal-stack"` | The Stimulus `data-controller` value attached to the `<dialog>`. |
|
|
@@ -444,8 +482,9 @@ Injected into `ActionView::Base` by the engine — available in every view.
|
|
|
444
482
|
| Helper | Description |
|
|
445
483
|
| ------------------------------------------------- | ----------- |
|
|
446
484
|
| `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. |
|
|
447
|
-
| `modal_stack_container(size:, variant:, side:, width:, height:, dismissible:, html: {}) { ... }` | Wraps a panel view with the markup the JS runtime expects.
|
|
448
|
-
| `
|
|
485
|
+
| `modal_stack_container(size:, variant:, side:, width:, height:, dismissible:, back:, transition:, html: {}) { ... }` | Wraps a panel view with the markup the JS runtime expects. `back: true` injects a back-button slot wired to `modal-stack#pathBack` (hidden by CSS at the first frame); `transition:` writes `data-modal-stack-transition` for host CSS hooks. |
|
|
486
|
+
| `modal_back_link(name = nil, **opts) { ... }` | Renders a `<button>` wired to the `modal-stack-back-link` Stimulus controller. Pass `steps:` (default `1`) to walk back multiple frames in a single click — clamped at the first frame, never closes the layer. Block form supported for custom markup (e.g. `<%= modal_back_link(class: "btn") { "← Back" } %>`). |
|
|
487
|
+
| `modal_stack_stylesheet_link_tag(**options)` | Emits `<link rel="stylesheet">` for the configured preset (`modal_stack/tailwind_v4.css`, etc.). Returns an empty SafeBuffer when `css_provider = :none`. |
|
|
449
488
|
| `modal_stack_dialog_tag(**html_options)` | Emits the singleton `<dialog id="modal-stack-root" data-controller="modal-stack">`. Drop just before `</body>`. |
|
|
450
489
|
| `modal_stack_javascript_tag` | Reserved hook for layouts; currently a no-op (JS is loaded via your bundler / importmap). |
|
|
451
490
|
|
|
@@ -470,11 +509,21 @@ options as Turbo's built-in stream actions (`partial:`, `template:`,
|
|
|
470
509
|
| ----------------------------------------------------------- | ------- |
|
|
471
510
|
| `modal_push(content = nil, **opts, &block)` | `variant:`, `dismissible:`, `url:`, `side:`, `size:`, `width:`, `height:`, plus any rendering options |
|
|
472
511
|
| `modal_pop` | — |
|
|
473
|
-
| `modal_replace(content = nil, **opts, &block)` | All `modal_push` options plus `history:` (`:replace` *(default)* or `:push`) and `layer_id
|
|
512
|
+
| `modal_replace(content = nil, **opts, &block)` | All `modal_push` options plus `history:` (`:replace` *(default)* or `:push`) and `layer_id:`. **Resets the path to a single frame** when the top layer has multiple frames. |
|
|
474
513
|
| `modal_close_all` | — |
|
|
514
|
+
| `modal_path_to(content = nil, **opts, &block)` | `url:`, `transition:` (`:slide` *(default from config)* / `:fade` / `:none`), `stale:` (when true, runtime refetches on back), `layer_id:`, plus any rendering options. |
|
|
515
|
+
| `modal_path_back(steps: 1, transition: nil)` | `steps:` (positive integer, clamped at the first frame), `transition:` override. |
|
|
475
516
|
|
|
476
517
|
`history: :push` raises `ArgumentError` if given any value other than
|
|
477
|
-
`:push` or `:replace`.
|
|
518
|
+
`:push` or `:replace`. Unknown transitions on `modal_path_to` /
|
|
519
|
+
`modal_path_back` raise `ArgumentError` as well.
|
|
520
|
+
|
|
521
|
+
> 💡 **Stale frames.** When you want a frame to refetch on back instead
|
|
522
|
+
> of restoring from the in-memory cache, either pass `stale: true` to
|
|
523
|
+
> `modal_path_to` or set the `X-Modal-Stack-Stale: true` header on the
|
|
524
|
+
> response. Useful when the frame's data is expected to change between
|
|
525
|
+
> the forward visit and the back visit (e.g. a list that the user
|
|
526
|
+
> mutated downstream).
|
|
478
527
|
|
|
479
528
|
### Layer DOM contract
|
|
480
529
|
|
|
@@ -487,24 +536,42 @@ Each pushed layer is a `<div>` inside the dialog with:
|
|
|
487
536
|
data-variant="drawer"
|
|
488
537
|
data-side="right"
|
|
489
538
|
data-dismissible="true"
|
|
539
|
+
data-frame-index="1"
|
|
540
|
+
data-frame-depth="2"
|
|
490
541
|
data-modal-stack-size="lg"
|
|
491
542
|
data-modal-stack-width="42rem" style="width: 42rem;">
|
|
492
|
-
|
|
543
|
+
<div data-modal-stack-frame
|
|
544
|
+
data-frame-index="1"
|
|
545
|
+
data-transition="slide"
|
|
546
|
+
data-direction="forward">
|
|
547
|
+
<!-- panel content -->
|
|
548
|
+
</div>
|
|
493
549
|
</div>
|
|
494
550
|
```
|
|
495
551
|
|
|
552
|
+
The frame wrapper sits between the layer and the panel content. It
|
|
553
|
+
defaults to `display: contents` so it is invisible to host CSS. When a
|
|
554
|
+
transition is requested, the runtime sets `[data-transition]` and
|
|
555
|
+
`[data-direction]` on the entering frame — the shipped presets pick
|
|
556
|
+
these up via `@starting-style` rules (`slide`: translate from right/left,
|
|
557
|
+
`fade`: opacity 0→1) and restore `overflow-y: auto` on the layer once
|
|
558
|
+
`transitionend` fires. `data-frame-depth="N"` on the layer reflects the
|
|
559
|
+
current path length — `1` for layers without a path, `N` for layers `N`
|
|
560
|
+
frames deep.
|
|
561
|
+
|
|
496
562
|
Underlying layers receive `inert`. A layer being unmounted gets
|
|
497
563
|
`data-leaving=""` for the duration of the exit transition (capped at
|
|
498
564
|
600ms even if the host CSS forgets to define one).
|
|
499
565
|
|
|
500
566
|
### Stimulus controllers
|
|
501
567
|
|
|
502
|
-
|
|
568
|
+
All three controllers are registered via `installModalStack(application)`.
|
|
503
569
|
|
|
504
570
|
| Identifier | Role |
|
|
505
571
|
| ---------------------- | ---- |
|
|
506
|
-
| `modal-stack` | Bound to the singleton `<dialog>`. Wires popstate / cancel / backdrop-click listeners, registers the `Turbo.StreamActions`, hosts the Orchestrator. |
|
|
572
|
+
| `modal-stack` | Bound to the singleton `<dialog>`. Wires popstate / cancel / backdrop-click listeners, registers the `Turbo.StreamActions`, hosts the Orchestrator. Also exposes a public `pathBack` action — wire it on any element via `data-action="click->modal-stack#pathBack"` (with optional `data-modal-stack-steps-param="2"`). |
|
|
507
573
|
| `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. |
|
|
574
|
+
| `modal-stack-back-link`| Attached to elements rendered by `modal_back_link`. On `click`, calls `orchestrator.pathBack({ steps })` — never closes the layer; clamps at the first frame. |
|
|
508
575
|
|
|
509
576
|
### JS runtime
|
|
510
577
|
|
|
@@ -514,11 +581,13 @@ The package exports a small functional core + a browser adapter:
|
|
|
514
581
|
import {
|
|
515
582
|
// pure reducer — no IO, no DOM
|
|
516
583
|
createStack, push, pop, replaceTop, closeAll, handlePopstate,
|
|
517
|
-
|
|
584
|
+
pathTo, pathBack,
|
|
585
|
+
snapshot, restore, topLayer, VARIANTS, TRANSITIONS,
|
|
586
|
+
ModalStackDepthError,
|
|
518
587
|
|
|
519
588
|
// orchestrator + browser runtime
|
|
520
589
|
Orchestrator, BrowserRuntime,
|
|
521
|
-
FRAGMENT_HEADER, SNAPSHOT_KEY, SCROLLBAR_WIDTH_VAR,
|
|
590
|
+
FRAGMENT_HEADER, STALE_HEADER, SNAPSHOT_KEY, SCROLLBAR_WIDTH_VAR,
|
|
522
591
|
} from "modal_stack"
|
|
523
592
|
|
|
524
593
|
import { install } from "modal_stack/install"
|
|
@@ -530,12 +599,13 @@ side-effect-free and 100% covered; the browser adapter is the only
|
|
|
530
599
|
file that touches `<dialog>`, `history`, `fetch`, and `sessionStorage`.
|
|
531
600
|
|
|
532
601
|
The reducer's command type vocabulary (`mountLayer`, `morphTopLayer`,
|
|
533
|
-
`unmountTopLayer`, `unmountAllLayers`, `
|
|
534
|
-
`
|
|
535
|
-
`
|
|
536
|
-
`
|
|
537
|
-
|
|
538
|
-
|
|
602
|
+
`unmountTopLayer`, `unmountAllLayers`, `mountFrame`, `unmountFrame`,
|
|
603
|
+
`clearFrameCache`, `showDialog`, `closeDialog`, `lockScroll`,
|
|
604
|
+
`unlockScroll`, `inertLayer`, `pushHistory`, `replaceHistory`,
|
|
605
|
+
`historyBack`, `rebuildFromSnapshot`, `persistSnapshot`, `clearSnapshot`)
|
|
606
|
+
forms the contract between `state.js` and any runtime — swap in a
|
|
607
|
+
custom adapter (e.g. for Hotwire Native) by implementing one method
|
|
608
|
+
per command name.
|
|
539
609
|
|
|
540
610
|
#### Custom events
|
|
541
611
|
|
|
@@ -544,7 +614,7 @@ The `<dialog>` emits two `CustomEvent`s that bubble to `document`:
|
|
|
544
614
|
| Event | `detail` | Fired when |
|
|
545
615
|
| ---------------------- | ------------------------------------------- | ---------- |
|
|
546
616
|
| `modal_stack:ready` | `{ stackId }` | The Stimulus controller has connected and the orchestrator is ready. |
|
|
547
|
-
| `modal_stack:error` | `{ action, error }` | A Turbo Stream action (`modal_push`/`modal_pop`/`modal_replace`/`modal_close_all`) threw or rejected. The page is not crashed; surface UI feedback in the listener. |
|
|
617
|
+
| `modal_stack:error` | `{ action, error }` | A Turbo Stream action (`modal_push`/`modal_pop`/`modal_replace`/`modal_close_all`/`modal_path_to`/`modal_path_back`) threw or rejected. The page is not crashed; surface UI feedback in the listener. |
|
|
548
618
|
|
|
549
619
|
```js
|
|
550
620
|
document.addEventListener("modal_stack:error", (event) => {
|
|
@@ -577,10 +647,12 @@ specs. For Minitest, `require "modal_stack/capybara/minitest"`.
|
|
|
577
647
|
| Helper / matcher | Description |
|
|
578
648
|
| --------------------------------- | ----------- |
|
|
579
649
|
| `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. |
|
|
650
|
+
| `within_modal_frame(depth: nil) { ... }` | Scopes Capybara matchers to the **current** frame inside a layer (the one that's not animating out). Useful when a path is in flight. |
|
|
580
651
|
| `have_modal_open` | Matcher: passes when the dialog has `[open]`. |
|
|
581
652
|
| `have_no_modal_open` | Negation. |
|
|
582
653
|
| `have_modal_stack(depth: nil)` | Matcher: asserts the live (non-leaving) layer count. |
|
|
583
654
|
| `have_no_modal_stack` | Negation. |
|
|
655
|
+
| `have_modal_frames(count)` | Matcher: asserts the top layer's path has `count` frames. Layers without a path read as `1`. |
|
|
584
656
|
| `close_modal` | Sends `ESC` to the dialog. Honors `dismissible: false` (the layer stays). |
|
|
585
657
|
| `close_all_modals(max: 16)` | Pops every layer by sending `ESC` repeatedly. |
|
|
586
658
|
| `modal_stack_depth` | Reads the current depth from the live DOM. |
|
|
@@ -594,7 +666,7 @@ $ bin/rails g modal_stack:install [flags]
|
|
|
594
666
|
| Flag | Type | Default | Values |
|
|
595
667
|
| --------------------- | ------- | ----------- | ------ |
|
|
596
668
|
| `--mode` | String | `auto` | `auto`, `importmap`, `jsbundling`, `sprockets` |
|
|
597
|
-
| `--css-provider` | String | `
|
|
669
|
+
| `--css-provider` | String | `tailwind_v4` | `tailwind_v3`, `tailwind_v4`, `bootstrap`, `vanilla`, `none` (legacy `tailwind` accepted, normalized to `tailwind_v3`) |
|
|
598
670
|
| `--skip-layout` | Boolean | `false` | When set, doesn't inject the stylesheet/dialog helpers into `application.html.erb` |
|
|
599
671
|
| `--skip-js` | Boolean | `false` | When set, skips the Importmap pin / package install / Stimulus install wiring |
|
|
600
672
|
| `--skip-initializer` | Boolean | `false` | When set, doesn't generate `config/initializers/modal_stack.rb` |
|
|
@@ -613,17 +685,27 @@ safe.
|
|
|
613
685
|
|
|
614
686
|
## 🎨 CSS presets & theming
|
|
615
687
|
|
|
616
|
-
|
|
688
|
+
Four opinionated stylesheets ship with the gem. Pick one with
|
|
617
689
|
`config.css_provider`:
|
|
618
690
|
|
|
619
|
-
| Preset
|
|
620
|
-
|
|
|
621
|
-
| `:
|
|
622
|
-
| `:
|
|
623
|
-
| `:
|
|
624
|
-
| `:
|
|
691
|
+
| Preset | File | Best for |
|
|
692
|
+
| --------------- | ----------------------------------------------------- | -------- |
|
|
693
|
+
| `:tailwind_v4` | `app/assets/stylesheets/modal_stack/tailwind_v4.css` | Tailwind v4 apps — chains on `@theme` tokens (`--color-*`, `--radius-*`, `--shadow-*`, `--container-*`) so the modal picks up your theme automatically. Falls back to Tailwind defaults when `@theme` isn't redefined. |
|
|
694
|
+
| `:tailwind_v3` | `app/assets/stylesheets/modal_stack/tailwind_v3.css` | Tailwind v3 apps — static values aligned with Tailwind v3 defaults (v3 doesn't expose tokens as CSS variables). Legacy `:tailwind` is accepted as an alias. |
|
|
695
|
+
| `:bootstrap` | `app/assets/stylesheets/modal_stack/bootstrap.css` | Picks up Bootstrap 5 CSS variables |
|
|
696
|
+
| `:vanilla` | `app/assets/stylesheets/modal_stack/vanilla.css` | Framework-free, neutral defaults |
|
|
697
|
+
| `:none` | — | Bring your own CSS |
|
|
698
|
+
|
|
699
|
+
All four presets share the following capabilities:
|
|
700
|
+
|
|
701
|
+
- **Frame transitions** — `slide` (horizontal translate via `@starting-style`) and `fade` (opacity) are both fully implemented. The entering frame animates in; the layer clips off-screen frames with `overflow: hidden` for the duration, then restores `overflow-y: auto`.
|
|
702
|
+
- **All four drawer sides** — `left`, `right`, `top`, `bottom` with matching entry/exit animations.
|
|
703
|
+
- **Mobile scroll containment** — `overscroll-behavior: contain` prevents scroll chaining when modal content reaches its boundary.
|
|
704
|
+
- **Safe-area inset** — `bottom_sheet` and `drawer[data-side="bottom"]` apply `env(safe-area-inset-bottom)` padding.
|
|
705
|
+
- **Keyboard focus ring** — `.modal-stack__panel-back` has a `:focus-visible` outline.
|
|
706
|
+
- **Reduced-motion** — frame transition durations collapse to `1ms` alongside layer transitions.
|
|
625
707
|
|
|
626
|
-
All
|
|
708
|
+
All presets are driven by the same `--modal-stack-*` CSS variables.
|
|
627
709
|
Override on `:root` to retheme without touching the gem:
|
|
628
710
|
|
|
629
711
|
```css
|
|
@@ -681,9 +763,11 @@ provided by the host app).
|
|
|
681
763
|
|
|
682
764
|
- **Native `<dialog>`** — modern browsers handle focus trap, ESC, and `aria-modal` for free.
|
|
683
765
|
- **Inertness** — underlying layers in a stack receive `inert`, so screen-readers and keyboard navigation skip them.
|
|
684
|
-
- **Reduced motion** — when `prefers-reduced-motion: reduce` is set, presets collapse transitions to
|
|
766
|
+
- **Reduced motion** — when `prefers-reduced-motion: reduce` is set, presets collapse all transitions (layer and frame) to 1 ms.
|
|
685
767
|
- **Focus restoration** — when a layer is popped, focus returns to the trigger element (per `<dialog>` semantics).
|
|
686
|
-
- **
|
|
768
|
+
- **Back-button focus ring** — `.modal-stack__panel-back` renders a `:focus-visible` outline on keyboard focus in every preset.
|
|
769
|
+
- **Body scroll lock** — `<body data-modal-stack-locked>` prevents background scroll while the dialog is open; `overscroll-behavior: contain` on layers additionally blocks scroll chaining (pull-to-refresh, iOS bounce) when modal content is scrolled to its boundary.
|
|
770
|
+
- **Safe-area padding** — `bottom_sheet` and bottom drawers apply `env(safe-area-inset-bottom)` so content is never hidden under the iOS home indicator.
|
|
687
771
|
|
|
688
772
|
---
|
|
689
773
|
|
|
@@ -720,7 +804,7 @@ modal_stack/
|
|
|
720
804
|
├── app/
|
|
721
805
|
│ ├── assets/
|
|
722
806
|
│ │ ├── javascripts/modal_stack.js # pre-built importmap bundle (committed)
|
|
723
|
-
│ │ └── stylesheets/modal_stack/ #
|
|
807
|
+
│ │ └── stylesheets/modal_stack/ # tailwind_v3 / tailwind_v4 / bootstrap / vanilla presets
|
|
724
808
|
│ ├── javascript/modal_stack/ # ES module sources + bun tests
|
|
725
809
|
│ │ ├── state.js # pure reducer (100% coverage)
|
|
726
810
|
│ │ ├── orchestrator.js # state → command translator
|