@react-email/ui 6.8.0 → 6.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +3 -3
  3. package/.next/diagnostics/route-bundle-stats.json +10 -9
  4. package/.next/fallback-build-manifest.json +3 -3
  5. package/.next/prerender-manifest.json +3 -3
  6. package/.next/server/app/_global-error.html +1 -1
  7. package/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  9. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  14. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  15. package/.next/server/app/page/server-reference-manifest.json +1 -1
  16. package/.next/server/app/page_client-reference-manifest.js +1 -1
  17. package/.next/server/app/preview/[...slug]/page/server-reference-manifest.json +8 -8
  18. package/.next/server/app/preview/[...slug]/page.js +5 -4
  19. package/.next/server/app/preview/[...slug]/page.js.nft.json +1 -1
  20. package/.next/server/app/preview/[...slug]/page_client-reference-manifest.js +1 -1
  21. package/.next/server/chunks/ssr/{0kci_next_dist_esm_build_templates_app-page_0o.jt0x.js → 0kci_next_dist_esm_build_templates_app-page_13jevt-.js} +3 -3
  22. package/.next/server/chunks/ssr/0kci_next_dist_esm_build_templates_app-page_13jevt-.js.map +1 -0
  23. package/.next/server/chunks/ssr/[root-of-the-server]__0jx5d~g._.js +2 -2
  24. package/.next/server/chunks/ssr/[root-of-the-server]__0k6754k._.js +1 -1
  25. package/.next/server/chunks/ssr/[root-of-the-server]__0k6754k._.js.map +1 -1
  26. package/.next/server/chunks/ssr/{[root-of-the-server]__0d.h7dp._.js → [root-of-the-server]__13sdo3r._.js} +2 -2
  27. package/.next/server/chunks/ssr/_03liear._.js +1 -1
  28. package/.next/server/chunks/ssr/_03liear._.js.map +1 -1
  29. package/.next/server/chunks/ssr/_06p~r0t._.js +1 -1
  30. package/.next/server/chunks/ssr/_06p~r0t._.js.map +1 -1
  31. package/.next/server/chunks/ssr/_0au~b4w._.js +1 -1
  32. package/.next/server/chunks/ssr/_0au~b4w._.js.map +1 -1
  33. package/.next/server/chunks/ssr/_0bb37hq._.js +1 -1
  34. package/.next/server/chunks/ssr/_0bb37hq._.js.map +1 -1
  35. package/.next/server/chunks/ssr/_0jzzir7._.js +1 -1
  36. package/.next/server/chunks/ssr/_0jzzir7._.js.map +1 -1
  37. package/.next/server/chunks/ssr/_0o5084b._.js +5 -5
  38. package/.next/server/chunks/ssr/_0o5084b._.js.map +1 -1
  39. package/.next/server/chunks/ssr/_0z2_2b7._.js +1 -1
  40. package/.next/server/chunks/ssr/_0z2_2b7._.js.map +1 -1
  41. package/.next/server/chunks/ssr/packages_ui_src_0.ov~_x._.js +3 -0
  42. package/.next/server/chunks/ssr/packages_ui_src_0.ov~_x._.js.map +1 -0
  43. package/.next/server/chunks/ssr/packages_ui_src_11mz0xt._.js +3 -0
  44. package/.next/server/chunks/ssr/packages_ui_src_11mz0xt._.js.map +1 -0
  45. package/.next/server/middleware-build-manifest.js +3 -3
  46. package/.next/server/pages/500.html +1 -1
  47. package/.next/server/server-reference-manifest.js +1 -1
  48. package/.next/server/server-reference-manifest.json +9 -9
  49. package/.next/static/chunks/{0xfhv_nejv2yt.js → 0.__jzgiil6q7.js} +1 -1
  50. package/.next/static/chunks/00ecsosfoo~be.css +3 -0
  51. package/.next/static/chunks/{15~kc67eqdve8.js → 0cr8lx05fcstc.js} +1 -1
  52. package/.next/static/chunks/0nhnu1wkjtfwe.js +1 -0
  53. package/.next/static/chunks/{0m-l5t15j5bnl.js → 0ntsvdyzr-1u3.js} +1 -1
  54. package/.next/static/chunks/0owottqfmsjwp.js +42 -0
  55. package/.next/trace +1 -1
  56. package/.next/trace-build +1 -1
  57. package/.next/types/routes.d.ts +3 -1
  58. package/.next/types/validator.ts +9 -0
  59. package/package.json +2 -2
  60. package/src/app/preview/[...slug]/preview.tsx +126 -104
  61. package/src/app/preview/layout.tsx +9 -0
  62. package/src/components/code.tsx +2 -2
  63. package/src/components/icons/icon-panel-right.tsx +19 -0
  64. package/src/components/json-editor.tsx +71 -0
  65. package/src/components/preview-props-editor.tsx +101 -0
  66. package/src/components/preview-props-panel.tsx +31 -0
  67. package/src/components/toolbar/copy-for-ai.tsx +3 -3
  68. package/src/components/toolbar/toolbar-button.tsx +2 -2
  69. package/src/components/toolbar.tsx +231 -65
  70. package/src/components/topbar/props-panel-toggle.tsx +31 -0
  71. package/src/contexts/props-panel.tsx +61 -0
  72. package/src/hooks/use-hot-reload.ts +11 -10
  73. package/.next/server/chunks/ssr/0kci_next_dist_esm_build_templates_app-page_0o.jt0x.js.map +0 -1
  74. package/.next/static/chunks/0l15-vshddb6t.css +0 -3
  75. package/.next/static/chunks/0o7gbl8p~22d1.js +0 -42
  76. package/src/components/toolbar/preview-props-editor.tsx +0 -102
  77. /package/.next/server/chunks/ssr/{[root-of-the-server]__0d.h7dp._.js.map → [root-of-the-server]__13sdo3r._.js.map} +0 -0
  78. /package/.next/static/{RcXkYVVpqLeGe0HK28j4o → c8jFxxcwBv319fqx4lN5X}/_buildManifest.js +0 -0
  79. /package/.next/static/{RcXkYVVpqLeGe0HK28j4o → c8jFxxcwBv319fqx4lN5X}/_clientMiddlewareManifest.js +0 -0
  80. /package/.next/static/{RcXkYVVpqLeGe0HK28j4o → c8jFxxcwBv319fqx4lN5X}/_ssgManifest.js +0 -0
