Skip to content

Commit a17374a

Browse files
KayleeWilliamsgabrielmferndependabot[bot]bukinoshita
committed
feat(react-email): added a theme switcher to the dev preview (#1749)
Signed-off-by: dependabot[bot] <[email protected]> Co-authored-by: gabriel miranda <[email protected]> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Bu Kinoshita <[email protected]>
1 parent 623826c commit a17374a

File tree

7 files changed

+186
-35
lines changed

7 files changed

+186
-35
lines changed

.changeset/dirty-needles-chew.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-email": minor
3+
---
4+
5+
Theme switcher for email template

packages/react-email/src/app/preview/[...slug]/preview.tsx

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
'use client';
22

33
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
4-
import React from 'react';
4+
import React, { useRef } from 'react';
55
import { Toaster } from 'sonner';
66
import type { EmailRenderingResult } from '../../../actions/render-email-by-path';
77
import { CodeContainer } from '../../../components/code-container';
88
import { Shell } from '../../../components/shell';
99
import { Tooltip } from '../../../components/tooltip';
1010
import { useEmailRenderingResult } from '../../../hooks/use-email-rendering-result';
1111
import { useHotreload } from '../../../hooks/use-hot-reload';
12+
import { useIframeColorScheme } from '../../../hooks/use-iframe-color-scheme';
1213
import { useRenderingMetadata } from '../../../hooks/use-rendering-metadata';
1314
import { RenderingError } from './rendering-error';
1415

@@ -29,6 +30,7 @@ const Preview = ({
2930
const pathname = usePathname();
3031
const searchParams = useSearchParams();
3132

33+
const activeTheme = searchParams.get('theme') ?? 'light';
3234
const activeView = searchParams.get('view') ?? 'desktop';
3335
const activeLang = searchParams.get('lang') ?? 'jsx';
3436

@@ -43,6 +45,9 @@ const Preview = ({
4345
serverRenderingResult,
4446
);
4547

48+
const iframeRef = useRef<HTMLIFrameElement>(null);
49+
useIframeColorScheme(iframeRef, activeTheme);
50+
4651
if (process.env.NEXT_PUBLIC_IS_BUILDING !== 'true') {
4752
// this will not change on runtime so it doesn't violate
4853
// the rules of hooks
@@ -60,28 +65,20 @@ const Preview = ({
6065
});
6166
}
6267

63-
const handleViewChange = (view: string) => {
64-
const params = new URLSearchParams(searchParams);
65-
params.set('view', view);
66-
router.push(`${pathname}?${params.toString()}`);
67-
};
68+
const hasNoErrors = typeof renderedEmailMetadata !== 'undefined';
6869

69-
const handleLangChange = (lang: string) => {
70+
const setActiveLang = (lang: string) => {
7071
const params = new URLSearchParams(searchParams);
7172
params.set('view', 'source');
7273
params.set('lang', lang);
7374
router.push(`${pathname}?${params.toString()}`);
7475
};
7576

76-
const hasNoErrors = typeof renderedEmailMetadata !== 'undefined';
77-
7877
return (
7978
<Shell
80-
activeView={hasNoErrors ? activeView : undefined}
8179
currentEmailOpenSlug={slug}
8280
markup={renderedEmailMetadata?.markup}
8381
pathSeparator={pathSeparator}
84-
setActiveView={hasNoErrors ? handleViewChange : undefined}
8582
>
8683
{/* This relative is so that when there is any error the user can still switch between emails */}
8784
<div className="relative h-full">
@@ -93,22 +90,24 @@ const Preview = ({
9390
<>
9491
{activeView === 'desktop' && (
9592
<iframe
96-
className="w-full bg-white h-[calc(100vh_-_140px)] lg:h-[calc(100vh_-_70px)]"
93+
className="h-[calc(100vh_-_140px)] w-full bg-white lg:h-[calc(100vh_-_70px)]"
94+
ref={iframeRef}
9795
srcDoc={renderedEmailMetadata.markup}
9896
title={slug}
9997
/>
10098
)}
10199

102100
{activeView === 'mobile' && (
103101
<iframe
104-
className="w-[360px] bg-white h-[calc(100vh_-_140px)] lg:h-[calc(100vh_-_70px)] mx-auto"
102+
className="mx-auto h-[calc(100vh_-_140px)] w-[360px] bg-white lg:h-[calc(100vh_-_70px)]"
103+
ref={iframeRef}
105104
srcDoc={renderedEmailMetadata.markup}
106105
title={slug}
107106
/>
108107
)}
109108

110109
{activeView === 'source' && (
111-
<div className="flex gap-6 mx-auto p-6 max-w-3xl">
110+
<div className="mx-auto flex max-w-3xl gap-6 p-6">
112111
<Tooltip.Provider>
113112
<CodeContainer
114113
activeLang={activeLang}
@@ -126,7 +125,7 @@ const Preview = ({
126125
content: renderedEmailMetadata.plainText,
127126
},
128127
]}
129-
setActiveLang={handleLangChange}
128+
setActiveLang={setActiveLang}
130129
/>
131130
</Tooltip.Provider>
132131
</div>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import * as React from 'react';
2+
import type { IconElement, IconProps } from './icon-base';
3+
import { IconBase } from './icon-base';
4+
5+
export const IconMoon = React.forwardRef<IconElement, Readonly<IconProps>>(
6+
({ ...props }, forwardedRef) => (
7+
<IconBase ref={forwardedRef} {...props}>
8+
<path
9+
fill="currentColor"
10+
d="m17.75 4.09l-2.53 1.94l.91 3.06l-2.63-1.81l-2.63 1.81l.91-3.06l-2.53-1.94L12.44 4l1.06-3l1.06 3zm3.5 6.91l-1.64 1.25l.59 1.98l-1.7-1.17l-1.7 1.17l.59-1.98L15.75 11l2.06-.05L18.5 9l.69 1.95zm-2.28 4.95c.83-.08 1.72 1.1 1.19 1.85c-.32.45-.66.87-1.08 1.27C15.17 23 8.84 23 4.94 19.07c-3.91-3.9-3.91-10.24 0-14.14c.4-.4.82-.76 1.27-1.08c.75-.53 1.93.36 1.85 1.19c-.27 2.86.69 5.83 2.89 8.02a9.96 9.96 0 0 0 8.02 2.89m-1.64 2.02a12.08 12.08 0 0 1-7.8-3.47c-2.17-2.19-3.33-5-3.49-7.82c-2.81 3.14-2.7 7.96.31 10.98c3.02 3.01 7.84 3.12 10.98.31"
11+
/>
12+
</IconBase>
13+
),
14+
);
15+
16+
IconMoon.displayName = 'IconMoon';
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import * as React from 'react';
2+
import type { IconElement, IconProps } from './icon-base';
3+
import { IconBase } from './icon-base';
4+
5+
export const IconSun = React.forwardRef<IconElement, Readonly<IconProps>>(
6+
({ ...props }, forwardedRef) => (
7+
<IconBase ref={forwardedRef} {...props}>
8+
<path
9+
fill="currentColor"
10+
d="m3.55 19.09l1.41 1.41l1.8-1.79l-1.42-1.42M12 6c-3.31 0-6 2.69-6 6s2.69 6 6 6s6-2.69 6-6c0-3.32-2.69-6-6-6m8 7h3v-2h-3m-2.76 7.71l1.8 1.79l1.41-1.41l-1.79-1.8M20.45 5l-1.41-1.4l-1.8 1.79l1.42 1.42M13 1h-2v3h2M6.76 5.39L4.96 3.6L3.55 5l1.79 1.81zM1 13h3v-2H1m12 9h-2v3h2"
11+
/>
12+
</IconBase>
13+
),
14+
);
15+
16+
IconSun.displayName = 'IconSun';

packages/react-email/src/components/shell.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,13 @@ interface ShellProps extends RootProps {
1111
markup?: string;
1212
currentEmailOpenSlug?: string;
1313
pathSeparator?: string;
14-
activeView?: string;
15-
setActiveView?: (view: string) => void;
1614
}
1715

1816
export const Shell = ({
1917
currentEmailOpenSlug,
2018
children,
2119
pathSeparator,
2220
markup,
23-
activeView,
24-
setActiveView,
2521
}: ShellProps) => {
2622
const [sidebarToggled, setSidebarToggled] = React.useState(false);
2723
const [triggerTransition, setTriggerTransition] = React.useState(false);
@@ -89,7 +85,6 @@ export const Shell = ({
8985
>
9086
{currentEmailOpenSlug && pathSeparator ? (
9187
<Topbar
92-
activeView={activeView}
9388
currentEmailOpenSlug={currentEmailOpenSlug}
9489
markup={markup}
9590
onToggleSidebar={() => {
@@ -104,11 +99,10 @@ export const Shell = ({
10499
}, 300);
105100
}}
106101
pathSeparator={pathSeparator}
107-
setActiveView={setActiveView}
108102
/>
109103
) : null}
110104

111-
<div className="h-[calc(100vh_-_70px)] overflow-auto mx-auto">
105+
<div className="h-[calc(100vh_-_70px)] overflow-auto mx-auto ">
112106
{children}
113107
</div>
114108
</main>

packages/react-email/src/components/topbar.tsx

Lines changed: 99 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,58 @@
11
'use client';
22
import * as ToggleGroup from '@radix-ui/react-toggle-group';
33
import { motion } from 'framer-motion';
4-
import type * as React from 'react';
4+
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
55
import { cn } from '../utils';
66
import { tabTransition } from '../utils/constants';
77
import { Heading } from './heading';
88
import { IconHideSidebar } from './icons/icon-hide-sidebar';
99
import { IconMonitor } from './icons/icon-monitor';
10+
import { IconMoon } from './icons/icon-moon';
1011
import { IconPhone } from './icons/icon-phone';
1112
import { IconSource } from './icons/icon-source';
13+
import { IconSun } from './icons/icon-sun';
1214
import { Send } from './send';
1315
import { Tooltip } from './tooltip';
1416

1517
interface TopbarProps {
1618
currentEmailOpenSlug: string;
1719
pathSeparator: string;
18-
activeView?: string;
1920
markup?: string;
2021
onToggleSidebar?: () => void;
21-
setActiveView?: (view: string) => void;
2222
}
2323

2424
export const Topbar: React.FC<Readonly<TopbarProps>> = ({
2525
currentEmailOpenSlug,
2626
pathSeparator,
2727
markup,
28-
activeView,
29-
setActiveView,
3028
onToggleSidebar,
3129
}) => {
30+
const router = useRouter();
31+
const pathname = usePathname();
32+
const searchParams = useSearchParams();
33+
34+
const activeTheme = searchParams.get('theme') ?? 'light';
35+
const activeView = searchParams.get('view') ?? 'desktop';
36+
37+
const setActiveView = (view: string) => {
38+
const params = new URLSearchParams(searchParams);
39+
params.set('view', view);
40+
router.push(`${pathname}?${params.toString()}`);
41+
};
42+
43+
const setTheme = (theme: string) => {
44+
const params = new URLSearchParams(searchParams);
45+
params.set('theme', theme);
46+
router.push(`${pathname}?${params.toString()}`);
47+
};
48+
3249
return (
3350
<Tooltip.Provider>
34-
<header className="flex relative items-center px-4 justify-between h-[70px] border-b border-slate-6">
51+
<header className="relative flex h-[70px] items-center justify-between border-slate-6 border-b px-4">
3552
<Tooltip>
3653
<Tooltip.Trigger asChild>
3754
<button
38-
className="hidden lg:flex rounded-lg px-2 py-2 transition ease-in-out duration-200 relative hover:bg-slate-5 text-slate-11 hover:text-slate-12"
55+
className="relative hidden rounded-lg px-2 py-2 text-slate-11 transition duration-200 ease-in-out hover:bg-slate-5 hover:text-slate-12 lg:flex"
3956
onClick={() => {
4057
if (onToggleSidebar) {
4158
onToggleSidebar();
@@ -49,18 +66,87 @@ export const Topbar: React.FC<Readonly<TopbarProps>> = ({
4966
<Tooltip.Content>Show/hide sidebar</Tooltip.Content>
5067
</Tooltip>
5168

52-
<div className="items-center overflow-hidden hidden lg:flex text-center absolute left-1/2 transform -translate-x-1/2 top-1/2 -translate-y-1/2">
69+
<div className="-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-1/2 hidden transform items-center overflow-hidden text-center lg:flex">
5370
<Heading as="h2" className="truncate" size="2" weight="medium">
5471
{currentEmailOpenSlug.split(pathSeparator).pop()}
5572
</Heading>
5673
</div>
5774

58-
<div className="flex gap-3 justify-between lg:justify-start w-full lg:w-fit">
75+
<div className="flex w-full justify-between gap-3 lg:w-fit lg:justify-start">
76+
<ToggleGroup.Root
77+
aria-label="Color Scheme"
78+
className="inline-block items-center bg-slate-2 border border-slate-6 rounded-md overflow-hidden h-[36px]"
79+
id="theme-toggle"
80+
onValueChange={(value) => {
81+
if (value) setTheme(value);
82+
}}
83+
type="single"
84+
value={activeTheme}
85+
>
86+
<ToggleGroup.Item value="light">
87+
<Tooltip>
88+
<Tooltip.Trigger asChild>
89+
<div
90+
className={cn(
91+
'relative px-3 py-2 transition duration-200 ease-in-out hover:text-slate-12',
92+
{
93+
'text-slate-11': activeTheme !== 'light',
94+
'text-slate-12': activeTheme === 'light',
95+
},
96+
)}
97+
>
98+
{activeTheme === 'light' && (
99+
<motion.span
100+
animate={{ opacity: 1 }}
101+
className="absolute top-0 right-0 bottom-0 left-0 bg-slate-4"
102+
exit={{ opacity: 0 }}
103+
initial={{ opacity: 0 }}
104+
layoutId="topbar-theme-tabs"
105+
transition={tabTransition}
106+
/>
107+
)}
108+
<IconSun />
109+
</div>
110+
</Tooltip.Trigger>
111+
<Tooltip.Content>Light</Tooltip.Content>
112+
</Tooltip>
113+
</ToggleGroup.Item>
114+
<ToggleGroup.Item value="dark">
115+
<Tooltip>
116+
<Tooltip.Trigger asChild>
117+
<div
118+
className={cn(
119+
'relative px-3 py-2 transition duration-200 ease-in-out hover:text-slate-12',
120+
{
121+
'text-slate-11': activeTheme !== 'dark',
122+
'text-slate-12': activeTheme === 'dark',
123+
},
124+
)}
125+
>
126+
{activeTheme === 'dark' && (
127+
<motion.span
128+
animate={{ opacity: 1 }}
129+
className="absolute top-0 right-0 bottom-0 left-0 bg-slate-4"
130+
exit={{ opacity: 0 }}
131+
initial={{ opacity: 0 }}
132+
layoutId="topbar-theme-tabs"
133+
transition={tabTransition}
134+
/>
135+
)}
136+
<IconMoon />
137+
</div>
138+
</Tooltip.Trigger>
139+
<Tooltip.Content>Dark</Tooltip.Content>
140+
</Tooltip>
141+
</ToggleGroup.Item>
142+
</ToggleGroup.Root>
143+
59144
<ToggleGroup.Root
60145
aria-label="View mode"
61146
className="inline-block items-center bg-slate-2 border border-slate-6 rounded-md overflow-hidden h-[36px]"
147+
id="view-toggle"
62148
onValueChange={(value) => {
63-
if (value) setActiveView?.(value);
149+
if (value) setActiveView(value);
64150
}}
65151
type="single"
66152
value={activeView}
@@ -83,7 +169,7 @@ export const Topbar: React.FC<Readonly<TopbarProps>> = ({
83169
className="absolute left-0 right-0 top-0 bottom-0 bg-slate-4"
84170
exit={{ opacity: 0 }}
85171
initial={{ opacity: 0 }}
86-
layoutId="topbar-tabs"
172+
layoutId="topbar-view-tabs"
87173
transition={tabTransition}
88174
/>
89175
)}
@@ -111,7 +197,7 @@ export const Topbar: React.FC<Readonly<TopbarProps>> = ({
111197
className="absolute left-0 right-0 top-0 bottom-0 bg-slate-4"
112198
exit={{ opacity: 0 }}
113199
initial={{ opacity: 0 }}
114-
layoutId="topbar-tabs"
200+
layoutId="topbar-view-tabs"
115201
transition={tabTransition}
116202
/>
117203
)}
@@ -139,7 +225,7 @@ export const Topbar: React.FC<Readonly<TopbarProps>> = ({
139225
className="absolute left-0 right-0 top-0 bottom-0 bg-slate-4"
140226
exit={{ opacity: 0 }}
141227
initial={{ opacity: 0 }}
142-
layoutId="topbar-tabs"
228+
layoutId="topbar-view-tabs"
143229
transition={tabTransition}
144230
/>
145231
)}

0 commit comments

Comments
 (0)