modal_stack 0.1.1 → 0.2.0

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: 44bb2750129e5c72cb364e6fd217603b35e838cd2b42d8f7d55b93902cb1409c
4
- data.tar.gz: 76aa67df55336d851d2c11ce06d00ab99fd1959bb4542331dba3b129ad3cacad
3
+ metadata.gz: 5d872f05608ece432767e38bbe58ec38bf97c60a0cd90ccf1afd37f41f7f6192
4
+ data.tar.gz: 3da69811422baf2e1e7927bae0dbe46cf75311b5214e34e90e0a04e8aa691250
5
5
  SHA512:
6
- metadata.gz: 64e9cb4d64f3cabb2d78b3b7f37aa608300008cbab3195fb1cc3f0c57f9b7492907577d0fe4db4415aaebc75a54226ab9114a632ce905864d8de96d8b078766e
7
- data.tar.gz: 2f525716be7b232ada7ce0a3499c859707e6e61e5e4990ae6097b416e876e05f62e56d5d945ac5819aeb355a464811213fb6b9f1bd149ab4b849ff87d75c42ad
6
+ metadata.gz: 3d9470889c9fa8b2d967174818eb813a90657baac94a70edadf3f7a416400e364cc5e15ed9b7274544b138f8b5647189e21143b14aa27d67cac79ae711822d20
7
+ data.tar.gz: 5dcdab2a2dca8c1b57b67e62f1daff68cc3b04395e5e66d9a3da933de02caef78460116c4b097fb040fa33011a83eb79f0645d6e273e91ad21ec552a27023d1b
data/CHANGELOG.md CHANGED
@@ -12,6 +12,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
12
12
 
13
13
  ### Fixed
14
14
 
15
+ ## [0.2.0] - 2026-05-03
16
+
17
+ ### Added
18
+ - **`max_depth` enforcement**: pushes past the cap are now intercepted by the reducer. The new `config.max_depth_strategy` (`:warn` default, `:raise`, `:silent`) controls behaviour. The cap can be disabled with `config.max_depth = nil`.
19
+ - **`ModalStackDepthError`** JS class, thrown by `push()` under the `:raise` strategy. Exported from `state.js`.
20
+ - **Scrollbar-width compensation**: `BrowserRuntime#lockScroll` now sets `--modal-stack-scrollbar-width` on `<html>` so the host CSS can offset fixed elements without layout shift. The CSS variable was already referenced by the Tailwind / Bootstrap / vanilla presets — this completes the wiring.
21
+ - **`modal_stack:error` custom event**: malformed Turbo Stream payloads (bad `data-*`, fetch failures) no longer crash the page. The error is logged and re-emitted as a bubbling `CustomEvent` on the `<dialog>` so apps can surface UI feedback.
22
+ - **JSDoc** on the JS public surface (`state.js`, `runtime.js`, `orchestrator.js`) — including `Layer`, `Stack`, `Command`, and `Transition` typedefs.
23
+ - New tests: max_depth strategies, scrollbar-width compensation, missing-handler error message, default_dismissible/max_depth/max_depth_strategy validation, dialog tag wiring.
24
+
25
+ ### Changed
26
+ - `Configuration#default_dismissible=` now raises `ArgumentError` on non-boolean values (was a silent `attr_accessor`).
27
+ - `Configuration#max_depth=` now coerces strings, accepts `nil`, and rejects non-positive integers.
28
+ - `Orchestrator` constructor accepts `maxDepth` + `maxDepthStrategy`. The Stimulus controller forwards them via `data-modal-stack-max-depth-value` / `data-modal-stack-max-depth-strategy-value`, which `modal_stack_dialog_tag` now emits from the gem's configuration.
29
+ - The "runtime missing handler" error message now lists the runtime's known handlers and the current stack depth.
30
+ - `INITIALIZER_VERSION` bumped to `0.2.0` because the generator template gained `config.max_depth_strategy`.
31
+
15
32
  ## [0.1.1] - 2026-05-02
16
33
 
17
34
  ### Added
data/README.md CHANGED
@@ -210,7 +210,8 @@ ModalStack.configure do |config|
210
210
  config.default_dismissible = true # ESC + backdrop click close the layer
211
211
 
212
212
  # ─── Behavior ─────────────────────────────────────────────────────
213
- config.max_depth = 5 # hard cap on nested layers
213
+ config.max_depth = 5 # hard cap on nested layers (nil to disable)
214
+ config.max_depth_strategy = :warn # :warn | :raise | :silent
214
215
  config.respect_reduced_motion = true # honor prefers-reduced-motion
215
216
  config.replace_turbo_confirm = false # use modal_stack confirmations for data-turbo-confirm
216
217
 
@@ -393,8 +394,17 @@ The `<dialog>` itself is opened on first push, closed on last pop. Page
393
394
  scroll is locked while any layer is open (`<body data-modal-stack-locked>`)
394
395
  so the page beneath doesn't scroll under your finger on touch devices.
395
396
 
396
- `max_depth` (default `5`) is a hard ceiling pushing past it raises a
397
- runtime error, on the assumption that you have a state-machine bug.
397
+ `max_depth` (default `5`) is a hard ceiling on the number of stacked layers,
398
+ on the assumption that going past it usually means you have a state-machine
399
+ bug. The behaviour is controlled by `config.max_depth_strategy`:
400
+
401
+ | Strategy | Behaviour |
402
+ | ---------- | -------------------------------------------------------------------- |
403
+ | `:warn` | (default) The push is dropped and `console.warn` logs a message. |
404
+ | `:raise` | The JS runtime throws `ModalStackDepthError` (caught by the stream-action error boundary, see below). |
405
+ | `:silent` | The push is dropped without logging. |
406
+
407
+ Set `config.max_depth = nil` to disable the cap entirely.
398
408
 
399
409
  ---
400
410
 
@@ -416,7 +426,8 @@ ModalStack.reset_configuration! # test-fixture helper
416
426
  | `default_size` | Symbol | `:md` | `:sm`, `:md`, `:lg`, `:xl`. Validated. |
417
427
  | `default_dismissible` | Boolean | `true` | Default for `dismissible:` when omitted. |
