Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 22 additions & 0 deletions .changeset/typography-text-node.md
Original file line number Diff line number Diff line change
@@ -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
<TextNode as={h1}>Hello!</TextNode>
// Renders: <h1>Hello!</h1>
```

```tsx
<TextNode>
<h2>Hello!</h2>
</TextNode>
// Renders: <h2>Hello!</h2>
```
128 changes: 128 additions & 0 deletions packages/typography/src/TextNode/TestNode.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import React, { PropsWithChildren } from 'react';
Copy link

Copilot AI Sep 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filename 'TestNode.spec.tsx' contains a typo. It should be 'TextNode.spec.tsx' to match the component name.

Copilot uses AI. Check for mistakes.

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(<TextNode>Test string content</TextNode>);
expect(screen.getByText('Test string content')).toBeInTheDocument();
});

test('renders as a div by default', () => {
render(<TextNode>Test string content</TextNode>);
expect(
screen.getByText('Test string content').tagName.toLowerCase(),
).toEqual('div');
});

test('renders with HTML element', () => {
const { container } = render(
<TextNode as="p">Test paragraph content</TextNode>,
);
const paragraph = container.querySelector('p');
expect(paragraph).toBeInTheDocument();
expect(paragraph).toHaveTextContent('Test paragraph content');
});

test('renders as React component', () => {
const Wrapper = ({ children }: PropsWithChildren<{}>) => (
<div data-testid="wrapper">{children}</div>
);
const { container } = render(
<TextNode as={Wrapper}>Test paragraph content</TextNode>,
);
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 = (
<div data-testid="test-div">
<span>Nested content</span>
</div>
);

render(<TextNode>{testContent}</TextNode>);

expect(screen.getByTestId('test-div')).toBeInTheDocument();
expect(screen.getByText('Nested content')).toBeInTheDocument();
});

test('renders multiple React node children', () => {
render(
<TextNode>
<span data-testid="first-span">First</span>
<span data-testid="second-span">Second</span>
</TextNode>,
);

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(
<TextNode as="p">
<div data-testid="test-div">React node content</div>
</TextNode>,
);

// 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 = () => (
<div>
<h2>Complex Title</h2>
<p>Complex paragraph</p>
</div>
);

render(
<TextNode>
<ComplexComponent />
</TextNode>,
);

expect(screen.getByText('Complex Title')).toBeInTheDocument();
expect(screen.getByText('Complex paragraph')).toBeInTheDocument();
});
});

describe('edge cases', () => {
test('handles empty children', () => {
const { container } = render(<TextNode></TextNode>);
expect(container.firstChild).toBeNull();
});

test('handles null children', () => {
const { container } = render(<TextNode>{null}</TextNode>);
expect(container.firstChild).toBeNull();
});

test('handles undefined children', () => {
const { container } = render(<TextNode>{undefined}</TextNode>);
expect(container.firstChild).toBeNull();
});

test('handles number children as string', () => {
render(<TextNode as={'span'}>{42}</TextNode>);
expect(screen.getByText('42')).toBeInTheDocument();
expect(screen.getByText('42').tagName.toLowerCase()).toEqual('span');
});

test('handles boolean children', () => {
const { container } = render(<TextNode>{true}</TextNode>);
// React doesn't render boolean values
expect(container.firstChild).toBeNull();
});
});
});
29 changes: 29 additions & 0 deletions packages/typography/src/TextNode/TextNode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React, { PropsWithChildren } from 'react';

Check failure on line 1 in packages/typography/src/TextNode/TextNode.tsx

View workflow job for this annotation

GitHub Actions / Check lints

Run autofix to sort these imports!
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
* ```
* <TextNode as={h1}>Hello!</TextNode> // <h1>Hello!</h1>
* ```
*
* @example
* ```
* <TextNode><h2>Hello!</h2></TextNode> // <h2>Hello!</h2>
* ```
*/
export const TextNode = ({
children,
as,
}: PropsWithChildren<{ as?: PolymorphicAs }>) => {
return typeof children === 'string' || typeof children === 'number' ? (
<Polymorph as={as}>{children}</Polymorph>
) : (
children
);
};
Comment on lines +20 to +29
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In what cases will this be used? Is it intended to be a wrapper that replaces logic elsewhere like in Description? If so, I'm wondering if we should add an explicit wrapper <div> around children on L27 so attributes/props can be passed to the component

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A use case for this could be Drawer. We currently have this:

<Body
as={typeof title === 'string' ? 'h2' : 'div'}
baseFontSize={BaseFontSize.Body2}
id={titleId}
className={titleStyles}
>
{title}
</Body>

but that could be replaced with TextNode like this:

const Wrapper = ({ children }: PropsWithChildren<{}>) => ( 
    <Body
      as={'h2'}
      baseFontSize={BaseFontSize.Body2}
      id={titleId}
      className={titleStyles}
    >
      {title}
    </Body>
);

  <TextNode as={Wrapper}>{title}</TextNode>

However, in this case, the consumer will lose the id attribute if using a ReactNode so being able to pass that id to the ReactNode could be beneficial.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good example! Yes, it seems like being able to pass attributes/props to the TextNode would be important

Copy link
Collaborator Author

@TheSonOfThomp TheSonOfThomp Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see the value in spreading props into the block that uses Polymorph, since that avoids the need for a Wrapper component.

<Drawer title="Some String" />

// renders
<TextNode as={Wrapper}>{title}</TextNode>
// or
<TextNode as='h2' className="..." id="abc">{title}</TextNode>

/// renders 
<Wrapper>{children}</Wrapper>
// or
<h2 {...props}>{children}</h2>

But I think an internal Wrapper might be preferable to potentially overriding props that the user has provided.

<Drawer title={<span id="xyz">My Title</span>} />

/// renders 
<TextNode as={H2} id="abc">{title}</TextNode>

/// renders
<span id="???">My title</span>

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, nesting in an extra div could make styling & layout brittle

How about this:

Suggested change
export const TextNode = ({
children,
as,
}: PropsWithChildren<{ as?: PolymorphicAs }>) => {
return typeof children === 'string' || typeof children === 'number' ? (
<Polymorph as={as}>{children}</Polymorph>
) : (
children
);
};
export const TextNode = ({
children,
as,
...props
}: PropsWithChildren<{ as?: PolymorphicAs }>) => { // TODO: make props Polymorphic
return typeof children === 'string' || typeof children === 'number' ? (
<Polymorph as={as}>{children}</Polymorph>
) : (
React.cloneElement(children, {
...props,
...children.props
})
);
};

1 change: 1 addition & 0 deletions packages/typography/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading