From d953cc3d899941b6cc32499eb6cd89923eb0da58 Mon Sep 17 00:00:00 2001 From: VINEETH ASOK KUMAR Date: Wed, 17 Dec 2025 01:45:36 +0100 Subject: [PATCH 1/2] Refactor components to use polymorphic utility types Replaces custom polymorphic type logic in Container, GridContainer, EllipsisContent, Link, and Text components with shared utility types from src/utils/polymorphic. Adds src/utils/polymorphic with documentation and type tests to ensure type safety and ref forwarding for polymorphic components. This centralizes and standardizes the polymorphic pattern across the codebase. --- src/components/Container/Container.tsx | 29 +- .../EllipsisContent/EllipsisContent.tsx | 30 +-- .../GridContainer/GridContainer.tsx | 27 +- src/components/Link/Link.tsx | 30 +-- src/components/Typography/Text/Text.tsx | 29 +- src/utils/polymorphic/README.md | 234 ++++++++++++++++ src/utils/polymorphic/index.test.tsx | 251 ++++++++++++++++++ src/utils/polymorphic/index.ts | 58 ++++ 8 files changed, 603 insertions(+), 85 deletions(-) create mode 100644 src/utils/polymorphic/README.md create mode 100644 src/utils/polymorphic/index.test.tsx create mode 100644 src/utils/polymorphic/index.ts diff --git a/src/components/Container/Container.tsx b/src/components/Container/Container.tsx index 8ac566d4a..2625d1698 100644 --- a/src/components/Container/Container.tsx +++ b/src/components/Container/Container.tsx @@ -1,12 +1,12 @@ import { styled } from "styled-components"; -import { - ComponentProps, - ComponentPropsWithRef, - ElementType, - ReactNode, - forwardRef, -} from "react"; +import { ElementType, forwardRef } from "react"; import { Orientation } from "@/components"; +import { + PolymorphicComponent, + PolymorphicComponentProps, + PolymorphicProps, + PolymorphicRef, +} from "@/utils/polymorphic"; type AlignItemsOptions = "start" | "center" | "end" | "stretch"; export type GapOptions = "none" | "xxs" | "xs" | "sm" | "md" | "lg" | "xl" | "xxl"; @@ -23,9 +23,8 @@ type JustifyContentOptions = export type PaddingOptions = "none" | "xxs" | "xs" | "sm" | "md" | "lg" | "xl" | "xxl"; type WrapOptions = "nowrap" | "wrap" | "wrap-reverse"; -export interface ContainerProps { - /** Custom component to render as */ - component?: T; +export interface ContainerProps + extends PolymorphicComponentProps { /** Alignment of items along the cross axis */ alignItems?: AlignItemsOptions; /** The content to display inside the container */ @@ -62,10 +61,6 @@ export interface ContainerProps { overflow?: string; } -type ContainerPolymorphicComponent = ( - props: Omit, keyof T> & ContainerProps -) => ReactNode; - const _Container = ( { component, @@ -87,8 +82,8 @@ const _Container = ( minHeight, overflow, ...props - }: Omit, keyof T> & ContainerProps, - ref: ComponentPropsWithRef["ref"] + }: PolymorphicProps>, + ref: PolymorphicRef ) => { return ( = forwardRef(_Container); diff --git a/src/components/EllipsisContent/EllipsisContent.tsx b/src/components/EllipsisContent/EllipsisContent.tsx index 83b71af1d..2e0aaa1c5 100644 --- a/src/components/EllipsisContent/EllipsisContent.tsx +++ b/src/components/EllipsisContent/EllipsisContent.tsx @@ -1,12 +1,12 @@ -import { - ComponentProps, - ComponentPropsWithRef, - ElementType, - ReactNode, - forwardRef, -} from "react"; +import { ElementType, forwardRef } from "react"; import { mergeRefs } from "@/utils/mergeRefs"; import { styled } from "styled-components"; +import { + PolymorphicComponent, + PolymorphicComponentProps, + PolymorphicProps, + PolymorphicRef, +} from "@/utils/polymorphic"; const EllipsisContainer = styled.div` display: inline-block; @@ -24,17 +24,12 @@ const EllipsisContainer = styled.div` text-overflow: ellipsis; } `; -export interface EllipsisContentProps { - component?: T; -} - -type EllipsisPolymorphicComponent = ( - props: Omit, keyof T> & EllipsisContentProps -) => ReactNode; +export interface EllipsisContentProps + extends PolymorphicComponentProps {} const _EllipsisContent = ( - { component, ...props }: Omit, keyof T> & EllipsisContentProps, - ref: ComponentPropsWithRef["ref"] + { component, ...props }: PolymorphicProps>, + ref: PolymorphicRef ) => { return ( ( ); }; -export const EllipsisContent: EllipsisPolymorphicComponent = forwardRef(_EllipsisContent); +export const EllipsisContent: PolymorphicComponent = + forwardRef(_EllipsisContent); diff --git a/src/components/GridContainer/GridContainer.tsx b/src/components/GridContainer/GridContainer.tsx index 1ecb7af02..6c34a210e 100644 --- a/src/components/GridContainer/GridContainer.tsx +++ b/src/components/GridContainer/GridContainer.tsx @@ -1,11 +1,11 @@ import { styled } from "styled-components"; +import { ElementType, forwardRef } from "react"; import { - ComponentProps, - ComponentPropsWithRef, - ElementType, - forwardRef, - ReactNode, -} from "react"; + PolymorphicComponent, + PolymorphicComponentProps, + PolymorphicProps, + PolymorphicRef, +} from "@/utils/polymorphic"; export type FlowOptions = "row" | "column" | "row-dense" | "column-dense"; type GapOptions = "none" | "xxs" | "xs" | "sm" | "md" | "lg" | "xl" | "xxl" | "unset"; @@ -21,9 +21,8 @@ type ContentOptions = | "left" | "right"; -export interface GridContainerProps { - /** Custom component to render as */ - component?: T; +export interface GridContainerProps + extends PolymorphicComponentProps { /** Alignment of items along the block axis */ alignItems?: ItemsOptions; /** Alignment of content along the block axis */ @@ -74,10 +73,6 @@ export interface GridContainerProps { overflow?: string; } -type GridContainerPolymorphicComponent = ( - props: Omit, keyof T> & GridContainerProps -) => ReactNode; - const _GridContainer = ( { alignItems = "stretch", @@ -106,8 +101,8 @@ const _GridContainer = ( overflow, component, ...props - }: Omit, keyof T> & GridContainerProps, - ref: ComponentPropsWithRef["ref"] + }: PolymorphicProps>, + ref: PolymorphicRef ) => { return ( = forwardRef(_GridContainer); diff --git a/src/components/Link/Link.tsx b/src/components/Link/Link.tsx index 4f657bb3f..be4f18434 100644 --- a/src/components/Link/Link.tsx +++ b/src/components/Link/Link.tsx @@ -1,17 +1,17 @@ -import { - ComponentProps, - ComponentPropsWithRef, - ElementType, - ReactEventHandler, - ReactNode, - forwardRef, -} from "react"; +import { ElementType, ReactEventHandler, forwardRef } from "react"; import { Icon, IconName } from "@/components"; import { styled } from "styled-components"; import { linkStyles } from "./common"; import { TextSize, TextWeight } from "../commonTypes"; +import { + PolymorphicComponent, + PolymorphicComponentProps, + PolymorphicProps, + PolymorphicRef, +} from "@/utils/polymorphic"; -export interface LinkProps { +export interface LinkProps + extends PolymorphicComponentProps { /** The font size of the link text */ size?: TextSize; /** The font weight of the link text */ @@ -22,8 +22,6 @@ export interface LinkProps { children?: React.ReactNode; /** Optional icon to display after the link text */ icon?: IconName; - /** Custom component to render as the link element */ - component?: T; } const CuiLink = styled.a<{ $size: TextSize; $weight: TextWeight }>` @@ -43,10 +41,6 @@ const IconWrapper = styled.span<{ $size: TextSize }>` } `; -type LinkPolymorphicComponent = ( - props: Omit, keyof T> & LinkProps -) => ReactNode; - /** Component for linking to other pages or sections from with body text */ const _Link = ( { @@ -57,8 +51,8 @@ const _Link = ( children, component, ...props - }: Omit, keyof T> & LinkProps, - ref: ComponentPropsWithRef["ref"] + }: PolymorphicProps>, + ref: PolymorphicRef ) => ( ( )} ); -export const Link: LinkPolymorphicComponent = forwardRef(_Link); +export const Link: PolymorphicComponent = forwardRef(_Link); diff --git a/src/components/Typography/Text/Text.tsx b/src/components/Typography/Text/Text.tsx index 91a75c872..f1af4cfbf 100644 --- a/src/components/Typography/Text/Text.tsx +++ b/src/components/Typography/Text/Text.tsx @@ -1,17 +1,18 @@ -import { - ComponentProps, - ComponentPropsWithRef, - ElementType, - ReactNode, - forwardRef, -} from "react"; +import { ElementType, ReactNode, forwardRef } from "react"; import { styled } from "styled-components"; import { TextSize, TextWeight } from "@/components/commonTypes"; +import { + PolymorphicComponent, + PolymorphicComponentProps, + PolymorphicProps, + PolymorphicRef, +} from "@/utils/polymorphic"; export type TextAlignment = "left" | "center" | "right"; export type TextColor = "default" | "muted" | "danger" | "disabled"; -export interface TextProps { +export interface TextProps + extends PolymorphicComponentProps { /** The text content to display */ children: ReactNode; /** The text alignment */ @@ -24,16 +25,10 @@ export interface TextProps { weight?: TextWeight; /** Additional CSS class name */ className?: string; - /** Custom component to render as */ - component?: T; /** Whether the text should fill the full width of its container */ fillWidth?: boolean; } -type TextPolymorphicComponent = ( - props: Omit, keyof T> & TextProps -) => ReactNode; - const _Text = ( { align, @@ -45,8 +40,8 @@ const _Text = ( component, fillWidth, ...props - }: Omit, keyof T> & TextProps, - ref: ComponentPropsWithRef["ref"] + }: PolymorphicProps>, + ref: PolymorphicRef ) => ( = forwardRef(_Text); export { Text }; diff --git a/src/utils/polymorphic/README.md b/src/utils/polymorphic/README.md new file mode 100644 index 000000000..f1f2ee0ce --- /dev/null +++ b/src/utils/polymorphic/README.md @@ -0,0 +1,234 @@ +## What Are Polymorphic Components? + +Polymorphic components allow you to change the underlying HTML element or React component while preserving full type safety. This means you get autocomplete and type checking for both: +1. The custom component's props (like `gap`, `orientation`, etc.) +2. The native element's props (like `onClick`, `href`, `disabled`, etc.) + +## Key Features + +✅ **Full Type Safety** - TypeScript knows which props are valid based on the `component` prop +✅ **DRY Implementation** - Reusable utility types in `@/utils/polymorphic` +✅ **Ref Forwarding** - Properly typed refs for any element type +✅ **Native & Custom Components** - Pass any HTML element or React component +✅ **Works with Styled Components** - Compatible with styled-components and SCSS modules + +## Implementation + +### Utility Types + +All polymorphic logic is centralized in [`src/utils/polymorphic/index.ts`](index.ts): + +```typescript +import { + PolymorphicComponent, + PolymorphicComponentProps, + PolymorphicProps, + PolymorphicRef, +} from "@/utils/polymorphic"; +``` + +### Creating a Polymorphic Component + +```typescript +import { ElementType, forwardRef } from "react"; +import clsx from "clsx"; +import { + PolymorphicComponent, + PolymorphicComponentProps, + PolymorphicProps, + PolymorphicRef, +} from "@/utils/polymorphic"; +import styles from "./MyComponent.module.scss"; + +// 1. Define props interface extending PolymorphicComponentProps +export interface MyComponentProps + extends PolymorphicComponentProps { + customProp?: string; + gap?: "sm" | "md" | "lg"; +} + +// 2. Implement the component with PolymorphicProps and PolymorphicRef +const _MyComponent = ( + { component, customProp, gap, ...props }: PolymorphicProps>, + ref: PolymorphicRef +) => { + const Component = component ?? "div"; + + return ( + + ); +}; + +// 3. Export with PolymorphicComponent type +export const MyComponent: PolymorphicComponent = forwardRef(_MyComponent); +``` + +## Usage Examples + +### With Native HTML Elements + +```tsx +// As a button - gets all button props + console.log(e)} + disabled + gap="md" // Custom Container prop +/> + +// As an anchor - gets all anchor props + + +// As a list item + + List item text + +``` + +### With Custom React Components + +```tsx +// Container rendered as Link + + This is a Container with Link styling + + +// EllipsisContent rendered as Link + + This text will be truncated with ellipsis + +``` + +### With Refs + +```tsx +const buttonRef = useRef(null); +const divRef = useRef(null); + + + Button Container + + + + Div Container (default) + +``` + +## Available Polymorphic Components + +The following components support the `component` prop: + +- **`Container`** - Flexible layout container (default: `div`) +- **`GridContainer`** - CSS Grid container (default: `div`) +- **`EllipsisContent`** - Text truncation wrapper (default: `div`) +- **`Link`** - Link component (default: `a`) +- **`Text`** - Text/Typography component (default: `p`) + +## Type Safety Examples + +### ✅ Valid Usage + +```tsx +// All these work perfectly with full autocomplete: + + + + + +``` + +### ❌ Type Errors (Caught at Compile Time) + +```tsx +// ERROR: div doesn't have 'disabled' + + +// ERROR: span doesn't have 'href' + + +// ERROR: Invalid prop value + +``` + +## Real-World Example + +```tsx + + + + Page Title + + + + + + Section Title + + + + Feature 1 + Feature 2 + Feature 3 + + + + + + Documentation + + + +``` + +## Technical Details + +### How It Works + +1. **`PolymorphicComponentProps`** - Base interface with `component?: T` +2. **`PolymorphicProps`** - Merges component-specific props with native element props +3. **`PolymorphicRef`** - Extracts the correct ref type for element `T` +4. **`PolymorphicComponent`** - Final exported component type with proper inference + +### Type Inference Flow + +```typescript + + ↓ +T = "button" (inferred from component prop) + ↓ +Props = ContainerProps & ComponentProps<"button"> + ↓ +Result: You get autocomplete for both Container AND button props! +``` + +For more examples, see [`src/utils/polymorphic/index.test.tsx`](index.test.tsx). diff --git a/src/utils/polymorphic/index.test.tsx b/src/utils/polymorphic/index.test.tsx new file mode 100644 index 000000000..6af252bdb --- /dev/null +++ b/src/utils/polymorphic/index.test.tsx @@ -0,0 +1,251 @@ +/** + * Type safety tests for polymorphic components with SCSS + * These examples demonstrate that the polymorphic component pattern + * works correctly with SCSS modules and provides full type safety + */ + +import { Container, Link, GridContainer, EllipsisContent } from "@/components"; +import { Text } from "@/components/Typography/Text/Text"; + +// ============================================================================ +// Test 1: Native HTML Elements +// ============================================================================ + +export const NativeElementTest = () => ( + <> + {/* Container as button - gets button props */} + ) => + console.log(e.currentTarget.value) + } // ✅ button events work + disabled // ✅ button attributes work + gap="md" // ✅ Container props work + /> + + {/* Container as a link */} + + + {/* Link as span */} + ) => console.log(e)} // ✅ span events work + > + Link content + + + {/* Text as h1 */} + + Heading text + + +); + +// ============================================================================ +// Test 2: Custom React Components +// ============================================================================ + +export const CustomComponentTest = () => ( + <> + {/* Container can render another custom component */} + + This is a Container rendered as Link + + + {/* EllipsisContent rendered as Link */} + + This text will be truncated + + + {/* Container rendered as Text */} + + This is a Container rendered as Text + + + {/* GridContainer as section */} + + + {/* Link as button */} + console.log(e)} // ✅ button events work + disabled // ✅ button attributes work + size="lg" // ✅ Link props work + weight="semibold" // ✅ Link props work + > + Button Link + + +); + +// ============================================================================ +// Test 3: Type Errors (These should NOT compile if uncommented) +// ============================================================================ + +// Example type errors that would be caught: +// 1. div doesn't have 'disabled' +// 2. span doesn't have 'href' +// 3. Invalid Container prop values +// 4. Invalid Link color values + +// ============================================================================ +// Test 4: Ref Forwarding +// ============================================================================ + +import { useRef } from "react"; + +export const RefTest = () => { + const buttonRef = useRef(null); + const divRef = useRef(null); + + return ( + <> + {/* ✅ Ref type matches component type */} + + Button Container + + + {/* ✅ Default ref type (div) */} + + Div Container + + + ); +}; + +// ============================================================================ +// Test 5: Real-World Usage Examples +// ============================================================================ + +export const RealWorldExample = () => ( + + + + Polymorphic Components with Styled Components + + + + + + Features + + + + + ✅ Full type safety + + + ✅ Works with styled-components + + + ✅ Native HTML props + + + ✅ Custom component props + + + + + + + Learn more + + + +); + +// ============================================================================ +// Vitest Suite +// ============================================================================ + +import { describe, it, expect } from "vitest"; + +describe("Polymorphic Components Type Safety", () => { + it("should render polymorphic components without errors", () => { + // This test suite is primarily for TypeScript type checking + // The type-safe rendering is validated at compile time + expect(true).toBe(true); + }); +}); diff --git a/src/utils/polymorphic/index.ts b/src/utils/polymorphic/index.ts new file mode 100644 index 000000000..9e741cb72 --- /dev/null +++ b/src/utils/polymorphic/index.ts @@ -0,0 +1,58 @@ +import { ComponentProps, ComponentPropsWithRef, ElementType, ReactNode } from "react"; + +/** + * Utility types for creating type-safe polymorphic components with SCSS modules + * + * Usage example: + * ```tsx + * interface MyComponentProps extends PolymorphicComponentProps { + * customProp?: string; + * } + * + * type MyComponentType = PolymorphicComponent; + * + * const _MyComponent = ( + * props: PolymorphicProps>, + * ref: PolymorphicRef + * ) => { + * const Component = props.component ?? "div"; + * return ; + * }; + * + * export const MyComponent: MyComponentType = forwardRef(_MyComponent); + * ``` + */ + +/** + * Base props for polymorphic components + */ +export interface PolymorphicComponentProps { + component?: T; +} + +/** + * Merges the component's custom props with native HTML element props + * Excludes conflicting keys from ComponentProps to ensure custom props take precedence + */ +export type PolymorphicProps< + T extends ElementType, + TProps extends PolymorphicComponentProps, +> = Omit, keyof TProps> & TProps; + +/** + * Extracts the correct ref type for the polymorphic component + */ +export type PolymorphicRef = ComponentPropsWithRef["ref"]; + +/** + * Type for the final exported polymorphic component + * This uses a mapped type to properly infer the element type + */ +export type PolymorphicComponent< + TProps extends PolymorphicComponentProps, + TDefaultElement extends ElementType = "div", +> = ( + props: PolymorphicProps & { component?: T }> & { + ref?: PolymorphicRef; + } +) => ReactNode; From 8044ae30b02e1f2b23f11af5a4bce5746f3aa5ae Mon Sep 17 00:00:00 2001 From: VINEETH ASOK KUMAR Date: Wed, 17 Dec 2025 13:09:58 +0100 Subject: [PATCH 2/2] Prettify content --- src/components/Container/Container.tsx | 5 +++-- src/components/EllipsisContent/EllipsisContent.tsx | 5 +++-- src/components/GridContainer/GridContainer.tsx | 5 +++-- src/components/Link/Link.tsx | 5 +++-- src/components/Typography/Text/Text.tsx | 5 +++-- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/components/Container/Container.tsx b/src/components/Container/Container.tsx index 2625d1698..8c97b0d83 100644 --- a/src/components/Container/Container.tsx +++ b/src/components/Container/Container.tsx @@ -23,8 +23,9 @@ type JustifyContentOptions = export type PaddingOptions = "none" | "xxs" | "xs" | "sm" | "md" | "lg" | "xl" | "xxl"; type WrapOptions = "nowrap" | "wrap" | "wrap-reverse"; -export interface ContainerProps - extends PolymorphicComponentProps { +export interface ContainerProps< + T extends ElementType = "div", +> extends PolymorphicComponentProps { /** Alignment of items along the cross axis */ alignItems?: AlignItemsOptions; /** The content to display inside the container */ diff --git a/src/components/EllipsisContent/EllipsisContent.tsx b/src/components/EllipsisContent/EllipsisContent.tsx index 2e0aaa1c5..95c839f4d 100644 --- a/src/components/EllipsisContent/EllipsisContent.tsx +++ b/src/components/EllipsisContent/EllipsisContent.tsx @@ -24,8 +24,9 @@ const EllipsisContainer = styled.div` text-overflow: ellipsis; } `; -export interface EllipsisContentProps - extends PolymorphicComponentProps {} +export interface EllipsisContentProps< + T extends ElementType = "div", +> extends PolymorphicComponentProps {} const _EllipsisContent = ( { component, ...props }: PolymorphicProps>, diff --git a/src/components/GridContainer/GridContainer.tsx b/src/components/GridContainer/GridContainer.tsx index 6c34a210e..2d4ed2f45 100644 --- a/src/components/GridContainer/GridContainer.tsx +++ b/src/components/GridContainer/GridContainer.tsx @@ -21,8 +21,9 @@ type ContentOptions = | "left" | "right"; -export interface GridContainerProps - extends PolymorphicComponentProps { +export interface GridContainerProps< + T extends ElementType = "div", +> extends PolymorphicComponentProps { /** Alignment of items along the block axis */ alignItems?: ItemsOptions; /** Alignment of content along the block axis */ diff --git a/src/components/Link/Link.tsx b/src/components/Link/Link.tsx index be4f18434..4d51c5f30 100644 --- a/src/components/Link/Link.tsx +++ b/src/components/Link/Link.tsx @@ -10,8 +10,9 @@ import { PolymorphicRef, } from "@/utils/polymorphic"; -export interface LinkProps - extends PolymorphicComponentProps { +export interface LinkProps< + T extends ElementType = "a", +> extends PolymorphicComponentProps { /** The font size of the link text */ size?: TextSize; /** The font weight of the link text */ diff --git a/src/components/Typography/Text/Text.tsx b/src/components/Typography/Text/Text.tsx index f1af4cfbf..a4204040e 100644 --- a/src/components/Typography/Text/Text.tsx +++ b/src/components/Typography/Text/Text.tsx @@ -11,8 +11,9 @@ import { export type TextAlignment = "left" | "center" | "right"; export type TextColor = "default" | "muted" | "danger" | "disabled"; -export interface TextProps - extends PolymorphicComponentProps { +export interface TextProps< + T extends ElementType = "p", +> extends PolymorphicComponentProps { /** The text content to display */ children: ReactNode; /** The text alignment */