418
428
  | `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. |
419
- | `max_depth` | Integer | `5` | Hard cap on stack depth pushing past it raises. |
429
+ | `max_depth` | Integer | `5` | Hard cap on stack depth. Coerced from strings; set to `nil` to disable. Validated. |
430
+ | `max_depth_strategy` | Symbol | `:warn` | One of `:warn`, `:raise`, `:silent`. See [Stack depth & inertness](#stack-depth--inertness). Validated. |
420
431
  | `request_header` | String | `"X-Modal-Stack-Request"` | HTTP header used by the JS runtime to signal stack-originated fetches. Read by `modal_stack_request?`. |
421
432
  | `dialog_id` | String | `"modal-stack-root"` | The id of the singleton `<dialog>`. Override only on name collision. |
422
433
  | `stack_root_data_attribute` | String | `"modal-stack"` | The Stimulus `data-controller` value attached to the `<dialog>`. |
@@ -503,11 +514,11 @@ The package exports a small functional core + a browser adapter:
503
514
  import {
504
515
  // pure reducer — no IO, no DOM
505
516
  createStack, push, pop, replaceTop, closeAll, handlePopstate,
506
- snapshot, restore, topLayer, VARIANTS,
517
+ snapshot, restore, topLayer, VARIANTS, ModalStackDepthError,
507
518
 
508
519
  // orchestrator + browser runtime
509
520
  Orchestrator, BrowserRuntime,
510
- FRAGMENT_HEADER, SNAPSHOT_KEY,
521
+ FRAGMENT_HEADER, SNAPSHOT_KEY, SCROLLBAR_WIDTH_VAR,
511
522
  } from "modal_stack"
512
523
 
513
524
  import { install } from "modal_stack/install"
@@ -518,6 +529,39 @@ entry point your `application.js` calls. The reducer is
518
529
  side-effect-free and 100% covered; the browser adapter is the only
519
530
  file that touches `<dialog>`, `history`, `fetch`, and `sessionStorage`.
520
531
 
532
+ The reducer's command type vocabulary (`mountLayer`, `morphTopLayer`,
533
+ `unmountTopLayer`, `unmountAllLayers`, `showDialog`, `closeDialog`,
534
+ `lockScroll`, `unlockScroll`, `inertLayer`, `pushHistory`,
535
+ `replaceHistory`, `historyBack`, `rebuildFromSnapshot`, `persistSnapshot`,
536
+ `clearSnapshot`) forms the contract between `state.js` and any runtime —
537
+ swap in a custom adapter (e.g. for Hotwire Native) by implementing one
538
+ method per command name.
539
+
540
+ #### Custom events
541
+
542
+ The `<dialog>` emits two `CustomEvent`s that bubble to `document`:
543
+
544
+ | Event | `detail` | Fired when |
545
+ | ---------------------- | ------------------------------------------- | ---------- |
546
+ | `modal_stack:ready` | `{ stackId }` | The Stimulus controller has connected and the orchestrator is ready. |
547
+ | `modal_stack:error` | `{ action, error }` | A Turbo Stream action (`modal_push`/`modal_pop`/`modal_replace`/`modal_close_all`) threw or rejected. The page is not crashed; surface UI feedback in the listener. |
548
+
549
+ ```js
550
+ document.addEventListener("modal_stack:error", (event) => {
551
+ const { action, error } = event.detail;
552
+ showFlash(`Modal action ${action} failed: ${error.message}`);
553
+ });
554
+ ```
555
+
556
+ #### Scrollbar-width compensation
557
+
558
+ When the first layer is pushed, `BrowserRuntime#lockScroll` measures
559
+ `window.innerWidth - documentElement.clientWidth` and writes the result
560
+ to `--modal-stack-scrollbar-width` on `<html>`. The shipped CSS presets
561
+ already consume the variable (`padding-right: var(--modal-stack-scrollbar-width, 0)`)
562
+ so fixed elements don't jump rightward on lock. If you maintain custom
563
+ CSS, compose your fixed-position rules against the same variable.
564
+
521
565
  ### Capybara helpers
522
566
 
523
567
  For system specs, opt in by requiring the RSpec entrypoint:
