modal_stack 0.3.0 → 0.4.2
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 +48 -0
- data/README.md +187 -36
- data/app/assets/javascripts/modal_stack.js +693 -73
- data/app/assets/stylesheets/modal_stack/bootstrap.css +113 -3
- data/app/assets/stylesheets/modal_stack/tailwind_v3.css +63 -2
- data/app/assets/stylesheets/modal_stack/tailwind_v4.css +63 -2
- data/app/assets/stylesheets/modal_stack/vanilla.css +121 -3
- data/app/javascript/modal_stack/controllers/modal_stack_back_link_controller.js +32 -0
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +161 -8
- data/app/javascript/modal_stack/install.js +7 -1
- data/app/javascript/modal_stack/orchestrator.js +70 -10
- data/app/javascript/modal_stack/orchestrator.test.js +98 -2
- data/app/javascript/modal_stack/runtime.js +316 -9
- data/app/javascript/modal_stack/runtime.test.js +90 -6
- data/app/javascript/modal_stack/state.js +343 -45
- data/app/javascript/modal_stack/state.test.js +404 -17
- 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/templates/initializer.rb +9 -0
- 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 +37 -16
- 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 +7 -1
- 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 +9 -2
|
@@ -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,14 +492,15 @@ describe("pop", () => {
|
|
|
306
492
|
const first = pushed(freshStack()).state;
|
|
307
493
|
const { state, commands } = pop(first);
|
|
308
494
|
expect(state.layers).toEqual([]);
|
|
309
|
-
// closeDialog
|
|
310
|
-
//
|
|
495
|
+
// closeDialog and clearSnapshot come first so a reload during the
|
|
496
|
+
// exit animation does not restore the modal that is already closing.
|
|
311
497
|
expect(commands).toEqual([
|
|
312
498
|
{ type: "closeDialog" },
|
|
499
|
+
{ type: "clearSnapshot" },
|
|
313
500
|
{ type: "unmountTopLayer" },
|
|
501
|
+
{ type: "clearFrameCache", layerId: "L1" },
|
|
314
502
|
{ type: "historyBack", n: 1 },
|
|
315
503
|
{ type: "unlockScroll" },
|
|
316
|
-
{ type: "clearSnapshot" },
|
|
317
504
|
]);
|
|
318
505
|
});
|
|
319
506
|
|
|
@@ -322,11 +509,30 @@ describe("pop", () => {
|
|
|
322
509
|
s = push(s, { id: "L2", url: "/clients/new" }).state;
|
|
323
510
|
const { state, commands } = pop(s);
|
|
324
511
|
expect(state.layers).toHaveLength(1);
|
|
512
|
+
// persistSnapshot comes first so a reload during the animation
|
|
513
|
+
// restores the correct remaining stack (without the popped layer).
|
|
325
514
|
expect(commands).toEqual([
|
|
515
|
+
{ type: "persistSnapshot" },
|
|
326
516
|
{ type: "unmountTopLayer" },
|
|
517
|
+
{ type: "clearFrameCache", layerId: "L2" },
|
|
327
518
|
{ type: "historyBack", n: 1 },
|
|
328
519
|
{ type: "inertLayer", layerId: "L1", value: false },
|
|
329
|
-
|
|
520
|
+
]);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("popping a layer with a path walks history back across all its frames", () => {
|
|
524
|
+
let s = pushed(freshStack()).state;
|
|
525
|
+
s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
|
|
526
|
+
s = pathTo(s, { url: "/projects/42/edit/step3" }).state;
|
|
527
|
+
const { state, commands } = pop(s);
|
|
528
|
+
expect(state.layers).toEqual([]);
|
|
529
|
+
expect(commands).toEqual([
|
|
530
|
+
{ type: "closeDialog" },
|
|
531
|
+
{ type: "clearSnapshot" },
|
|
532
|
+
{ type: "unmountTopLayer" },
|
|
533
|
+
{ type: "clearFrameCache", layerId: "L1" },
|
|
534
|
+
{ type: "historyBack", n: 3 },
|
|
535
|
+
{ type: "unlockScroll" },
|
|
330
536
|
]);
|
|
331
537
|
});
|
|
332
538
|
});
|
|
@@ -355,7 +561,7 @@ describe("replaceTop", () => {
|
|
|
355
561
|
{
|
|
356
562
|
type: "replaceHistory",
|
|
357
563
|
url: "/projects/42/edit/billing",
|
|
358
|
-
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
|
|
564
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
|
|
359
565
|
},
|
|
360
566
|
{ type: "persistSnapshot" },
|
|
361
567
|
]);
|
|
@@ -400,10 +606,38 @@ describe("replaceTop", () => {
|
|
|
400
606
|
expect(commands[1]).toEqual({
|
|
401
607
|
type: "pushHistory",
|
|
402
608
|
url: "/onboarding/step2",
|
|
403
|
-
historyState: { stackId: STACK_ID, layerId: "L1b", depth: 1 },
|
|
609
|
+
historyState: { stackId: STACK_ID, layerId: "L1b", depth: 1, frameIndex: 0 },
|
|
404
610
|
});
|
|
405
611
|
});
|
|
406
612
|
|
|
613
|
+
test("collapses path frames and walks history back the surplus", () => {
|
|
614
|
+
let s = pushed(freshStack()).state;
|
|
615
|
+
s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
|
|
616
|
+
s = pathTo(s, { url: "/projects/42/edit/step3" }).state;
|
|
617
|
+
const { state, commands } = replaceTop(s, { url: "/projects/42/edit/billing" });
|
|
618
|
+
expect(state.layers[0].frames).toEqual([
|
|
619
|
+
{ url: "/projects/42/edit/billing", stale: false },
|
|
620
|
+
]);
|
|
621
|
+
expect(commands).toEqual([
|
|
622
|
+
{ type: "clearFrameCache", layerId: "L1" },
|
|
623
|
+
{ type: "historyBack", n: 2 },
|
|
624
|
+
{
|
|
625
|
+
type: "morphTopLayer",
|
|
626
|
+
layerId: "L1",
|
|
627
|
+
url: "/projects/42/edit/billing",
|
|
628
|
+
depth: 1,
|
|
629
|
+
variant: "modal",
|
|
630
|
+
dismissible: true,
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
type: "replaceHistory",
|
|
634
|
+
url: "/projects/42/edit/billing",
|
|
635
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
|
|
636
|
+
},
|
|
637
|
+
{ type: "persistSnapshot" },
|
|
638
|
+
]);
|
|
639
|
+
});
|
|
640
|
+
|
|
407
641
|
test("rejects unknown historyMode", () => {
|
|
408
642
|
const s = pushed(freshStack()).state;
|
|
409
643
|
expect(() => replaceTop(s, { url: "/x" }, { historyMode: "wat" })).toThrow(
|
|
@@ -426,12 +660,24 @@ describe("closeAll", () => {
|
|
|
426
660
|
expect(state.layers).toEqual([]);
|
|
427
661
|
expect(commands).toEqual([
|
|
428
662
|
{ type: "closeDialog" },
|
|
663
|
+
{ type: "clearSnapshot" },
|
|
429
664
|
{ type: "unmountAllLayers" },
|
|
665
|
+
{ type: "clearFrameCache", layerId: "L1" },
|
|
666
|
+
{ type: "clearFrameCache", layerId: "L2" },
|
|
667
|
+
{ type: "clearFrameCache", layerId: "L3" },
|
|
430
668
|
{ type: "unlockScroll" },
|
|
431
669
|
{ type: "historyBack", n: 3 },
|
|
432
|
-
{ type: "clearSnapshot" },
|
|
433
670
|
]);
|
|
434
671
|
});
|
|
672
|
+
|
|
673
|
+
test("counts every frame across every layer when walking history back", () => {
|
|
674
|
+
let s = pushed(freshStack()).state;
|
|
675
|
+
s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
|
|
676
|
+
s = push(s, { id: "L2", url: "/clients/new" }).state;
|
|
677
|
+
s = pathTo(s, { url: "/clients/new/contact" }).state;
|
|
678
|
+
const { commands } = closeAll(s);
|
|
679
|
+
expect(commands).toContainEqual({ type: "historyBack", n: 4 });
|
|
680
|
+
});
|
|
435
681
|
});
|
|
436
682
|
|
|
437
683
|
describe("handlePopstate", () => {
|
|
@@ -450,9 +696,11 @@ describe("handlePopstate", () => {
|
|
|
450
696
|
expect(state.layers).toEqual([]);
|
|
451
697
|
expect(commands).toEqual([
|
|
452
698
|
{ type: "closeDialog" },
|
|
699
|
+
{ type: "clearSnapshot" },
|
|
453
700
|
{ type: "unmountAllLayers" },
|
|
701
|
+
{ type: "clearFrameCache", layerId: "L1" },
|
|
702
|
+
{ type: "clearFrameCache", layerId: "L2" },
|
|
454
703
|
{ type: "unlockScroll" },
|
|
455
|
-
{ type: "clearSnapshot" },
|
|
456
704
|
]);
|
|
457
705
|
});
|
|
458
706
|
|
|
@@ -467,14 +715,15 @@ describe("handlePopstate", () => {
|
|
|
467
715
|
test("back: targetDepth < current pops layers and un-inerts new top (no historyBack)", () => {
|
|
468
716
|
const s = buildTwoLayer();
|
|
469
717
|
const { state, commands } = handlePopstate(s, {
|
|
470
|
-
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
|
|
718
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
|
|
471
719
|
locationHref: "/projects/42/edit",
|
|
472
720
|
});
|
|
473
721
|
expect(state.layers.map((l) => l.id)).toEqual(["L1"]);
|
|
474
722
|
expect(commands).toEqual([
|
|
723
|
+
{ type: "persistSnapshot" },
|
|
475
724
|
{ type: "unmountTopLayer" },
|
|
725
|
+
{ type: "clearFrameCache", layerId: "L2" },
|
|
476
726
|
{ type: "inertLayer", layerId: "L1", value: false },
|
|
477
|
-
{ type: "persistSnapshot" },
|
|
478
727
|
]);
|
|
479
728
|
expect(commands).not.toContainEqual({ type: "historyBack", n: 1 });
|
|
480
729
|
});
|
|
@@ -488,10 +737,12 @@ describe("handlePopstate", () => {
|
|
|
488
737
|
expect(state.layers).toEqual([]);
|
|
489
738
|
expect(commands).toEqual([
|
|
490
739
|
{ type: "closeDialog" },
|
|
740
|
+
{ type: "clearSnapshot" },
|
|
491
741
|
{ type: "unmountTopLayer" },
|
|
492
742
|
{ type: "unmountTopLayer" },
|
|
743
|
+
{ type: "clearFrameCache", layerId: "L1" },
|
|
744
|
+
{ type: "clearFrameCache", layerId: "L2" },
|
|
493
745
|
{ type: "unlockScroll" },
|
|
494
|
-
{ type: "clearSnapshot" },
|
|
495
746
|
]);
|
|
496
747
|
});
|
|
497
748
|
|
|
@@ -514,7 +765,7 @@ describe("handlePopstate", () => {
|
|
|
514
765
|
{ historyMode: "push" },
|
|
515
766
|
).state;
|
|
516
767
|
const { state, commands } = handlePopstate(after, {
|
|
517
|
-
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
|
|
768
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
|
|
518
769
|
locationHref: "/onboarding/step1",
|
|
519
770
|
});
|
|
520
771
|
expect(state.layers[0]).toMatchObject({
|
|
@@ -522,6 +773,7 @@ describe("handlePopstate", () => {
|
|
|
522
773
|
url: "/onboarding/step1",
|
|
523
774
|
});
|
|
524
775
|
expect(commands).toEqual([
|
|
776
|
+
{ type: "clearFrameCache", layerId: "L1b" },
|
|
525
777
|
{
|
|
526
778
|
type: "morphTopLayer",
|
|
527
779
|
layerId: "L1",
|
|
@@ -537,12 +789,56 @@ describe("handlePopstate", () => {
|
|
|
537
789
|
test("same depth, same layerId is a noop", () => {
|
|
538
790
|
const s = pushed(freshStack()).state;
|
|
539
791
|
const { state, commands } = handlePopstate(s, {
|
|
540
|
-
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
|
|
792
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
|
|
541
793
|
locationHref: "/projects/42/edit",
|
|
542
794
|
});
|
|
543
795
|
expect(state).toEqual(s);
|
|
544
796
|
expect(commands).toEqual([]);
|
|
545
797
|
});
|
|
798
|
+
|
|
799
|
+
test("back through frames: same layer, lower frameIndex steps back in path", () => {
|
|
800
|
+
let s = pushed(freshStack()).state;
|
|
801
|
+
s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
|
|
802
|
+
s = pathTo(s, { url: "/projects/42/edit/step3" }).state;
|
|
803
|
+
|
|
804
|
+
const { state, commands } = handlePopstate(s, {
|
|
805
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 1 },
|
|
806
|
+
locationHref: "/projects/42/edit/step2",
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
expect(state.layers[0].frames).toEqual([
|
|
810
|
+
{ url: "/projects/42/edit", stale: false },
|
|
811
|
+
{ url: "/projects/42/edit/step2", stale: false },
|
|
812
|
+
]);
|
|
813
|
+
expect(state.layers[0].url).toBe("/projects/42/edit/step2");
|
|
814
|
+
expect(commands).toEqual([
|
|
815
|
+
{
|
|
816
|
+
type: "unmountFrame",
|
|
817
|
+
layerId: "L1",
|
|
818
|
+
fromFrameIndex: 2,
|
|
819
|
+
toFrameIndex: 1,
|
|
820
|
+
url: "/projects/42/edit/step2",
|
|
821
|
+
stale: false,
|
|
822
|
+
},
|
|
823
|
+
{ type: "persistSnapshot" },
|
|
824
|
+
]);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test("forward popstate to a frame we no longer track defers to snapshot rebuild", () => {
|
|
828
|
+
let s = pushed(freshStack()).state;
|
|
829
|
+
s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
|
|
830
|
+
// user pressed back, dropping the step2 frame from state
|
|
831
|
+
s = pathBack(s).state;
|
|
832
|
+
|
|
833
|
+
const { state, commands } = handlePopstate(s, {
|
|
834
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 1 },
|
|
835
|
+
locationHref: "/projects/42/edit/step2",
|
|
836
|
+
});
|
|
837
|
+
expect(state).toEqual(s);
|
|
838
|
+
expect(commands).toEqual([
|
|
839
|
+
{ type: "rebuildFromSnapshot", targetDepth: 1, targetLayerId: "L1" },
|
|
840
|
+
]);
|
|
841
|
+
});
|
|
546
842
|
});
|
|
547
843
|
|
|
548
844
|
describe("snapshot / restore", () => {
|
|
@@ -575,13 +871,13 @@ describe("snapshot / restore", () => {
|
|
|
575
871
|
expect(restore("not-json", { stackId: STACK_ID })).toBeNull();
|
|
576
872
|
expect(restore("", { stackId: STACK_ID })).toBeNull();
|
|
577
873
|
expect(restore(null, { stackId: STACK_ID })).toBeNull();
|
|
578
|
-
expect(restore('{"v":
|
|
874
|
+
expect(restore('{"v":3}', { stackId: STACK_ID })).toBeNull();
|
|
579
875
|
expect(restore('{"v":1}', { stackId: STACK_ID })).toBeNull();
|
|
580
876
|
});
|
|
581
877
|
|
|
582
878
|
test("returns null when a layer has unknown variant", () => {
|
|
583
879
|
const malicious = JSON.stringify({
|
|
584
|
-
v:
|
|
880
|
+
v: 2,
|
|
585
881
|
stackId: STACK_ID,
|
|
586
882
|
baseUrl: "/",
|
|
587
883
|
layers: [{ id: "L1", url: "/", variant: "popover", dismissible: true }],
|
|
@@ -589,4 +885,95 @@ describe("snapshot / restore", () => {
|
|
|
589
885
|
});
|
|
590
886
|
expect(restore(malicious, { stackId: STACK_ID })).toBeNull();
|
|
591
887
|
});
|
|
888
|
+
|
|
889
|
+
test("serializes frames as { url, stale } only", () => {
|
|
890
|
+
let s = pushed(freshStack()).state;
|
|
891
|
+
s = pathTo(s, { url: "/projects/42/edit/step2", stale: true }).state;
|
|
892
|
+
const parsed = JSON.parse(snapshot(s));
|
|
893
|
+
expect(parsed.v).toBe(2);
|
|
894
|
+
expect(parsed.layers[0].frames).toEqual([
|
|
895
|
+
{ url: "/projects/42/edit", stale: false },
|
|
896
|
+
{ url: "/projects/42/edit/step2", stale: true },
|
|
897
|
+
]);
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
test("round-trips a layer with multiple frames", () => {
|
|
901
|
+
let s = pushed(freshStack()).state;
|
|
902
|
+
s = pathTo(s, { url: "/projects/42/edit/step2" }).state;
|
|
903
|
+
s = pathTo(s, { url: "/projects/42/edit/step3", stale: true }).state;
|
|
904
|
+
const restored = restore(snapshot(s), { stackId: STACK_ID });
|
|
905
|
+
expect(restored).toEqual(s);
|
|
906
|
+
expect(restored.layers[0].frames).toHaveLength(3);
|
|
907
|
+
expect(restored.layers[0].frames[2].stale).toBe(true);
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
test("accepts a v1 snapshot and synthesizes single-frame arrays", () => {
|
|
911
|
+
const v1 = JSON.stringify({
|
|
912
|
+
v: 1,
|
|
913
|
+
stackId: STACK_ID,
|
|
914
|
+
baseUrl: BASE_URL,
|
|
915
|
+
layers: [
|
|
916
|
+
{
|
|
917
|
+
id: "L1",
|
|
918
|
+
url: "/projects/42/edit",
|
|
919
|
+
variant: "modal",
|
|
920
|
+
dismissible: true,
|
|
921
|
+
size: null,
|
|
922
|
+
side: null,
|
|
923
|
+
width: null,
|
|
924
|
+
height: null,
|
|
925
|
+
},
|
|
926
|
+
],
|
|
927
|
+
savedAt: Date.now(),
|
|
928
|
+
});
|
|
929
|
+
const restored = restore(v1, { stackId: STACK_ID });
|
|
930
|
+
expect(restored).not.toBeNull();
|
|
931
|
+
expect(restored.layers[0].frames).toEqual([
|
|
932
|
+
{ url: "/projects/42/edit", stale: false },
|
|
933
|
+
]);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
test("returns null when a v2 layer has malformed frames", () => {
|
|
937
|
+
const malicious = JSON.stringify({
|
|
938
|
+
v: 2,
|
|
939
|
+
stackId: STACK_ID,
|
|
940
|
+
baseUrl: BASE_URL,
|
|
941
|
+
layers: [
|
|
942
|
+
{
|
|
943
|
+
id: "L1",
|
|
944
|
+
url: "/x",
|
|
945
|
+
variant: "modal",
|
|
946
|
+
dismissible: true,
|
|
947
|
+
size: null,
|
|
948
|
+
side: null,
|
|
949
|
+
width: null,
|
|
950
|
+
height: null,
|
|
951
|
+
frames: [{ stale: false }],
|
|
952
|
+
},
|
|
953
|
+
],
|
|
954
|
+
savedAt: Date.now(),
|
|
955
|
+
});
|
|
956
|
+
expect(restore(malicious, { stackId: STACK_ID })).toBeNull();
|
|
957
|
+
|
|
958
|
+
const emptyFrames = JSON.stringify({
|
|
959
|
+
v: 2,
|
|
960
|
+
stackId: STACK_ID,
|
|
961
|
+
baseUrl: BASE_URL,
|
|
962
|
+
layers: [
|
|
963
|
+
{
|
|
964
|
+
id: "L1",
|
|
965
|
+
url: "/x",
|
|
966
|
+
variant: "modal",
|
|
967
|
+
dismissible: true,
|
|
968
|
+
size: null,
|
|
969
|
+
side: null,
|
|
970
|
+
width: null,
|
|
971
|
+
height: null,
|
|
972
|
+
frames: [],
|
|
973
|
+
},
|
|
974
|
+
],
|
|
975
|
+
savedAt: Date.now(),
|
|
976
|
+
});
|
|
977
|
+
expect(restore(emptyFrames, { stackId: STACK_ID })).toBeNull();
|
|
978
|
+
});
|
|
592
979
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= content_tag(:dialog, dialog_attrs) do %><% end %>
|
|
@@ -56,6 +56,15 @@ ModalStack.configure do |config|
|
|
|
56
56
|
# :silent — drop the push, no warning
|
|
57
57
|
config.max_depth_strategy = :warn
|
|
58
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
|
+
|
|
59
68
|
# Replace `data-turbo-confirm` window.confirm with a modal_stack
|
|
60
69
|
# confirmation layer (cf. RFC §15.Q7). Off by default — opt-in.
|
|
61
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 %>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<%# modal_stack panel template — override this file to customise the HTML
|
|
2
|
+
structure that wraps every modal/drawer/bottom-sheet/confirmation panel.
|
|
3
|
+
Available locals:
|
|
4
|
+
content — SafeBuffer, the yielded view content
|
|
5
|
+
back_button — SafeBuffer | nil, pre-rendered back button (nil when back: false)
|
|
6
|
+
wrapper_attrs — Hash { class:, data:, ...extras } — must stay on the root element
|
|
7
|
+
so the JS runtime can read its data attributes
|
|
8
|
+
size — Symbol :sm | :md | :lg | :xl
|
|
9
|
+
variant — Symbol :modal | :drawer | :bottom_sheet | :confirmation
|
|
10
|
+
dismissible — Boolean
|
|
11
|
+
side — Symbol | nil :left | :right | :top | :bottom
|
|
12
|
+
width — String | nil CSS value (e.g. "42rem")
|
|
13
|
+
height — String | nil CSS value
|
|
14
|
+
transition — Symbol | nil :slide | :fade | :none %>
|
|
15
|
+
<%= content_tag(:div, wrapper_attrs) do %>
|
|
16
|
+
<%= back_button %>
|
|
17
|
+
<%= content %>
|
|
18
|
+
<% end %>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/base"
|
|
5
|
+
|
|
6
|
+
module ModalStack
|
|
7
|
+
module Generators
|
|
8
|
+
class ViewsGenerator < Rails::Generators::Base
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
class_option :panel, type: :boolean, default: false,
|
|
12
|
+
desc: "Copy only the panel partial"
|
|
13
|
+
class_option :dialog, type: :boolean, default: false,
|
|
14
|
+
desc: "Copy only the dialog partial"
|
|
15
|
+
|
|
16
|
+
def copy_views
|
|
17
|
+
if options[:panel]
|
|
18
|
+
copy_panel
|
|
19
|
+
elsif options[:dialog]
|
|
20
|
+
copy_dialog
|
|
21
|
+
else
|
|
22
|
+
copy_panel
|
|
23
|
+
copy_dialog
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def show_readme
|
|
28
|
+
say <<~TXT, :green
|
|
29
|
+
|
|
30
|
+
modal_stack views ejected to app/views/modal_stack/.
|
|
31
|
+
|
|
32
|
+
Edit the copied partials to override the default HTML structure.
|
|
33
|
+
The `wrapper_attrs` / `dialog_attrs` locals carry the required
|
|
34
|
+
data attributes — keep them on the root element so the JS runtime
|
|
35
|
+
continues to work.
|
|
36
|
+
TXT
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def copy_panel
|
|
42
|
+
copy_file "_panel.html.erb", "app/views/modal_stack/_panel.html.erb"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def copy_dialog
|
|
46
|
+
copy_file "_dialog.html.erb", "app/views/modal_stack/_dialog.html.erb"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|