modal_stack 0.3.0 → 0.4.2

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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/README.md +187 -36
  4. data/app/assets/javascripts/modal_stack.js +693 -73
  5. data/app/assets/stylesheets/modal_stack/bootstrap.css +113 -3
  6. data/app/assets/stylesheets/modal_stack/tailwind_v3.css +63 -2
  7. data/app/assets/stylesheets/modal_stack/tailwind_v4.css +63 -2
  8. data/app/assets/stylesheets/modal_stack/vanilla.css +121 -3
  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 +161 -8
  11. data/app/javascript/modal_stack/install.js +7 -1
  12. data/app/javascript/modal_stack/orchestrator.js +70 -10
  13. data/app/javascript/modal_stack/orchestrator.test.js +98 -2
  14. data/app/javascript/modal_stack/runtime.js +316 -9
  15. data/app/javascript/modal_stack/runtime.test.js +90 -6
  16. data/app/javascript/modal_stack/state.js +343 -45
  17. data/app/javascript/modal_stack/state.test.js +404 -17
  18. data/app/views/modal_stack/_dialog.html.erb +1 -0
  19. data/app/views/modal_stack/_panel.html.erb +4 -0
  20. data/lib/generators/modal_stack/install/templates/initializer.rb +9 -0
  21. data/lib/generators/modal_stack/views/templates/_dialog.html.erb +9 -0
  22. data/lib/generators/modal_stack/views/templates/_panel.html.erb +18 -0
  23. data/lib/generators/modal_stack/views/views_generator.rb +50 -0
  24. data/lib/modal_stack/capybara.rb +21 -0
  25. data/lib/modal_stack/configuration.rb +37 -16
  26. data/lib/modal_stack/engine.rb +2 -0
  27. data/lib/modal_stack/helpers/modal_back_link_helper.rb +48 -0
  28. data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +7 -1
  29. data/lib/modal_stack/helpers/modal_stack_container_helper.rb +42 -15
  30. data/lib/modal_stack/turbo_streams_extension.rb +56 -0
  31. data/lib/modal_stack/version.rb +1 -1
  32. data/lib/modal_stack.rb +5 -1
  33. metadata +9 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a4ea30705b3d178cd2be1ad1a3b0a7b5487b40490b327d0e727b29e9e644f06f
4
- data.tar.gz: '049b2eafb22af9cffd3ec3d454a23550dff04273cb23e4b96146a3716a62a383'
3
+ metadata.gz: 1ef421729ab66f062b0cd608549828f9cec8dd70a8854ad48b94baf2932c70b0
4
+ data.tar.gz: 3de87974331a9634bc32baba6563f20b34da3121594a32627335bfc13b13d8df
5
5
  SHA512:
