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.
@@ -0,0 +1,311 @@
1
+ /*
2
+ * modal_stack — Tailwind v4 preset
3
+ *
4
+ * Tailwind v4 exposes its design tokens as native CSS variables via the
5
+ * `@theme` directive (`--color-*`, `--radius-*`, `--shadow-*`,
6
+ * `--ease-*`, `--container-*`, …). This preset chains on those so the
7
+ * modal stack picks up your theme automatically. Fallbacks match the
8
+ * Tailwind v4 defaults, so the preset still renders correctly when a
9
+ * project hasn't redefined `@theme` (or isn't running v4 yet).
10
+ *
11
+ * Override `--modal-stack-*` on `:root` for modal-specific tweaks
12
+ * without touching the gem.
13
+ *
14
+ * Variants: [data-variant="modal" | "drawer" | "bottom_sheet" | "confirmation"]
15
+ * Drawer side: [data-side="left" | "right" | "top" | "bottom"]
16
+ * Sizes: [data-modal-stack-size="sm" | "md" | "lg" | "xl"]
17
+ *
18
+ * Entry / exit transitions:
19
+ * - @starting-style + transition-behavior: allow-discrete (Chrome 117+,
20
+ * Safari 17.4+, Firefox 129+).
21
+ * - The runtime sets [data-leaving] before unmounting a layer so the
22
+ * same transition rules apply on exit.
23
+ */
24
+
25
+ :root {
26
+ --modal-stack-z-base: 1000;
27
+ --modal-stack-duration: 220ms;
28
+ --modal-stack-ease: var(--ease-out, cubic-bezier(0.16, 1, 0.3, 1));
29
+ --modal-stack-radius: var(--radius-2xl, 14px);
30
+ --modal-stack-bg: var(--color-white, #ffffff);
31
+ --modal-stack-fg: var(--color-slate-900, #0f172a);
32
+ --modal-stack-shadow: var(--shadow-2xl, 0 30px 60px -20px rgba(15, 23, 42, 0.35));
33
+ --modal-stack-backdrop: rgba(15, 23, 42, 0.55);
34
+ /* `none` by default — `backdrop-filter: blur()` even at radius 0
35
+ * still allocates a filter layer on the compositor, and animating
36
+ * a non-zero radius costs ~190ms/frame on Hi-DPI displays. Opt in
37
+ * with `:root { --modal-stack-backdrop-filter: blur(8px); }` —
38
+ * the filter is applied statically when the dialog opens (no
39
+ * per-frame compositor cost). */
40
+ --modal-stack-backdrop-filter: none;
41
+ --modal-stack-panel-padding: 24px;
42
+ --modal-stack-size-sm: var(--container-sm, 24rem); /* 384px */
43
+ --modal-stack-size-md: 34rem; /* 544px — no v4 container alias */
44
+ --modal-stack-size-lg: var(--container-3xl, 48rem); /* 768px */
45
+ --modal-stack-size-xl: var(--container-5xl, 64rem); /* 1024px */
46
+ --modal-stack-drawer-width: var(--container-md, 28rem); /* 448px */
47
+ --modal-stack-drawer-height: 22rem; /* 352px */
48
+ }
49
+
50
+ /* --- Body lock when the stack is open ------------------------------- */
51
+
52
+ body[data-modal-stack-locked] {
53
+ overflow: hidden;
54
+ /* Compensate for the scrollbar width to avoid a layout shift. */
55
+ padding-right: var(--modal-stack-scrollbar-width, 0);
56
+ }
57
+
58
+ /* --- The single dialog root ---------------------------------------- */
59
+
60
+ #modal-stack-root {
61
+ position: fixed;
62
+ inset: 0;
63
+ width: 100vw;
64
+ height: 100vh;
65
+ margin: 0;
66
+ padding: 0;
67
+ border: 0;
68
+ background: transparent;
69
+ max-width: none;
70
+ max-height: none;
71
+ overflow: visible;
72
+ z-index: var(--modal-stack-z-base);
73
+ opacity: 0;
74
+ transition:
75
+ opacity var(--modal-stack-duration) var(--modal-stack-ease),
76
+ overlay var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete,
77
+ display var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete;
78
+ }
79
+
80
+ #modal-stack-root[open] {
81
+ opacity: 1;
82
+ }
83
+
84
+ @starting-style {
85
+ #modal-stack-root[open] {
86
+ opacity: 0;
87
+ }
88
+ }
89
+
90
+ #modal-stack-root::backdrop {
91
+ background: rgba(15, 23, 42, 0);
92
+ transition:
93
+ background var(--modal-stack-duration) var(--modal-stack-ease),
94
+ overlay var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete,
95
+ display var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete;
96
+ }
97
+
98
+ #modal-stack-root[open]::backdrop {
99
+ background: var(--modal-stack-backdrop);
100
+ /* Static (not in the transition list above). Default is `none` so
101
+ * Chrome skips the filter pass entirely. Override --modal-stack-
102
+ * backdrop-filter to opt in. */
103
+ backdrop-filter: var(--modal-stack-backdrop-filter);
104
+ }
105
+
106
+ @starting-style {
107
+ #modal-stack-root[open]::backdrop {
108
+ background: rgba(15, 23, 42, 0);
109
+ }
110
+ }
111
+
112
+ /* --- Layer (one per modal, vertically stacked Z-axis) -------------- */
113
+
114
+ [data-modal-stack-target="layer"] {
115
+ position: absolute;
116
+ top: 50%;
117
+ left: 50%;
118
+ width: var(--modal-stack-size-md);
119
+ max-width: 92vw;
120
+ max-height: min(85vh, 720px);
121
+ overflow-y: auto;
122
+ background: var(--modal-stack-bg);
123
+ color: var(--modal-stack-fg);
124
+ border-radius: var(--modal-stack-radius);
125
+ box-shadow: var(--modal-stack-shadow);
126
+ padding: 0;
127
+ transform: translate(-50%, -50%);
128
+ transition:
129
+ transform var(--modal-stack-duration) var(--modal-stack-ease),
130
+ opacity var(--modal-stack-duration) var(--modal-stack-ease);
131
+ }
132
+
133
+ @starting-style {
134
+ [data-modal-stack-target="layer"] {
135
+ opacity: 0;
136
+ transform: translate(-50%, -44%) scale(0.97);
137
+ }
138
+ }
139
+
140
+ [data-modal-stack-target="layer"][data-leaving] {
141
+ opacity: 0;
142
+ transform: translate(-50%, -56%) scale(0.97);
143
+ pointer-events: none;
144
+ }
145
+
146
+ [data-modal-stack-target="layer"][inert] {
147
+ opacity: 0.5;
148
+ }
149
+
150
+ /* --- Sizes via [data-modal-stack-size] ----------------------------- */
151
+
152
+ [data-modal-stack-target="layer"][data-modal-stack-size="sm"] { width: var(--modal-stack-size-sm); }
153
+ [data-modal-stack-target="layer"][data-modal-stack-size="md"] { width: var(--modal-stack-size-md); }
154
+ [data-modal-stack-target="layer"][data-modal-stack-size="lg"] { width: var(--modal-stack-size-lg); }
155
+ [data-modal-stack-target="layer"][data-modal-stack-size="xl"] { width: var(--modal-stack-size-xl); }
156
+
157
+ /* --- Subtle depth offset for stacked layers ----------------------- */
158
+
159
+ [data-modal-stack-target="layer"][data-depth="2"] {
160
+ transform: translate(-50%, -53%) scale(1.015);
161
+ }
162
+
163
+ [data-modal-stack-target="layer"][data-depth="3"] {
164
+ transform: translate(-50%, -56%) scale(1.03);
165
+ }
166
+
167
+ [data-modal-stack-target="layer"][data-depth="4"] {
168
+ transform: translate(-50%, -59%) scale(1.045);
169
+ }
170
+
171
+ /* --- Drawer ------------------------------------------------------- */
172
+
173
+ [data-modal-stack-target="layer"][data-variant="drawer"] {
174
+ left: 0;
175
+ top: 0;
176
+ max-width: 100vw;
177
+ max-height: 100vh;
178
+ }
179
+
180
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"],
181
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="left"] {
182
+ top: 0;
183
+ height: 100vh;
184
+ max-height: none;
185
+ width: var(--modal-stack-drawer-width);
186
+ max-width: 90vw;
187
+ }
188
+
189
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"] {
190
+ left: auto;
191
+ right: 0;
192
+ transform: translateX(0);
193
+ border-radius: var(--modal-stack-radius) 0 0 var(--modal-stack-radius);
194
+ }
195
+
196
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="left"] {
197
+ left: 0;
198
+ transform: translateX(0);
199
+ border-radius: 0 var(--modal-stack-radius) var(--modal-stack-radius) 0;
200
+ }
201
+
202
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="top"] {
203
+ left: 0;
204
+ top: 0;
205
+ width: 100vw;
206
+ max-width: none;
207
+ height: var(--modal-stack-drawer-height);
208
+ max-height: 85vh;
209
+ transform: translateY(0);
210
+ border-radius: 0 0 var(--modal-stack-radius) var(--modal-stack-radius);
211
+ }
212
+
213
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="bottom"] {
214
+ left: 0;
215
+ top: auto;
216
+ bottom: 0;
217
+ width: 100vw;
218
+ max-width: none;
219
+ height: var(--modal-stack-drawer-height);
220
+ max-height: 85vh;
221
+ transform: translateY(0);
222
+ border-radius: var(--modal-stack-radius) var(--modal-stack-radius) 0 0;
223
+ }
224
+
225
+ @starting-style {
226
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"] {
227
+ opacity: 0;
228
+ transform: translateX(100%);
229
+ }
230
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="left"] {
231
+ opacity: 0;
232
+ transform: translateX(-100%);
233
+ }
234
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="top"] {
235
+ opacity: 0;
236
+ transform: translateY(-100%);
237
+ }
238
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="bottom"] {
239
+ opacity: 0;
240
+ transform: translateY(100%);
241
+ }
242
+ }
243
+
244
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"][data-leaving] {
245
+ opacity: 0;
246
+ transform: translateX(100%);
247
+ }
248
+
249
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="left"][data-leaving] {
250
+ opacity: 0;
251
+ transform: translateX(-100%);
252
+ }
253
+
254
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="top"][data-leaving] {
255
+ opacity: 0;
256
+ transform: translateY(-100%);
257
+ }
258
+
259
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="bottom"][data-leaving] {
260
+ opacity: 0;
261
+ transform: translateY(100%);
262
+ }
263
+
264
+ /* --- Bottom sheet ------------------------------------------------- */
265
+
266
+ [data-modal-stack-target="layer"][data-variant="bottom_sheet"] {
267
+ top: auto;
268
+ bottom: 0;
269
+ left: 0;
270
+ width: 100vw;
271
+ max-width: none;
272
+ transform: translate(0, 0);
273
+ border-radius: var(--modal-stack-radius) var(--modal-stack-radius) 0 0;
274
+ max-height: 90vh;
275
+ }
276
+
277
+ @starting-style {
278
+ [data-modal-stack-target="layer"][data-variant="bottom_sheet"] {
279
+ opacity: 0;
280
+ transform: translateY(100%);
281
+ }
282
+ }
283
+
284
+ [data-modal-stack-target="layer"][data-variant="bottom_sheet"][data-leaving] {
285
+ opacity: 0;
286
+ transform: translateY(100%);
287
+ }
288
+
289
+ /* --- Confirmation ------------------------------------------------- */
290
+
291
+ [data-modal-stack-target="layer"][data-variant="confirmation"] {
292
+ width: var(--modal-stack-size-sm);
293
+ }
294
+
295
+ /* --- Panel slot (the modal_stack_container output) --------------- */
296
+
297
+ .modal-stack__panel {
298
+ padding: var(--modal-stack-panel-padding);
299
+ display: grid;
300
+ gap: 14px;
301
+ }
302
+
303
+ /* --- Reduced motion ---------------------------------------------- */
304
+
305
+ @media (prefers-reduced-motion: reduce) {
306
+ #modal-stack-root,
307
+ #modal-stack-root::backdrop,
308
+ [data-modal-stack-target="layer"] {
309
+ transition-duration: 1ms !important;
310
+ }
311
+ }
@@ -18,7 +18,9 @@
18
18
  --modal-stack-fg: #1a1a1a;
