Skip to content

Commit 037940a

Browse files
fix(button): Padding behaviour (#909)
Co-authored-by: Bu Kinoshita <[email protected]>
1 parent 240c5a4 commit 037940a

File tree

7 files changed

+288
-37
lines changed

7 files changed

+288
-37
lines changed

docs/components/button.mdx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
---
2-
title: 'Button'
3-
sidebarTitle: 'Button'
4-
description: 'A link that is styled to look like a button.'
5-
'og:image': 'https://react.email/static/covers/button.png'
6-
icon: 'b'
2+
title: "Button"
3+
sidebarTitle: "Button"
4+
description: "A link that is styled to look like a button."
5+
"og:image": "https://react.email/static/covers/button.png"
6+
icon: "b"
77
---
88

99
<Info>
@@ -36,11 +36,14 @@ pnpm add @react-email/button -E
3636
Add the component to your email template. Include styles where needed.
3737

3838
```jsx
39-
import { Button } from '@react-email/button';
39+
import { Button } from "@react-email/button";
4040

4141
const Email = () => {
4242
return (
43-
<Button href="https://example.com" style={{ color: '#61dafb' }}>
43+
<Button
44+
href="https://example.com"
45+
style={{ color: "#61dafb", padding: "10px 20px" }}
46+
>
4447
Click me
4548
</Button>
4649
);

packages/button/src/button.spec.tsx

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,42 @@
11
import { render } from "@react-email/render";
22
import { Button } from "./index";
33

4-
describe("render", () => {
5-
it("renders the <Button> component", () => {
4+
describe("<Button> component", () => {
5+
beforeEach(() => {
6+
vi.restoreAllMocks();
7+
vi.resetModules();
8+
});
9+
10+
it("renders children correctly", () => {
11+
const testMessage = "Test message";
12+
const html = render(<Button>{testMessage}</Button>);
13+
expect(html).toContain(testMessage);
14+
});
15+
16+
it("passes style and other props correctly", () => {
17+
const style = { backgroundColor: "red" };
18+
const html = render(
19+
<Button data-testid="button-test" style={style}>
20+
Test
21+
</Button>,
22+
);
23+
expect(html).toContain("background-color:red");
24+
expect(html).toContain('data-testid="button-test"');
25+
});
26+
27+
it("renders correctly with padding values from style prop", () => {
628
const actualOutput = render(
7-
<Button href="https://example.com" pX={20} pY={12} />,
29+
<Button href="https://example.com" style={{ padding: "12px 20px" }} />,
830
);
931
expect(actualOutput).toMatchInlineSnapshot(
10-
'"<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Transitional//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\\"><a href=\\"https://example.com\\" data-id=\\"react-email-button\\" style=\\"line-height:100%;text-decoration:none;display:inline-block;max-width:100%;padding:12px 20px\\" target=\\"_blank\\"><span><!--[if mso]><i style=\\"letter-spacing: 20px;mso-font-width:-100%;mso-text-raise:18\\" hidden>&nbsp;</i><![endif]--></span><span style=\\"max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px\\"></span><span><!--[if mso]><i style=\\"letter-spacing: 20px;mso-font-width:-100%\\" hidden>&nbsp;</i><![endif]--></span></a>"',
32+
'"<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Transitional//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\\"><a href=\\"https://example.com\\" style=\\"padding:12px 20px 12px 20px;line-height:100%;text-decoration:none;display:inline-block;max-width:100%\\" target=\\"_blank\\"><span><!--[if mso]><i style=\\"letter-spacing: 20px;mso-font-width:-100%;mso-text-raise:18\\" hidden>&nbsp;</i><![endif]--></span><span style=\\"max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px\\"></span><span><!--[if mso]><i style=\\"letter-spacing: 20px;mso-font-width:-100%\\" hidden>&nbsp;</i><![endif]--></span></a>"',
1133
);
1234
});
1335

1436
it("renders the <Button> component with no padding value", () => {
1537
const actualOutput = render(<Button href="https://example.com" />);
1638
expect(actualOutput).toMatchInlineSnapshot(
17-
'"<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Transitional//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\\"><a href=\\"https://example.com\\" data-id=\\"react-email-button\\" style=\\"line-height:100%;text-decoration:none;display:inline-block;max-width:100%;padding:0px 0px\\" target=\\"_blank\\"><span><!--[if mso]><i style=\\"letter-spacing: 0px;mso-font-width:-100%;mso-text-raise:0\\" hidden>&nbsp;</i><![endif]--></span><span style=\\"max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:0\\"></span><span><!--[if mso]><i style=\\"letter-spacing: 0px;mso-font-width:-100%\\" hidden>&nbsp;</i><![endif]--></span></a>"',
39+
'"<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Transitional//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\\"><a href=\\"https://example.com\\" style=\\"line-height:100%;text-decoration:none;display:inline-block;max-width:100%;padding:0px 0px 0px 0px\\" target=\\"_blank\\"><span><!--[if mso]><i style=\\"letter-spacing: 0px;mso-font-width:-100%;mso-text-raise:0\\" hidden>&nbsp;</i><![endif]--></span><span style=\\"max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:0\\"></span><span><!--[if mso]><i style=\\"letter-spacing: 0px;mso-font-width:-100%\\" hidden>&nbsp;</i><![endif]--></span></a>"',
1840
);
1941
});
2042
});

packages/button/src/button.tsx

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,72 @@
11
import * as React from "react";
2-
import { pxToPt } from "./utils";
2+
import { parsePadding, pxToPt } from "./utils";
33

4-
type RootProps = React.ComponentPropsWithoutRef<"a">;
5-
6-
export interface ButtonProps extends RootProps {
7-
pX?: number;
8-
pY?: number;
9-
}
4+
type ButtonProps = React.ComponentPropsWithoutRef<"a">;
105

116
export const Button: React.FC<Readonly<ButtonProps>> = ({
127
children,
138
style,
14-
pX = 0,
15-
pY = 0,
169
target = "_blank",
1710
...props
1811
}) => {
19-
const y = (pY || 0) * 2;
20-
const textRaise = pxToPt(y.toString());
12+
const { pt, pr, pb, pl } = parsePadding({
13+
padding: style?.padding,
14+
paddingLeft: style?.paddingLeft,
15+
paddingRight: style?.paddingRight,
16+
paddingTop: style?.paddingTop,
17+
paddingBottom: style?.paddingBottom,
18+
});
19+
20+
const y = pt + pb;
21+
const textRaise = pxToPt(y);
2122

2223
return (
2324
<a
2425
{...props}
25-
data-id="react-email-button"
26-
style={buttonStyle({ ...style, pX, pY })}
26+
style={buttonStyle({ ...style, pt, pr, pb, pl })}
2727
target={target}
2828
>
2929
<span
3030
dangerouslySetInnerHTML={{
31-
__html: `<!--[if mso]><i style="letter-spacing: ${pX}px;mso-font-width:-100%;mso-text-raise:${textRaise}" hidden>&nbsp;</i><![endif]-->`,
31+
__html: `<!--[if mso]><i style="letter-spacing: ${pl}px;mso-font-width:-100%;mso-text-raise:${textRaise}" hidden>&nbsp;</i><![endif]-->`,
3232
}}
3333
/>
34-
<span style={buttonTextStyle(pY)}>{children}</span>
34+
<span style={buttonTextStyle(pb)}>{children}</span>
3535
<span
3636
dangerouslySetInnerHTML={{
37-
__html: `<!--[if mso]><i style="letter-spacing: ${pX}px;mso-font-width:-100%" hidden>&nbsp;</i><![endif]-->`,
37+
__html: `<!--[if mso]><i style="letter-spacing: ${pr}px;mso-font-width:-100%" hidden>&nbsp;</i><![endif]-->`,
3838
}}
3939
/>
4040
</a>
4141
);
4242
};
4343

4444
const buttonStyle = (
45-
style?: React.CSSProperties & { pY: number; pX: number },
45+
style?: React.CSSProperties & {
46+
pt: number;
47+
pr: number;
48+
pb: number;
49+
pl: number;
50+
},
4651
) => {
47-
const { pY, pX, ...rest } = style || {};
52+
const { pt, pr, pb, pl, ...rest } = style || {};
4853

4954
return {
5055
...rest,
5156
lineHeight: "100%",
5257
textDecoration: "none",
5358
display: "inline-block",
5459
maxWidth: "100%",
55-
padding: `${pY}px ${pX}px`,
60+
padding: `${pt}px ${pr}px ${pb}px ${pl}px`,
5661
};
5762
};
5863

59-
const buttonTextStyle = (pY?: number) => {
60-
const paddingY = pY || 0;
61-
64+
const buttonTextStyle = (pb?: number) => {
6265
return {
6366
maxWidth: "100%",
6467
display: "inline-block",
6568
lineHeight: "120%",
6669
msoPaddingAlt: "0px",
67-
msoTextRaise: pxToPt(paddingY.toString()),
70+
msoTextRaise: pxToPt(pb || 0),
6871
};
6972
};

packages/button/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from "./px-to-pt";
2+
export * from "./parse-padding";
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
type PaddingType = string | number | undefined;
2+
3+
interface PaddingProperties {
4+
padding: PaddingType;
5+
paddingTop?: PaddingType;
6+
paddingRight?: PaddingType;
7+
paddingBottom?: PaddingType;
8+
paddingLeft?: PaddingType;
9+
}
10+
11+
/**
12+
* converts padding value to `px` equivalent.
13+
* @example "1em" =\> 16
14+
*/
15+
export function convertToPx(value: PaddingType) {
16+
let px = 0;
17+
18+
if (!value) {
19+
return px;
20+
}
21+
22+
if (typeof value === "number") {
23+
return value;
24+
}
25+
26+
const matches = value.match(/^([\d.]+)(px|em|rem|%)$/);
27+
28+
if (matches && matches.length === 3) {
29+
const numValue = parseFloat(matches[1]);
30+
const unit = matches[2];
31+
32+
switch (unit) {
33+
case "px":
34+
return numValue;
35+
case "em":
36+
case "rem":
37+
px = numValue * 16;
38+
return px;
39+
case "%":
40+
px = (numValue / 100) * 600;
41+
return px;
42+
default:
43+
return numValue;
44+
}
45+
} else {
46+
return 0;
47+
}
48+
}
49+
50+
/**
51+
* Parses all the values out of a padding string to get the value for all padding props in `px`
52+
* @example e.g. "10px" =\> pt: 10, pr: 10, pb: 10, pl: 10
53+
*/
54+
export function parsePadding({
55+
padding = "",
56+
paddingTop,
57+
paddingRight,
58+
paddingBottom,
59+
paddingLeft,
60+
}: PaddingProperties) {
61+
let pt = 0;
62+
let pr = 0;
63+
let pb = 0;
64+
let pl = 0;
65+
66+
if (typeof padding === "number") {
67+
pt = padding;
68+
pr = padding;
69+
pb = padding;
70+
pl = padding;
71+
} else {
72+
const values = padding.split(/\s+/);
73+
74+
switch (values.length) {
75+
case 1:
76+
pt = convertToPx(values[0]);
77+
pr = convertToPx(values[0]);
78+
pb = convertToPx(values[0]);
79+
pl = convertToPx(values[0]);
80+
break;
81+
case 2:
82+
pt = convertToPx(values[0]);
83+
pb = convertToPx(values[0]);
84+
pr = convertToPx(values[1]);
85+
pl = convertToPx(values[1]);
86+
break;
87+
case 3:
88+
pt = convertToPx(values[0]);
89+
pr = convertToPx(values[1]);
90+
pl = convertToPx(values[1]);
91+
pb = convertToPx(values[2]);
92+
break;
93+
case 4:
94+
pt = convertToPx(values[0]);
95+
pr = convertToPx(values[1]);
96+
pb = convertToPx(values[2]);
97+
pl = convertToPx(values[3]);
98+
break;
99+
default:
100+
break;
101+
}
102+
}
103+
104+
return {
105+
pt: paddingTop ? convertToPx(paddingTop) : pt,
106+
pr: paddingRight ? convertToPx(paddingRight) : pr,
107+
pb: paddingBottom ? convertToPx(paddingBottom) : pb,
108+
pl: paddingLeft ? convertToPx(paddingLeft) : pl,
109+
};
110+
}

packages/button/src/utils/px-to-pt.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
export const pxToPt = (px: string): number | null =>
2-
isNaN(Number(px)) ? null : (parseInt(px, 10) * 3) / 4;
1+
export const pxToPt = (px: number): number | null =>
2+
typeof px === "number" && !isNaN(Number(px)) ? (px * 3) / 4 : null;

0 commit comments

Comments
 (0)