6
- metadata.gz: da14584321b5c28cd93ecbc9dd260865313c5ccd62e17d7846a734c3da47b54e3f0ac20b703542cb14d8cc93fe3611c5de0d7e17c06900ecab92c554ae5c806f
7
- data.tar.gz: 111ac685cf469d968fb71288cc13822d059e97212a01b098d3766ce0136990ac1b0db4384777a86245fb6923cb225fc62c23543979d79fe18efbc1d2b1e8dbfe
6
+ metadata.gz: 3c217b0849b43aece36f81f333cd78169db098009f4f04cab70ab79f5788949cf4f3c0e99a6aa658ea35e5700d120477e98a8903ead7ca1481f63063068d4abd
7
+ data.tar.gz: 4897264315c5f453abc0302b2f1b436db73ba38f5724c79c568631489d0efdb5b90fa653137ddcdc60326fac0461c3fe0d1dfc20762151c199748454fcb26d33
data/CHANGELOG.md CHANGED
@@ -6,11 +6,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.2] - 2026-05-15
10
+
11
+ ### Added
12
+ - **URL never changes** — the browser address bar stays on the originating URL when modals open. Each modal/frame still pushes a phantom `history.pushState` entry so the back button closes layers one by one, but the URL itself does not change.
13
+ - **Reload restoration** — the current modal stack (layers + active wizard step) is persisted to `sessionStorage` and fully restored on page reload. Single-step modals restore via a GET re-fetch; POST-only wizard steps are restored from their cached HTML so they never 404.
14
+ - **`modal_stack:frame-leave` / `modal_stack:frame-enter` custom events** — fired on the `<dialog>` (bubbles to `document`) when a path frame transitions in or out. Detail: `{ layerId, frameIndex, direction: "forward" | "back" }`. Use these to save and restore form field values across wizard steps without gem involvement (see README for example Stimulus controller).
15
+ - **`data-turbo-permanent`** on the dialog element by default — prevents Turbo Drive from destroying and recreating the controller on page-restoration renders, eliminating a class of double-listener bugs and mid-animation state loss.
16
+
17
+ ### Fixed
18
+ - **No Turbo page-restoration on modal close** — our `popstate` listener is now registered in capture phase and calls `event.stopImmediatePropagation()` for popstates we triggered ourselves. Turbo's bubble-phase handler never sees them, so no restoration visit, no loading bar, and no body replacement on close.
19
+ - **Scroll lock survives Turbo renders** — `turbo:before-cache` strips `data-modal-stack-locked` from `<body>` before Turbo caches a snapshot; `turbo:render` handler in the controller calls `unlockScroll()` when `depth === 0`, neutralising any cached-snapshot restoration that re-applies the attribute.
20
+ - **Double-pop on concurrent close handlers** — `_onCancel` and `_onBackdropClick` now return early if `!this.element.open`, preventing a second `pop()` call when Stimulus double-connects the controller (which registers two listeners on the same dialog).
21
+ - **Multiple queued `historyBack` calls** — replaced the boolean suppress flag with a counter (`#suppressTurboVisitCount`) so N back-steps each cancel their own Turbo restoration visit independently, with a 1-second safety-valve reset.
22
+ - **`turbo:before-cache` cleanup** — `--modal-stack-scrollbar-width` CSS variable is also stripped before caching so Turbo snapshots are always taken in the unlocked baseline state.
23
+
24
+ ## [0.4.1] - 2026-05-14
25
+
9
26
  ### Added
27
+ - **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.
28
+ - **`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.
29
+ - **`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.
30
+ - **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.
31
+ - **`: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
32
 
11
33
  ### Changed
34
+ - `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
35
 
13
36
  ### Fixed
37
+ - `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.
38
+
39
+ ## [0.4.0] - 2026-05-08
40
+
41
+ ### Added
42
+ - **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.
43
+ - **`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.
44
+ - **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.
45
+ - **`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.
46
+ - **`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"]`).
47
+ - **`config.default_path_transition`** (default `:slide`, also `:fade` / `:none`): read by `modal_path_to` / `modal_path_back` when no per-call `transition:` is given.
48
+ - **New Capybara matchers**: `have_modal_frames(count)` and `within_modal_frame(depth: nil)` for testing path-based wizards.
49
+ - **`modal-stack#pathBack` Stimulus action** for direct wiring on custom UI (`data-action="click->modal-stack#pathBack"`, optional `data-modal-stack-steps-param`).
50
+ - **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.
51
+ - **`Orchestrator#pathTo` / `pathBack`** public methods, `pathTo` / `pathBack` reducer functions, `mountFrame` / `unmountFrame` / `clearFrameCache` runtime commands.
52
+ - 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.
53
+
54
+ ### Changed
55
+ - **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.
56
+ - **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).
57
+ - **`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.
58
+ - **`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.
59
+ - **`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`.
60
+ - **`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.
61
+ - `INITIALIZER_VERSION` bumped to `0.4.0`; the install template documents `default_path_transition`.
14
62
 
15
63
  ## [0.3.0] - 2026-05-03
16
64
 
data/README.md CHANGED
@@ -82,16 +82,17 @@ browser-`back`-ing through nested confirmation steps — they break down.
82
82
  ## ✨ Features
83
83
 
84
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.
85
86
  - 🪟 **Native `<dialog>`** — focus trap, ESC, accessible roles for free.
86
87
  - 🔗 **Deep-linking** — the top of the stack lives in `window.location`. Bookmark it, share it, refresh it.
87
- - ↩️ **Browser back = pop** — one history entry per layer; `cmd`+`←` does what users expect.
88
- - 🎮 **Imperative Turbo Stream actions** — `turbo_stream.modal_push / modal_pop / modal_replace / modal_close_all` from anywhere.
89
- - 🎨 **Three CSS presets** — Tailwind, Bootstrap, vanilla. All driven by `--modal-stack-*` CSS variables for easy retheming.
90
- - 🪞 **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`.
91
92
  - 📏 **Sizes & custom dimensions** — `:sm` / `:md` / `:lg` / `:xl`, or pass `width:` / `height:` strings (`"42rem"`, `"min(90vw, 56rem)"`).