19
19
  --modal-stack-shadow: 0 24px 60px -16px rgba(0, 0, 0, 0.32);
20
20
  --modal-stack-backdrop: rgba(0, 0, 0, 0.5);
21
- --modal-stack-backdrop-blur: 0;
21
+ /* Default `none` so Chrome skips the filter pass. Opt in with
22
+ * `:root { --modal-stack-backdrop-filter: blur(8px); }`. */
23
+ --modal-stack-backdrop-filter: none;
22
24
  --modal-stack-panel-padding: 20px;
23
25
  --modal-stack-size-sm: 320px;
24
26
  --modal-stack-size-md: 480px;
@@ -60,23 +62,22 @@ body[data-modal-stack-locked] {
60
62
 
61
63
  #modal-stack-root::backdrop {
62
64
  background: rgba(0, 0, 0, 0);
63
- backdrop-filter: blur(0);
64
65
  transition:
65
66
  background var(--modal-stack-duration) var(--modal-stack-ease),
66
- backdrop-filter var(--modal-stack-duration) var(--modal-stack-ease),
67
67
  overlay var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete,
68
68
  display var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete;
69
69
  }
70
70
 
71
71
  #modal-stack-root[open]::backdrop {
72
72
  background: var(--modal-stack-backdrop);
73
- backdrop-filter: blur(var(--modal-stack-backdrop-blur));
73
+ /* Static. Default is `none` (no filter pass). Opt in with
74
+ * --modal-stack-backdrop-filter on :root. */
75
+ backdrop-filter: var(--modal-stack-backdrop-filter);
74
76
  }
