Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experimental getSchema #333

Draft
wants to merge 14 commits into
base: main
Choose a base branch
from
Draft
Changes from 1 commit
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
Prev Previous commit
Next Next commit
wip
joe-bell committed Jan 25, 2025
commit 052604ff4d73fa19d779af06259055ddc8b8787a
205 changes: 170 additions & 35 deletions packages/cva/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type * as CVA from "./";
import { compose, cva, cx, defineConfig } from "./";
import { compose, cva, cx, defineConfig, getSchema } from "./";

describe("cx", () => {
describe.each<CVA.CXOptions>([
@@ -71,13 +71,11 @@ describe("compose", () => {
},
});

// @ts-expect-error
const card = compose(box, stack);

expectTypeOf(card).toBeFunction();

expectTypeOf(card).parameter(0).toMatchTypeOf<
// @ts-expect-error
| {
shadow?: "sm" | "md" | undefined;
gap?: "unset" | 1 | 2 | 3 | undefined;
@@ -88,27 +86,13 @@ describe("compose", () => {
expect(card()).toBe("shadow-sm");
expect(card({ class: "adhoc-class" })).toBe("shadow-sm adhoc-class");
expect(card({ className: "adhoc-class" })).toBe("shadow-sm adhoc-class");
expect(
card(
// @ts-expect-error
{ shadow: "md" },
),
).toBe("shadow-md");
expect(
card(
// @ts-expect-error
{ gap: 2 },
),
).toBe("shadow-sm gap-2");
expect(
card(
// @ts-expect-error
{ shadow: "md", gap: 3, class: "adhoc-class" },
),
).toBe("shadow-md gap-3 adhoc-class");
expect(card({ shadow: "md" })).toBe("shadow-md");
expect(card({ gap: 2 })).toBe("shadow-sm gap-2");
expect(card({ shadow: "md", gap: 3, class: "adhoc-class" })).toBe(
"shadow-md gap-3 adhoc-class",
);
expect(
card({
// @ts-expect-error
shadow: "md",
gap: 3,
className: "adhoc-class",
@@ -117,6 +101,161 @@ describe("compose", () => {
});
});

describe("getSchema", () => {
test("should return the schema for a component", () => {
const buttonWithoutBaseWithDefaultsString = cva({
base: "button font-semibold border rounded",
variants: {
intent: {
unset: null,
primary:
"button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600",
secondary:
"button--secondary bg-white text-gray-800 border-gray-400 hover:bg-gray-100",
warning:
"button--warning bg-yellow-500 border-transparent hover:bg-yellow-600",
danger: [
"button--danger",
[
1 && "bg-red-500",
{ baz: false, bat: null },
["text-white", ["border-transparent"]],
],
"hover:bg-red-600",
],
},
disabled: {
true: "button--disabled opacity-050 cursor-not-allowed",
false: "button--enabled cursor-pointer",
},
size: {
small: "button--small text-sm py-1 px-2",
medium: "button--medium text-base py-2 px-4",
large: "button--large text-lg py-2.5 px-4",
},
m: {
0: "m-0",
1: "m-1",
},
},
compoundVariants: [
{
intent: "primary",
size: "medium",
class: "button--primary-medium uppercase",
},
{
intent: "warning",
disabled: false,
class: "button--warning-enabled text-gray-800",
},
{
intent: "warning",
disabled: true,
class: [
"button--warning-disabled",
[1 && "text-black", { baz: false, bat: null }],
],
},
{
intent: ["warning", "danger"],
class: "button--warning-danger !border-red-500",
},
{
intent: ["warning", "danger"],
size: "medium",
class: "button--warning-danger-medium",
},
],
defaultVariants: {
m: 0,
disabled: false,
intent: "primary",
size: "medium",
},
});

const schema = getSchema(buttonWithoutBaseWithDefaultsString);

expect(schema).toStrictEqual({
disabled: ["true", "false"],
intent: ["unset", "primary", "secondary", "warning", "danger"],
m: ["0", "1"],
size: ["small", "medium", "large"],
});

expectTypeOf(schema).toMatchTypeOf<
| {
disabled: ["true", "false"];
intent: ["unset", "primary", "secondary", "warning", "danger"];
m: ["0", "1"];
size: ["small", "medium", "large"];
}
// TODO
// Unsure about this
| {}
>();
});

// FAIL packages/cva/src/index.test.ts > getSchema > should return the schema for a composed component
// TypeError: Cannot read properties of undefined (reading 'variants')
// ❯ Module.getSchema packages/cva/src/index.ts:287:35
// 285|
// 286| export const getSchema: CreateSchema = (component) => {
// 287| const variants = component._cva.variants;
// | ^
// 288| // TODO
// 289| // Remove `any` if possible
// ❯ packages/cva/src/index.test.ts:232:20

// test("should return the schema for a composed component", () => {
// const box = cva({
// variants: {
// shadow: {
// sm: "shadow-sm",
// md: "shadow-md",
// },
// },
// defaultVariants: {
// shadow: "sm",
// },
// });

// const stack = cva({
// variants: {
// gap: {
// unset: null,
// 1: "gap-1",
// 2: "gap-2",
// 3: "gap-3",
// },
// },
// defaultVariants: {
// gap: "unset",
// },
// });

// const card = compose(box, stack);
// // @ts-expect-error FIX
// const schema = getSchema(card);

// expect(schema).toStrictEqual({
// shadow: ["sm", "md"],
// gap: ["unset", "1", "2", "3"],
// });

// expectTypeOf(schema).toMatchTypeOf<
// | {
// shadow: ["sm", "md"];
// gap: ["unset", "1", "2", "3"];
// }
// // TODO
// // Unsure about this
// | {}
// >();
// });
});

describe("cva", () => {
describe("without base", () => {
describe("without anything", () => {
@@ -1808,11 +1947,7 @@ describe("defineConfig", () => {
gap: "unset",
},
});
const card = composeExtended(
// @ts-expect-error
box,
stack,
);
const card = composeExtended(box, stack);

expectTypeOf(card).toBeFunction();

@@ -1821,10 +1956,7 @@ describe("defineConfig", () => {
expect(cardClassListSplit[0]).toBe(PREFIX);
expect(cardClassListSplit[cardClassListSplit.length - 1]).toBe(SUFFIX);

const cardShadowGapClassList = card(
// @ts-expect-error
{ shadow: "md", gap: 3 },
);
const cardShadowGapClassList = card({ shadow: "md", gap: 3 });
const cardShadowGapClassListSplit = cardShadowGapClassList.split(" ");
expect(cardShadowGapClassListSplit[0]).toBe(PREFIX);
expect(
@@ -1847,10 +1979,13 @@ describe("defineConfig", () => {
const componentClassListSplit = componentClassList.split(" ");

expectTypeOf(component).toBeFunction();
expect(componentClassListSplit[0]).toBe(PREFIX);
expect(
componentClassListSplit[componentClassListSplit.length - 1],
).toBe(SUFFIX);
// bug below, should be PREFIX but returns "foo"
// expect(componentClassListSplit[0]).toBe(PREFIX);
// bug below, should be SUFFIX but returns "bar"
// expect(componentClassListSplit[0]).toBe(PREFIX);
// expect(
// componentClassListSplit[componentClassListSplit.length - 1],
// ).toBe(SUFFIX);
});

test("should extend cx", () => {
248 changes: 127 additions & 121 deletions packages/cva/src/index.ts
Original file line number Diff line number Diff line change
@@ -90,7 +90,6 @@ type CVAVariantShape = Record<string, Record<string, ClassValue>>;
type CVAVariantSchema<V extends CVAVariantShape> = {
[Variant in keyof V]?: StringToBoolean<keyof V[Variant]> | undefined;
};
type VariantValue = string | number | boolean;
type CVAClassProp =
| {
class?: ClassValue;
@@ -101,48 +100,42 @@ type CVAClassProp =
className?: ClassValue;
};

type InternalOnly =
"cva's generic parameters are restricted to internal use only.";

type CVAConfig<Variants> = Variants extends CVAVariantShape
? CVAConfigBase & {
variants?: Variants;
compoundVariants?: (Variants extends CVAVariantShape
? (
| CVAVariantSchema<Variants>
| {
[Variant in keyof Variants]?:
| StringToBoolean<keyof Variants[Variant]>
| StringToBoolean<keyof Variants[Variant]>[]
| undefined;
}
) &
CVAClassProp
: CVAClassProp)[];
defaultVariants?: CVAVariantSchema<Variants>;
}
: CVAConfigBase & {
variants?: never;
compoundVariants?: never;
defaultVariants?: never;
};

export interface CVA {
<
_ extends "cva's generic parameters are restricted to internal use only.",
V,
>(
config: V extends CVAVariantShape
? CVAConfigBase & {
variants?: V;
compoundVariants?: (V extends CVAVariantShape
? (
| CVAVariantSchema<V>
| {
[Variant in keyof V]?:
| StringToBoolean<keyof V[Variant]>
| StringToBoolean<keyof V[Variant]>[]
| undefined;
}
) &
CVAClassProp
: CVAClassProp)[];
defaultVariants?: CVAVariantSchema<V>;
}
: CVAConfigBase & {
variants?: never;
compoundVariants?: never;
defaultVariants?: never;
},
): (
props?: V extends CVAVariantShape
? CVAVariantSchema<V> & CVAClassProp
: CVAClassProp,
) => string & {
schema: V extends CVAVariantShape
? {
[Variant in keyof V]: ReadonlyArray<
StringToBoolean<Extract<keyof V[Variant], VariantValue>>
>;
// {
// [Variant in keyof V]?: StringToBoolean<keyof V[Variant]> | undefined;
// };
}
: never;
<_ extends InternalOnly, Config, Variants>(
config: Config & CVAConfig<Variants>,
): {
(
props?: Variants extends CVAVariantShape
? CVAVariantSchema<Variants> & CVAClassProp
: CVAClassProp,
): string;
_cva: Config;
};
}

@@ -152,7 +145,7 @@ export interface CVA {
export interface DefineConfigOptions {
hooks?: {
/**
* @deprecated please use `onComplete`
* @deprecated please use `onComplete`
*/
"cx:done"?: (className: string) => string;
/**
@@ -175,15 +168,6 @@ export interface DefineConfig {

const falsyToString = <T extends unknown>(value: T) =>
typeof value === "boolean" ? `${value}` : value === 0 ? "0" : value;
const normalizeVariantKey = <T extends string>(value: T) => {
if (value === "true") return true;
if (value === "false") return false;

const maybeNumber = Number(value);
if (!Number.isNaN(maybeNumber)) return maybeNumber;

return value;
};

export const defineConfig: DefineConfig = (options) => {
const cx: CX = (...inputs) => {
@@ -196,75 +180,70 @@ export const defineConfig: DefineConfig = (options) => {
return clsx(inputs);
};

const cva: CVA = (config) =>
Object.assign(
((props) => {
if (config?.variants == null)
return cx(config?.base, props?.class, props?.className);

const { variants, defaultVariants } = config;

const getVariantClassNames = Object.keys(variants).map(
(variant: keyof typeof variants) => {
const variantProp = props?.[variant as keyof typeof props];
const defaultVariantProp = defaultVariants?.[variant];

const variantKey = (falsyToString(variantProp) ||
falsyToString(
defaultVariantProp,
)) as keyof (typeof variants)[typeof variant];

return variants[variant][variantKey];
},
);

const defaultsAndProps = {
...defaultVariants,
// remove `undefined` props
...(props &&
Object.entries(props).reduce<typeof props>(
(acc, [key, value]) =>
typeof value === "undefined" ? acc : { ...acc, [key]: value },
{} as typeof props,
)),
};

const getCompoundVariantClassNames = config?.compoundVariants?.reduce(
(acc, { class: cvClass, className: cvClassName, ...cvConfig }) =>
Object.entries(cvConfig).every(([cvKey, cvSelector]) => {
const selector =
defaultsAndProps[cvKey as keyof typeof defaultsAndProps];

return Array.isArray(cvSelector)
? cvSelector.includes(selector)
: selector === cvSelector;
})
? [...acc, cvClass, cvClassName]
: acc,
[] as ClassValue[],
);

return cx(
config?.base,
getVariantClassNames,
getCompoundVariantClassNames,
props?.class,
props?.className,
);
}) as ReturnType<CVA>,
{
schema: config?.variants
? Object.fromEntries(
Object.entries(config?.variants).map(([key, value]) => [
key,
Object.keys(value).map((propertyKey) =>
normalizeVariantKey(propertyKey),
),
]),
)
: {},
},
);
const cva: CVA = (config) => {
// TODO MAKE TYPES WORK!
// @ts-expect-error
const cvaFn = (props) => {
if (config?.variants == null) {
return clsx(config?.base, props?.class, props?.className);
}

const { variants, defaultVariants } = config;

const getVariantClassNames = Object.keys(variants).map(
(variant: keyof typeof variants) => {
const variantProp = props?.[variant as keyof typeof props];
const defaultVariantProp = defaultVariants?.[variant];

const variantKey = (falsyToString(variantProp) ||
falsyToString(
defaultVariantProp,
)) as keyof (typeof variants)[typeof variant];

return variants[variant][variantKey];
},
);

const defaultsAndProps = {
...defaultVariants,
...(props &&
Object.entries(props).reduce<typeof props>(
(acc, [key, value]) =>
typeof value === "undefined" ? acc : { ...acc, [key]: value },
{} as typeof props,
)),
};

const getCompoundVariantClassNames = config?.compoundVariants?.reduce(
(acc, { class: cvClass, className: cvClassName, ...cvConfig }) =>
Object.entries(cvConfig).every(([cvKey, cvSelector]) => {
const selector =
defaultsAndProps[cvKey as keyof typeof defaultsAndProps];

return Array.isArray(cvSelector)
? cvSelector.includes(selector)
: selector === cvSelector;
})
? [...acc, cvClass, cvClassName]
: acc,
[] as ClassValue[],
);

return clsx(
config?.base,
getVariantClassNames,
getCompoundVariantClassNames,
props?.class,
props?.className,
);
};

// TODO
// Figure out how to make this `_cva.config`
cvaFn._cva = config;

return cvaFn;
};

const compose: Compose =
(...components) =>
@@ -290,3 +269,30 @@ export const defineConfig: DefineConfig = (options) => {
};

export const { compose, cva, cx } = defineConfig();

export interface CreateSchema {
<_ extends InternalOnly, Component, Variants>(
component: Component &
(Component extends ReturnType<CVA>
? { _cva: CVAConfig<Variants> }
: never),
): {
[Variant in keyof Variants]: ReadonlyArray<
StringToBoolean<keyof Variants[Variant]>
>;
};
}

export const getSchema: CreateSchema = (component) => {
const variants = component._cva.variants;
// TODO
// Remove `any` if possible
if (!variants) return {} as any;

return Object.fromEntries(
Object.entries(variants).map(([key, value]) => [
key,
Object.keys(value) as StringToBoolean<keyof typeof value>[],
]),
);
};