modal_stack 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +37 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +748 -0
  6. data/Rakefile +12 -0
  7. data/app/assets/javascripts/modal_stack.js +756 -0
  8. data/app/assets/stylesheets/modal_stack/bootstrap.css +232 -0
  9. data/app/assets/stylesheets/modal_stack/tailwind.css +303 -0
  10. data/app/assets/stylesheets/modal_stack/vanilla.css +219 -0
  11. data/app/javascript/modal_stack/controllers/modal_stack_controller.js +149 -0
  12. data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +34 -0
  13. data/app/javascript/modal_stack/index.js +15 -0
  14. data/app/javascript/modal_stack/install.js +15 -0
  15. data/app/javascript/modal_stack/orchestrator.js +98 -0
  16. data/app/javascript/modal_stack/orchestrator.test.js +260 -0
  17. data/app/javascript/modal_stack/runtime.js +217 -0
  18. data/app/javascript/modal_stack/runtime.test.js +134 -0
  19. data/app/javascript/modal_stack/state.js +315 -0
  20. data/app/javascript/modal_stack/state.test.js +508 -0
  21. data/app/views/layouts/modal.html.erb +6 -0
  22. data/lib/generators/modal_stack/install/install_generator.rb +224 -0
  23. data/lib/generators/modal_stack/install/templates/initializer.rb +57 -0
  24. data/lib/modal_stack/capybara/minitest.rb +9 -0
  25. data/lib/modal_stack/capybara/rspec.rb +9 -0
  26. data/lib/modal_stack/capybara.rb +85 -0
  27. data/lib/modal_stack/configuration.rb +90 -0
  28. data/lib/modal_stack/controller_extensions.rb +73 -0
  29. data/lib/modal_stack/engine.rb +44 -0
  30. data/lib/modal_stack/helpers/modal_link_helper.rb +65 -0
  31. data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +45 -0
  32. data/lib/modal_stack/helpers/modal_stack_container_helper.rb +36 -0
  33. data/lib/modal_stack/initializer_version_check.rb +33 -0
  34. data/lib/modal_stack/turbo_streams_extension.rb +73 -0
  35. data/lib/modal_stack/version.rb +5 -0
  36. data/lib/modal_stack.rb +36 -0
  37. metadata +130 -0
