import { pretty, render } from '@react-email/render';
import { ResponsiveColumn, ResponsiveRow } from '@responsive-email/react-email';
import React from 'react';
import plugin from 'tailwindcss/plugin';
import { Body } from '../body/index.js';
import { Button } from '../button/index.js';
import { Head } from '../head/index.js';
import { Heading } from '../heading/index.js';
import { Hr } from '../hr/index.js';
import { Html } from '../html/index.js';
import { Link } from '../link/index.js';
import type { TailwindConfig } from './tailwind.js';
import { Tailwind } from './tailwind.js';
describe('Tailwind component', () => {
const headMissingError =
'Tailwind:
not found inside .\nMove inside , or remove these classes that require a :';
it('allows for complex children manipulation', async () => {
const actualOutput = await render(
This is the first column
This is the second column
,
);
expect(actualOutput).toMatchInlineSnapshot(
`"This is the second column
"`,
);
});
it('works with blocklist', async () => {
const actualOutput = await render(
Click me
,
{ pretty: true },
);
expect(actualOutput).toMatchInlineSnapshot(`
"
Click me
"
`);
});
it('works with shadows', async () => {
expect(
await render(
shadow around here
,
).then(pretty),
).toMatchInlineSnapshot(`
"
shadow around here
"
`);
});
it('works with class manipulation done on components', async () => {
const MyComponnt = (props: {
className?: string;
style?: React.CSSProperties;
}) => {
expect(
props.style,
'styles should not be generated for a component',
).toBeUndefined();
expect(props.className).toBe('p-4 text-blue-400');
return (
);
};
expect(
await render(
,
),
).toMatchInlineSnapshot(
`"
"`,
);
});
it("works properly with 'no-underline'", async () => {
const actualOutput = await render(
or copy and paste this URL into your browser:{' '}
https://react.email
or copy and paste this URL into your browser:{' '}
https://react.email
,
);
expect(actualOutput).toMatchInlineSnapshot(
`"or copy and paste this URL into your browser: https://react.email
or copy and paste this URL into your browser: https://react.email
"`,
);
});
it('renders children with inline Tailwind styles', async () => {
const actualOutput = await render(
,
);
expect(actualOutput).toMatchInlineSnapshot(
`"
"`,
);
});
test('', async () => {
const actualOutput = await render(
Testing button
Testing
,
);
expect(actualOutput).toMatchInlineSnapshot(
`"Testing button Testing"`,
);
});
it('works with custom components with fragment at the root', async () => {
const Wrapper = (props: { children: React.ReactNode }) => {
return {props.children} ;
};
const Brand = () => {
return (
<>
>
);
};
const EmailTemplate = () => {
return (
Hello world
);
};
const actualOutput = await render(EmailTemplate());
expect(actualOutput).toMatchInlineSnapshot(
`"Hello world
"`,
);
});
it("doesn't generate styles from text", async () => {
expect(
await render(container bg-red-500 bg-blue-300 ),
).toMatchInlineSnapshot(
`"container bg-red-500 bg-blue-300"`,
);
});
it('works with components that return children', async () => {
const Wrapper = (props: { children: React.ReactNode }) => {
return {props.children} ;
};
const Brand = () => {
return (
);
};
const EmailTemplate = () => {
return (
Hello world
);
};
const actualOutput = await render(EmailTemplate());
expect(actualOutput).toMatchInlineSnapshot(
`"Hello world
"`,
);
});
it('works with Heading component', async () => {
const EmailTemplate = () => {
return (
Hello
My testing heading
friends
);
};
expect(await render( )).toMatchInlineSnapshot(
`"HelloMy testing heading friends"`,
);
});
it('works with components that use React.forwardRef', async () => {
const Wrapper = (props: { children: React.ReactNode }) => {
return {props.children} ;
};
const Brand = React.forwardRef((ref, props) => {
return (
}
{...props}
>
React Email
);
});
Brand.displayName = 'Brand';
const EmailTemplate = () => {
return (
Hello world
);
};
const actualOutput = await render(EmailTemplate());
expect(actualOutput).toMatchInlineSnapshot(
`"Hello world
"`,
);
});
it('uses background image', async () => {
const actualOutput = await render(
,
);
expect(actualOutput).toMatchInlineSnapshot(
`"
"`,
);
});
it('does not override inline styles with Tailwind styles', async () => {
const actualOutput = await render(
,
);
expect(actualOutput).toMatchInlineSnapshot(
`"
"`,
);
});
it('overrides component styles with Tailwind styles', async () => {
const actualOutput = await render(
,
);
expect(actualOutput).toMatchInlineSnapshot(
`" "`,
);
});
it('preserves mso styles', async () => {
const actualOutput = await render(
`,
}}
/>
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
"
`);
});
// See https://github.com/resend/react-email/issues/2388
it('properly does not inline custom utilities', async () => {
const actualOutput = await render(
{
addUtilities({
'.text-body': {
'@apply text-[green] dark:text-[orange]': {},
},
});
}),
],
}}
>
this is the body
,
);
expect(actualOutput).toMatchInlineSnapshot(
`" "`,
);
});
it('recognizes custom responsive screen', async () => {
const actualOutput = await render(
Test
Test
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
Test
Test
"
`);
});
it('works with calc() with + sign', async () => {
const actualOutput = await render(
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
"
`);
});
describe('with non-inlinable styles', () => {
/*
This test is because of https://github.com/resend/react-email/issues/1112
which was being caused because we required to, either have our component,
or a element directly inside the component for media queries to be applied
onto. The problem with this approach was that the check to see if an element was an instance of
the component fails after minification as we did it by the function name.
The best solution is to check for the Head element on arbitrarily deep levels of the React tree
and apply the styles there. This also fixes the issue where it would not be allowed to use
Tailwind classes on the element as the would be required directly bellow Tailwind.
*/
it('works with arbitrarily deep (in the React tree) elements', async () => {
expect(
await render(
,
).then(pretty),
).toMatchInlineSnapshot(`
"
"
`);
const MyHead = (props: Record) => {
return ;
};
expect(
await render(
,
),
).toMatchInlineSnapshot(
`"
"`,
);
});
it('handles non-inlinable styles in custom utilities', async () => {
const actualOutput = await render(
{
api.addUtilities({
'.text-body': {
'@apply text-[green] sm:text-[darkgreen]': {},
},
});
},
},
],
}}
>
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
"
`);
});
it('adds css to and keep class names', async () => {
const actualOutput = await render(
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
"
`);
});
it('throws error when used without the head and with media query class names very deeply nested', async () => {
const Component1 = (props: Record) => {
return (
{props.children}
);
};
const Component2 = (props: Record) => {
return (
{props.children}
);
};
const Component3 = (props: Record) => {
return (
{props.children}
);
};
function renderComplexEmailWithoutHead() {
return render(
,
);
}
await expect(renderComplexEmailWithoutHead).rejects.toThrow(
`${headMissingError} sm:h-10 sm:w-10.`,
);
});
it('works with relatively complex media query utilities', async () => {
const Email = () => {
return (
I am some text
);
};
expect(await render( ).then(pretty)).toMatchInlineSnapshot(`
"
I am some text
"
`);
});
it('throws a clear error when is outside and dark: classes are used', async () => {
function renderEmailWithHeadOutsideTailwind() {
return render(
this is the body
,
);
}
await expect(renderEmailWithHeadOutsideTailwind).rejects.toThrow(
`${headMissingError} dark:bg-white dark:text-gray-100.`,
);
});
it('throws an error when used without a ', async () => {
function noHead() {
return render(
{/* */}
,
).then(pretty);
}
await expect(noHead).rejects.toThrow(
`${headMissingError} sm:bg-red-500.`,
);
});
it('persists existing elements', async () => {
const actualOutput = await render(
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
"
`);
});
});
describe('with custom theme config', () => {
it('supports custom colors', async () => {
const config: TailwindConfig = {
theme: {
extend: {
colors: {
custom: '#1fb6ff',
},
},
},
};
const actualOutput = await render(
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
"
`);
});
it('supports custom camelCased colors', async () => {
const config: TailwindConfig = {
theme: {
extend: {
colors: {
customColor: '#1fb6ff',
},
},
},
};
const actualOutput = await render(
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
"
`);
});
it('supports custom fonts', async () => {
const config: TailwindConfig = {
theme: {
extend: {
fontFamily: {
sans: ['Graphik', 'sans-serif'],
serif: ['Merriweather', 'serif'],
},
},
},
};
const actualOutput = await render(
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
"
`);
});
it('supports custom spacing', async () => {
const config: TailwindConfig = {
theme: {
extend: {
spacing: {
'8xl': '96rem',
},
},
},
};
const actualOutput = await render(
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
"
`);
});
it('supports custom border radius', async () => {
const config: TailwindConfig = {
theme: {
extend: {
borderRadius: {
'4xl': '2rem',
},
},
},
};
const actualOutput = await render(
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
"
`);
});
it('supports custom text alignment', async () => {
const config: TailwindConfig = {
theme: {
extend: {
textAlign: {
justify: 'justify',
},
},
},
};
const actualOutput = await render(
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
"
`);
});
});
describe('with css configuration', () => {
it('handles empty theme string', async () => {
const actualOutput = await render(
Default utilities
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
Default utilities
"
`);
});
it('supports custom colors', async () => {
const theme = `
@theme {
--color-custom: #1fb6ff;
}
`;
const actualOutput = await render(
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
"
`);
});
it('supports custom fonts', async () => {
const theme = `
@theme {
--font-sans: "Graphik", sans-serif;
--font-serif: "Merriweather", serif;
}
`;
const actualOutput = await render(
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
"
`);
});
it('supports custom spacing', async () => {
const theme = `
@theme {
--spacing-8xl: 96rem;
}
`;
const actualOutput = await render(
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
"
`);
});
it('supports custom border radius', async () => {
const theme = `
@theme {
--border-radius-4xl: 2rem;
}
`;
const actualOutput = await render(
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
"
`);
});
it('supports custom text alignment', async () => {
const theme = `
@theme {
--text-align-justify: justify;
}
`;
const actualOutput = await render(
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
"
`);
});
it('supports both config and theme props together', async () => {
const customConfig = {
theme: {
extend: {
colors: {
primary: '#ff0000',
},
},
},
} satisfies TailwindConfig;
const customTheme = `
@theme {
--color-secondary: #00ff00;
}
`;
const actualOutput = await render(
Both config and theme
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
Both config and theme
"
`);
});
});
describe('with utilities', () => {
it('handles empty utilities string', async () => {
const actualOutput = await render(
Default utilities
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
Default utilities
"
`);
});
it('supports custom utilities', async () => {
const utilities = `
.custom-shadow {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
border-radius: 8px;
padding: 16px;
}
`;
const actualOutput = await render(
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
"
`);
});
it('supports animations', async () => {
const utilities = `
.pulse-animation {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
`;
const actualOutput = await render(
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
"
`);
});
it('supports both config and utilities props together', async () => {
const customConfig = {
theme: {
extend: {
colors: {
primary: '#ff0000',
},
},
},
} satisfies TailwindConfig;
const customUtilities = `
.card-base {
border: 1px solid #e5e7eb;
padding: 20px;
}
`;
const actualOutput = await render(
Config and utilities
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
Config and utilities
"
`);
});
it('supports config, theme, and utilities together', async () => {
const customConfig = {
theme: {
extend: {
colors: {
primary: '#ff0000',
},
},
},
} satisfies TailwindConfig;
const customTheme = `
@theme {
--color-secondary: #00ff00;
}
`;
const customUtilities = `
.special-border {
border: 2px dashed #0000ff;
}
`;
const actualOutput = await render(
All three props
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
All three props
"
`);
});
});
describe('with custom plugins config', () => {
const config = {
plugins: [
{
handler: (api) => {
api.addUtilities({
'.border-custom': {
border: '2px solid',
},
});
},
},
],
} satisfies TailwindConfig;
it('supports custom plugins', async () => {
const actualOutput = await render(
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
"
`);
});
it('supports custom plugins with responsive styles', async () => {
const actualOutput = await render(
,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"
"
`);
});
});
});