modal_stack 0.2.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5d872f05608ece432767e38bbe58ec38bf97c60a0cd90ccf1afd37f41f7f6192
4
- data.tar.gz: 3da69811422baf2e1e7927bae0dbe46cf75311b5214e34e90e0a04e8aa691250
3
+ metadata.gz: a4ea30705b3d178cd2be1ad1a3b0a7b5487b40490b327d0e727b29e9e644f06f
4
+ data.tar.gz: '049b2eafb22af9cffd3ec3d454a23550dff04273cb23e4b96146a3716a62a383'
5
5
  SHA512:
6
- metadata.gz: 3d9470889c9fa8b2d967174818eb813a90657baac94a70edadf3f7a416400e364cc5e15ed9b7274544b138f8b5647189e21143b14aa27d67cac79ae711822d20
7
- data.tar.gz: 5dcdab2a2dca8c1b57b67e62f1daff68cc3b04395e5e66d9a3da933de02caef78460116c4b097fb040fa33011a83eb79f0645d6e273e91ad21ec552a27023d1b
6
+ metadata.gz: da14584321b5c28cd93ecbc9dd260865313c5ccd62e17d7846a734c3da47b54e3f0ac20b703542cb14d8cc93fe3611c5de0d7e17c06900ecab92c554ae5c806f
7
+ data.tar.gz: 111ac685cf469d968fb71288cc13822d059e97212a01b098d3766ce0136990ac1b0db4384777a86245fb6923cb225fc62c23543979d79fe18efbc1d2b1e8dbfe
data/CHANGELOG.md CHANGED
@@ -12,6 +12,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
12
12
 
13
13
  ### Fixed
14
14
 
