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
315 changes: 315 additions & 0 deletions packages/tailwind/src/tailwind.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,321 @@ describe('Tailwind component', () => {
});
});

describe('with theme', () => {
it('handles empty theme string', async () => {
const actualOutput = await render(
<Tailwind theme="">
<div className="bg-red-500 text-white">Default utilities</div>
</Tailwind>,
).then(pretty);

expect(actualOutput).toMatchInlineSnapshot(`
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!--$-->
<div style="background-color:rgb(251,44,54);color:rgb(255,255,255)">
Default utilities
</div>
<!--/$-->
"
`);
});

it('supports custom colors', async () => {
const theme = `
@theme {
--color-custom: #1fb6ff;
}
`;

const actualOutput = await render(
<Tailwind theme={theme}>
<div className="bg-custom text-custom" />
</Tailwind>,
).then(pretty);

expect(actualOutput).toMatchInlineSnapshot(`
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!--$-->
<div style="background-color:rgb(31,182,255);color:rgb(31,182,255)"></div>
<!--/$-->
"
`);
});

it('supports custom fonts', async () => {
const theme = `
@theme {
--font-sans: "Graphik", sans-serif;
--font-serif: "Merriweather", serif;
}
`;

const actualOutput = await render(
<Tailwind theme={theme}>
<div className="font-sans" />
<div className="font-serif" />
</Tailwind>,
).then(pretty);

expect(actualOutput).toMatchInlineSnapshot(`
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!--$-->
<div style='font-family:"Graphik",sans-serif'></div>
<div style='font-family:"Merriweather",serif'></div>
<!--/$-->
"
`);
});

it('supports custom spacing', async () => {
const theme = `
@theme {
--spacing-8xl: 96rem;
}
`;

const actualOutput = await render(
<Tailwind theme={theme}>
<div className="m-8xl" />
</Tailwind>,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!--$-->
<div style="margin:96rem"></div>
<!--/$-->
"
`);
});

it('supports custom border radius', async () => {
const theme = `
@theme {
--border-radius-4xl: 2rem;
}
`;

const actualOutput = await render(
<Tailwind theme={theme}>
<div className="rounded-4xl" />
</Tailwind>,
).then(pretty);
expect(actualOutput).toMatchInlineSnapshot(`
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!--$-->
<div style="border-radius:2rem"></div>
<!--/$-->
"
`);
});

it('supports custom text alignment', async () => {
const theme = `
@theme {
--text-align-justify: justify;
}
`;

const actualOutput = await render(
<Tailwind theme={theme}>
<div className="text-justify" />
</Tailwind>,
).then(pretty);

expect(actualOutput).toMatchInlineSnapshot(`
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!--$-->
<div style="text-align:justify"></div>
<!--/$-->
"
`);
});

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(
<Tailwind config={customConfig} theme={customTheme}>
<div className="bg-primary text-secondary">Both config and theme</div>
</Tailwind>,
).then(pretty);

expect(actualOutput).toMatchInlineSnapshot(`
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!--$-->
<div style="background-color:rgb(255,0,0);color:rgb(0,255,0)">
Both config and theme
</div>
<!--/$-->
"
`);
});
});

describe('with utilities', () => {
it('handles empty utilities string', async () => {
const actualOutput = await render(
<Tailwind utility="">
<div className="bg-red-500 text-white">Default utilities</div>
</Tailwind>,
).then(pretty);

expect(actualOutput).toMatchInlineSnapshot(`
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!--$-->
<div style="background-color:rgb(251,44,54);color:rgb(255,255,255)">
Default utilities
</div>
<!--/$-->
"
`);
});

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(
<Tailwind utility={utilities}>
<div className="custom-shadow" />
</Tailwind>,
).then(pretty);

expect(actualOutput).toMatchInlineSnapshot(`
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!--$-->
<div
style="box-shadow:0 4px 6px rgba(0,0,0,0.1);border-radius:8px;padding:16px"></div>
<!--/$-->
"
`);
});

it('supports animations', async () => {
const utilities = `
.pulse-animation {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
`;

const actualOutput = await render(
<Tailwind utility={utilities}>
<div className="pulse-animation" />
</Tailwind>,
).then(pretty);

expect(actualOutput).toMatchInlineSnapshot(`
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!--$-->
<div style="animation:pulse 2s cubic-bezier(0.4,0,0.6,1) infinite"></div>
<!--/$-->
"
`);
});

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(
<Tailwind config={customConfig} utility={customUtilities}>
<div className="bg-primary card-base">Config and utilities</div>
</Tailwind>,
).then(pretty);

expect(actualOutput).toMatchInlineSnapshot(`
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!--$-->
<div
style="background-color:rgb(255,0,0);border:1px solid rgb(229,231,235);padding:20px">
Config and utilities
</div>
<!--/$-->
"
`);
});

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(
<Tailwind
config={customConfig}
theme={customTheme}
utility={customUtilities}
>
<div className="bg-primary text-secondary special-border">
All three props
</div>
</Tailwind>,
).then(pretty);

expect(actualOutput).toMatchInlineSnapshot(`
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!--$-->
<div
style="background-color:rgb(255,0,0);color:rgb(0,255,0);border:2px dashed rgb(0,0,255)">
All three props
</div>
<!--/$-->
"
`);
});
});

describe('with custom plugins config', () => {
const config = {
plugins: [
Expand Down
47 changes: 36 additions & 11 deletions packages/tailwind/src/tailwind.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,19 @@ import { mapReactTree } from './utils/react/map-react-tree';
import { cloneElementWithInlinedStyles } from './utils/tailwindcss/clone-element-with-inlined-styles';
import { setupTailwind } from './utils/tailwindcss/setup-tailwind';

export type CSSString = string;
export type TailwindConfig = Omit<Config, 'content'>;

export interface TailwindProps {
children: React.ReactNode;
config?: TailwindConfig;
}

export interface EmailElementProps {
children?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}

/**
* The pixel based preset is the preset recommended to use for emails.
* It is used to style the email with a pixel based system.
*/
export const pixelBasedPreset: TailwindConfig = {
theme: {
extend: {
Expand Down Expand Up @@ -82,15 +82,40 @@ export const pixelBasedPreset: TailwindConfig = {
},
};

export function Tailwind({ children, config }: TailwindProps) {
/**
* Stringifies the data to a JSON string that is safe to use.
* It will replace functions with their string representation.
*/
function JSONStringify(data: object) {
return JSON.stringify(data, (_key, value) =>
typeof value === 'function' ? value.toString() : value,
);
}

export interface TailwindProps {
children: React.ReactNode;
/** Tailwind config object. Used in Tailwind v3. */
config?: TailwindConfig;
/** Tailwind theme in CSS. Used in Tailwind v4. */
theme?: CSSString;
/** Tailwind utilities in CSS. Used in Tailwind v4. */
utility?: CSSString;
}

export function Tailwind({ children, config, theme, utility }: TailwindProps) {
const twConfigData = {
config,
cssConfigs: {
theme,
utility,
},
};
const tailwindSetup = useSuspensedPromise(
() => setupTailwind(config ?? {}),
JSON.stringify(config, (_key, value) =>
typeof value === 'function' ? value.toString() : value,
),
() => setupTailwind(twConfigData),
JSONStringify(twConfigData),
);
let classesUsed: string[] = [];

let classesUsed: string[] = [];
let mappedChildren: React.ReactNode = mapReactTree(children, (node) => {
if (React.isValidElement<EmailElementProps>(node)) {
if (node.props.className) {
Expand Down
Loading
Loading