@@ -11,6 +11,16 @@ var VARIANTS = Object.freeze([
11
11
  var SNAPSHOT_VERSION = 1;
12
12
  var DEFAULT_MAX_AGE_MS = 30 * 60 * 1000;
13
13
  var DRAWER_SIDES = Object.freeze(["left", "right", "top", "bottom"]);
14
+ var MAX_DEPTH_STRATEGIES = Object.freeze(["raise", "warn", "silent"]);
15
+
16
+ class ModalStackDepthError extends Error {
17
+ constructor({ maxDepth, attemptedDepth }) {
18
+ super(`modal_stack: cannot push past max_depth=${maxDepth} ` + `(attempted depth=${attemptedDepth})`);
19
+ this.name = "ModalStackDepthError";
20
+ this.maxDepth = maxDepth;
21
+ this.attemptedDepth = attemptedDepth;
22
+ }
23
+ }
14
24
  function normalizeLayerOptions({ variant, size, side, width, height }) {
15
25
  const normalizedSide = variant === "drawer" ? side ?? "right" : side ?? null;
16
26
  if (variant === "drawer" && !DRAWER_SIDES.includes(normalizedSide)) {
@@ -46,7 +56,7 @@ function createStack({ stackId, baseUrl }) {
46
56
  function topLayer(state) {
47
57
  return state.layers[state.layers.length - 1] ?? null;
48
58
  }
49
- function push(state, layer) {
59
+ function push(state, layer, options = {}) {
50
60
  if (!layer?.id)
51
61
  throw new Error("layer.id required");
52
62
  if (!layer?.url)
@@ -55,6 +65,22 @@ function push(state, layer) {
55
65
  if (!VARIANTS.includes(variant)) {
56
66
  throw new Error(`unknown variant: ${variant}`);
57
67
  }
68
+ const { maxDepth = null, maxDepthStrategy = "warn" } = options;
69
+ if (maxDepth != null && state.layers.length >= maxDepth) {
70
+ if (!MAX_DEPTH_STRATEGIES.includes(maxDepthStrategy)) {
71
+ throw new Error(`unknown maxDepthStrategy: ${maxDepthStrategy} (expected one of ${MAX_DEPTH_STRATEGIES.join(", ")})`);
72
+ }
73
+ if (maxDepthStrategy === "raise") {
74
+ throw new ModalStackDepthError({
75
+ maxDepth,
76
+ attemptedDepth: state.layers.length + 1
77
+ });
78
+ }
79
+ if (maxDepthStrategy === "warn" && typeof console !== "undefined") {
80
+ console.warn(`[modal_stack] push ignored: stack is at max_depth=${maxDepth}. ` + `Set ModalStack.configuration.max_depth higher, or use ` + `max_depth_strategy = :silent to suppress this warning.`);
81
+ }
82
+ return { state, commands: [] };
83
+ }
58
84
  const newLayer = freezeLayer({
59
85
  id: layer.id,
60
86
  url: layer.url,
@@ -302,10 +328,19 @@ function restore(serialized, { stackId, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Dat
302
328
  // app/javascript/modal_stack/orchestrator.js
303
329
  class Orchestrator {
304
330
  #expectedPopstates = 0;
305
- constructor({ runtime, stackId, baseUrl, restoreFrom = null }) {
331
+ constructor({
332
+ runtime,
333
+ stackId,
334
+ baseUrl,
335
+ restoreFrom = null,
336
+ maxDepth = null,
337
+ maxDepthStrategy = "warn"
338
+ }) {
306
339
  if (!runtime)
307
340
  throw new Error("runtime required");
308
341
  this.runtime = runtime;
342
+ this.maxDepth = maxDepth;
343
+ this.maxDepthStrategy = maxDepthStrategy;
309
344
  this.state = createStack({ stackId, baseUrl });
310
345
  if (restoreFrom) {
311
346
  const restored = restore(restoreFrom, { stackId });
@@ -320,10 +355,16 @@ class Orchestrator {
320
355
  return this.state.layers.length;
321
356
  }
322
357
  async push(layer, { html = null, fragment = null } = {}) {
358
+ const transition = push(this.state, layer, {
359
+ maxDepth: this.maxDepth,
360
+ maxDepthStrategy: this.maxDepthStrategy
361
+ });
362
+ if (transition.commands.length === 0)
363
+ return;
323
364
  if (fragment == null && html == null && layer?.url) {
324
365
  fragment = await this.#prefetch(layer.url);
325
366
  }
326
- return this.#dispatch(push(this.state, layer), { html, fragment });
367
+ return this.#dispatch(transition, { html, fragment });
327
368
  }
328
369
  pop() {
329
370
  return this.#dispatch(pop(this.state));
@@ -371,7 +412,8 @@ class Orchestrator {
371
412
  }
372
413
  const handler = this.runtime[cmd.type];
373
414
  if (typeof handler !== "function") {
374
- throw new Error(`runtime missing handler for "${cmd.type}"`);
415
+ const known = Object.getOwnPropertyNames(Object.getPrototypeOf(this.runtime)).filter((name) => name !== "constructor" && typeof this.runtime[name] === "function").sort().join(", ");
416
+ throw new Error(`[modal_stack] runtime missing handler for "${cmd.type}" ` + `(stack depth=${this.depth}). ` + `Known handlers: ${known || "<none>"}.`);
375
417
  }
376
418
  await handler.call(this.runtime, cmd);
377
419
  }
@@ -380,6 +422,7 @@ class Orchestrator {
380
422
  // app/javascript/modal_stack/runtime.js
381
423
  var SNAPSHOT_KEY = "modalStackSnapshot";
382
424
  var FRAGMENT_HEADER = "X-Modal-Stack-Request";
425
+ var SCROLLBAR_WIDTH_VAR = "--modal-stack-scrollbar-width";
383
426
  var LAYER_SELECTOR = '[data-modal-stack-target="layer"]';
384
427
  var LEAVE_TIMEOUT_MS = 600;
385
428
 
@@ -414,12 +457,22 @@ class BrowserRuntime {
414
457
  this.dialog.close();
415
458
  }
416
459
  lockScroll() {
417
- if (this.body)
418
- this.body.dataset.modalStackLocked = "";
460
+ if (!this.body)
461
+ return;
462
+ const root = this.document?.documentElement;
463
+ if (root) {
464
+ const scrollbarWidth = Math.max(0, (globalThis.innerWidth ?? root.clientWidth) - root.clientWidth);
465
+ root.style.setProperty(SCROLLBAR_WIDTH_VAR, `${scrollbarWidth}px`);
466
+ }
467
+ this.body.dataset.modalStackLocked = "";
419
468
  }
420
469
  unlockScroll() {
421
- if (this.body)
422
- delete this.body.dataset.modalStackLocked;
470
+ if (!this.body)
471
+ return;
472
+ delete this.body.dataset.modalStackLocked;
473
+ const root = this.document?.documentElement;
474
+ if (root)
475
+ root.style.removeProperty(SCROLLBAR_WIDTH_VAR);
423
476
  }
424
477
  inertLayer({ layerId, value }) {
425
478
  const layer = this.#findLayer(layerId);
@@ -582,7 +635,9 @@ function escapeAttr(value) {
582
635
  class ModalStackController extends Controller {
583
636
  static values = {
584
637
  stackId: String,
585
- baseUrl: String
638
+ baseUrl: String,
639
+ maxDepth: { type: Number, default: 0 },
640
+ maxDepthStrategy: { type: String, default: "warn" }
586
641
  };
587
642
  connect() {
588
643
  const stackId = this.stackIdValue || generateLayerId();
@@ -593,7 +648,9 @@ class ModalStackController extends Controller {
593
648
  runtime: this.runtime,
594
649
  stackId,
595
650
  baseUrl,
596
- restoreFrom: snapshot2
651
+ restoreFrom: snapshot2,
652
+ maxDepth: this.maxDepthValue > 0 ? this.maxDepthValue : null,
653
+ maxDepthStrategy: this.maxDepthStrategyValue || "warn"
597
654
  });
598
655
  this._onPopstate = (event) => this.orchestrator.onPopstate({
599
656
  historyState: event.state,
@@ -649,25 +706,46 @@ class ModalStackController extends Controller {
649
706
  }
650
707
  const StreamActions = Turbo.StreamActions || (Turbo.StreamActions = {});
651
708
  const orchestrator = this.orchestrator;
652
- StreamActions.modal_push = function modalPush() {
653
- orchestrator.push(layerFromStreamElement(this), {
709
+ const dialog = this.element;
710
+ const guarded = (action, fn) => function guardedStreamAction() {
711
+ try {
712
+ const result = fn.call(this, orchestrator);
713
+ if (result && typeof result.catch === "function") {
714
+ result.catch((err) => emitStreamError(dialog, action, err));
715
+ }
716
+ } catch (err) {
717
+ emitStreamError(dialog, action, err);
718
+ }
719
+ };
720
+ StreamActions.modal_push = guarded("modal_push", function(orch) {
721
+ return orch.push(layerFromStreamElement(this), {
654
722
  fragment: this.templateContent.cloneNode(true)
655
723
  });
656
- };
657
- StreamActions.modal_pop = function modalPop() {
658
- orchestrator.pop();
659
- };
660
- StreamActions.modal_replace = function modalReplace() {
661
- orchestrator.replaceTop(layerPatchFromStreamElement(this), {
724
+ });
725
+ StreamActions.modal_pop = guarded("modal_pop", function(orch) {
726
+ return orch.pop();
727
+ });
728
+ StreamActions.modal_replace = guarded("modal_replace", function(orch) {
729
+ return orch.replaceTop(layerPatchFromStreamElement(this), {
662
730
  fragment: this.templateContent.cloneNode(true),
663
731
  historyMode: this.dataset.historyMode || "replace"
664
732
  });
665
- };
666
- StreamActions.modal_close_all = function modalCloseAll() {
667
- orchestrator.closeAll();
668
- };
733
+ });
734
+ StreamActions.modal_close_all = guarded("modal_close_all", function(orch) {
735
+ return orch.closeAll();
736
+ });
669
737
  }
670
738
  }
739
+ function emitStreamError(dialog, action, error) {
740
+ if (typeof console !== "undefined" && console.error) {
741
+ console.error(`[modal_stack] stream action "${action}" failed:`, error);
742
+ }
743
+ dialog.dispatchEvent(new CustomEvent("modal_stack:error", {
744
+ bubbles: true,
745
+ cancelable: false,
746
+ detail: { action, error }
747
+ }));
748
+ }
671
749
  function layerFromStreamElement(el) {
672
750
  return {
673
751
  id: el.dataset.layerId || generateLayerId(),
@@ -6,6 +6,8 @@ export class ModalStackController extends Controller {
6
6
  static values = {
7
7
  stackId: String,
8
8
  baseUrl: String,
9
+ maxDepth: { type: Number, default: 0 },
10
+ maxDepthStrategy: { type: String, default: "warn" },
9
11
  };
10
12
 
11
13
  connect() {
@@ -20,6 +22,10 @@ export class ModalStackController extends Controller {
20
22
  stackId,
21
23
  baseUrl,
22
24
  restoreFrom: snapshot,
25
+ // Stimulus Number values default to 0, but state.js treats null as
26
+ // "no cap" — so map 0/missing to null here.
27
+ maxDepth: this.maxDepthValue > 0 ? this.maxDepthValue : null,
28
+ maxDepthStrategy: this.maxDepthStrategyValue || "warn",
23
29
  });
24
30
 
25
31
  this._onPopstate = (event) =>
@@ -89,28 +95,57 @@ export class ModalStackController extends Controller {
89
95
  }
90
96
  const StreamActions = Turbo.StreamActions || (Turbo.StreamActions = {});
91
97
  const orchestrator = this.orchestrator;
92
-
93
- StreamActions.modal_push = function modalPush() {
94
- orchestrator.push(layerFromStreamElement(this), {
98
+ const dialog = this.element;
99
+
100
+ // Wraps a stream-action body so a malformed payload (bad data-*, fetch
101
+ // 500, etc.) doesn't bubble up and break the page. The error is logged
102
+ // and re-emitted as `modal_stack:error` so apps can surface UI feedback.
103
+ const guarded = (action, fn) =>
104
+ function guardedStreamAction() {
105
+ try {
106
+ const result = fn.call(this, orchestrator);
107
+ if (result && typeof result.catch === "function") {
108
+ result.catch((err) => emitStreamError(dialog, action, err));
109
+ }
110
+ } catch (err) {
111
+ emitStreamError(dialog, action, err);
112
+ }
113
+ };
114
+
115
+ StreamActions.modal_push = guarded("modal_push", function (orch) {
116
+ return orch.push(layerFromStreamElement(this), {
95
117
  fragment: this.templateContent.cloneNode(true),
96
118
  });
97
- };
119
+ });
98
120
 
99
- StreamActions.modal_pop = function modalPop() {
100
- orchestrator.pop();
101
- };
121
+ StreamActions.modal_pop = guarded("modal_pop", function (orch) {
122
+ return orch.pop();
123
+ });
102
124
 
103
- StreamActions.modal_replace = function modalReplace() {
104
- orchestrator.replaceTop(layerPatchFromStreamElement(this), {
125
+ StreamActions.modal_replace = guarded("modal_replace", function (orch) {
126
+ return orch.replaceTop(layerPatchFromStreamElement(this), {
105
127
  fragment: this.templateContent.cloneNode(true),
106
128
  historyMode: this.dataset.historyMode || "replace",
107
129
  });
108
- };
130
+ });
109
131
 
110
- StreamActions.modal_close_all = function modalCloseAll() {
111
- orchestrator.closeAll();
112
- };
132
+ StreamActions.modal_close_all = guarded("modal_close_all", function (orch) {
133
+ return orch.closeAll();
134
+ });
135
+ }
136
+ }
137
+
138
+ function emitStreamError(dialog, action, error) {
139
+ if (typeof console !== "undefined" && console.error) {
140
+ console.error(`[modal_stack] stream action "${action}" failed:`, error);
113
141
  }
142
+ dialog.dispatchEvent(
143
+ new CustomEvent("modal_stack:error", {
144
+ bubbles: true,
145
+ cancelable: false,
146
+ detail: { action, error },
147
+ }),
148
+ );
114
149
  }
115
150
 
116
151
  function layerFromStreamElement(el) {
@@ -9,12 +9,36 @@ import {
9
9
  snapshot,
10
10
  } from "./state.js";
11
11
 
12
+ /**
13
+ * Owns the current `Stack`, calls the pure reducer, and executes the emitted
14
+ * commands against an injected runtime. The only stateful piece is
15
+ * `#expectedPopstates`, which lets us avoid re-entering the reducer when our
16
+ * own `historyBack` calls fire `popstate`.
17
+ *
18
+ * @typedef {Object} OrchestratorOptions
19
+ * @property {object} runtime Instance with one method per command type
20
+ * @property {string} stackId
21
+ * @property {string} baseUrl
22
+ * @property {string|null} [restoreFrom] Serialized snapshot from sessionStorage
23
+ * @property {number|null} [maxDepth] null disables the cap
24
+ * @property {"raise"|"warn"|"silent"} [maxDepthStrategy]
25
+ */
12
26
  export class Orchestrator {
13
27
  #expectedPopstates = 0;
14
28
 
15
- constructor({ runtime, stackId, baseUrl, restoreFrom = null }) {
29
+ /** @param {OrchestratorOptions} options */
30
+ constructor({
31
+ runtime,
32
+ stackId,
33
+ baseUrl,
34
+ restoreFrom = null,
35
+ maxDepth = null,
36
+ maxDepthStrategy = "warn",
37
+ }) {
16
38
  if (!runtime) throw new Error("runtime required");
17
39
  this.runtime = runtime;
40
+ this.maxDepth = maxDepth;
41
+ this.maxDepthStrategy = maxDepthStrategy;
18
42
  this.state = createStack({ stackId, baseUrl });
19
43
 
20
44
  if (restoreFrom) {
@@ -31,17 +55,34 @@ export class Orchestrator {
31
55
  return this.state.layers.length;
32
56
  }
33
57
 
58
+ /**
59
+ * Push a layer. When `html`/`fragment` are absent, the orchestrator
60
+ * pre-fetches the URL so `mountLayer` is a sync DOM append (no flash).
61
+ * @param {Partial<import("./state.js").Layer> & { id: string, url: string }} layer
62
+ * @param {{ html?: string|null, fragment?: DocumentFragment|null }} [options]
63
+ */
34
64
  async push(layer, { html = null, fragment = null } = {}) {
65
+ const transition = push(this.state, layer, {
66
+ maxDepth: this.maxDepth,
67
+ maxDepthStrategy: this.maxDepthStrategy,
68
+ });
69
+ if (transition.commands.length === 0) return;
70
+
35
71
  if (fragment == null && html == null && layer?.url) {
36
72
  fragment = await this.#prefetch(layer.url);
37
73
  }
38
- return this.#dispatch(push(this.state, layer), { html, fragment });
74
+ return this.#dispatch(transition, { html, fragment });
39
75
  }
40
76
 
41
77
  pop() {
42
78
  return this.#dispatch(pop(this.state));
43
79
  }
44
80
 
81
+ /**
82
+ * Replace (morph) the top layer.
83
+ * @param {Partial<import("./state.js").Layer>} patch
84
+ * @param {{ html?: string|null, fragment?: DocumentFragment|null, historyMode?: "push"|"replace" }} [options]
85
+ */
45
86
  async replaceTop(patch, { html = null, fragment = null, ...opts } = {}) {
46
87
  if (fragment == null && html == null && patch?.url) {
47
88
  fragment = await this.#prefetch(patch.url);
@@ -91,7 +132,15 @@ export class Orchestrator {
91
132
 
92
133
  const handler = this.runtime[cmd.type];
93
134
  if (typeof handler !== "function") {
94
- throw new Error(`runtime missing handler for "${cmd.type}"`);
135
+ const known = Object.getOwnPropertyNames(Object.getPrototypeOf(this.runtime))
136
+ .filter((name) => name !== "constructor" && typeof this.runtime[name] === "function")
137
+ .sort()
138
+ .join(", ");
139
+ throw new Error(
140
+ `[modal_stack] runtime missing handler for "${cmd.type}" ` +
141
+ `(stack depth=${this.depth}). ` +
142
+ `Known handlers: ${known || "<none>"}.`,
143
+ );
95
144
  }
96
145
  await handler.call(this.runtime, cmd);
97
146
  }
@@ -238,6 +238,56 @@ describe("onPopstate", () => {
238
238
  expect(rebuild).toMatchObject({ targetDepth: 2, targetLayerId: "L2" });
239
239
  });
240
240
 
241
+ test("missing handler error names known handlers", async () => {
242
+ const partialRuntime = {
243
+ // showDialog is missing on purpose so we can verify the error message.
244
+ mountLayer: () => {},
245
+ lockScroll: () => {},
246
+ pushHistory: () => {},
247
+ persistSnapshot: () => {},
248
+ };
249
+ Object.setPrototypeOf(partialRuntime, {
250
+ mountLayer: partialRuntime.mountLayer,
251
+ lockScroll: partialRuntime.lockScroll,
252
+ pushHistory: partialRuntime.pushHistory,
253
+ persistSnapshot: partialRuntime.persistSnapshot,
254
+ });
255
+ const orch = new Orchestrator({
256
+ runtime: partialRuntime,
257
+ stackId: STACK_ID,
258
+ baseUrl: BASE_URL,
259
+ });
260
+ let caught = null;
261
+ try {
262
+ await orch.push({ id: "L1", url: "/x" });
263
+ } catch (e) {
264
+ caught = e;
265
+ }
266
+ expect(caught).toBeInstanceOf(Error);
267
+ expect(caught.message).toMatch(/showDialog/);
268
+ expect(caught.message).toMatch(/depth=/);
269
+ });
270
+
271
+ test("max_depth strategy is threaded through to the reducer", async () => {
272
+ const orch = new Orchestrator({
273
+ runtime: recordingRuntime(),
274
+ stackId: STACK_ID,
275
+ baseUrl: BASE_URL,
276
+ maxDepth: 1,
277
+ maxDepthStrategy: "raise",
278
+ });
279
+ await orch.push({ id: "L1", url: "/x" });
280
+ let caught = null;
281
+ try {
282
+ await orch.push({ id: "L2", url: "/y" });
283
+ } catch (e) {
284
+ caught = e;
285
+ }
286
+ expect(caught).not.toBeNull();
287
+ expect(caught.name).toBe("ModalStackDepthError");
288
+ expect(orch.depth).toBe(1);
289
+ });
290
+
241
291
  test("popstate from a different stack tears down without history changes", async () => {
242
292
  await orchestrator.push({ id: "L1", url: "/x" });
243
293
  runtime._calls.length = 0;
@@ -1,12 +1,30 @@
1
1
  export const SNAPSHOT_KEY = "modalStackSnapshot";
2
2
  export const FRAGMENT_HEADER = "X-Modal-Stack-Request";
3
+ export const SCROLLBAR_WIDTH_VAR = "--modal-stack-scrollbar-width";
3
4
 
4
5
  const LAYER_SELECTOR = '[data-modal-stack-target="layer"]';
5
6
  // Hard cap: never wait longer than this for an exit transition to fire,
6
7
  // even if the host CSS forgot to transition the leaving state.
7
8
  const LEAVE_TIMEOUT_MS = 600;
8
9
 
10
+ /**
11
+ * The only file that touches `<dialog>`, `history`, `fetch`, and
12
+ * `sessionStorage`. Implements one method per command type emitted by the
13
+ * reducer in `state.js`.
14
+ *
15
+ * Tests can swap in any object that implements the same surface (see
16
+ * `orchestrator.test.js` for an in-memory fake).
17
+ */
9
18
  export class BrowserRuntime {
19
+ /**
20
+ * @param {Object} options
21
+ * @param {HTMLDialogElement} options.dialog
22
+ * @param {HTMLElement} [options.body]
23
+ * @param {History} [options.history]
24
+ * @param {typeof fetch} [options.fetcher]
25
+ * @param {Storage} [options.store]
26
+ * @param {Document} [options.documentRef]
27
+ */
10
28
  constructor({
11
29
  dialog,
12
30
  body = globalThis.document?.body,
@@ -35,11 +53,27 @@ export class BrowserRuntime {
35
53
  }
36
54
 
37
55
  lockScroll() {
38
- if (this.body) this.body.dataset.modalStackLocked = "";
56
+ if (!this.body) return;
57
+ // Compensate for the scrollbar that disappears once <body> stops
58
+ // overflowing — without this, fixed elements jump rightward by the
59
+ // scrollbar width on lock. Host CSS reads the variable via
60
+ // `padding-right: var(--modal-stack-scrollbar-width, 0)`.
61
+ const root = this.document?.documentElement;
62
+ if (root) {
63
+ const scrollbarWidth = Math.max(
64
+ 0,
65
+ (globalThis.innerWidth ?? root.clientWidth) - root.clientWidth,
66
+ );
67
+ root.style.setProperty(SCROLLBAR_WIDTH_VAR, `${scrollbarWidth}px`);
68
+ }
69
+ this.body.dataset.modalStackLocked = "";
39
70
  }
40
71
 
41
72
  unlockScroll() {
42
- if (this.body) delete this.body.dataset.modalStackLocked;
73
+ if (!this.body) return;
74
+ delete this.body.dataset.modalStackLocked;
75
+ const root = this.document?.documentElement;
76
+ if (root) root.style.removeProperty(SCROLLBAR_WIDTH_VAR);
43
77
  }
44
78
 
45
79
  inertLayer({ layerId, value }) {
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { BrowserRuntime, FRAGMENT_HEADER, SNAPSHOT_KEY } from "./runtime.js";
2
+ import {
3
+ BrowserRuntime,
4
+ FRAGMENT_HEADER,
5
+ SCROLLBAR_WIDTH_VAR,
6
+ SNAPSHOT_KEY,
7
+ } from "./runtime.js";
3
8
 
4
9
  function fakeStore() {
5
10
  const map = new Map();
@@ -110,6 +115,70 @@ describe("history wiring", () => {
110
115
  });
111
116
  });
112
117
 
118
+ describe("scroll lock", () => {
119
+ function fakeStyle() {
120
+ const props = new Map();
121
+ return {
122
+ props,
123
+ setProperty: (k, v) => props.set(k, v),
124
+ removeProperty: (k) => props.delete(k),
125
+ };
126
+ }
127
+
128
+ function fakeRoot({ clientWidth = 1000 } = {}) {
129
+ return { clientWidth, style: fakeStyle() };
130
+ }
131
+
132
+ test("lockScroll sets scrollbar-width var from window/root delta", () => {
133
+ const root = fakeRoot({ clientWidth: 985 });
134
+ const body = { dataset: {} };
135
+ const documentRef = { documentElement: root, body };
136
+ const rt = new BrowserRuntime(
137
+ noopRuntimeArgs({ documentRef, body }),
138
+ );
139
+ // Bun's globalThis.innerWidth is 0 by default — set it for the duration.
140
+ const original = globalThis.innerWidth;
141
+ globalThis.innerWidth = 1000;
142
+ try {
143
+ rt.lockScroll();
144
+ } finally {
145
+ globalThis.innerWidth = original;
146
+ }
147
+ expect(root.style.props.get(SCROLLBAR_WIDTH_VAR)).toBe("15px");
148
+ expect("modalStackLocked" in body.dataset).toBe(true);
149
+ });
150
+
151
+ test("unlockScroll clears the css variable", () => {
152
+ const root = fakeRoot();
153
+ root.style.props.set(SCROLLBAR_WIDTH_VAR, "15px");
154
+ const body = { dataset: { modalStackLocked: "" } };
155
+ const documentRef = { documentElement: root, body };
156
+ const rt = new BrowserRuntime(
157
+ noopRuntimeArgs({ documentRef, body }),
158
+ );
159
+ rt.unlockScroll();
160
+ expect(root.style.props.has(SCROLLBAR_WIDTH_VAR)).toBe(false);
161
+ expect("modalStackLocked" in body.dataset).toBe(false);
162
+ });
163
+
164
+ test("lockScroll never goes negative when there's no scrollbar", () => {
165
+ const root = fakeRoot({ clientWidth: 1000 });
166
+ const body = { dataset: {} };
167
+ const documentRef = { documentElement: root, body };
168
+ const rt = new BrowserRuntime(
169
+ noopRuntimeArgs({ documentRef, body }),
170
+ );
171
+ const original = globalThis.innerWidth;
172
+ globalThis.innerWidth = 800; // narrower than clientWidth
173
+ try {
174
+ rt.lockScroll();
175
+ } finally {
176
+ globalThis.innerWidth = original;
177
+ }
178
+ expect(root.style.props.get(SCROLLBAR_WIDTH_VAR)).toBe("0px");
179
+ });
180
+ });
181
+
113
182
  describe("fetch headers", () => {
114
183
  test("sends Accept and X-Modal-Stack-Request headers", async () => {
115
184
  let captured = null;
@@ -1,3 +1,27 @@
1
+ /**
2
+ * @typedef {"modal" | "drawer" | "bottom_sheet" | "confirmation"} Variant
3
+ * @typedef {"left" | "right" | "top" | "bottom"} DrawerSide
4
+ * @typedef {"sm" | "md" | "lg" | "xl"} Size
5
+ *
6
+ * @typedef {Object} Layer
7
+ * @property {string} id Stable layer identifier (used for inertness + DOM lookup)
8
+ * @property {string} url Layer URL — also written to history
9
+ * @property {Variant} variant
10
+ * @property {boolean} dismissible
11
+ * @property {Size|null} size
12
+ * @property {DrawerSide|null} side Required for drawers; null otherwise
13
+ * @property {string|null} width Free-form CSS width (e.g. "42rem")
14
+ * @property {string|null} height
15
+ *
16
+ * @typedef {Object} Stack
17
+ * @property {string} stackId
18
+ * @property {string} baseUrl
19
+ * @property {readonly Layer[]} layers
20
+ *
21
+ * @typedef {{ type: string } & Record<string, unknown>} Command
22
+ * @typedef {{ state: Stack, commands: readonly Command[] }} Transition
23
+ */
24
+
1
25
  export const VARIANTS = Object.freeze([
2
26
  "modal",
3
27
  "drawer",
@@ -8,6 +32,25 @@ export const VARIANTS = Object.freeze([
8
32
  const SNAPSHOT_VERSION = 1;
9
33
  const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000;
10
34
  const DRAWER_SIDES = Object.freeze(["left", "right", "top", "bottom"]);
35
+ const MAX_DEPTH_STRATEGIES = Object.freeze(["raise", "warn", "silent"]);
36
+
37
+ /**
38
+ * Thrown by `push()` when `maxDepth` is exceeded under the `"raise"` strategy.
39
+ * Caught upstream by the orchestrator's stream-action error boundary so the
40
+ * page doesn't blow up — but applications can also catch it directly when
41
+ * calling `orchestrator.push()` programmatically.
42
+ */
43
+ export class ModalStackDepthError extends Error {
44
+ constructor({ maxDepth, attemptedDepth }) {
45
+ super(
46
+ `modal_stack: cannot push past max_depth=${maxDepth} ` +
47
+ `(attempted depth=${attemptedDepth})`,
48
+ );
49
+ this.name = "ModalStackDepthError";
50
+ this.maxDepth = maxDepth;
51
+ this.attemptedDepth = attemptedDepth;
52
+ }
53
+ }
11
54
 
12
55
  function normalizeLayerOptions({ variant, size, side, width, height }) {
13
56
  // A drawer must always carry a side so CSS can position it.
@@ -37,17 +80,34 @@ function freezeLayer({ id, url, variant, dismissible, size, side, width, height
37
80
  });
38
81
  }
39
82
 
83
+ /**
84
+ * Build an empty, frozen stack.
85
+ * @param {{ stackId: string, baseUrl: string }} options
86
+ * @returns {Stack}
87
+ */
40
88
  export function createStack({ stackId, baseUrl }) {
41
89
  if (!stackId) throw new Error("stackId required");
42
90
  if (!baseUrl) throw new Error("baseUrl required");
43
91
  return Object.freeze({ stackId, baseUrl, layers: Object.freeze([]) });
44
92
  }
45
93
 
94
+ /**
95
+ * @param {Stack} state
96
+ * @returns {Layer|null}
97
+ */
46
98
  export function topLayer(state) {
47
99
  return state.layers[state.layers.length - 1] ?? null;
48
100
  }
49
101
 
50
- export function push(state, layer) {
102
+ /**
103
+ * Push a new layer on top of the stack.
104
+ *
105
+ * @param {Stack} state
106
+ * @param {Partial<Layer> & { id: string, url: string }} layer
107
+ * @param {{ maxDepth?: number|null, maxDepthStrategy?: "raise"|"warn"|"silent" }} [options]
108
+ * @returns {Transition}
109
+ */
110
+ export function push(state, layer, options = {}) {
51
111
  if (!layer?.id) throw new Error("layer.id required");
52
112
  if (!layer?.url) throw new Error("layer.url required");
53
113
  const variant = layer.variant ?? "modal";
@@ -55,6 +115,29 @@ export function push(state, layer) {
55
115
  throw new Error(`unknown variant: ${variant}`);
56
116
  }
57
117
 
118
+ const { maxDepth = null, maxDepthStrategy = "warn" } = options;
119
+ if (maxDepth != null && state.layers.length >= maxDepth) {
120
+ if (!MAX_DEPTH_STRATEGIES.includes(maxDepthStrategy)) {
121
+ throw new Error(
122
+ `unknown maxDepthStrategy: ${maxDepthStrategy} (expected one of ${MAX_DEPTH_STRATEGIES.join(", ")})`,
123
+ );
124
+ }
125
+ if (maxDepthStrategy === "raise") {
126
+ throw new ModalStackDepthError({
127
+ maxDepth,
128
+ attemptedDepth: state.layers.length + 1,
129
+ });
130
+ }
131
+ if (maxDepthStrategy === "warn" && typeof console !== "undefined") {
132
+ console.warn(
133
+ `[modal_stack] push ignored: stack is at max_depth=${maxDepth}. ` +
134
+ `Set ModalStack.configuration.max_depth higher, or use ` +
135
+ `max_depth_strategy = :silent to suppress this warning.`,
136
+ );
137
+ }
138
+ return { state, commands: [] };
139
+ }
140
+
58
141
  const newLayer = freezeLayer({
59
142
  id: layer.id,
60
143
  url: layer.url,
@@ -102,6 +185,11 @@ export function push(state, layer) {
102
185
  return { state: { ...state, layers }, commands };
103
186
  }
104
187
 
188
+ /**
189
+ * Pop the top layer. No-op when the stack is empty.
190
+ * @param {Stack} state
191
+ * @returns {Transition}
192
+ */
105
193
  export function pop(state) {
106
194
  if (state.layers.length === 0) return { state, commands: [] };
107
195
 
@@ -122,6 +210,13 @@ export function pop(state) {
122
210
  return { state: { ...state, layers: newLayers }, commands };
123
211
  }
124
212
 
213
+ /**
214
+ * Replace (morph) the top layer in-place.
215
+ * @param {Stack} state
216
+ * @param {Partial<Layer>} patch
217
+ * @param {{ historyMode?: "push"|"replace" }} [options]
218
+ * @returns {Transition}
219
+ */
125
220
  export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
126
221
  if (state.layers.length === 0) {
127
222
  throw new Error("replaceTop requires at least one layer");
@@ -171,6 +266,11 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
171
266
  };
172
267
  }
173
268
 
269
+ /**
270
+ * Close every layer at once.
271
+ * @param {Stack} state
272
+ * @returns {Transition}
273
+ */
174
274
  export function closeAll(state) {
175
275
  if (state.layers.length === 0) return { state, commands: [] };
176
276
  const n = state.layers.length;
@@ -186,6 +286,13 @@ export function closeAll(state) {
186
286
  };
187
287
  }
188
288
 
289
+ /**
290
+ * Reduce a browser `popstate` into a transition: pop layers, morph the top,
291
+ * or request a rebuild from snapshot for forward navigation.
292
+ * @param {Stack} state
293
+ * @param {{ historyState: any, locationHref: string }} options
294
+ * @returns {Transition}
295
+ */
189
296
  export function handlePopstate(state, { historyState, locationHref }) {
190
297
  const isOurs =
191
298
  historyState && historyState.stackId === state.stackId;
@@ -273,6 +380,12 @@ export function handlePopstate(state, { historyState, locationHref }) {
273
380
  return { state, commands: [] };
274
381
  }
275
382
 
383
+ /**
384
+ * Serialize the stack for sessionStorage. Versioned + timestamped.
385
+ * @param {Stack} state
386
+ * @param {{ now?: () => number }} [options]
387
+ * @returns {string}
388
+ */
276
389
  export function snapshot(state, { now = Date.now } = {}) {
277
390
  return JSON.stringify({
278
391
  v: SNAPSHOT_VERSION,
@@ -283,6 +396,13 @@ export function snapshot(state, { now = Date.now } = {}) {
283
396
  });
284
397
  }
285
398
 
399
+ /**
400
+ * Restore a stack from a serialized snapshot. Returns null on any validation
401
+ * failure (wrong stackId, expired, malformed JSON, etc.).
402
+ * @param {string} serialized
403
+ * @param {{ stackId?: string, maxAgeMs?: number, now?: () => number }} [options]
404
+ * @returns {Stack|null}
405
+ */
286
406
  export function restore(
287
407
  serialized,
288
408
  { stackId, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Date.now } = {},
@@ -1,8 +1,9 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import {
3
3
  closeAll,
4
4
  createStack,
5
5
  handlePopstate,
6
+ ModalStackDepthError,
6
7
  pop,
7
8
  push,
8
9
  replaceTop,
@@ -156,6 +157,87 @@ describe("push", () => {
156
157
  ).toThrow(/unknown drawer side/);
157
158
  });
158
159
 
160
+ describe("max_depth", () => {
161
+ let warnings = [];
162
+ const originalWarn = console.warn;
163
+
164
+ beforeEach(() => {
165
+ warnings = [];
166
+ console.warn = (...args) => warnings.push(args.join(" "));
167
+ });
168
+
169
+ afterEach(() => {
170
+ console.warn = originalWarn;
171
+ });
172
+
173
+ function stackOfDepth(n) {
174
+ let s = freshStack();
175
+ for (let i = 0; i < n; i++) {
176
+ s = push(s, { id: `L${i}`, url: `/p/${i}`, variant: "modal" }).state;
177
+ }
178
+ return s;
179
+ }
180
+
181
+ test("no cap when maxDepth is null (default)", () => {
182
+ const s = stackOfDepth(10);
183
+ const { state, commands } = push(s, { id: "L10", url: "/p/10", variant: "modal" });
184
+ expect(state.layers).toHaveLength(11);
185
+ expect(commands.length).toBeGreaterThan(0);
186
+ });
187
+
188
+ test("strategy 'warn' drops the push and logs", () => {
189
+ const s = stackOfDepth(3);
190
+ const { state, commands } = push(
191
+ s,
192
+ { id: "Lx", url: "/x", variant: "modal" },
193
+ { maxDepth: 3, maxDepthStrategy: "warn" },
194
+ );
195
+ expect(state).toBe(s);
196
+ expect(commands).toEqual([]);
197
+ expect(warnings.join("\n")).toMatch(/max_depth=3/);
198
+ });
199
+
200
+ test("strategy 'silent' drops the push without warning", () => {
201
+ const s = stackOfDepth(3);
202
+ const { state, commands } = push(
203
+ s,
204
+ { id: "Lx", url: "/x", variant: "modal" },
205
+ { maxDepth: 3, maxDepthStrategy: "silent" },
206
+ );
207
+ expect(state).toBe(s);
208
+ expect(commands).toEqual([]);
209
+ expect(warnings).toEqual([]);
210
+ });
211
+
212
+ test("strategy 'raise' throws ModalStackDepthError", () => {
213
+ const s = stackOfDepth(3);
214
+ let caught = null;
215
+ try {
216
+ push(
217
+ s,
218
+ { id: "Lx", url: "/x", variant: "modal" },
219
+ { maxDepth: 3, maxDepthStrategy: "raise" },
220
+ );
221
+ } catch (e) {
222
+ caught = e;
223
+ }
224
+ expect(caught).toBeInstanceOf(ModalStackDepthError);
225
+ expect(caught.maxDepth).toBe(3);
226
+ expect(caught.attemptedDepth).toBe(4);
227
+ });
228
+
229
+ test("rejects unknown strategy", () => {
230
+ const s = stackOfDepth(3);
231
+ expect(() =>
232
+ push(
233
+ s,
234
+ { id: "Lx", url: "/x", variant: "modal" },
235
+ { maxDepth: 3, maxDepthStrategy: "explode" },
236
+ ),
237
+ ).toThrow(/unknown maxDepthStrategy/);
238
+ });
239
+ });
240
+
159
241
  test("passes custom width and height to mount command", () => {
160
242
  const { state, commands } = push(freshStack(), {
161
243
  id: "L1",
@@ -40,9 +40,15 @@ ModalStack.configure do |config|
40
40
  # layout to "modal" — read by `modal_stack_request?`.
41
41
  config.request_header = "X-Modal-Stack-Request"
42
42
 
43
- # Hard cap on stack depth (push past this is a runtime error).
43
+ # Hard cap on stack depth. Set to `nil` to disable.
44
44
  config.max_depth = 5
45
45
 
46
+ # What to do when a push would exceed `max_depth`:
47
+ # :warn — log a console warning, drop the push (default)
48
+ # :raise — throw `ModalStackDepthError` from the JS runtime
49
+ # :silent — drop the push, no warning
50
+ config.max_depth_strategy = :warn
51
+
46
52
  # Replace `data-turbo-confirm` window.confirm with a modal_stack
47
53
  # confirmation layer (cf. RFC §15.Q7). Off by default — opt-in.
48
54
  config.replace_turbo_confirm = false
@@ -15,10 +15,9 @@ module ModalStack
15
15
  ASSETS_MODES = %i[importmap jsbundling sprockets auto].freeze
16
16
  VARIANTS = %i[modal drawer bottom_sheet confirmation].freeze
17
17
  SIZES = %i[sm md lg xl].freeze
18
+ MAX_DEPTH_STRATEGIES = %i[raise warn silent].freeze
18
19
 
19
20
  attr_accessor :default_classes,
20
- :default_dismissible,
21
- :max_depth,
22
21
  :request_header,
23
22
  :dialog_id,
24
23
  :stack_root_data_attribute,
@@ -28,7 +27,13 @@ module ModalStack
28
27
  :initializer_version,
29
28
  :silence_initializer_warning
30
29
 
31
- attr_reader :css_provider, :assets_mode, :default_variant, :default_size
30
+ attr_reader :css_provider,
31
+ :assets_mode,
32
+ :default_variant,
33
+ :default_size,
34
+ :default_dismissible,
35
+ :max_depth,
36
+ :max_depth_strategy
32
37
 
33
38
  def initialize
34
39
  @css_provider = :tailwind
@@ -37,6 +42,7 @@ module ModalStack
37
42
  @default_size = :md
38
43
  @default_dismissible = true
39
44
  @max_depth = 5
45
+ @max_depth_strategy = :warn
40
46
  @request_header = "X-Modal-Stack-Request"
41
47
  @dialog_id = "modal-stack-root"
42
48
  @stack_root_data_attribute = "modal-stack"
@@ -76,6 +82,34 @@ module ModalStack
76
82
  @default_size = value
77
83
  end
78
84
 
85
+ def default_dismissible=(value)
86
+ raise ArgumentError, "default_dismissible must be true or false, got #{value.inspect}" unless [true, false].include?(value)
87
+
88
+ @default_dismissible = value
89
+ end
90
+
91
+ def max_depth=(value)
92
+ if value.nil?
93
+ @max_depth = nil
94
+ return
95
+ end
96
+
97
+ coerced = Integer(value, exception: false)
98
+ raise ArgumentError, "max_depth must be a positive integer or nil, got #{value.inspect}" if coerced.nil? || coerced < 1
99
+
100
+ @max_depth = coerced
101
+ end
102
+
103
+ def max_depth_strategy=(value)
104
+ value = value.to_sym
105
+ unless MAX_DEPTH_STRATEGIES.include?(value)
106
+ raise ArgumentError,
107
+ "max_depth_strategy must be one of #{MAX_DEPTH_STRATEGIES.inspect}, got #{value.inspect}"
108
+ end
109
+
110
+ @max_depth_strategy = value
111
+ end
112
+
79
113
  private
80
114
 
81
115
  def default_classes_hash
@@ -26,14 +26,22 @@ module ModalStack
26
26
  config = ModalStack.configuration
27
27
  attrs = html_options.dup
28
28
  attrs[:id] ||= config.dialog_id
29
-
30
- existing_data = attrs[:data] || {}
31
- controllers = [existing_data[:controller], config.stack_root_data_attribute].compact.join(" ").strip
32
- attrs[:data] = existing_data.merge(controller: controllers)
29
+ attrs[:data] = build_dialog_data(attrs[:data], config)
33
30
 
34
31
  content_tag(:dialog, "".html_safe, attrs)
35
32
  end
36
33
 
34
+ # Merges caller-provided data attrs with the gem-managed ones (controller,
35
+ # max-depth value/strategy). Caller data wins on key collision.
36
+ def build_dialog_data(provided, config)
37
+ existing = provided || {}
38
+ controllers = [existing[:controller], config.stack_root_data_attribute].compact.join(" ").strip
39
+ data = existing.merge(controller: controllers)
40
+ data[:modal_stack_max_depth_value] ||= config.max_depth if config.max_depth
41
+ data[:modal_stack_max_depth_strategy_value] ||= config.max_depth_strategy.to_s
42
+ data
43
+ end
44
+
37
45
  # Emits a no-op SafeBuffer for now — kept as a stable hook for apps
38
46
  # that prefer a single line in their layout. The actual JS loading
39
47
  # is handled by the host app's bundler / importmap.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ModalStack
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/modal_stack.rb CHANGED
@@ -14,7 +14,7 @@ module ModalStack
14
14
  # Bumped when config/initializers/modal_stack.rb gains/loses an option,
15
15
  # so apps that haven't regenerated their initializer get a one-line
16
16
  # boot warning. Independent from the gem's VERSION.
17
- INITIALIZER_VERSION = "0.1.0"
17
+ INITIALIZER_VERSION = "0.2.0"
18
18
 
19
19
  class << self
20
20
  def configuration
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: modal_stack
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Florian Gagnaire
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-02 00:00:00.000000000 Z
11
+ date: 2026-05-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: railties