15
+ ## [0.3.0] - 2026-05-03
16
+
17
+ ### Added
18
+ - **Prefetch cache + dedupe** in `Orchestrator`: fragments fetched for `push` / `replaceTop` are cached per URL (TTL 30 s) and concurrent prefetches for the same URL share a single in-flight request. Aborts pending requests on `closeAll` and on stack-mismatching `popstate`.
19
+ - **Hover/focus prefetch** on `modal-stack-link`: links warm the cache on `pointerenter` / `focus` so the modal opens with no network latency on click. Opt out with `data-modal-stack-link-prefetch="false"`.
20
+ - **`Orchestrator#prefetch(url)`** public method (and matching `ModalStackController#prefetch`) for warming the cache from app code.
21
+ - **Request-scoped configuration** via `controller.modal_stack_config` (helper-method exposed). Rendering helpers now read configuration once per request instead of once per call.
22
+ - **`:tailwind_v4` CSS preset** that chains on Tailwind v4 `@theme` tokens (`--color-*`, `--radius-*`, `--shadow-*`, `--ease-*`, `--container-*`), so the modal stack inherits the host project's Tailwind theme automatically. Fallbacks match the Tailwind v4 defaults so the preset still renders correctly when `@theme` isn't redefined (or when Tailwind v4 isn't installed).
23
+ - **`:tailwind_v3` CSS preset** — the previous `:tailwind` preset, renamed for clarity. Static values aligned with Tailwind v3 defaults (Tailwind v3 doesn't expose tokens as CSS variables).
24
+ - `Configuration::CSS_PROVIDER_ALIASES` constant for legacy `:tailwind` → `:tailwind_v3` normalization.
25
+ - New tests: prefetch dedupe / cache hit / TTL / abort-on-closeAll / `prefetch` API; CSS-derived leave timeout; `:tailwind` alias normalization; generator default + alias mapping.
26
+
27
+ ### Changed
28
+ - **Animation safety timeout** is now derived from the `--modal-stack-duration` CSS variable on the `<dialog>` (1.5× the declared duration, floored at 300 ms). Falls back to 600 ms when the variable is unset, so existing host CSS keeps working.
29
+ - `BrowserRuntime#fetchFragment` accepts an `{ signal }` option to support `AbortController` cancellation.
30
+ - **CSS preset perf overhaul**: removed animated blur from all four presets — animating `backdrop-filter: blur(2px)` on a fullscreen surface costs ~190 ms/frame on Hi-DPI displays (Retina, 4K), which collapsed modal animations to ~5 fps.
31
+ - The old `--modal-stack-backdrop-blur` variable (radius only) is replaced with `--modal-stack-backdrop-filter` (full filter expression). Default is `none`, which lets Chrome skip the filter pass entirely — `backdrop-filter: blur(0)` still allocated a filter layer, so a `0` radius wasn't actually free. Apps that want a blurred backdrop now opt in with `:root { --modal-stack-backdrop-filter: blur(8px); }` (any filter expression accepted, not just `blur()`).
32
+ - `backdrop-filter` is no longer in the backdrop's `transition` list — when the user opts in, the filter is applied statically when the dialog opens, so the cost is paid once and the per-frame compositor work disappears.
33
+ - `filter: blur(0.5px)` on inert (underlying) layers has been dropped — only `opacity: 0.5` remains. The blur was visually negligible on screen but forced an extra GPU layer per stacked modal.
34
+ - The `filter` property has been removed from the layer's `transition` list since nothing animates it any more.
35
+ - **Backdrop fade now runs in parallel with the layer's leave animation** when closing the last modal (or `closeAll`). Previously the reducer emitted `[unmountTopLayer, …, closeDialog, …]` and the orchestrator awaited each command sequentially — `closeDialog` only fired after the layer's 220 ms `transitionend`, so the backdrop fade-out was perceived *after* the modal was gone (effective close time ≈ 440 ms). The reducer now emits `closeDialog` first; it's synchronous on the runtime, so the dialog's exit transition (opacity, backdrop background, `display`/`overlay` `allow-discrete`) starts immediately and runs alongside the layer's `[data-leaving]` transition. Total close time is now ≈ 220 ms. Fix applied to `pop`, `closeAll`, and the two `handlePopstate` branches that close the dialog.
36
+ - **`config.css_provider` default** is now `:tailwind_v3` (was `:tailwind`). Existing apps with `config.css_provider = :tailwind` keep working — the alias is normalized to `:tailwind_v3` on assignment, with no rendered CSS change.
37
+ - **Generator default `--css-provider`** is now `tailwind_v4` for new installs (was `tailwind`). Run `bin/rails g modal_stack:install --css-provider=tailwind_v3` to opt back in to the v3 preset, or pass the legacy `tailwind` flag — it's normalized to `tailwind_v3` in the generated initializer.
38
+ - The `app/assets/stylesheets/modal_stack/tailwind.css` file has been renamed to `tailwind_v3.css`. Sprockets manifests written by previous installs that contain `//= link modal_stack/tailwind.css` need the line updated to `tailwind_v3.css` (or `tailwind_v4.css`). Importmap / jsbundling installs aren't affected — they don't reference the stylesheet by filename.
39
+ - `INITIALIZER_VERSION` bumped to `0.3.0` because the generator template documents the new providers.
40
+
15
41
  ## [0.2.0] - 2026-05-03
16
42
 
17
43
  ### Added
data/README.md CHANGED
@@ -9,9 +9,10 @@ browser back/forward support, and drive everything from imperative Turbo
9
9
  Stream actions (`modal_push`, `modal_pop`, `modal_replace`, `modal_close_all`).
10
10
 
11
11
  [![CI](https://github.com/Metalzoid/modal_stack/actions/workflows/main.yml/badge.svg)](https://github.com/Metalzoid/modal_stack/actions)
12
- [![Gem Version](https://badge.fury.io/rb/modal_stack.svg)](https://rubygems.org/gems/modal_stack)
13
- [![Ruby](https://img.shields.io/gem/ruby-version/modal_stack?label=ruby)](https://www.ruby-lang.org/)
14
- [![Rails](https://img.shields.io/gem/dv/modal_stack/railties?label=rails)](https://rubyonrails.org/)
12
+ [![Gem Version](https://img.shields.io/gem/v/modal_stack.svg?label=gem)](https://rubygems.org/gems/modal_stack)
13
+ [![Downloads](https://img.shields.io/gem/dt/modal_stack?label=downloads)](https://rubygems.org/gems/modal_stack)
14
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
15
+ [![Rails](https://img.shields.io/badge/rails-7.2%20%7C%208.0%20%7C%208.1-CC0000?logo=rubyonrails&logoColor=white)](https://rubyonrails.org/)
15
16
  [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE.txt)
16
17
 
17
18
  </div>
@@ -140,10 +141,11 @@ $ bin/rails g modal_stack:install --mode=sprockets # legacy apps
140
141
  Pick the CSS preset that matches your stack:
141
142
 
142
143
  ```bash
143
- $ bin/rails g modal_stack:install --css-provider=tailwind # default
144
- $ bin/rails g modal_stack:install --css-provider=bootstrap # picks up Bootstrap 5 vars
145
- $ bin/rails g modal_stack:install --css-provider=vanilla # framework-free
146
- $ bin/rails g modal_stack:install --css-provider=none # bring your own CSS
144
+ $ bin/rails g modal_stack:install --css-provider=tailwind_v4 # default — chains on Tailwind v4 @theme tokens
145
+ $ bin/rails g modal_stack:install --css-provider=tailwind_v3 # static values aligned with Tailwind v3
146
+ $ bin/rails g modal_stack:install --css-provider=bootstrap # picks up Bootstrap 5 vars
147
+ $ bin/rails g modal_stack:install --css-provider=vanilla # framework-free
148
+ $ bin/rails g modal_stack:install --css-provider=none # bring your own CSS
147
149
  ```
148
150
 
149
151
  ### What the generator does
@@ -204,7 +206,7 @@ Everything lives in `config/initializers/modal_stack.rb`:
204
206
  ```ruby
205
207
  ModalStack.configure do |config|
206
208
  # ─── Presentation ─────────────────────────────────────────────────
207
- config.css_provider = :tailwind # :tailwind | :bootstrap | :vanilla | :none
209
+ config.css_provider = :tailwind_v4 # :tailwind_v3 | :tailwind_v4 | :bootstrap | :vanilla | :none
208
210
  config.default_variant = :modal # :modal | :drawer | :bottom_sheet | :confirmation
209
211
  config.default_size = :md # :sm | :md | :lg | :xl
210
212
  config.default_dismissible = true # ESC + backdrop click close the layer
@@ -420,7 +422,7 @@ ModalStack.reset_configuration! # test-fixture helper
420
422
 
421
423
  | Attribute | Type | Default | Description |
422
424
  | ---------------------------- | ------- | ------------------------ | ----------- |
423
- | `css_provider` | Symbol | `:tailwind` | One of `:tailwind`, `:bootstrap`, `:vanilla`, `:none`. Determines which stylesheet `modal_stack_stylesheet_link_tag` resolves to. Validated. |
425
+ | `css_provider` | Symbol | `:tailwind_v3` | One of `:tailwind_v3`, `:tailwind_v4`, `:bootstrap`, `:vanilla`, `:none`. Determines which stylesheet `modal_stack_stylesheet_link_tag` resolves to. The legacy `:tailwind` is accepted and normalized to `:tailwind_v3`. New installs default to `:tailwind_v4`. Validated. |
424
426
  | `assets_mode` | Symbol | `:auto` | One of `:importmap`, `:jsbundling`, `:sprockets`, `:auto`. Used by the generator. Validated. |
425
427
  | `default_variant` | Symbol | `:modal` | `:modal`, `:drawer`, `:bottom_sheet`, or `:confirmation`. Validated. |
426
428
  | `default_size` | Symbol | `:md` | `:sm`, `:md`, `:lg`, `:xl`. Validated. |
@@ -445,7 +447,7 @@ Injected into `ActionView::Base` by the engine — available in every view.
445
447
  | ------------------------------------------------- | ----------- |
446
448
  | `modal_link_to(name, options, html_options)` | Renders a `link_to` wired to push a layer when clicked. Accepts the modal options (`as:`, `side:`, `size:`, `width:`, `height:`, `dismissible:`) on top of standard `link_to` arguments. Falls back to plain `link_to` for Hotwire Native requests. |
447
449
  | `modal_stack_container(size:, variant:, side:, width:, height:, dismissible:, html: {}) { ... }` | Wraps a panel view with the markup the JS runtime expects. Renders a `<div>` carrying the size/variant/dismissible/dimension data attributes. |
448
- | `modal_stack_stylesheet_link_tag(**options)` | Emits `<link rel="stylesheet">` for the configured preset (`modal_stack/tailwind.css`, etc.). Returns an empty SafeBuffer when `css_provider = :none`. |
450
+ | `modal_stack_stylesheet_link_tag(**options)` | Emits `<link rel="stylesheet">` for the configured preset (`modal_stack/tailwind_v4.css`, etc.). Returns an empty SafeBuffer when `css_provider = :none`. |
449
451
  | `modal_stack_dialog_tag(**html_options)` | Emits the singleton `<dialog id="modal-stack-root" data-controller="modal-stack">`. Drop just before `</body>`. |
450
452
  | `modal_stack_javascript_tag` | Reserved hook for layouts; currently a no-op (JS is loaded via your bundler / importmap). |
451
453
 
@@ -594,7 +596,7 @@ $ bin/rails g modal_stack:install [flags]
594
596
  | Flag | Type | Default | Values |
595
597
  | --------------------- | ------- | ----------- | ------ |
596
598
  | `--mode` | String | `auto` | `auto`, `importmap`, `jsbundling`, `sprockets` |
597
- | `--css-provider` | String | `tailwind` | `tailwind`, `bootstrap`, `vanilla`, `none` |
599
+ | `--css-provider` | String | `tailwind_v4` | `tailwind_v3`, `tailwind_v4`, `bootstrap`, `vanilla`, `none` (legacy `tailwind` accepted, normalized to `tailwind_v3`) |
598
600
  | `--skip-layout` | Boolean | `false` | When set, doesn't inject the stylesheet/dialog helpers into `application.html.erb` |
599
601
  | `--skip-js` | Boolean | `false` | When set, skips the Importmap pin / package install / Stimulus install wiring |
600
602
  | `--skip-initializer` | Boolean | `false` | When set, doesn't generate `config/initializers/modal_stack.rb` |
@@ -613,17 +615,18 @@ safe.
613
615
 
614
616
  ## 🎨 CSS presets & theming
615
617
 
616
- Three opinionated stylesheets ship with the gem. Pick one with
618
+ Four opinionated stylesheets ship with the gem. Pick one with
617
619
  `config.css_provider`:
618
620
 
619
- | Preset | File | Best for |
620
- | ------------ | ------------------------------------------ | -------- |
621
- | `:tailwind` | `app/assets/stylesheets/modal_stack/tailwind.css` | Tailwind apps — uses Tailwind tokens by default but overridable |
622
- | `:bootstrap` | `app/assets/stylesheets/modal_stack/bootstrap.css` | Picks up Bootstrap 5 CSS variables |
623
- | `:vanilla` | `app/assets/stylesheets/modal_stack/vanilla.css` | Framework-free, neutral defaults |
624
- | `:none` | | Bring your own CSS |
621
+ | Preset | File | Best for |
622
+ | --------------- | ----------------------------------------------------- | -------- |
623
+ | `:tailwind_v4` | `app/assets/stylesheets/modal_stack/tailwind_v4.css` | Tailwind v4 apps — chains on `@theme` tokens (`--color-*`, `--radius-*`, `--shadow-*`, `--container-*`) so the modal picks up your theme automatically. Falls back to Tailwind defaults when `@theme` isn't redefined. |
624
+ | `:tailwind_v3` | `app/assets/stylesheets/modal_stack/tailwind_v3.css` | Tailwind v3 apps static values aligned with Tailwind v3 defaults (v3 doesn't expose tokens as CSS variables). Legacy `:tailwind` is accepted as an alias. |
625
+ | `:bootstrap` | `app/assets/stylesheets/modal_stack/bootstrap.css` | Picks up Bootstrap 5 CSS variables |
626
+ | `:vanilla` | `app/assets/stylesheets/modal_stack/vanilla.css` | Framework-free, neutral defaults |
627
+ | `:none` | — | Bring your own CSS |
625
628
 
626
- All three presets are driven by the same `--modal-stack-*` CSS variables.
629
+ All presets are driven by the same `--modal-stack-*` CSS variables.
627
630
  Override on `:root` to retheme without touching the gem:
628
631
 
629
632
  ```css
@@ -720,7 +723,7 @@ modal_stack/
720
723
  ├── app/
721
724
  │ ├── assets/
722
725
  │ │ ├── javascripts/modal_stack.js # pre-built importmap bundle (committed)
723
- │ │ └── stylesheets/modal_stack/ # tailwind / bootstrap / vanilla presets
726
+ │ │ └── stylesheets/modal_stack/ # tailwind_v3 / tailwind_v4 / bootstrap / vanilla presets
724
727
  │ ├── javascript/modal_stack/ # ES module sources + bun tests
725
728
  │ │ ├── state.js # pure reducer (100% coverage)
726
729
  │ │ ├── orchestrator.js # state → command translator
@@ -126,15 +126,16 @@ function pop(state) {
126
126
  return { state, commands: [] };
127
127
  const newLayers = Object.freeze(state.layers.slice(0, -1));
128
128
  const newTop = newLayers[newLayers.length - 1] ?? null;
129
- const commands = [
130
- { type: "unmountTopLayer" },
131
- { type: "historyBack", n: 1 }
132
- ];
129
+ const commands = [];
133
130
  if (newTop) {
131
+ commands.push({ type: "unmountTopLayer" });
132
+ commands.push({ type: "historyBack", n: 1 });
134
133
  commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
135
134
  commands.push({ type: "persistSnapshot" });
136
135
  } else {
137
136
  commands.push({ type: "closeDialog" });
137
+ commands.push({ type: "unmountTopLayer" });
138
+ commands.push({ type: "historyBack", n: 1 });
138
139
  commands.push({ type: "unlockScroll" });
139
140
  commands.push({ type: "clearSnapshot" });
140
141
  }
@@ -192,8 +193,8 @@ function closeAll(state) {
192
193
  return {
193
194
  state: { ...state, layers: Object.freeze([]) },
194
195
  commands: [
195
- { type: "unmountAllLayers" },
196
196
  { type: "closeDialog" },
197
+ { type: "unmountAllLayers" },
197
198
  { type: "unlockScroll" },
198
199
  { type: "historyBack", n },
199
200
  { type: "clearSnapshot" }
@@ -208,8 +209,8 @@ function handlePopstate(state, { historyState, locationHref }) {
208
209
  return {
209
210
  state: { ...state, layers: Object.freeze([]) },
210
211
  commands: [
211
- { type: "unmountAllLayers" },
212
212
  { type: "closeDialog" },
213
+ { type: "unmountAllLayers" },
213
214
  { type: "unlockScroll" },
214
215
  { type: "clearSnapshot" }
215
216
  ]
@@ -222,6 +223,8 @@ function handlePopstate(state, { historyState, locationHref }) {
222
223
  const newLayers = Object.freeze(state.layers.slice(0, targetDepth));
223
224
  const newTop = newLayers[newLayers.length - 1] ?? null;
224
225
  const commands = [];
226
+ if (!newTop)
227
+ commands.push({ type: "closeDialog" });
225
228
  for (let i = 0;i < currentDepth - targetDepth; i++) {
226
229
  commands.push({ type: "unmountTopLayer" });
227
230
  }
@@ -229,7 +232,6 @@ function handlePopstate(state, { historyState, locationHref }) {
229
232
  commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
230
233
  commands.push({ type: "persistSnapshot" });
231
234
  } else {
232
- commands.push({ type: "closeDialog" });
233
235
  commands.push({ type: "unlockScroll" });
234
236
  commands.push({ type: "clearSnapshot" });
235
237
  }
@@ -326,21 +328,27 @@ function restore(serialized, { stackId, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Dat
326
328
  }
327
329
 
328
330
  // app/javascript/modal_stack/orchestrator.js
331
+ var PREFETCH_TTL_MS = 30000;
332
+
329
333
  class Orchestrator {
330
334
  #expectedPopstates = 0;
335
+ #fragmentCache = new Map;
336
+ #inflight = new Map;
331
337
  constructor({
332
338
  runtime,
333
339
  stackId,
334
340
  baseUrl,
335
341
  restoreFrom = null,
336
342
  maxDepth = null,
337
- maxDepthStrategy = "warn"
343
+ maxDepthStrategy = "warn",
344
+ prefetchTtlMs = PREFETCH_TTL_MS
338
345
  }) {
339
346
  if (!runtime)
340
347
  throw new Error("runtime required");
341
348
  this.runtime = runtime;
342
349
  this.maxDepth = maxDepth;
343
350
  this.maxDepthStrategy = maxDepthStrategy;
351
+ this.prefetchTtlMs = prefetchTtlMs;
344
352
  this.state = createStack({ stackId, baseUrl });
345
353
  if (restoreFrom) {
346
354
  const restored = restore(restoreFrom, { stackId });
@@ -378,9 +386,44 @@ class Orchestrator {
378
386
  async#prefetch(url) {
379
387
  if (typeof this.runtime.fetchFragment !== "function")
380
388
  return null;
381
- 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);
382
424
  }
383
425
  closeAll() {
426
+ this.#invalidatePrefetch();
384
427
  return this.#dispatch(closeAll(this.state));
385
428
  }
386
429
  onPopstate({ historyState, locationHref }) {
@@ -388,6 +431,7 @@ class Orchestrator {
388
431
  this.#expectedPopstates -= 1;
389
432
  return Promise.resolve();
390
433
  }
434
+ this.#invalidatePrefetch();
391
435
  return this.#dispatch(handlePopstate(this.state, { historyState, locationHref }));
392
436
  }
393
437
  async#dispatch({ state, commands }, payload = {}) {
@@ -418,13 +462,26 @@ class Orchestrator {
418
462
  await handler.call(this.runtime, cmd);
419
463
  }
420
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
+ }
421
476
 
422
477
  // app/javascript/modal_stack/runtime.js
423
478
  var SNAPSHOT_KEY = "modalStackSnapshot";
424
479
  var FRAGMENT_HEADER = "X-Modal-Stack-Request";
425
480
  var SCROLLBAR_WIDTH_VAR = "--modal-stack-scrollbar-width";
426
481
  var LAYER_SELECTOR = '[data-modal-stack-target="layer"]';
427
- 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;
428
485
 
429
486
  class BrowserRuntime {
430
487
  constructor({
@@ -502,11 +559,12 @@ class BrowserRuntime {
502
559
  const layer = this.#topLayer();
503
560
  if (!layer)
504
561
  return;
505
- await animateOut(layer);
562
+ await animateOut(layer, this.#leaveTimeoutMs());
506
563
  }
507
564
  async unmountAllLayers() {
508
565
  const layers = [...this.dialog.querySelectorAll(LAYER_SELECTOR)];
509
- await Promise.all(layers.map(animateOut));
566
+ const timeout = this.#leaveTimeoutMs();
567
+ await Promise.all(layers.map((l) => animateOut(l, timeout)));
510
568
  }
511
569
  pushHistory({ url, historyState }) {
512
570
  this.history.pushState(historyState, "", url);
@@ -543,6 +601,22 @@ class BrowserRuntime {
543
601
  return null;
544
602
  }
545
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
+ }
546
620
  #findLayer(layerId) {
547
621
  return this.dialog.querySelector(`${LAYER_SELECTOR}[data-layer-id="${escapeAttr(layerId)}"]`);
548
622
  }
@@ -579,13 +653,14 @@ class BrowserRuntime {
579
653
  layer.style.removeProperty("height");
580
654
  }
581
655
  }
582
- async fetchFragment(url) {
656
+ async fetchFragment(url, { signal } = {}) {
583
657
  const resp = await this.fetcher(url, {
584
658
  headers: {
585
659
  Accept: "text/html, text/vnd.turbo-stream.html",
586
660
  [FRAGMENT_HEADER]: "1"
587
661
  },
588
- credentials: "same-origin"
662
+ credentials: "same-origin",
663
+ signal
589
664
  });
590
665
  if (!resp.ok) {
591
666
  throw new Error(`modal_stack: fetch ${url} → ${resp.status}`);
@@ -608,7 +683,7 @@ function parseFragment(html, doc) {
608
683
  fragment.append(...parsed.body.childNodes);
609
684
  return fragment;
610
685
  }
611
- function animateOut(layer) {
686
+ function animateOut(layer, timeoutMs = LEAVE_TIMEOUT_FALLBACK_MS) {
612
687
  return new Promise((resolve) => {
613
688
  let done = false;
614
689
  const finish = () => {
@@ -621,9 +696,20 @@ function animateOut(layer) {
621
696
  };
622
697
  layer.addEventListener("transitionend", finish, { once: true });
623
698
  layer.dataset.leaving = "";
624
- setTimeout(finish, LEAVE_TIMEOUT_MS);
699
+ setTimeout(finish, timeoutMs);
625
700
  });
626
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
+ }
627
713
  function escapeAttr(value) {
628
714
  if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
629
715
  return CSS.escape(value);
@@ -694,6 +780,9 @@ class ModalStackController extends Controller {
694
780
  closeAll() {
695
781
  return this.orchestrator.closeAll();
696
782
  }
783
+ prefetch(url) {
784
+ return this.orchestrator.prefetch(url);
785
+ }
697
786
  #topLayer() {
698
787
  const layers = this.orchestrator.layers;
699
788
  return layers[layers.length - 1] ?? null;
@@ -790,11 +879,21 @@ function generateLayerId() {
790
879
  import { Controller as Controller2 } from "@hotwired/stimulus";
791
880
 
792
881
  class ModalStackLinkController extends Controller2 {
793
- open(event) {
794
- const stack = document.querySelector('[data-controller~="modal-stack"]');
795
- 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)
796
891
  return;
797
- 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();
798
897
  if (!controller)
799
898
  return;
800
899
  event.preventDefault();
@@ -810,6 +909,18 @@ class ModalStackLinkController extends Controller2 {
810
909
  dismissible: ds.modalStackLinkDismissible !== "false"
811
910
  });
812
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
+ }
813
924
  }
814
925
  function generateLayerId2() {
815
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] ----------------------------- */