diff --git a/.changeset/recipe-boolean-variants.md b/.changeset/recipe-boolean-variants.md new file mode 100644 index 00000000..e07f8c81 --- /dev/null +++ b/.changeset/recipe-boolean-variants.md @@ -0,0 +1,10 @@ +--- +"@styleframe/runtime": minor +"@styleframe/transpiler": minor +"styleframe": minor +--- + +Add boolean support for recipe variant props + +- When a variant defines both `true` and `false` keys, the runtime now accepts boolean `true`/`false` values in addition to string `"true"`/`"false"` +- Generated `.d.ts` type declarations include `| boolean` in the type union for boolean variants diff --git a/engine/runtime/src/runtime.test.ts b/engine/runtime/src/runtime.test.ts index e8fc62a1..ddc9cd9e 100644 --- a/engine/runtime/src/runtime.test.ts +++ b/engine/runtime/src/runtime.test.ts @@ -586,6 +586,98 @@ describe("createRecipe", () => { expect(result).not.toContain("button--disabled"); }); + it("should accept boolean true to select 'true' variant option", () => { + const runtime = { + variants: { + disabled: { + true: { opacity: "50" }, + false: { opacity: "100" }, + }, + }, + defaultVariants: { + disabled: "false", + }, + } as const satisfies RecipeRuntime; + + const button = createRecipe("button", runtime); + const result = button({ disabled: true }); + + expect(result).toContain("_opacity:50"); + }); + + it("should accept boolean false to select 'false' variant option", () => { + const runtime = { + variants: { + disabled: { + true: { opacity: "50" }, + false: { opacity: "100" }, + }, + }, + defaultVariants: { + disabled: "true", + }, + } as const satisfies RecipeRuntime; + + const button = createRecipe("button", runtime); + const result = button({ disabled: false }); + + expect(result).toContain("_opacity:100"); + }); + + it("should match boolean true in compound variant conditions", () => { + const runtime = { + variants: { + color: { + primary: {}, + }, + disabled: { + true: {}, + false: {}, + }, + }, + defaultVariants: { + color: "primary", + disabled: "false", + }, + compoundVariants: [ + { + match: { color: "primary", disabled: "true" }, + css: { + opacity: "50", + }, + className: "button--primary-disabled", + }, + ], + } as const satisfies RecipeRuntime; + + const button = createRecipe("button", runtime); + const result = button({ disabled: true }); + + expect(result).toContain("_opacity:50"); + expect(result).toContain("button--primary-disabled"); + }); + + it("should not fall through to defaultVariants when boolean false is provided", () => { + const runtime = { + variants: { + disabled: { + true: { opacity: "50" }, + false: { opacity: "100" }, + }, + }, + defaultVariants: { + disabled: "true", + }, + } as const satisfies RecipeRuntime; + + const button = createRecipe("button", runtime); + // boolean false should select "false" variant, not fall through to default "true" + const result = button({ disabled: false }); + + expect(result).toContain("_opacity:100"); + expect(result).not.toContain("_opacity:50"); + }); + it("should handle compound variant with both css and className", () => { const runtime = { variants: { diff --git a/engine/runtime/src/runtime.ts b/engine/runtime/src/runtime.ts index cc3a481f..996ae4a1 100644 --- a/engine/runtime/src/runtime.ts +++ b/engine/runtime/src/runtime.ts @@ -45,6 +45,22 @@ function toClassName( * @param value - The value to check * @returns True if the value is a modifier block */ +/** + * Resolves a variant value from props, coercing booleans to strings. + * Falls back to defaultVariants if the prop is not provided. + */ +function resolveVariantValue( + props: Record, + variantKey: string, + defaultVariants?: Record, +): string | undefined { + const raw = props[variantKey]; + return ( + (raw !== undefined ? String(raw) : undefined) ?? + defaultVariants?.[variantKey] + ); +} + function isModifierBlock( value: RuntimeVariantDeclarationsValue, ): value is RuntimeModifierDeclarationsBlock { @@ -151,11 +167,11 @@ export function createRecipe( runtime.variants, )) { // Get the selected variant value from props or defaultVariants - const selectedVariant = - (props as Record)[variantKey] ?? - runtime.defaultVariants?.[ - variantKey as keyof typeof runtime.defaultVariants - ]; + const selectedVariant = resolveVariantValue( + props as Record, + variantKey, + runtime.defaultVariants as Record | undefined, + ); const options = variantOptions as RuntimeVariantOptions; const declarations = selectedVariant @@ -179,11 +195,11 @@ export function createRecipe( compoundVariant.match, )) { // Get the selected variant value from props or defaultVariants - const selectedVariant = - (props as Record)[variantKey] ?? - runtime.defaultVariants?.[ - variantKey as keyof typeof runtime.defaultVariants - ]; + const selectedVariant = resolveVariantValue( + props as Record, + variantKey, + runtime.defaultVariants as Record | undefined, + ); if (selectedVariant !== variantValue) { allConditionsMatch = false; diff --git a/engine/runtime/src/types.ts b/engine/runtime/src/types.ts index 5f74183d..72a2036d 100644 --- a/engine/runtime/src/types.ts +++ b/engine/runtime/src/types.ts @@ -44,7 +44,11 @@ export type RecipeVariantProps = R extends { : // Specific type with literal keys - extract exact variant keys and options { [K in keyof V]?: V[K] extends Record - ? keyof V[K] & string + ? "true" extends keyof V[K] + ? "false" extends keyof V[K] + ? (keyof V[K] & string) | boolean + : keyof V[K] & string + : keyof V[K] & string : never; } : Record diff --git a/engine/transpiler/src/consume/dts/recipe.test.ts b/engine/transpiler/src/consume/dts/recipe.test.ts new file mode 100644 index 00000000..77348008 --- /dev/null +++ b/engine/transpiler/src/consume/dts/recipe.test.ts @@ -0,0 +1,108 @@ +import type { Root, StyleframeOptions } from "@styleframe/core"; +import { + createRecipeFunction, + createRoot, + createUtilityFunction, +} from "@styleframe/core"; +import { createRecipeConsumer } from "./recipe"; + +describe("createRecipeConsumer (dts)", () => { + const mockConsume = vi.fn(); + const consumeRecipe = createRecipeConsumer(mockConsume); + const options: StyleframeOptions = {}; + + let root: Root; + let recipe: ReturnType; + let utility: ReturnType; + + beforeEach(() => { + mockConsume.mockClear(); + root = createRoot(); + recipe = createRecipeFunction(root, root); + utility = createUtilityFunction(root, root); + }); + + it("should include boolean in type union when variant has both true and false keys", () => { + utility("opacity", ({ value }) => ({ opacity: value })); + + const instance = recipe({ + name: "button", + base: {}, + variants: { + disabled: { + true: { opacity: "50%" }, + false: { opacity: "100%" }, + }, + }, + }); + + const result = consumeRecipe(instance, options); + + expect(result).toContain('disabled?: "true" | "false" | boolean'); + }); + + it("should not include boolean when variant has only true key", () => { + utility("borderRadius", ({ value }) => ({ borderRadius: value })); + + const instance = recipe({ + name: "button", + base: {}, + variants: { + rounded: { + true: { borderRadius: "full" }, + }, + }, + }); + + const result = consumeRecipe(instance, options); + + expect(result).toContain('rounded?: "true"'); + expect(result).not.toContain("boolean"); + }); + + it("should not include boolean for normal string variants", () => { + utility("padding", ({ value }) => ({ padding: value })); + + const instance = recipe({ + name: "button", + base: {}, + variants: { + size: { + sm: { padding: "1rem" }, + md: { padding: "2rem" }, + lg: { padding: "3rem" }, + }, + }, + }); + + const result = consumeRecipe(instance, options); + + expect(result).toContain('size?: "sm" | "md" | "lg"'); + expect(result).not.toContain("boolean"); + }); + + it("should handle mix of boolean and string variants", () => { + utility("opacity", ({ value }) => ({ opacity: value })); + utility("padding", ({ value }) => ({ padding: value })); + + const instance = recipe({ + name: "button", + base: {}, + variants: { + disabled: { + true: { opacity: "50%" }, + false: { opacity: "100%" }, + }, + size: { + sm: { padding: "1rem" }, + md: { padding: "2rem" }, + }, + }, + }); + + const result = consumeRecipe(instance, options); + + expect(result).toContain('disabled?: "true" | "false" | boolean'); + expect(result).toContain('size?: "sm" | "md"'); + }); +}); diff --git a/engine/transpiler/src/consume/dts/recipe.ts b/engine/transpiler/src/consume/dts/recipe.ts index 3fc6878a..c4fa6fef 100644 --- a/engine/transpiler/src/consume/dts/recipe.ts +++ b/engine/transpiler/src/consume/dts/recipe.ts @@ -19,7 +19,12 @@ function generateVariantPropsType(runtime: Recipe["_runtime"]): string { const optionKeys = Object.keys(variantOptions); if (optionKeys.length > 0) { const optionUnion = optionKeys.map((k) => `"${k}"`).join(" | "); - entries.push(`${variantKey}?: ${optionUnion}`); + const isBooleanVariant = + optionKeys.includes("true") && optionKeys.includes("false"); + const typeUnion = isBooleanVariant + ? `${optionUnion} | boolean` + : optionUnion; + entries.push(`${variantKey}?: ${typeUnion}`); } }