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 +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +50 -6
- data/app/assets/javascripts/modal_stack.js +100 -22
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +48 -13
- data/app/javascript/modal_stack/orchestrator.js +52 -3
- data/app/javascript/modal_stack/orchestrator.test.js +50 -0
- data/app/javascript/modal_stack/runtime.js +36 -2
- data/app/javascript/modal_stack/runtime.test.js +70 -1
- data/app/javascript/modal_stack/state.js +121 -1
- data/app/javascript/modal_stack/state.test.js +83 -1
- data/lib/generators/modal_stack/install/templates/initializer.rb +7 -1
- data/lib/modal_stack/configuration.rb +37 -3
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +12 -4
- data/lib/modal_stack/version.rb +1 -1
- data/lib/modal_stack.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: 5d872f05608ece432767e38bbe58ec38bf97c60a0cd90ccf1afd37f41f7f6192
|
|
4
|
+
data.tar.gz: 3da69811422baf2e1e7927bae0dbe46cf75311b5214e34e90e0a04e8aa691250
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
397
|
-
|
|
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
|
|
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({
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
653
|
-
|
|
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
|
|
658
|
-
|
|
659
|
-
};
|
|
660
|
-
StreamActions.modal_replace = function
|
|
661
|
-
|
|
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
|
|
667
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
};
|
|
121
|
+
StreamActions.modal_pop = guarded("modal_pop", function (orch) {
|
|
122
|
+
return orch.pop();
|
|
123
|
+
});
|
|
102
124
|
|
|
103
|
-
StreamActions.modal_replace = function
|
|
104
|
-
|
|
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
|
|
111
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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,
|
|
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.
|
data/lib/modal_stack/version.rb
CHANGED
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.
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-05-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: railties
|