modal_stack 0.3.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 +33 -0
- data/README.md +113 -32
- data/app/assets/javascripts/modal_stack.js +488 -50
- 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 +50 -0
- data/app/javascript/modal_stack/install.js +7 -1
- data/app/javascript/modal_stack/orchestrator.js +53 -7
- data/app/javascript/modal_stack/orchestrator.test.js +96 -0
- data/app/javascript/modal_stack/runtime.js +167 -5
- data/app/javascript/modal_stack/runtime.test.js +83 -0
- data/app/javascript/modal_stack/state.js +319 -34
- data/app/javascript/modal_stack/state.test.js +394 -9
- 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 +1 -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();
|
|
@@ -311,6 +497,7 @@ describe("pop", () => {
|
|
|
311
497
|
expect(commands).toEqual([
|
|
312
498
|
{ type: "closeDialog" },
|
|
313
499
|
{ type: "unmountTopLayer" },
|
|
500
|
+
{ type: "clearFrameCache", layerId: "L1" },
|
|
314
501
|
{ type: "historyBack", n: 1 },
|
|
315
502
|
{ type: "unlockScroll" },
|
|
316
503
|
{ type: "clearSnapshot" },
|
|
@@ -324,11 +511,28 @@ describe("pop", () => {
|
|
|
324
511
|
expect(state.layers).toHaveLength(1);
|
|
325
512
|
expect(commands).toEqual([
|
|
326
513
|
{ type: "unmountTopLayer" },
|
|
514
|
+
{ type: "clearFrameCache", layerId: "L2" },
|
|
327
515
|
{ type: "historyBack", n: 1 },
|
|
328
516
|
{ type: "inertLayer", layerId: "L1", value: false },
|
|
329
517
|
{ type: "persistSnapshot" },
|
|
330
518
|
]);
|
|
331
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
|
+
});
|
|
332
536
|
});
|
|
333
537
|
|
|
334
538
|
describe("replaceTop", () => {
|
|
@@ -355,7 +559,7 @@ describe("replaceTop", () => {
|
|
|
355
559
|
{
|
|
356
560
|
type: "replaceHistory",
|
|
357
561
|
url: "/projects/42/edit/billing",
|
|
358
|
-
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
|
|
562
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
|
|
359
563
|
},
|
|
360
564
|
{ type: "persistSnapshot" },
|
|
361
565
|
]);
|
|
@@ -400,10 +604,38 @@ describe("replaceTop", () => {
|
|
|
400
604
|
expect(commands[1]).toEqual({
|
|
401
605
|
type: "pushHistory",
|
|
402
606
|
url: "/onboarding/step2",
|
|
403
|
-
historyState: { stackId: STACK_ID, layerId: "L1b", depth: 1 },
|
|
607
|
+
historyState: { stackId: STACK_ID, layerId: "L1b", depth: 1, frameIndex: 0 },
|
|
404
608
|
});
|
|
405
609
|
});
|
|
406
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
|
+
|
|
407
639
|
test("rejects unknown historyMode", () => {
|
|
408
640
|
const s = pushed(freshStack()).state;
|
|
409
641
|
expect(() => replaceTop(s, { url: "/x" }, { historyMode: "wat" })).toThrow(
|
|
@@ -427,11 +659,23 @@ describe("closeAll", () => {
|
|
|
427
659
|
expect(commands).toEqual([
|
|
428
660
|
{ type: "closeDialog" },
|
|
429
661
|
{ type: "unmountAllLayers" },
|
|
662
|
+
{ type: "clearFrameCache", layerId: "L1" },
|
|
663
|
+
{ type: "clearFrameCache", layerId: "L2" },
|
|
664
|
+
{ type: "clearFrameCache", layerId: "L3" },
|
|
430
665
|
{ type: "unlockScroll" },
|
|
431
666
|
{ type: "historyBack", n: 3 },
|
|
432
667
|
{ type: "clearSnapshot" },
|
|
433
668
|
]);
|
|
434
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
|
+
});
|
|
435
679
|
});
|
|
436
680
|
|
|
437
681
|
describe("handlePopstate", () => {
|
|
@@ -451,6 +695,8 @@ describe("handlePopstate", () => {
|
|
|
451
695
|
expect(commands).toEqual([
|
|
452
696
|
{ type: "closeDialog" },
|
|
453
697
|
{ type: "unmountAllLayers" },
|
|
698
|
+
{ type: "clearFrameCache", layerId: "L1" },
|
|
699
|
+
{ type: "clearFrameCache", layerId: "L2" },
|
|
454
700
|
{ type: "unlockScroll" },
|
|
455
701
|
{ type: "clearSnapshot" },
|
|
456
702
|
]);
|
|
@@ -467,12 +713,13 @@ describe("handlePopstate", () => {
|
|
|
467
713
|
test("back: targetDepth < current pops layers and un-inerts new top (no historyBack)", () => {
|
|
468
714
|
const s = buildTwoLayer();
|
|
469
715
|
const { state, commands } = handlePopstate(s, {
|
|
470
|
-
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
|
|
716
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
|
|
471
717
|
locationHref: "/projects/42/edit",
|
|
472
718
|
});
|
|
473
719
|
expect(state.layers.map((l) => l.id)).toEqual(["L1"]);
|
|
474
720
|
expect(commands).toEqual([
|
|
475
721
|
{ type: "unmountTopLayer" },
|
|
722
|
+
{ type: "clearFrameCache", layerId: "L2" },
|
|
476
723
|
{ type: "inertLayer", layerId: "L1", value: false },
|
|
477
724
|
{ type: "persistSnapshot" },
|
|
478
725
|
]);
|
|
@@ -490,6 +737,8 @@ describe("handlePopstate", () => {
|
|
|
490
737
|
{ type: "closeDialog" },
|
|
491
738
|
{ type: "unmountTopLayer" },
|
|
492
739
|
{ type: "unmountTopLayer" },
|
|
740
|
+
{ type: "clearFrameCache", layerId: "L1" },
|
|
741
|
+
{ type: "clearFrameCache", layerId: "L2" },
|
|
493
742
|
{ type: "unlockScroll" },
|
|
494
743
|
{ type: "clearSnapshot" },
|
|
495
744
|
]);
|
|
@@ -514,7 +763,7 @@ describe("handlePopstate", () => {
|
|
|
514
763
|
{ historyMode: "push" },
|
|
515
764
|
).state;
|
|
516
765
|
const { state, commands } = handlePopstate(after, {
|
|
517
|
-
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
|
|
766
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
|
|
518
767
|
locationHref: "/onboarding/step1",
|
|
519
768
|
});
|
|
520
769
|
expect(state.layers[0]).toMatchObject({
|
|
@@ -522,6 +771,7 @@ describe("handlePopstate", () => {
|
|
|
522
771
|
url: "/onboarding/step1",
|
|
523
772
|
});
|
|
524
773
|
expect(commands).toEqual([
|
|
774
|
+
{ type: "clearFrameCache", layerId: "L1b" },
|
|
525
775
|
{
|
|
526
776
|
type: "morphTopLayer",
|
|
527
777
|
layerId: "L1",
|
|
@@ -537,12 +787,56 @@ describe("handlePopstate", () => {
|
|
|
537
787
|
test("same depth, same layerId is a noop", () => {
|
|
538
788
|
const s = pushed(freshStack()).state;
|
|
539
789
|
const { state, commands } = handlePopstate(s, {
|
|
540
|
-
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1 },
|
|
790
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
|
|
541
791
|
locationHref: "/projects/42/edit",
|
|
542
792
|
});
|
|
543
793
|
expect(state).toEqual(s);
|
|
544
794
|
expect(commands).toEqual([]);
|
|
545
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
|
+
});
|
|
546
840
|
});
|
|
547
841
|
|
|
548
842
|
describe("snapshot / restore", () => {
|
|
@@ -575,13 +869,13 @@ describe("snapshot / restore", () => {
|
|
|
575
869
|
expect(restore("not-json", { stackId: STACK_ID })).toBeNull();
|
|
576
870
|
expect(restore("", { stackId: STACK_ID })).toBeNull();
|
|
577
871
|
expect(restore(null, { stackId: STACK_ID })).toBeNull();
|
|
578
|
-
expect(restore('{"v":
|
|
872
|
+
expect(restore('{"v":3}', { stackId: STACK_ID })).toBeNull();
|
|
579
873
|
expect(restore('{"v":1}', { stackId: STACK_ID })).toBeNull();
|
|
580
874
|
});
|
|
581
875
|
|
|
582
876
|
test("returns null when a layer has unknown variant", () => {
|
|
583
877
|
const malicious = JSON.stringify({
|
|
584
|
-
v:
|
|
878
|
+
v: 2,
|
|
585
879
|
stackId: STACK_ID,
|
|
586
880
|
baseUrl: "/",
|
|
587
881
|
layers: [{ id: "L1", url: "/", variant: "popover", dismissible: true }],
|
|
@@ -589,4 +883,95 @@ describe("snapshot / restore", () => {
|
|
|
589
883
|
});
|
|
590
884
|
expect(restore(malicious, { stackId: STACK_ID })).toBeNull();
|
|
591
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
|
+
});
|
|
592
977
|
});
|
|
@@ -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
|
data/lib/modal_stack/capybara.rb
CHANGED
|
@@ -14,6 +14,7 @@ module ModalStack
|
|
|
14
14
|
module Capybara
|
|
15
15
|
DIALOG_SELECTOR = "#modal-stack-root"
|
|
16
16
|
LAYER_SELECTOR = '[data-modal-stack-target="layer"]:not([data-leaving])'
|
|
17
|
+
FRAME_SELECTOR = "[data-modal-stack-frame]:not([data-leaving])"
|
|
17
18
|
|
|
18
19
|
# Scope Capybara matchers to a specific layer of the stack.
|
|
19
20
|
#
|
|
@@ -81,5 +82,25 @@ module ModalStack
|
|
|
81
82
|
def modal_stack_depth
|
|
82
83
|
::Capybara.current_session.all(:css, LAYER_SELECTOR, wait: 0).size
|
|
83
84
|
end
|
|
85
|
+
|
|
86
|
+
# Capybara matcher: assert the top layer's path frame depth.
|
|
87
|
+
#
|
|
88
|
+
# expect(page).to have_modal_frames(2)
|
|
89
|
+
#
|
|
90
|
+
# Layers without a path read as a single frame.
|
|
91
|
+
def have_modal_frames(count, **)
|
|
92
|
+
have_css("#{LAYER_SELECTOR}[data-frame-depth=\"#{count}\"]", **)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Scope Capybara to the *current* frame inside the top (or specified)
|
|
96
|
+
# layer — i.e. the frame that's not animating out.
|
|
97
|
+
def within_modal_frame(depth: nil, **, &)
|
|
98
|
+
layers = ::Capybara.current_session.all(:css, LAYER_SELECTOR, minimum: 1, **)
|
|
99
|
+
layer = depth ? layers[depth - 1] : layers.last
|
|
100
|
+
raise ::Capybara::ElementNotFound, "no modal_stack layer at depth #{depth}" unless layer
|
|
101
|
+
|
|
102
|
+
frame = layer.first(:css, FRAME_SELECTOR, minimum: 1, **)
|
|
103
|
+
::Capybara.current_session.within(frame, &)
|
|
104
|
+
end
|
|
84
105
|
end
|
|
85
106
|
end
|