Skip to content

Commit 511fbc5

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 dc1cb94 commit 511fbc5

File tree

6 files changed

+176
-2
lines changed

6 files changed

+176
-2
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: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

33
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
4-
import { use, useState } from 'react';
4+
import { use, useState, useRef } from 'react';
55
import { flushSync } from 'react-dom';
66
import { Toaster } from 'sonner';
77
import { useDebouncedCallback } from 'use-debounce';
@@ -19,7 +19,9 @@ import { ViewSizeControls } from '../../../components/topbar/view-size-controls'
1919
import { PreviewContext } from '../../../contexts/preview';
2020
import { useClampedState } from '../../../hooks/use-clamped-state';
2121
import { cn } from '../../../utils';
22+
import { useIframeColorScheme } from '../../../hooks/use-iframe-color-scheme';
2223
import { RenderingError } from './rendering-error';
24+
import { ThemeToggleGroup } from '../../../components/topbar/theme-toggle-group';
2325

2426
interface PreviewProps extends React.ComponentProps<'div'> {
2527
emailTitle: string;
@@ -32,9 +34,17 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => {
3234
const pathname = usePathname();
3335
const searchParams = useSearchParams();
3436

35-
const activeView = searchParams.get('view') ?? 'preview';
37+
const activeTheme: 'dark' | 'light' =
38+
searchParams.get('theme') === 'dark' ? 'dark' : 'light';
39+
const activeView = searchParams.get('view') ?? 'desktop';
3640
const activeLang = searchParams.get('lang') ?? 'jsx';
3741

42+
const handleThemeChange = (theme: 'dark' | 'light') => {
43+
const params = new URLSearchParams(searchParams);
44+
params.set('theme', theme);
45+
router.push(`${pathname}?${params.toString()}`);
46+
};
47+
3848
const handleViewChange = (view: string) => {
3949
const params = new URLSearchParams(searchParams);
4050
params.set('view', view);
@@ -51,6 +61,9 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => {
5161
);
5262
};
5363

64+
const iframeRef = useRef<HTMLIFrameElement>(null);
65+
useIframeColorScheme(iframeRef, activeTheme);
66+
5467
const hasRenderingMetadata = typeof renderedEmailMetadata !== 'undefined';
5568
const hasErrors = 'error' in renderingResult;
5669

@@ -99,6 +112,10 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => {
99112
viewHeight={height}
100113
viewWidth={width}
101114
/>
115+
<ThemeToggleGroup
116+
active={activeTheme}
117+
onChange={(theme) => handleThemeChange(theme)}
118+
/>
102119
<ActiveViewToggleGroup
103120
activeView={activeView}
104121
setActiveView={handleViewChange}
@@ -164,6 +181,7 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => {
164181
<iframe
165182
className="solid max-h-full rounded-lg bg-white"
166183
ref={(iframe) => {
184+
iframeRef.current = iframe;
167185
if (iframe) {
168186
return makeIframeDocumentBubbleEvents(iframe);
169187
}
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';
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import * as ToggleGroup from '@radix-ui/react-toggle-group';
2+
import { Tooltip } from '../tooltip';
3+
import { cn } from '../../utils';
4+
import { motion } from 'framer-motion';
5+
import { IconMoon } from '../icons/icon-moon';
6+
import { tabTransition } from '../../utils/constants';
7+
import { IconSun } from '../icons/icon-sun';
8+
9+
interface ThemeToggleGroupProps {
10+
active: 'light' | 'dark';
11+
onChange: (theme: 'light' | 'dark') => unknown;
12+
}
13+
14+
export const ThemeToggleGroup = ({ active, onChange }: ThemeToggleGroupProps) => {
15+
return (
16+
<ToggleGroup.Root
17+
aria-label="Color Scheme"
18+
className="inline-block items-center bg-slate-2 border border-slate-6 rounded-md overflow-hidden h-[36px]"
19+
id="theme-toggle"
20+
onValueChange={(value) => {
21+
if (value) onChange(value as 'light' | 'dark');
22+
}}
23+
type="single"
24+
value={active}
25+
>
26+
<ToggleGroup.Item value="light">
27+
<Tooltip>
28+
<Tooltip.Trigger asChild>
29+
<div
30+
className={cn(
31+
'relative px-3 py-2 transition duration-200 ease-in-out hover:text-slate-12',
32+
{
33+
'text-slate-11': active !== 'light',
34+
'text-slate-12': active === 'light',
35+
},
36+
)}
37+
>
38+
{active === 'light' && (
39+
<motion.span
40+
animate={{ opacity: 1 }}
41+
className="absolute top-0 right-0 bottom-0 left-0 bg-slate-4"
42+
exit={{ opacity: 0 }}
43+
initial={{ opacity: 0 }}
44+
layoutId="topbar-theme-tabs"
45+
transition={tabTransition}
46+
/>
47+
)}
48+
<IconSun />
49+
</div>
50+
</Tooltip.Trigger>
51+
<Tooltip.Content>Light</Tooltip.Content>
52+
</Tooltip>
53+
</ToggleGroup.Item>
54+
<ToggleGroup.Item value="dark">
55+
<Tooltip>
56+
<Tooltip.Trigger asChild>
57+
<div
58+
className={cn(
59+
'relative px-3 py-2 transition duration-200 ease-in-out hover:text-slate-12',
60+
{
61+
'text-slate-11': active !== 'dark',
62+
'text-slate-12': active === 'dark',
63+
},
64+
)}
65+
>
66+
{active === 'dark' && (
67+
<motion.span
68+
animate={{ opacity: 1 }}
69+
className="absolute top-0 right-0 bottom-0 left-0 bg-slate-4"
70+
exit={{ opacity: 0 }}
71+
initial={{ opacity: 0 }}
72+
layoutId="topbar-theme-tabs"
73+
transition={tabTransition}
74+
/>
75+
)}
76+
<IconMoon />
77+
</div>
78+
</Tooltip.Trigger>
79+
<Tooltip.Content>Dark</Tooltip.Content>
80+
</Tooltip>
81+
</ToggleGroup.Item>
82+
</ToggleGroup.Root>
83+
);
84+
};
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import * as React from 'react';
2+
3+
export function useIframeColorScheme(
4+
iframeRef: React.RefObject<HTMLIFrameElement | null>,
5+
theme: string,
6+
) {
7+
React.useEffect(() => {
8+
const iframe = iframeRef.current;
9+
10+
if (!iframe) return;
11+
12+
// Set on iframe element itself
13+
iframe.style.colorScheme = theme;
14+
15+
// Set on iframe's document if available
16+
if (iframe.contentDocument) {
17+
iframe.contentDocument.documentElement.style.colorScheme = theme;
18+
iframe.contentDocument.body.style.colorScheme = theme;
19+
}
20+
21+
// Ensure styles are applied after it loads
22+
const handleLoad = () => {
23+
if (iframe.contentDocument) {
24+
iframe.contentDocument.documentElement.style.colorScheme = theme;
25+
iframe.contentDocument.body.style.colorScheme = theme;
26+
}
27+
};
28+
29+
iframe.addEventListener('load', handleLoad);
30+
31+
return () => {
32+
iframe.removeEventListener('load', handleLoad);
33+
};
34+
}, [theme, iframeRef]);
35+
}

0 commit comments

Comments
 (0)