modal_stack 0.1.1 → 0.3.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 +43 -0
- data/README.md +73 -26
- data/app/assets/javascripts/modal_stack.js +230 -41
- data/app/assets/stylesheets/modal_stack/bootstrap.css +7 -8
- data/app/assets/stylesheets/modal_stack/{tailwind.css → tailwind_v3.css} +19 -12
- data/app/assets/stylesheets/modal_stack/tailwind_v4.css +311 -0
- data/app/assets/stylesheets/modal_stack/vanilla.css +7 -8
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +52 -13
- data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +29 -7
- data/app/javascript/modal_stack/orchestrator.js +136 -4
- data/app/javascript/modal_stack/orchestrator.test.js +218 -2
- data/app/javascript/modal_stack/runtime.js +91 -10
- data/app/javascript/modal_stack/runtime.test.js +138 -1
- data/app/javascript/modal_stack/state.js +142 -8
- data/app/javascript/modal_stack/state.test.js +89 -5
- data/lib/generators/modal_stack/install/install_generator.rb +18 -4
- data/lib/generators/modal_stack/install/templates/initializer.rb +19 -6
- data/lib/modal_stack/configuration.rb +44 -5
- data/lib/modal_stack/controller_extensions.rb +8 -1
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +26 -6
- data/lib/modal_stack/version.rb +1 -1
- data/lib/modal_stack.rb +1 -1
- metadata +4 -3
|
@@ -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,
|
|
@@ -100,15 +126,16 @@ function pop(state) {
|
|
|
100
126
|
return { state, commands: [] };
|
|
101
127
|
const newLayers = Object.freeze(state.layers.slice(0, -1));
|
|
102
128
|
const newTop = newLayers[newLayers.length - 1] ?? null;
|
|
103
|
-
const commands = [
|
|
104
|
-
{ type: "unmountTopLayer" },
|
|
105
|
-
{ type: "historyBack", n: 1 }
|
|
106
|
-
];
|
|
129
|
+
const commands = [];
|
|
107
130
|
if (newTop) {
|
|
131
|
+
commands.push({ type: "unmountTopLayer" });
|
|
132
|
+
commands.push({ type: "historyBack", n: 1 });
|
|
108
133
|
commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
|
|
109
134
|
commands.push({ type: "persistSnapshot" });
|
|
110
135
|
} else {
|
|
111
136
|
commands.push({ type: "closeDialog" });
|
|
137
|
+
commands.push({ type: "unmountTopLayer" });
|
|
138
|
+
commands.push({ type: "historyBack", n: 1 });
|
|
112
139
|
commands.push({ type: "unlockScroll" });
|
|
113
140
|
commands.push({ type: "clearSnapshot" });
|
|
114
141
|
}
|
|
@@ -166,8 +193,8 @@ function closeAll(state) {
|
|
|
166
193
|
return {
|
|
167
194
|
state: { ...state, layers: Object.freeze([]) },
|
|
168
195
|
commands: [
|
|
169
|
-
{ type: "unmountAllLayers" },
|
|
170
196
|
{ type: "closeDialog" },
|
|
197
|
+
{ type: "unmountAllLayers" },
|
|
171
198
|
{ type: "unlockScroll" },
|
|
172
199
|
{ type: "historyBack", n },
|
|
173
200
|
{ type: "clearSnapshot" }
|
|
@@ -182,8 +209,8 @@ function handlePopstate(state, { historyState, locationHref }) {
|
|
|
182
209
|
return {
|
|
183
210
|
state: { ...state, layers: Object.freeze([]) },
|
|
184
211
|
commands: [
|
|
185
|
-
{ type: "unmountAllLayers" },
|
|
186
212
|
{ type: "closeDialog" },
|
|
213
|
+
{ type: "unmountAllLayers" },
|
|
187
214
|
{ type: "unlockScroll" },
|
|
188
215
|
{ type: "clearSnapshot" }
|
|
189
216
|
]
|
|
@@ -196,6 +223,8 @@ function handlePopstate(state, { historyState, locationHref }) {
|
|
|
196
223
|
const newLayers = Object.freeze(state.layers.slice(0, targetDepth));
|
|
197
224
|
const newTop = newLayers[newLayers.length - 1] ?? null;
|
|
198
225
|
const commands = [];
|
|
226
|
+
if (!newTop)
|
|
227
|
+
commands.push({ type: "closeDialog" });
|
|
199
228
|
for (let i = 0;i < currentDepth - targetDepth; i++) {
|
|
200
229
|
commands.push({ type: "unmountTopLayer" });
|
|
201
230
|
}
|
|
@@ -203,7 +232,6 @@ function handlePopstate(state, { historyState, locationHref }) {
|
|
|
203
232
|
commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
|
|
204
233
|
commands.push({ type: "persistSnapshot" });
|
|
205
234
|
} else {
|
|
206
|
-
commands.push({ type: "closeDialog" });
|
|
207
235
|
commands.push({ type: "unlockScroll" });
|
|
208
236
|
commands.push({ type: "clearSnapshot" });
|
|
209
237
|
}
|
|
@@ -300,12 +328,27 @@ function restore(serialized, { stackId, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Dat
|
|
|
300
328
|
}
|
|
301
329
|
|
|
302
330
|
// app/javascript/modal_stack/orchestrator.js
|
|
331
|
+
var PREFETCH_TTL_MS = 30000;
|
|
332
|
+
|
|
303
333
|
class Orchestrator {
|
|
304
334
|
#expectedPopstates = 0;
|
|
305
|
-
|
|
335
|
+
#fragmentCache = new Map;
|
|
336
|
+
#inflight = new Map;
|
|
337
|
+
constructor({
|
|
338
|
+
runtime,
|
|
339
|
+
stackId,
|
|
340
|
+
baseUrl,
|
|
341
|
+
restoreFrom = null,
|
|
342
|
+
maxDepth = null,
|
|
343
|
+
maxDepthStrategy = "warn",
|
|
344
|
+
prefetchTtlMs = PREFETCH_TTL_MS
|
|
345
|
+
}) {
|
|
306
346
|
if (!runtime)
|
|
307
347
|
throw new Error("runtime required");
|
|
308
348
|
this.runtime = runtime;
|
|
349
|
+
this.maxDepth = maxDepth;
|
|
350
|
+
this.maxDepthStrategy = maxDepthStrategy;
|
|
351
|
+
this.prefetchTtlMs = prefetchTtlMs;
|
|
309
352
|
this.state = createStack({ stackId, baseUrl });
|
|
310
353
|
if (restoreFrom) {
|
|
311
354
|
const restored = restore(restoreFrom, { stackId });
|
|
@@ -320,10 +363,16 @@ class Orchestrator {
|
|
|
320
363
|
return this.state.layers.length;
|
|
321
364
|
}
|
|
322
365
|
async push(layer, { html = null, fragment = null } = {}) {
|
|
366
|
+
const transition = push(this.state, layer, {
|
|
367
|
+
maxDepth: this.maxDepth,
|
|
368
|
+
maxDepthStrategy: this.maxDepthStrategy
|
|
369
|
+
});
|
|
370
|
+
if (transition.commands.length === 0)
|
|
371
|
+
return;
|
|
323
372
|
if (fragment == null && html == null && layer?.url) {
|
|
324
373
|
fragment = await this.#prefetch(layer.url);
|
|
325
374
|
}
|
|
326
|
-
return this.#dispatch(
|
|
375
|
+
return this.#dispatch(transition, { html, fragment });
|
|
327
376
|
}
|
|
328
377
|
pop() {
|
|
329
378
|
return this.#dispatch(pop(this.state));
|
|
@@ -337,9 +386,44 @@ class Orchestrator {
|
|
|
337
386
|
async#prefetch(url) {
|
|
338
387
|
if (typeof this.runtime.fetchFragment !== "function")
|
|
339
388
|
return null;
|
|
340
|
-
|
|
389
|
+
const cached = this.#fragmentCache.get(url);
|
|
390
|
+
if (cached && Date.now() - cached.ts < this.prefetchTtlMs) {
|
|
391
|
+
return cloneFragment(cached.fragment);
|
|
392
|
+
}
|
|
393
|
+
const existing = this.#inflight.get(url);
|
|
394
|
+
if (existing) {
|
|
395
|
+
const entry2 = await existing.promise;
|
|
396
|
+
return cloneFragment(entry2.fragment);
|
|
397
|
+
}
|
|
398
|
+
const controller = supportsAbort() ? new AbortController : null;
|
|
399
|
+
const fetchPromise = this.runtime.fetchFragment(url, controller ? { signal: controller.signal } : undefined).then((fragment) => {
|
|
400
|
+
const entry2 = { fragment, ts: Date.now() };
|
|
401
|
+
this.#fragmentCache.set(url, entry2);
|
|
402
|
+
return entry2;
|
|
403
|
+
}).finally(() => {
|
|
404
|
+
this.#inflight.delete(url);
|
|
405
|
+
});
|
|
406
|
+
this.#inflight.set(url, { controller, promise: fetchPromise });
|
|
407
|
+
const entry = await fetchPromise;
|
|
408
|
+
return cloneFragment(entry.fragment);
|
|
409
|
+
}
|
|
410
|
+
#invalidatePrefetch() {
|
|
411
|
+
for (const { controller } of this.#inflight.values()) {
|
|
412
|
+
try {
|
|
413
|
+
controller?.abort();
|
|
414
|
+
} catch {}
|
|
415
|
+
}
|
|
416
|
+
this.#inflight.clear();
|
|
417
|
+
this.#fragmentCache.clear();
|
|
418
|
+
}
|
|
419
|
+
prefetch(url) {
|
|
420
|
+
if (!url || typeof this.runtime.fetchFragment !== "function") {
|
|
421
|
+
return Promise.resolve(null);
|
|
422
|
+
}
|
|
423
|
+
return this.#prefetch(url).catch(() => null);
|
|
341
424
|
}
|
|
342
425
|
closeAll() {
|
|
426
|
+
this.#invalidatePrefetch();
|
|
343
427
|
return this.#dispatch(closeAll(this.state));
|
|
344
428
|
}
|
|
345
429
|
onPopstate({ historyState, locationHref }) {
|
|
@@ -347,6 +431,7 @@ class Orchestrator {
|
|
|
347
431
|
this.#expectedPopstates -= 1;
|
|
348
432
|
return Promise.resolve();
|
|
349
433
|
}
|
|
434
|
+
this.#invalidatePrefetch();
|
|
350
435
|
return this.#dispatch(handlePopstate(this.state, { historyState, locationHref }));
|
|
351
436
|
}
|
|
352
437
|
async#dispatch({ state, commands }, payload = {}) {
|
|
@@ -371,17 +456,32 @@ class Orchestrator {
|
|
|
371
456
|
}
|
|
372
457
|
const handler = this.runtime[cmd.type];
|
|
373
458
|
if (typeof handler !== "function") {
|
|
374
|
-
|
|
459
|
+
const known = Object.getOwnPropertyNames(Object.getPrototypeOf(this.runtime)).filter((name) => name !== "constructor" && typeof this.runtime[name] === "function").sort().join(", ");
|
|
460
|
+
throw new Error(`[modal_stack] runtime missing handler for "${cmd.type}" ` + `(stack depth=${this.depth}). ` + `Known handlers: ${known || "<none>"}.`);
|
|
375
461
|
}
|
|
376
462
|
await handler.call(this.runtime, cmd);
|
|
377
463
|
}
|
|
378
464
|
}
|
|
465
|
+
function cloneFragment(fragment) {
|
|
466
|
+
if (!fragment)
|
|
467
|
+
return fragment;
|
|
468
|
+
if (typeof fragment.cloneNode === "function") {
|
|
469
|
+
return fragment.cloneNode(true);
|
|
470
|
+
}
|
|
471
|
+
return fragment;
|
|
472
|
+
}
|
|
473
|
+
function supportsAbort() {
|
|
474
|
+
return typeof globalThis.AbortController === "function";
|
|
475
|
+
}
|
|
379
476
|
|
|
380
477
|
// app/javascript/modal_stack/runtime.js
|
|
381
478
|
var SNAPSHOT_KEY = "modalStackSnapshot";
|
|
382
479
|
var FRAGMENT_HEADER = "X-Modal-Stack-Request";
|
|
480
|
+
var SCROLLBAR_WIDTH_VAR = "--modal-stack-scrollbar-width";
|
|
383
481
|
var LAYER_SELECTOR = '[data-modal-stack-target="layer"]';
|
|
384
|
-
var
|
|
482
|
+
var DURATION_CSS_VAR = "--modal-stack-duration";
|
|
483
|
+
var LEAVE_TIMEOUT_FLOOR_MS = 300;
|
|
484
|
+
var LEAVE_TIMEOUT_FALLBACK_MS = 600;
|
|
385
485
|
|
|
386
486
|
class BrowserRuntime {
|
|
387
487
|
constructor({
|
|
@@ -414,12 +514,22 @@ class BrowserRuntime {
|
|
|
414
514
|
this.dialog.close();
|
|
415
515
|
}
|
|
416
516
|
lockScroll() {
|
|
417
|
-
if (this.body)
|
|
418
|
-
|
|
517
|
+
if (!this.body)
|
|
518
|
+
return;
|
|
519
|
+
const root = this.document?.documentElement;
|
|
520
|
+
if (root) {
|
|
521
|
+
const scrollbarWidth = Math.max(0, (globalThis.innerWidth ?? root.clientWidth) - root.clientWidth);
|
|
522
|
+
root.style.setProperty(SCROLLBAR_WIDTH_VAR, `${scrollbarWidth}px`);
|
|
523
|
+
}
|
|
524
|
+
this.body.dataset.modalStackLocked = "";
|
|
419
525
|
}
|
|
420
526
|
unlockScroll() {
|
|
421
|
-
if (this.body)
|
|
422
|
-
|
|
527
|
+
if (!this.body)
|
|
528
|
+
return;
|
|
529
|
+
delete this.body.dataset.modalStackLocked;
|
|
530
|
+
const root = this.document?.documentElement;
|
|
531
|
+
if (root)
|
|
532
|
+
root.style.removeProperty(SCROLLBAR_WIDTH_VAR);
|
|
423
533
|
}
|
|
424
534
|
inertLayer({ layerId, value }) {
|
|
425
535
|
const layer = this.#findLayer(layerId);
|
|
@@ -449,11 +559,12 @@ class BrowserRuntime {
|
|
|
449
559
|
const layer = this.#topLayer();
|
|
450
560
|
if (!layer)
|
|
451
561
|
return;
|
|
452
|
-
await animateOut(layer);
|
|
562
|
+
await animateOut(layer, this.#leaveTimeoutMs());
|
|
453
563
|
}
|
|
454
564
|
async unmountAllLayers() {
|
|
455
565
|
const layers = [...this.dialog.querySelectorAll(LAYER_SELECTOR)];
|
|
456
|
-
|
|
566
|
+
const timeout = this.#leaveTimeoutMs();
|
|
567
|
+
await Promise.all(layers.map((l) => animateOut(l, timeout)));
|
|
457
568
|
}
|
|
458
569
|
pushHistory({ url, historyState }) {
|
|
459
570
|
this.history.pushState(historyState, "", url);
|
|
@@ -490,6 +601,22 @@ class BrowserRuntime {
|
|
|
490
601
|
return null;
|
|
491
602
|
}
|
|
492
603
|
}
|
|
604
|
+
#leaveTimeoutMs() {
|
|
605
|
+
if (this._cachedLeaveTimeoutMs != null)
|
|
606
|
+
return this._cachedLeaveTimeoutMs;
|
|
607
|
+
const get = globalThis.getComputedStyle;
|
|
608
|
+
if (typeof get !== "function" || !this.dialog?.ownerDocument) {
|
|
609
|
+
return LEAVE_TIMEOUT_FALLBACK_MS;
|
|
610
|
+
}
|
|
611
|
+
let parsed = NaN;
|
|
612
|
+
try {
|
|
613
|
+
const raw = get(this.dialog).getPropertyValue(DURATION_CSS_VAR);
|
|
614
|
+
parsed = parseDurationMs(raw);
|
|
615
|
+
} catch {}
|
|
616
|
+
const ms = Number.isFinite(parsed) ? Math.max(Math.ceil(parsed * 1.5), LEAVE_TIMEOUT_FLOOR_MS) : LEAVE_TIMEOUT_FALLBACK_MS;
|
|
617
|
+
this._cachedLeaveTimeoutMs = ms;
|
|
618
|
+
return ms;
|
|
619
|
+
}
|
|
493
620
|
#findLayer(layerId) {
|
|
494
621
|
return this.dialog.querySelector(`${LAYER_SELECTOR}[data-layer-id="${escapeAttr(layerId)}"]`);
|
|
495
622
|
}
|
|
@@ -526,13 +653,14 @@ class BrowserRuntime {
|
|
|
526
653
|
layer.style.removeProperty("height");
|
|
527
654
|
}
|
|
528
655
|
}
|
|
529
|
-
async fetchFragment(url) {
|
|
656
|
+
async fetchFragment(url, { signal } = {}) {
|
|
530
657
|
const resp = await this.fetcher(url, {
|
|
531
658
|
headers: {
|
|
532
659
|
Accept: "text/html, text/vnd.turbo-stream.html",
|
|
533
660
|
[FRAGMENT_HEADER]: "1"
|
|
534
661
|
},
|
|
535
|
-
credentials: "same-origin"
|
|
662
|
+
credentials: "same-origin",
|
|
663
|
+
signal
|
|
536
664
|
});
|
|
537
665
|
if (!resp.ok) {
|
|
538
666
|
throw new Error(`modal_stack: fetch ${url} → ${resp.status}`);
|
|
@@ -555,7 +683,7 @@ function parseFragment(html, doc) {
|
|
|
555
683
|
fragment.append(...parsed.body.childNodes);
|
|
556
684
|
return fragment;
|
|
557
685
|
}
|
|
558
|
-
function animateOut(layer) {
|
|
686
|
+
function animateOut(layer, timeoutMs = LEAVE_TIMEOUT_FALLBACK_MS) {
|
|
559
687
|
return new Promise((resolve) => {
|
|
560
688
|
let done = false;
|
|
561
689
|
const finish = () => {
|
|
@@ -568,9 +696,20 @@ function animateOut(layer) {
|
|
|
568
696
|
};
|
|
569
697
|
layer.addEventListener("transitionend", finish, { once: true });
|
|
570
698
|
layer.dataset.leaving = "";
|
|
571
|
-
setTimeout(finish,
|
|
699
|
+
setTimeout(finish, timeoutMs);
|
|
572
700
|
});
|
|
573
701
|
}
|
|
702
|
+
function parseDurationMs(raw) {
|
|
703
|
+
if (typeof raw !== "string")
|
|
704
|
+
return NaN;
|
|
705
|
+
const value = raw.trim();
|
|
706
|
+
if (!value)
|
|
707
|
+
return NaN;
|
|
708
|
+
const num = parseFloat(value);
|
|
709
|
+
if (!Number.isFinite(num))
|
|
710
|
+
return NaN;
|
|
711
|
+
return /m?s$/i.test(value) && !/ms$/i.test(value) ? num * 1000 : num;
|
|
712
|
+
}
|
|
574
713
|
function escapeAttr(value) {
|
|
575
714
|
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
576
715
|
return CSS.escape(value);
|
|
@@ -582,7 +721,9 @@ function escapeAttr(value) {
|
|
|
582
721
|
class ModalStackController extends Controller {
|
|
583
722
|
static values = {
|
|
584
723
|
stackId: String,
|
|
585
|
-
baseUrl: String
|
|
724
|
+
baseUrl: String,
|
|
725
|
+
maxDepth: { type: Number, default: 0 },
|
|
726
|
+
maxDepthStrategy: { type: String, default: "warn" }
|
|
586
727
|
};
|
|
587
728
|
connect() {
|
|
588
729
|
const stackId = this.stackIdValue || generateLayerId();
|
|
@@ -593,7 +734,9 @@ class ModalStackController extends Controller {
|
|
|
593
734
|
runtime: this.runtime,
|
|
594
735
|
stackId,
|
|
595
736
|
baseUrl,
|
|
596
|
-
restoreFrom: snapshot2
|
|
737
|
+
restoreFrom: snapshot2,
|
|
738
|
+
maxDepth: this.maxDepthValue > 0 ? this.maxDepthValue : null,
|
|
739
|
+
maxDepthStrategy: this.maxDepthStrategyValue || "warn"
|
|
597
740
|
});
|
|
598
741
|
this._onPopstate = (event) => this.orchestrator.onPopstate({
|
|
599
742
|
historyState: event.state,
|
|
@@ -637,6 +780,9 @@ class ModalStackController extends Controller {
|
|
|
637
780
|
closeAll() {
|
|
638
781
|
return this.orchestrator.closeAll();
|
|
639
782
|
}
|
|
783
|
+
prefetch(url) {
|
|
784
|
+
return this.orchestrator.prefetch(url);
|
|
785
|
+
}
|
|
640
786
|
#topLayer() {
|
|
641
787
|
const layers = this.orchestrator.layers;
|
|
642
788
|
return layers[layers.length - 1] ?? null;
|
|
@@ -649,25 +795,46 @@ class ModalStackController extends Controller {
|
|
|
649
795
|
}
|
|
650
796
|
const StreamActions = Turbo.StreamActions || (Turbo.StreamActions = {});
|
|
651
797
|
const orchestrator = this.orchestrator;
|
|
652
|
-
|
|
653
|
-
|
|
798
|
+
const dialog = this.element;
|
|
799
|
+
const guarded = (action, fn) => function guardedStreamAction() {
|
|
800
|
+
try {
|
|
801
|
+
const result = fn.call(this, orchestrator);
|
|
802
|
+
if (result && typeof result.catch === "function") {
|
|
803
|
+
result.catch((err) => emitStreamError(dialog, action, err));
|
|
804
|
+
}
|
|
805
|
+
} catch (err) {
|
|
806
|
+
emitStreamError(dialog, action, err);
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
StreamActions.modal_push = guarded("modal_push", function(orch) {
|
|
810
|
+
return orch.push(layerFromStreamElement(this), {
|
|
654
811
|
fragment: this.templateContent.cloneNode(true)
|
|
655
812
|
});
|
|
656
|
-
};
|
|
657
|
-
StreamActions.modal_pop = function
|
|
658
|
-
|
|
659
|
-
};
|
|
660
|
-
StreamActions.modal_replace = function
|
|
661
|
-
|
|
813
|
+
});
|
|
814
|
+
StreamActions.modal_pop = guarded("modal_pop", function(orch) {
|
|
815
|
+
return orch.pop();
|
|
816
|
+
});
|
|
817
|
+
StreamActions.modal_replace = guarded("modal_replace", function(orch) {
|
|
818
|
+
return orch.replaceTop(layerPatchFromStreamElement(this), {
|
|
662
819
|
fragment: this.templateContent.cloneNode(true),
|
|
663
820
|
historyMode: this.dataset.historyMode || "replace"
|
|
664
821
|
});
|
|
665
|
-
};
|
|
666
|
-
StreamActions.modal_close_all = function
|
|
667
|
-
|
|
668
|
-
};
|
|
822
|
+
});
|
|
823
|
+
StreamActions.modal_close_all = guarded("modal_close_all", function(orch) {
|
|
824
|
+
return orch.closeAll();
|
|
825
|
+
});
|
|
669
826
|
}
|
|
670
827
|
}
|
|
828
|
+
function emitStreamError(dialog, action, error) {
|
|
829
|
+
if (typeof console !== "undefined" && console.error) {
|
|
830
|
+
console.error(`[modal_stack] stream action "${action}" failed:`, error);
|
|
831
|
+
}
|
|
832
|
+
dialog.dispatchEvent(new CustomEvent("modal_stack:error", {
|
|
833
|
+
bubbles: true,
|
|
834
|
+
cancelable: false,
|
|
835
|
+
detail: { action, error }
|
|
836
|
+
}));
|
|
837
|
+
}
|
|
671
838
|
function layerFromStreamElement(el) {
|
|
672
839
|
return {
|
|
673
840
|
id: el.dataset.layerId || generateLayerId(),
|
|
@@ -712,11 +879,21 @@ function generateLayerId() {
|
|
|
712
879
|
import { Controller as Controller2 } from "@hotwired/stimulus";
|
|
713
880
|
|
|
714
881
|
class ModalStackLinkController extends Controller2 {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
882
|
+
connect() {
|
|
883
|
+
if (this.element.dataset.modalStackLinkPrefetch === "false")
|
|
884
|
+
return;
|
|
885
|
+
this._onIntent = () => this.#warm();
|
|
886
|
+
this.element.addEventListener("pointerenter", this._onIntent);
|
|
887
|
+
this.element.addEventListener("focus", this._onIntent);
|
|
888
|
+
}
|
|
889
|
+
disconnect() {
|
|
890
|
+
if (!this._onIntent)
|
|
718
891
|
return;
|
|
719
|
-
|
|
892
|
+
this.element.removeEventListener("pointerenter", this._onIntent);
|
|
893
|
+
this.element.removeEventListener("focus", this._onIntent);
|
|
894
|
+
}
|
|
895
|
+
open(event) {
|
|
896
|
+
const controller = this.#stackController();
|
|
720
897
|
if (!controller)
|
|
721
898
|
return;
|
|
722
899
|
event.preventDefault();
|
|
@@ -732,6 +909,18 @@ class ModalStackLinkController extends Controller2 {
|
|
|
732
909
|
dismissible: ds.modalStackLinkDismissible !== "false"
|
|
733
910
|
});
|
|
734
911
|
}
|
|
912
|
+
#warm() {
|
|
913
|
+
const controller = this.#stackController();
|
|
914
|
+
if (!controller || typeof controller.prefetch !== "function")
|
|
915
|
+
return;
|
|
916
|
+
controller.prefetch(this.element.href);
|
|
917
|
+
}
|
|
918
|
+
#stackController() {
|
|
919
|
+
const stack = document.querySelector('[data-controller~="modal-stack"]');
|
|
920
|
+
if (!stack)
|
|
921
|
+
return null;
|
|
922
|
+
return this.application.getControllerForElementAndIdentifier(stack, "modal-stack");
|
|
923
|
+
}
|
|
735
924
|
}
|
|
736
925
|
function generateLayerId2() {
|
|
737
926
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
--modal-stack-fg: var(--bs-body-color, #212529);
|
|
24
24
|
--modal-stack-shadow: var(--bs-box-shadow-lg, 0 1rem 3rem rgba(0, 0, 0, 0.175));
|
|
25
25
|
--modal-stack-backdrop: rgba(var(--bs-backdrop-color, 0, 0, 0), var(--bs-backdrop-opacity, 0.5));
|
|
26
|
-
|
|
26
|
+
/* Default `none` so Chrome skips the filter pass. Opt in with
|
|
27
|
+
* `:root { --modal-stack-backdrop-filter: blur(8px); }`. */
|
|
28
|
+
--modal-stack-backdrop-filter: none;
|
|
27
29
|
--modal-stack-panel-padding: var(--bs-modal-padding, 1rem);
|
|
28
30
|
--modal-stack-size-sm: 300px;
|
|
29
31
|
--modal-stack-size-md: 500px;
|
|
@@ -65,23 +67,22 @@ body[data-modal-stack-locked] {
|
|
|
65
67
|
|
|
66
68
|
#modal-stack-root::backdrop {
|
|
67
69
|
background: rgba(0, 0, 0, 0);
|
|
68
|
-
backdrop-filter: blur(0);
|
|
69
70
|
transition:
|
|
70
71
|
background var(--modal-stack-duration) var(--modal-stack-ease),
|
|
71
|
-
backdrop-filter var(--modal-stack-duration) var(--modal-stack-ease),
|
|
72
72
|
overlay var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete,
|
|
73
73
|
display var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
#modal-stack-root[open]::backdrop {
|
|
77
77
|
background: var(--modal-stack-backdrop);
|
|
78
|
-
|
|
78
|
+
/* Static. Default is `none` (no filter pass). Opt in with
|
|
79
|
+
* --modal-stack-backdrop-filter on :root. */
|
|
80
|
+
backdrop-filter: var(--modal-stack-backdrop-filter);
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
@starting-style {
|
|
82
84
|
#modal-stack-root[open]::backdrop {
|
|
83
85
|
background: rgba(0, 0, 0, 0);
|
|
84
|
-
backdrop-filter: blur(0);
|
|
85
86
|
}
|
|
86
87
|
}
|
|
87
88
|
|
|
@@ -101,8 +102,7 @@ body[data-modal-stack-locked] {
|
|
|
101
102
|
transform: translate(-50%, -50%);
|
|
102
103
|
transition:
|
|
103
104
|
transform var(--modal-stack-duration) var(--modal-stack-ease),
|
|
104
|
-
opacity var(--modal-stack-duration) var(--modal-stack-ease)
|
|
105
|
-
filter var(--modal-stack-duration) var(--modal-stack-ease);
|
|
105
|
+
opacity var(--modal-stack-duration) var(--modal-stack-ease);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
@starting-style {
|
|
@@ -120,7 +120,6 @@ body[data-modal-stack-locked] {
|
|
|
120
120
|
|
|
121
121
|
[data-modal-stack-target="layer"][inert] {
|
|
122
122
|
opacity: 0.5;
|
|
123
|
-
filter: blur(0.5px);
|
|
124
123
|
}
|
|
125
124
|
|
|
126
125
|
[data-modal-stack-target="layer"][data-modal-stack-size="sm"] { width: var(--modal-stack-size-sm); }
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* modal_stack — Tailwind preset
|
|
2
|
+
* modal_stack — Tailwind v3 preset
|
|
3
|
+
*
|
|
4
|
+
* Tailwind v3 doesn't expose its design tokens as native CSS variables,
|
|
5
|
+
* so this preset ships static values aligned with the Tailwind defaults
|
|
6
|
+
* (slate text, white surface, rounded-2xl-ish radius, container sizes).
|
|
7
|
+
* Override the `--modal-stack-*` tokens on `:root` to retheme without
|
|
8
|
+
* touching the gem.
|
|
3
9
|
*
|
|
4
10
|
* Structural CSS for the <dialog id="modal-stack-root"> + layered
|
|
5
11
|
* `[data-modal-stack-target="layer"]` setup driven by the JS runtime.
|
|
6
|
-
* Visual tokens are exposed as CSS custom properties on `:root` so they
|
|
7
|
-
* can be overridden globally, per scope, or via Tailwind's
|
|
8
|
-
* `:root { --modal-stack-* }` declaration.
|
|
9
12
|
*
|
|
10
13
|
* Variants: [data-variant="modal" | "drawer" | "bottom_sheet" | "confirmation"]
|
|
11
14
|
* Drawer side: [data-side="left" | "right" | "top" | "bottom"]
|
|
@@ -27,7 +30,13 @@
|
|
|
27
30
|
--modal-stack-fg: #0f172a;
|
|
28
31
|
--modal-stack-shadow: 0 30px 60px -20px rgba(15, 23, 42, 0.35);
|
|
29
32
|
--modal-stack-backdrop: rgba(15, 23, 42, 0.55);
|
|
30
|
-
|
|
33
|
+
/* `none` by default — `backdrop-filter: blur()` even at radius 0
|
|
34
|
+
* still allocates a filter layer on the compositor, and animating
|
|
35
|
+
* a non-zero radius costs ~190ms/frame on Hi-DPI displays. Opt in
|
|
36
|
+
* with `:root { --modal-stack-backdrop-filter: blur(8px); }` —
|
|
37
|
+
* the filter is applied statically when the dialog opens (no
|
|
38
|
+
* per-frame compositor cost). */
|
|
39
|
+
--modal-stack-backdrop-filter: none;
|
|
31
40
|
--modal-stack-panel-padding: 24px;
|
|
32
41
|
--modal-stack-size-sm: 24rem; /* 384px */
|
|
33
42
|
--modal-stack-size-md: 34rem; /* 544px */
|
|
@@ -79,23 +88,23 @@ body[data-modal-stack-locked] {
|
|
|
79
88
|
|
|
80
89
|
#modal-stack-root::backdrop {
|
|
81
90
|
background: rgba(15, 23, 42, 0);
|
|
82
|
-
backdrop-filter: blur(0);
|
|
83
91
|
transition:
|
|
84
92
|
background var(--modal-stack-duration) var(--modal-stack-ease),
|
|
85
|
-
backdrop-filter var(--modal-stack-duration) var(--modal-stack-ease),
|
|
86
93
|
overlay var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete,
|
|
87
94
|
display var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete;
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
#modal-stack-root[open]::backdrop {
|
|
91
98
|
background: var(--modal-stack-backdrop);
|
|
92
|
-
|
|
99
|
+
/* Static (not in the transition list above). Default is `none` so
|
|
100
|
+
* Chrome skips the filter pass entirely. Override --modal-stack-
|
|
101
|
+
* backdrop-filter to opt in. */
|
|
102
|
+
backdrop-filter: var(--modal-stack-backdrop-filter);
|
|
93
103
|
}
|
|
94
104
|
|
|
95
105
|
@starting-style {
|
|
96
106
|
#modal-stack-root[open]::backdrop {
|
|
97
107
|
background: rgba(15, 23, 42, 0);
|
|
98
|
-
backdrop-filter: blur(0);
|
|
99
108
|
}
|
|
100
109
|
}
|
|
101
110
|
|
|
@@ -117,8 +126,7 @@ body[data-modal-stack-locked] {
|
|
|
117
126
|
transform: translate(-50%, -50%);
|
|
118
127
|
transition:
|
|
119
128
|
transform var(--modal-stack-duration) var(--modal-stack-ease),
|
|
120
|
-
opacity var(--modal-stack-duration) var(--modal-stack-ease)
|
|
121
|
-
filter var(--modal-stack-duration) var(--modal-stack-ease);
|
|
129
|
+
opacity var(--modal-stack-duration) var(--modal-stack-ease);
|
|
122
130
|
}
|
|
123
131
|
|
|
124
132
|
@starting-style {
|
|
@@ -136,7 +144,6 @@ body[data-modal-stack-locked] {
|
|
|
136
144
|
|
|
137
145
|
[data-modal-stack-target="layer"][inert] {
|
|
138
146
|
opacity: 0.5;
|
|
139
|
-
filter: blur(0.5px);
|
|
140
147
|
}
|
|
141
148
|
|
|
142
149
|
/* --- Sizes via [data-modal-stack-size] ----------------------------- */
|