modal_stack 0.3.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 +33 -0
- data/README.md +113 -32
- data/app/assets/javascripts/modal_stack.js +488 -50
- data/app/assets/stylesheets/modal_stack/bootstrap.css +113 -3
- data/app/assets/stylesheets/modal_stack/tailwind_v3.css +63 -2
- data/app/assets/stylesheets/modal_stack/tailwind_v4.css +63 -2
- data/app/assets/stylesheets/modal_stack/vanilla.css +121 -3
- data/app/javascript/modal_stack/controllers/modal_stack_back_link_controller.js +32 -0
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +50 -0
- data/app/javascript/modal_stack/install.js +7 -1
- data/app/javascript/modal_stack/orchestrator.js +53 -7
- data/app/javascript/modal_stack/orchestrator.test.js +96 -0
- data/app/javascript/modal_stack/runtime.js +167 -5
- data/app/javascript/modal_stack/runtime.test.js +83 -0
- data/app/javascript/modal_stack/state.js +319 -34
- data/app/javascript/modal_stack/state.test.js +394 -9
- 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/templates/initializer.rb +9 -0
- 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 +37 -16
- 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 +1 -1
- 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 +9 -2
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,44 @@ 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`.
|
|
14
47
|
|
|
15
48
|
## [0.3.0] - 2026-05-03
|
|
16
49
|
|
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** —
|
|
88
|
-
- 🎮 **Imperative Turbo Stream actions** — `turbo_stream.modal_push / modal_pop / modal_replace / modal_close_all` from anywhere.
|
|
89
|
-
- 🎨 **
|
|
90
|
-
- 🪞 **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`.
|
|
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
|
|
216
|
-
config.max_depth_strategy = :warn
|
|
217
|
-
config.
|
|
218
|
-
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
|
|
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
|
-
|
|
365
|
-
`
|
|
366
|
-
|
|
367
|
-
|
|
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.
|
|
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,36 @@ 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
|
+
> ⚠️ **`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
|
+
|
|
388
423
|
### Stack depth & inertness
|
|
389
424
|
|
|
390
425
|
When a layer is pushed on top of another, the bottom layer automatically
|
|
@@ -430,6 +465,7 @@ ModalStack.reset_configuration! # test-fixture helper
|
|
|
430
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. |
|
|
431
466
|
| `max_depth` | Integer | `5` | Hard cap on stack depth. Coerced from strings; set to `nil` to disable. Validated. |
|
|
432
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. |
|
|
433
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?`. |
|
|
434
470
|
| `dialog_id` | String | `"modal-stack-root"` | The id of the singleton `<dialog>`. Override only on name collision. |
|
|
435
471
|
| `stack_root_data_attribute` | String | `"modal-stack"` | The Stimulus `data-controller` value attached to the `<dialog>`. |
|
|
@@ -446,7 +482,8 @@ Injected into `ActionView::Base` by the engine — available in every view.
|
|
|
446
482
|
| Helper | Description |
|
|
447
483
|
| ------------------------------------------------- | ----------- |
|
|
448
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. |
|
|
449
|
-
| `modal_stack_container(size:, variant:, side:, width:, height:, dismissible:, html: {}) { ... }` | Wraps a panel view with the markup the JS runtime expects.
|
|
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" } %>`). |
|
|
450
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`. |
|
|
451
488
|
| `modal_stack_dialog_tag(**html_options)` | Emits the singleton `<dialog id="modal-stack-root" data-controller="modal-stack">`. Drop just before `</body>`. |
|
|
452
489
|
| `modal_stack_javascript_tag` | Reserved hook for layouts; currently a no-op (JS is loaded via your bundler / importmap). |
|
|
@@ -472,11 +509,21 @@ options as Turbo's built-in stream actions (`partial:`, `template:`,
|
|
|
472
509
|
| ----------------------------------------------------------- | ------- |
|
|
473
510
|
| `modal_push(content = nil, **opts, &block)` | `variant:`, `dismissible:`, `url:`, `side:`, `size:`, `width:`, `height:`, plus any rendering options |
|
|
474
511
|
| `modal_pop` | — |
|
|
475
|
-
| `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. |
|
|
476
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. |
|
|
477
516
|
|
|
478
517
|
`history: :push` raises `ArgumentError` if given any value other than
|
|
479
|
-
`: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).
|
|
480
527
|
|
|
481
528
|
### Layer DOM contract
|
|
482
529
|
|
|
@@ -489,24 +536,42 @@ Each pushed layer is a `<div>` inside the dialog with:
|
|
|
489
536
|
data-variant="drawer"
|
|
490
537
|
data-side="right"
|
|
491
538
|
data-dismissible="true"
|
|
539
|
+
data-frame-index="1"
|
|
540
|
+
data-frame-depth="2"
|
|
492
541
|
data-modal-stack-size="lg"
|
|
493
542
|
data-modal-stack-width="42rem" style="width: 42rem;">
|
|
494
|
-
|
|
543
|
+
<div data-modal-stack-frame
|
|
544
|
+
data-frame-index="1"
|
|
545
|
+
data-transition="slide"
|
|
546
|
+
data-direction="forward">
|
|
547
|
+
<!-- panel content -->
|
|
548
|
+
</div>
|
|
495
549
|
</div>
|
|
496
550
|
```
|
|
497
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
|
+
|
|
498
562
|
Underlying layers receive `inert`. A layer being unmounted gets
|
|
499
563
|
`data-leaving=""` for the duration of the exit transition (capped at
|
|
500
564
|
600ms even if the host CSS forgets to define one).
|
|
501
565
|
|
|
502
566
|
### Stimulus controllers
|
|
503
567
|
|
|
504
|
-
|
|
568
|
+
All three controllers are registered via `installModalStack(application)`.
|
|
505
569
|
|
|
506
570
|
| Identifier | Role |
|
|
507
571
|
| ---------------------- | ---- |
|
|
508
|
-
| `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"`). |
|
|
509
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. |
|
|
510
575
|
|
|
511
576
|
### JS runtime
|
|
512
577
|
|
|
@@ -516,11 +581,13 @@ The package exports a small functional core + a browser adapter:
|
|
|
516
581
|
import {
|
|
517
582
|
// pure reducer — no IO, no DOM
|
|
518
583
|
createStack, push, pop, replaceTop, closeAll, handlePopstate,
|
|
519
|
-
|
|
584
|
+
pathTo, pathBack,
|
|
585
|
+
snapshot, restore, topLayer, VARIANTS, TRANSITIONS,
|
|
586
|
+
ModalStackDepthError,
|
|
520
587
|
|
|
521
588
|
// orchestrator + browser runtime
|
|
522
589
|
Orchestrator, BrowserRuntime,
|
|
523
|
-
FRAGMENT_HEADER, SNAPSHOT_KEY, SCROLLBAR_WIDTH_VAR,
|
|
590
|
+
FRAGMENT_HEADER, STALE_HEADER, SNAPSHOT_KEY, SCROLLBAR_WIDTH_VAR,
|
|
524
591
|
} from "modal_stack"
|
|
525
592
|
|
|
526
593
|
import { install } from "modal_stack/install"
|
|
@@ -532,12 +599,13 @@ side-effect-free and 100% covered; the browser adapter is the only
|
|
|
532
599
|
file that touches `<dialog>`, `history`, `fetch`, and `sessionStorage`.
|
|
533
600
|
|
|
534
601
|
The reducer's command type vocabulary (`mountLayer`, `morphTopLayer`,
|
|
535
|
-
`unmountTopLayer`, `unmountAllLayers`, `
|
|
536
|
-
`
|
|
537
|
-
`
|
|
538
|
-
`
|
|
539
|
-
|
|
540
|
-
|
|
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.
|
|
541
609
|
|
|
542
610
|
#### Custom events
|
|
543
611
|
|
|
@@ -546,7 +614,7 @@ The `<dialog>` emits two `CustomEvent`s that bubble to `document`:
|
|
|
546
614
|
| Event | `detail` | Fired when |
|
|
547
615
|
| ---------------------- | ------------------------------------------- | ---------- |
|
|
548
616
|
| `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. |
|
|
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. |
|
|
550
618
|
|
|
551
619
|
```js
|
|
552
620
|
document.addEventListener("modal_stack:error", (event) => {
|
|
@@ -579,10 +647,12 @@ specs. For Minitest, `require "modal_stack/capybara/minitest"`.
|
|
|
579
647
|
| Helper / matcher | Description |
|
|
580
648
|
| --------------------------------- | ----------- |
|
|
581
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. |
|
|
582
651
|
| `have_modal_open` | Matcher: passes when the dialog has `[open]`. |
|
|
583
652
|
| `have_no_modal_open` | Negation. |
|
|
584
653
|
| `have_modal_stack(depth: nil)` | Matcher: asserts the live (non-leaving) layer count. |
|
|
585
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`. |
|
|
586
656
|
| `close_modal` | Sends `ESC` to the dialog. Honors `dismissible: false` (the layer stays). |
|
|
587
657
|
| `close_all_modals(max: 16)` | Pops every layer by sending `ESC` repeatedly. |
|
|
588
658
|
| `modal_stack_depth` | Reads the current depth from the live DOM. |
|
|
@@ -626,6 +696,15 @@ Four opinionated stylesheets ship with the gem. Pick one with
|
|
|
626
696
|
| `:vanilla` | `app/assets/stylesheets/modal_stack/vanilla.css` | Framework-free, neutral defaults |
|
|
627
697
|
| `:none` | — | Bring your own CSS |
|
|
628
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.
|
|
707
|
+
|
|
629
708
|
All presets are driven by the same `--modal-stack-*` CSS variables.
|
|
630
709
|
Override on `:root` to retheme without touching the gem:
|
|
631
710
|
|
|
@@ -684,9 +763,11 @@ provided by the host app).
|
|
|
684
763
|
|
|
685
764
|
- **Native `<dialog>`** — modern browsers handle focus trap, ESC, and `aria-modal` for free.
|
|
686
765
|
- **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
|
|
766
|
+
- **Reduced motion** — when `prefers-reduced-motion: reduce` is set, presets collapse all transitions (layer and frame) to 1 ms.
|
|
688
767
|
- **Focus restoration** — when a layer is popped, focus returns to the trigger element (per `<dialog>` semantics).
|
|
689
|
-
- **
|
|
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.
|
|
690
771
|
|
|
691
772
|
---
|
|
692
773
|
|