modal_stack 0.2.0 → 0.4.1
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 +59 -0
- data/README.md +136 -52
- data/app/assets/javascripts/modal_stack.js +612 -63
- data/app/assets/stylesheets/modal_stack/bootstrap.css +120 -11
- data/app/assets/stylesheets/modal_stack/{tailwind.css → tailwind_v3.css} +82 -14
- data/app/assets/stylesheets/modal_stack/tailwind_v4.css +372 -0
- data/app/assets/stylesheets/modal_stack/vanilla.css +128 -11
- data/app/javascript/modal_stack/controllers/modal_stack_back_link_controller.js +32 -0
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +54 -0
- data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +29 -7
- data/app/javascript/modal_stack/install.js +7 -1
- data/app/javascript/modal_stack/orchestrator.js +132 -3
- data/app/javascript/modal_stack/orchestrator.test.js +264 -2
- data/app/javascript/modal_stack/runtime.js +222 -13
- data/app/javascript/modal_stack/runtime.test.js +151 -0
- data/app/javascript/modal_stack/state.js +338 -39
- data/app/javascript/modal_stack/state.test.js +400 -13
- data/app/views/modal_stack/_dialog.html.erb +1 -0
- data/app/views/modal_stack/_panel.html.erb +4 -0
- data/lib/generators/modal_stack/install/install_generator.rb +18 -4
- data/lib/generators/modal_stack/install/templates/initializer.rb +21 -5
- data/lib/generators/modal_stack/views/templates/_dialog.html.erb +9 -0
- data/lib/generators/modal_stack/views/templates/_panel.html.erb +18 -0
- data/lib/generators/modal_stack/views/views_generator.rb +50 -0
- data/lib/modal_stack/capybara.rb +21 -0
- data/lib/modal_stack/configuration.rb +43 -17
- data/lib/modal_stack/controller_extensions.rb +8 -1
- data/lib/modal_stack/engine.rb +2 -0
- data/lib/modal_stack/helpers/modal_back_link_helper.rb +48 -0
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +15 -3
- data/lib/modal_stack/helpers/modal_stack_container_helper.rb +42 -15
- data/lib/modal_stack/turbo_streams_extension.rb +56 -0
- data/lib/modal_stack/version.rb +1 -1
- data/lib/modal_stack.rb +5 -1
- metadata +11 -3
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
createStack,
|
|
5
5
|
handlePopstate,
|
|
6
6
|
ModalStackDepthError,
|
|
7
|
+
pathBack,
|
|
8
|
+
pathTo,
|
|
7
9
|
pop,
|
|
8
10
|
push,
|
|
9
11
|
replaceTop,
|
|
@@ -80,12 +82,21 @@ describe("push", () => {
|
|
|
80
82
|
{
|
|
81
83
|
type: "pushHistory",
|
|
82
84
|
url: "/projects/42/edit",
|
|
83
|
-
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
|
|
85
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
|
|
84
86
|
},
|
|
85
87
|
{ type: "persistSnapshot" },
|
|
86
88
|
]);
|
|
87
89
|
});
|
|
88
90
|
|
|
91
|
+
test("first layer carries a single-frame array", () => {
|
|
92
|
+
const { state } = pushed(freshStack());
|
|
93
|
+
expect(state.layers[0].frames).toEqual([
|
|
94
|
+
{ url: "/projects/42/edit", stale: false },
|
|
95
|
+
]);
|
|
96
|
+
expect(Object.isFrozen(state.layers[0].frames)).toBe(true);
|
|
97
|
+
expect(Object.isFrozen(state.layers[0].frames[0])).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
89
100
|
test("preserves side and size for drawer layers", () => {
|
|
90
101
|
const { state, commands } = push(freshStack(), {
|
|
91
102
|
id: "L1",
|
|
@@ -270,7 +281,7 @@ describe("push", () => {
|
|
|
270
281
|
expect(commands).toContainEqual({
|
|
271
282
|
type: "pushHistory",
|
|
272
283
|
url: "/clients/new",
|
|
273
|
-
historyState: { stackId: STACK_ID, layerId: "L2", depth: 2 },
|
|
284
|
+
historyState: { stackId: STACK_ID, layerId: "L2", depth: 2, frameIndex: 0 },
|
|
274
285
|
});
|
|
275
286
|
});
|
|
276
287
|
|
|
@@ -296,6 +307,181 @@ describe("push", () => {
|
|
|
296
307
|
});
|
|
297
308
|
});
|
|
298
309
|
|
|
310
|
+
describe("pathTo", () => {
|
|
311
|
+
test("throws on empty stack", () => {
|
|
312
|
+
expect(() => pathTo(freshStack(), { url: "/x" })).toThrow(/at least one layer/);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("requires a frame.url", () => {
|
|
316
|
+
const s = pushed(freshStack()).state;
|
|
317
|
+
expect(() => pathTo(s, {})).toThrow(/frame\.url/);
|
|
318
|
+
expect(() => pathTo(s, { url: "" })).toThrow(/frame\.url/);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("appends a frame to the top layer and emits mountFrame + pushHistory", () => {
|
|
322
|
+
const s = pushed(freshStack()).state;
|
|
323
|
+
const { state, commands } = pathTo(s, { url: "/projects/42/edit/step2" });
|
|
324
|
+
|
|
325
|
+
expect(state.layers).toHaveLength(1);
|
|
326
|
+
expect(state.layers[0].id).toBe("L1");
|
|
327
|
+
expect(state.layers[0].url).toBe("/projects/42/edit/step2");
|
|
328
|
+
expect(state.layers[0].frames).toEqual([
|
|
329
|
+
{ url: "/projects/42/edit", stale: false },
|
|
330
|
+
{ url: "/projects/42/edit/step2", stale: false },
|
|
331
|
+
]);
|
|
332
|
+
|
|
333
|
+
expect(commands).toEqual([
|
|
334
|
+
{
|
|
335
|
+
type: "mountFrame",
|
|
336
|
+
layerId: "L1",
|
|
337
|
+
fromFrameIndex: 0,
|
|
338
|
+
toFrameIndex: 1,
|
|
339
|
+
url: "/projects/42/edit/step2",
|
|
340
|
+
stale: false,
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
type: "pushHistory",
|
|
344
|
+
url: "/projects/42/edit/step2",
|
|
345
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 1 },
|
|
346
|
+
},
|
|
347
|
+
{ type: "persistSnapshot" },
|
|
348
|
+
]);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("propagates stale flag and transition option", () => {
|
|
352
|
+
const s = pushed(freshStack()).state;
|
|
353
|
+
const { state, commands } = pathTo(
|
|
354
|
+
s,
|
|
355
|
+
{ url: "/projects/42/edit/step2", stale: true },
|
|
356
|
+
{ transition: "fade" },
|
|
357
|
+
);
|
|
358
|
+
expect(state.layers[0].frames[1].stale).toBe(true);
|
|
359
|
+
expect(commands[0]).toMatchObject({
|
|
360
|
+
type: "mountFrame",
|
|
361
|
+
stale: true,
|
|
362
|
+
transition: "fade",
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("rejects unknown transition", () => {
|
|
367
|
+
const s = pushed(freshStack()).state;
|
|
368
|
+
expect(() => pathTo(s, { url: "/x" }, { transition: "warp" })).toThrow(
|
|
369
|
+
/unknown transition/,
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("targets the top layer when several layers are stacked", () => {
|
|
374
|
+
let s = pushed(freshStack()).state;
|
|
375
|
+
s = push(s, { id: "L2", url: "/clients/new" }).state;
|
|
376
|
+
const { state, commands } = pathTo(s, { url: "/clients/new/contact" });
|
|
377
|
+
expect(state.layers[0].frames).toEqual([
|
|
378
|
+
{ url: "/projects/42/edit", stale: false },
|
|
379
|
+
]);
|
|
380
|
+
expect(state.layers[1].frames).toEqual([
|
|
381
|
+
{ url: "/clients/new", stale: false },
|
|
382
|
+
{ url: "/clients/new/contact", stale: false },
|
|
383
|
+
]);
|
|
384
|
+
expect(commands[0]).toMatchObject({ layerId: "L2", toFrameIndex: 1 });
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe("pathBack", () => {
|
|
389
|
+
test("throws on empty stack", () => {
|
|
390
|
+
expect(() => pathBack(freshStack())).toThrow(/at least one layer/);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("rejects non-positive steps", () => {
|
|
394
|
+
const s = pushed(freshStack()).state;
|
|
395
|
+
expect(() => pathBack(s, { steps: 0 })).toThrow(/positive integer/);
|
|
396
|
+
expect(() => pathBack(s, { steps: -1 })).toThrow(/positive integer/);
|
|
397
|
+
expect(() => pathBack(s, { steps: NaN })).toThrow(/positive integer/);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("on a single-frame layer is a noop (does not close the layer)", () => {
|
|
401
|
+
const s = pushed(freshStack()).state;
|
|
402
|
+
const result = pathBack(s);
|
|
403
|
+
expect(result.state).toBe(s);
|
|
404
|
+
expect(result.commands).toEqual([]);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("steps back one frame by default and emits unmountFrame + historyBack", () => {
|
|
408
|
+
let s = pushed(freshStack()).state;
|
|
409
|
+
s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
|
|
410
|
+
|
|
411
|
+
const { state, commands } = pathBack(s);
|
|
412
|
+
expect(state.layers[0].frames).toEqual([
|
|
413
|
+
{ url: "/projects/42/edit", stale: false },
|
|
414
|
+
]);
|
|
415
|
+
expect(state.layers[0].url).toBe("/projects/42/edit");
|
|
416
|
+
|
|
417
|
+
expect(commands).toEqual([
|
|
418
|
+
{
|
|
419
|
+
type: "unmountFrame",
|
|
420
|
+
layerId: "L1",
|
|
421
|
+
fromFrameIndex: 1,
|
|
422
|
+
toFrameIndex: 0,
|
|
423
|
+
url: "/projects/42/edit",
|
|
424
|
+
stale: false,
|
|
425
|
+
},
|
|
426
|
+
{ type: "historyBack", n: 1 },
|
|
427
|
+
{ type: "persistSnapshot" },
|
|
428
|
+
]);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("steps back N frames in one shot", () => {
|
|
432
|
+
let s = pushed(freshStack()).state;
|
|
433
|
+
s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
|
|
434
|
+
s = pathTo(s, { url: "/projects/42/edit/step3" }).state;
|
|
435
|
+
|
|
436
|
+
const { state, commands } = pathBack(s, { steps: 2 });
|
|
437
|
+
expect(state.layers[0].frames).toHaveLength(1);
|
|
438
|
+
expect(state.layers[0].url).toBe("/projects/42/edit");
|
|
439
|
+
expect(commands).toEqual([
|
|
440
|
+
{
|
|
441
|
+
type: "unmountFrame",
|
|
442
|
+
layerId: "L1",
|
|
443
|
+
fromFrameIndex: 2,
|
|
444
|
+
toFrameIndex: 0,
|
|
445
|
+
url: "/projects/42/edit",
|
|
446
|
+
stale: false,
|
|
447
|
+
},
|
|
448
|
+
{ type: "historyBack", n: 2 },
|
|
449
|
+
{ type: "persistSnapshot" },
|
|
450
|
+
]);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("clamps steps to keep at least the first frame", () => {
|
|
454
|
+
let s = pushed(freshStack()).state;
|
|
455
|
+
s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
|
|
456
|
+
s = pathTo(s, { url: "/projects/42/edit/step3" }).state;
|
|
457
|
+
|
|
458
|
+
const { state, commands } = pathBack(s, { steps: 99 });
|
|
459
|
+
expect(state.layers[0].frames).toHaveLength(1);
|
|
460
|
+
expect(commands.find((c) => c.type === "historyBack")).toEqual({
|
|
461
|
+
type: "historyBack",
|
|
462
|
+
n: 2,
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("preserves the stale flag of the destination frame", () => {
|
|
467
|
+
let s = pushed(freshStack()).state;
|
|
468
|
+
s = pathTo(s, { url: "/step2", stale: true }).state;
|
|
469
|
+
s = pathTo(s, { url: "/step3" }).state;
|
|
470
|
+
const { commands } = pathBack(s);
|
|
471
|
+
expect(commands[0]).toMatchObject({
|
|
472
|
+
type: "unmountFrame",
|
|
473
|
+
stale: true,
|
|
474
|
+
url: "/step2",
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("rejects unknown transition", () => {
|
|
479
|
+
let s = pushed(freshStack()).state;
|
|
480
|
+
s = pathTo(s, { url: "/step2" }).state;
|
|
481
|
+
expect(() => pathBack(s, { transition: "warp" })).toThrow(/unknown transition/);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
299
485
|
describe("pop", () => {
|
|
300
486
|
test("noop on empty stack", () => {
|
|
301
487
|
const s = freshStack();
|
|
@@ -306,10 +492,13 @@ describe("pop", () => {
|
|
|
306
492
|
const first = pushed(freshStack()).state;
|
|
307
493
|
const { state, commands } = pop(first);
|
|
308
494
|
expect(state.layers).toEqual([]);
|
|
495
|
+
// closeDialog comes first so its exit transition runs in parallel
|
|
496
|
+
// with the layer's [data-leaving] transition.
|
|
309
497
|
expect(commands).toEqual([
|
|
498
|
+
{ type: "closeDialog" },
|
|
310
499
|
{ type: "unmountTopLayer" },
|
|
500
|
+
{ type: "clearFrameCache", layerId: "L1" },
|
|
311
501
|
{ type: "historyBack", n: 1 },
|
|
312
|
-
{ type: "closeDialog" },
|
|
313
502
|
{ type: "unlockScroll" },
|
|
314
503
|
{ type: "clearSnapshot" },
|
|
315
504
|
]);
|
|
@@ -322,11 +511,28 @@ describe("pop", () => {
|
|
|
322
511
|
expect(state.layers).toHaveLength(1);
|
|
323
512
|
expect(commands).toEqual([
|
|
324
513
|
{ type: "unmountTopLayer" },
|
|
514
|
+
{ type: "clearFrameCache", layerId: "L2" },
|
|
325
515
|
{ type: "historyBack", n: 1 },
|
|
326
516
|
{ type: "inertLayer", layerId: "L1", value: false },
|
|
327
517
|
{ type: "persistSnapshot" },
|
|
328
518
|
]);
|
|
329
519
|
});
|
|
520
|
+
|
|
521
|
+
test("popping a layer with a path walks history back across all its frames", () => {
|
|
522
|
+
let s = pushed(freshStack()).state;
|
|
523
|
+
s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
|
|
524
|
+
s = pathTo(s, { url: "/projects/42/edit/step3" }).state;
|
|
525
|
+
const { state, commands } = pop(s);
|
|
526
|
+
expect(state.layers).toEqual([]);
|
|
527
|
+
expect(commands).toEqual([
|
|
528
|
+
{ type: "closeDialog" },
|
|
529
|
+
{ type: "unmountTopLayer" },
|
|
530
|
+
{ type: "clearFrameCache", layerId: "L1" },
|
|
531
|
+
{ type: "historyBack", n: 3 },
|
|
532
|
+
{ type: "unlockScroll" },
|
|
533
|
+
{ type: "clearSnapshot" },
|
|
534
|
+
]);
|
|
535
|
+
});
|
|
330
536
|
});
|
|
331
537
|
|
|
332
538
|
describe("replaceTop", () => {
|
|
@@ -353,7 +559,7 @@ describe("replaceTop", () => {
|
|
|
353
559
|
{
|
|
354
560
|
type: "replaceHistory",
|
|
355
561
|
url: "/projects/42/edit/billing",
|
|
356
|
-
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
|
|
562
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
|
|
357
563
|
},
|
|
358
564
|
{ type: "persistSnapshot" },
|
|
359
565
|
]);
|
|
@@ -398,10 +604,38 @@ describe("replaceTop", () => {
|
|
|
398
604
|
expect(commands[1]).toEqual({
|
|
399
605
|
type: "pushHistory",
|
|
400
606
|
url: "/onboarding/step2",
|
|
401
|
-
historyState: { stackId: STACK_ID, layerId: "L1b", depth: 1 },
|
|
607
|
+
historyState: { stackId: STACK_ID, layerId: "L1b", depth: 1, frameIndex: 0 },
|
|
402
608
|
});
|
|
403
609
|
});
|
|
404
610
|
|
|
611
|
+
test("collapses path frames and walks history back the surplus", () => {
|
|
612
|
+
let s = pushed(freshStack()).state;
|
|
613
|
+
s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
|
|
614
|
+
s = pathTo(s, { url: "/projects/42/edit/step3" }).state;
|
|
615
|
+
const { state, commands } = replaceTop(s, { url: "/projects/42/edit/billing" });
|
|
616
|
+
expect(state.layers[0].frames).toEqual([
|
|
617
|
+
{ url: "/projects/42/edit/billing", stale: false },
|
|
618
|
+
]);
|
|
619
|
+
expect(commands).toEqual([
|
|
620
|
+
{ type: "clearFrameCache", layerId: "L1" },
|
|
621
|
+
{ type: "historyBack", n: 2 },
|
|
622
|
+
{
|
|
623
|
+
type: "morphTopLayer",
|
|
624
|
+
layerId: "L1",
|
|
625
|
+
url: "/projects/42/edit/billing",
|
|
626
|
+
depth: 1,
|
|
627
|
+
variant: "modal",
|
|
628
|
+
dismissible: true,
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
type: "replaceHistory",
|
|
632
|
+
url: "/projects/42/edit/billing",
|
|
633
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
|
|
634
|
+
},
|
|
635
|
+
{ type: "persistSnapshot" },
|
|
636
|
+
]);
|
|
637
|
+
});
|
|
638
|
+
|
|
405
639
|
test("rejects unknown historyMode", () => {
|
|
406
640
|
const s = pushed(freshStack()).state;
|
|
407
641
|
expect(() => replaceTop(s, { url: "/x" }, { historyMode: "wat" })).toThrow(
|
|
@@ -423,13 +657,25 @@ describe("closeAll", () => {
|
|
|
423
657
|
const { state, commands } = closeAll(s);
|
|
424
658
|
expect(state.layers).toEqual([]);
|
|
425
659
|
expect(commands).toEqual([
|
|
426
|
-
{ type: "unmountAllLayers" },
|
|
427
660
|
{ type: "closeDialog" },
|
|
661
|
+
{ type: "unmountAllLayers" },
|
|
662
|
+
{ type: "clearFrameCache", layerId: "L1" },
|
|
663
|
+
{ type: "clearFrameCache", layerId: "L2" },
|
|
664
|
+
{ type: "clearFrameCache", layerId: "L3" },
|
|
428
665
|
{ type: "unlockScroll" },
|
|
429
666
|
{ type: "historyBack", n: 3 },
|
|
430
667
|
{ type: "clearSnapshot" },
|
|
431
668
|
]);
|
|
432
669
|
});
|
|
670
|
+
|
|
671
|
+
test("counts every frame across every layer when walking history back", () => {
|
|
672
|
+
let s = pushed(freshStack()).state;
|
|
673
|
+
s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
|
|
674
|
+
s = push(s, { id: "L2", url: "/clients/new" }).state;
|
|
675
|
+
s = pathTo(s, { url: "/clients/new/contact" }).state;
|
|
676
|
+
const { commands } = closeAll(s);
|
|
677
|
+
expect(commands).toContainEqual({ type: "historyBack", n: 4 });
|
|
678
|
+
});
|
|
433
679
|
});
|
|
434
680
|
|
|
435
681
|
describe("handlePopstate", () => {
|
|
@@ -447,8 +693,10 @@ describe("handlePopstate", () => {
|
|
|
447
693
|
});
|
|
448
694
|
expect(state.layers).toEqual([]);
|
|
449
695
|
expect(commands).toEqual([
|
|
450
|
-
{ type: "unmountAllLayers" },
|
|
451
696
|
{ type: "closeDialog" },
|
|
697
|
+
{ type: "unmountAllLayers" },
|
|
698
|
+
{ type: "clearFrameCache", layerId: "L1" },
|
|
699
|
+
{ type: "clearFrameCache", layerId: "L2" },
|
|
452
700
|
{ type: "unlockScroll" },
|
|
453
701
|
{ type: "clearSnapshot" },
|
|
454
702
|
]);
|
|
@@ -465,12 +713,13 @@ describe("handlePopstate", () => {
|
|
|
465
713
|
test("back: targetDepth < current pops layers and un-inerts new top (no historyBack)", () => {
|
|
466
714
|
const s = buildTwoLayer();
|
|
467
715
|
const { state, commands } = handlePopstate(s, {
|
|
468
|
-
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
|
|
716
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
|
|
469
717
|
locationHref: "/projects/42/edit",
|
|
470
718
|
});
|
|
471
719
|
expect(state.layers.map((l) => l.id)).toEqual(["L1"]);
|
|
472
720
|
expect(commands).toEqual([
|
|
473
721
|
{ type: "unmountTopLayer" },
|
|
722
|
+
{ type: "clearFrameCache", layerId: "L2" },
|
|
474
723
|
{ type: "inertLayer", layerId: "L1", value: false },
|
|
475
724
|
{ type: "persistSnapshot" },
|
|
476
725
|
]);
|
|
@@ -485,9 +734,11 @@ describe("handlePopstate", () => {
|
|
|
485
734
|
});
|
|
486
735
|
expect(state.layers).toEqual([]);
|
|
487
736
|
expect(commands).toEqual([
|
|
737
|
+
{ type: "closeDialog" },
|
|
488
738
|
{ type: "unmountTopLayer" },
|
|
489
739
|
{ type: "unmountTopLayer" },
|
|
490
|
-
{ type: "
|
|
740
|
+
{ type: "clearFrameCache", layerId: "L1" },
|
|
741
|
+
{ type: "clearFrameCache", layerId: "L2" },
|
|
491
742
|
{ type: "unlockScroll" },
|
|
492
743
|
{ type: "clearSnapshot" },
|
|
493
744
|
]);
|
|
@@ -512,7 +763,7 @@ describe("handlePopstate", () => {
|
|
|
512
763
|
{ historyMode: "push" },
|
|
513
764
|
).state;
|
|
514
765
|
const { state, commands } = handlePopstate(after, {
|
|
515
|
-
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
|
|
766
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
|
|
516
767
|
locationHref: "/onboarding/step1",
|
|
517
768
|
});
|
|
518
769
|
expect(state.layers[0]).toMatchObject({
|
|
@@ -520,6 +771,7 @@ describe("handlePopstate", () => {
|
|
|
520
771
|
url: "/onboarding/step1",
|
|
521
772
|
});
|
|
522
773
|
expect(commands).toEqual([
|
|
774
|
+
{ type: "clearFrameCache", layerId: "L1b" },
|
|
523
775
|
{
|
|
524
776
|
type: "morphTopLayer",
|
|
525
777
|
layerId: "L1",
|
|
@@ -535,12 +787,56 @@ describe("handlePopstate", () => {
|
|
|
535
787
|
test("same depth, same layerId is a noop", () => {
|
|
536
788
|
const s = pushed(freshStack()).state;
|
|
537
789
|
const { state, commands } = handlePopstate(s, {
|
|
538
|
-
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
|
|
790
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
|
|
539
791
|
locationHref: "/projects/42/edit",
|
|
540
792
|
});
|
|
541
793
|
expect(state).toEqual(s);
|
|
542
794
|
expect(commands).toEqual([]);
|
|
543
795
|
});
|
|
796
|
+
|
|
797
|
+
test("back through frames: same layer, lower frameIndex steps back in path", () => {
|
|
798
|
+
let s = pushed(freshStack()).state;
|
|
799
|
+
s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
|
|
800
|
+
s = pathTo(s, { url: "/projects/42/edit/step3" }).state;
|
|
801
|
+
|
|
802
|
+
const { state, commands } = handlePopstate(s, {
|
|
803
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 1 },
|
|
804
|
+
locationHref: "/projects/42/edit/step2",
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
expect(state.layers[0].frames).toEqual([
|
|
808
|
+
{ url: "/projects/42/edit", stale: false },
|
|
809
|
+
{ url: "/projects/42/edit/step2", stale: false },
|
|
810
|
+
]);
|
|
811
|
+
expect(state.layers[0].url).toBe("/projects/42/edit/step2");
|
|
812
|
+
expect(commands).toEqual([
|
|
813
|
+
{
|
|
814
|
+
type: "unmountFrame",
|
|
815
|
+
layerId: "L1",
|
|
816
|
+
fromFrameIndex: 2,
|
|
817
|
+
toFrameIndex: 1,
|
|
818
|
+
url: "/projects/42/edit/step2",
|
|
819
|
+
stale: false,
|
|
820
|
+
},
|
|
821
|
+
{ type: "persistSnapshot" },
|
|
822
|
+
]);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
test("forward popstate to a frame we no longer track defers to snapshot rebuild", () => {
|
|
826
|
+
let s = pushed(freshStack()).state;
|
|
827
|
+
s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
|
|
828
|
+
// user pressed back, dropping the step2 frame from state
|
|
829
|
+
s = pathBack(s).state;
|
|
830
|
+
|
|
831
|
+
const { state, commands } = handlePopstate(s, {
|
|
832
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 1 },
|
|
833
|
+
locationHref: "/projects/42/edit/step2",
|
|
834
|
+
});
|
|
835
|
+
expect(state).toEqual(s);
|
|
836
|
+
expect(commands).toEqual([
|
|
837
|
+
{ type: "rebuildFromSnapshot", targetDepth: 1, targetLayerId: "L1" },
|
|
838
|
+
]);
|
|
839
|
+
});
|
|
544
840
|
});
|
|
545
841
|
|
|
546
842
|
describe("snapshot / restore", () => {
|
|
@@ -573,13 +869,13 @@ describe("snapshot / restore", () => {
|
|
|
573
869
|
expect(restore("not-json", { stackId: STACK_ID })).toBeNull();
|
|
574
870
|
expect(restore("", { stackId: STACK_ID })).toBeNull();
|
|
575
871
|
expect(restore(null, { stackId: STACK_ID })).toBeNull();
|
|
576
|
-
expect(restore('{"v":
|
|
872
|
+
expect(restore('{"v":3}', { stackId: STACK_ID })).toBeNull();
|
|
577
873
|
expect(restore('{"v":1}', { stackId: STACK_ID })).toBeNull();
|
|
578
874
|
});
|
|
579
875
|
|
|
580
876
|
test("returns null when a layer has unknown variant", () => {
|
|
581
877
|
const malicious = JSON.stringify({
|
|
582
|
-
v:
|
|
878
|
+
v: 2,
|
|
583
879
|
stackId: STACK_ID,
|
|
584
880
|
baseUrl: "/",
|
|
585
881
|
layers: [{ id: "L1", url: "/", variant: "popover", dismissible: true }],
|
|
@@ -587,4 +883,95 @@ describe("snapshot / restore", () => {
|
|
|
587
883
|
});
|
|
588
884
|
expect(restore(malicious, { stackId: STACK_ID })).toBeNull();
|
|
589
885
|
});
|
|
886
|
+
|
|
887
|
+
test("serializes frames as { url, stale } only", () => {
|
|
888
|
+
let s = pushed(freshStack()).state;
|
|
889
|
+
s = pathTo(s, { url: "/projects/42/edit/step2", stale: true }).state;
|
|
890
|
+
const parsed = JSON.parse(snapshot(s));
|
|
891
|
+
expect(parsed.v).toBe(2);
|
|
892
|
+
expect(parsed.layers[0].frames).toEqual([
|
|
893
|
+
{ url: "/projects/42/edit", stale: false },
|
|
894
|
+
{ url: "/projects/42/edit/step2", stale: true },
|
|
895
|
+
]);
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
test("round-trips a layer with multiple frames", () => {
|
|
899
|
+
let s = pushed(freshStack()).state;
|
|
900
|
+
s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
|
|
901
|
+
s = pathTo(s, { url: "/projects/42/edit/step3", stale: true }).state;
|
|
902
|
+
const restored = restore(snapshot(s), { stackId: STACK_ID });
|
|
903
|
+
expect(restored).toEqual(s);
|
|
904
|
+
expect(restored.layers[0].frames).toHaveLength(3);
|
|
905
|
+
expect(restored.layers[0].frames[2].stale).toBe(true);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test("accepts a v1 snapshot and synthesizes single-frame arrays", () => {
|
|
909
|
+
const v1 = JSON.stringify({
|
|
910
|
+
v: 1,
|
|
911
|
+
stackId: STACK_ID,
|
|
912
|
+
baseUrl: BASE_URL,
|
|
913
|
+
layers: [
|
|
914
|
+
{
|
|
915
|
+
id: "L1",
|
|
916
|
+
url: "/projects/42/edit",
|
|
917
|
+
variant: "modal",
|
|
918
|
+
dismissible: true,
|
|
919
|
+
size: null,
|
|
920
|
+
side: null,
|
|
921
|
+
width: null,
|
|
922
|
+
height: null,
|
|
923
|
+
},
|
|
924
|
+
],
|
|
925
|
+
savedAt: Date.now(),
|
|
926
|
+
});
|
|
927
|
+
const restored = restore(v1, { stackId: STACK_ID });
|
|
928
|
+
expect(restored).not.toBeNull();
|
|
929
|
+
expect(restored.layers[0].frames).toEqual([
|
|
930
|
+
{ url: "/projects/42/edit", stale: false },
|
|
931
|
+
]);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
test("returns null when a v2 layer has malformed frames", () => {
|
|
935
|
+
const malicious = JSON.stringify({
|
|
936
|
+
v: 2,
|
|
937
|
+
stackId: STACK_ID,
|
|
938
|
+
baseUrl: BASE_URL,
|
|
939
|
+
layers: [
|
|
940
|
+
{
|
|
941
|
+
id: "L1",
|
|
942
|
+
url: "/x",
|
|
943
|
+
variant: "modal",
|
|
944
|
+
dismissible: true,
|
|
945
|
+
size: null,
|
|
946
|
+
side: null,
|
|
947
|
+
width: null,
|
|
948
|
+
height: null,
|
|
949
|
+
frames: [{ stale: false }],
|
|
950
|
+
},
|
|
951
|
+
],
|
|
952
|
+
savedAt: Date.now(),
|
|
953
|
+
});
|
|
954
|
+
expect(restore(malicious, { stackId: STACK_ID })).toBeNull();
|
|
955
|
+
|
|
956
|
+
const emptyFrames = JSON.stringify({
|
|
957
|
+
v: 2,
|
|
958
|
+
stackId: STACK_ID,
|
|
959
|
+
baseUrl: BASE_URL,
|
|
960
|
+
layers: [
|
|
961
|
+
{
|
|
962
|
+
id: "L1",
|
|
963
|
+
url: "/x",
|
|
964
|
+
variant: "modal",
|
|
965
|
+
dismissible: true,
|
|
966
|
+
size: null,
|
|
967
|
+
side: null,
|
|
968
|
+
width: null,
|
|
969
|
+
height: null,
|
|
970
|
+
frames: [],
|
|
971
|
+
},
|
|
972
|
+
],
|
|
973
|
+
savedAt: Date.now(),
|
|
974
|
+
});
|
|
975
|
+
expect(restore(emptyFrames, { stackId: STACK_ID })).toBeNull();
|
|
976
|
+
});
|
|
590
977
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= content_tag(:dialog, dialog_attrs) do %><% end %>
|
|
@@ -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.
|
|
@@ -49,6 +56,15 @@ ModalStack.configure do |config|
|
|
|
49
56
|
# :silent — drop the push, no warning
|
|
50
57
|
config.max_depth_strategy = :warn
|
|
51
58
|
|
|
59
|
+
# Default transition between frames in a modal path (the wizard-style
|
|
60
|
+
# `modal_path_to` / `modal_path_back` flow). Override per call with
|
|
61
|
+
# `transition:` on the stream action when a specific step needs a
|
|
62
|
+
# different feel.
|
|
63
|
+
# :slide — directional, current frame slides out, next slides in
|
|
64
|
+
# :fade — cross-fade, no direction
|
|
65
|
+
# :none — instantaneous swap
|
|
66
|
+
config.default_path_transition = :slide
|
|
67
|
+
|
|
52
68
|
# Replace `data-turbo-confirm` window.confirm with a modal_stack
|
|
53
69
|
# confirmation layer (cf. RFC §15.Q7). Off by default — opt-in.
|
|
54
70
|
config.replace_turbo_confirm = false
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<%# modal_stack dialog template — override this file to customise the HTML
|
|
2
|
+
structure of the singleton <dialog> element that acts as the modal root.
|
|
3
|
+
Available locals:
|
|
4
|
+
dialog_attrs — Hash { id:, data: { controller: "modal-stack", ... }, ...extras }
|
|
5
|
+
Must stay on the <dialog> element — the JS Stimulus controller
|
|
6
|
+
and layer injection depend on these attributes.
|
|
7
|
+
Note: the JS runtime appends panel layers as direct children of <dialog> via
|
|
8
|
+
appendChild. Static children you add here will be siblings of those layers. %>
|
|
9
|
+
<%= content_tag(:dialog, dialog_attrs) do %><% end %>
|