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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6457e6ddbb7a769783e711fd3956cbefc390ec9ada62daa6aa4d5d8a2bb7896
4
- data.tar.gz: bb59d60f0eea1bbfd57506b98eff623ed1cbea3f9cb2002406d32e0a6c22df36
3
+ metadata.gz: 992372f08bce8ea31105e8cba05c60035014852c197c38b325648eaebf154820
4
+ data.tar.gz: 70db58c91eda0101149fde86cec748812c7b8aff3f3908a30d6178538000c98f
5
5
  SHA512:
6
- metadata.gz: 69bdbb2322e3dad7984544f33b823a1ca62eb83993f51a8942a9321bfa7387978df9cc3c4ba3ee56dbfe49dd484a7a5b09f78fe811c7ebc636b8e51dd16e1e2a
7
- data.tar.gz: b7c29d6355f7fb8f6fd15971037798eb22236c05bf19078d9a0cc7c58e080c09c30c0031ce98942b03fa183e8adcc301ac5f36186c77fa86443b63eafd417763
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 two `CustomEvent`s that bubble to `document`:
635
+ The `<dialog>` emits `CustomEvent`s that bubble to `document`:
613
636
 
614
- | Event | `detail` | Fired when |
615
- | ---------------------- | ------------------------------------------- | ---------- |
616
- | `modal_stack:ready` | `{ stackId }` | The Stimulus controller has connected and the orchestrator is ready. |
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. |
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 (!newTop)
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({ url, historyState }) {
903
- this.history.pushState(historyState, "", url);
968
+ pushHistory({ historyState }) {
969
+ this.history.pushState(historyState, "", this.location?.href ?? "");
904
970
  }
905
- replaceHistory({ url, historyState }) {
906
- this.history.replaceState(historyState, "", url);
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
- const snapshot2 = this.runtime.readSnapshot();
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: snapshot2,
1260
+ restoreFrom: null,
1135
1261
  maxDepth: this.maxDepthValue > 0 ? this.maxDepthValue : null,
1136
1262
  maxDepthStrategy: this.maxDepthStrategyValue || "warn"
1137
1263
  });
1138
- this._onPopstate = (event) => this.orchestrator.onPopstate({
1139
- historyState: event.state,
1140
- locationHref: window.location.href
1141
- });
1142
- window.addEventListener("popstate", this._onPopstate);
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
- this.element.dispatchEvent(new CustomEvent("modal_stack:ready", { bubbles: true, detail: { stackId } }));
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,