75
77
 
76
78
  @starting-style {
77
79
  #modal-stack-root[open]::backdrop {
78
80
  background: rgba(0, 0, 0, 0);
79
- backdrop-filter: blur(0);
80
81
  }
81
82
  }
82
83
 
@@ -96,8 +97,7 @@ body[data-modal-stack-locked] {
96
97
  transform: translate(-50%, -50%);
97
98
  transition:
98
99
  transform var(--modal-stack-duration) var(--modal-stack-ease),
99
- opacity var(--modal-stack-duration) var(--modal-stack-ease),
100
- filter var(--modal-stack-duration) var(--modal-stack-ease);
100
+ opacity var(--modal-stack-duration) var(--modal-stack-ease);
101
101
  }
102
102
 
103
103
  @starting-style {
@@ -115,7 +115,6 @@ body[data-modal-stack-locked] {
115
115
 
116
116
  [data-modal-stack-target="layer"][inert] {
117
117
  opacity: 0.5;
118
- filter: blur(0.5px);
119
118
  }
120
119
 
121
120
  [data-modal-stack-target="layer"][data-modal-stack-size="sm"] { width: var(--modal-stack-size-sm); }
@@ -79,6 +79,10 @@ export class ModalStackController extends Controller {
79
79
  return this.orchestrator.closeAll();
80
80
  }
81
81
 
82
+ prefetch(url) {
83
+ return this.orchestrator.prefetch(url);
84
+ }
85
+
82
86
  #topLayer() {
83
87
  const layers = this.orchestrator.layers;
84
88
  return layers[layers.length - 1] ?? null;
@@ -1,14 +1,21 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
3
  export class ModalStackLinkController extends Controller {
4
- open(event) {
5
- const stack = document.querySelector('[data-controller~="modal-stack"]');
6
- if (!stack) return;
4
+ connect() {
5
+ if (this.element.dataset.modalStackLinkPrefetch === "false") return;
6
+ this._onIntent = () => this.#warm();
7
+ this.element.addEventListener("pointerenter", this._onIntent);
8
+ this.element.addEventListener("focus", this._onIntent);
9
+ }
7
10
 
8
- const controller = this.application.getControllerForElementAndIdentifier(
9
- stack,
10
- "modal-stack",
11
- );
11
+ disconnect() {
12
+ if (!this._onIntent) return;
13
+ this.element.removeEventListener("pointerenter", this._onIntent);
14
+ this.element.removeEventListener("focus", this._onIntent);
15
+ }
16
+
17
+ open(event) {
18
+ const controller = this.#stackController();
12
19
  if (!controller) return;
13
20
 
14
21
  event.preventDefault();
@@ -24,6 +31,21 @@ export class ModalStackLinkController extends Controller {
24
31
  dismissible: ds.modalStackLinkDismissible !== "false",
25
32
  });
26
33
  }
34
+
35
+ #warm() {
36
+ const controller = this.#stackController();
37
+ if (!controller || typeof controller.prefetch !== "function") return;
38
+ controller.prefetch(this.element.href);
39
+ }
40
+
41
+ #stackController() {
42
+ const stack = document.querySelector('[data-controller~="modal-stack"]');
43
+ if (!stack) return null;
44
+ return this.application.getControllerForElementAndIdentifier(
45
+ stack,
46
+ "modal-stack",
47
+ );
48
+ }
27
49
  }
