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.
@@ -208,8 +208,8 @@ describe("closeAll", () => {
208
208
  expect(orchestrator.depth).toBe(0);
209
209
  const types = runtime._calls.map((c) => c.type);
210
210
  expect(types).toEqual([
211
- "unmountAllLayers",
212
211
  "closeDialog",
212
+ "unmountAllLayers",
213
213
  "unlockScroll",
214
214
  "historyBack",
215
215
  "clearSnapshot",
@@ -224,6 +224,172 @@ describe("closeAll", () => {
224
224
  });
225
225
  });
226
226
 
227
+ describe("prefetch cache + abort", () => {
228
+ // Each fakeFragment supports cloneNode so the orchestrator can hand out
229
+ // independent copies without exhausting the cached entry.
230
+ function fakeFragment(label) {
231
+ return {
232
+ label,
233
+ consumed: false,
234
+ cloneNode() {
235
+ return fakeFragment(label);
236
+ },
237
+ };
238
+ }
239
+
240
+ function fetchingRuntime({ delayMs = 0, fail = false } = {}) {
241
+ const calls = [];
242
+ const aborts = [];
243
+ const handlerNames = [
244
+ "showDialog",
245
+ "lockScroll",
246
+ "inertLayer",
247
+ "mountLayer",
248
+ "morphTopLayer",
249
+ "unmountTopLayer",
250
+ "unmountAllLayers",
251
+ "closeDialog",
252
+ "unlockScroll",
253
+ "pushHistory",
254
+ "replaceHistory",
255
+ "historyBack",
256
+ "rebuildFromSnapshot",
257
+ "persistSnapshot",
258
+ "clearSnapshot",
259
+ ];
260
+ const runtime = { _calls: calls, _fetches: [], _aborts: aborts };
261
+ for (const name of handlerNames) {
262
+ runtime[name] = (cmd) => {
263
+ calls.push({ type: name, ...cmd });
264
+ };
265
+ }
266
+ runtime.fetchFragment = (url, { signal } = {}) => {
267
+ runtime._fetches.push(url);
268
+ return new Promise((resolve, reject) => {
269
+ const t = setTimeout(() => {
270
+ if (fail) reject(new Error("boom"));
271
+ else resolve(fakeFragment(`frag:${url}`));
272
+ }, delayMs);
273
+ if (signal) {
274
+ signal.addEventListener("abort", () => {
275
+ clearTimeout(t);
276
+ aborts.push(url);
277
+ const err = new Error("aborted");
278
+ err.name = "AbortError";
279
+ reject(err);
280
+ });
281
+ }
282
+ });
283
+ };
284
+ return runtime;
285
+ }
286
+
287
+ test("dedupes concurrent prefetches for the same url", async () => {
288
+ const rt = fetchingRuntime({ delayMs: 5 });
289
+ const orch = new Orchestrator({
290
+ runtime: rt,
291
+ stackId: STACK_ID,
292
+ baseUrl: BASE_URL,
293
+ });
294
+ await Promise.all([
295
+ orch.push({ id: "L1", url: "/x" }),
296
+ orch.push({ id: "L2", url: "/x" }),
297
+ ]);
298
+ expect(rt._fetches).toEqual(["/x"]);
299
+ });
300
+
301
+ test("hits the cache on a second push to the same url", async () => {
302
+ const rt = fetchingRuntime();
303
+ const orch = new Orchestrator({
304
+ runtime: rt,
305
+ stackId: STACK_ID,
306
+ baseUrl: BASE_URL,
307
+ });
308
+ await orch.push({ id: "L1", url: "/x" });
309
+ await orch.pop();
310
+ await orch.push({ id: "L2", url: "/x" });
311
+ expect(rt._fetches).toEqual(["/x"]);
312
+ });
313
+
314
+ test("returns a fresh clone per consumer (cache survives consumption)", async () => {
315
+ const rt = fetchingRuntime();
316
+ const orch = new Orchestrator({
317
+ runtime: rt,
318
+ stackId: STACK_ID,
319
+ baseUrl: BASE_URL,
320
+ });
321
+ const seenFragments = [];
322
+ rt.mountLayer = (cmd) => {
323
+ seenFragments.push(cmd.fragment);
324
+ };
325
+ await orch.push({ id: "L1", url: "/x" });
326
+ await orch.pop();
327
+ await orch.push({ id: "L2", url: "/x" });
328
+ expect(seenFragments).toHaveLength(2);
329
+ expect(seenFragments[0]).not.toBe(seenFragments[1]);
330
+ });
331
+
332
+ test("TTL expires the cache and triggers a refetch", async () => {
333
+ const rt = fetchingRuntime();
334
+ const orch = new Orchestrator({
335
+ runtime: rt,
336
+ stackId: STACK_ID,
337
+ baseUrl: BASE_URL,
338
+ prefetchTtlMs: 1,
339
+ });
340
+ await orch.push({ id: "L1", url: "/x" });
341
+ await orch.pop();
342
+ await new Promise((r) => setTimeout(r, 5));
343
+ await orch.push({ id: "L2", url: "/x" });
344
+ expect(rt._fetches).toEqual(["/x", "/x"]);
345
+ });
346
+
347
+ test("prefetch warms the cache without dispatching commands", async () => {
348
+ const rt = fetchingRuntime();
349
+ const orch = new Orchestrator({
350
+ runtime: rt,
351
+ stackId: STACK_ID,
352
+ baseUrl: BASE_URL,
353
+ });
354
+ await orch.prefetch("/x");
355
+ expect(rt._fetches).toEqual(["/x"]);
356
+ expect(rt._calls).toEqual([]);
357
+ // Subsequent push consumes the cache, no second fetch.
358
+ await orch.push({ id: "L1", url: "/x" });
359
+ expect(rt._fetches).toEqual(["/x"]);
360
+ });
361
+
362
+ test("prefetch swallows errors (best-effort)", async () => {
363
+ const rt = fetchingRuntime({ fail: true });
364
+ const orch = new Orchestrator({
365
+ runtime: rt,
366
+ stackId: STACK_ID,
367
+ baseUrl: BASE_URL,
368
+ });
369
+ await expect(orch.prefetch("/boom")).resolves.toBeNull();
370
+ });
371
+
372
+ test("closeAll aborts in-flight prefetches and clears the cache", async () => {
373
+ const rt = fetchingRuntime({ delayMs: 50 });
374
+ const orch = new Orchestrator({
375
+ runtime: rt,
376
+ stackId: STACK_ID,
377
+ baseUrl: BASE_URL,
378
+ });
379
+ // First push completes so the cache has /x.
380
+ await orch.push({ id: "L1", url: "/x" });
381
+ // Second push starts fetching /y and stays in flight.
382
+ const inflight = orch.push({ id: "L2", url: "/y" });
383
+ await new Promise((r) => setTimeout(r, 5));
384
+ await orch.closeAll();
385
+ await expect(inflight).rejects.toThrow(/aborted/);
386
+ expect(rt._aborts).toContain("/y");
387
+ // Cache has been cleared too: re-push of /x must refetch.
388
+ await orch.push({ id: "L3", url: "/x" });
389
+ expect(rt._fetches.filter((u) => u === "/x")).toHaveLength(2);
390
+ });
391
+ });
392
+
227
393
  describe("onPopstate", () => {
228
394
  test("forward navigation requests rebuild from snapshot", async () => {
229
395
  await orchestrator.push({ id: "L1", url: "/x" });
@@ -299,8 +465,8 @@ describe("onPopstate", () => {
299
465
 
300
466
  const types = runtime._calls.map((c) => c.type);
301
467
  expect(types).toEqual([
302
- "unmountAllLayers",
303
468
  "closeDialog",
469
+ "unmountAllLayers",
304
470
  "unlockScroll",
305
471
  "clearSnapshot",
306
472
  ]);
@@ -3,9 +3,15 @@ export const FRAGMENT_HEADER = "X-Modal-Stack-Request";
3
3
  export const SCROLLBAR_WIDTH_VAR = "--modal-stack-scrollbar-width";
4
4
 
5
5
  const LAYER_SELECTOR = '[data-modal-stack-target="layer"]';
6
- // Hard cap: never wait longer than this for an exit transition to fire,
7
- // even if the host CSS forgot to transition the leaving state.
8
- const LEAVE_TIMEOUT_MS = 600;
6
+ // CSS variable host stylesheets set to declare their leave-transition
7
+ // duration (e.g. "220ms"). When present, the runtime sizes its safety
8
+ // timeout from this value; otherwise it falls back to a conservative cap.
9
+ const DURATION_CSS_VAR = "--modal-stack-duration";
10
+ // Floor for the safety timeout — even very short CSS transitions need
11
+ // enough headroom for transitionend to fire on slow devices.
12
+ const LEAVE_TIMEOUT_FLOOR_MS = 300;
13
+ // Used when no CSS variable is exposed (host CSS missing, JSDOM tests).
14
+ const LEAVE_TIMEOUT_FALLBACK_MS = 600;
9
15
 
10
16
  /**
11
17
  * The only file that touches `<dialog>`, `history`, `fetch`, and
@@ -102,12 +108,13 @@ export class BrowserRuntime {
102
108
  async unmountTopLayer() {
103
109
  const layer = this.#topLayer();
104
110
  if (!layer) return;
105
- await animateOut(layer);
111
+ await animateOut(layer, this.#leaveTimeoutMs());
106
112
  }
107
113
 
108
114
  async unmountAllLayers() {
109
115
  const layers = [...this.dialog.querySelectorAll(LAYER_SELECTOR)];
110
- await Promise.all(layers.map(animateOut));
116
+ const timeout = this.#leaveTimeoutMs();
117
+ await Promise.all(layers.map((l) => animateOut(l, timeout)));
111
118
  }
112
119
 
113
120
  pushHistory({ url, historyState }) {
@@ -156,6 +163,34 @@ export class BrowserRuntime {
156
163
  }
157
164
  }
158
165
 
166
+ // Reads --modal-stack-duration from the dialog's computed style and
167
+ // returns 1.5× that as the safety timeout (in ms). Cached after the
168
+ // first successful read since the variable is host-CSS-defined and
169
+ // shouldn't change at runtime. Returns LEAVE_TIMEOUT_FALLBACK_MS when
170
+ // getComputedStyle is unavailable (tests) or the variable is missing.
171
+ #leaveTimeoutMs() {
172
+ if (this._cachedLeaveTimeoutMs != null) return this._cachedLeaveTimeoutMs;
173
+
174
+ const get = globalThis.getComputedStyle;
175
+ if (typeof get !== "function" || !this.dialog?.ownerDocument) {
176
+ return LEAVE_TIMEOUT_FALLBACK_MS;
177
+ }
178
+
179
+ let parsed = NaN;
180
+ try {
181
+ const raw = get(this.dialog).getPropertyValue(DURATION_CSS_VAR);
182
+ parsed = parseDurationMs(raw);
183
+ } catch {
184
+ // getComputedStyle can throw in detached/foreign documents.
185
+ }
186
+
187
+ const ms = Number.isFinite(parsed)
188
+ ? Math.max(Math.ceil(parsed * 1.5), LEAVE_TIMEOUT_FLOOR_MS)
189
+ : LEAVE_TIMEOUT_FALLBACK_MS;
190
+ this._cachedLeaveTimeoutMs = ms;
191
+ return ms;
192
+ }
193
+
159
194
  #findLayer(layerId) {
160
195
  return this.dialog.querySelector(
161
196
  `${LAYER_SELECTOR}[data-layer-id="${escapeAttr(layerId)}"]`,
@@ -193,13 +228,14 @@ export class BrowserRuntime {
193
228
  }
194
229
  }
195
230
 
196
- async fetchFragment(url) {
231
+ async fetchFragment(url, { signal } = {}) {
197
232
  const resp = await this.fetcher(url, {
198
233
  headers: {
199
234
  Accept: "text/html, text/vnd.turbo-stream.html",
200
235
  [FRAGMENT_HEADER]: "1",
201
236
  },
202
237
  credentials: "same-origin",
238
+ signal,
203
239
  });
204
240
  if (!resp.ok) {
205
241
  throw new Error(`modal_stack: fetch ${url} → ${resp.status}`);
@@ -227,7 +263,7 @@ function parseFragment(html, doc) {
227
263
  // out, then awaits transitionend (with a hard timeout) before removing
228
264
  // the element from the DOM. If the host CSS doesn't define an exit
229
265
  // transition, the timeout still fires and the layer is removed cleanly.
230
- function animateOut(layer) {
266
+ function animateOut(layer, timeoutMs = LEAVE_TIMEOUT_FALLBACK_MS) {
231
267
  return new Promise((resolve) => {
232
268
  let done = false;
233
269
  const finish = () => {
@@ -239,10 +275,21 @@ function animateOut(layer) {
239
275
  };
240
276
  layer.addEventListener("transitionend", finish, { once: true });
241
277
  layer.dataset.leaving = "";
242
- setTimeout(finish, LEAVE_TIMEOUT_MS);
278
+ setTimeout(finish, timeoutMs);
243
279
  });
244
280
  }
245
281
 
282
+ // Parses a CSS time token ("220ms", "0.22s", " 220 ms ") to milliseconds.
283
+ // Returns NaN when the input is empty or unparseable so callers can fall back.
284
+ function parseDurationMs(raw) {
285
+ if (typeof raw !== "string") return NaN;
286
+ const value = raw.trim();
287
+ if (!value) return NaN;
288
+ const num = parseFloat(value);
289
+ if (!Number.isFinite(num)) return NaN;
290
+ return /m?s$/i.test(value) && !/ms$/i.test(value) ? num * 1000 : num;
291
+ }
292
+
246
293
  function escapeAttr(value) {
247
294
  if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
248
295
  return CSS.escape(value);
@@ -179,6 +179,74 @@ describe("scroll lock", () => {
179
179
  });
180
180
  });
181
181
 
182
+ describe("leave timeout from CSS variable", () => {
183
+ function rtWithDuration(raw) {
184
+ const ownerDocument = {};
185
+ const dialog = {
186
+ ownerDocument,
187
+ querySelectorAll: () => [],
188
+ };
189
+ const documentRef = { documentElement: {}, body: {} };
190
+ const original = globalThis.getComputedStyle;
191
+ globalThis.getComputedStyle = () => ({
192
+ getPropertyValue: (name) =>
193
+ name === "--modal-stack-duration" ? raw : "",
194
+ });
195
+ const rt = new BrowserRuntime(
196
+ noopRuntimeArgs({ dialog, documentRef }),
197
+ );
198
+ return { rt, restore: () => (globalThis.getComputedStyle = original) };
199
+ }
200
+
201
+ async function trigger(rt) {
202
+ // unmountAllLayers reads the timeout once (querying [] layers, so it
203
+ // resolves immediately) and stashes the result on _cachedLeaveTimeoutMs.
204
+ await rt.unmountAllLayers();
205
+ }
206
+
207
+ test("derives 1.5x of the CSS variable, with a 300ms floor", async () => {
208
+ const { rt, restore } = rtWithDuration("220ms");
209
+ try {
210
+ await trigger(rt);
211
+ // 220ms × 1.5 = 330ms
212
+ expect(rt._cachedLeaveTimeoutMs).toBe(330);
213
+ } finally {
214
+ restore();
215
+ }
216
+ });
217
+
218
+ test("floors the timeout at 300ms for very fast transitions", async () => {
219
+ const { rt, restore } = rtWithDuration("100ms");
220
+ try {
221
+ await trigger(rt);
222
+ expect(rt._cachedLeaveTimeoutMs).toBe(300);
223
+ } finally {
224
+ restore();
225
+ }
226
+ });
227
+
228
+ test("falls back to 600 when the variable is empty", async () => {
229
+ const { rt, restore } = rtWithDuration("");
230
+ try {
231
+ await trigger(rt);
232
+ expect(rt._cachedLeaveTimeoutMs).toBe(600);
233
+ } finally {
234
+ restore();
235
+ }
236
+ });
237
+
238
+ test("supports seconds units (e.g. 0.4s)", async () => {
239
+ const { rt, restore } = rtWithDuration("0.4s");
240
+ try {
241
+ await trigger(rt);
242
+ // 400ms × 1.5 = 600ms
243
+ expect(rt._cachedLeaveTimeoutMs).toBe(600);
244
+ } finally {
245
+ restore();
246
+ }
247
+ });
248
+ });
249
+
182
250
  describe("fetch headers", () => {
183
251
  test("sends Accept and X-Modal-Stack-Request headers", async () => {
184
252
  let captured = null;
@@ -195,15 +195,23 @@ export function pop(state) {
195
195
 
196
196
  const newLayers = Object.freeze(state.layers.slice(0, -1));
197
197
  const newTop = newLayers[newLayers.length - 1] ?? null;
198
- const commands = [
199
- { type: "unmountTopLayer" },
200
- { type: "historyBack", n: 1 },
201
- ];
198
+ const commands = [];
202
199
  if (newTop) {
200
+ commands.push({ type: "unmountTopLayer" });
201
+ commands.push({ type: "historyBack", n: 1 });
203
202
  commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
204
203
  commands.push({ type: "persistSnapshot" });
205
204
  } else {
205
+ // closeDialog first so the dialog's exit transition (opacity +
206
+ // backdrop background + display/overlay allow-discrete) starts
207
+ // immediately and runs in parallel with the layer's [data-leaving]
208
+ // transition. Without this order, the orchestrator awaits 220ms
209
+ // on unmountTopLayer before closing the dialog, then the backdrop
210
+ // fade kicks in for *another* 220ms — visually the backdrop fades
211
+ // after the modal is gone.
206
212
  commands.push({ type: "closeDialog" });
213
+ commands.push({ type: "unmountTopLayer" });
214
+ commands.push({ type: "historyBack", n: 1 });
207
215
  commands.push({ type: "unlockScroll" });
208
216
  commands.push({ type: "clearSnapshot" });
209
217
  }
@@ -276,9 +284,11 @@ export function closeAll(state) {
276
284
  const n = state.layers.length;
277
285
  return {
278
286
  state: { ...state, layers: Object.freeze([]) },
287
+ // closeDialog first so the dialog's exit transition runs in
288
+ // parallel with the layers' [data-leaving] transitions.
279
289
  commands: [
280
- { type: "unmountAllLayers" },
281
290
  { type: "closeDialog" },
291
+ { type: "unmountAllLayers" },
282
292
  { type: "unlockScroll" },
283
293
  { type: "historyBack", n },
284
294
  { type: "clearSnapshot" },
@@ -301,9 +311,10 @@ export function handlePopstate(state, { historyState, locationHref }) {
301
311
  if (state.layers.length === 0) return { state, commands: [] };
302
312
  return {
303
313
  state: { ...state, layers: Object.freeze([]) },
314
+ // closeDialog first — see closeAll() for rationale.
304
315
  commands: [
305
- { type: "unmountAllLayers" },
306
316
  { type: "closeDialog" },
317
+ { type: "unmountAllLayers" },
307
318
  { type: "unlockScroll" },
308
319
  { type: "clearSnapshot" },
309
320
  ],
@@ -318,6 +329,10 @@ export function handlePopstate(state, { historyState, locationHref }) {
318
329
  const newLayers = Object.freeze(state.layers.slice(0, targetDepth));
319
330
  const newTop = newLayers[newLayers.length - 1] ?? null;
320
331
  const commands = [];
332
+ // When popping back to the root via popstate, fire closeDialog
333
+ // first so the dialog's exit transition runs alongside the
334
+ // sequential unmountTopLayer cascade.
335
+ if (!newTop) commands.push({ type: "closeDialog" });
321
336
  for (let i = 0; i < currentDepth - targetDepth; i++) {
322
337
  commands.push({ type: "unmountTopLayer" });
323
338
  }
@@ -325,7 +340,6 @@ export function handlePopstate(state, { historyState, locationHref }) {
325
340
  commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
326
341
  commands.push({ type: "persistSnapshot" });
327
342
  } else {
328
- commands.push({ type: "closeDialog" });
329
343
  commands.push({ type: "unlockScroll" });
330
344
  commands.push({ type: "clearSnapshot" });
331
345
  }
@@ -306,10 +306,12 @@ describe("pop", () => {
306
306
  const first = pushed(freshStack()).state;
307
307
  const { state, commands } = pop(first);
308
308
  expect(state.layers).toEqual([]);
309
+ // closeDialog comes first so its exit transition runs in parallel
310
+ // with the layer's [data-leaving] transition.
309
311
  expect(commands).toEqual([
312
+ { type: "closeDialog" },
310
313
  { type: "unmountTopLayer" },
311
314
  { type: "historyBack", n: 1 },
312
- { type: "closeDialog" },
313
315
  { type: "unlockScroll" },
314
316
  { type: "clearSnapshot" },
315
317
  ]);
@@ -423,8 +425,8 @@ describe("closeAll", () => {
423
425
  const { state, commands } = closeAll(s);
424
426
  expect(state.layers).toEqual([]);
425
427
  expect(commands).toEqual([
426
- { type: "unmountAllLayers" },
427
428
  { type: "closeDialog" },
429
+ { type: "unmountAllLayers" },
428
430
  { type: "unlockScroll" },
429
431
  { type: "historyBack", n: 3 },
430
432
  { type: "clearSnapshot" },
@@ -447,8 +449,8 @@ describe("handlePopstate", () => {
447
449
  });
448
450
  expect(state.layers).toEqual([]);
449
451
  expect(commands).toEqual([
450
- { type: "unmountAllLayers" },
451
452
  { type: "closeDialog" },
453
+ { type: "unmountAllLayers" },
452
454
  { type: "unlockScroll" },
453
455
  { type: "clearSnapshot" },
454
456
  ]);
@@ -485,9 +487,9 @@ describe("handlePopstate", () => {
485
487
  });
486
488
  expect(state.layers).toEqual([]);
487
489
  expect(commands).toEqual([
490
+ { type: "closeDialog" },
488
491
  { type: "unmountTopLayer" },
489
492
  { type: "unmountTopLayer" },
490
- { type: "closeDialog" },
491
493
  { type: "unlockScroll" },
492
494
  { type: "clearSnapshot" },
493
495
  ]);
@@ -9,11 +9,16 @@ module ModalStack
9
9
  source_root File.expand_path("templates", __dir__)
10
10
 
11
11
  ASSETS_MODES = ModalStack::Configuration::ASSETS_MODES.map(&:to_s).freeze
12
- CSS_PROVIDERS = ModalStack::Configuration::CSS_PROVIDERS.map(&:to_s).freeze
12
+ # The CLI accepts the canonical providers plus the legacy `tailwind`
13
+ # alias (normalized to `tailwind_v3` by Configuration). New installs
14
+ # default to `tailwind_v4` — Tailwind v4 is the modern default and
15
+ # the preset's fallbacks make it safe even without v4 installed.
16
+ CSS_PROVIDERS = (ModalStack::Configuration::CSS_PROVIDERS +
17
+ ModalStack::Configuration::CSS_PROVIDER_ALIASES.keys).map(&:to_s).freeze
13
18
 
14
19
  class_option :mode, type: :string, default: "auto", enum: ASSETS_MODES,
15
20
  desc: "JS asset strategy"
16
- class_option :css_provider, type: :string, default: "tailwind",
21
+ class_option :css_provider, type: :string, default: "tailwind_v4",
17
22
  enum: CSS_PROVIDERS,
18
23
  desc: "CSS preset bundled with the install"
19
24
  class_option :skip_layout, type: :boolean, default: false,
@@ -55,7 +60,7 @@ module ModalStack
55
60
  modal_stack installed.
56
61
 
57
62
  Mode: #{resolved_mode}
58
- CSS provider: #{options[:css_provider]}
63
+ CSS provider: #{resolved_css_provider}
59
64
 
60
65
  Next steps:
61
66
  1. Confirm config/initializers/modal_stack.rb matches your needs.
@@ -82,6 +87,15 @@ module ModalStack
82
87
  @resolved_mode ||= detect_mode
83
88
  end
84
89
 
90
+ # Normalize the legacy `tailwind` alias to the canonical `tailwind_v3`
91
+ # string so the initializer file and sprockets manifest line both
92
+ # reference a stylesheet that actually exists in the gem.
93
+ def resolved_css_provider
94
+ provider = options[:css_provider].to_s
95
+ aliased = ModalStack::Configuration::CSS_PROVIDER_ALIASES[provider.to_sym]
96
+ aliased ? aliased.to_s : provider
97
+ end
98
+
85
99
  def detect_mode
86
100
  mode = options[:mode].to_s
87
101
  return mode unless mode == "auto"
@@ -146,7 +160,7 @@ module ModalStack
146
160
  manifest = "app/assets/config/manifest.js"
147
161
  if file_exists?(manifest)
148
162
  append_unique manifest, "//= link modal_stack.js"
149
- append_unique manifest, "//= link modal_stack/#{options[:css_provider]}.css" unless options[:css_provider] == "none"
163
+ append_unique manifest, "//= link modal_stack/#{resolved_css_provider}.css" unless resolved_css_provider == "none"
150
164
  else
151
165
  say_status :warn, "#{manifest} not found; add `//= link modal_stack.js` manually", :yellow
152
166
  end
@@ -11,11 +11,18 @@ ModalStack.configure do |config|
11
11
  # CSS provider. Determines which stylesheet
12
12
  # `modal_stack_stylesheet_link_tag` resolves to.
13
13
  #
14
- # :tailwind — Tailwind-aligned tokens (default)
15
- # :bootstrap — picks up Bootstrap 5 CSS variables
16
- # :vanilla — neutral defaults, framework-free
17
- # :none — emit no <link>; provide your own CSS
18
- config.css_provider = :<%= options[:css_provider] %>
14
+ # :tailwind_v4 Chains on Tailwind v4 @theme tokens (--color-*,
15
+ # --radius-*, --shadow-*, --container-*). Default for
16
+ # new installs. Falls back to Tailwind defaults when
17
+ # @theme isn't redefined, so it's safe even without v4.
18
+ # :tailwind_v3 Static values aligned with Tailwind v3 defaults
19
+ # (Tailwind v3 doesn't expose tokens as CSS variables).
20
+ # `:tailwind` is accepted as an alias for backwards
21
+ # compatibility.
22
+ # :bootstrap — Picks up Bootstrap 5 CSS variables.
23
+ # :vanilla — Neutral defaults, framework-free.
24
+ # :none — Emit no <link>; provide your own CSS.
25
+ config.css_provider = :<%= resolved_css_provider %>
19
26
 
20
27
  # JS asset strategy used by the install generator and by the
21
28
  # `modal_stack_javascript_tag` helper.
@@ -11,7 +11,11 @@ module ModalStack
11
11
  # end
12
12
  #
13
13
  class Configuration
14
- CSS_PROVIDERS = %i[tailwind bootstrap vanilla none].freeze
14
+ CSS_PROVIDERS = %i[tailwind_v3 tailwind_v4 bootstrap vanilla none].freeze
15
+ # Aliases accepted on input, normalized to a canonical CSS_PROVIDERS value.
16
+ # `:tailwind` predates the v3/v4 split — keep it working, map to v3 (no change
17
+ # in rendered CSS for existing apps).
18
+ CSS_PROVIDER_ALIASES = { tailwind: :tailwind_v3 }.freeze
15
19
  ASSETS_MODES = %i[importmap jsbundling sprockets auto].freeze
16
20
  VARIANTS = %i[modal drawer bottom_sheet confirmation].freeze
17
21
  SIZES = %i[sm md lg xl].freeze
@@ -36,7 +40,7 @@ module ModalStack
36
40
  :max_depth_strategy
37
41
 
38
42
  def initialize
39
- @css_provider = :tailwind
43
+ @css_provider = :tailwind_v3
40
44
  @assets_mode = :auto
41
45
  @default_variant = :modal
42
46
  @default_size = :md
@@ -56,6 +60,7 @@ module ModalStack
56
60
 
57
61
  def css_provider=(value)
58
62
  value = value.to_sym
63
+ value = CSS_PROVIDER_ALIASES.fetch(value, value)
59
64
  raise ArgumentError, "css_provider must be one of #{CSS_PROVIDERS.inspect}, got #{value.inspect}" unless CSS_PROVIDERS.include?(value)
60
65
 
61
66
  @css_provider = value
@@ -5,7 +5,7 @@ module ModalStack
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  included do
8
- helper_method :modal_stack_request?
8
+ helper_method :modal_stack_request?, :modal_stack_config
9
9
  end
10
10
 
11
11
  class_methods do
@@ -39,6 +39,13 @@ module ModalStack
39
39
  end
40
40
  end
41
41
 
42
+ # Request-scoped accessor for `ModalStack.configuration`. Memoized so
43
+ # helpers that read several config values per render hit the global
44
+ # singleton once instead of N times.
45
+ def modal_stack_config
46
+ @modal_stack_config ||= ModalStack.configuration
47
+ end
48
+
42
49
  # True when the current request was issued by the modal_stack JS runtime
43
50
  # (signaled by the X-Modal-Stack-Request header on the fetch).
44
51
  def modal_stack_request?
@@ -11,7 +11,7 @@ module ModalStack
11
11
  # Returns an empty SafeBuffer when `config.css_provider = :none`,
12
12
  # so apps can call this unconditionally.
13
13
  def modal_stack_stylesheet_link_tag(**)
14
- provider = ModalStack.configuration.css_provider
14
+ provider = _modal_stack_config.css_provider
15
15
  return ActiveSupport::SafeBuffer.new if provider == :none
16
16
 
17
17
  stylesheet_link_tag("modal_stack/#{provider}", **)
@@ -23,7 +23,7 @@ module ModalStack
23
23
  # <%= modal_stack_dialog_tag %>
24
24
  #
25
25
  def modal_stack_dialog_tag(**html_options)
26
- config = ModalStack.configuration
26
+ config = _modal_stack_config
27
27
  attrs = html_options.dup
28
28
  attrs[:id] ||= config.dialog_id
29
29
  attrs[:data] = build_dialog_data(attrs[:data], config)
@@ -48,6 +48,18 @@ module ModalStack
48
48
  def modal_stack_javascript_tag(**)
49
49
  ActiveSupport::SafeBuffer.new
50
50
  end
51
+
52
+ private
53
+
54
+ # Prefers the request-scoped accessor injected by ControllerExtensions
55
+ # so we hit `ModalStack.configuration` once per request, not per call.
56
+ # Falls back to the global singleton for non-controller render contexts
57
+ # (mailers, ActionCable, isolated view tests).
58
+ def _modal_stack_config
59
+ return modal_stack_config if respond_to?(:modal_stack_config, true)
60
+
61
+ ModalStack.configuration
62
+ end
51
63
  end
52
64
  end
53
65
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ModalStack
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end