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.
@@ -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
- constructor({ runtime, stackId, baseUrl, restoreFrom = null }) {
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(push(this.state, layer), { html, fragment });
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
- return this.runtime.fetchFragment(url);
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
- throw new Error(`runtime missing handler for "${cmd.type}"`);
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 LEAVE_TIMEOUT_MS = 600;
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
- this.body.dataset.modalStackLocked = "";
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
- delete this.body.dataset.modalStackLocked;
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
- await Promise.all(layers.map(animateOut));
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, LEAVE_TIMEOUT_MS);
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
- StreamActions.modal_push = function modalPush() {
653
- orchestrator.push(layerFromStreamElement(this), {
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 modalPop() {
658
- orchestrator.pop();
659
- };
660
- StreamActions.modal_replace = function modalReplace() {
661
- orchestrator.replaceTop(layerPatchFromStreamElement(this), {
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 modalCloseAll() {
667
- orchestrator.closeAll();
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
- open(event) {
716
- const stack = document.querySelector('[data-controller~="modal-stack"]');
717
- if (!stack)
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
- const controller = this.application.getControllerForElementAndIdentifier(stack, "modal-stack");
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
- --modal-stack-backdrop-blur: 0;
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
- backdrop-filter: blur(var(--modal-stack-backdrop-blur));
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
- --modal-stack-backdrop-blur: 2px;
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
- backdrop-filter: blur(var(--modal-stack-backdrop-blur));
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] ----------------------------- */