@@ -16,7 +16,6 @@ type ActiveTab =
16
16
  | 'linter'
17
17
  | 'compatibility'
18
18
  | 'spam-assassin'
19
- | 'props'
20
19
  | 'resend'
21
20
  | undefined;
22
21
 
@@ -285,7 +284,7 @@ export const CopyForAI = ({
285
284
  <button
286
285
  type="button"
287
286
  className={cn(
288
- 'flex items-center gap-1 h-7 px-2.5 rounded-md text-xs font-medium self-center',
287
+ 'flex shrink-0 items-center gap-1 h-7 whitespace-nowrap px-2.5 rounded-md text-xs font-medium self-center',
289
288
  'text-slate-11',
290
289
  'hover:text-slate-12 transition-colors',
291
290
  'outline-none',
@@ -297,9 +296,10 @@ export const CopyForAI = ({
297
296
 
298
297
  <DropdownMenu.Portal>
299
298
  <DropdownMenu.Content
300
- className="min-w-[260px] rounded-xl p-1.5 shadow-2xl z-50 border border-white/10"
299
+ className="max-h-[var(--radix-dropdown-menu-content-available-height)] w-80 max-w-[calc(100vw-1rem)] overflow-y-auto rounded-xl p-1.5 shadow-2xl z-50 border border-white/10"
301
300
  style={{ backgroundColor: '#0c0c0c' }}
302
301
  sideOffset={8}
302
+ collisionPadding={8}
303
303
  align="end"
304
304
  side="top"
305
305
  >
@@ -25,7 +25,7 @@ export const ToolbarButton = ({
25
25
  type="button"
26
26
  {...props}
27
27
  className={cn(
28
- 'h-full w-fit font-regular flex text-sm text-slate-10 items-center align-middle justify-center px-1 gap-2 relative',
28
+ 'h-full w-fit shrink-0 whitespace-nowrap font-regular flex text-sm text-slate-10 items-center align-middle justify-center px-1 gap-2 relative',
29
29
  'hover:text-slate-12 transition-colors',
30
30
  active && 'data-[state=active]:text-cyan-11',
31
31
  className,
@@ -34,7 +34,7 @@ export const ToolbarButton = ({
34
34
  {children}
35
35
  {active ? (
36
36
  <motion.span
37
- className="-bottom-px absolute rounded-xs left-0 w-full bg-cyan-11 h-px"
37
+ className="bottom-0 absolute rounded-xs left-0 w-full bg-cyan-11 h-px"
38
38
  layoutId="active-toolbar-button"
39
39
  transition={{
40
40
  type: 'spring',
@@ -1,5 +1,7 @@
1
1
  'use client';
2
2
 
3
+ import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
4
+ import * as Popover from '@radix-ui/react-popover';
3
5
  import * as Tabs from '@radix-ui/react-tabs';
4
6
  import { LayoutGroup } from 'framer-motion';
5
7
  import { usePathname, useSearchParams } from 'next/navigation';
@@ -20,7 +22,6 @@ import { IconReload } from './icons/icon-reload';
20
22
  import { Compatibility, useCompatibility } from './toolbar/compatibility';
21
23
  import { CopyForAI } from './toolbar/copy-for-ai';
22
24
  import { Linter, type LintingRow, useLinter } from './toolbar/linter';
23
- import { PreviewPropsEditor } from './toolbar/preview-props-editor';
24
25
  import { ResendIntegration } from './toolbar/resend';
25
26
  import {
26
27
  SpamAssassin,
@@ -29,19 +30,20 @@ import {
29
30
  } from './toolbar/spam-assassin';
30
31
  import { ToolbarButton } from './toolbar/toolbar-button';
31
32
 
32
- export type ToolbarTabValue =
33
- | 'linter'
34
- | 'compatibility'
35
- | 'spam-assassin'
36
- | 'props'
37
- | 'resend';
33
+ const toolbarTabValues = [
34
+ 'linter',
35
+ 'compatibility',
36
+ 'spam-assassin',
37
+ 'resend',
38
+ ] as const;
39
+
40
+ export type ToolbarTabValue = (typeof toolbarTabValues)[number];
38
41
 
39
42
  export const useToolbarState = () => {
40
43
  const searchParams = useSearchParams();
41
44
 
42
- const activeTab = (searchParams.get('toolbar-panel') ?? undefined) as
43
- | ToolbarTabValue
44
- | undefined;
45
+ const param = searchParams.get('toolbar-panel');
46
+ const activeTab = toolbarTabValues.find((value) => value === param);
45
47
 
46
48
  return {
47
49
  activeTab,
@@ -149,6 +151,52 @@ const ToolbarInner = ({
149
151
  }
150
152
 
151
153
  const id = React.useId();
154
+ const [isToolbarInfoOpen, setIsToolbarInfoOpen] = React.useState(false);
155
+ const infoPointerTypeRef = React.useRef('');
156
+ const infoBlurListenerRef = React.useRef<(() => void) | null>(null);
157
+ // Only a cleanup for when the component unmounts with the popover still
158
+ // open; opening/closing itself is handled directly in `onOpenChange`.
159
+ React.useEffect(() => {
160
+ return () => {
161
+ if (infoBlurListenerRef.current) {
162
+ window.removeEventListener('blur', infoBlurListenerRef.current);
163
+ infoBlurListenerRef.current = null;
164
+ }
165
+ };
166
+ }, []);
167
+
168
+ const toolbarPanelDescription =
169
+ (activeTab === 'linter' &&
170
+ 'The Linter tab checks all the images and links for common issues like missing alt text, broken URLs, insecure HTTP methods, and more.') ||
171
+ (activeTab === 'spam-assassin' &&
172
+ 'The Spam tab will look at the content and use a robust scoring framework to determine if the email is likely to be spam. Powered by SpamAssassin.') ||
173
+ (activeTab === 'compatibility' &&
174
+ 'The Compatibility tab shows how well the HTML/CSS is supported across mail clients like Outlook, Gmail, etc. Powered by Can I Email.') ||
175
+ (activeTab === 'resend' &&
176
+ 'The Resend tab allows you to upload your React Email code using the Resend Templates API.') ||
177
+ 'Info';
178
+
179
+ const panelLabels: Record<ToolbarTabValue, string> = {
180
+ linter: 'Linter',
181
+ compatibility: 'Compatibility',
182
+ 'spam-assassin': 'Spam',
183
+ resend: 'Resend',
184
+ };
185
+ const availablePanels: { value: ToolbarTabValue; label: string }[] = [
186
+ { value: 'linter', label: panelLabels.linter },
187
+ ...(isRawHtmlEmail
188
+ ? []
189
+ : [
190
+ { value: 'compatibility' as const, label: panelLabels.compatibility },
191
+ ]),
192
+ { value: 'spam-assassin', label: panelLabels['spam-assassin'] },
193
+ { value: 'resend', label: panelLabels.resend },
194
+ ];
195
+ // The label must reflect the URL-selected panel even when that panel is not
196
+ // offered for this template (e.g. compatibility on a raw HTML email), since
197
+ // the content area still renders that panel's state.
198
+ const activePanelLabel =
199
+ (activeTab ? panelLabels[activeTab] : undefined) ?? 'Linter';
152
200
 
153
201
  return (
154
202
  <div
@@ -167,39 +215,78 @@ const ToolbarInner = ({
167
215
  asChild
168
216
  >
169
217
  <div className="flex flex-col h-full">
170
- <Tabs.List className="flex gap-4 px-4 border-b border-solid border-slate-6 h-10 w-full shrink-0">
171
- <LayoutGroup id={`toolbar-${id}`}>
172
- <Tabs.Trigger asChild value="linter">
173
- <ToolbarButton active={activeTab === 'linter'}>
174
- Linter
175
- </ToolbarButton>
176
- </Tabs.Trigger>
177
- {isRawHtmlEmail ? null : (
178
- <Tabs.Trigger asChild value="compatibility">
179
- <ToolbarButton active={activeTab === 'compatibility'}>
180
- Compatibility
218
+ <div className="flex h-10 w-full shrink-0 items-center border-b border-solid border-slate-6 px-2 sm:px-4">
219
+ <div className="flex h-full min-w-0 flex-1 items-center sm:hidden">
220
+ <DropdownMenu.Root>
221
+ <DropdownMenu.Trigger asChild>
222
+ <button
223
+ type="button"
224
+ className="group flex h-full items-center gap-1 px-1 text-slate-11 text-sm transition-colors hover:text-slate-12"
225
+ >
226
+ {activePanelLabel}
227
+ <IconArrowDown
228
+ size={20}
229
+ className="transition-transform group-data-[state=open]:rotate-180"
230
+ />
231
+ </button>
232
+ </DropdownMenu.Trigger>
233
+ <DropdownMenu.Portal>
234
+ <DropdownMenu.Content
235
+ align="start"
236
+ className="z-50 min-w-44 rounded-md border border-slate-6 bg-black p-1 font-sans"
237
+ collisionPadding={8}
238
+ side="top"
239
+ sideOffset={8}
240
+ >
241
+ {availablePanels.map((panel) => (
242
+ <DropdownMenu.Item
243
+ key={panel.value}
244
+ onSelect={() => {
245
+ setActivePanelValue(panel.value);
246
+ }}
247
+ className={cn(
248
+ 'flex cursor-pointer items-center justify-between gap-2 rounded px-3 py-2 text-slate-11 text-sm outline-none',
249
+ 'data-[highlighted]:bg-white/5 data-[highlighted]:text-slate-12',
250
+ activeTab === panel.value && 'text-cyan-11',
251
+ )}
252
+ >
253
+ {panel.label}
254
+ {activeTab === panel.value ? (
255
+ <IconCheck size={16} />
256
+ ) : null}
257
+ </DropdownMenu.Item>
258
+ ))}
259
+ </DropdownMenu.Content>
260
+ </DropdownMenu.Portal>
261
+ </DropdownMenu.Root>
262
+ </div>
263
+ <Tabs.List className="hidden h-full min-w-0 flex-1 gap-4 sm:flex">
264
+ <LayoutGroup id={`toolbar-${id}`}>
265
+ <Tabs.Trigger asChild value="linter">
266
+ <ToolbarButton active={activeTab === 'linter'}>
267
+ Linter
181
268
  </ToolbarButton>
182
269
  </Tabs.Trigger>
183
- )}
184
- <Tabs.Trigger asChild value="spam-assassin">
185
- <ToolbarButton active={activeTab === 'spam-assassin'}>
186
- Spam
187
- </ToolbarButton>
188
- </Tabs.Trigger>
189
- {isRawHtmlEmail || isBuilding ? null : (
190
- <Tabs.Trigger asChild value="props">
191
- <ToolbarButton active={activeTab === 'props'}>
192
- Props
270
+ {isRawHtmlEmail ? null : (
271
+ <Tabs.Trigger asChild value="compatibility">
272
+ <ToolbarButton active={activeTab === 'compatibility'}>
273
+ Compatibility
274
+ </ToolbarButton>
275
+ </Tabs.Trigger>
276
+ )}
277
+ <Tabs.Trigger asChild value="spam-assassin">
278
+ <ToolbarButton active={activeTab === 'spam-assassin'}>
279
+ Spam
193
280
  </ToolbarButton>
194
281
  </Tabs.Trigger>
195
- )}
196
- <Tabs.Trigger asChild value="resend">
197
- <ToolbarButton active={activeTab === 'resend'}>
198
- Resend
199
- </ToolbarButton>
200
- </Tabs.Trigger>
201
- </LayoutGroup>
202
- <div className="flex items-center gap-1 ml-auto">
282
+ <Tabs.Trigger asChild value="resend">
283
+ <ToolbarButton active={activeTab === 'resend'}>
284
+ Resend
285
+ </ToolbarButton>
286
+ </Tabs.Trigger>
287
+ </LayoutGroup>
288
+ </Tabs.List>
289
+ <div className="ml-2 flex shrink-0 items-center gap-1 sm:ml-4">
203
290
  <CopyForAI
204
291
  lintingRows={lintingRows}
205
292
  compatibilityResults={compatibilityCheckingResults}
@@ -208,27 +295,111 @@ const ToolbarInner = ({
208
295
  isRawHtmlEmail={isRawHtmlEmail}
209
296
  activeTab={activeTab}
210
297
  />
211
- <ToolbarButton
212
- delayDuration={0}
213
- tooltip={
214
- (activeTab === 'linter' &&
215
- 'The Linter tab checks all the images and links for common issues like missing alt text, broken URLs, insecure HTTP methods, and more.') ||
216
- (activeTab === 'spam-assassin' &&
217
- 'The Spam tab will look at the content and use a robust scoring framework to determine if the email is likely to be spam. Powered by SpamAssassin.') ||
218
- (activeTab === 'compatibility' &&
219
- 'The Compatibility tab shows how well the HTML/CSS is supported across mail clients like Outlook, Gmail, etc. Powered by Can I Email.') ||
220
- (activeTab === 'props' &&
221
- 'The Props tab lets you edit the props the preview renders with, to try out different content without changing the template.') ||
222
- (activeTab === 'resend' &&
223
- 'The Resend tab allows you to upload your React Email code using the Resend Templates API.') ||
224
- 'Info'
225
- }
298
+ <Popover.Root
299
+ onOpenChange={(open) => {
300
+ if (!open) {
301
+ setIsToolbarInfoOpen(false);
302
+ if (infoBlurListenerRef.current) {
303
+ window.removeEventListener(
304
+ 'blur',
305
+ infoBlurListenerRef.current,
306
+ );
307
+ infoBlurListenerRef.current = null;
308
+ }
309
+ return;
310
+ }
311
+
312
+ setIsToolbarInfoOpen(true);
313
+ if (infoBlurListenerRef.current === null) {
314
+ // Taps inside the email preview iframe do not reach
315
+ // Radix's dismiss layer, but they do blur the window.
316
+ const closeWhenPreviewGetsFocus = () => {
317
+ if (
318
+ !(document.activeElement instanceof HTMLIFrameElement)
319
+ ) {
320
+ return;
321
+ }
322
+ setIsToolbarInfoOpen(false);
323
+ if (infoBlurListenerRef.current) {
324
+ window.removeEventListener(
325
+ 'blur',
326
+ infoBlurListenerRef.current,
327
+ );
328
+ infoBlurListenerRef.current = null;
329
+ }
330
+ };
331
+ infoBlurListenerRef.current = closeWhenPreviewGetsFocus;
332
+ window.addEventListener('blur', closeWhenPreviewGetsFocus);
333
+ }
334
+ }}
335
+ open={isToolbarInfoOpen}
226
336
  >
227
- <IconInfo size={24} />
228
- </ToolbarButton>
229
- {isBuilding ||
230
- activeTab === 'resend' ||
231
- activeTab === 'props' ? null : (
337
+ <Popover.Trigger asChild>
338
+ <ToolbarButton
339
+ aria-label="About the current toolbar panel"
340
+ delayDuration={0}
341
+ tooltip={
342
+ isToolbarInfoOpen ? undefined : toolbarPanelDescription
343
+ }
344
+ onPointerDown={(event) => {
345
+ // Read the pointer type from `pointerdown` rather than
346
+ // the `click` event: Safari before 18.2 dispatches
347
+ // `click` as a plain MouseEvent without `pointerType`.
348
+ infoPointerTypeRef.current = event.pointerType;
349
+ const clearPointerType = () => {
350
+ window.removeEventListener(
351
+ 'pointerup',
352
+ clearPointerType,
353
+ true,
354
+ );
355
+ window.removeEventListener(
356
+ 'pointercancel',
357
+ clearPointerType,
358
+ true,
359
+ );
360
+ // Deferred so the `click` for this same interaction
361
+ // can still read the pointer type; this also
362
+ // self-heals if the pointer is released off the
363
+ // button and no `click` ever fires.
364
+ setTimeout(() => {
365
+ infoPointerTypeRef.current = '';
366
+ }, 0);
367
+ };
368
+ window.addEventListener('pointerup', clearPointerType, {
369
+ capture: true,
370
+ });
371
+ window.addEventListener(
372
+ 'pointercancel',
373
+ clearPointerType,
374
+ { capture: true },
375
+ );
376
+ }}
377
+ onClick={(event) => {
378
+ // Mouse users already get this info on hover, so only
379
+ // touch and keyboard interactions open the popover.
380
+ // Keyboard activation dispatches `click` without a
381
+ // preceding `pointerdown`, leaving the ref empty.
382
+ if (infoPointerTypeRef.current === 'mouse') {
383
+ event.preventDefault();
384
+ }
385
+ }}
386
+ >
387
+ <IconInfo size={24} />
388
+ </ToolbarButton>
389
+ </Popover.Trigger>
390
+ <Popover.Portal>
391
+ <Popover.Content
392
+ align="end"
393
+ className="z-50 w-60 max-w-[calc(100vw-1rem)] rounded-md border border-slate-6 bg-black px-3 py-2 font-sans text-white text-xs"
394
+ collisionPadding={8}
395
+ side="top"
396
+ sideOffset={8}
397
+ >
398
+ {toolbarPanelDescription}
399
+ </Popover.Content>
400
+ </Popover.Portal>
401
+ </Popover.Root>
402
+ {isBuilding || activeTab === 'resend' ? null : (
232
403
  <ToolbarButton
233
404
  tooltip="Reload"
234
405
  disabled={lintLoading || spamLoading || compatibilityLoading}
@@ -273,7 +444,7 @@ const ToolbarInner = ({
273
444
  />
274
445
  </ToolbarButton>
275
446
  </div>
276
- </Tabs.List>
447
+ </div>
277
448
 
278
449
  <div className="grow transition-opacity opacity-100 group-data-[toggled=false]/toolbar:opacity-0 overflow-y-auto pr-3 pl-4 pt-3">
279
450
  <Tabs.Content value="linter">
@@ -330,11 +501,6 @@ const ToolbarInner = ({
330
501
  <SpamAssassin result={spamCheckingResult} />
331
502
  )}
332
503
  </Tabs.Content>
333
- {isRawHtmlEmail || isBuilding ? null : (
334
- <Tabs.Content className="h-full" value="props">
335
- <PreviewPropsEditor />
336
- </Tabs.Content>
337
- )}
338
504
  <Tabs.Content value="resend">
339
505
  {hasSetupResendIntegration ? (
340
506
  <ResendIntegration
@@ -0,0 +1,31 @@
1
+ import * as Toggle from '@radix-ui/react-toggle';
2
+ import { cn } from '../../utils';
3
+ import { IconPanelRight } from '../icons/icon-panel-right';
4
+ import { Tooltip } from '../tooltip';
5
+
6
+ interface PropsPanelToggleProps {
7
+ open: boolean;
8
+ onChange: (open: boolean) => unknown;
9
+ }
10
+
11
+ export const PropsPanelToggle = ({ open, onChange }: PropsPanelToggleProps) => {
12
+ return (
13
+ <Tooltip>
14
+ <Tooltip.Trigger asChild>
15
+ <Toggle.Root
16
+ aria-label="Props panel"
17
+ className={cn(
18
+ 'relative flex h-9 w-9 items-center justify-center rounded-lg border border-slate-6 text-sm transition duration-200 ease-in-out',
19
+ 'text-slate-11 aria-pressed:bg-slate-4 hover:text-slate-12',
20
+ )}
21
+ onPressedChange={() => onChange(!open)}
22
+ pressed={open}
23
+ value="props"
24
+ >
25
+ <IconPanelRight size={20} />
26
+ </Toggle.Root>
27
+ </Tooltip.Trigger>
28
+ <Tooltip.Content>Show/hide the props panel</Tooltip.Content>
29
+ </Tooltip>
30
+ );
31
+ };
@@ -0,0 +1,61 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext, useState } from 'react';
4
+ import { useCachedWorkspaceState } from '../hooks/use-cached-workspace-state';
5
+
6
+ const PropsPanelContext = createContext<
7
+ | {
8
+ open: boolean;
9
+ setOpen: (open: boolean) => void;
10
+ /**
11
+ * False until the user toggles the panel, so restoring the remembered
12
+ * state on load applies instantly instead of animating.
13
+ */
14
+ animated: boolean;
15
+ }
16
+ | undefined
17
+ >(undefined);
18
+
19
+ /**
20
+ * Must stay mounted in the preview layout, which survives navigating between
21
+ * templates — otherwise the panel closes or replays its opening animation on
22
+ * every navigation.
23
+ */
24
+ export const PropsPanelProvider = ({
25
+ children,
26
+ }: {
27
+ children: React.ReactNode;
28
+ }) => {
29
+ const [cachedOpen, setCachedOpen] =
30
+ useCachedWorkspaceState<boolean>('props-panel-open');
31
+ // Writing to the workspace cache does not re-render, so toggles land in
32
+ // this state and the cached value only matters on load.
33
+ const [override, setOverride] = useState<boolean | undefined>(undefined);
34
+ const [animated, setAnimated] = useState(false);
35
+
36
+ const open = override ?? cachedOpen === true;
37
+
38
+ const setOpen = (newOpen: boolean) => {
39
+ setAnimated(true);
40
+ setOverride(newOpen);
41
+ setCachedOpen(newOpen);
42
+ };
43
+
44
+ return (
45
+ <PropsPanelContext.Provider value={{ open, setOpen, animated }}>
46
+ {children}
47
+ </PropsPanelContext.Provider>
48
+ );
49
+ };
50
+
51
+ export const usePropsPanel = () => {
52
+ const context = useContext(PropsPanelContext);
53
+
54
+ if (context === undefined) {
55
+ throw new Error(
56
+ 'Cannot call `usePropsPanel` outside of a `PropsPanelProvider`.',
57
+ );
58
+ }
59
+
60
+ return context;
61
+ };
@@ -11,21 +11,22 @@ import type { HotReloadChange } from '../utils/types/hot-reload-change';
11
11
  export const useHotreload = (
12
12
  onShouldReload: (changes: HotReloadChange[]) => void,
13
13
  ) => {
14
- const socketRef = useRef<Socket | null>(null);
14
+ const onShouldReloadRef = useRef(onShouldReload);
15
+ onShouldReloadRef.current = onShouldReload;
15
16
 
16
17
  useEffect(() => {
17
- if (!socketRef.current) {
18
- socketRef.current = io();
19
- }
20
- const socket = socketRef.current;
18
+ const socket: Socket = io();
21
19
 
22
- socket.on('reload', (changes: HotReloadChange[]) => {
20
+ const handleReload = (changes: HotReloadChange[]) => {
23
21
  console.debug('Reloading...');
24
- void onShouldReload(changes);
25
- });
22
+ void onShouldReloadRef.current(changes);
23
+ };
24
+
25
+ socket.on('reload', handleReload);
26
26
 
27
27
  return () => {
28
- socket.off();
28
+ socket.off('reload', handleReload);
29
+ socket.disconnect();
29
30
  };
30
- }, [onShouldReload]);
31
+ }, []);
31
32
  };