modal_stack 0.4.1 → 0.4.3
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 +23 -0
- data/README.md +98 -7
- data/app/assets/javascripts/modal_stack.js +205 -23
- data/app/assets/stylesheets/modal_stack/bootstrap.css +35 -0
- data/app/assets/stylesheets/modal_stack/tailwind_v3.css +35 -0
- data/app/assets/stylesheets/modal_stack/tailwind_v4.css +35 -0
- data/app/assets/stylesheets/modal_stack/vanilla.css +35 -0
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +111 -8
- data/app/javascript/modal_stack/orchestrator.js +17 -3
- data/app/javascript/modal_stack/orchestrator.test.js +2 -2
- data/app/javascript/modal_stack/runtime.js +149 -4
- data/app/javascript/modal_stack/runtime.test.js +7 -6
- data/app/javascript/modal_stack/state.js +24 -11
- data/app/javascript/modal_stack/state.test.js +11 -9
- data/app/views/modal_stack/_panel.html.erb +13 -0
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +6 -0
- data/lib/modal_stack/helpers/modal_stack_container_helper.rb +4 -1
- data/lib/modal_stack/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 992372f08bce8ea31105e8cba05c60035014852c197c38b325648eaebf154820
|
|
4
|
+
data.tar.gz: 70db58c91eda0101149fde86cec748812c7b8aff3f3908a30d6178538000c98f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 98043c481ff734f042b88f2117b766fb1dc2110dfdacbad71d66de39471999d15e1dfb368e9466964d1127b809ed4bb3a8aea4fb83847aba6163d717f75eb4bf
|
|
7
|
+
data.tar.gz: 46b3b5491dd13836ee207e62b42d2a3023ee1b65ac75da634d40a944608822b2a40e102ef39861f0208ab47a17afb4dbe0d529341c8d9611adce2ef9ba600ab5
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.3] - 2026-05-25
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- **`title:` option on `modal_stack_container`** — renders a `<header class="modal-stack__panel-header">` containing an `<h2 class="modal-stack__panel-title">` above the panel content. No output when omitted, so existing panels are unaffected.
|
|
13
|
+
- **`close_button:` option on `modal_stack_container`** — renders a `×` button (`<button class="modal-stack__panel-close">`) wired to `modal-stack#pop`. Defaults to the `dismissible:` value, so dismissible layers get a close button for free and locked layers (`dismissible: false`) get none by default. Pass `close_button: false` to suppress it explicitly on a dismissible layer.
|
|
14
|
+
- **CSS** for the new header slot in all four presets (`tailwind_v4`, `tailwind_v3`, `bootstrap`, `vanilla`): `.modal-stack__panel-header` (flexbox row), `.modal-stack__panel-title` (flex-1), `.modal-stack__panel-close` (transparent button, opacity hover, focus-visible ring).
|
|
15
|
+
- `title` and `show_close` passed as **separate partial locals** to `_panel.html.erb` so host apps overriding the partial can consume them individually rather than receiving a pre-rendered HTML blob.
|
|
16
|
+
|
|
17
|
+
## [0.4.2] - 2026-05-15
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- **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.
|
|
21
|
+
- **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.
|
|
22
|
+
- **`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).
|
|
23
|
+
- **`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.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- **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.
|
|
27
|
+
- **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.
|
|
28
|
+
- **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).
|
|
29
|
+
- **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.
|
|
30
|
+
- **`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.
|
|
31
|
+
|
|
9
32
|
## [0.4.1] - 2026-05-14
|
|
10
33
|
|
|
11
34
|
### Added
|
data/README.md
CHANGED
|
@@ -279,9 +279,30 @@ options at the call site:
|
|
|
279
279
|
```
|
|
280
280
|
|
|
281
281
|
`modal_stack_container` accepts `size:`, `variant:`, `side:`, `width:`,
|
|
282
|
-
`height:`, `dismissible:`, and an `html: { class:, data:, ... }` Hash for
|
|
282
|
+
`height:`, `dismissible:`, `title:`, `close_button:`, and an `html: { class:, data:, ... }` Hash for
|
|
283
283
|
extra attributes on the wrapping `<div>`.
|
|
284
284
|
|
|
285
|
+
Pass `title:` to render a `<header>` with an `<h2>` above the panel content.
|
|
286
|
+
`close_button:` (default: inherits `dismissible:`) renders a `×` button wired to
|
|
287
|
+
`modal-stack#pop` — locked layers (`dismissible: false`) get no close button by default:
|
|
288
|
+
|
|
289
|
+
```erb
|
|
290
|
+
<%# Dismissible — title + auto close button %>
|
|
291
|
+
<%= modal_stack_container title: "Edit project", size: :md do %>
|
|
292
|
+
<%= render "form", project: @project %>
|
|
293
|
+
<% end %>
|
|
294
|
+
|
|
295
|
+
<%# Locked — title only, no × (close_button: defaults to false) %>
|
|
296
|
+
<%= modal_stack_container title: "Are you sure?",
|
|
297
|
+
variant: :confirmation,
|
|
298
|
+
dismissible: false do %>
|
|
299
|
+
<button data-action="click->modal-stack#pop">Confirm</button>
|
|
300
|
+
<% end %>
|
|
301
|
+
|
|
302
|
+
<%# Explicit override %>
|
|
303
|
+
<%= modal_stack_container title: "Info", close_button: false do %>…<% end %>
|
|
304
|
+
```
|
|
305
|
+
|
|
285
306
|
### Stack-aware controllers
|
|
286
307
|
|
|
287
308
|
`modal_stack_layout` switches the controller's layout to `modal` **only**
|
|
@@ -409,6 +430,8 @@ stale), either pass `stale: true` to `modal_path_to` or set the
|
|
|
409
430
|
`X-Modal-Stack-Stale: true` header on the response. The runtime will
|
|
410
431
|
refetch the URL instead of restoring from cache.
|
|
411
432
|
|
|
433
|
+
> **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.
|
|
434
|
+
|
|
412
435
|
> ⚠️ **`replaceTop` collapses path frames.** When the top layer has a
|
|
413
436
|
> path and you call `turbo_stream.modal_replace`, the path is forgotten:
|
|
414
437
|
> the layer is morphed back to a single frame and the surplus history
|
|
@@ -482,7 +505,7 @@ Injected into `ActionView::Base` by the engine — available in every view.
|
|
|
482
505
|
| Helper | Description |
|
|
483
506
|
| ------------------------------------------------- | ----------- |
|
|
484
507
|
| `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. |
|
|
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. |
|
|
508
|
+
| `modal_stack_container(size:, variant:, side:, width:, height:, dismissible:, back:, transition:, title:, close_button:, 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. `title:` renders a `<header>` with `<h2>`; `close_button:` (default: `dismissible:` value) renders a `×` close button. |
|
|
486
509
|
| `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
510
|
| `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`. |
|
|
488
511
|
| `modal_stack_dialog_tag(**html_options)` | Emits the singleton `<dialog id="modal-stack-root" data-controller="modal-stack">`. Drop just before `</body>`. |
|
|
@@ -609,12 +632,14 @@ per command name.
|
|
|
609
632
|
|
|
610
633
|
#### Custom events
|
|
611
634
|
|
|
612
|
-
The `<dialog>` emits
|
|
635
|
+
The `<dialog>` emits `CustomEvent`s that bubble to `document`:
|
|
613
636
|
|
|
614
|
-
| Event
|
|
615
|
-
|
|
|
616
|
-
| `modal_stack:ready`
|
|
617
|
-
| `modal_stack:error`
|
|
637
|
+
| Event | `detail` | Fired when |
|
|
638
|
+
| --------------------------- | --------------------------------------------------- | ---------- |
|
|
639
|
+
| `modal_stack:ready` | `{ stackId }` | The Stimulus controller has connected and the orchestrator is ready. |
|
|
640
|
+
| `modal_stack:error` | `{ action, error }` | A Turbo Stream action threw or rejected. The page is not crashed; surface UI feedback in the listener. |
|
|
641
|
+
| `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. |
|
|
642
|
+
| `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"`. |
|
|
618
643
|
|
|
619
644
|
```js
|
|
620
645
|
document.addEventListener("modal_stack:error", (event) => {
|
|
@@ -623,6 +648,72 @@ document.addEventListener("modal_stack:error", (event) => {
|
|
|
623
648
|
});
|
|
624
649
|
```
|
|
625
650
|
|
|
651
|
+
##### Persisting form data across wizard steps
|
|
652
|
+
|
|
653
|
+
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.
|
|
654
|
+
|
|
655
|
+
Example with a Stimulus controller added to each wizard step view:
|
|
656
|
+
|
|
657
|
+
```js
|
|
658
|
+
// app/javascript/controllers/wizard_step_controller.js
|
|
659
|
+
import { Controller } from "@hotwired/stimulus"
|
|
660
|
+
|
|
661
|
+
export default class extends Controller {
|
|
662
|
+
static values = { key: String }
|
|
663
|
+
|
|
664
|
+
connect() {
|
|
665
|
+
// Listen on the dialog so events from any nested frame reach us.
|
|
666
|
+
this._leave = (e) => {
|
|
667
|
+
if (this.#isMyFrame(e)) this.#save()
|
|
668
|
+
}
|
|
669
|
+
this._enter = (e) => {
|
|
670
|
+
if (this.#isMyFrame(e)) this.#restore()
|
|
671
|
+
}
|
|
672
|
+
document.addEventListener("modal_stack:frame-leave", this._leave)
|
|
673
|
+
document.addEventListener("modal_stack:frame-enter", this._enter)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
disconnect() {
|
|
677
|
+
document.removeEventListener("modal_stack:frame-leave", this._leave)
|
|
678
|
+
document.removeEventListener("modal_stack:frame-enter", this._enter)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
#isMyFrame(event) {
|
|
682
|
+
// The controller's element lives inside the frame — check ancestry.
|
|
683
|
+
return event.target.contains(this.element)
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
#save() {
|
|
687
|
+
const data = {}
|
|
688
|
+
for (const el of this.element.querySelectorAll("input, textarea, select")) {
|
|
689
|
+
if (el.name) data[el.name] = el.value
|
|
690
|
+
}
|
|
691
|
+
sessionStorage.setItem(this.keyValue, JSON.stringify(data))
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
#restore() {
|
|
695
|
+
const raw = sessionStorage.getItem(this.keyValue)
|
|
696
|
+
if (!raw) return
|
|
697
|
+
const data = JSON.parse(raw)
|
|
698
|
+
for (const [name, value] of Object.entries(data)) {
|
|
699
|
+
const el = this.element.querySelector(`[name="${name}"]`)
|
|
700
|
+
if (el) el.value = value
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
Then attach it to the step partial:
|
|
707
|
+
|
|
708
|
+
```erb
|
|
709
|
+
<%# app/views/wizard/_step1.html.erb %>
|
|
710
|
+
<div data-controller="wizard-step" data-wizard-step-key-value="wizard-step-1">
|
|
711
|
+
<%# form fields … %>
|
|
712
|
+
</div>
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
> **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.
|
|
716
|
+
|
|
626
717
|
#### Scrollbar-width compensation
|
|
627
718
|
|
|
628
719
|
When the first layer is pushed, `BrowserRuntime#lockScroll` measures
|
|
@@ -289,18 +289,18 @@ function pop(state) {
|
|
|
289
289
|
const newTop = newLayers[newLayers.length - 1] ?? null;
|
|
290
290
|
const commands = [];
|
|
291
291
|
if (newTop) {
|
|
292
|
+
commands.push({ type: "persistSnapshot" });
|
|
292
293
|
commands.push({ type: "unmountTopLayer" });
|
|
293
294
|
commands.push({ type: "clearFrameCache", layerId: popped.id });
|
|
294
295
|
commands.push({ type: "historyBack", n: framesToWalkBack });
|
|
295
296
|
commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
|
|
296
|
-
commands.push({ type: "persistSnapshot" });
|
|
297
297
|
} else {
|
|
298
298
|
commands.push({ type: "closeDialog" });
|
|
299
|
+
commands.push({ type: "clearSnapshot" });
|
|
299
300
|
commands.push({ type: "unmountTopLayer" });
|
|
300
301
|
commands.push({ type: "clearFrameCache", layerId: popped.id });
|
|
301
302
|
commands.push({ type: "historyBack", n: framesToWalkBack });
|
|
302
303
|
commands.push({ type: "unlockScroll" });
|
|
303
|
-
commands.push({ type: "clearSnapshot" });
|
|
304
304
|
}
|
|
305
305
|
return { state: { ...state, layers: newLayers }, commands };
|
|
306
306
|
}
|
|
@@ -369,11 +369,11 @@ function closeAll(state) {
|
|
|
369
369
|
state: { ...state, layers: Object.freeze([]) },
|
|
370
370
|
commands: [
|
|
371
371
|
{ type: "closeDialog" },
|
|
372
|
+
{ type: "clearSnapshot" },
|
|
372
373
|
{ type: "unmountAllLayers" },
|
|
373
374
|
...cacheClears,
|
|
374
375
|
{ type: "unlockScroll" },
|
|
375
|
-
{ type: "historyBack", n }
|
|
376
|
-
{ type: "clearSnapshot" }
|
|
376
|
+
{ type: "historyBack", n }
|
|
377
377
|
]
|
|
378
378
|
};
|
|
379
379
|
}
|
|
@@ -390,10 +390,10 @@ function handlePopstate(state, { historyState, locationHref }) {
|
|
|
390
390
|
state: { ...state, layers: Object.freeze([]) },
|
|
391
391
|
commands: [
|
|
392
392
|
{ type: "closeDialog" },
|
|
393
|
+
{ type: "clearSnapshot" },
|
|
393
394
|
{ type: "unmountAllLayers" },
|
|
394
395
|
...cacheClears,
|
|
395
|
-
{ type: "unlockScroll" }
|
|
396
|
-
{ type: "clearSnapshot" }
|
|
396
|
+
{ type: "unlockScroll" }
|
|
397
397
|
]
|
|
398
398
|
};
|
|
399
399
|
}
|
|
@@ -406,8 +406,12 @@ function handlePopstate(state, { historyState, locationHref }) {
|
|
|
406
406
|
const newLayers = Object.freeze(state.layers.slice(0, targetDepth));
|
|
407
407
|
const newTop = newLayers[newLayers.length - 1] ?? null;
|
|
408
408
|
const commands = [];
|
|
409
|
-
if (
|
|
409
|
+
if (newTop) {
|
|
410
|
+
commands.push({ type: "persistSnapshot" });
|
|
411
|
+
} else {
|
|
410
412
|
commands.push({ type: "closeDialog" });
|
|
413
|
+
commands.push({ type: "clearSnapshot" });
|
|
414
|
+
}
|
|
411
415
|
for (let i = 0;i < droppedLayers.length; i++) {
|
|
412
416
|
commands.push({ type: "unmountTopLayer" });
|
|
413
417
|
}
|
|
@@ -416,10 +420,8 @@ function handlePopstate(state, { historyState, locationHref }) {
|
|
|
416
420
|
}
|
|
417
421
|
if (newTop) {
|
|
418
422
|
commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
|
|
419
|
-
commands.push({ type: "persistSnapshot" });
|
|
420
423
|
} else {
|
|
421
424
|
commands.push({ type: "unlockScroll" });
|
|
422
|
-
commands.push({ type: "clearSnapshot" });
|
|
423
425
|
}
|
|
424
426
|
return { state: { ...state, layers: newLayers }, commands };
|
|
425
427
|
}
|
|
@@ -615,6 +617,9 @@ class Orchestrator {
|
|
|
615
617
|
get depth() {
|
|
616
618
|
return this.state.layers.length;
|
|
617
619
|
}
|
|
620
|
+
get expectedPopstates() {
|
|
621
|
+
return this.#expectedPopstates;
|
|
622
|
+
}
|
|
618
623
|
async push(layer, { html = null, fragment = null } = {}) {
|
|
619
624
|
const transition = push(this.state, layer, {
|
|
620
625
|
maxDepth: this.maxDepth,
|
|
@@ -689,6 +694,15 @@ class Orchestrator {
|
|
|
689
694
|
this.#inflight.clear();
|
|
690
695
|
this.#fragmentCache.clear();
|
|
691
696
|
}
|
|
697
|
+
setFragmentCache(url, fragment) {
|
|
698
|
+
if (!url || !fragment)
|
|
699
|
+
return;
|
|
700
|
+
this.#fragmentCache.set(url, {
|
|
701
|
+
fragment: cloneFragment(fragment),
|
|
702
|
+
stale: false,
|
|
703
|
+
ts: Date.now()
|
|
704
|
+
});
|
|
705
|
+
}
|
|
692
706
|
prefetch(url) {
|
|
693
707
|
if (!url || typeof this.runtime.fetchFragment !== "function") {
|
|
694
708
|
return Promise.resolve(null);
|
|
@@ -749,6 +763,7 @@ function supportsAbort() {
|
|
|
749
763
|
|
|
750
764
|
// app/javascript/modal_stack/runtime.js
|
|
751
765
|
var SNAPSHOT_KEY = "modalStackSnapshot";
|
|
766
|
+
var FRAME_HTML_KEY = "modalStackFrameHtml";
|
|
752
767
|
var FRAGMENT_HEADER = "X-Modal-Stack-Request";
|
|
753
768
|
var STALE_HEADER = "X-Modal-Stack-Stale";
|
|
754
769
|
var SCROLLBAR_WIDTH_VAR = "--modal-stack-scrollbar-width";
|
|
@@ -759,10 +774,13 @@ var LEAVE_TIMEOUT_FLOOR_MS = 300;
|
|
|
759
774
|
var LEAVE_TIMEOUT_FALLBACK_MS = 600;
|
|
760
775
|
|
|
761
776
|
class BrowserRuntime {
|
|
777
|
+
#suppressTurboVisitCount = 0;
|
|
778
|
+
#suppressTurboVisitTimer = null;
|
|
762
779
|
constructor({
|
|
763
780
|
dialog,
|
|
764
781
|
body = globalThis.document?.body,
|
|
765
782
|
history = globalThis.history,
|
|
783
|
+
location = globalThis.location,
|
|
766
784
|
fetcher = globalThis.fetch?.bind(globalThis),
|
|
767
785
|
store = globalThis.sessionStorage,
|
|
768
786
|
documentRef = globalThis.document
|
|
@@ -776,10 +794,34 @@ class BrowserRuntime {
|
|
|
776
794
|
this.dialog = dialog;
|
|
777
795
|
this.body = body;
|
|
778
796
|
this.history = history;
|
|
797
|
+
this.location = location;
|
|
779
798
|
this.fetcher = fetcher;
|
|
780
799
|
this.store = store;
|
|
781
800
|
this.document = documentRef;
|
|
782
801
|
this._frameCache = new Map;
|
|
802
|
+
this.#suppressTurboVisitCount = 0;
|
|
803
|
+
this._turboVisitGuard = (event) => {
|
|
804
|
+
if (this.#suppressTurboVisitCount <= 0)
|
|
805
|
+
return;
|
|
806
|
+
this.#suppressTurboVisitCount -= 1;
|
|
807
|
+
if (this.#suppressTurboVisitCount === 0)
|
|
808
|
+
clearTimeout(this.#suppressTurboVisitTimer);
|
|
809
|
+
event.preventDefault();
|
|
810
|
+
};
|
|
811
|
+
documentRef.addEventListener?.("turbo:before-visit", this._turboVisitGuard);
|
|
812
|
+
this._turboBeforeCache = () => {
|
|
813
|
+
if (!this.body)
|
|
814
|
+
return;
|
|
815
|
+
delete this.body.dataset.modalStackLocked;
|
|
816
|
+
const root = this.document?.documentElement;
|
|
817
|
+
if (root)
|
|
818
|
+
root.style.removeProperty(SCROLLBAR_WIDTH_VAR);
|
|
819
|
+
};
|
|
820
|
+
documentRef.addEventListener?.("turbo:before-cache", this._turboBeforeCache);
|
|
821
|
+
}
|
|
822
|
+
destroy() {
|
|
823
|
+
this.document?.removeEventListener?.("turbo:before-visit", this._turboVisitGuard);
|
|
824
|
+
this.document?.removeEventListener?.("turbo:before-cache", this._turboBeforeCache);
|
|
783
825
|
}
|
|
784
826
|
showDialog() {
|
|
785
827
|
if (!this.dialog.open)
|
|
@@ -844,6 +886,11 @@ class BrowserRuntime {
|
|
|
844
886
|
const frag = await this.#resolveFragment({ url, html, fragment });
|
|
845
887
|
const oldFrame = this.#findFrame(layer, fromFrameIndex);
|
|
846
888
|
if (oldFrame) {
|
|
889
|
+
this.#dispatchFrameEvent("modal_stack:frame-leave", {
|
|
890
|
+
layerId,
|
|
891
|
+
frameIndex: fromFrameIndex,
|
|
892
|
+
direction: "forward"
|
|
893
|
+
});
|
|
847
894
|
const cached = this.document.createDocumentFragment();
|
|
848
895
|
cached.append(...oldFrame.childNodes);
|
|
849
896
|
this._frameCache.set(this.#frameKey(layerId, fromFrameIndex), cached);
|
|
@@ -852,8 +899,16 @@ class BrowserRuntime {
|
|
|
852
899
|
newFrame.append(...frag.childNodes);
|
|
853
900
|
layer.appendChild(newFrame);
|
|
854
901
|
this.#applyFrameDepth(layer, toFrameIndex);
|
|
902
|
+
if (toFrameIndex > 0) {
|
|
903
|
+
this.#persistFrameHtml(layerId, toFrameIndex, newFrame.innerHTML);
|
|
904
|
+
}
|
|
855
905
|
if (oldFrame)
|
|
856
906
|
oldFrame.remove();
|
|
907
|
+
this.#dispatchFrameEvent("modal_stack:frame-enter", {
|
|
908
|
+
layerId,
|
|
909
|
+
frameIndex: toFrameIndex,
|
|
910
|
+
direction: "forward"
|
|
911
|
+
});
|
|
857
912
|
if (transition)
|
|
858
913
|
this.#cleanupFrameTransition(newFrame);
|
|
859
914
|
}
|
|
@@ -861,6 +916,11 @@ class BrowserRuntime {
|
|
|
861
916
|
const layer = this.#findLayer(layerId);
|
|
862
917
|
if (!layer)
|
|
863
918
|
return;
|
|
919
|
+
this.#dispatchFrameEvent("modal_stack:frame-leave", {
|
|
920
|
+
layerId,
|
|
921
|
+
frameIndex: fromFrameIndex,
|
|
922
|
+
direction: "back"
|
|
923
|
+
});
|
|
864
924
|
const cacheKey = this.#frameKey(layerId, toFrameIndex);
|
|
865
925
|
let restored = stale ? null : this._frameCache.get(cacheKey) ?? null;
|
|
866
926
|
if (!restored) {
|
|
@@ -878,6 +938,11 @@ class BrowserRuntime {
|
|
|
878
938
|
const oldFrame = this.#findFrame(layer, fromFrameIndex);
|
|
879
939
|
if (oldFrame)
|
|
880
940
|
oldFrame.remove();
|
|
941
|
+
this.#dispatchFrameEvent("modal_stack:frame-enter", {
|
|
942
|
+
layerId,
|
|
943
|
+
frameIndex: toFrameIndex,
|
|
944
|
+
direction: "back"
|
|
945
|
+
});
|
|
881
946
|
if (transition)
|
|
882
947
|
this.#cleanupFrameTransition(newFrame);
|
|
883
948
|
}
|
|
@@ -887,6 +952,7 @@ class BrowserRuntime {
|
|
|
887
952
|
if (key.startsWith(prefix))
|
|
888
953
|
this._frameCache.delete(key);
|
|
889
954
|
}
|
|
955
|
+
this.#removeFrameHtmlForLayer(layerId);
|
|
890
956
|
}
|
|
891
957
|
async unmountTopLayer() {
|
|
892
958
|
const layer = this.#topLayer();
|
|
@@ -899,13 +965,18 @@ class BrowserRuntime {
|
|
|
899
965
|
const timeout = this.#leaveTimeoutMs();
|
|
900
966
|
await Promise.all(layers.map((l) => animateOut(l, timeout)));
|
|
901
967
|
}
|
|
902
|
-
pushHistory({
|
|
903
|
-
this.history.pushState(historyState, "",
|
|
968
|
+
pushHistory({ historyState }) {
|
|
969
|
+
this.history.pushState(historyState, "", this.location?.href ?? "");
|
|
904
970
|
}
|
|
905
|
-
replaceHistory({
|
|
906
|
-
this.history.replaceState(historyState, "",
|
|
971
|
+
replaceHistory({ historyState }) {
|
|
972
|
+
this.history.replaceState(historyState, "", this.location?.href ?? "");
|
|
907
973
|
}
|
|
908
974
|
historyBack({ n }) {
|
|
975
|
+
this.#suppressTurboVisitCount += 1;
|
|
976
|
+
clearTimeout(this.#suppressTurboVisitTimer);
|
|
977
|
+
this.#suppressTurboVisitTimer = setTimeout(() => {
|
|
978
|
+
this.#suppressTurboVisitCount = 0;
|
|
979
|
+
}, 1000);
|
|
909
980
|
this.history.go(-n);
|
|
910
981
|
}
|
|
911
982
|
rebuildFromSnapshot() {
|
|
@@ -923,6 +994,7 @@ class BrowserRuntime {
|
|
|
923
994
|
return;
|
|
924
995
|
try {
|
|
925
996
|
this.store.removeItem(SNAPSHOT_KEY);
|
|
997
|
+
this.store.removeItem(FRAME_HTML_KEY);
|
|
926
998
|
} catch {}
|
|
927
999
|
}
|
|
928
1000
|
readSnapshot() {
|
|
@@ -934,6 +1006,54 @@ class BrowserRuntime {
|
|
|
934
1006
|
return null;
|
|
935
1007
|
}
|
|
936
1008
|
}
|
|
1009
|
+
restoreFrameCacheFromStorage() {
|
|
1010
|
+
const map = this.#readFrameHtmlMap();
|
|
1011
|
+
for (const [key, html] of Object.entries(map)) {
|
|
1012
|
+
this._frameCache.set(key, parseFragment(html, this.document));
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
getFrameFragment(layerId, frameIndex) {
|
|
1016
|
+
return this._frameCache.get(this.#frameKey(layerId, frameIndex)) ?? null;
|
|
1017
|
+
}
|
|
1018
|
+
#persistFrameHtml(layerId, frameIndex, html) {
|
|
1019
|
+
if (!this.store)
|
|
1020
|
+
return;
|
|
1021
|
+
try {
|
|
1022
|
+
const map = this.#readFrameHtmlMap();
|
|
1023
|
+
map[`${layerId}#${frameIndex}`] = html;
|
|
1024
|
+
this.store.setItem(FRAME_HTML_KEY, JSON.stringify(map));
|
|
1025
|
+
} catch {}
|
|
1026
|
+
}
|
|
1027
|
+
#readFrameHtmlMap() {
|
|
1028
|
+
try {
|
|
1029
|
+
const raw = this.store?.getItem(FRAME_HTML_KEY);
|
|
1030
|
+
return raw ? JSON.parse(raw) : {};
|
|
1031
|
+
} catch {
|
|
1032
|
+
return {};
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
#removeFrameHtmlForLayer(layerId) {
|
|
1036
|
+
if (!this.store)
|
|
1037
|
+
return;
|
|
1038
|
+
try {
|
|
1039
|
+
const map = this.#readFrameHtmlMap();
|
|
1040
|
+
const prefix = `${layerId}#`;
|
|
1041
|
+
let changed = false;
|
|
1042
|
+
for (const key of Object.keys(map)) {
|
|
1043
|
+
if (key.startsWith(prefix)) {
|
|
1044
|
+
delete map[key];
|
|
1045
|
+
changed = true;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
if (!changed)
|
|
1049
|
+
return;
|
|
1050
|
+
if (Object.keys(map).length === 0) {
|
|
1051
|
+
this.store.removeItem(FRAME_HTML_KEY);
|
|
1052
|
+
} else {
|
|
1053
|
+
this.store.setItem(FRAME_HTML_KEY, JSON.stringify(map));
|
|
1054
|
+
}
|
|
1055
|
+
} catch {}
|
|
1056
|
+
}
|
|
937
1057
|
#leaveTimeoutMs() {
|
|
938
1058
|
if (this._cachedLeaveTimeoutMs != null)
|
|
939
1059
|
return this._cachedLeaveTimeoutMs;
|
|
@@ -959,6 +1079,9 @@ class BrowserRuntime {
|
|
|
959
1079
|
#frameKey(layerId, frameIndex) {
|
|
960
1080
|
return `${layerId}#${frameIndex}`;
|
|
961
1081
|
}
|
|
1082
|
+
#dispatchFrameEvent(name, detail) {
|
|
1083
|
+
this.dialog.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
|
|
1084
|
+
}
|
|
962
1085
|
#purgeFrameCacheAbove(layerId, frameIndex) {
|
|
963
1086
|
const prefix = `${layerId}#`;
|
|
964
1087
|
for (const key of [...this._frameCache.keys()]) {
|
|
@@ -1122,26 +1245,38 @@ class ModalStackController extends Controller2 {
|
|
|
1122
1245
|
maxDepth: { type: Number, default: 0 },
|
|
1123
1246
|
maxDepthStrategy: { type: String, default: "warn" }
|
|
1124
1247
|
};
|
|
1248
|
+
#restoring = false;
|
|
1125
1249
|
connect() {
|
|
1126
|
-
const stackId = this.stackIdValue || generateLayerId();
|
|
1127
1250
|
const baseUrl = this.baseUrlValue || window.location.href;
|
|
1128
1251
|
this.runtime = new BrowserRuntime({ dialog: this.element });
|
|
1129
|
-
|
|
1252
|
+
this.runtime.restoreFrameCacheFromStorage();
|
|
1253
|
+
const savedSnapshot = this.runtime.readSnapshot();
|
|
1254
|
+
const snapshotState = savedSnapshot ? restore(savedSnapshot) : null;
|
|
1255
|
+
const stackId = this.stackIdValue || snapshotState?.stackId || generateLayerId();
|
|
1130
1256
|
this.orchestrator = new Orchestrator({
|
|
1131
1257
|
runtime: this.runtime,
|
|
1132
1258
|
stackId,
|
|
1133
1259
|
baseUrl,
|
|
1134
|
-
restoreFrom:
|
|
1260
|
+
restoreFrom: null,
|
|
1135
1261
|
maxDepth: this.maxDepthValue > 0 ? this.maxDepthValue : null,
|
|
1136
1262
|
maxDepthStrategy: this.maxDepthStrategyValue || "warn"
|
|
1137
1263
|
});
|
|
1138
|
-
this._onPopstate = (event) =>
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1264
|
+
this._onPopstate = (event) => {
|
|
1265
|
+
const isOwn = this.orchestrator.expectedPopstates > 0;
|
|
1266
|
+
this.orchestrator.onPopstate({
|
|
1267
|
+
historyState: event.state,
|
|
1268
|
+
locationHref: window.location.href
|
|
1269
|
+
});
|
|
1270
|
+
if (isOwn)
|
|
1271
|
+
event.stopImmediatePropagation();
|
|
1272
|
+
};
|
|
1273
|
+
window.addEventListener("popstate", this._onPopstate, true);
|
|
1143
1274
|
this._onCancel = (event) => {
|
|
1144
1275
|
event.preventDefault();
|
|
1276
|
+
if (!this.element.open)
|
|
1277
|
+
return;
|
|
1278
|
+
if (this.#restoring)
|
|
1279
|
+
return;
|
|
1145
1280
|
const top = this.#topLayer();
|
|
1146
1281
|
if (!top || top.dismissible === false)
|
|
1147
1282
|
return;
|
|
@@ -1151,19 +1286,66 @@ class ModalStackController extends Controller2 {
|
|
|
1151
1286
|
this._onBackdropClick = (event) => {
|
|
1152
1287
|
if (event.target !== this.element)
|
|
1153
1288
|
return;
|
|
1289
|
+
if (!this.element.open)
|
|
1290
|
+
return;
|
|
1291
|
+
if (this.#restoring)
|
|
1292
|
+
return;
|
|
1154
1293
|
const top = this.#topLayer();
|
|
1155
1294
|
if (!top || top.dismissible === false)
|
|
1156
1295
|
return;
|
|
1157
1296
|
this.orchestrator.pop();
|
|
1158
1297
|
};
|
|
1159
1298
|
this.element.addEventListener("click", this._onBackdropClick);
|
|
1299
|
+
this._onTurboRender = () => {
|
|
1300
|
+
if (this.orchestrator.depth === 0)
|
|
1301
|
+
this.runtime.unlockScroll();
|
|
1302
|
+
};
|
|
1303
|
+
document.addEventListener("turbo:render", this._onTurboRender);
|
|
1160
1304
|
this.#registerStreamActions();
|
|
1161
|
-
|
|
1305
|
+
if (snapshotState?.layers?.length > 0) {
|
|
1306
|
+
this.#restoring = true;
|
|
1307
|
+
this.#restoreSnapshot(snapshotState.layers).catch((err) => console.warn("[modal_stack] snapshot restore failed:", err)).finally(() => {
|
|
1308
|
+
this.#restoring = false;
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
this.element.dispatchEvent(new CustomEvent("modal_stack:ready", {
|
|
1312
|
+
bubbles: true,
|
|
1313
|
+
detail: { stackId }
|
|
1314
|
+
}));
|
|
1315
|
+
}
|
|
1316
|
+
async#restoreSnapshot(layers) {
|
|
1317
|
+
const baseUrls = layers.map((l) => l.frames?.[0]?.url ?? l.url);
|
|
1318
|
+
const baseFragments = await Promise.all(baseUrls.map((url) => this.orchestrator.prefetch(url).catch(() => null)));
|
|
1319
|
+
for (let i = 0;i < layers.length; i++) {
|
|
1320
|
+
const layer = layers[i];
|
|
1321
|
+
await this.orchestrator.push({
|
|
1322
|
+
id: layer.id,
|
|
1323
|
+
url: baseUrls[i],
|
|
1324
|
+
variant: layer.variant,
|
|
1325
|
+
dismissible: layer.dismissible,
|
|
1326
|
+
size: layer.size,
|
|
1327
|
+
side: layer.side,
|
|
1328
|
+
width: layer.width,
|
|
1329
|
+
height: layer.height
|
|
1330
|
+
}, { fragment: baseFragments[i] });
|
|
1331
|
+
const extraFrames = (layer.frames ?? []).slice(1);
|
|
1332
|
+
for (let fi = 0;fi < extraFrames.length; fi++) {
|
|
1333
|
+
const frame = extraFrames[fi];
|
|
1334
|
+
const frameIndex = fi + 1;
|
|
1335
|
+
const cached = this.runtime.getFrameFragment(layer.id, frameIndex);
|
|
1336
|
+
if (!cached)
|
|
1337
|
+
break;
|
|
1338
|
+
this.orchestrator.setFragmentCache(frame.url, cached.cloneNode(true));
|
|
1339
|
+
await this.orchestrator.pathTo({ url: frame.url, stale: frame.stale }, { fragment: cached.cloneNode(true) });
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1162
1342
|
}
|
|
1163
1343
|
disconnect() {
|
|
1164
|
-
window.removeEventListener("popstate", this._onPopstate);
|
|
1344
|
+
window.removeEventListener("popstate", this._onPopstate, true);
|
|
1165
1345
|
this.element.removeEventListener("cancel", this._onCancel);
|
|
1166
1346
|
this.element.removeEventListener("click", this._onBackdropClick);
|
|
1347
|
+
document.removeEventListener("turbo:render", this._onTurboRender);
|
|
1348
|
+
this.runtime.destroy?.();
|
|
1167
1349
|
}
|
|
1168
1350
|
push(layer, opts) {
|
|
1169
1351
|
return this.orchestrator.push(layer, opts);
|
|
@@ -331,6 +331,41 @@ body[data-modal-stack-locked] {
|
|
|
331
331
|
display: none;
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
/* --- Header slot ------------------------------------------------- */
|
|
335
|
+
|
|
336
|
+
.modal-stack__panel-header {
|
|
337
|
+
display: flex;
|
|
338
|
+
align-items: center;
|
|
339
|
+
justify-content: space-between;
|
|
340
|
+
gap: 0.75rem;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.modal-stack__panel-title {
|
|
344
|
+
margin: 0;
|
|
345
|
+
flex: 1;
|
|
346
|
+
min-width: 0;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
.modal-stack__panel-close {
|
|
350
|
+
font: inherit;
|
|
351
|
+
background: transparent;
|
|
352
|
+
border: 0;
|
|
353
|
+
padding: 0.25rem;
|
|
354
|
+
cursor: pointer;
|
|
355
|
+
color: inherit;
|
|
356
|
+
opacity: 0.55;
|
|
357
|
+
line-height: 1;
|
|
358
|
+
font-size: 1.4em;
|
|
359
|
+
flex-shrink: 0;
|
|
360
|
+
transition: opacity 150ms ease;
|
|
361
|
+
}
|
|
362
|
+
.modal-stack__panel-close:hover { opacity: 1; }
|
|
363
|
+
.modal-stack__panel-close:focus-visible {
|
|
364
|
+
outline: 2px solid currentColor;
|
|
365
|
+
outline-offset: 2px;
|
|
366
|
+
border-radius: 2px;
|
|
367
|
+
}
|
|
368
|
+
|
|
334
369
|
@media (prefers-reduced-motion: reduce) {
|
|
335
370
|
#modal-stack-root,
|
|
336
371
|
#modal-stack-root::backdrop,
|