From b6e09f73d29adb571b49f7d30b29af60d25cd8df Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 15 Apr 2026 01:54:48 +0530 Subject: [PATCH 1/2] refactor: replace `as` prop with `render` prop in Text and Headline Migrate Text and Headline components from the brittle `as` polymorphic prop pattern to Base UI's `render` prop + `useRender` hook, matching the pattern already used by the Flex component. This eliminates manual type unions, removes a `@ts-expect-error` suppression, and enables rendering as any element via JSX or render functions. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../playground/headline-examples.tsx | 4 +- .../content/docs/components/headline/demo.ts | 2 +- .../content/docs/components/headline/props.ts | 7 ++- .../src/content/docs/components/text/demo.ts | 2 +- .../content/docs/components/text/index.mdx | 2 +- .../src/content/docs/components/text/props.ts | 7 ++- .../headline/__tests__/headline.test.tsx | 38 +++++++++++++--- .../raystack/components/headline/headline.tsx | 27 +++++++----- packages/raystack/components/link/link.tsx | 2 +- .../components/text/__tests__/text.test.tsx | 44 ++++++++++++------- packages/raystack/components/text/text.tsx | 30 ++++++------- 11 files changed, 104 insertions(+), 61 deletions(-) diff --git a/apps/www/src/components/playground/headline-examples.tsx b/apps/www/src/components/playground/headline-examples.tsx index 802efb456..ae4d8dbff 100644 --- a/apps/www/src/components/playground/headline-examples.tsx +++ b/apps/www/src/components/playground/headline-examples.tsx @@ -8,13 +8,13 @@ export function HeadlineExamples() { - + }> Large Headline Medium Headline - + }> Small Headline diff --git a/apps/www/src/content/docs/components/headline/demo.ts b/apps/www/src/content/docs/components/headline/demo.ts index a417a1c35..6c305e14d 100644 --- a/apps/www/src/content/docs/components/headline/demo.ts +++ b/apps/www/src/content/docs/components/headline/demo.ts @@ -20,7 +20,7 @@ export const playground = { options: ['regular', 'medium'], defaultValue: 'medium' }, - as: { + render: { type: 'select', options: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], defaultValue: 'h2' diff --git a/apps/www/src/content/docs/components/headline/props.ts b/apps/www/src/content/docs/components/headline/props.ts index f86784d87..7fc8614d4 100644 --- a/apps/www/src/content/docs/components/headline/props.ts +++ b/apps/www/src/content/docs/components/headline/props.ts @@ -12,10 +12,13 @@ export interface HeadlineProps { weight?: 'regular' | 'medium'; /** - * HTML heading element to render. + * Custom render element or function. Accepts a JSX element (e.g. `

`) + * or a render function for full control. * @default "h2" */ - as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + render?: + | React.ReactElement + | ((props: React.ComponentPropsWithRef<'h2'>) => React.ReactElement); /** * Text alignment. diff --git a/apps/www/src/content/docs/components/text/demo.ts b/apps/www/src/content/docs/components/text/demo.ts index 3f49caecd..4cc67a8ef 100644 --- a/apps/www/src/content/docs/components/text/demo.ts +++ b/apps/www/src/content/docs/components/text/demo.ts @@ -23,7 +23,7 @@ export const playground = { ], defaultValue: 'primary' }, - as: { + render: { type: 'select', options: ['span', 'p', 'div', 'label', 'a'], defaultValue: 'span' diff --git a/apps/www/src/content/docs/components/text/index.mdx b/apps/www/src/content/docs/components/text/index.mdx index c5de5012a..38bc586e2 100644 --- a/apps/www/src/content/docs/components/text/index.mdx +++ b/apps/www/src/content/docs/components/text/index.mdx @@ -29,7 +29,7 @@ import { Text } from '@raystack/apsara' ## API Reference -According to the element rendered using `as`, Text will extend over the default HTML Attributes +Use the `render` prop to change the rendered element or provide a custom render function. diff --git a/apps/www/src/content/docs/components/text/props.ts b/apps/www/src/content/docs/components/text/props.ts index b6ab559de..5ac54ab3d 100644 --- a/apps/www/src/content/docs/components/text/props.ts +++ b/apps/www/src/content/docs/components/text/props.ts @@ -1,9 +1,12 @@ export interface TextProps { /** - * Text element to render as. + * Custom render element or function. Accepts a JSX element (e.g. `

