modal_stack 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +23 -20
- data/app/assets/javascripts/modal_stack.js +131 -20
- data/app/assets/stylesheets/modal_stack/bootstrap.css +7 -8
- data/app/assets/stylesheets/modal_stack/{tailwind.css → tailwind_v3.css} +19 -12
- data/app/assets/stylesheets/modal_stack/tailwind_v4.css +311 -0
- data/app/assets/stylesheets/modal_stack/vanilla.css +7 -8
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +4 -0
- data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +29 -7
- data/app/javascript/modal_stack/orchestrator.js +84 -1
- data/app/javascript/modal_stack/orchestrator.test.js +168 -2
- data/app/javascript/modal_stack/runtime.js +55 -8
- data/app/javascript/modal_stack/runtime.test.js +68 -0
- data/app/javascript/modal_stack/state.js +21 -7
- data/app/javascript/modal_stack/state.test.js +6 -4
- data/lib/generators/modal_stack/install/install_generator.rb +18 -4
- data/lib/generators/modal_stack/install/templates/initializer.rb +12 -5
- data/lib/modal_stack/configuration.rb +7 -2
- data/lib/modal_stack/controller_extensions.rb +8 -1
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +14 -2
- data/lib/modal_stack/version.rb +1 -1
- data/lib/modal_stack.rb +1 -1
- metadata +4 -3
|
@@ -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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
@@ -11,7 +11,11 @@ module ModalStack
|
|
|
11
11
|
# end
|
|
12
12
|
#
|
|
13
13
|
class Configuration
|
|
14
|
-
CSS_PROVIDERS = %i[
|
|
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 = :
|
|
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 =
|
|
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 =
|
|
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
|
data/lib/modal_stack/version.rb
CHANGED