diff --git a/src/__tests__/tv.test.ts b/src/__tests__/tv.test.ts index 0b89913..041a533 100644 --- a/src/__tests__/tv.test.ts +++ b/src/__tests__/tv.test.ts @@ -2628,3 +2628,256 @@ describe("Tailwind Variants (TV) - Tailwind Merge", () => { expect(result).toHaveClass(["text-medium", "text-blue-500", "w-unit-4"]); }); }); + +describe("Tailwind Variants (TV) - Required Variants", () => { + test("should throw error when required variant is not provided", () => { + const button = tv({ + base: "font-semibold border border-blue-500 text-blue-500 hover:bg-blue-500 hover:text-white", + variants: { + intent: { + primary: "bg-blue-500 text-white border-transparent hover:bg-blue-600", + secondary: "bg-white text-blue-500 border-blue-500 hover:bg-blue-500 hover:text-white", + }, + size: { + small: "text-sm px-2 py-1", + medium: "text-base px-4 py-2", + large: "text-lg px-6 py-3", + }, + }, + requiredVariants: ["intent", "size"], + }); + + // @ts-expect-error - Testing runtime error with missing required variant + expect(() => button({size: "small"})).toThrow( + 'Missing required variant: "intent". This variant must be provided.', + ); + + // @ts-expect-error - Testing runtime error with missing required variant + expect(() => button({intent: "primary"})).toThrow( + 'Missing required variant: "size". This variant must be provided.', + ); + + expect(() => button()).toThrow( + 'Missing required variant: "intent". This variant must be provided.', + ); + }); + + test("should work correctly when all required variants are provided", () => { + const button = tv({ + base: "font-semibold border border-blue-500 text-blue-500 hover:bg-blue-500 hover:text-white", + variants: { + intent: { + primary: "bg-blue-500 text-white border-transparent hover:bg-blue-600", + secondary: "bg-white text-blue-500 border-blue-500 hover:bg-blue-500 hover:text-white", + }, + size: { + small: "text-sm px-2 py-1", + medium: "text-base px-4 py-2", + large: "text-lg px-6 py-3", + }, + }, + requiredVariants: ["intent", "size"], + }); + + const expectedResult = + "font-semibold border hover:text-white bg-blue-500 text-white border-transparent hover:bg-blue-600 text-base px-4 py-2"; + const result = button({intent: "primary", size: "medium"}); + + expect(result).toBe(expectedResult); + }); + + test("should work with defaultVariants for required variants", () => { + const button = tv({ + base: "font-semibold border", + variants: { + intent: { + primary: "bg-blue-500 text-white", + secondary: "bg-white text-blue-500", + }, + size: { + small: "text-sm px-2 py-1", + medium: "text-base px-4 py-2", + }, + }, + defaultVariants: { + intent: "primary", + size: "medium", + }, + requiredVariants: ["intent"], + }); + + const expectedResult1 = "font-semibold border bg-white text-blue-500 text-base px-4 py-2"; + const result1 = button({intent: "secondary"}); + + expect(result1).toBe(expectedResult1); + + expect(() => button()).toThrow( + 'Missing required variant: "intent". This variant must be provided.', + ); + }); + + test("should work with slots and required variants", () => { + const card = tv({ + slots: { + base: "rounded-lg border shadow-md", + header: "p-4 border-b", + body: "p-4", + footer: "p-4 border-t bg-gray-50", + }, + variants: { + size: { + small: { + base: "max-w-sm", + header: "p-2", + body: "p-2", + footer: "p-2", + }, + large: { + base: "max-w-4xl", + header: "p-6", + body: "p-6", + footer: "p-6", + }, + }, + }, + requiredVariants: ["size"], + }); + + expect(() => card()).toThrow( + 'Missing required variant: "size". This variant must be provided.', + ); + + const {base, header, body, footer} = card({size: "small"}); + + expect(base()).toBe("rounded-lg border shadow-md max-w-sm"); + expect(header()).toBe("border-b p-2"); + expect(body()).toBe("p-2"); + expect(footer()).toBe("border-t bg-gray-50 p-2"); + }); + + test("should work without required variants", () => { + const button = tv({ + base: "font-semibold", + variants: { + intent: { + primary: "bg-blue-500 text-white", + secondary: "bg-white text-blue-500", + }, + }, + }); + + const expectedResult1 = "font-semibold"; + const result1 = button(); + + expect(result1).toBe(expectedResult1); + + const expectedResult2 = "font-semibold bg-blue-500 text-white"; + const result2 = button({intent: "primary"}); + + expect(result2).toBe(expectedResult2); + }); + + test("should throw error if requiredVariants is not an array", () => { + expect(() => + tv({ + base: "font-semibold", + variants: { + intent: { + primary: "bg-blue-500", + }, + }, + // @ts-expect-error - testing runtime validation + requiredVariants: "intent", + })(), + ).toThrow('The "requiredVariants" prop must be an array. Received: string'); + + expect(() => + tv({ + base: "font-semibold", + variants: { + intent: { + primary: "bg-blue-500", + }, + }, + // @ts-expect-error - testing runtime validation + requiredVariants: {intent: true}, + })(), + ).toThrow('The "requiredVariants" prop must be an array. Received: object'); + }); + + test("should expose requiredVariants on component", () => { + const button = tv({ + base: "font-semibold", + variants: { + intent: { + primary: "bg-blue-500", + secondary: "bg-white", + }, + size: { + small: "text-sm", + large: "text-lg", + }, + }, + requiredVariants: ["intent"], + }); + + expect(button.requiredVariants).toEqual(["intent"]); + }); + + test("should work with extend and required variants", () => { + const baseButton = tv({ + base: "font-semibold", + variants: { + intent: { + primary: "bg-blue-500", + secondary: "bg-white", + }, + }, + requiredVariants: ["intent"], + }); + + const iconButton = tv({ + extend: baseButton, + variants: { + size: { + small: "p-1", + large: "p-3", + }, + }, + requiredVariants: ["intent", "size"], + }); + + // @ts-expect-error - Testing runtime error with missing required variant + expect(() => iconButton({size: "small"})).toThrow( + 'Missing required variant: "intent". This variant must be provided.', + ); + + // @ts-expect-error - Testing runtime error with missing required variant + expect(() => iconButton({intent: "primary"})).toThrow( + 'Missing required variant: "size". This variant must be provided.', + ); + + const expectedResult = "font-semibold p-1 bg-blue-500"; + const result = iconButton({intent: "primary", size: "small"}); + + expect(result).toBe(expectedResult); + }); + + test("should throw error when requiredVariants contains invalid variant names", () => { + const button = tv({ + base: "font-semibold", + variants: { + intent: { + primary: "bg-blue-500", + secondary: "bg-white", + }, + }, + // @ts-expect-error - Testing runtime error with invalid variant name + requiredVariants: ["intent", "invalidVariant"], + }); + + expect(() => button({intent: "primary"})).toThrow( + 'Missing required variant: "invalidVariant". This variant must be provided.', + ); + }); +}); diff --git a/src/core.js b/src/core.js index 413705d..9b41850 100644 --- a/src/core.js +++ b/src/core.js @@ -19,6 +19,7 @@ export const getTailwindVariants = (cn) => { compoundVariants: compoundVariantsProps = [], compoundSlots = [], defaultVariants: defaultVariantsProps = {}, + requiredVariants = [], } = options; const config = {...defaultConfig, ...configProp}; @@ -81,6 +82,25 @@ export const getTailwindVariants = (cn) => { ); } + if (requiredVariants && !Array.isArray(requiredVariants)) { + throw new TypeError( + `The "requiredVariants" prop must be an array. Received: ${typeof requiredVariants}`, + ); + } + + // Validate required variants + if (requiredVariants && Array.isArray(requiredVariants)) { + for (let i = 0; i < requiredVariants.length; i++) { + const requiredVariant = requiredVariants[i]; + + if (props?.[requiredVariant] === undefined) { + throw new Error( + `Missing required variant: "${requiredVariant}". This variant must be provided.`, + ); + } + } + } + const getVariantValue = (variant, vrs = variants, _slotKey = null, slotProps = null) => { const variantObj = vrs[variant]; @@ -326,6 +346,7 @@ export const getTailwindVariants = (cn) => { component.defaultVariants = defaultVariants; component.compoundSlots = compoundSlots; component.compoundVariants = compoundVariants; + component.requiredVariants = requiredVariants; return component; }; diff --git a/src/types.d.ts b/src/types.d.ts index 073c630..39c9367 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -144,21 +144,34 @@ export type TVProps< S extends TVSlots, EV extends TVVariants, ES extends TVSlots, + RV extends [] | (keyof V | keyof EV)[] = [], > = EV extends undefined ? V extends undefined ? ClassProp : { - [K in keyof V]?: StringToBoolean | undefined; + [K in keyof V as K extends RV[number] ? never : K]?: + | StringToBoolean + | undefined; + } & { + [K in keyof V as K extends RV[number] ? K : never]: StringToBoolean; } & ClassProp : V extends undefined ? { - [K in keyof EV]?: StringToBoolean | undefined; + [K in keyof EV as K extends RV[number] ? never : K]?: + | StringToBoolean + | undefined; + } & { + [K in keyof EV as K extends RV[number] ? K : never]: StringToBoolean; } & ClassProp : { - [K in keyof V | keyof EV]?: + [K in keyof V | keyof EV as K extends RV[number] ? never : K]?: | (K extends keyof V ? StringToBoolean : never) | (K extends keyof EV ? StringToBoolean : never) | undefined; + } & { + [K in keyof V | keyof EV as K extends RV[number] ? K : never]: + | (K extends keyof V ? StringToBoolean : never) + | (K extends keyof EV ? StringToBoolean : never); } & ClassProp; export type TVVariantKeys, S extends TVSlots> = V extends Object @@ -171,6 +184,7 @@ export type TVReturnProps< B extends ClassValue, EV extends TVVariants, ES extends TVSlots, + RV extends [] | (keyof V | keyof EV)[] = [], // @ts-expect-error E extends TVReturnType = undefined, > = { @@ -182,6 +196,7 @@ export type TVReturnProps< compoundVariants: TVCompoundVariants; compoundSlots: TVCompoundSlots; variantKeys: TVVariantKeys; + requiredVariants: RV; }; type HasSlots = S extends undefined @@ -196,27 +211,31 @@ export type TVReturnType< B extends ClassValue, EV extends TVVariants, ES extends TVSlots, + RV extends [] | (keyof V | keyof EV)[] = [], // @ts-expect-error E extends TVReturnType = undefined, > = { - (props?: TVProps): HasSlots extends true + (props?: TVProps): HasSlots extends true ? { [K in keyof (ES extends undefined ? {} : ES)]: ( - slotProps?: TVProps, + slotProps?: TVProps, ) => string; } & { - [K in keyof (S extends undefined ? {} : S)]: (slotProps?: TVProps) => string; + [K in keyof (S extends undefined ? {} : S)]: ( + slotProps?: TVProps, + ) => string; } & { - [K in TVSlotsWithBase<{}, B>]: (slotProps?: TVProps) => string; + [K in TVSlotsWithBase<{}, B>]: (slotProps?: TVProps) => string; } : string; -} & TVReturnProps; +} & TVReturnProps; export type TV = { < V extends TVVariants, CV extends TVCompoundVariants, DV extends TVDefaultVariants, + RV extends [] | (keyof V | keyof EV)[] = [], B extends ClassValue = undefined, S extends TVSlots = undefined, // @ts-expect-error @@ -227,7 +246,8 @@ export type TV = { // @ts-expect-error EV extends undefined ? {} : EV, // @ts-expect-error - ES extends undefined ? {} : ES + ES extends undefined ? {} : ES, + RV >, EV extends TVVariants = E["variants"], ES extends TVSlots = E["slots"] extends TVSlots ? E["slots"] : undefined, @@ -266,13 +286,18 @@ export type TV = { * @see https://www.tailwind-variants.org/docs/variants#default-variants */ defaultVariants?: DV; + /** + * Required variants that must be provided at runtime and are required in the type system. + * @see https://www.tailwind-variants.org/docs/variants#required-variants + */ + requiredVariants?: RV; }, /** * The config object allows you to modify the default configuration. * @see https://www.tailwind-variants.org/docs/api-reference#config-optional */ config?: TVConfig, - ): TVReturnType; + ): TVReturnType; }; export type TVLite = { @@ -280,6 +305,7 @@ export type TVLite = { V extends TVVariants, CV extends TVCompoundVariants, DV extends TVDefaultVariants, + RV extends [] | (keyof V | keyof EV)[] = [], B extends ClassValue = undefined, S extends TVSlots = undefined, // @ts-expect-error @@ -290,7 +316,8 @@ export type TVLite = { // @ts-expect-error EV extends undefined ? {} : EV, // @ts-expect-error - ES extends undefined ? {} : ES + ES extends undefined ? {} : ES, + RV >, EV extends TVVariants = E["variants"], ES extends TVSlots = E["slots"] extends TVSlots ? E["slots"] : undefined, @@ -328,7 +355,12 @@ export type TVLite = { * @see https://www.tailwind-variants.org/docs/variants#default-variants */ defaultVariants?: DV; - }): TVReturnType; + /** + * Required variants that must be provided at runtime and are required in the type system. + * @see https://www.tailwind-variants.org/docs/variants#required-variants + */ + requiredVariants?: RV; + }): TVReturnType; }; export type VariantProps any> = Omit<