Skip to content

Commit 900a650

Browse files
committed
feat: add requiredVariants option for mandatory variant enforcement
1 parent 1b67c1a commit 900a650

File tree

3 files changed

+306
-12
lines changed

3 files changed

+306
-12
lines changed

src/__tests__/tv.test.ts

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2628,3 +2628,244 @@ describe("Tailwind Variants (TV) - Tailwind Merge", () => {
26282628
expect(result).toHaveClass(["text-medium", "text-blue-500", "w-unit-4"]);
26292629
});
26302630
});
2631+
2632+
describe("Tailwind Variants (TV) - Required Variants", () => {
2633+
test("should throw error when required variant is not provided", () => {
2634+
const button = tv({
2635+
base: "font-semibold border border-blue-500 text-blue-500 hover:bg-blue-500 hover:text-white",
2636+
variants: {
2637+
intent: {
2638+
primary: "bg-blue-500 text-white border-transparent hover:bg-blue-600",
2639+
secondary: "bg-white text-blue-500 border-blue-500 hover:bg-blue-500 hover:text-white",
2640+
},
2641+
size: {
2642+
small: "text-sm px-2 py-1",
2643+
medium: "text-base px-4 py-2",
2644+
large: "text-lg px-6 py-3",
2645+
},
2646+
},
2647+
requiredVariants: ["intent", "size"] as const,
2648+
});
2649+
2650+
// Should throw when intent is missing
2651+
// @ts-expect-error - Testing runtime error with missing required variant
2652+
expect(() => button({size: "small"})).toThrow(
2653+
'Missing required variant: "intent". This variant must be provided.',
2654+
);
2655+
2656+
// Should throw when size is missing
2657+
// @ts-expect-error - Testing runtime error with missing required variant
2658+
expect(() => button({intent: "primary"})).toThrow(
2659+
'Missing required variant: "size". This variant must be provided.',
2660+
);
2661+
2662+
// Should throw when both are missing
2663+
expect(() => button()).toThrow(
2664+
'Missing required variant: "intent". This variant must be provided.',
2665+
);
2666+
});
2667+
2668+
test("should work correctly when all required variants are provided", () => {
2669+
const button = tv({
2670+
base: "font-semibold border border-blue-500 text-blue-500 hover:bg-blue-500 hover:text-white",
2671+
variants: {
2672+
intent: {
2673+
primary: "bg-blue-500 text-white border-transparent hover:bg-blue-600",
2674+
secondary: "bg-white text-blue-500 border-blue-500 hover:bg-blue-500 hover:text-white",
2675+
},
2676+
size: {
2677+
small: "text-sm px-2 py-1",
2678+
medium: "text-base px-4 py-2",
2679+
large: "text-lg px-6 py-3",
2680+
},
2681+
},
2682+
requiredVariants: ["intent", "size"] as const,
2683+
});
2684+
2685+
const expectedResult =
2686+
"font-semibold border hover:text-white bg-blue-500 text-white border-transparent hover:bg-blue-600 text-base px-4 py-2";
2687+
const result = button({intent: "primary", size: "medium"});
2688+
2689+
expect(result).toBe(expectedResult);
2690+
});
2691+
2692+
test("should work with defaultVariants for required variants", () => {
2693+
const button = tv({
2694+
base: "font-semibold border",
2695+
variants: {
2696+
intent: {
2697+
primary: "bg-blue-500 text-white",
2698+
secondary: "bg-white text-blue-500",
2699+
},
2700+
size: {
2701+
small: "text-sm px-2 py-1",
2702+
medium: "text-base px-4 py-2",
2703+
},
2704+
},
2705+
defaultVariants: {
2706+
intent: "primary",
2707+
size: "medium",
2708+
},
2709+
requiredVariants: ["intent"] as const,
2710+
});
2711+
2712+
const expectedResult1 = "font-semibold border bg-white text-blue-500 text-base px-4 py-2";
2713+
const result1 = button({intent: "secondary"});
2714+
2715+
expect(result1).toBe(expectedResult1);
2716+
2717+
// Should still throw when required variant is not provided, even with default
2718+
expect(() => button()).toThrow(
2719+
'Missing required variant: "intent". This variant must be provided.',
2720+
);
2721+
});
2722+
2723+
test("should work with slots and required variants", () => {
2724+
const card = tv({
2725+
slots: {
2726+
base: "rounded-lg border shadow-md",
2727+
header: "p-4 border-b",
2728+
body: "p-4",
2729+
footer: "p-4 border-t bg-gray-50",
2730+
},
2731+
variants: {
2732+
size: {
2733+
small: {
2734+
base: "max-w-sm",
2735+
header: "p-2",
2736+
body: "p-2",
2737+
footer: "p-2",
2738+
},
2739+
large: {
2740+
base: "max-w-4xl",
2741+
header: "p-6",
2742+
body: "p-6",
2743+
footer: "p-6",
2744+
},
2745+
},
2746+
},
2747+
requiredVariants: ["size"] as const,
2748+
});
2749+
2750+
// Should throw when required variant is missing
2751+
expect(() => card()).toThrow(
2752+
'Missing required variant: "size". This variant must be provided.',
2753+
);
2754+
2755+
const {base, header, body, footer} = card({size: "small"});
2756+
2757+
expect(base()).toBe("rounded-lg border shadow-md max-w-sm");
2758+
expect(header()).toBe("border-b p-2");
2759+
expect(body()).toBe("p-2");
2760+
expect(footer()).toBe("border-t bg-gray-50 p-2");
2761+
});
2762+
2763+
test("should work without required variants", () => {
2764+
const button = tv({
2765+
base: "font-semibold",
2766+
variants: {
2767+
intent: {
2768+
primary: "bg-blue-500 text-white",
2769+
secondary: "bg-white text-blue-500",
2770+
},
2771+
},
2772+
});
2773+
2774+
const expectedResult1 = "font-semibold";
2775+
const result1 = button();
2776+
2777+
expect(result1).toBe(expectedResult1);
2778+
2779+
const expectedResult2 = "font-semibold bg-blue-500 text-white";
2780+
const result2 = button({intent: "primary"});
2781+
2782+
expect(result2).toBe(expectedResult2);
2783+
});
2784+
2785+
test("should throw error if requiredVariants is not an array", () => {
2786+
expect(() =>
2787+
tv({
2788+
base: "font-semibold",
2789+
variants: {
2790+
intent: {
2791+
primary: "bg-blue-500",
2792+
},
2793+
},
2794+
// @ts-expect-error - testing runtime validation
2795+
requiredVariants: "intent",
2796+
})(),
2797+
).toThrow('The "requiredVariants" prop must be an array. Received: string');
2798+
2799+
expect(() =>
2800+
tv({
2801+
base: "font-semibold",
2802+
variants: {
2803+
intent: {
2804+
primary: "bg-blue-500",
2805+
},
2806+
},
2807+
// @ts-expect-error - testing runtime validation
2808+
requiredVariants: {intent: true},
2809+
})(),
2810+
).toThrow('The "requiredVariants" prop must be an array. Received: object');
2811+
});
2812+
2813+
test("should expose requiredVariants on component", () => {
2814+
const button = tv({
2815+
base: "font-semibold",
2816+
variants: {
2817+
intent: {
2818+
primary: "bg-blue-500",
2819+
secondary: "bg-white",
2820+
},
2821+
size: {
2822+
small: "text-sm",
2823+
large: "text-lg",
2824+
},
2825+
},
2826+
requiredVariants: ["intent"] as const,
2827+
});
2828+
2829+
expect(button.requiredVariants).toEqual(["intent"]);
2830+
});
2831+
2832+
test("should work with extend and required variants", () => {
2833+
const baseButton = tv({
2834+
base: "font-semibold",
2835+
variants: {
2836+
intent: {
2837+
primary: "bg-blue-500",
2838+
secondary: "bg-white",
2839+
},
2840+
},
2841+
requiredVariants: ["intent"] as const,
2842+
});
2843+
2844+
const iconButton = tv({
2845+
extend: baseButton,
2846+
variants: {
2847+
size: {
2848+
small: "p-1",
2849+
large: "p-3",
2850+
},
2851+
},
2852+
requiredVariants: ["intent", "size"] as const,
2853+
});
2854+
2855+
// Should throw when required variants from extended component are missing
2856+
// @ts-expect-error - Testing runtime error with missing required variant
2857+
expect(() => iconButton({size: "small"})).toThrow(
2858+
'Missing required variant: "intent". This variant must be provided.',
2859+
);
2860+
2861+
// @ts-expect-error - Testing runtime error with missing required variant
2862+
expect(() => iconButton({intent: "primary"})).toThrow(
2863+
'Missing required variant: "size". This variant must be provided.',
2864+
);
2865+
2866+
const expectedResult = "font-semibold p-1 bg-blue-500";
2867+
const result = iconButton({intent: "primary", size: "small"});
2868+
2869+
expect(result).toBe(expectedResult);
2870+
});
2871+
});

