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 +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +23 -20
- data/app/assets/javascripts/modal_stack.js +131 -20
- 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 +4 -0
- data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +29 -7
- data/app/javascript/modal_stack/orchestrator.js +84 -1
- data/app/javascript/modal_stack/orchestrator.test.js +168 -2
- data/app/javascript/modal_stack/runtime.js +55 -8
- data/app/javascript/modal_stack/runtime.test.js +68 -0
- data/app/javascript/modal_stack/state.js +21 -7
- data/app/javascript/modal_stack/state.test.js +6 -4
- data/lib/generators/modal_stack/install/install_generator.rb +18 -4
- data/lib/generators/modal_stack/install/templates/initializer.rb +12 -5
- data/lib/modal_stack/configuration.rb +7 -2
- data/lib/modal_stack/controller_extensions.rb +8 -1
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +14 -2
- data/lib/modal_stack/version.rb +1 -1
- data/lib/modal_stack.rb +1 -1
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a4ea30705b3d178cd2be1ad1a3b0a7b5487b40490b327d0e727b29e9e644f06f
|
|
4
|
+
data.tar.gz: '049b2eafb22af9cffd3ec3d454a23550dff04273cb23e4b96146a3716a62a383'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://github.com/Metalzoid/modal_stack/actions)
|
|
12
|
-
[](https://rubygems.org/gems/modal_stack)
|
|
13
|
+
[](https://rubygems.org/gems/modal_stack)
|
|
14
|
+
[](https://www.ruby-lang.org/)
|
|
15
|
+
[](https://rubyonrails.org/)
|
|
15
16
|
[](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=
|
|
144
|
-
$ bin/rails g modal_stack:install --css-provider=
|
|
145
|
-
$ bin/rails g modal_stack:install --css-provider=
|
|
146
|
-
$ bin/rails g modal_stack:install --css-provider=
|
|
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 = :
|
|
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 | `:
|
|
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/
|
|
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 | `
|
|
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
|
-
|
|
618
|
+
Four opinionated stylesheets ship with the gem. Pick one with
|
|
617
619
|
`config.css_provider`:
|
|
618
620
|
|
|
619
|
-
| Preset
|
|
620
|
-
|
|
|
621
|
-
| `:
|
|
622
|
-
| `:
|
|
623
|
-
| `:
|
|
624
|
-
| `:
|
|
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
|
|
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/ #
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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] ----------------------------- */
|