diff --git a/.changeset/typography-text-node.md b/.changeset/typography-text-node.md new file mode 100644 index 0000000000..7b5e9519ba --- /dev/null +++ b/.changeset/typography-text-node.md @@ -0,0 +1,22 @@ +--- +'@leafygreen-ui/typography': minor +--- + +Adds `TextNode` component. + +Wraps a string in the provided `as` component, +or renders the provided `ReactNode`. + +Useful when rendering `children` props that can be any react node + +```tsx +Hello! +// Renders:

Hello!

+``` + +```tsx + +

Hello!

+
+// Renders:

Hello!

+``` \ No newline at end of file diff --git a/packages/typography/src/TextNode/TestNode.spec.tsx b/packages/typography/src/TextNode/TestNode.spec.tsx new file mode 100644 index 0000000000..057b5f7b25 --- /dev/null +++ b/packages/typography/src/TextNode/TestNode.spec.tsx @@ -0,0 +1,128 @@ +import React, { PropsWithChildren } from 'react'; +import { render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; + +import { TextNode } from './TextNode'; + +describe('packages/typography/TextNode', () => { + describe('when children is a string', () => { + test('renders string children wrapped in Polymorph component', () => { + render(Test string content); + expect(screen.getByText('Test string content')).toBeInTheDocument(); + }); + + test('renders as a div by default', () => { + render(Test string content); + expect( + screen.getByText('Test string content').tagName.toLowerCase(), + ).toEqual('div'); + }); + + test('renders with HTML element', () => { + const { container } = render( + Test paragraph content, + ); + const paragraph = container.querySelector('p'); + expect(paragraph).toBeInTheDocument(); + expect(paragraph).toHaveTextContent('Test paragraph content'); + }); + + test('renders as React component', () => { + const Wrapper = ({ children }: PropsWithChildren<{}>) => ( +
{children}
+ ); + const { container } = render( + Test paragraph content, + ); + const wrapperEl = screen.getByTestId('wrapper'); + expect(wrapperEl).toBeInTheDocument(); + }); + }); + + describe('when children is a React node', () => { + test('renders React node children directly without wrapping', () => { + const testContent = ( +
+ Nested content +
+ ); + + render({testContent}); + + expect(screen.getByTestId('test-div')).toBeInTheDocument(); + expect(screen.getByText('Nested content')).toBeInTheDocument(); + }); + + test('renders multiple React node children', () => { + render( + + First + Second + , + ); + + expect(screen.getByTestId('first-span')).toBeInTheDocument(); + expect(screen.getByTestId('second-span')).toBeInTheDocument(); + }); + + test('ignores as prop when children is not a string', () => { + const { container } = render( + +
React node content
+
, + ); + + // Should not create a paragraph wrapper + expect(container.querySelector('p')).not.toBeInTheDocument(); + // Should render the div directly + expect(screen.getByTestId('test-div')).toBeInTheDocument(); + }); + + test('renders complex nested React components', () => { + const ComplexComponent = () => ( +
+

Complex Title

+

Complex paragraph

+
+ ); + + render( + + + , + ); + + expect(screen.getByText('Complex Title')).toBeInTheDocument(); + expect(screen.getByText('Complex paragraph')).toBeInTheDocument(); + }); + }); + + describe('edge cases', () => { + test('handles empty children', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test('handles null children', () => { + const { container } = render({null}); + expect(container.firstChild).toBeNull(); + }); + + test('handles undefined children', () => { + const { container } = render({undefined}); + expect(container.firstChild).toBeNull(); + }); + + test('handles number children as string', () => { + render({42}); + expect(screen.getByText('42')).toBeInTheDocument(); + expect(screen.getByText('42').tagName.toLowerCase()).toEqual('span'); + }); + + test('handles boolean children', () => { + const { container } = render({true}); + // React doesn't render boolean values + expect(container.firstChild).toBeNull(); + }); + }); +}); diff --git a/packages/typography/src/TextNode/TextNode.tsx b/packages/typography/src/TextNode/TextNode.tsx new file mode 100644 index 0000000000..b2ecc46a2a --- /dev/null +++ b/packages/typography/src/TextNode/TextNode.tsx @@ -0,0 +1,29 @@ +import React, { PropsWithChildren } from 'react'; +import { Polymorph, type PolymorphicAs } from '@leafygreen-ui/polymorphic'; + +/** + * Wraps a string in the provided `as` component, + * or renders the provided `ReactNode`. + * + * Useful when rendering `children` props that can be any react node + * + * @example + * ``` + * Hello! //

Hello!

+ * ``` + * + * @example + * ``` + *

Hello!

//

Hello!

+ * ``` + */ +export const TextNode = ({ + children, + as, +}: PropsWithChildren<{ as?: PolymorphicAs }>) => { + return typeof children === 'string' || typeof children === 'number' ? ( + {children} + ) : ( + children + ); +}; diff --git a/packages/typography/src/index.ts b/packages/typography/src/index.ts index 048b16f53a..dbd06def7c 100644 --- a/packages/typography/src/index.ts +++ b/packages/typography/src/index.ts @@ -33,6 +33,7 @@ export type { OverlineProps } from './Overline/Overline.types'; export { bodyTypeScaleStyles } from './styles'; export { default as Subtitle } from './Subtitle/Subtitle'; export type { SubtitleProps } from './Subtitle/Subtitle.types'; +export { TextNode } from './TextNode/TextNode'; export { DEFAULT_LGID_ROOT, getLgIds, type GetLgIdsReturnType } from './utils'; export { StaticWidthText } from './utils/StaticWidthText'; export { useUpdatedBaseFontSize } from './utils/useUpdatedBaseFontSize';