@@ -0,0 +1,756 @@
1
+ // app/javascript/modal_stack/controllers/modal_stack_controller.js
2
+ import { Controller } from "@hotwired/stimulus";
3
+
4
+ // app/javascript/modal_stack/state.js
5
+ var VARIANTS = Object.freeze([
6
+ "modal",
7
+ "drawer",
8
+ "bottom_sheet",
9
+ "confirmation"
10
+ ]);
11
+ var SNAPSHOT_VERSION = 1;
12
+ var DEFAULT_MAX_AGE_MS = 30 * 60 * 1000;
13
+ var DRAWER_SIDES = Object.freeze(["left", "right", "top", "bottom"]);
14
+ function normalizeLayerOptions({ variant, size, side, width, height }) {
15
+ const normalizedSide = variant === "drawer" ? side ?? "right" : side ?? null;
16
+ if (variant === "drawer" && !DRAWER_SIDES.includes(normalizedSide)) {
17
+ throw new Error(`unknown drawer side: ${normalizedSide}`);
18
+ }
19
+ return {
20
+ size: size ?? null,
21
+ side: normalizedSide,
22
+ width: width ?? null,
23
+ height: height ?? null
24
+ };
25
+ }
26
+ function freezeLayer({ id, url, variant, dismissible, size, side, width, height }) {
27
+ const normalized = normalizeLayerOptions({ variant, size, side, width, height });
28
+ return Object.freeze({
29
+ id,
30
+ url,
31
+ variant,
32
+ dismissible: !!dismissible,
33
+ size: normalized.size,
34
+ side: normalized.side,
35
+ width: normalized.width,
36
+ height: normalized.height
37
+ });
38
+ }
39
+ function createStack({ stackId, baseUrl }) {
40
+ if (!stackId)
41
+ throw new Error("stackId required");
42
+ if (!baseUrl)
43
+ throw new Error("baseUrl required");
44
+ return Object.freeze({ stackId, baseUrl, layers: Object.freeze([]) });
45
+ }
46
+ function topLayer(state) {
47
+ return state.layers[state.layers.length - 1] ?? null;
48
+ }
49
+ function push(state, layer) {
50
+ if (!layer?.id)
51
+ throw new Error("layer.id required");
52
+ if (!layer?.url)
53
+ throw new Error("layer.url required");
54
+ const variant = layer.variant ?? "modal";
55
+ if (!VARIANTS.includes(variant)) {
56
+ throw new Error(`unknown variant: ${variant}`);
57
+ }
58
+ const newLayer = freezeLayer({
59
+ id: layer.id,
60
+ url: layer.url,
61
+ variant,
62
+ dismissible: layer.dismissible ?? true,
63
+ size: layer.size,
64
+ side: layer.side,
65
+ width: layer.width,
66
+ height: layer.height
67
+ });
68
+ const previousTop = topLayer(state);
69
+ const layers = Object.freeze([...state.layers, newLayer]);
70
+ const depth = layers.length;
71
+ const commands = [];
72
+ commands.push({
73
+ type: "mountLayer",
74
+ layerId: newLayer.id,
75
+ url: newLayer.url,
76
+ depth,
77
+ variant: newLayer.variant,
78
+ dismissible: newLayer.dismissible,
79
+ ...newLayer.size ? { size: newLayer.size } : {},
80
+ ...newLayer.side ? { side: newLayer.side } : {},
81
+ ...newLayer.width ? { width: newLayer.width } : {},
82
+ ...newLayer.height ? { height: newLayer.height } : {}
83
+ });
84
+ if (depth === 1) {
85
+ commands.push({ type: "showDialog" });
86
+ commands.push({ type: "lockScroll" });
87
+ } else {
88
+ commands.push({ type: "inertLayer", layerId: previousTop.id, value: true });
89
+ }
90
+ commands.push({
91
+ type: "pushHistory",
92
+ url: newLayer.url,
93
+ historyState: { stackId: state.stackId, layerId: newLayer.id, depth }
94
+ });
95
+ commands.push({ type: "persistSnapshot" });
96
+ return { state: { ...state, layers }, commands };
97
+ }
98
+ function pop(state) {
99
+ if (state.layers.length === 0)
100
+ return { state, commands: [] };
101
+ const newLayers = Object.freeze(state.layers.slice(0, -1));
102
+ const newTop = newLayers[newLayers.length - 1] ?? null;
103
+ const commands = [
104
+ { type: "unmountTopLayer" },
105
+ { type: "historyBack", n: 1 }
106
+ ];
107
+ if (newTop) {
108
+ commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
109
+ commands.push({ type: "persistSnapshot" });
110
+ } else {
111
+ commands.push({ type: "closeDialog" });
112
+ commands.push({ type: "unlockScroll" });
113
+ commands.push({ type: "clearSnapshot" });
114
+ }
115
+ return { state: { ...state, layers: newLayers }, commands };
116
+ }
117
+ function replaceTop(state, patch, { historyMode = "replace" } = {}) {
118
+ if (state.layers.length === 0) {
119
+ throw new Error("replaceTop requires at least one layer");
120
+ }
121
+ if (historyMode !== "push" && historyMode !== "replace") {
122
+ throw new Error(`unknown historyMode: ${historyMode}`);
123
+ }
124
+ const top = topLayer(state);
125
+ const next = freezeLayer({
126
+ id: patch.id ?? top.id,
127
+ url: patch.url ?? top.url,
128
+ variant: patch.variant ?? top.variant,
129
+ dismissible: patch.dismissible ?? top.dismissible,
130
+ size: patch.size ?? top.size,
131
+ side: patch.side ?? top.side,
132
+ width: patch.width ?? top.width,
133
+ height: patch.height ?? top.height
134
+ });
135
+ const newLayers = Object.freeze([...state.layers.slice(0, -1), next]);
136
+ const depth = newLayers.length;
137
+ const historyCmd = {
138
+ type: historyMode === "push" ? "pushHistory" : "replaceHistory",
139
+ url: next.url,
140
+ historyState: { stackId: state.stackId, layerId: next.id, depth }
141
+ };
142
+ return {
143
+ state: { ...state, layers: newLayers },
144
+ commands: [
145
+ {
146
+ type: "morphTopLayer",
147
+ layerId: next.id,
148
+ url: next.url,
149
+ depth,
150
+ variant: next.variant,
151
+ dismissible: next.dismissible,
152
+ ...next.size ? { size: next.size } : {},
153
+ ...next.side ? { side: next.side } : {},
154
+ ...next.width ? { width: next.width } : {},
155
+ ...next.height ? { height: next.height } : {}
156
+ },
157
+ historyCmd,
158
+ { type: "persistSnapshot" }
159
+ ]
160
+ };
161
+ }
162
+ function closeAll(state) {
163
+ if (state.layers.length === 0)
164
+ return { state, commands: [] };
165
+ const n = state.layers.length;
166
+ return {
167
+ state: { ...state, layers: Object.freeze([]) },
168
+ commands: [
169
+ { type: "unmountAllLayers" },
170
+ { type: "closeDialog" },
171
+ { type: "unlockScroll" },
172
+ { type: "historyBack", n },
173
+ { type: "clearSnapshot" }
174
+ ]
175
+ };
176
+ }
177
+ function handlePopstate(state, { historyState, locationHref }) {
178
+ const isOurs = historyState && historyState.stackId === state.stackId;
179
+ if (!isOurs) {
180
+ if (state.layers.length === 0)
181
+ return { state, commands: [] };
182
+ return {
183
+ state: { ...state, layers: Object.freeze([]) },
184
+ commands: [
185
+ { type: "unmountAllLayers" },
186
+ { type: "closeDialog" },
187
+ { type: "unlockScroll" },
188
+ { type: "clearSnapshot" }
189
+ ]
190
+ };
191
+ }
192
+ const targetDepth = historyState.depth ?? 0;
193
+ const currentDepth = state.layers.length;
194
+ const targetLayerId = historyState.layerId ?? null;
195
+ if (targetDepth < currentDepth) {
196
+ const newLayers = Object.freeze(state.layers.slice(0, targetDepth));
197
+ const newTop = newLayers[newLayers.length - 1] ?? null;
198
+ const commands = [];
199
+ for (let i = 0;i < currentDepth - targetDepth; i++) {
200
+ commands.push({ type: "unmountTopLayer" });
201
+ }
202
+ if (newTop) {
203
+ commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
204
+ commands.push({ type: "persistSnapshot" });
205
+ } else {
206
+ commands.push({ type: "closeDialog" });
207
+ commands.push({ type: "unlockScroll" });
208
+ commands.push({ type: "clearSnapshot" });
209
+ }
210
+ return { state: { ...state, layers: newLayers }, commands };
211
+ }
212
+ if (targetDepth > currentDepth) {
213
+ return {
214
+ state,
215
+ commands: [
216
+ { type: "rebuildFromSnapshot", targetDepth, targetLayerId }
217
+ ]
218
+ };
219
+ }
220
+ const top = topLayer(state);
221
+ if (top && targetLayerId && top.id !== targetLayerId) {
222
+ const updatedTop = freezeLayer({
223
+ id: targetLayerId,
224
+ url: locationHref ?? top.url,
225
+ variant: top.variant,
226
+ dismissible: top.dismissible,
227
+ size: top.size,
228
+ side: top.side,
229
+ width: top.width,
230
+ height: top.height
231
+ });
232
+ const newLayers = Object.freeze([
233
+ ...state.layers.slice(0, -1),
234
+ updatedTop
235
+ ]);
236
+ return {
237
+ state: { ...state, layers: newLayers },
238
+ commands: [
239
+ {
240
+ type: "morphTopLayer",
241
+ layerId: targetLayerId,
242
+ url: updatedTop.url,
243
+ depth: currentDepth,
244
+ variant: updatedTop.variant,
245
+ dismissible: updatedTop.dismissible,
246
+ ...updatedTop.size ? { size: updatedTop.size } : {},
247
+ ...updatedTop.side ? { side: updatedTop.side } : {},
248
+ ...updatedTop.width ? { width: updatedTop.width } : {},
249
+ ...updatedTop.height ? { height: updatedTop.height } : {}
250
+ },
251
+ { type: "persistSnapshot" }
252
+ ]
253
+ };
254
+ }
255
+ return { state, commands: [] };
256
+ }
257
+ function snapshot(state, { now = Date.now } = {}) {
258
+ return JSON.stringify({
259
+ v: SNAPSHOT_VERSION,
260
+ stackId: state.stackId,
261
+ baseUrl: state.baseUrl,
262
+ layers: state.layers,
263
+ savedAt: now()
264
+ });
265
+ }
266
+ function restore(serialized, { stackId, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Date.now } = {}) {
267
+ if (typeof serialized !== "string" || serialized.length === 0)
268
+ return null;
269
+ let parsed;
270
+ try {
271
+ parsed = JSON.parse(serialized);
272
+ } catch {
273
+ return null;
274
+ }
275
+ if (parsed?.v !== SNAPSHOT_VERSION)
276
+ return null;
277
+ if (typeof parsed.stackId !== "string")
278
+ return null;
279
+ if (typeof parsed.baseUrl !== "string")
280
+ return null;
281
+ if (!Array.isArray(parsed.layers))
282
+ return null;
283
+ if (typeof parsed.savedAt !== "number")
284
+ return null;
285
+ if (stackId && parsed.stackId !== stackId)
286
+ return null;
287
+ if (now() - parsed.savedAt > maxAgeMs)
288
+ return null;
289
+ for (const l of parsed.layers) {
290
+ if (!l || typeof l.id !== "string" || typeof l.url !== "string")
291
+ return null;
292
+ if (!VARIANTS.includes(l.variant))
293
+ return null;
294
+ }
295
+ return Object.freeze({
296
+ stackId: parsed.stackId,
297
+ baseUrl: parsed.baseUrl,
298
+ layers: Object.freeze(parsed.layers.map(freezeLayer))
299
+ });
300
+ }
301
+
302
+ // app/javascript/modal_stack/orchestrator.js
303
+ class Orchestrator {
304
+ #expectedPopstates = 0;
305
+ constructor({ runtime, stackId, baseUrl, restoreFrom = null }) {
306
+ if (!runtime)
307
+ throw new Error("runtime required");
308
+ this.runtime = runtime;
309
+ this.state = createStack({ stackId, baseUrl });
310
+ if (restoreFrom) {
311
+ const restored = restore(restoreFrom, { stackId });
312
+ if (restored)
313
+ this.state = restored;
314
+ }
315
+ }
316
+ get layers() {
317
+ return this.state.layers;
318
+ }
319
+ get depth() {
320
+ return this.state.layers.length;
321
+ }
322
+ async push(layer, { html = null, fragment = null } = {}) {
323
+ if (fragment == null && html == null && layer?.url) {
324
+ fragment = await this.#prefetch(layer.url);
325
+ }
326
+ return this.#dispatch(push(this.state, layer), { html, fragment });
327
+ }
328
+ pop() {
329
+ return this.#dispatch(pop(this.state));
330
+ }
331
+ async replaceTop(patch, { html = null, fragment = null, ...opts } = {}) {
332
+ if (fragment == null && html == null && patch?.url) {
333
+ fragment = await this.#prefetch(patch.url);
334
+ }
335
+ return this.#dispatch(replaceTop(this.state, patch, opts), { html, fragment });
336
+ }
337
+ async#prefetch(url) {
338
+ if (typeof this.runtime.fetchFragment !== "function")
339
+ return null;
340
+ return this.runtime.fetchFragment(url);
341
+ }
342
+ closeAll() {
343
+ return this.#dispatch(closeAll(this.state));
344
+ }
345
+ onPopstate({ historyState, locationHref }) {
346
+ if (this.#expectedPopstates > 0) {
347
+ this.#expectedPopstates -= 1;
348
+ return Promise.resolve();
349
+ }
350
+ return this.#dispatch(handlePopstate(this.state, { historyState, locationHref }));
351
+ }
352
+ async#dispatch({ state, commands }, payload = {}) {
353
+ this.state = state;
354
+ for (const cmd of commands) {
355
+ if (cmd.type === "mountLayer" || cmd.type === "morphTopLayer") {
356
+ if (payload.html != null)
357
+ cmd.html = payload.html;
358
+ if (payload.fragment != null)
359
+ cmd.fragment = payload.fragment;
360
+ }
361
+ await this.#execute(cmd);
362
+ }
363
+ }
364
+ async#execute(cmd) {
365
+ if (cmd.type === "historyBack") {
366
+ this.#expectedPopstates += 1;
367
+ }
368
+ if (cmd.type === "persistSnapshot") {
369
+ await this.runtime.persistSnapshot?.(snapshot(this.state));
370
+ return;
371
+ }
372
+ const handler = this.runtime[cmd.type];
373
+ if (typeof handler !== "function") {
374
+ throw new Error(`runtime missing handler for "${cmd.type}"`);
375
+ }
376
+ await handler.call(this.runtime, cmd);
377
+ }
378
+ }
379
+
380
+ // app/javascript/modal_stack/runtime.js
381
+ var SNAPSHOT_KEY = "modalStackSnapshot";
382
+ var FRAGMENT_HEADER = "X-Modal-Stack-Request";
383
+ var LAYER_SELECTOR = '[data-modal-stack-target="layer"]';
384
+ var LEAVE_TIMEOUT_MS = 600;
385
+
386
+ class BrowserRuntime {
387
+ constructor({
388
+ dialog,
389
+ body = globalThis.document?.body,
390
+ history = globalThis.history,
391
+ fetcher = globalThis.fetch?.bind(globalThis),
392
+ store = globalThis.sessionStorage,
393
+ documentRef = globalThis.document
394
+ } = {}) {
395
+ if (!dialog)
396
+ throw new Error("BrowserRuntime: dialog element required");
397
+ if (!fetcher)
398
+ throw new Error("BrowserRuntime: fetch implementation required");
399
+ if (!documentRef)
400
+ throw new Error("BrowserRuntime: document reference required");
401
+ this.dialog = dialog;
402
+ this.body = body;
403
+ this.history = history;
404
+ this.fetcher = fetcher;
405
+ this.store = store;
406
+ this.document = documentRef;
407
+ }
408
+ showDialog() {
409
+ if (!this.dialog.open)
410
+ this.dialog.showModal();
411
+ }
412
+ closeDialog() {
413
+ if (this.dialog.open)
414
+ this.dialog.close();
415
+ }
416
+ lockScroll() {
417
+ if (this.body)
418
+ this.body.dataset.modalStackLocked = "";
419
+ }
420
+ unlockScroll() {
421
+ if (this.body)
422
+ delete this.body.dataset.modalStackLocked;
423
+ }
424
+ inertLayer({ layerId, value }) {
425
+ const layer = this.#findLayer(layerId);
426
+ if (!layer)
427
+ return;
428
+ if (value)
429
+ layer.setAttribute("inert", "");
430
+ else
431
+ layer.removeAttribute("inert");
432
+ }
433
+ async mountLayer({ layerId, url, depth, variant, dismissible, size, side, width, height, html, fragment }) {
434
+ const frag = await this.#resolveFragment({ url, html, fragment });
435
+ const layer = this.document.createElement("div");
436
+ this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
437
+ layer.append(...frag.childNodes);
438
+ this.dialog.appendChild(layer);
439
+ }
440
+ async morphTopLayer({ layerId, url, depth, variant, dismissible, size, side, width, height, html, fragment }) {
441
+ const frag = await this.#resolveFragment({ url, html, fragment });
442
+ const layer = this.#topLayer();
443
+ if (!layer)
444
+ return;
445
+ this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
446
+ layer.replaceChildren(...frag.childNodes);
447
+ }
448
+ async unmountTopLayer() {
449
+ const layer = this.#topLayer();
450
+ if (!layer)
451
+ return;
452
+ await animateOut(layer);
453
+ }
454
+ async unmountAllLayers() {
455
+ const layers = [...this.dialog.querySelectorAll(LAYER_SELECTOR)];
456
+ await Promise.all(layers.map(animateOut));
457
+ }
458
+ pushHistory({ url, historyState }) {
459
+ this.history.pushState(historyState, "", url);
460
+ }
461
+ replaceHistory({ url, historyState }) {
462
+ this.history.replaceState(historyState, "", url);
463
+ }
464
+ historyBack({ n }) {
465
+ this.history.go(-n);
466
+ }
467
+ rebuildFromSnapshot() {
468
+ this.dialog.dispatchEvent(new CustomEvent("modal_stack:rebuild-requested", { bubbles: true }));
469
+ }
470
+ persistSnapshot(json) {
471
+ if (!this.store)
472
+ return;
473
+ try {
474
+ this.store.setItem(SNAPSHOT_KEY, json);
475
+ } catch {}
476
+ }
477
+ clearSnapshot() {
478
+ if (!this.store)
479
+ return;
480
+ try {
481
+ this.store.removeItem(SNAPSHOT_KEY);
482
+ } catch {}
483
+ }
484
+ readSnapshot() {
485
+ if (!this.store)
486
+ return null;
487
+ try {
488
+ return this.store.getItem(SNAPSHOT_KEY);
489
+ } catch {
490
+ return null;
491
+ }
492
+ }
493
+ #findLayer(layerId) {
494
+ return this.dialog.querySelector(`${LAYER_SELECTOR}[data-layer-id="${escapeAttr(layerId)}"]`);
495
+ }
496
+ #topLayer() {
497
+ const layers = this.dialog.querySelectorAll(LAYER_SELECTOR);
498
+ return layers[layers.length - 1] ?? null;
499
+ }
500
+ #applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height }) {
501
+ layer.dataset.modalStackTarget = "layer";
502
+ layer.dataset.layerId = layerId;
503
+ layer.dataset.depth = String(depth);
504
+ layer.dataset.variant = variant;
505
+ layer.dataset.dismissible = String(dismissible);
506
+ if (size)
507
+ layer.dataset.modalStackSize = size;
508
+ else
509
+ delete layer.dataset.modalStackSize;
510
+ if (side)
511
+ layer.dataset.side = side;
512
+ else
513
+ delete layer.dataset.side;
514
+ if (width) {
515
+ layer.dataset.modalStackWidth = width;
516
+ layer.style.width = width;
517
+ } else {
518
+ delete layer.dataset.modalStackWidth;
519
+ layer.style.removeProperty("width");
520
+ }
521
+ if (height) {
522
+ layer.dataset.modalStackHeight = height;
523
+ layer.style.height = height;
524
+ } else {
525
+ delete layer.dataset.modalStackHeight;
526
+ layer.style.removeProperty("height");
527
+ }
528
+ }
529
+ async fetchFragment(url) {
530
+ const resp = await this.fetcher(url, {
531
+ headers: {
532
+ Accept: "text/html, text/vnd.turbo-stream.html",
533
+ [FRAGMENT_HEADER]: "1"
534
+ },
535
+ credentials: "same-origin"
536
+ });
537
+ if (!resp.ok) {
538
+ throw new Error(`modal_stack: fetch ${url} → ${resp.status}`);
539
+ }
540
+ const html = await resp.text();
541
+ return parseFragment(html, this.document);
542
+ }
543
+ async#resolveFragment({ url, html, fragment }) {
544
+ if (fragment)
545
+ return fragment;
546
+ if (html != null)
547
+ return parseFragment(html, this.document);
548
+ return this.fetchFragment(url);
549
+ }
550
+ }
551
+ function parseFragment(html, doc) {
552
+ const parser = new DOMParser;
553
+ const parsed = parser.parseFromString(html, "text/html");
554
+ const fragment = doc.createDocumentFragment();
555
+ fragment.append(...parsed.body.childNodes);
556
+ return fragment;
557
+ }
558
+ function animateOut(layer) {
559
+ return new Promise((resolve) => {
560
+ let done = false;
561
+ const finish = () => {
562
+ if (done)
563
+ return;
564
+ done = true;
565
+ layer.removeEventListener("transitionend", finish);
566
+ layer.remove();
567
+ resolve();
568
+ };
569
+ layer.addEventListener("transitionend", finish, { once: true });
570
+ layer.dataset.leaving = "";
571
+ setTimeout(finish, LEAVE_TIMEOUT_MS);
572
+ });
573
+ }
574
+ function escapeAttr(value) {
575
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
576
+ return CSS.escape(value);
577
+ }
578
+ return String(value).replace(/["\\]/g, "\\$&");
579
+ }
580
+
581
+ // app/javascript/modal_stack/controllers/modal_stack_controller.js
582
+ class ModalStackController extends Controller {
583
+ static values = {
584
+ stackId: String,
585
+ baseUrl: String
586
+ };
587
+ connect() {
588
+ const stackId = this.stackIdValue || generateLayerId();
589
+ const baseUrl = this.baseUrlValue || window.location.href;
590
+ this.runtime = new BrowserRuntime({ dialog: this.element });
591
+ const snapshot2 = this.runtime.readSnapshot();
592
+ this.orchestrator = new Orchestrator({
593
+ runtime: this.runtime,
594
+ stackId,
595
+ baseUrl,
596
+ restoreFrom: snapshot2
597
+ });
598
+ this._onPopstate = (event) => this.orchestrator.onPopstate({
599
+ historyState: event.state,
600
+ locationHref: window.location.href
601
+ });
602
+ window.addEventListener("popstate", this._onPopstate);
603
+ this._onCancel = (event) => {
604
+ event.preventDefault();
605
+ const top = this.#topLayer();
606
+ if (!top || top.dismissible === false)
607
+ return;
608
+ this.orchestrator.pop();
609
+ };
610
+ this.element.addEventListener("cancel", this._onCancel);
611
+ this._onBackdropClick = (event) => {
612
+ if (event.target !== this.element)
613
+ return;
614
+ const top = this.#topLayer();
615
+ if (!top || top.dismissible === false)
616
+ return;
617
+ this.orchestrator.pop();
618
+ };
619
+ this.element.addEventListener("click", this._onBackdropClick);
620
+ this.#registerStreamActions();
621
+ this.element.dispatchEvent(new CustomEvent("modal_stack:ready", { bubbles: true, detail: { stackId } }));
622
+ }
623
+ disconnect() {
624
+ window.removeEventListener("popstate", this._onPopstate);
625
+ this.element.removeEventListener("cancel", this._onCancel);
626
+ this.element.removeEventListener("click", this._onBackdropClick);
627
+ }
628
+ push(layer, opts) {
629
+ return this.orchestrator.push(layer, opts);
630
+ }
631
+ pop() {
632
+ return this.orchestrator.pop();
633
+ }
634
+ replaceTop(patch, opts) {
635
+ return this.orchestrator.replaceTop(patch, opts);
636
+ }
637
+ closeAll() {
638
+ return this.orchestrator.closeAll();
639
+ }
640
+ #topLayer() {
641
+ const layers = this.orchestrator.layers;
642
+ return layers[layers.length - 1] ?? null;
643
+ }
644
+ #registerStreamActions() {
645
+ const Turbo = globalThis.Turbo;
646
+ if (!Turbo) {
647
+ console.warn("[modal_stack] Turbo is not loaded; modal_push/pop/replace stream actions are disabled. " + "Ensure turbo-rails (or @hotwired/turbo) loads before modal_stack.");
648
+ return;
649
+ }
650
+ const StreamActions = Turbo.StreamActions || (Turbo.StreamActions = {});
651
+ const orchestrator = this.orchestrator;
652
+ StreamActions.modal_push = function modalPush() {
653
+ orchestrator.push(layerFromStreamElement(this), {
654
+ fragment: this.templateContent.cloneNode(true)
655
+ });
656
+ };
657
+ StreamActions.modal_pop = function modalPop() {
658
+ orchestrator.pop();
659
+ };
660
+ StreamActions.modal_replace = function modalReplace() {
661
+ orchestrator.replaceTop(layerPatchFromStreamElement(this), {
662
+ fragment: this.templateContent.cloneNode(true),
663
+ historyMode: this.dataset.historyMode || "replace"
664
+ });
665
+ };
666
+ StreamActions.modal_close_all = function modalCloseAll() {
667
+ orchestrator.closeAll();
668
+ };
669
+ }
670
+ }
671
+ function layerFromStreamElement(el) {
672
+ return {
673
+ id: el.dataset.layerId || generateLayerId(),
674
+ url: el.dataset.url || window.location.href,
675
+ variant: el.dataset.variant || "modal",
676
+ side: el.dataset.side,
677
+ size: el.dataset.size,
678
+ width: el.dataset.width,
679
+ height: el.dataset.height,
680
+ dismissible: el.dataset.dismissible !== "false"
681
+ };
682
+ }
683
+ function layerPatchFromStreamElement(el) {
684
+ const patch = {};
685
+ if (el.dataset.layerId)
686
+ patch.id = el.dataset.layerId;
687
+ if (el.dataset.url)
688
+ patch.url = el.dataset.url;
689
+ if (el.dataset.variant)
690
+ patch.variant = el.dataset.variant;
691
+ if (el.dataset.side)
692
+ patch.side = el.dataset.side;
693
+ if (el.dataset.size)
694
+ patch.size = el.dataset.size;
695
+ if (el.dataset.width)
696
+ patch.width = el.dataset.width;
697
+ if (el.dataset.height)
698
+ patch.height = el.dataset.height;
699
+ if (el.dataset.dismissible != null) {
700
+ patch.dismissible = el.dataset.dismissible !== "false";
701
+ }
702
+ return patch;
703
+ }
704
+ function generateLayerId() {
705
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
706
+ return crypto.randomUUID();
707
+ }
708
+ return `ms-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
709
+ }
710
+
711
+ // app/javascript/modal_stack/controllers/modal_stack_link_controller.js
712
+ import { Controller as Controller2 } from "@hotwired/stimulus";
713
+
714
+ class ModalStackLinkController extends Controller2 {
715
+ open(event) {
716
+ const stack = document.querySelector('[data-controller~="modal-stack"]');
717
+ if (!stack)
718
+ return;
719
+ const controller = this.application.getControllerForElementAndIdentifier(stack, "modal-stack");
720
+ if (!controller)
721
+ return;
722
+ event.preventDefault();
723
+ const ds = this.element.dataset;
724
+ controller.push({
725
+ id: generateLayerId2(),
726
+ url: this.element.href,
727
+ variant: ds.modalStackLinkVariant || "modal",
728
+ side: ds.modalStackLinkSide,
729
+ size: ds.modalStackLinkSize,
730
+ width: ds.modalStackLinkWidth,
731
+ height: ds.modalStackLinkHeight,
732
+ dismissible: ds.modalStackLinkDismissible !== "false"
733
+ });
734
+ }
735
+ }
736
+ function generateLayerId2() {
737
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
738
+ return crypto.randomUUID();
739
+ }
740
+ return `ms-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
741
+ }
742
+
743
+ // app/javascript/modal_stack/install.js
744
+ function install(application) {
745
+ if (!application || typeof application.register !== "function") {
746
+ throw new Error("modal_stack: install(application) requires a Stimulus Application instance");
747
+ }
748
+ application.register("modal-stack", ModalStackController);
749
+ application.register("modal-stack-link", ModalStackLinkController);
750
+ return application;
751
+ }
752
+ export {
753
+ install,
754
+ ModalStackLinkController,
755
+ ModalStackController
756
+ };