Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/recipe-boolean-variants.md
Original file line number Diff line number Diff line change
@@ -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
92 changes: 92 additions & 0 deletions engine/runtime/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
36 changes: 26 additions & 10 deletions engine/runtime/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | boolean>,
variantKey: string,
defaultVariants?: Record<string, string>,
): string | undefined {
const raw = props[variantKey];
return (
(raw !== undefined ? String(raw) : undefined) ??
defaultVariants?.[variantKey]
);
}

function isModifierBlock(
value: RuntimeVariantDeclarationsValue,
): value is RuntimeModifierDeclarationsBlock {
Expand Down Expand Up @@ -151,11 +167,11 @@ export function createRecipe<R extends RecipeRuntime>(
runtime.variants,
)) {
// Get the selected variant value from props or defaultVariants
const selectedVariant =
(props as Record<string, string>)[variantKey] ??
runtime.defaultVariants?.[
variantKey as keyof typeof runtime.defaultVariants
];
const selectedVariant = resolveVariantValue(
props as Record<string, string | boolean>,
variantKey,
runtime.defaultVariants as Record<string, string> | undefined,
);

const options = variantOptions as RuntimeVariantOptions;
const declarations = selectedVariant
Expand All @@ -179,11 +195,11 @@ export function createRecipe<R extends RecipeRuntime>(
compoundVariant.match,
)) {
// Get the selected variant value from props or defaultVariants
const selectedVariant =
(props as Record<string, string>)[variantKey] ??
runtime.defaultVariants?.[
variantKey as keyof typeof runtime.defaultVariants
];
const selectedVariant = resolveVariantValue(
props as Record<string, string | boolean>,
variantKey,
runtime.defaultVariants as Record<string, string> | undefined,
);

if (selectedVariant !== variantValue) {
allConditionsMatch = false;
Expand Down
6 changes: 5 additions & 1 deletion engine/runtime/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ export type RecipeVariantProps<R> = R extends {
: // Specific type with literal keys - extract exact variant keys and options
{
[K in keyof V]?: V[K] extends Record<string, unknown>
? 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<string, string>
Expand Down
108 changes: 108 additions & 0 deletions engine/transpiler/src/consume/dts/recipe.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createRecipeFunction>;
let utility: ReturnType<typeof createUtilityFunction>;

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"');
});
});
7 changes: 6 additions & 1 deletion engine/transpiler/src/consume/dts/recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
}

Expand Down
Loading