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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -0
- data/README.md +73 -26
- data/app/assets/javascripts/modal_stack.js +230 -41
- 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 +52 -13
- data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +29 -7
- data/app/javascript/modal_stack/orchestrator.js +136 -4
- data/app/javascript/modal_stack/orchestrator.test.js +218 -2
- data/app/javascript/modal_stack/runtime.js +91 -10
- data/app/javascript/modal_stack/runtime.test.js +138 -1
- data/app/javascript/modal_stack/state.js +142 -8
- data/app/javascript/modal_stack/state.test.js +89 -5
- data/lib/generators/modal_stack/install/install_generator.rb +18 -4
- data/lib/generators/modal_stack/install/templates/initializer.rb +19 -6
- data/lib/modal_stack/configuration.rb +44 -5
- data/lib/modal_stack/controller_extensions.rb +8 -1
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +26 -6
- data/lib/modal_stack/version.rb +1 -1
- data/lib/modal_stack.rb +1 -1
- metadata +4 -3
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
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: #{
|
|
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/#{
|
|
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
|
-
# :
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
|
|
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
|
|
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
|