Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f5d622e
fix(extendVariants): return component type error
IsDyh01 Oct 10, 2025
3858ffd
fix(CompoundVariants): correct type inference for extended/compound v…
ITBoomBKStudio Oct 24, 2025
84aaa3e
test: cover compound/extend inference; enforce CP required props
ITBoomBKStudio Oct 24, 2025
5ab2c68
fix(types): correct CompoundVariants class value inference
ITBoomBKStudio Oct 24, 2025
2dfa44c
fix(system-rsc): correct slot detection in getSlots()
ITBoomBKStudio Oct 24, 2025
6336fa8
fix(types): make ExtendVariants props optional and guard V[key] with …
ITBoomBKStudio Oct 25, 2025
b770b54
test(extendVariants): add compoundVariants integration test
ITBoomBKStudio Oct 25, 2025
0293552
fix(system-rsc): getSlots() brief JSDoc comment added
ITBoomBKStudio Oct 25, 2025
0021c8f
test(extendVariants): new styles - extended & fixed styles - origina…
ITBoomBKStudio Oct 25, 2025
73032ce
test(extendVariants): fixed slot component variant styles extended test
ITBoomBKStudio Oct 25, 2025
8baeab2
fix(types): avoid leaking React internals by removing PropsWithoutRef
ITBoomBKStudio Oct 26, 2025
4e64ece
chore(changeset): add patch for extendVariants and CompoundVariants t…
ITBoomBKStudio Oct 26, 2025
c878267
chore(system-rsc): add changeset for getSlots() slot detection fix
ITBoomBKStudio Oct 26, 2025
d18110f
refactor(types): unify slot value inference via GetSuggestedValues<S>…
ITBoomBKStudio Oct 26, 2025
5bbef15
Merge pull request #1 from ITBoomBKStudio/fix/getslots-logic
ITBoomBKStudio Nov 26, 2025
9f38de7
fix(extendVariants): improved as-prop handling and exclude classNames…
ITBoomBKStudio Nov 26, 2025
fe87bdb
fix(system-rsc): add polymorphic 'as' prop support to extendVariants
ITBoomBKStudio Dec 31, 2025
39581d3
Merge branch 'canary' into pr/5847
wingkwong Jan 4, 2026
a381ab1
Merge branch 'fix/compound-variants-types' of https://github.com/ITBo…
wingkwong Jan 4, 2026
5eb049e
chore(system-rsc): add missing tests
wingkwong Jan 4, 2026
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
5 changes: 5 additions & 0 deletions .changeset/nine-apes-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@heroui/system-rsc": patch
---

fix(system-rsc): correct type inference in extendVariants and CompoundVariants & correct slot detection in getSlots() to ensure proper slot key extraction and consistent compoundVariants behavior.
164 changes: 160 additions & 4 deletions packages/core/system-rsc/__tests__/extend-variants.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type {ExtendVariantProps} from "../src/extend-variants";
import type {ExtendVariantProps, ExtendVariantWithSlotsProps} from "../src/extend-variants";

import React from "react";
import {render, screen} from "@testing-library/react";

import {extendVariants} from "../src/extend-variants";
import {Button} from "../test-utils/extend-components";
import {Card} from "../test-utils/slots-component";
import {Link} from "../../react/src";

const createExtendNoSlotsComponent = (styles: ExtendVariantProps = {}) =>
extendVariants(Button, {
Expand Down Expand Up @@ -37,9 +38,14 @@ const createExtendNoSlotsComponent = (styles: ExtendVariantProps = {}) =>
],
});

