diff --git a/.changeset/nine-apes-remain.md b/.changeset/nine-apes-remain.md new file mode 100644 index 0000000000..ca670ffb6d --- /dev/null +++ b/.changeset/nine-apes-remain.md @@ -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. diff --git a/packages/core/system-rsc/__tests__/extend-variants.test.tsx b/packages/core/system-rsc/__tests__/extend-variants.test.tsx index 9d3979deb4..3d5ce5f41d 100644 --- a/packages/core/system-rsc/__tests__/extend-variants.test.tsx +++ b/packages/core/system-rsc/__tests__/extend-variants.test.tsx @@ -1,4 +1,4 @@ -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"; @@ -6,6 +6,7 @@ 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, { @@ -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", @@ -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", () => { @@ -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(Press me); + const {container} = render( + + Press me + , + ); + + // 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( + Press me, + ); + + const button = container.querySelector("button"); + + expect(button).toHaveTextContent("Press me"); + }); + test("should override the base styles", () => { const Button2 = createExtendNoSlotsComponent(); const {container} = render( @@ -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( + + Link Button + , + ); + + 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(Div Button); + + 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(); + const Button2 = createExtendNoSlotsComponent(); + + render( + + Link + , + ); + + 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( + + Link + , + ); + + 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( + + Link + , + ); + + 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: { @@ -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( + + Card Content + , + ); + + 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( + + Card Content + , + ); + + 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: { diff --git a/packages/core/system-rsc/src/extend-variants.d.ts b/packages/core/system-rsc/src/extend-variants.d.ts index 2dceadae6d..53c8e4d60f 100644 --- a/packages/core/system-rsc/src/extend-variants.d.ts +++ b/packages/core/system-rsc/src/extend-variants.d.ts @@ -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 = { [K in keyof S]?: ClassValue; }; type Variants = { - [K: string]: {[P: string]: S extends undefined ? ClassValue : SlotsClassValue}; + [K: string]: {[P: string]: GetSuggestedValues}; }; type ComponentProps = C extends JSXElementConstructor ? P : never; @@ -20,16 +16,16 @@ type ComponentSlots = CP extends {classNames?: infer S} ? S : undefined; type ValidateSubtype = OmitUndefined extends U ? "true" : "false"; -type GetSuggestedValues = S extends undefined ? ClassValue : SlotsClassValue; +type GetSuggestedValues = ClassValue | (S extends undefined ? never : SlotsClassValue); type SuggestedVariants = { - [K in keyof CP]?: ValidateSubtype extends "true" + [K in Exclude]?: ValidateSubtype< + CP[K], + string + > extends "true" ? {[K2 in CP[K]]?: GetSuggestedValues} : ValidateSubtype extends "true" - ? { - true?: GetSuggestedValues; - false?: GetSuggestedValues; - } + ? {true?: GetSuggestedValues; false?: GetSuggestedValues} : never; }; @@ -46,9 +42,11 @@ type VariantValue = { : never); }; -type DefaultVariants = VariantValue; +type DefaultVariants = VariantValue & { + classNames?: S extends undefined ? never : SlotsClassValue; +}; -type CompoundVariants = Array & ClassProp>; +type CompoundVariants = Array & ClassProp>>; type Options = { /** @@ -73,7 +71,7 @@ export type ExtendVariantProps = { export type ExtendVariantWithSlotsProps = { variants?: Record>>; - defaultVariants?: Record; + defaultVariants?: Record>; compoundVariants?: Array>>; }; @@ -84,33 +82,35 @@ type InferRef = ? I : any; -export type ExtendVariants = { - < - C extends JSXElementConstructor, - CP extends ComponentProps, - S extends ComponentSlots, - V extends ComposeVariants, - SV extends SuggestedVariants, - DV extends DefaultVariants, - CV extends CompoundVariants, - >( - BaseComponent: C, - styles: { - variants?: V; - defaultVariants?: DV; - compoundVariants?: CV; - slots?: S; - }, - opts?: Options, - ): ForwardRefExoticComponent< - PropsWithoutRef< - CP & { - [key in keyof V]?: StringToBoolean; - } - > & - RefAttributes> - >; -}; +export type ExtendVariants = < + C extends keyof JSX.IntrinsicElements | JSXElementConstructor, + CP extends ComponentProps = ComponentProps, + S extends ComponentSlots = ComponentSlots, + V extends ComposeVariants = ComposeVariants, + SV extends SuggestedVariants = SuggestedVariants, + DV extends DefaultVariants = DefaultVariants, + CV extends CompoundVariants> = CompoundVariants< + V, + SV, + ComponentSlots + >, +>( + BaseComponent: C, + styles: { + variants?: V; + defaultVariants?: DV; + compoundVariants?: CV; + slots?: S; + }, + opts?: Options, +) => InternalForwardRefRenderFunction< + C, + { + [K in Exclude]?: + | (K extends keyof CP ? CP[K] : never) + | (K extends keyof V ? StringToBoolean> : never); + } +>; // main function export declare const extendVariants: ExtendVariants; diff --git a/packages/core/system-rsc/src/extend-variants.js b/packages/core/system-rsc/src/extend-variants.js index 3d11be1c35..b9e43ae9ac 100644 --- a/packages/core/system-rsc/src/extend-variants.js +++ b/packages/core/system-rsc/src/extend-variants.js @@ -3,22 +3,38 @@ import {tv, cn} from "@heroui/theme"; import {mapPropsVariants} from "./utils"; +/** + * Extracts slot names from variant configurations. + * Traverses: variants -> variant groups -> variant configs -> slot names + * @param {Object} variants - Nested object: { variantName: { value: { slotName: "...", ... } } } + * @returns {Object} Map of slot names to empty strings + */ function getSlots(variants) { - return variants - ? Object.values(variants) - .flatMap(Object.values) - .reduce((acc, slot) => { - if (typeof slot === "object" && slot !== null && !(slot instanceof String)) { - Object.keys(slot).forEach((key) => { - if (!acc.hasOwnProperty(key)) { - acc[key] = ""; - } - }); - } - - return acc; - }, {}) - : {}; + if (!variants || typeof variants !== "object") return {}; + + const acc = Object.create(null); + + for (const group of Object.values(variants)) { + if (!group || typeof group !== "object") continue; + + for (const config of Object.values(group)) { + // Skip non-objects, arrays (which would yield numeric indices), and String objects + if ( + !config || + typeof config !== "object" || + Array.isArray(config) || + config instanceof String + ) { + continue; + } + + for (const slotName of Object.keys(config)) { + acc[slotName] = ""; + } + } + } + + return acc; } function getClassNamesWithProps({ @@ -114,22 +130,25 @@ export function extendVariants(BaseComponent, styles = {}, opts = {}) { const hasSlots = typeof slots === "object" && Object.keys(slots).length !== 0; const ForwardedComponent = React.forwardRef((originalProps = {}, ref) => { + // Extract 'as' prop if present + const {as: Component = BaseComponent, ...restProps} = originalProps; + const newProps = React.useMemo(() => getClassNamesWithProps( { slots, variants, compoundVariants, - props: originalProps, + props: restProps, // Use restProps without 'as' defaultVariants, hasSlots, opts, }, - [originalProps], + [restProps], ), ); - return React.createElement(BaseComponent, {...originalProps, ...newProps, ref}); + return React.createElement(Component, {...restProps, ...newProps, ref}); }); // Add collection node function for collection-based components diff --git a/packages/core/system-rsc/src/types.ts b/packages/core/system-rsc/src/types.ts index a2534abf1c..1bdafa4779 100644 --- a/packages/core/system-rsc/src/types.ts +++ b/packages/core/system-rsc/src/types.ts @@ -40,7 +40,7 @@ export type MergeWithAs< AsComponent extends As = As, > = (RightJoinProps | RightJoinProps) & { as?: AsComponent; -}; +} & React.RefAttributes; export type InternalForwardRefRenderFunction< Component extends As,