clava 0.2.4 → 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.
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from "vitest";
1
+ import { describe, expect, test, vi } from "vitest";
2
2
  import {
3
3
  CONFIGS,
4
4
  createCVFromConfig,
@@ -47,6 +47,84 @@ for (const config of Object.values(CONFIGS)) {
47
47
  expect(getStyleClass(props)).toEqual({ class: cls("lg red") });
48
48
  });
49
49
 
50
+ test("computed re-runs when it changes variants", () => {
51
+ const component = getModeComponent(
52
+ mode,
53
+ cv({
54
+ variants: {
55
+ size: { sm: "sm", lg: "lg" },
56
+ color: { red: "red", blue: "blue" },
57
+ },
58
+ computed: ({ variants, setVariants, addClass }) => {
59
+ if (variants.size === "lg") {
60
+ setVariants({ color: "red" });
61
+ }
62
+ if (variants.color === "red") {
63
+ addClass("computed-red");
64
+ }
65
+ },
66
+ }),
67
+ );
68
+ const props = component({ size: "lg" });
69
+ expect(getStyleClass(props)).toEqual({
70
+ class: cls("lg red computed-red"),
71
+ });
72
+ });
73
+
74
+ test("computed re-runs when setDefaultVariants changes variants", () => {
75
+ const component = getModeComponent(
76
+ mode,
77
+ cv({
78
+ variants: {
79
+ size: { sm: "sm", lg: "lg" },
80
+ color: { red: "red", blue: "blue" },
81
+ },
82
+ computed: ({ variants, setDefaultVariants, addClass }) => {
83
+ setDefaultVariants({ color: "red" });
84
+ if (variants.color === "red") {
85
+ setDefaultVariants({ size: "lg" });
86
+ }
87
+ if (variants.size === "lg") {
88
+ addClass("computed-lg");
89
+ }
90
+ },
91
+ }),
92
+ );
93
+ const props = component();
94
+ expect(getStyleClass(props)).toEqual({
95
+ class: cls("lg red computed-lg"),
96
+ });
97
+ });
98
+
99
+ test("computed converges with NaN setDefaultVariants", () => {
100
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
101
+ const component = getModeComponent(
102
+ mode,
103
+ cv({
104
+ computedVariants: {
105
+ value: (value: number) => (Number.isNaN(value) ? "nan" : null),
106
+ },
107
+ computed: ({ variants, setDefaultVariants, addClass }) => {
108
+ setDefaultVariants({ value: Number.NaN });
109
+ if (Number.isNaN(variants.value)) {
110
+ addClass("computed-nan");
111
+ }
112
+ },
113
+ }),
114
+ );
115
+
116
+ try {
117
+ const props = component();
118
+ expect(getStyleClass(props)).toEqual({
119
+ class: cls("nan computed-nan"),
120
+ });
121
+ expect(component.getVariants()).toEqual({ value: Number.NaN });
122
+ expect(warn).not.toHaveBeenCalled();
123
+ } finally {
124
+ warn.mockRestore();
125
+ }
126
+ });
127
+
50
128
  test("computed with setDefaultVariants", () => {
51
129
  const component = getModeComponent(
52
130
  mode,
@@ -348,6 +426,351 @@ for (const config of Object.values(CONFIGS)) {
348
426
  expect(getStyleClass(props)).toEqual({ class: cls("sm red") });
349
427
  });
350
428
 
429
+ test("computed re-runs when base component computed changes variants", () => {
430
+ const base = cv({
431
+ variants: { size: { sm: "sm", lg: "lg" }, active: "" },
432
+ defaultVariants: { size: "sm" },
433
+ computed: ({ variants, setVariants }) => {
434
+ if (variants.active) {
435
+ setVariants({ size: "lg" });
436
+ }
437
+ },
438
+ });
439
+ const component = getModeComponent(
440
+ mode,
441
+ cv({
442
+ extend: [base],
443
+ variants: { color: { red: "red", blue: "blue" } },
444
+ computed: ({ variants, setVariants }) => {
445
+ if (variants.size === "lg") {
446
+ setVariants({ color: "red" });
447
+ }
448
+ },
449
+ }),
450
+ );
451
+ const props = component({ active: true });
452
+ expect(getStyleClass(props)).toEqual({ class: cls("lg red") });
453
+ });
454
+
455
+ test("base computed setDefaultVariants works after its own setVariants re-run", () => {
456
+ const base = cv({
457
+ variants: {
458
+ size: { sm: "sm", lg: "lg" },
459
+ active: "",
460
+ mode: { on: "on" },
461
+ },
462
+ defaultVariants: { size: "sm" },
463
+ computed: ({ variants, setVariants, setDefaultVariants }) => {
464
+ if (variants.active) {
465
+ setVariants({ mode: "on" });
466
+ }
467
+ if (variants.mode === "on") {
468
+ setDefaultVariants({ size: "lg" });
469
+ }
470
+ },
471
+ });
472
+ const component = getModeComponent(mode, cv({ extend: [base] }));
473
+ const props = component({ active: true });
474
+ expect(getStyleClass(props)).toEqual({ class: cls("lg on") });
475
+ });
476
+
477
+ test("computed setVariants uses the latest pending value", () => {
478
+ const component = getModeComponent(
479
+ mode,
480
+ cv({
481
+ variants: { size: { sm: "sm", lg: "lg" } },
482
+ defaultVariants: { size: "sm" },
483
+ computed: ({ setVariants }) => {
484
+ setVariants({ size: "lg" });
485
+ setVariants({ size: "sm" });
486
+ },
487
+ }),
488
+ );
489
+ const props = component();
490
+ expect(getStyleClass(props)).toEqual({ class: cls("sm") });
491
+ });
492
+
493
+ test("computed setVariants does not mutate props with plain extends", () => {
494
+ const base = cv({
495
+ variants: { color: { red: "red", blue: "blue" } },
496
+ });
497
+ const component = getModeComponent(
498
+ mode,
499
+ cv({
500
+ extend: [base],
501
+ variants: { active: "" },
502
+ computed: ({ variants, setVariants }) => {
503
+ if (variants.active) {
504
+ setVariants({ color: "red" });
505
+ }
506
+ },
507
+ }),
508
+ );
509
+ const mutableProps = { active: true };
510
+ component(mutableProps);
511
+ expect(mutableProps).toEqual({ active: true });
512
+
513
+ const frozenProps = Object.freeze({ active: true });
514
+ const getProps = () => component(frozenProps);
515
+ expect(getProps).not.toThrow();
516
+ expect(getStyleClass(getProps())).toEqual({ class: cls("red") });
517
+ });
518
+
519
+ test("child setVariants keeps overriding base setDefaultVariants across re-runs", () => {
520
+ const base = cv({
521
+ variants: { color: { red: "red", blue: "blue" } },
522
+ computed: ({ setDefaultVariants }) => {
523
+ setDefaultVariants({ color: "blue" });
524
+ },
525
+ });
526
+ const component = getModeComponent(
527
+ mode,
528
+ cv({
529
+ extend: [base],
530
+ variants: { size: { sm: "sm", lg: "lg" } },
531
+ defaultVariants: { size: "sm" },
532
+ computed: ({ variants, setVariants }) => {
533
+ if (variants.size === "sm") {
534
+ setVariants({ color: "red" });
535
+ }
536
+ },
537
+ }),
538
+ );
539
+ const props = component();
540
+ expect(getStyleClass(props)).toEqual({ class: cls("red sm") });
541
+ });
542
+
543
+ test("computed setVariants sticks across re-runs", () => {
544
+ const base = cv({
545
+ variants: { color: { red: "red", blue: "blue" } },
546
+ computed: ({ setDefaultVariants }) => {
547
+ setDefaultVariants({ color: "blue" });
548
+ },
549
+ });
550
+ const component = getModeComponent(
551
+ mode,
552
+ cv({
553
+ extend: [base],
554
+ variants: { color: { red: "", blue: "" }, done: "" },
555
+ computed: ({ variants, setVariants }) => {
556
+ if (!variants.done) {
557
+ setVariants({ color: "red", done: true });
558
+ }
559
+ },
560
+ }),
561
+ );
562
+ const props = component();
563
+ expect(getStyleClass(props)).toEqual({ class: cls("red") });
564
+ });
565
+
566
+ test("base computed setDefaultVariants can override child static defaults after a re-run", () => {
567
+ const base = cv({
568
+ variants: {
569
+ size: { sm: "sm", lg: "lg" },
570
+ active: "",
571
+ mode: { on: "on" },
572
+ },
573
+ computed: ({ variants, setVariants, setDefaultVariants }) => {
574
+ if (variants.active) {
575
+ setVariants({ mode: "on" });
576
+ }
577
+ if (variants.mode === "on") {
578
+ setDefaultVariants({ size: "lg" });
579
+ }
580
+ },
581
+ });
582
+ const component = getModeComponent(
583
+ mode,
584
+ cv({ extend: [base], defaultVariants: { size: "sm" } }),
585
+ );
586
+ const props = component({ active: true });
587
+ expect(getStyleClass(props)).toEqual({ class: cls("lg on") });
588
+ });
589
+
590
+ test("setVariants from earlier extends overrides setDefaultVariants from later extends", () => {
591
+ const first = cv({
592
+ variants: { color: { red: "first-red", blue: "first-blue" } },
593
+ computed: ({ setVariants }) => {
594
+ setVariants({ color: "red" });
595
+ },
596
+ });
597
+ const second = cv({
598
+ variants: { color: { red: "second-red", blue: "second-blue" } },
599
+ computed: ({ setDefaultVariants }) => {
600
+ setDefaultVariants({ color: "blue" });
601
+ },
602
+ });
603
+ const component = getModeComponent(mode, cv({ extend: [first, second] }));
604
+ const props = component();
605
+ expect(getStyleClass(props)).toEqual({
606
+ class: cls("first-red second-red"),
607
+ });
608
+ });
609
+
610
+ test("setDefaultVariants from later extends overrides setDefaultVariants from earlier extends", () => {
611
+ const first = cv({
612
+ variants: { color: { red: "first-red", blue: "first-blue" } },
613
+ computed: ({ setDefaultVariants }) => {
614
+ setDefaultVariants({ color: "red" });
615
+ },
616
+ });
617
+ const second = cv({
618
+ variants: { color: { red: "second-red", blue: "second-blue" } },
619
+ computed: ({ setDefaultVariants }) => {
620
+ setDefaultVariants({ color: "blue" });
621
+ },
622
+ });
623
+ const component = getModeComponent(mode, cv({ extend: [first, second] }));
624
+ const props = component();
625
+ expect(getStyleClass(props)).toEqual({
626
+ class: cls("first-blue second-blue"),
627
+ });
628
+ });
629
+
630
+ test("setDefaultVariants does not override stable setVariants on later passes", () => {
631
+ const base = cv({
632
+ variants: { color: { red: "base-red", blue: "base-blue" } },
633
+ computed: ({ setVariants }) => {
634
+ setVariants({ color: "red" });
635
+ },
636
+ });
637
+ const component = getModeComponent(
638
+ mode,
639
+ cv({
640
+ extend: [base],
641
+ variants: { color: { red: "child-red", blue: "child-blue" } },
642
+ computed: ({ variants, setDefaultVariants }) => {
643
+ if (variants.color === "red") {
644
+ setDefaultVariants({ color: "blue" });
645
+ }
646
+ },
647
+ }),
648
+ );
649
+ const props = component();
650
+ expect(getStyleClass(props)).toEqual({
651
+ class: cls("base-red child-red"),
652
+ });
653
+ });
654
+
655
+ test("setDefaultVariants does not override setVariants from a previous pass", () => {
656
+ const component = getModeComponent(
657
+ mode,
658
+ cv({
659
+ variants: {
660
+ color: { red: "red", blue: "blue" },
661
+ done: "",
662
+ },
663
+ computed: ({ variants, setVariants, setDefaultVariants }) => {
664
+ if (!variants.done) {
665
+ setVariants({ color: "red", done: true });
666
+ }
667
+ if (variants.done) {
668
+ setDefaultVariants({ color: "blue" });
669
+ }
670
+ },
671
+ }),
672
+ );
673
+ const props = component();
674
+ expect(getStyleClass(props)).toEqual({ class: cls("red") });
675
+ });
676
+
677
+ test("computed warns when variants keep changing", () => {
678
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
679
+ const component = getModeComponent(
680
+ mode,
681
+ cv({
682
+ variants: { size: { sm: "sm", lg: "lg" } },
683
+ defaultVariants: { size: "sm" },
684
+ computed: ({ variants, setVariants }) => {
685
+ setVariants({ size: variants.size === "sm" ? "lg" : "sm" });
686
+ },
687
+ }),
688
+ );
689
+
690
+ try {
691
+ component();
692
+ expect(warn).toHaveBeenCalledWith(
693
+ expect.stringContaining(
694
+ "Maximum computed update iterations exceeded",
695
+ ),
696
+ );
697
+ } finally {
698
+ warn.mockRestore();
699
+ }
700
+ });
701
+
702
+ test("computed warning is shared across extended components", () => {
703
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
704
+ const base = cv({
705
+ variants: { size: { sm: "sm", lg: "lg" } },
706
+ defaultVariants: { size: "sm" },
707
+ computed: ({ variants, setVariants }) => {
708
+ setVariants({ size: variants.size === "sm" ? "lg" : "sm" });
709
+ },
710
+ });
711
+ const component = getModeComponent(mode, cv({ extend: [base] }));
712
+
713
+ try {
714
+ component();
715
+ expect(warn).toHaveBeenCalledTimes(1);
716
+ expect(warn).toHaveBeenCalledWith(
717
+ expect.stringContaining(
718
+ "Maximum computed update iterations exceeded",
719
+ ),
720
+ );
721
+ } finally {
722
+ warn.mockRestore();
723
+ }
724
+ });
725
+
726
+ test("getVariants warns when variants keep changing", () => {
727
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
728
+ const component = getModeComponent(
729
+ mode,
730
+ cv({
731
+ variants: { size: { sm: "sm", lg: "lg" } },
732
+ defaultVariants: { size: "sm" },
733
+ computed: ({ variants, setVariants }) => {
734
+ setVariants({ size: variants.size === "sm" ? "lg" : "sm" });
735
+ },
736
+ }),
737
+ );
738
+
739
+ try {
740
+ component.getVariants();
741
+ expect(warn).toHaveBeenCalledWith(
742
+ expect.stringContaining(
743
+ "Maximum computed update iterations exceeded",
744
+ ),
745
+ );
746
+ } finally {
747
+ warn.mockRestore();
748
+ }
749
+ });
750
+
751
+ test("computed warning is omitted in production", () => {
752
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
753
+ vi.stubEnv("NODE_ENV", "production");
754
+ const component = getModeComponent(
755
+ mode,
756
+ cv({
757
+ variants: { size: { sm: "sm", lg: "lg" } },
758
+ defaultVariants: { size: "sm" },
759
+ computed: ({ variants, setVariants }) => {
760
+ setVariants({ size: variants.size === "sm" ? "lg" : "sm" });
761
+ },
762
+ }),
763
+ );
764
+
765
+ try {
766
+ component();
767
+ expect(warn).not.toHaveBeenCalled();
768
+ } finally {
769
+ vi.unstubAllEnvs();
770
+ warn.mockRestore();
771
+ }
772
+ });
773
+
351
774
  test("computed setDefaultVariants when explicitly passing undefined", () => {
352
775
  const component = getModeComponent(
353
776
  mode,
@@ -1,4 +1,5 @@
1
1
  import type { ComponentProps, JSX } from "solid-js";
2
+ import { splitProps as splitSolidProps } from "solid-js";
2
3
  import { expect, expectTypeOf, test } from "vitest";
3
4
  import { type VariantProps, cv, splitProps } from "../src/index.ts";
4
5
  import { type HTMLObjProps } from "../src/types.ts";
@@ -32,3 +33,32 @@ test("component props", () => {
32
33
  style: { "font-size": "16px" },
33
34
  });
34
35
  });
36
+
37
+ test("solid splitProps accepts html propKeys", () => {
38
+ const component = cv({ variants: { size: { sm: "sm", md: "md" } } });
39
+ expect(component.html.propKeys).toEqual(["class", "style", "size"]);
40
+
41
+ interface Props
42
+ extends ComponentProps<"button">, VariantProps<typeof component> {}
43
+ const props: Props = { class: "custom", size: "md", id: "my-button" };
44
+
45
+ const [variantProps, rest] = splitSolidProps(props, component.html.propKeys);
46
+ expect(variantProps).toEqual({ class: "custom", size: "md" });
47
+ expect(rest).toEqual({ id: "my-button" });
48
+ });
49
+
50
+ test("solid splitProps accepts htmlObj propKeys", () => {
51
+ const component = cv({ variants: { size: { sm: "sm", md: "md" } } });
52
+ expect(component.htmlObj.propKeys).toEqual(["class", "style", "size"]);
53
+
54
+ interface Props
55
+ extends ComponentProps<"button">, VariantProps<typeof component> {}
56
+ const props: Props = { class: "custom", size: "md", id: "my-button" };
57
+
58
+ const [variantProps, rest] = splitSolidProps(
59
+ props,
60
+ component.htmlObj.propKeys,
61
+ );
62
+ expect(variantProps).toEqual({ class: "custom", size: "md" });
63
+ expect(rest).toEqual({ id: "my-button" });
64
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, expectTypeOf, test } from "vitest";
2
- import { splitProps } from "../src/index.ts";
2
+ import { cv, splitProps } from "../src/index.ts";
3
3
  import {
4
4
  CONFIGS,
5
5
  type HTMLProperties,
@@ -588,3 +588,75 @@ for (const config of Object.values(CONFIGS)) {
588
588
  });
589
589
  });
590
590
  }
591
+
592
+ test.each([
593
+ [
594
+ "missing getVariants",
595
+ {
596
+ propKeys: ["size"],
597
+ variantKeys: ["size"],
598
+ },
599
+ ],
600
+ [
601
+ "non-callable getVariants",
602
+ {
603
+ getVariants: true,
604
+ propKeys: ["size"],
605
+ variantKeys: ["size"],
606
+ },
607
+ ],
608
+ [
609
+ "non-array propKeys",
610
+ {
611
+ getVariants: () => ({}),
612
+ propKeys: null,
613
+ variantKeys: ["size"],
614
+ },
615
+ ],
616
+ ] as const)(
617
+ "splitProps ignores malformed component-like sources: %s",
618
+ (_, malformedSource) => {
619
+ const props = { id: "test", size: "lg" };
620
+
621
+ const [sourceProps, otherProps] = splitProps(
622
+ props,
623
+ // @ts-expect-error malformed source
624
+ malformedSource,
625
+ );
626
+ expect(sourceProps).toEqual({});
627
+ expect(otherProps).toEqual(props);
628
+ },
629
+ );
630
+
631
+ test("splitProps ignores sources with malformed variantKeys", () => {
632
+ const component = cv({ variants: { size: { sm: "sm", lg: "lg" } } });
633
+ const props = { id: "test", size: "lg", color: "red" };
634
+ const malformedSource = {
635
+ getVariants: () => ({}),
636
+ propKeys: ["color"],
637
+ variantKeys: null,
638
+ };
639
+
640
+ const result = splitProps(
641
+ props,
642
+ component,
643
+ // @ts-expect-error malformed source
644
+ malformedSource,
645
+ ) as unknown[];
646
+ expect(result).toEqual([{ size: "lg" }, {}, { id: "test", color: "red" }]);
647
+ });
648
+
649
+ test("splitProps type rejects non-string component source keys", () => {
650
+ const props = { size: "lg" };
651
+ const symbolKey = Symbol("size");
652
+
653
+ const [sourceProps, otherProps] = splitProps(props, {
654
+ getVariants: () => ({}),
655
+ // @ts-expect-error component source keys must be strings
656
+ propKeys: [symbolKey],
657
+ // @ts-expect-error component source keys must be strings
658
+ variantKeys: [1],
659
+ });
660
+ expect(sourceProps).toEqual({});
661
+ expect(otherProps).toEqual(props);
662
+ });