Skip to content
Open
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>
Copy link
Preview

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 component reference 'h1' should be a string 'h1' for HTML elements. This will cause a runtime error as h1 is not defined.

Suggested change
<TextNode as={h1}>Hello!</TextNode>
<TextNode as="h1">Hello!</TextNode>

Copilot uses AI. Check for mistakes.

// 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
Preview

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';

Check warning on line 3 in packages/typography/src/TextNode/TestNode.spec.tsx

View workflow job for this annotation

GitHub Actions / Check lints

'axe' is defined but never used. Allowed unused vars must match /^_/u

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(

Check warning on line 34 in packages/typography/src/TextNode/TestNode.spec.tsx

View workflow job for this annotation

GitHub Actions / Check lints

'container' is assigned a value but never used. Allowed unused vars must match /^_/u
<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

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