92
93
  - 🔒 **Dismissible flag** — `dismissible: false` for confirmations users must answer.
93
94
  - ♿ **`prefers-reduced-motion`** — animations collapse to 1ms when the OS asks.
94
- - 🧪 **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`.
95
96
  - ⚡ **Three asset pipelines** — Importmap (default), jsbundling, Sprockets.
96
97
  - 🧱 **Engine-based** — zero monkey-patching, pure Rails Engine + Stimulus + Turbo.
97
98
 
@@ -212,10 +213,11 @@ ModalStack.configure do |config|
212
213
  config.default_dismissible = true # ESC + backdrop click close the layer
213
214
 
214
215
  # ─── Behavior ─────────────────────────────────────────────────────
215
- config.max_depth = 5 # hard cap on nested layers (nil to disable)
216
- config.max_depth_strategy = :warn # :warn | :raise | :silent
217
- config.respect_reduced_motion = true # honor prefers-reduced-motion
218
- 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
219
221
 
220
222
  # ─── Wiring (rarely changed) ──────────────────────────────────────
221
223
  config.dialog_id = "modal-stack-root"
@@ -361,10 +363,12 @@ strings — they're applied as inline styles, taking precedence over `size:`:
361
363
  ### Wizards & multi-step flows
362
364
 
363
365
  For step-by-step flows inside a single layer (onboarding, multi-step forms),
364
- combine `modal_push` (for the initial open) with `modal_replace` carrying
365
- `history: :push` between steps. Each step gets its own URL and a real
366
- history entry, so browser-back returns to the previous step (not the page
367
- 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.
368
372
 
369
373
  ```ruby
370
374
  class WizardController < ApplicationController
@@ -374,10 +378,11 @@ class WizardController < ApplicationController
374
378
  respond_to do |format|
375
379
  format.html # full-page render for deep-links
376
380
  format.turbo_stream do
377
- render turbo_stream: turbo_stream.modal_replace(
381
+ render turbo_stream: turbo_stream.modal_path_to(
378
382
  template: "wizard/step_2",
379
- history: :push,
380
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
381
386
  )
382
387
  end
383
388
  end
@@ -385,6 +390,38 @@ class WizardController < ApplicationController
385
390
  end
386
391
  ```
387
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
+ > **Persisting form data between steps:** form field values are *not* automatically saved — see [`modal_stack:frame-leave` / `modal_stack:frame-enter`](#persisting-form-data-across-wizard-steps) for the recommended pattern.
413
+
414
+ > ⚠️ **`replaceTop` collapses path frames.** When the top layer has a
415
+ > path and you call `turbo_stream.modal_replace`, the path is forgotten:
416
+ > the layer is morphed back to a single frame and the surplus history
417
+ > entries are walked back. Use `modal_path_back` if you want to step
418
+ > back, `modal_replace` if you want to swap the entire layer.
419
+
420
+ If you'd rather not keep a back-history (e.g. each step replaces the
421
+ previous and there's no going back), use the older `modal_replace`
422
+ mechanism with `history: :push` instead — the URL still changes per
423
+ step, but no in-memory cache is kept and `replaceTop` is the right tool.
424
+
388
425
  ### Stack depth & inertness
389
426
 
390
427
  When a layer is pushed on top of another, the bottom layer automatically
@@ -430,6 +467,7 @@ ModalStack.reset_configuration! # test-fixture helper
430
467
  | `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. |
431
468
  | `max_depth` | Integer | `5` | Hard cap on stack depth. Coerced from strings; set to `nil` to disable. Validated. |
432
469
  | `max_depth_strategy` | Symbol | `:warn` | One of `:warn`, `:raise`, `:silent`. See [Stack depth & inertness](#stack-depth--inertness). Validated. |
470
+ | `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. |
433
471
  | `request_header` | String | `"X-Modal-Stack-Request"` | HTTP header used by the JS runtime to signal stack-originated fetches. Read by `modal_stack_request?`. |
434
472
  | `dialog_id` | String | `"modal-stack-root"` | The id of the singleton `<dialog>`. Override only on name collision. |
435
473
  | `stack_root_data_attribute` | String | `"modal-stack"` | The Stimulus `data-controller` value attached to the `<dialog>`. |
@@ -446,7 +484,8 @@ Injected into `ActionView::Base` by the engine — available in every view.
446
484
  | Helper | Description |
447
485
  | ------------------------------------------------- | ----------- |
448
486
  | `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. |
449
- | `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. |
487
+ | `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. |
488
+ | `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" } %>`). |
450
489
  | `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`. |
451
490
  | `modal_stack_dialog_tag(**html_options)` | Emits the singleton `<dialog id="modal-stack-root" data-controller="modal-stack">`. Drop just before `</body>`. |
452
491
  | `modal_stack_javascript_tag` | Reserved hook for layouts; currently a no-op (JS is loaded via your bundler / importmap). |
@@ -472,11 +511,21 @@ options as Turbo's built-in stream actions (`partial:`, `template:`,
472
511
  | ----------------------------------------------------------- | ------- |
473
512
  | `modal_push(content = nil, **opts, &block)` | `variant:`, `dismissible:`, `url:`, `side:`, `size:`, `width:`, `height:`, plus any rendering options |
474
513
  | `modal_pop` | — |
475
- | `modal_replace(content = nil, **opts, &block)` | All `modal_push` options plus `history:` (`:replace` *(default)* or `:push`) and `layer_id:` |
514
+ | `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. |
476
515
  | `modal_close_all` | — |
516
+ | `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. |
517
+ | `modal_path_back(steps: 1, transition: nil)` | `steps:` (positive integer, clamped at the first frame), `transition:` override. |
477
518
 
478
519
  `history: :push` raises `ArgumentError` if given any value other than
479
- `:push` or `:replace`.
520
+ `:push` or `:replace`. Unknown transitions on `modal_path_to` /
521
+ `modal_path_back` raise `ArgumentError` as well.
522
+
523
+ > 💡 **Stale frames.** When you want a frame to refetch on back instead
524
+ > of restoring from the in-memory cache, either pass `stale: true` to
525
+ > `modal_path_to` or set the `X-Modal-Stack-Stale: true` header on the
526
+ > response. Useful when the frame's data is expected to change between
527
+ > the forward visit and the back visit (e.g. a list that the user
528
+ > mutated downstream).
480
529
 
481
530
  ### Layer DOM contract
482
531
 
@@ -489,24 +538,42 @@ Each pushed layer is a `<div>` inside the dialog with:
489
538
  data-variant="drawer"
490
539
  data-side="right"
491
540
  data-dismissible="true"
541
+ data-frame-index="1"
542
+ data-frame-depth="2"
492
543
  data-modal-stack-size="lg"
493
544
  data-modal-stack-width="42rem" style="width: 42rem;">
494
- <!-- panel content -->
545
+ <div data-modal-stack-frame
546
+ data-frame-index="1"
547
+ data-transition="slide"
548
+ data-direction="forward">
549
+ <!-- panel content -->
550
+ </div>
495
551
  </div>
496
552
  ```
497
553
 
554
+ The frame wrapper sits between the layer and the panel content. It
555
+ defaults to `display: contents` so it is invisible to host CSS. When a
556
+ transition is requested, the runtime sets `[data-transition]` and
557
+ `[data-direction]` on the entering frame — the shipped presets pick
558
+ these up via `@starting-style` rules (`slide`: translate from right/left,
559
+ `fade`: opacity 0→1) and restore `overflow-y: auto` on the layer once
560
+ `transitionend` fires. `data-frame-depth="N"` on the layer reflects the
561
+ current path length — `1` for layers without a path, `N` for layers `N`
562
+ frames deep.
563
+
498
564
  Underlying layers receive `inert`. A layer being unmounted gets
499
565
  `data-leaving=""` for the duration of the exit transition (capped at
500
566
  600ms even if the host CSS forgets to define one).
501
567
 
502
568
  ### Stimulus controllers
503
569
 
504
- Both controllers are registered via `installModalStack(application)`.
570
+ All three controllers are registered via `installModalStack(application)`.
505
571
 
506
572
  | Identifier | Role |
507
573
  | ---------------------- | ---- |
508
- | `modal-stack` | Bound to the singleton `<dialog>`. Wires popstate / cancel / backdrop-click listeners, registers the `Turbo.StreamActions`, hosts the Orchestrator. |
574
+ | `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"`). |
509
575
  | `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. |
576
+ | `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. |
510
577
 
511
578
  ### JS runtime
512
579
 
@@ -516,11 +583,13 @@ The package exports a small functional core + a browser adapter:
516
583
  import {
517
584
  // pure reducer — no IO, no DOM
518
585
  createStack, push, pop, replaceTop, closeAll, handlePopstate,
519
- snapshot, restore, topLayer, VARIANTS, ModalStackDepthError,
586
+ pathTo, pathBack,
587
+ snapshot, restore, topLayer, VARIANTS, TRANSITIONS,
588
+ ModalStackDepthError,
520
589
 
521
590
  // orchestrator + browser runtime
522
591
  Orchestrator, BrowserRuntime,
523
- FRAGMENT_HEADER, SNAPSHOT_KEY, SCROLLBAR_WIDTH_VAR,
592
+ FRAGMENT_HEADER, STALE_HEADER, SNAPSHOT_KEY, SCROLLBAR_WIDTH_VAR,
524
593
  } from "modal_stack"
525
594
 
526
595
  import { install } from "modal_stack/install"
@@ -532,21 +601,24 @@ side-effect-free and 100% covered; the browser adapter is the only
532
601
  file that touches `<dialog>`, `history`, `fetch`, and `sessionStorage`.
533
602
 
534
603
  The reducer's command type vocabulary (`mountLayer`, `morphTopLayer`,
535
- `unmountTopLayer`, `unmountAllLayers`, `showDialog`, `closeDialog`,
536
- `lockScroll`, `unlockScroll`, `inertLayer`, `pushHistory`,
537
- `replaceHistory`, `historyBack`, `rebuildFromSnapshot`, `persistSnapshot`,
538
- `clearSnapshot`) forms the contract between `state.js` and any runtime —
539
- swap in a custom adapter (e.g. for Hotwire Native) by implementing one
540
- method per command name.
604
+ `unmountTopLayer`, `unmountAllLayers`, `mountFrame`, `unmountFrame`,
605
+ `clearFrameCache`, `showDialog`, `closeDialog`, `lockScroll`,
606
+ `unlockScroll`, `inertLayer`, `pushHistory`, `replaceHistory`,
607
+ `historyBack`, `rebuildFromSnapshot`, `persistSnapshot`, `clearSnapshot`)
608
+ forms the contract between `state.js` and any runtime swap in a
609
+ custom adapter (e.g. for Hotwire Native) by implementing one method
610
+ per command name.
541
611
 
542
612
  #### Custom events
543
613
 
544
- The `<dialog>` emits two `CustomEvent`s that bubble to `document`:
614
+ The `<dialog>` emits `CustomEvent`s that bubble to `document`:
545
615
 
546
- | Event | `detail` | Fired when |
547
- | ---------------------- | ------------------------------------------- | ---------- |
548
- | `modal_stack:ready` | `{ stackId }` | The Stimulus controller has connected and the orchestrator is ready. |
549
- | `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. |
616
+ | Event | `detail` | Fired when |
617
+ | --------------------------- | --------------------------------------------------- | ---------- |
618
+ | `modal_stack:ready` | `{ stackId }` | The Stimulus controller has connected and the orchestrator is ready. |
619
+ | `modal_stack:error` | `{ action, error }` | A Turbo Stream action threw or rejected. The page is not crashed; surface UI feedback in the listener. |
620
+ | `modal_stack:frame-leave` | `{ layerId, frameIndex, direction }` | A path frame is about to be swapped out (user going forward or back). Fired while the leaving frame is still in the DOM — listeners can read current form field values. |
621
+ | `modal_stack:frame-enter` | `{ layerId, frameIndex, direction }` | A path frame has been mounted (user arrived at a step, restored from cache, or restored after page reload). Fired after the new frame is in the DOM — listeners can populate form fields. `direction` is `"forward"` or `"back"`. |
550
622
 
551
623
  ```js
