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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +136 -52
  4. data/app/assets/javascripts/modal_stack.js +612 -63
  5. data/app/assets/stylesheets/modal_stack/bootstrap.css +120 -11
  6. data/app/assets/stylesheets/modal_stack/{tailwind.css → tailwind_v3.css} +82 -14
  7. data/app/assets/stylesheets/modal_stack/tailwind_v4.css +372 -0
  8. data/app/assets/stylesheets/modal_stack/vanilla.css +128 -11
  9. data/app/javascript/modal_stack/controllers/modal_stack_back_link_controller.js +32 -0
  10. data/app/javascript/modal_stack/controllers/modal_stack_controller.js +54 -0
  11. data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +29 -7
  12. data/app/javascript/modal_stack/install.js +7 -1
  13. data/app/javascript/modal_stack/orchestrator.js +132 -3
  14. data/app/javascript/modal_stack/orchestrator.test.js +264 -2
  15. data/app/javascript/modal_stack/runtime.js +222 -13
  16. data/app/javascript/modal_stack/runtime.test.js +151 -0
  17. data/app/javascript/modal_stack/state.js +338 -39
  18. data/app/javascript/modal_stack/state.test.js +400 -13
  19. data/app/views/modal_stack/_dialog.html.erb +1 -0
  20. data/app/views/modal_stack/_panel.html.erb +4 -0
  21. data/lib/generators/modal_stack/install/install_generator.rb +18 -4
  22. data/lib/generators/modal_stack/install/templates/initializer.rb +21 -5
  23. data/lib/generators/modal_stack/views/templates/_dialog.html.erb +9 -0
  24. data/lib/generators/modal_stack/views/templates/_panel.html.erb +18 -0
  25. data/lib/generators/modal_stack/views/views_generator.rb +50 -0
  26. data/lib/modal_stack/capybara.rb +21 -0
  27. data/lib/modal_stack/configuration.rb +43 -17
  28. data/lib/modal_stack/controller_extensions.rb +8 -1
  29. data/lib/modal_stack/engine.rb +2 -0
  30. data/lib/modal_stack/helpers/modal_back_link_helper.rb +48 -0
  31. data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +15 -3
  32. data/lib/modal_stack/helpers/modal_stack_container_helper.rb +42 -15
  33. data/lib/modal_stack/turbo_streams_extension.rb +56 -0
  34. data/lib/modal_stack/version.rb +1 -1
  35. data/lib/modal_stack.rb +5 -1
  36. metadata +11 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5d872f05608ece432767e38bbe58ec38bf97c60a0cd90ccf1afd37f41f7f6192
4
- data.tar.gz: 3da69811422baf2e1e7927bae0dbe46cf75311b5214e34e90e0a04e8aa691250
3
+ metadata.gz: c6457e6ddbb7a769783e711fd3956cbefc390ec9ada62daa6aa4d5d8a2bb7896
4
+ data.tar.gz: bb59d60f0eea1bbfd57506b98eff623ed1cbea3f9cb2002406d32e0a6c22df36
5
5
  SHA512:
6
- metadata.gz: 3d9470889c9fa8b2d967174818eb813a90657baac94a70edadf3f7a416400e364cc5e15ed9b7274544b138f8b5647189e21143b14aa27d67cac79ae711822d20
7
- data.tar.gz: 5dcdab2a2dca8c1b57b67e62f1daff68cc3b04395e5e66d9a3da933de02caef78460116c4b097fb040fa33011a83eb79f0645d6e273e91ad21ec552a27023d1b
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
  [![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/)
12
+ [![Gem Version](https://img.shields.io/gem/v/modal_stack.svg?label=gem)](https://rubygems.org/gems/modal_stack)
13
+ [![Downloads](https://img.shields.io/gem/dt/modal_stack?label=downloads)](https://rubygems.org/gems/modal_stack)
14
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
15
+ [![Rails](https://img.shields.io/badge/rails-7.2%20%7C%208.0%20%7C%208.1-CC0000?logo=rubyonrails&logoColor=white)](https://rubyonrails.org/)
15
16
  [![License](https://img.shields.io/badge/license-MIT-green.svg)](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** — 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`.
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=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
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 = :tailwind # :tailwind | :bootstrap | :vanilla | :none
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 # hard cap on nested layers (nil to disable)
214
- config.max_depth_strategy = :warn # :warn | :raise | :silent
215
- config.respect_reduced_motion = true # honor prefers-reduced-motion
216
- config.replace_turbo_confirm = false # use modal_stack confirmations for data-turbo-confirm
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
- combine `modal_push` (for the initial open) with `modal_replace` carrying
363
- `history: :push` between steps. Each step gets its own URL and a real
364
- history entry, so browser-back returns to the previous step (not the page
365
- behind the wizard):
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.modal_replace(
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 | `:tailwind` | One of `:tailwind`, `:bootstrap`, `:vanilla`, `:none`. Determines which stylesheet `modal_stack_stylesheet_link_tag` resolves to. Validated. |
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. Renders a `<div>` carrying the size/variant/dismissible/dimension data attributes. |
448
- | `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`. |
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
- <!-- panel content -->
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
- Both controllers are registered via `installModalStack(application)`.
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
- snapshot, restore, topLayer, VARIANTS, ModalStackDepthError,
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`, `showDialog`, `closeDialog`,
534
- `lockScroll`, `unlockScroll`, `inertLayer`, `pushHistory`,
535
- `replaceHistory`, `historyBack`, `rebuildFromSnapshot`, `persistSnapshot`,
536
- `clearSnapshot`) forms the contract between `state.js` and any runtime —
537
- swap in a custom adapter (e.g. for Hotwire Native) by implementing one
538
- method per command name.
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 | `tailwind` | `tailwind`, `bootstrap`, `vanilla`, `none` |
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
- Three opinionated stylesheets ship with the gem. Pick one with
688
+ Four opinionated stylesheets ship with the gem. Pick one with
617
689
  `config.css_provider`:
618
690
 
619
- | Preset | File | Best for |
620
- | ------------ | ------------------------------------------ | -------- |
621
- | `:tailwind` | `app/assets/stylesheets/modal_stack/tailwind.css` | Tailwind apps — uses Tailwind tokens by default but overridable |
622
- | `:bootstrap` | `app/assets/stylesheets/modal_stack/bootstrap.css` | Picks up Bootstrap 5 CSS variables |
623
- | `:vanilla` | `app/assets/stylesheets/modal_stack/vanilla.css` | Framework-free, neutral defaults |
624
- | `:none` | | Bring your own CSS |
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 three presets are driven by the same `--modal-stack-*` CSS variables.
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 1ms.
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
- - **Body scroll lock** — `<body data-modal-stack-locked>` prevents background scroll while the dialog is open.
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/ # tailwind / bootstrap / vanilla presets
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