From 198174ca0c020a144304ddf3b79d79d9fd82e0ff Mon Sep 17 00:00:00 2001 From: jonsherrard Date: Wed, 21 Jan 2026 21:04:39 +0000 Subject: [PATCH 1/3] Custom variants plugin mapper --- src/index.ts | 3 + src/variants/createVariants.test.ts | 783 ++++++++++++++++++++++++++++ src/variants/createVariants.ts | 375 +++++++++++++ src/variants/index.ts | 23 + src/variants/types.ts | 137 +++++ 5 files changed, 1321 insertions(+) create mode 100644 src/variants/createVariants.test.ts create mode 100644 src/variants/createVariants.ts create mode 100644 src/variants/index.ts create mode 100644 src/variants/types.ts diff --git a/src/index.ts b/src/index.ts index 515af19..e349c8f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,3 +44,6 @@ export { FONT_SIZES, LETTER_SPACING_SCALE } from "./parser/typography"; // Re-export enhanced components with modifier support export * from "./components"; + +// Re-export variants API (Tailwind Variants / CVA compatible) +export * from "./variants"; diff --git a/src/variants/createVariants.test.ts b/src/variants/createVariants.test.ts new file mode 100644 index 0000000..e15b629 --- /dev/null +++ b/src/variants/createVariants.test.ts @@ -0,0 +1,783 @@ +import { describe, expect, it } from "vitest"; +import { createVariants, cv, cva, tv } from "./createVariants"; + +describe("createVariants", () => { + describe("basic functionality", () => { + it("should return empty style for empty config", () => { + const variant = createVariants({}); + const result = variant(); + expect(result.className).toBe(""); + expect(result.style).toEqual({}); + }); + + it("should handle base classes", () => { + const variant = createVariants({ + base: "m-4 p-2", + }); + const result = variant(); + expect(result.className).toBe("m-4 p-2"); + expect(result.style).toEqual({ + margin: 16, + padding: 8, + }); + }); + + it("should handle base classes as array", () => { + const variant = createVariants({ + base: ["m-4", "p-2"], + }); + const result = variant(); + expect(result.className).toBe("m-4 p-2"); + expect(result.style).toEqual({ + margin: 16, + padding: 8, + }); + }); + }); + + describe("variants", () => { + it("should apply variant classes", () => { + const button = createVariants({ + base: "rounded-lg", + variants: { + color: { + primary: "bg-blue-500", + secondary: "bg-gray-500", + }, + }, + }); + + const result = button({ color: "primary" }); + expect(result.className).toBe("rounded-lg bg-blue-500"); + expect(result.style).toHaveProperty("backgroundColor", "#2b7fff"); + }); + + it("should handle multiple variants", () => { + const button = createVariants({ + variants: { + color: { + primary: "bg-blue-500", + secondary: "bg-gray-500", + }, + size: { + sm: "text-sm p-1", + lg: "text-lg p-4", + }, + }, + }); + + const result = button({ color: "primary", size: "lg" }); + expect(result.className).toBe("bg-blue-500 text-lg p-4"); + expect(result.style).toHaveProperty("backgroundColor", "#2b7fff"); + expect(result.style).toHaveProperty("fontSize", 18); + expect(result.style).toHaveProperty("padding", 16); + }); + + it("should handle variant values as arrays", () => { + const button = createVariants({ + variants: { + color: { + primary: ["bg-blue-500", "text-white"], + }, + }, + }); + + const result = button({ color: "primary" }); + expect(result.className).toBe("bg-blue-500 text-white"); + }); + + it("should handle null variant values", () => { + const button = createVariants({ + variants: { + disabled: { + true: "opacity-50", + false: null, + }, + }, + }); + + const enabledResult = button({ disabled: false }); + expect(enabledResult.className).toBe(""); + + const disabledResult = button({ disabled: true }); + expect(disabledResult.className).toBe("opacity-50"); + }); + }); + + describe("boolean variants", () => { + it("should handle boolean true variant", () => { + const button = createVariants({ + variants: { + disabled: { + true: "opacity-50", + }, + }, + }); + + const result = button({ disabled: true }); + expect(result.className).toBe("opacity-50"); + expect(result.style).toHaveProperty("opacity", 0.5); + }); + + it("should handle boolean false variant", () => { + const button = createVariants({ + variants: { + disabled: { + true: "opacity-50", + false: "opacity-100", + }, + }, + }); + + const result = button({ disabled: false }); + expect(result.className).toBe("opacity-100"); + expect(result.style).toHaveProperty("opacity", 1); + }); + + it("should not apply boolean variant when undefined", () => { + const button = createVariants({ + variants: { + disabled: { + true: "opacity-50", + }, + }, + }); + + const result = button({}); + expect(result.className).toBe(""); + }); + }); + + describe("default variants", () => { + it("should apply default variants when no props provided", () => { + const button = createVariants({ + variants: { + color: { + primary: "bg-blue-500", + secondary: "bg-gray-500", + }, + size: { + sm: "text-sm", + md: "text-base", + }, + }, + defaultVariants: { + color: "primary", + size: "md", + }, + }); + + const result = button(); + expect(result.className).toBe("bg-blue-500 text-base"); + }); + + it("should override default variants with props", () => { + const button = createVariants({ + variants: { + color: { + primary: "bg-blue-500", + secondary: "bg-gray-500", + }, + }, + defaultVariants: { + color: "primary", + }, + }); + + const result = button({ color: "secondary" }); + expect(result.className).toBe("bg-gray-500"); + }); + + it("should handle boolean default variants", () => { + const button = createVariants({ + variants: { + disabled: { + true: "opacity-50", + false: "opacity-100", + }, + }, + defaultVariants: { + disabled: false, + }, + }); + + const result = button(); + expect(result.className).toBe("opacity-100"); + }); + }); + + describe("compound variants", () => { + it("should apply compound variant when conditions match", () => { + const button = createVariants({ + variants: { + color: { + primary: "bg-blue-500", + secondary: "bg-gray-500", + }, + disabled: { + true: "opacity-50", + }, + }, + compoundVariants: [ + { + color: "primary", + disabled: true, + class: "bg-blue-300", + }, + ], + }); + + const result = button({ color: "primary", disabled: true }); + expect(result.className).toBe("bg-blue-500 opacity-50 bg-blue-300"); + }); + + it("should not apply compound variant when conditions do not match", () => { + const button = createVariants({ + variants: { + color: { + primary: "bg-blue-500", + secondary: "bg-gray-500", + }, + disabled: { + true: "opacity-50", + }, + }, + compoundVariants: [ + { + color: "primary", + disabled: true, + class: "bg-blue-300", + }, + ], + }); + + const result = button({ color: "secondary", disabled: true }); + expect(result.className).toBe("bg-gray-500 opacity-50"); + expect(result.className).not.toContain("bg-blue-300"); + }); + + it("should support array conditions in compound variants", () => { + const button = createVariants({ + variants: { + color: { + primary: "bg-blue-500", + secondary: "bg-gray-500", + danger: "bg-red-500", + }, + size: { + sm: "text-sm", + md: "text-base", + }, + }, + compoundVariants: [ + { + color: ["primary", "secondary"], + size: "sm", + class: "font-bold", + }, + ], + }); + + const primaryResult = button({ color: "primary", size: "sm" }); + expect(primaryResult.className).toContain("font-bold"); + + const secondaryResult = button({ color: "secondary", size: "sm" }); + expect(secondaryResult.className).toContain("font-bold"); + + const dangerResult = button({ color: "danger", size: "sm" }); + expect(dangerResult.className).not.toContain("font-bold"); + }); + + it("should support className key as alias for class", () => { + const button = createVariants({ + variants: { + color: { + primary: "bg-blue-500", + }, + }, + compoundVariants: [ + { + color: "primary", + className: "font-semibold", + }, + ], + }); + + const result = button({ color: "primary" }); + expect(result.className).toContain("font-semibold"); + }); + + it("should apply compound variants with default variants", () => { + const button = createVariants({ + variants: { + color: { + primary: "bg-blue-500", + }, + disabled: { + true: "opacity-50", + false: "", + }, + }, + compoundVariants: [ + { + color: "primary", + disabled: false, + class: "hover:bg-blue-600", + }, + ], + defaultVariants: { + color: "primary", + disabled: false, + }, + }); + + const result = button(); + expect(result.className).toContain("hover:bg-blue-600"); + }); + }); + + describe("class/className override", () => { + it("should allow class override", () => { + const button = createVariants({ + base: "bg-blue-500", + }); + + const result = button({ class: "bg-red-500" }); + expect(result.className).toBe("bg-blue-500 bg-red-500"); + }); + + it("should allow className override", () => { + const button = createVariants({ + base: "bg-blue-500", + }); + + const result = button({ className: "bg-red-500" }); + expect(result.className).toBe("bg-blue-500 bg-red-500"); + }); + }); + + describe("class deduplication", () => { + it("should deduplicate class names", () => { + const button = createVariants({ + base: "m-4 p-2", + variants: { + size: { + lg: "m-4 p-4", + }, + }, + }); + + const result = button({ size: "lg" }); + // m-4 should only appear once + const m4Count = result.className.split(" ").filter((c) => c === "m-4").length; + expect(m4Count).toBe(1); + }); + }); +}); + +describe("slots", () => { + it("should create slot functions", () => { + const card = createVariants({ + slots: { + base: "rounded-lg", + header: "p-4", + body: "p-4", + }, + }); + + const result = card(); + expect(typeof result.base).toBe("function"); + expect(typeof result.header).toBe("function"); + expect(typeof result.body).toBe("function"); + }); + + it("should return correct classes for each slot", () => { + const card = createVariants({ + slots: { + base: "rounded-lg shadow-md", + header: "p-4 border-b", + body: "p-4", + }, + }); + + const slots = card(); + expect(slots.base().className).toBe("rounded-lg shadow-md"); + expect(slots.header().className).toBe("p-4 border-b"); + expect(slots.body().className).toBe("p-4"); + }); + + it("should parse slot classes to styles", () => { + const card = createVariants({ + slots: { + base: "m-4", + content: "p-2", + }, + }); + + const slots = card(); + expect(slots.base().style).toEqual({ margin: 16 }); + expect(slots.content().style).toEqual({ padding: 8 }); + }); + + it("should apply compound variants to specific slots", () => { + const card = createVariants({ + slots: { + base: "rounded-lg", + header: "p-4", + }, + variants: { + variant: { + elevated: "", + outlined: "", + }, + }, + compoundVariants: [ + { + variant: "elevated", + class: { + base: "shadow-lg", + }, + }, + { + variant: "outlined", + class: { + base: "border", + header: "border-b", + }, + }, + ], + }); + + const elevatedSlots = card({ variant: "elevated" }); + expect(elevatedSlots.base().className).toContain("shadow-lg"); + expect(elevatedSlots.header().className).not.toContain("shadow-lg"); + + const outlinedSlots = card({ variant: "outlined" }); + expect(outlinedSlots.base().className).toContain("border"); + expect(outlinedSlots.header().className).toContain("border-b"); + }); +}); + +describe("tv alias", () => { + it("should be an alias for createVariants", () => { + expect(tv).toBe(createVariants); + }); + + it("should work the same as createVariants", () => { + const button = tv({ + base: "font-semibold", + variants: { + color: { + primary: "bg-blue-500", + }, + }, + }); + + const result = button({ color: "primary" }); + expect(result.className).toBe("font-semibold bg-blue-500"); + }); +}); + +describe("cva", () => { + it("should accept base as first argument", () => { + const button = cva("font-semibold rounded-lg"); + const result = button(); + expect(result.className).toBe("font-semibold rounded-lg"); + }); + + it("should accept base as array", () => { + const button = cva(["font-semibold", "rounded-lg"]); + const result = button(); + expect(result.className).toBe("font-semibold rounded-lg"); + }); + + it("should work with variants", () => { + const button = cva("font-semibold", { + variants: { + intent: { + primary: "bg-blue-500 text-white", + secondary: "bg-gray-200 text-gray-800", + }, + size: { + small: "text-sm p-1", + medium: "text-base p-2", + }, + }, + }); + + const result = button({ intent: "primary", size: "small" }); + expect(result.className).toBe("font-semibold bg-blue-500 text-white text-sm p-1"); + }); + + it("should work with compound variants", () => { + const button = cva("font-semibold", { + variants: { + intent: { + primary: "bg-blue-500", + }, + disabled: { + true: "opacity-50", + false: null, + }, + }, + compoundVariants: [ + { + intent: "primary", + disabled: false, + class: "hover:bg-blue-600", + }, + ], + }); + + const result = button({ intent: "primary", disabled: false }); + expect(result.className).toContain("hover:bg-blue-600"); + }); + + it("should work with default variants", () => { + const button = cva("font-semibold", { + variants: { + intent: { + primary: "bg-blue-500", + secondary: "bg-gray-500", + }, + }, + defaultVariants: { + intent: "primary", + }, + }); + + const result = button(); + expect(result.className).toBe("font-semibold bg-blue-500"); + }); +}); + +describe("cv alias", () => { + it("should be an alias for cva", () => { + expect(cv).toBe(cva); + }); +}); + +describe("custom theme support", () => { + it("should parse custom colors from theme", () => { + const customTheme = { + colors: { + brand: "#ff6b6b", + }, + }; + + const button = createVariants( + { + variants: { + color: { + brand: "bg-brand", + }, + }, + }, + customTheme, + ); + + const result = button({ color: "brand" }); + expect(result.className).toBe("bg-brand"); + expect(result.style).toHaveProperty("backgroundColor", "#ff6b6b"); + }); + + it("should work with cva and custom theme", () => { + const customTheme = { + colors: { + primary: "#007aff", + }, + }; + + const button = cva( + "rounded-lg", + { + variants: { + color: { + primary: "bg-primary", + }, + }, + }, + customTheme, + ); + + const result = button({ color: "primary" }); + expect(result.style).toHaveProperty("backgroundColor", "#007aff"); + }); +}); + +describe("edge cases", () => { + it("should handle empty props", () => { + const button = createVariants({ + base: "m-4", + }); + + const result = button({}); + expect(result.className).toBe("m-4"); + }); + + it("should handle undefined variant values", () => { + const button = createVariants({ + variants: { + color: { + primary: "bg-blue-500", + }, + }, + }); + + const result = button({ color: undefined }); + expect(result.className).toBe(""); + }); + + it("should handle variant option that does not exist", () => { + const button = createVariants({ + variants: { + color: { + primary: "bg-blue-500", + }, + }, + }); + + // @ts-expect-error - Testing runtime behavior with invalid option + const result = button({ color: "nonexistent" }); + expect(result.className).toBe(""); + }); + + it("should handle whitespace in class strings", () => { + const button = createVariants({ + base: " m-4 p-2 ", + }); + + const result = button(); + expect(result.className).toBe("m-4 p-2"); + }); + + it("should handle multiple compound variants matching", () => { + const button = createVariants({ + variants: { + color: { + primary: "bg-blue-500", + }, + size: { + lg: "text-lg", + }, + disabled: { + true: "opacity-50", + }, + }, + compoundVariants: [ + { color: "primary", size: "lg", class: "font-bold" }, + { color: "primary", disabled: true, class: "bg-blue-300" }, + ], + }); + + const result = button({ color: "primary", size: "lg", disabled: true }); + expect(result.className).toContain("font-bold"); + expect(result.className).toContain("bg-blue-300"); + }); +}); + +describe("real-world examples", () => { + it("should handle a complete button component", () => { + const button = createVariants({ + base: "font-semibold rounded-lg", + variants: { + intent: { + primary: "bg-blue-500 text-white", + secondary: "bg-white text-gray-800 border border-gray-200", + danger: "bg-red-500 text-white", + }, + size: { + sm: "text-sm px-3 py-1", + md: "text-base px-4 py-2", + lg: "text-lg px-6 py-3", + }, + fullWidth: { + true: "w-full", + }, + disabled: { + true: "opacity-50", + }, + }, + compoundVariants: [ + { + intent: "primary", + disabled: true, + class: "bg-blue-300", + }, + { + intent: "danger", + disabled: true, + class: "bg-red-300", + }, + ], + defaultVariants: { + intent: "primary", + size: "md", + }, + }); + + // Default button + const defaultResult = button(); + expect(defaultResult.className).toContain("bg-blue-500"); + expect(defaultResult.className).toContain("text-base"); + + // Large danger button + const dangerResult = button({ intent: "danger", size: "lg" }); + expect(dangerResult.className).toContain("bg-red-500"); + expect(dangerResult.className).toContain("text-lg"); + + // Disabled primary button + const disabledResult = button({ disabled: true }); + expect(disabledResult.className).toContain("opacity-50"); + expect(disabledResult.className).toContain("bg-blue-300"); + }); + + it("should handle a card component with slots", () => { + const card = createVariants({ + slots: { + base: "rounded-xl overflow-hidden", + header: "p-4", + body: "p-4", + footer: "p-4", + }, + variants: { + variant: { + elevated: "", + outlined: "", + filled: "", + }, + }, + compoundVariants: [ + { + variant: "elevated", + class: { + base: "shadow-lg bg-white", + }, + }, + { + variant: "outlined", + class: { + base: "border border-gray-200 bg-white", + }, + }, + { + variant: "filled", + class: { + base: "bg-gray-100", + }, + }, + ], + defaultVariants: { + variant: "elevated", + }, + }); + + const slots = card(); + expect(slots.base().className).toContain("shadow-lg"); + expect(slots.header().className).toBe("p-4"); + + const outlinedSlots = card({ variant: "outlined" }); + expect(outlinedSlots.base().className).toContain("border"); + expect(outlinedSlots.base().className).not.toContain("shadow-lg"); + }); +}); diff --git a/src/variants/createVariants.ts b/src/variants/createVariants.ts new file mode 100644 index 0000000..9c05cab --- /dev/null +++ b/src/variants/createVariants.ts @@ -0,0 +1,375 @@ +/** + * Core variant creation function + * Provides a unified API compatible with both Tailwind Variants (tv) and CVA (cva) + */ + +import type { CustomTheme } from "../parser"; +import { parseClassName } from "../parser"; +import type { + CompoundVariant, + CvaConfig, + DefaultVariants, + SlotCompoundVariant, + SlotsDefinition, + SlotVariantResult, + VariantFunctionProps, + VariantResult, + VariantsConfig, + VariantsConfigWithSlots, + VariantsDefinition, + VariantValue, +} from "./types"; + +/** + * Normalize a variant value to a string + */ +function normalizeValue(value: VariantValue): string { + if (value === null || value === undefined) { + return ""; + } + if (Array.isArray(value)) { + return value.filter(Boolean).join(" "); + } + return value; +} + +/** + * Merge class strings, deduplicating and preserving order + */ +function mergeClasses(...classes: Array): string { + const result: string[] = []; + const seen = new Set(); + + for (const classStr of classes) { + if (!classStr) continue; + for (const cls of classStr.split(/\s+/)) { + if (cls && !seen.has(cls)) { + seen.add(cls); + result.push(cls); + } + } + } + + return result.join(" "); +} + +/** + * Check if a compound variant condition matches + */ +function matchesCompoundCondition( + compound: CompoundVariant | SlotCompoundVariant, + props: VariantFunctionProps, + defaultVariants?: DefaultVariants, +): boolean { + for (const key of Object.keys(compound)) { + // Skip class/className keys + if (key === "class" || key === "className") continue; + + const conditionValue = compound[key as keyof V]; + const propValue = props[key as keyof V] ?? defaultVariants?.[key as keyof V]; + + // Handle array conditions (OR logic) + if (Array.isArray(conditionValue)) { + if (!conditionValue.includes(propValue as never)) { + return false; + } + } else { + // Direct comparison + if (conditionValue !== propValue) { + return false; + } + } + } + + return true; +} + +/** + * Get classes for a specific variant selection + */ +function getVariantClasses( + variants: V | undefined, + props: VariantFunctionProps, + defaultVariants?: DefaultVariants, +): string { + if (!variants) return ""; + + const classes: string[] = []; + + for (const [variantName, variantOptions] of Object.entries(variants)) { + // Get the selected option, falling back to default + const selectedOption = props[variantName as keyof V] ?? defaultVariants?.[variantName as keyof V]; + + if (selectedOption === undefined || selectedOption === null) continue; + + // Handle boolean variants + const optionKey = + selectedOption === true ? "true" : selectedOption === false ? "false" : String(selectedOption); + + const optionValue = variantOptions[optionKey]; + if (optionValue !== undefined) { + classes.push(normalizeValue(optionValue)); + } + } + + return classes.filter(Boolean).join(" "); +} + +/** + * Get classes from compound variants + */ +function getCompoundClasses( + compoundVariants: Array> | undefined, + props: VariantFunctionProps, + defaultVariants?: DefaultVariants, +): string { + if (!compoundVariants) return ""; + + const classes: string[] = []; + + for (const compound of compoundVariants) { + if (matchesCompoundCondition(compound, props, defaultVariants)) { + const compoundClass = compound.class ?? compound.className; + if (compoundClass) { + classes.push(normalizeValue(compoundClass)); + } + } + } + + return classes.filter(Boolean).join(" "); +} + +/** + * Type guard to check if config has slots + */ +function hasSlots( + config: VariantsConfig | VariantsConfigWithSlots, +): config is VariantsConfigWithSlots { + return "slots" in config && config.slots !== undefined; +} + +/** + * Create a variant function without slots + */ +function createVariantFunction( + config: VariantsConfig, + customTheme?: CustomTheme, +): (props?: VariantFunctionProps) => VariantResult { + const { base, variants, compoundVariants, defaultVariants } = config; + + return (props: VariantFunctionProps = {} as VariantFunctionProps): VariantResult => { + // Compute class name + const baseClass = normalizeValue(base ?? null); + const variantClasses = getVariantClasses(variants, props, defaultVariants); + const compoundClasses = getCompoundClasses(compoundVariants, props, defaultVariants); + const overrideClass = props.class ?? props.className ?? ""; + + const className = mergeClasses(baseClass, variantClasses, compoundClasses, overrideClass); + + // Parse to style object + const style = className ? parseClassName(className, customTheme) : {}; + + return { className, style }; + }; +} + +/** + * Create a variant function with slots + */ +function createSlotVariantFunction( + config: VariantsConfigWithSlots, + customTheme?: CustomTheme, +): (props?: VariantFunctionProps) => SlotVariantResult { + const { slots, variants: _variants, compoundVariants, defaultVariants } = config; + + return (props: VariantFunctionProps = {} as VariantFunctionProps): SlotVariantResult => { + const result = {} as SlotVariantResult; + + for (const slotName of Object.keys(slots) as Array) { + result[slotName] = () => { + // Base slot classes + const baseClass = slots[slotName] ?? ""; + + // Collect variant classes for this slot + const variantClasses: string[] = []; + + // Note: For slot variants, the variants structure would need to be: + // variants: { size: { sm: { base: '...', avatar: '...' } } } + // But that's a different structure than standard variants. + // We'll support both standard variants (applied to all slots) and + // check for slot-specific variant values + + // Get standard variant classes (applied to all slots if not slot-specific) + // This is the simpler case where variants apply globally + // For slot-specific variants, users should use compoundVariants + + // Compound variant slot classes + const compoundClasses: string[] = []; + if (compoundVariants) { + for (const compound of compoundVariants) { + if (matchesCompoundCondition(compound, props, defaultVariants)) { + const slotClass = (compound.class ?? compound.className) as + | { [K in keyof S]?: VariantValue } + | undefined; + if (slotClass && typeof slotClass === "object" && slotName in slotClass) { + const slotValue = slotClass[slotName]; + if (slotValue) { + compoundClasses.push(normalizeValue(slotValue)); + } + } + } + } + } + + const className = mergeClasses(baseClass, ...variantClasses, ...compoundClasses); + const style = className ? parseClassName(className, customTheme) : {}; + + return { className, style }; + }; + } + + return result; + }; +} + +/** + * Create a variants function (Tailwind Variants style) + * + * Supports: + * - Base classes + * - Variants with multiple options + * - Boolean variants + * - Compound variants (conditional styles based on multiple variant combinations) + * - Default variants + * - Slots (multi-part components) + * + * @example + * ```typescript + * // Simple variant (no slots) + * const button = createVariants({ + * base: 'font-semibold rounded-lg', + * variants: { + * color: { + * primary: 'bg-blue-500 text-white', + * secondary: 'bg-gray-200 text-gray-800', + * }, + * size: { + * sm: 'text-sm px-3 py-1', + * md: 'text-base px-4 py-2', + * lg: 'text-lg px-6 py-3', + * }, + * disabled: { + * true: 'opacity-50 cursor-not-allowed', + * }, + * }, + * compoundVariants: [ + * { color: 'primary', disabled: true, class: 'bg-blue-300' }, + * ], + * defaultVariants: { + * color: 'primary', + * size: 'md', + * }, + * }); + * + * // Usage + * const { className, style } = button({ color: 'secondary', size: 'lg' }); + * ``` + * + * @example + * ```typescript + * // With slots + * const card = createVariants({ + * slots: { + * base: 'rounded-xl shadow-md', + * header: 'p-4 border-b', + * body: 'p-4', + * footer: 'p-4 border-t', + * }, + * variants: { + * variant: { + * elevated: {}, + * outlined: {}, + * }, + * }, + * compoundVariants: [ + * { variant: 'elevated', class: { base: 'shadow-lg' } }, + * { variant: 'outlined', class: { base: 'border border-gray-200 shadow-none' } }, + * ], + * }); + * + * // Usage + * const slots = card({ variant: 'elevated' }); + * const { style: baseStyle } = slots.base(); + * const { style: headerStyle } = slots.header(); + * ``` + */ +export function createVariants( + config: VariantsConfigWithSlots, + customTheme?: CustomTheme, +): (props?: VariantFunctionProps) => SlotVariantResult; + +export function createVariants( + config: VariantsConfig, + customTheme?: CustomTheme, +): (props?: VariantFunctionProps) => VariantResult; + +export function createVariants( + config: VariantsConfig | VariantsConfigWithSlots, + customTheme?: CustomTheme, +): (props?: VariantFunctionProps) => VariantResult | SlotVariantResult { + if (hasSlots(config)) { + return createSlotVariantFunction(config, customTheme); + } + return createVariantFunction(config, customTheme); +} + +/** + * Alias for createVariants - Tailwind Variants style + */ +export const tv = createVariants; + +/** + * CVA-style variant creation (base as first argument) + * + * @example + * ```typescript + * const button = cva('font-semibold rounded-lg', { + * variants: { + * intent: { + * primary: 'bg-blue-500 text-white', + * secondary: 'bg-gray-200 text-gray-800', + * }, + * size: { + * sm: 'text-sm px-3 py-1', + * md: 'text-base px-4 py-2', + * }, + * }, + * defaultVariants: { + * intent: 'primary', + * size: 'md', + * }, + * }); + * + * // Usage + * const { className, style } = button({ intent: 'secondary' }); + * ``` + */ +export function cva( + base: string | string[], + config?: CvaConfig, + customTheme?: CustomTheme, +): (props?: VariantFunctionProps) => VariantResult { + return createVariants( + { + base, + ...config, + }, + customTheme, + ); +} + +/** + * Alias for cva - Class Variance Authority style + * Also compatible with Tailwind Variants cv function + */ +export const cv = cva; diff --git a/src/variants/index.ts b/src/variants/index.ts new file mode 100644 index 0000000..fd6d746 --- /dev/null +++ b/src/variants/index.ts @@ -0,0 +1,23 @@ +/** + * Variants module - unified API for Tailwind Variants and CVA + */ + +export { createVariants, cv, cva, tv } from "./createVariants"; +export type { + CompoundVariant, + CvaConfig, + DefaultVariants, + ExtractVariantProps, + SlotCompoundVariant, + SlotVariantResult, + SlotVariants, + SlotsDefinition, + VariantFunctionProps, + VariantOptions, + VariantProps, + VariantResult, + VariantValue, + VariantsConfig, + VariantsConfigWithSlots, + VariantsDefinition, +} from "./types"; diff --git a/src/variants/types.ts b/src/variants/types.ts new file mode 100644 index 0000000..c0ae383 --- /dev/null +++ b/src/variants/types.ts @@ -0,0 +1,137 @@ +/** + * Type definitions for the variants system + * Supports both Tailwind Variants (tv) and CVA (cva) style APIs + */ + +import type { StyleObject } from "../types/core"; + +/** + * A single variant option value - can be a class string, array of classes, or null + */ +export type VariantValue = string | string[] | null; + +/** + * Variant options - maps option names to their class values + * e.g., { small: 'text-sm', medium: 'text-base', large: 'text-lg' } + */ +export type VariantOptions = Record; + +/** + * Variants definition - maps variant names to their options + * e.g., { size: { small: 'text-sm', medium: 'text-base' }, color: { primary: 'bg-blue-500' } } + */ +export type VariantsDefinition = Record; + +/** + * Extract variant props from a variants definition + */ +export type VariantProps = { + [K in keyof T]?: keyof T[K] | boolean; +}; + +/** + * Compound variant condition - applies classes when multiple variant conditions match + */ +export type CompoundVariant = { + [K in keyof T]?: keyof T[K] | Array | boolean; +} & { + class?: VariantValue; + className?: VariantValue; +}; + +/** + * Default variants - fallback values when no variant is specified + */ +export type DefaultVariants = { + [K in keyof T]?: keyof T[K] | boolean; +}; + +/** + * Slots definition - maps slot names to their base classes + * e.g., { base: 'flex', avatar: 'w-12 h-12', content: 'flex-1' } + */ +export type SlotsDefinition = Record; + +/** + * Slot variants - applies variant-specific classes to slots + */ +export type SlotVariants = { + [K in keyof V]?: { + [O in keyof V[K]]?: { + [Slot in keyof S]?: VariantValue; + }; + }; +}; + +/** + * Slot compound variants + */ +export type SlotCompoundVariant = { + [K in keyof V]?: keyof V[K] | Array | boolean; +} & { + class?: { + [Slot in keyof S]?: VariantValue; + }; + className?: { + [Slot in keyof S]?: VariantValue; + }; +}; + +/** + * Configuration for createVariants (without slots) + */ +export type VariantsConfig = { + base?: string | string[]; + variants?: V; + compoundVariants?: Array>; + defaultVariants?: DefaultVariants; +}; + +/** + * Configuration for createVariants (with slots) + */ +export type VariantsConfigWithSlots = { + slots: S; + variants?: V; + compoundVariants?: Array>; + defaultVariants?: DefaultVariants; +}; + +/** + * Props passed to the generated variant function + */ +export type VariantFunctionProps = VariantProps & { + class?: string; + className?: string; +}; + +/** + * Return type for variant function (without slots) + */ +export type VariantResult = { + className: string; + style: StyleObject; +}; + +/** + * Return type for variant function (with slots) + */ +export type SlotVariantResult = { + [K in keyof S]: () => VariantResult; +}; + +/** + * CVA-style configuration (base as first argument) + */ +export type CvaConfig = { + variants?: V; + compoundVariants?: Array>; + defaultVariants?: DefaultVariants; +}; + +/** + * Type helper to extract variant props from a variant function + */ +export type ExtractVariantProps = T extends (props?: infer P) => unknown + ? Omit + : never; From 6c7543d7fb9116592bb16a4bbef62531776b2933 Mon Sep 17 00:00:00 2001 From: jonsherrard Date: Wed, 21 Jan 2026 21:45:56 +0000 Subject: [PATCH 2/3] Use babel to transform cva and tv components --- package.json | 5 + pnpm-lock.yaml | 49 ++ src/babel/plugin.ts | 15 + src/babel/plugin/state.ts | 11 + src/babel/plugin/visitors/program.ts | 10 +- src/babel/plugin/visitors/variants.test.ts | 326 +++++++++ src/babel/plugin/visitors/variants.ts | 211 ++++++ src/babel/utils/variantProcessing.ts | 466 ++++++++++++ src/index.ts | 3 - src/variants/createVariants.test.ts | 783 --------------------- src/variants/createVariants.ts | 375 ---------- src/variants/index.ts | 23 - src/variants/types.ts | 137 ---- 13 files changed, 1092 insertions(+), 1322 deletions(-) create mode 100644 src/babel/plugin/visitors/variants.test.ts create mode 100644 src/babel/plugin/visitors/variants.ts create mode 100644 src/babel/utils/variantProcessing.ts delete mode 100644 src/variants/createVariants.test.ts delete mode 100644 src/variants/createVariants.ts delete mode 100644 src/variants/index.ts delete mode 100644 src/variants/types.ts diff --git a/package.json b/package.json index 866db34..bc4c729 100644 --- a/package.json +++ b/package.json @@ -93,5 +93,10 @@ "esbuild", "unrs-resolver" ] + }, + "dependencies": { + "class-variance-authority": "^0.7.1", + "tailwind-merge": "^3.4.0", + "tailwind-variants": "^3.2.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b938f2e..4576ac4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,16 @@ settings: importers: .: + dependencies: + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + tailwind-merge: + specifier: ^3.4.0 + version: 3.4.0 + tailwind-variants: + specifier: ^3.2.2 + version: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18) devDependencies: '@babel/cli': specifier: ^7.28.3 @@ -1983,6 +1993,9 @@ packages: cjs-module-lexer@2.1.1: resolution: {integrity: sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==} + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -2002,6 +2015,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -3971,6 +3988,22 @@ packages: resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} + tailwind-merge@3.4.0: + resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} + + tailwind-variants@3.2.2: + resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==} + engines: {node: '>=16.x', pnpm: '>=7.x'} + peerDependencies: + tailwind-merge: '>=3.0.0' + tailwindcss: '*' + peerDependenciesMeta: + tailwind-merge: + optional: true + + tailwindcss@4.1.18: + resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + terser@5.44.1: resolution: {integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==} engines: {node: '>=10'} @@ -6592,6 +6625,10 @@ snapshots: cjs-module-lexer@2.1.1: {} + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -6612,6 +6649,8 @@ snapshots: clone@1.0.4: {} + clsx@2.1.1: {} + co@4.6.0: {} collect-v8-coverage@1.0.3: {} @@ -9119,6 +9158,16 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 + tailwind-merge@3.4.0: {} + + tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18): + dependencies: + tailwindcss: 4.1.18 + optionalDependencies: + tailwind-merge: 3.4.0 + + tailwindcss@4.1.18: {} + terser@5.44.1: dependencies: '@jridgewell/source-map': 0.3.11 diff --git a/src/babel/plugin.ts b/src/babel/plugin.ts index ea1e39d..53fdb99 100644 --- a/src/babel/plugin.ts +++ b/src/babel/plugin.ts @@ -11,6 +11,11 @@ import { jsxAttributeVisitor } from "./plugin/visitors/className.js"; import { importDeclarationVisitor } from "./plugin/visitors/imports.js"; import { programEnter, programExit } from "./plugin/visitors/program.js"; import { callExpressionVisitor, taggedTemplateVisitor } from "./plugin/visitors/tw.js"; +import { + variantCallVisitor, + variantDefinitionVisitor, + variantImportVisitor, +} from "./plugin/visitors/variants.js"; // Re-export PluginOptions for external use export type { PluginOptions }; @@ -56,6 +61,13 @@ export default function reactNativeTailwindBabelPlugin( ImportDeclaration(path, state) { importDeclarationVisitor(path, state, t); + // Also track variant imports (tv/cva) + variantImportVisitor(path, state, t); + }, + + VariableDeclarator(path, state) { + // Process variant function definitions (const button = tv({...})) + variantDefinitionVisitor(path, state, t); }, TaggedTemplateExpression(path, state) { @@ -63,6 +75,9 @@ export default function reactNativeTailwindBabelPlugin( }, CallExpression(path, state) { + // First check if this is a variant function call + variantCallVisitor(path, state, t); + // Then check for tw/twStyle calls callExpressionVisitor(path, state, t); }, diff --git a/src/babel/plugin/state.ts b/src/babel/plugin/state.ts index c5b546e..31464f9 100644 --- a/src/babel/plugin/state.ts +++ b/src/babel/plugin/state.ts @@ -9,6 +9,7 @@ import type { StyleObject } from "../../types/core.js"; import type { CustomTheme } from "../config-loader.js"; import { extractCustomTheme } from "../config-loader.js"; import { DEFAULT_CLASS_ATTRIBUTES, buildAttributeMatchers } from "../utils/attributeMatchers.js"; +import type { VariantFunctionEntry } from "../utils/variantProcessing.js"; /** * Plugin options @@ -121,6 +122,11 @@ export type PluginState = PluginPass & { functionComponentsNeedingColorScheme: Set>; // Track function components that need windowDimensions hook injection functionComponentsNeedingWindowDimensions: Set>; + // Track tv/cva variant imports and definitions + tvImportNames: Set; + cvaImportNames: Set; + variantFunctions: Map; + hasVariantDefinitions: boolean; }; // Default identifier for the generated StyleSheet constant @@ -181,5 +187,10 @@ export function createInitialState( reactNativeImportPath: undefined, functionComponentsNeedingColorScheme: new Set(), functionComponentsNeedingWindowDimensions: new Set(), + // Variant support (tv/cva) + tvImportNames: new Set(), + cvaImportNames: new Set(), + variantFunctions: new Map(), + hasVariantDefinitions: false, }; } diff --git a/src/babel/plugin/visitors/program.ts b/src/babel/plugin/visitors/program.ts index 71b5a10..6c0d112 100644 --- a/src/babel/plugin/visitors/program.ts +++ b/src/babel/plugin/visitors/program.ts @@ -17,6 +17,7 @@ import { } from "../../utils/styleInjection.js"; import { removeTwImports } from "../../utils/twProcessing.js"; import type { PluginState } from "../state.js"; +import { removeVariantDefinitions, removeVariantImports } from "./variants.js"; /** * Program enter visitor - initialize state for each file @@ -41,9 +42,16 @@ export function programExit( removeTwImports(path, t); } - // If no classNames were found and no hooks/imports needed, skip processing + // Remove variant imports and definitions if they were processed + if (state.hasVariantDefinitions) { + removeVariantImports(path, state, t); + removeVariantDefinitions(path, state, t); + } + + // If no classNames/variants were found and no hooks/imports needed, skip processing if ( !state.hasClassNames && + !state.hasVariantDefinitions && !state.needsWindowDimensionsImport && !state.needsColorSchemeImport && !state.needsI18nManagerImport diff --git a/src/babel/plugin/visitors/variants.test.ts b/src/babel/plugin/visitors/variants.test.ts new file mode 100644 index 0000000..66adb7a --- /dev/null +++ b/src/babel/plugin/visitors/variants.test.ts @@ -0,0 +1,326 @@ +import { describe, expect, it } from "vitest"; +import { transform } from "../../../../test/helpers/babelTransform.js"; + +describe("variants visitor - tv()", () => { + describe("import tracking", () => { + it("should track tv import from tailwind-variants", () => { + const code = ` + import { tv } from 'tailwind-variants'; + + const button = tv({ + base: 'font-semibold rounded-lg', + variants: { + color: { + primary: 'bg-blue-500', + secondary: 'bg-gray-500', + }, + }, + }); + + function Button() { + return