`) + * or a render function for full control. * @default "span" */ - as?: 'span' | 'p' | 'div' | 'label' | 'a'; + render?: + | React.ReactElement + | ((props: React.ComponentPropsWithRef<'span'>) => React.ReactElement); /** * The visual style variant. diff --git a/packages/raystack/components/headline/__tests__/headline.test.tsx b/packages/raystack/components/headline/__tests__/headline.test.tsx index 248b7defe..e5002f745 100644 --- a/packages/raystack/components/headline/__tests__/headline.test.tsx +++ b/packages/raystack/components/headline/__tests__/headline.test.tsx @@ -30,13 +30,35 @@ describe('Headline', () => { }); }); - describe('As Prop', () => { - const headingLevels = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const; + describe('Render Prop', () => { + it('renders as h1 via render prop', () => { + render(}>Heading); + const heading = screen.getByText('Heading'); + expect(heading.tagName).toBe('H1'); + }); + + it('renders as h3 via render prop', () => { + render(}>Heading); + const heading = screen.getByText('Heading'); + expect(heading.tagName).toBe('H3'); + }); + + it('renders as h4 via render prop', () => { + render(}>Heading); + const heading = screen.getByText('Heading'); + expect(heading.tagName).toBe('H4'); + }); + + it('renders as h5 via render prop', () => { + render(}>Heading); + const heading = screen.getByText('Heading'); + expect(heading.tagName).toBe('H5'); + }); - it.each(headingLevels)('renders as %s element', level => { - render(Heading); + it('renders as h6 via render prop', () => { + render(}>Heading); const heading = screen.getByText('Heading'); - expect(heading.tagName).toBe(level.toUpperCase()); + expect(heading.tagName).toBe('H6'); }); it('renders as h2 by default', () => { @@ -44,6 +66,12 @@ describe('Headline', () => { const heading = screen.getByText('Heading'); expect(heading.tagName).toBe('H2'); }); + + it('supports render function', () => { + render(

}>Heading); + const heading = screen.getByText('Heading'); + expect(heading.tagName).toBe('H1'); + }); }); describe('Sizes', () => { diff --git a/packages/raystack/components/headline/headline.tsx b/packages/raystack/components/headline/headline.tsx index dd09a72b1..c04f85c68 100644 --- a/packages/raystack/components/headline/headline.tsx +++ b/packages/raystack/components/headline/headline.tsx @@ -1,5 +1,5 @@ +import { mergeProps, useRender } from '@base-ui/react'; import { cva, type VariantProps } from 'class-variance-authority'; -import { ComponentProps } from 'react'; import styles from './headline.module.css'; const headline = cva(styles.headline, { @@ -41,10 +41,7 @@ export type HeadlineBaseProps = VariantProps & { size?: 't1' | 't2' | 't3' | 't4' | 'small' | 'medium' | 'large'; }; -type HeadlineProps = HeadlineBaseProps & - ComponentProps<'h1'> & { - as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; - }; +export type HeadlineProps = HeadlineBaseProps & useRender.ComponentProps<'h2'>; export function Headline({ className, @@ -52,15 +49,21 @@ export function Headline({ weight, align, truncate, - as: Component = 'h2', + render, + ref, ...props }: HeadlineProps) { - return ( - - ); + const element = useRender({ + defaultTagName: 'h2', + ref, + render, + props: mergeProps<'h2'>( + { className: headline({ size, weight, align, truncate, className }) }, + props + ) + }); + + return element; } Headline.displayName = 'Headline'; diff --git a/packages/raystack/components/link/link.tsx b/packages/raystack/components/link/link.tsx index 95009982b..811a7e4a1 100644 --- a/packages/raystack/components/link/link.tsx +++ b/packages/raystack/components/link/link.tsx @@ -42,7 +42,7 @@ export function Link({ {...externalProps} {...downloadProps} {...props} - as='a' + render={} > {children} diff --git a/packages/raystack/components/text/__tests__/text.test.tsx b/packages/raystack/components/text/__tests__/text.test.tsx index 10249da39..49e599459 100644 --- a/packages/raystack/components/text/__tests__/text.test.tsx +++ b/packages/raystack/components/text/__tests__/text.test.tsx @@ -206,50 +206,60 @@ describe('Text', () => { }); }); - describe('As Rendering', () => { - it('renders as div when specified', () => { - render(Div text); + describe('Render Prop', () => { + it('renders as div via render prop', () => { + render(}>Div text); const div = screen.getByText('Div text'); expect(div.tagName.toLowerCase()).toBe('div'); }); - it('renders as paragraph when specified', () => { - render(Paragraph text); + it('renders as paragraph via render prop', () => { + render(}>Paragraph text); const p = screen.getByText('Paragraph text'); expect(p.tagName.toLowerCase()).toBe('p'); }); - it('renders as label when specified', () => { - render(Label text); + it('renders as label via render prop', () => { + render(}>Label text); const label = screen.getByText('Label text'); expect(label.tagName.toLowerCase()).toBe('label'); }); - it('renders as anchor when specified', () => { - render( - - Link text - - ); + it('renders as anchor via render prop', () => { + render(}>Link text); const a = screen.getByText('Link text'); expect(a.tagName.toLowerCase()).toBe('a'); expect(a).toHaveAttribute('href', '#test'); }); - it('forwards props to the correct element type', () => { + it('renders as h1 via render prop', () => { + render(}>Heading text); + + const h1 = screen.getByText('Heading text'); + expect(h1.tagName.toLowerCase()).toBe('h1'); + }); + + it('forwards props to the rendered element', () => { render( - - Label for input - + }>Label for input ); const label = screen.getByText('Label for input'); expect(label).toHaveAttribute('for', 'test-input'); }); + + it('supports render function', () => { + render( +
}>Section text + ); + + const section = screen.getByText('Section text'); + expect(section.tagName.toLowerCase()).toBe('section'); + }); }); describe('HTML Attributes', () => { diff --git a/packages/raystack/components/text/text.tsx b/packages/raystack/components/text/text.tsx index bd86e1e59..745c21457 100644 --- a/packages/raystack/components/text/text.tsx +++ b/packages/raystack/components/text/text.tsx @@ -1,5 +1,5 @@ +import { mergeProps, useRender } from '@base-ui/react'; import { cva, type VariantProps } from 'class-variance-authority'; -import { ComponentProps } from 'react'; import styles from './text.module.css'; export const textVariants = cva(styles.text, { @@ -131,13 +131,7 @@ export type TextBaseProps = VariantProps & { | 900; }; -type TextSpanProps = { as?: 'span' } & ComponentProps<'span'>; -type TextDivProps = { as: 'div' } & ComponentProps<'div'>; -type TextLabelProps = { as: 'label' } & ComponentProps<'label'>; -type TextPProps = { as: 'p' } & ComponentProps<'p'>; -type TextAProps = { as: 'a' } & ComponentProps<'a'>; -export type TextProps = TextBaseProps & - (TextSpanProps | TextDivProps | TextLabelProps | TextPProps | TextAProps); +export type TextProps = TextBaseProps & useRender.ComponentProps<'span'>; export function Text({ className, @@ -150,9 +144,9 @@ export function Text({ underline, strikeThrough, italic, - as: Component = 'span', - children, - ...rest + render, + ref, + ...props }: TextProps) { const textClassName = textVariants({ size, @@ -167,12 +161,14 @@ export function Text({ italic }); - return ( - // @ts-expect-error polymorphic ref - - {children} - - ); + const element = useRender({ + defaultTagName: 'span', + ref, + render, + props: mergeProps<'span'>({ className: textClassName }, props) + }); + + return element; } Text.displayName = 'Text'; From 440e60e50acc3111d594db3c753d905f57cd63dc Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 16 Apr 2026 02:34:36 +0530 Subject: [PATCH 2/2] docs: fix render prop playground controls to use JSX element syntax The render prop accepts ReactElement values, not strings. Update playground select options to use JSX element strings (e.g. '

') so getPropsString generates correct `render={

}` syntax. Also document the render prop in Headline's API Reference section. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/www/src/content/docs/components/headline/demo.ts | 4 ++-- apps/www/src/content/docs/components/headline/index.mdx | 2 +- apps/www/src/content/docs/components/text/demo.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/www/src/content/docs/components/headline/demo.ts b/apps/www/src/content/docs/components/headline/demo.ts index 6c305e14d..c24eee6e1 100644 --- a/apps/www/src/content/docs/components/headline/demo.ts +++ b/apps/www/src/content/docs/components/headline/demo.ts @@ -22,8 +22,8 @@ export const playground = { }, render: { type: 'select', - options: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], - defaultValue: 'h2' + options: ['

', '

', '

', '

', '

', '
'], + defaultValue: '

' }, align: { type: 'select', diff --git a/apps/www/src/content/docs/components/headline/index.mdx b/apps/www/src/content/docs/components/headline/index.mdx index 4789a37c7..2b8dd8b1b 100644 --- a/apps/www/src/content/docs/components/headline/index.mdx +++ b/apps/www/src/content/docs/components/headline/index.mdx @@ -20,7 +20,7 @@ import { Headline } from '@raystack/apsara' ## API Reference -Renders a heading element with configurable size and weight. +Renders a heading element with configurable size and weight. Use the `render` prop to change the heading level or provide a custom render function. diff --git a/apps/www/src/content/docs/components/text/demo.ts b/apps/www/src/content/docs/components/text/demo.ts index 4cc67a8ef..232c85b2f 100644 --- a/apps/www/src/content/docs/components/text/demo.ts +++ b/apps/www/src/content/docs/components/text/demo.ts @@ -25,8 +25,8 @@ export const playground = { }, render: { type: 'select', - options: ['span', 'p', 'div', 'label', 'a'], - defaultValue: 'span' + options: ['', '

', '