Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
5 changes: 5 additions & 0 deletions .changeset/preview-props-controls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-email": minor
---

Add a controls tab to the preview props panel. Templates can declare per-prop controls through a static `PreviewControls` property (text, bounded number, boolean, select, and raw JSON), mirroring how `PreviewProps` is declared; props without a declaration get a control inferred from their value. Edits merge into a single debounced props override, and the JSON editor remains available as an escape hatch.
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-popover": "catalog:",
"@radix-ui/react-slot": "catalog:",
"@radix-ui/react-switch": "1.3.5",
"@radix-ui/react-tabs": "catalog:",
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-toggle-group": "1.1.11",
Expand Down
24 changes: 24 additions & 0 deletions packages/ui/src/actions/render-email-by-path.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ import { convertStackWithSourceMap } from '../utils/convert-stack-with-sourcemap
import { createJsxRuntime } from '../utils/create-jsx-runtime';
import { getEmailComponent } from '../utils/get-email-component';
import { isPathWithinEmailsDirectory } from '../utils/is-path-within-emails-directory';
import {
type DeclaredPreviewControls,
validatePreviewControlsDeclaration,
} from '../utils/preview-controls/declared-preview-controls';
import { registerSpinnerAutostopping } from '../utils/register-spinner-autostopping';
import { isErr } from '../utils/result';
import {
createSpinner,
type Spinner,
Expand All @@ -30,6 +35,11 @@ export interface RenderedEmailMetadata {
* given, otherwise the template's own `PreviewProps`.
*/
previewProps: Record<string, unknown>;
/**
* The template's validated `PreviewControls` declaration, when it exports
* one. Props without a declared control get one inferred from their value.
*/
previewControls?: DeclaredPreviewControls;
prettyMarkup: string;
markup: string;
/**
Expand Down Expand Up @@ -238,6 +248,19 @@ export const renderEmailByPath = async (
} = componentResult;

const previewProps = previewPropsOverride ?? Email.PreviewProps ?? {};

const controlsResult = validatePreviewControlsDeclaration(
Email.PreviewControls,
);
let previewControls: DeclaredPreviewControls | undefined;
if (isErr(controlsResult)) {
console.warn(
`Ignoring the invalid \`PreviewControls\` of ${emailFilename}; its controls will be inferred from \`PreviewProps\` instead.\n${controlsResult.error}`,
);
} else {
previewControls = controlsResult.value;
}

const EmailComponent = Email as React.FunctionComponent;
try {
const timeBeforeEmailRendered = performance.now();
Expand Down Expand Up @@ -277,6 +300,7 @@ export const renderEmailByPath = async (

const renderingResult: RenderedEmailMetadata = {
previewProps: toJsonSafeProps(previewProps),
previewControls,
prettyMarkup,
// This ensures that no null byte character ends up in the rendered
// markup making users suspect of any issues. These null byte characters
Expand Down
49 changes: 49 additions & 0 deletions packages/ui/src/components/field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use client';

import type * as React from 'react';
import { cn } from '../utils';
import { IconArrowDown } from './icons/icon-arrow-down';

// Shared form-field primitives. They only own the field look; spacing
// between fields is left to the caller.

const fieldClasses =
'w-full appearance-none rounded-lg border border-slate-6 bg-slate-3 px-2 py-1 text-sm text-slate-12 placeholder-slate-10 outline-hidden transition duration-300 ease-in-out focus:ring-1 focus:ring-slate-10 disabled:opacity-60';

export const FieldLabel = ({
className,
htmlFor,
children,
...props
}: React.ComponentProps<'label'> & { htmlFor: string }) => (
<label
className={cn('block text-xs uppercase text-slate-10', className)}
htmlFor={htmlFor}
{...props}
>
{children}
</label>
);

export const TextInput = ({
className,
...props
}: React.ComponentProps<'input'>) => (
<input className={cn(fieldClasses, className)} {...props} />
Comment thread
gazjones00 marked this conversation as resolved.
Outdated
);

export const SelectInput = ({
className,
children,
...props
}: React.ComponentProps<'select'>) => (
<div className="relative">
<select className={cn(fieldClasses, 'pr-7', className)} {...props}>
{children}
</select>
<IconArrowDown
className="pointer-events-none absolute top-1/2 right-1 -translate-y-1/2 text-slate-10"
size={16}
/>
</div>
);
19 changes: 14 additions & 5 deletions packages/ui/src/components/json-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
'use client';

import { Highlight } from 'prism-react-renderer';
import type * as React from 'react';
import { cn } from '../utils';
import { codeTheme } from './code';

interface JsonEditorProps {
// The textarea must always end up with an accessible name: either its own
// `aria-label` or an `id` a <label htmlFor> points at.
type JsonEditorLabelling =
| { id: string; 'aria-label'?: string }
| { id?: string; 'aria-label': string };

type JsonEditorProps = JsonEditorLabelling & {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
className?: string;
'aria-label': string;
textareaRef?: React.Ref<HTMLTextAreaElement>;
'aria-invalid'?: boolean;
}
};

// Text metrics must match between the highlighted <pre> and the transparent
// <textarea> on top of it, or the caret drifts from the characters.
Expand All @@ -28,7 +35,8 @@ export const JsonEditor = ({
onChange,
disabled = false,
className,
...ariaProps
textareaRef,
...textareaProps
}: JsonEditorProps) => {
return (
<div
Expand All @@ -54,7 +62,7 @@ export const JsonEditor = ({
)}
</Highlight>
<textarea
{...ariaProps}
{...textareaProps}
className={cn(
sharedTextClasses,
'absolute inset-0 h-full w-full resize-none overflow-hidden bg-transparent text-transparent caret-slate-12 outline-none',
Expand All @@ -63,6 +71,7 @@ export const JsonEditor = ({
onChange={(event) => {
onChange(event.currentTarget.value);
}}
ref={textareaRef}
spellCheck={false}
value={value}
/>
Expand Down
Loading