28
50
 
29
51
  function generateLayerId() {
@@ -9,6 +9,11 @@ import {
9
9
  snapshot,
10
10
  } from "./state.js";
11
11
 
12
+ // How long a successful prefetch is reused before being refetched. Short
13
+ // enough that stale server-rendered HTML doesn't linger; long enough to
14
+ // absorb back/forward bounces and rapid double-clicks.
15
+ const PREFETCH_TTL_MS = 30_000;
16
+
12
17
  /**
13
18
  * Owns the current `Stack`, calls the pure reducer, and executes the emitted
14
19
  * commands against an injected runtime. The only stateful piece is
@@ -22,9 +27,16 @@ import {
22
27
  * @property {string|null} [restoreFrom] Serialized snapshot from sessionStorage
23
28
  * @property {number|null} [maxDepth] null disables the cap
24
29
  * @property {"raise"|"warn"|"silent"} [maxDepthStrategy]
30
+ * @property {number} [prefetchTtlMs] Override the prefetch cache TTL (testing)
25
31
  */
26
32
  export class Orchestrator {
27
33
  #expectedPopstates = 0;
34
+ // url → { fragment, ts }. Fragment is the canonical copy; consumers
35
+ // always receive a `cloneNode(true)` so the cached entry stays intact.
36
+ #fragmentCache = new Map();
37
+ // url → { controller, promise }. Lets concurrent prefetches dedupe onto
38
+ // the same in-flight request, and gives `closeAll` a way to cancel them.
39
+ #inflight = new Map();
28
40
 
29
41
  /** @param {OrchestratorOptions} options */
30
42
  constructor({
@@ -34,11 +46,13 @@ export class Orchestrator {
34
46
  restoreFrom = null,
35
47
  maxDepth = null,
36
48
  maxDepthStrategy = "warn",
49
+ prefetchTtlMs = PREFETCH_TTL_MS,
37
50
  }) {
38
51
  if (!runtime) throw new Error("runtime required");
39
52
  this.runtime = runtime;
40
53
  this.maxDepth = maxDepth;
41
54
  this.maxDepthStrategy = maxDepthStrategy;
55
+ this.prefetchTtlMs = prefetchTtlMs;
42
56
  this.state = createStack({ stackId, baseUrl });
43
57
 
44
58
  if (restoreFrom) {
@@ -92,10 +106,64 @@ export class Orchestrator {
92
106
 
93
107
  async #prefetch(url) {
94
108
  if (typeof this.runtime.fetchFragment !== "function") return null;
95
- return this.runtime.fetchFragment(url);
109
+
110
+ const cached = this.#fragmentCache.get(url);
111
+ if (cached && Date.now() - cached.ts < this.prefetchTtlMs) {
112
+ return cloneFragment(cached.fragment);
113
+ }
114
+
115
+ const existing = this.#inflight.get(url);
116
+ if (existing) {
117
+ const entry = await existing.promise;
118
+ return cloneFragment(entry.fragment);
119
+ }
120
+
121
+ const controller = supportsAbort() ? new AbortController() : null;
122
+ const fetchPromise = this.runtime
123
+ .fetchFragment(url, controller ? { signal: controller.signal } : undefined)
124
+ .then((fragment) => {
125
+ const entry = { fragment, ts: Date.now() };
126
+ this.#fragmentCache.set(url, entry);
127
+ return entry;
128
+ })
129
+ .finally(() => {
130
+ this.#inflight.delete(url);
131
+ });
132
+
133
+ this.#inflight.set(url, { controller, promise: fetchPromise });
134
+ const entry = await fetchPromise;
135
+ return cloneFragment(entry.fragment);
136
+ }
137
+
138
+ // Aborts every in-flight prefetch and forgets any cached fragments.
139
+ // Called when we tear the stack down (closeAll / cross-stack popstate)
140
+ // because the URLs in flight are no longer relevant. In-flight callers
141
+ // see an AbortError; caller code (controllers) already wraps push/pop
142
+ // in try/catch via `guarded()`.
143
+ #invalidatePrefetch() {
144
+ for (const { controller } of this.#inflight.values()) {
145
+ try {
146
+ controller?.abort();
147
+ } catch {
148
+ // ignore — abort is best-effort
149
+ }
150
+ }
151
+ this.#inflight.clear();
152
+ this.#fragmentCache.clear();
153
+ }
154
+
155
+ // Warm the prefetch cache for `url` without mutating the stack. Safe
156
+ // to call repeatedly for the same URL (deduped via #inflight) and from
157
+ // hover/focus handlers; failures are swallowed since this is best-effort.
158
+ prefetch(url) {
159
+ if (!url || typeof this.runtime.fetchFragment !== "function") {
160
+ return Promise.resolve(null);
161
+ }
162
+ return this.#prefetch(url).catch(() => null);
96
163
  }
97
164
 
98
165
  closeAll() {
166
+ this.#invalidatePrefetch();
99
167
  return this.#dispatch(closeAll(this.state));
100
168
  }
101
169
 
@@ -104,6 +172,9 @@ export class Orchestrator {
104
172
  this.#expectedPopstates -= 1;
105
173
  return Promise.resolve();
106
174
  }
175
+ // A popstate arriving while we have prefetches in flight means the
176
+ // user navigated away from any URL we were preloading; drop them.
177
+ this.#invalidatePrefetch();
107
178
  return this.#dispatch(
108
179
  handlePopstate(this.state, { historyState, locationHref }),
109
180
  );
@@ -145,3 +216,15 @@ export class Orchestrator {
145
216
  await handler.call(this.runtime, cmd);
146
217
  }
147
218
  }
219
+
220
+ function cloneFragment(fragment) {
221
+ if (!fragment) return fragment;
222
+ if (typeof fragment.cloneNode === "function") {
223
+ return fragment.cloneNode(true);
224
+ }
225
+ return fragment;
226
+ }
227
+
228
+ function supportsAbort() {
229
+ return typeof globalThis.AbortController === "function";
230
+ }