modal_stack 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { BrowserRuntime, FRAGMENT_HEADER, SNAPSHOT_KEY } from "./runtime.js";
2
+ import {
3
+ BrowserRuntime,
4
+ FRAGMENT_HEADER,
5
+ SCROLLBAR_WIDTH_VAR,
6
+ SNAPSHOT_KEY,
7
+ } from "./runtime.js";
3
8
 
4
9
  function fakeStore() {
5
10
  const map = new Map();
@@ -110,6 +115,138 @@ describe("history wiring", () => {
110
115
  });
111
116
  });
112
117
 
118
+ describe("scroll lock", () => {
119
+ function fakeStyle() {
120
+ const props = new Map();
121
+ return {
122
+ props,
123
+ setProperty: (k, v) => props.set(k, v),
124
+ removeProperty: (k) => props.delete(k),
125
+ };
126
+ }
127
+
128
+ function fakeRoot({ clientWidth = 1000 } = {}) {
129
+ return { clientWidth, style: fakeStyle() };
130
+ }
131
+
132
+ test("lockScroll sets scrollbar-width var from window/root delta", () => {
133
+ const root = fakeRoot({ clientWidth: 985 });
134
+ const body = { dataset: {} };
135
+ const documentRef = { documentElement: root, body };
136
+ const rt = new BrowserRuntime(
137
+ noopRuntimeArgs({ documentRef, body }),
138
+ );
139
+ // Bun's globalThis.innerWidth is 0 by default — set it for the duration.
140
+ const original = globalThis.innerWidth;
141
+ globalThis.innerWidth = 1000;
142
+ try {
143
+ rt.lockScroll();
144
+ } finally {
145
+ globalThis.innerWidth = original;
146
+ }
147
+ expect(root.style.props.get(SCROLLBAR_WIDTH_VAR)).toBe("15px");
148
+ expect("modalStackLocked" in body.dataset).toBe(true);
149
+ });
150
+
151
+ test("unlockScroll clears the css variable", () => {
152
+ const root = fakeRoot();
153
+ root.style.props.set(SCROLLBAR_WIDTH_VAR, "15px");
154
+ const body = { dataset: { modalStackLocked: "" } };
155
+ const documentRef = { documentElement: root, body };
156
+ const rt = new BrowserRuntime(
157
+ noopRuntimeArgs({ documentRef, body }),
158
+ );
159
+ rt.unlockScroll();
160
+ expect(root.style.props.has(SCROLLBAR_WIDTH_VAR)).toBe(false);
161
+ expect("modalStackLocked" in body.dataset).toBe(false);
162
+ });
163
+
164
+ test("lockScroll never goes negative when there's no scrollbar", () => {
165
+ const root = fakeRoot({ clientWidth: 1000 });
166
+ const body = { dataset: {} };
167
+ const documentRef = { documentElement: root, body };
168
+ const rt = new BrowserRuntime(
169
+ noopRuntimeArgs({ documentRef, body }),
170
+ );
171
+ const original = globalThis.innerWidth;
172
+ globalThis.innerWidth = 800; // narrower than clientWidth
173
+ try {
174
+ rt.lockScroll();
175
+ } finally {
176
+ globalThis.innerWidth = original;
177
+ }
178
+ expect(root.style.props.get(SCROLLBAR_WIDTH_VAR)).toBe("0px");
179
+ });
180
+ });
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
+
113
250
  describe("fetch headers", () => {
114
251
  test("sends Accept and X-Modal-Stack-Request headers", async () => {
115
252
  let captured = null;
@@ -1,3 +1,27 @@
1
+ /**
2
+ * @typedef {"modal" | "drawer" | "bottom_sheet" | "confirmation"} Variant
3
+ * @typedef {"left" | "right" | "top" | "bottom"} DrawerSide
4
+ * @typedef {"sm" | "md" | "lg" | "xl"} Size
5
+ *
6
+ * @typedef {Object} Layer
7
+ * @property {string} id Stable layer identifier (used for inertness + DOM lookup)
8
+ * @property {string} url Layer URL — also written to history
9
+ * @property {Variant} variant
10
+ * @property {boolean} dismissible
11
+ * @property {Size|null} size
12
+ * @property {DrawerSide|null} side Required for drawers; null otherwise
13
+ * @property {string|null} width Free-form CSS width (e.g. "42rem")
14
+ * @property {string|null} height
15
+ *
16
+ * @typedef {Object} Stack
17
+ * @property {string} stackId
18
+ * @property {string} baseUrl
19
+ * @property {readonly Layer[]} layers
20
+ *
21
+ * @typedef {{ type: string } & Record<string, unknown>} Command
22
+ * @typedef {{ state: Stack, commands: readonly Command[] }} Transition
23
+ */
24
+
1
25
  export const VARIANTS = Object.freeze([
2
26
  "modal",
3
27
  "drawer",
@@ -8,6 +32,25 @@ export const VARIANTS = Object.freeze([
8
32
  const SNAPSHOT_VERSION = 1;
9
33
  const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000;
10
34
  const DRAWER_SIDES = Object.freeze(["left", "right", "top", "bottom"]);
35
+ const MAX_DEPTH_STRATEGIES = Object.freeze(["raise", "warn", "silent"]);
36
+
37
+ /**
38
+ * Thrown by `push()` when `maxDepth` is exceeded under the `"raise"` strategy.
39
+ * Caught upstream by the orchestrator's stream-action error boundary so the
40
+ * page doesn't blow up — but applications can also catch it directly when
41
+ * calling `orchestrator.push()` programmatically.
42
+ */
43
+ export class ModalStackDepthError extends Error {
44
+ constructor({ maxDepth, attemptedDepth }) {
45
+ super(
46
+ `modal_stack: cannot push past max_depth=${maxDepth} ` +
47
+ `(attempted depth=${attemptedDepth})`,
48
+ );
49
+ this.name = "ModalStackDepthError";
50
+ this.maxDepth = maxDepth;
51
+ this.attemptedDepth = attemptedDepth;
52
+ }
53
+ }
11
54
 
12
55
  function normalizeLayerOptions({ variant, size, side, width, height }) {
13
56
  // A drawer must always carry a side so CSS can position it.
@@ -37,17 +80,34 @@ function freezeLayer({ id, url, variant, dismissible, size, side, width, height
37
80
  });
38
81
  }
39
82
 
83
+ /**
84
+ * Build an empty, frozen stack.
85
+ * @param {{ stackId: string, baseUrl: string }} options
86
+ * @returns {Stack}
87
+ */
40
88
  export function createStack({ stackId, baseUrl }) {
41
89
  if (!stackId) throw new Error("stackId required");
42
90
  if (!baseUrl) throw new Error("baseUrl required");
43
91
  return Object.freeze({ stackId, baseUrl, layers: Object.freeze([]) });
44
92
  }
45
93
 
94
+ /**
95
+ * @param {Stack} state
96
+ * @returns {Layer|null}
97
+ */
46
98
  export function topLayer(state) {
47
99
  return state.layers[state.layers.length - 1] ?? null;
48
100
  }
49
101
 
50
- export function push(state, layer) {
102
+ /**
103
+ * Push a new layer on top of the stack.
104
+ *
105
+ * @param {Stack} state
106
+ * @param {Partial<Layer> & { id: string, url: string }} layer
107
+ * @param {{ maxDepth?: number|null, maxDepthStrategy?: "raise"|"warn"|"silent" }} [options]
108
+ * @returns {Transition}
109
+ */
110
+ export function push(state, layer, options = {}) {
51
111
  if (!layer?.id) throw new Error("layer.id required");
52
112
  if (!layer?.url) throw new Error("layer.url required");
53
113
  const variant = layer.variant ?? "modal";
@@ -55,6 +115,29 @@ export function push(state, layer) {
55
115
  throw new Error(`unknown variant: ${variant}`);
56
116
  }
57
117
 
118
+ const { maxDepth = null, maxDepthStrategy = "warn" } = options;
119
+ if (maxDepth != null && state.layers.length >= maxDepth) {
120
+ if (!MAX_DEPTH_STRATEGIES.includes(maxDepthStrategy)) {
121
+ throw new Error(
122
+ `unknown maxDepthStrategy: ${maxDepthStrategy} (expected one of ${MAX_DEPTH_STRATEGIES.join(", ")})`,
123
+ );
124
+ }
125
+ if (maxDepthStrategy === "raise") {
126
+ throw new ModalStackDepthError({
127
+ maxDepth,
128
+ attemptedDepth: state.layers.length + 1,
129
+ });
130
+ }
131
+ if (maxDepthStrategy === "warn" && typeof console !== "undefined") {
132
+ console.warn(
133
+ `[modal_stack] push ignored: stack is at max_depth=${maxDepth}. ` +
134
+ `Set ModalStack.configuration.max_depth higher, or use ` +
135
+ `max_depth_strategy = :silent to suppress this warning.`,
136
+ );
137
+ }
138
+ return { state, commands: [] };
139
+ }
140
+
58
141
  const newLayer = freezeLayer({
59
142
  id: layer.id,
60
143
  url: layer.url,
@@ -102,26 +185,46 @@ export function push(state, layer) {
102
185
  return { state: { ...state, layers }, commands };
103
186
  }
104
187
 
188
+ /**
189
+ * Pop the top layer. No-op when the stack is empty.
190
+ * @param {Stack} state
191
+ * @returns {Transition}
192
+ */
105
193
  export function pop(state) {
106
194
  if (state.layers.length === 0) return { state, commands: [] };
107
195
 
108
196
  const newLayers = Object.freeze(state.layers.slice(0, -1));
109
197
  const newTop = newLayers[newLayers.length - 1] ?? null;
110
- const commands = [
111
- { type: "unmountTopLayer" },
112
- { type: "historyBack", n: 1 },
113
- ];
198
+ const commands = [];
114
199
  if (newTop) {
200
+ commands.push({ type: "unmountTopLayer" });
201
+ commands.push({ type: "historyBack", n: 1 });
115
202
  commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
116
203
  commands.push({ type: "persistSnapshot" });
117
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.
118
212
  commands.push({ type: "closeDialog" });
213
+ commands.push({ type: "unmountTopLayer" });
214
+ commands.push({ type: "historyBack", n: 1 });
119
215
  commands.push({ type: "unlockScroll" });
120
216
  commands.push({ type: "clearSnapshot" });
121
217
  }
122
218
  return { state: { ...state, layers: newLayers }, commands };
123
219
  }
124
220
 
221
+ /**
222
+ * Replace (morph) the top layer in-place.
223
+ * @param {Stack} state
224
+ * @param {Partial<Layer>} patch
225
+ * @param {{ historyMode?: "push"|"replace" }} [options]
226
+ * @returns {Transition}
227
+ */
125
228
  export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
126
229
  if (state.layers.length === 0) {
127
230
  throw new Error("replaceTop requires at least one layer");
@@ -171,14 +274,21 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
171
274
  };
172
275
  }
173
276
 
277
+ /**
278
+ * Close every layer at once.
279
+ * @param {Stack} state
280
+ * @returns {Transition}
281
+ */
174
282
  export function closeAll(state) {
175
283
  if (state.layers.length === 0) return { state, commands: [] };
176
284
  const n = state.layers.length;
177
285
  return {
178
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.
179
289
  commands: [
180
- { type: "unmountAllLayers" },
181
290
  { type: "closeDialog" },
291
+ { type: "unmountAllLayers" },
182
292
  { type: "unlockScroll" },
183
293
  { type: "historyBack", n },
184
294
  { type: "clearSnapshot" },
@@ -186,6 +296,13 @@ export function closeAll(state) {
186
296
  };
187
297
  }
188
298
 
299
+ /**
300
+ * Reduce a browser `popstate` into a transition: pop layers, morph the top,
301
+ * or request a rebuild from snapshot for forward navigation.
302
+ * @param {Stack} state
303
+ * @param {{ historyState: any, locationHref: string }} options
304
+ * @returns {Transition}
305
+ */
189
306
  export function handlePopstate(state, { historyState, locationHref }) {
190
307
  const isOurs =
191
308
  historyState && historyState.stackId === state.stackId;
@@ -194,9 +311,10 @@ export function handlePopstate(state, { historyState, locationHref }) {
194
311
  if (state.layers.length === 0) return { state, commands: [] };
195
312
  return {
196
313
  state: { ...state, layers: Object.freeze([]) },
314
+ // closeDialog first — see closeAll() for rationale.
197
315
  commands: [
198
- { type: "unmountAllLayers" },
199
316
  { type: "closeDialog" },
317
+ { type: "unmountAllLayers" },
200
318
  { type: "unlockScroll" },
201
319
  { type: "clearSnapshot" },
202
320
  ],
@@ -211,6 +329,10 @@ export function handlePopstate(state, { historyState, locationHref }) {
211
329
  const newLayers = Object.freeze(state.layers.slice(0, targetDepth));
212
330
  const newTop = newLayers[newLayers.length - 1] ?? null;
213
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" });
214
336
  for (let i = 0; i < currentDepth - targetDepth; i++) {
215
337
  commands.push({ type: "unmountTopLayer" });
216
338
  }
@@ -218,7 +340,6 @@ export function handlePopstate(state, { historyState, locationHref }) {
218
340
  commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
219
341
  commands.push({ type: "persistSnapshot" });
220
342
  } else {
221
- commands.push({ type: "closeDialog" });
222
343
  commands.push({ type: "unlockScroll" });
223
344
  commands.push({ type: "clearSnapshot" });
224
345
  }
@@ -273,6 +394,12 @@ export function handlePopstate(state, { historyState, locationHref }) {
273
394
  return { state, commands: [] };
274
395
  }
275
396
 
397
+ /**
398
+ * Serialize the stack for sessionStorage. Versioned + timestamped.
399
+ * @param {Stack} state
400
+ * @param {{ now?: () => number }} [options]
401
+ * @returns {string}
402
+ */
276
403
  export function snapshot(state, { now = Date.now } = {}) {
277
404
  return JSON.stringify({
278
405
  v: SNAPSHOT_VERSION,
@@ -283,6 +410,13 @@ export function snapshot(state, { now = Date.now } = {}) {
283
410
  });
284
411
  }
285
412
 
413
+ /**
414
+ * Restore a stack from a serialized snapshot. Returns null on any validation
415
+ * failure (wrong stackId, expired, malformed JSON, etc.).
416
+ * @param {string} serialized
417
+ * @param {{ stackId?: string, maxAgeMs?: number, now?: () => number }} [options]
418
+ * @returns {Stack|null}
419
+ */
286
420
  export function restore(
287
421
  serialized,
288
422
  { stackId, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Date.now } = {},
@@ -1,8 +1,9 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import {
3
3
  closeAll,
4
4
  createStack,
5
5
  handlePopstate,
6
+ ModalStackDepthError,
6
7
  pop,
7
8
  push,
8
9
  replaceTop,
@@ -156,6 +157,87 @@ describe("push", () => {
156
157
  ).toThrow(/unknown drawer side/);
157
158
  });
158
159
 
160
+ describe("max_depth", () => {
161
+ let warnings = [];
162
+ const originalWarn = console.warn;
163
+
164
+ beforeEach(() => {
165
+ warnings = [];
166
+ console.warn = (...args) => warnings.push(args.join(" "));
167
+ });
168
+
169
+ afterEach(() => {
170
+ console.warn = originalWarn;
171
+ });
172
+
173
+ function stackOfDepth(n) {
174
+ let s = freshStack();
175
+ for (let i = 0; i < n; i++) {
176
+ s = push(s, { id: `L${i}`, url: `/p/${i}`, variant: "modal" }).state;
177
+ }
178
+ return s;
179
+ }
180
+
181
+ test("no cap when maxDepth is null (default)", () => {
182
+ const s = stackOfDepth(10);
183
+ const { state, commands } = push(s, { id: "L10", url: "/p/10", variant: "modal" });
184
+ expect(state.layers).toHaveLength(11);
185
+ expect(commands.length).toBeGreaterThan(0);
186
+ });
187
+
188
+ test("strategy 'warn' drops the push and logs", () => {
189
+ const s = stackOfDepth(3);
190
+ const { state, commands } = push(
191
+ s,
192
+ { id: "Lx", url: "/x", variant: "modal" },
193
+ { maxDepth: 3, maxDepthStrategy: "warn" },
194
+ );
195
+ expect(state).toBe(s);
196
+ expect(commands).toEqual([]);
197
+ expect(warnings.join("\n")).toMatch(/max_depth=3/);
198
+ });
199
+
200
+ test("strategy 'silent' drops the push without warning", () => {
201
+ const s = stackOfDepth(3);
202
+ const { state, commands } = push(
203
+ s,
204
+ { id: "Lx", url: "/x", variant: "modal" },
205
+ { maxDepth: 3, maxDepthStrategy: "silent" },
206
+ );
207
+ expect(state).toBe(s);
208
+ expect(commands).toEqual([]);
209
+ expect(warnings).toEqual([]);
210
+ });
211
+
212
+ test("strategy 'raise' throws ModalStackDepthError", () => {
213
+ const s = stackOfDepth(3);
214
+ let caught = null;
215
+ try {
216
+ push(
217
+ s,
218
+ { id: "Lx", url: "/x", variant: "modal" },
219
+ { maxDepth: 3, maxDepthStrategy: "raise" },
220
+ );
221
+ } catch (e) {
222
+ caught = e;
223
+ }
224
+ expect(caught).toBeInstanceOf(ModalStackDepthError);
225
+ expect(caught.maxDepth).toBe(3);
226
+ expect(caught.attemptedDepth).toBe(4);
227
+ });
228
+
229
+ test("rejects unknown strategy", () => {
230
+ const s = stackOfDepth(3);
231
+ expect(() =>
232
+ push(
233
+ s,
234
+ { id: "Lx", url: "/x", variant: "modal" },
235
+ { maxDepth: 3, maxDepthStrategy: "explode" },
236
+ ),
237
+ ).toThrow(/unknown maxDepthStrategy/);
238
+ });
239
+ });
240
+
159
241
  test("passes custom width and height to mount command", () => {
160
242
  const { state, commands } = push(freshStack(), {
161
243
  id: "L1",
@@ -224,10 +306,12 @@ describe("pop", () => {
224
306
  const first = pushed(freshStack()).state;
225
307
  const { state, commands } = pop(first);
226
308
  expect(state.layers).toEqual([]);
309
+ // closeDialog comes first so its exit transition runs in parallel
310
+ // with the layer's [data-leaving] transition.
227
311
  expect(commands).toEqual([
312
+ { type: "closeDialog" },
228
313
  { type: "unmountTopLayer" },
229
314
  { type: "historyBack", n: 1 },
230
- { type: "closeDialog" },
231
315
  { type: "unlockScroll" },
232
316
  { type: "clearSnapshot" },
233
317
  ]);
@@ -341,8 +425,8 @@ describe("closeAll", () => {
341
425
  const { state, commands } = closeAll(s);
342
426
  expect(state.layers).toEqual([]);
343
427
  expect(commands).toEqual([
344
- { type: "unmountAllLayers" },
345
428
  { type: "closeDialog" },
429
+ { type: "unmountAllLayers" },
346
430
  { type: "unlockScroll" },
347
431
  { type: "historyBack", n: 3 },
348
432
  { type: "clearSnapshot" },
@@ -365,8 +449,8 @@ describe("handlePopstate", () => {
365
449
  });
366
450
  expect(state.layers).toEqual([]);
367
451
  expect(commands).toEqual([
368
- { type: "unmountAllLayers" },
369
452
  { type: "closeDialog" },
453
+ { type: "unmountAllLayers" },
370
454
  { type: "unlockScroll" },
371
455
  { type: "clearSnapshot" },
372
456
  ]);
@@ -403,9 +487,9 @@ describe("handlePopstate", () => {
403
487
  });
404
488
  expect(state.layers).toEqual([]);
405
489
  expect(commands).toEqual([
490
+ { type: "closeDialog" },
406
491
  { type: "unmountTopLayer" },
407
492
  { type: "unmountTopLayer" },
408
- { type: "closeDialog" },
409
493
  { type: "unlockScroll" },
410
494
  { type: "clearSnapshot" },
411
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.
@@ -40,9 +47,15 @@ ModalStack.configure do |config|
40
47
  # layout to "modal" — read by `modal_stack_request?`.
41
48
  config.request_header = "X-Modal-Stack-Request"
42
49
 
43
- # Hard cap on stack depth (push past this is a runtime error).
50
+ # Hard cap on stack depth. Set to `nil` to disable.
44
51
  config.max_depth = 5
45
52
 
53
+ # What to do when a push would exceed `max_depth`:
54
+ # :warn — log a console warning, drop the push (default)
55
+ # :raise — throw `ModalStackDepthError` from the JS runtime
56
+ # :silent — drop the push, no warning
57
+ config.max_depth_strategy = :warn
58
+
46
59
  # Replace `data-turbo-confirm` window.confirm with a modal_stack
47
60
  # confirmation layer (cf. RFC §15.Q7). Off by default — opt-in.
48
61
  config.replace_turbo_confirm = false