src/core.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const getTailwindVariants = (cn) => {
2020
compoundVariants: compoundVariantsProps = [],
2121
compoundSlots = [],
2222
defaultVariants: defaultVariantsProps = {},
23+
requiredVariants = [],
2324
} = options;
2425

2526
const config = {...defaultConfig, ...configProp};
@@ -82,6 +83,25 @@ export const getTailwindVariants = (cn) => {
8283
);
8384
}
8485

86+
if (requiredVariants && !Array.isArray(requiredVariants)) {
87+
throw new TypeError(
88+
`The "requiredVariants" prop must be an array. Received: ${typeof requiredVariants}`,
89+
);
90+
}
91+
92+
// Validate required variants
93+
if (requiredVariants && Array.isArray(requiredVariants)) {
94+
for (let i = 0; i < requiredVariants.length; i++) {
95+
const requiredVariant = requiredVariants[i];
96+
97+
if (props?.[requiredVariant] === undefined) {
98+
throw new Error(
99+
`Missing required variant: "${requiredVariant}". This variant must be provided.`,
100+
);
101+
}
102+
}
103+
}
104+
85105
const getScreenVariantValues = (screen, screenVariantValue, acc = [], slotKey) => {
86106
let result = acc;
87107

@@ -419,6 +439,7 @@ export const getTailwindVariants = (cn) => {
419439
component.defaultVariants = defaultVariants;
420440
component.compoundSlots = compoundSlots;
421441
component.compoundVariants = compoundVariants;
442+
component.requiredVariants = requiredVariants;
422443

423444
return component;
424445
};

0 commit comments

Comments
 (0)