const createExtendSlotsComponent = () =>
const createExtendSlotsComponent = (styles: ExtendVariantWithSlotsProps = {}) =>
extendVariants(Card, {
variants: {
variant: {
flat: "",
filled: "",
test: "",
},
shadow: {
none: {
base: "shadow-xs",
Expand Down Expand Up @@ -73,6 +79,19 @@ const createExtendSlotsComponent = () =>
shadow: "xl",
radius: "xl",
},
compoundVariants: styles?.compoundVariants ?? [
{
shadow: "none",
radius: "none",
class: "rounded-sm",
},
{
shadow: "none",
class: {
header: "scale-75",
},
},
],
});

describe("extendVariants function - no slots", () => {
Expand All @@ -91,14 +110,33 @@ describe("extendVariants function - no slots", () => {
expect(ref.current).not.toBeNull();
});

test("should render with given text", () => {
test("as Link should work", () => {
const Button2 = createExtendNoSlotsComponent();

render(<Button2>Press me</Button2>);
const {container} = render(
<Button2 as={Link} href="/sign-in">
Press me
</Button2>,
);

// Link component from react package - verify it renders
const link = container.querySelector("a");

expect(link).toBeInTheDocument();
expect(screen.getByText("Press me")).toBeInTheDocument();
});

test("should render with given text", () => {
const Button2 = createExtendNoSlotsComponent();
const {container} = render(
<Button2 className="px-3 py-2 rounded-medium hover:opacity-80">Press me</Button2>,
);

const button = container.querySelector("button");

expect(button).toHaveTextContent("Press me");
});

test("should override the base styles", () => {
const Button2 = createExtendNoSlotsComponent();
const {container} = render(
Expand Down Expand Up @@ -168,6 +206,78 @@ describe("extendVariants function - no slots", () => {
expect(button).toHaveClass("scale-150");
});

test("as prop should change rendered element to anchor", () => {
const Button2 = createExtendNoSlotsComponent();
const {container} = render(
<Button2 as={Link} href="/test">
Link Button
</Button2>,
);

const link = container.querySelector("a");

expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "/test");
expect(link).toHaveTextContent("Link Button");
});

test("as prop should change rendered element to div", () => {
const Button2 = createExtendNoSlotsComponent();
const {container} = render(<Button2 as="div">Div Button</Button2>);

const div = container.querySelector("div");

expect(div).toBeInTheDocument();
expect(div).toHaveTextContent("Div Button");

// Should not be a button
const button = container.querySelector("button");

expect(button).not.toBeInTheDocument();
});

test("ref should work with polymorphic component as anchor", () => {
const ref = React.createRef<HTMLAnchorElement>();
const Button2 = createExtendNoSlotsComponent();

render(
<Button2 ref={ref} as="a" href="/test">
Link
</Button2>,
);

expect(ref.current).toBeInstanceOf(HTMLAnchorElement);
expect(ref.current).toHaveAttribute("href", "/test");
});

test("variant styles should persist with 'as' prop", () => {
const Button2 = createExtendNoSlotsComponent();
const {container} = render(
<Button2 isScalable as="a" href="/test" size="2xl">
Link
</Button2>,
);

const link = container.querySelector("a");

expect(link).toHaveClass("size--2xl");
// isScalable=true triggers scale-125, but compound variant for size=2xl + isScalable overrides to scale-150
expect(link).toHaveClass("scale-150");
});

test("compound variant styles should work with 'as' prop", () => {
const Button2 = createExtendNoSlotsComponent();
const {container} = render(
<Button2 isScalable as="a" href="/test" size="2xl">
Link
</Button2>,
);

const link = container.querySelector("a");

expect(link).toHaveClass("scale-150"); // compound variant for size="2xl" + isScalable
});

test("should respect defaultVariants.className", () => {
const Button2 = extendVariants(Button, {
defaultVariants: {
Expand Down Expand Up @@ -291,6 +401,52 @@ describe("extendVariants function - with slots", () => {
expect(headerEl).toHaveClass("rounded-none");
});

test("should include the compound variant styles - extended", () => {
const Card2 = createExtendSlotsComponent();

const {getByTestId} = render(
<Card2 radius="none" shadow="none">
Card Content
</Card2>,
);

const baseEl = getByTestId("base");
const headerEl = getByTestId("header");

expect(baseEl).toHaveClass("rounded-sm");
expect(headerEl).toHaveClass("scale-75");
});

test("should include the compound variant styles - original", () => {
const Card2 = createExtendSlotsComponent({
compoundVariants: [
{
shadow: "none",
radius: "sm",
class: "rounded-xl",
},
{
radius: "sm",
class: {
header: "scale-150",
},
},
],
});

const {getByTestId} = render(
<Card2 radius="sm" shadow="none">
Card Content
</Card2>,
);

const baseEl = getByTestId("base");
const headerEl = getByTestId("header");

expect(baseEl).toHaveClass("rounded-xl");
expect(headerEl).toHaveClass("scale-150");
});

test("should override base component slots with direct slots option", () => {
const Card2 = extendVariants(Card, {
slots: {
Expand Down
86 changes: 43 additions & 43 deletions packages/core/system-rsc/src/extend-variants.d.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
import type {ClassValue, StringToBoolean, OmitUndefined, ClassProp} from "tailwind-variants";
import type {
ForwardRefExoticComponent,
JSXElementConstructor,
PropsWithoutRef,
RefAttributes,
} from "react";
import type {ForwardRefExoticComponent, JSXElementConstructor, RefAttributes} from "react";
import type {InternalForwardRefRenderFunction} from "./types.js";

type SlotsClassValue<S> = {
[K in keyof S]?: ClassValue;
};

type Variants<S> = {
[K: string]: {[P: string]: S extends undefined ? ClassValue : SlotsClassValue<S>};
[K: string]: {[P: string]: GetSuggestedValues<S>};
};

type ComponentProps<C> = C extends JSXElementConstructor<infer P> ? P : never;
Expand All @@ -20,16 +16,16 @@ type ComponentSlots<CP> = CP extends {classNames?: infer S} ? S : undefined;

type ValidateSubtype<T, U> = OmitUndefined<T> extends U ? "true" : "false";

type GetSuggestedValues<S> = S extends undefined ? ClassValue : SlotsClassValue<S>;
type GetSuggestedValues<S> = ClassValue | (S extends undefined ? never : SlotsClassValue<S>);

type SuggestedVariants<CP, S> = {
[K in keyof CP]?: ValidateSubtype<CP[K], string> extends "true"
[K in Exclude<keyof CP, "classNames" | "className" | "class">]?: ValidateSubtype<
CP[K],
string
> extends "true"
? {[K2 in CP[K]]?: GetSuggestedValues<S>}
: ValidateSubtype<CP[K], boolean> extends "true"
? {
true?: GetSuggestedValues<S>;
false?: GetSuggestedValues<S>;
}
? {true?: GetSuggestedValues<S>; false?: GetSuggestedValues<S>}
: never;
};

Expand All @@ -46,9 +42,11 @@ type VariantValue<V, SV> = {
: never);
};

type DefaultVariants<V, SV> = VariantValue<V, SV>;
type DefaultVariants<V, SV, S> = VariantValue<V, SV> & {
classNames?: S extends undefined ? never : SlotsClassValue<S>;
};

type CompoundVariants<V, SV> = Array<VariantValue<V, SV> & ClassProp<ClassValue>>;
type CompoundVariants<V, SV, S> = Array<VariantValue<V, SV> & ClassProp<GetSuggestedValues<S>>>;

type Options = {
/**
Expand All @@ -73,7 +71,7 @@ export type ExtendVariantProps = {

export type ExtendVariantWithSlotsProps = {
variants?: Record<string, Record<string, string | Record<string, string>>>;
defaultVariants?: Record<string, string>;
defaultVariants?: Record<string, string | Record<string, string>>;
compoundVariants?: Array<Record<string, boolean | string | Record<string, string>>>;
};

Expand All @@ -84,33 +82,35 @@ type InferRef<C> =
? I
: any;

export type ExtendVariants = {
<
C extends JSXElementConstructor<any>,
CP extends ComponentProps<C>,
S extends ComponentSlots<CP>,
V extends ComposeVariants<CP, S>,
SV extends SuggestedVariants<CP, S>,
DV extends DefaultVariants<V, SV>,
CV extends CompoundVariants<V, SV>,
>(
BaseComponent: C,
styles: {
variants?: V;
defaultVariants?: DV;
compoundVariants?: CV;
slots?: S;
},
opts?: Options,
): ForwardRefExoticComponent<
PropsWithoutRef<
CP & {
[key in keyof V]?: StringToBoolean<keyof V[key]>;
}
> &
RefAttributes<InferRef<C>>
>;
};
export type ExtendVariants = <
C extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>,
CP extends ComponentProps<C> = ComponentProps<C>,
S extends ComponentSlots<CP> = ComponentSlots<CP>,
V extends ComposeVariants<CP, S> = ComposeVariants<CP, S>,
SV extends SuggestedVariants<CP, S> = SuggestedVariants<CP, S>,
DV extends DefaultVariants<V, SV, S> = DefaultVariants<V, SV, S>,
CV extends CompoundVariants<V, SV, ComponentSlots<CP>> = CompoundVariants<
V,
SV,
ComponentSlots<CP>
>,
>(
BaseComponent: C,
styles: {
variants?: V;
defaultVariants?: DV;
compoundVariants?: CV;
slots?: S;
},
opts?: Options,
) => InternalForwardRefRenderFunction<
C,
{
[K in Exclude<keyof (CP & V), "ref" | "as">]?:
| (K extends keyof CP ? CP[K] : never)
| (K extends keyof V ? StringToBoolean<keyof NonNullable<V[K]>> : never);
}
>;

// main function
export declare const extendVariants: ExtendVariants;
Loading
Loading