552
624
  document.addEventListener("modal_stack:error", (event) => {
@@ -555,6 +627,72 @@ document.addEventListener("modal_stack:error", (event) => {
555
627
  });
556
628
  ```
557
629
 
630
+ ##### Persisting form data across wizard steps
631
+
632
+ Form field values typed by the user are **not** automatically preserved by modal_stack — that is the responsibility of the host application. Use `modal_stack:frame-leave` and `modal_stack:frame-enter` to save and restore data.
633
+
634
+ Example with a Stimulus controller added to each wizard step view:
635
+
636
+ ```js
637
+ // app/javascript/controllers/wizard_step_controller.js
638
+ import { Controller } from "@hotwired/stimulus"
639
+
640
+ export default class extends Controller {
641
+ static values = { key: String }
642
+
643
+ connect() {
644
+ // Listen on the dialog so events from any nested frame reach us.
645
+ this._leave = (e) => {
646
+ if (this.#isMyFrame(e)) this.#save()
647
+ }
648
+ this._enter = (e) => {
649
+ if (this.#isMyFrame(e)) this.#restore()
650
+ }
651
+ document.addEventListener("modal_stack:frame-leave", this._leave)
652
+ document.addEventListener("modal_stack:frame-enter", this._enter)
653
+ }
654
+
655
+ disconnect() {
656
+ document.removeEventListener("modal_stack:frame-leave", this._leave)
657
+ document.removeEventListener("modal_stack:frame-enter", this._enter)
658
+ }
659
+
660
+ #isMyFrame(event) {
661
+ // The controller's element lives inside the frame — check ancestry.
662
+ return event.target.contains(this.element)
663
+ }
664
+
665
+ #save() {
666
+ const data = {}
667
+ for (const el of this.element.querySelectorAll("input, textarea, select")) {
668
+ if (el.name) data[el.name] = el.value
669
+ }
670
+ sessionStorage.setItem(this.keyValue, JSON.stringify(data))
671
+ }
672
+
673
+ #restore() {
674
+ const raw = sessionStorage.getItem(this.keyValue)
675
+ if (!raw) return
676
+ const data = JSON.parse(raw)
677
+ for (const [name, value] of Object.entries(data)) {
678
+ const el = this.element.querySelector(`[name="${name}"]`)
679
+ if (el) el.value = value
680
+ }
681
+ }
682
+ }
683
+ ```
684
+
685
+ Then attach it to the step partial:
686
+
687
+ ```erb
688
+ <%# app/views/wizard/_step1.html.erb %>
689
+ <div data-controller="wizard-step" data-wizard-step-key-value="wizard-step-1">
690
+ <%# form fields … %>
691
+ </div>
692
+ ```
693
+
694
+ > **Why not auto-persist?** The gem cannot know the shape of your data, applicable validations, or where you want it stored (session, server-side draft, etc.). Keeping this boundary explicit also means you can encrypt, debounce, or sync to a server-side draft without fighting the gem.
695
+
558
696
  #### Scrollbar-width compensation
559
697
 
560
698
  When the first layer is pushed, `BrowserRuntime#lockScroll` measures
@@ -579,10 +717,12 @@ specs. For Minitest, `require "modal_stack/capybara/minitest"`.
579
717
  | Helper / matcher | Description |
580
718
  | --------------------------------- | ----------- |
581
719
  | `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. |
720
+ | `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. |
582
721
  | `have_modal_open` | Matcher: passes when the dialog has `[open]`. |
583
722
  | `have_no_modal_open` | Negation. |
584
723
  | `have_modal_stack(depth: nil)` | Matcher: asserts the live (non-leaving) layer count. |
585
724
  | `have_no_modal_stack` | Negation. |
725
+ | `have_modal_frames(count)` | Matcher: asserts the top layer's path has `count` frames. Layers without a path read as `1`. |
586
726
  | `close_modal` | Sends `ESC` to the dialog. Honors `dismissible: false` (the layer stays). |
587
727
  | `close_all_modals(max: 16)` | Pops every layer by sending `ESC` repeatedly. |
588
728
  | `modal_stack_depth` | Reads the current depth from the live DOM. |
@@ -626,6 +766,15 @@ Four opinionated stylesheets ship with the gem. Pick one with
626
766
  | `:vanilla` | `app/assets/stylesheets/modal_stack/vanilla.css` | Framework-free, neutral defaults |
627
767
  | `:none` | — | Bring your own CSS |
628
768
 
769
+ All four presets share the following capabilities:
770
+
771
+ - **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`.
772
+ - **All four drawer sides** — `left`, `right`, `top`, `bottom` with matching entry/exit animations.
773
+ - **Mobile scroll containment** — `overscroll-behavior: contain` prevents scroll chaining when modal content reaches its boundary.
774
+ - **Safe-area inset** — `bottom_sheet` and `drawer[data-side="bottom"]` apply `env(safe-area-inset-bottom)` padding.
775
+ - **Keyboard focus ring** — `.modal-stack__panel-back` has a `:focus-visible` outline.
776
+ - **Reduced-motion** — frame transition durations collapse to `1ms` alongside layer transitions.
777
+
629
778
  All presets are driven by the same `--modal-stack-*` CSS variables.
630
779
  Override on `:root` to retheme without touching the gem:
631
780
 
@@ -684,9 +833,11 @@ provided by the host app).
684
833
 
685
834
  - **Native `<dialog>`** — modern browsers handle focus trap, ESC, and `aria-modal` for free.
686
835
  - **Inertness** — underlying layers in a stack receive `inert`, so screen-readers and keyboard navigation skip them.
687
- - **Reduced motion** — when `prefers-reduced-motion: reduce` is set, presets collapse transitions to 1ms.
836
+ - **Reduced motion** — when `prefers-reduced-motion: reduce` is set, presets collapse all transitions (layer and frame) to 1 ms.
688
837
  - **Focus restoration** — when a layer is popped, focus returns to the trigger element (per `<dialog>` semantics).
689
- - **Body scroll lock** — `<body data-modal-stack-locked>` prevents background scroll while the dialog is open.
838
+ - **Back-button focus ring** — `.modal-stack__panel-back` renders a `:focus-visible` outline on keyboard focus in every preset.
839
+ - **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.
840
+ - **Safe-area padding** — `bottom_sheet` and bottom drawers apply `env(safe-area-inset-bottom)` so content is never hidden under the iOS home indicator.
690
841
 
691
842
  ---
692
843