@unlayer/react-elements 0.1.8 → 0.1.10

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.
package/README.md CHANGED
@@ -51,14 +51,17 @@ function WelcomeEmail() {
51
51
 
52
52
  These props have non-obvious shapes that **must** be followed exactly:
53
53
 
54
- - **fontFamily**: Must be `{ label: string, value: string }`, NOT a plain string.
54
+ - **fontFamily**: Accepts a plain family-name string (`fontFamily="Georgia"`) or, for a full stack, `{ label, value }` (recommended).
55
55
  ```tsx
56
56
  fontFamily={{ label: "Arial", value: "arial, sans-serif" }}
57
57
  ```
58
- - **fontWeight**: Must be a number (`400`, `700`), NOT a string (`"400"`).
58
+ - **fontWeight**: Accepts a number (`700`), a numeric string (`"700"`), or a CSS keyword (`"bold"`).
59
+ - **fontSize / padding**: Accept a CSS string (`"28px"`, `"20px 40px"`) or a bare number (treated as px: `fontSize={28}` → `28px`).
60
+ - **lineHeight**: Accepts a CSS string (`"1.4"`, `"140%"`) or a bare number (kept **unitless**: `lineHeight={1.4}` → `"1.4"`).
59
61
  - **Wrapper component**: Use `<Email>`, `<Page>`, or `<Document>` as root — they set the rendering mode automatically.
60
62
  - **href**: Can be a plain string URL (auto-wrapped) or `{ name: "web", values: { href, target } }`.
61
- - **Image src**: Can be a plain string URL (auto-wrapped) or `{ url, width?, autoWidth?, maxWidth? }`.
63
+ - **Image sizing**: `src` is a plain URL string or `{ url, width?, height?, ... }`, where `width`/`height` are the image's **natural** size. By default an image is **responsive** — it fills its container, capped at its natural size. For a **fixed** display size, use a **percent**: `width="50%"` or `maxWidth="50%"`. A px/number `width` is treated as the natural size, so `width="300px"` shows the image at up to 300px (responsive).
64
+ - **Heading level**: `headingType` (or its alias `level`) accepts `h1`–`h6`.
62
65
  - **children**: Text components accept children as shorthand. `<Heading>Hello</Heading>` sets the heading text. `<Paragraph>` supports children for plain text.
63
66
  - **Paragraph text**: Use `html` prop for text content (supports inline formatting like `<b>`, `<a>`). Use children for plain text.
64
67
 
@@ -154,6 +157,7 @@ Must be child of Row. Count must match layout.
154
157
  - `fontFamily?: { label: string, value: string }`
155
158
  - `padding?: string` — `"10px 20px"`
156
159
  - `borderRadius?: string` — `"4px"`
160
+ - `width?: number | string` — display width; `width="100%"` makes the button full-width, `width="200px"` pins it
157
161
  - `textAlign?: "left" | "center" | "right"` — `"center"`
158
162
 
159
163
  ### Paragraph
@@ -439,13 +443,12 @@ const monoFont = { label: "Monospace", value: "'SF Mono', 'Fira Code', 'Roboto M
439
443
 
440
444
  ## Common Mistakes
441
445
 
442
- 1. **fontFamily as string** — `fontFamily="Arial"` Must be `fontFamily={{ label: "Arial", value: "arial, sans-serif" }}`
443
- 2. **fontWeight as string** — `fontWeight="700"` Must be `fontWeight={700}`
444
- 3. **Column count mismatch** — `TwoEqual` layout requires exactly 2 `<Column>` children
445
- 4. **Missing Column** — Items must be inside `<Column>`, never directly in `<Row>`
446
- 5. **Missing Row** — Columns must be inside `<Row>`, never directly in `<Email>`/`<Page>`/`<Document>`
447
- 6. **Paragraph text prop**Use `html` prop or children, not `text` (which is not typed for Paragraph)
448
- 7. **padding without units** — Use `padding="0px"` not `padding="0"` — the type requires the `px` suffix for consistency
446
+ 1. **Column count mismatch** — `TwoEqual` layout requires exactly 2 `<Column>` children
447
+ 2. **Missing Column** — Items must be inside `<Column>`, never directly in `<Row>`
448
+ 3. **Missing Row** — Columns must be inside `<Row>`, never directly in `<Email>`/`<Page>`/`<Document>`
449
+ 4. **JSX formatting in children** — `<Heading>Hi <b>x</b></Heading>` is flattened to plain text (the formatting is **not** preserved). For inline formatting use `<Paragraph html="Hi <b>x</b>" />`.
450
+
451
+ > Note: the CSS-idiom forms that used to be mistakes now work a string `fontFamily`, a string/number `fontWeight`, a numeric `fontSize`, `padding="0"`, and `<Paragraph text="..." />` are all accepted and normalized. The object/numeric forms above are still recommended for clarity.
449
452
 
450
453
  ## Development
451
454
 
package/dist/index.cjs CHANGED
@@ -208,16 +208,53 @@ function analyzeNestedStructure(defaultValues) {
208
208
  nestedStructureCache.set(defaultValues, nestedGroups);
209
209
  return nestedGroups;
210
210
  }
211
+ var PX_SIZE_KEYS = [
212
+ "fontSize",
213
+ "padding",
214
+ "containerPadding",
215
+ "borderRadius",
216
+ "letterSpacing"
217
+ ];
218
+ function normalizeCssProps(props) {
219
+ if (typeof props.fontFamily === "string") {
220
+ const v = props.fontFamily;
221
+ props.fontFamily = { label: v, value: v };
222
+ }
223
+ if (typeof props.fontWeight === "string" && /^\d+$/.test(props.fontWeight.trim())) {
224
+ props.fontWeight = Number(props.fontWeight.trim());
225
+ }
226
+ if (typeof props.lineHeight === "number") {
227
+ props.lineHeight = String(props.lineHeight);
228
+ }
229
+ for (const key of PX_SIZE_KEYS) {
230
+ const v = props[key];
231
+ if (typeof v === "number") {
232
+ props[key] = `${v}px`;
233
+ } else if (typeof v === "string" && /^\d+$/.test(v.trim())) {
234
+ props[key] = `${v.trim()}px`;
235
+ }
236
+ }
237
+ }
238
+ function flattenChildrenText(node) {
239
+ if (node == null || typeof node === "boolean") return "";
240
+ if (typeof node === "string") return node;
241
+ if (typeof node === "number") return String(node);
242
+ if (Array.isArray(node)) return node.map(flattenChildrenText).join("");
243
+ if (typeof node === "object" && "props" in node) {
244
+ return flattenChildrenText(node.props?.children);
245
+ }
246
+ return "";
247
+ }
211
248
  function mapSemanticProps(props, defaultValues, componentType) {
212
249
  const { children, values, ...restProps } = props;
213
250
  const userProps = { ...restProps };
214
251
  const result = values ? { ...values } : {};
215
252
  if (children !== void 0 && !result.text && !result.textJson) {
253
+ const textContent = typeof children === "string" ? children : flattenChildrenText(children);
216
254
  if (componentType === "Paragraph") {
217
- const textContent = typeof children === "string" ? children : String(children);
218
255
  result.textJson = textToTextJson(textContent);
219
256
  } else {
220
- result.text = children;
257
+ result.text = textContent;
221
258
  }
222
259
  }
223
260
  const textFromEscapeHatch = result.text;
@@ -243,6 +280,7 @@ function mapSemanticProps(props, defaultValues, componentType) {
243
280
  };
244
281
  }
245
282
  }
283
+ normalizeCssProps(userProps);
246
284
  const nestedGroups = analyzeNestedStructure(defaultValues);
247
285
  const nested = {};
248
286
  const flat = {};
@@ -277,6 +315,19 @@ function mapSemanticProps(props, defaultValues, componentType) {
277
315
  };
278
316
  }
279
317
  }
318
+ if (defaultValues && typeof defaultValues === "object" && "border" in defaultValues) {
319
+ const borderSideRe = /^border(Top|Right|Bottom|Left)(Width|Style|Color)$/;
320
+ const collected = {};
321
+ for (const key of Object.keys(final)) {
322
+ if (borderSideRe.test(key)) {
323
+ collected[key] = final[key];
324
+ delete final[key];
325
+ }
326
+ }
327
+ if (Object.keys(collected).length > 0) {
328
+ final.border = { ...final.border || {}, ...collected };
329
+ }
330
+ }
280
331
  return final;
281
332
  }
282
333
  function normalizeLinkValue(value) {
@@ -545,7 +596,26 @@ var DEFAULT_VALUES = {
545
596
  var Button = createItemComponent({
546
597
  name: "Button",
547
598
  defaultValues: DEFAULT_VALUES,
548
- propMapper: (props) => mapSemanticProps(props, DEFAULT_VALUES, "Button"),
599
+ propMapper: (props) => {
600
+ const mapped = mapSemanticProps(
601
+ props,
602
+ DEFAULT_VALUES,
603
+ "Button"
604
+ );
605
+ const size = mapped.size;
606
+ if (size && typeof size === "object" && !Array.isArray(size)) {
607
+ const s = size;
608
+ if (typeof s.width === "number") {
609
+ s.width = `${s.width}px`;
610
+ } else if (typeof s.width === "string" && /^\d+(?:\.\d+)?$/.test(s.width.trim())) {
611
+ s.width = `${s.width.trim()}px`;
612
+ }
613
+ if (s.width !== void 0 && s.autoWidth === void 0) {
614
+ s.autoWidth = false;
615
+ }
616
+ }
617
+ return mapped;
618
+ },
549
619
  displayName: "Button",
550
620
  exporters: exporters.ButtonExporters
551
621
  });
@@ -610,18 +680,46 @@ var Image = createItemComponent({
610
680
  defaultValues: DEFAULT_VALUES5,
611
681
  propMapper: (props) => {
612
682
  const { alt, src, ...rest } = props;
683
+ const restValues = rest.values;
684
+ const normalizedRest = restValues && typeof restValues.src === "string" ? { ...rest, values: { ...restValues, src: { url: restValues.src } } } : rest;
613
685
  const base = mapSemanticProps(
614
- rest,
686
+ normalizedRest,
615
687
  DEFAULT_VALUES5,
616
688
  "Image"
617
689
  );
618
690
  if (alt !== void 0) {
619
691
  base.altText = alt;
620
692
  }
621
- if (typeof src === "string") {
622
- base.src = { url: src, autoWidth: true, maxWidth: "100%" };
623
- } else if (src !== void 0) {
624
- base.src = { ...DEFAULT_VALUES5.src, ...src };
693
+ const baseSrc = base.src;
694
+ const fromValues = baseSrc && typeof baseSrc === "object" && !Array.isArray(baseSrc) ? baseSrc : typeof baseSrc === "string" ? { url: baseSrc } : {};
695
+ const fromProp = typeof src === "string" ? { url: src } : src ?? {};
696
+ const userSrc = { ...fromValues, ...fromProp };
697
+ if (src !== void 0 || baseSrc !== void 0) {
698
+ const isStringUrl = typeof src === "string" || src === void 0 && typeof baseSrc === "string";
699
+ const start = isStringUrl ? { autoWidth: true, maxWidth: "100%" } : { ...DEFAULT_VALUES5.src };
700
+ const merged = { ...start, ...userSrc };
701
+ const pctRe = /^\d+(?:\.\d+)?%$/;
702
+ if (typeof merged.width === "string") {
703
+ const t = merged.width.trim();
704
+ if (pctRe.test(t)) {
705
+ if (userSrc.maxWidth === void 0) merged.maxWidth = t;
706
+ delete merged.width;
707
+ } else {
708
+ const px = /^(\d+(?:\.\d+)?)(?:px)?$/.exec(t);
709
+ if (px) merged.width = parseFloat(px[1]);
710
+ }
711
+ }
712
+ const displayPct = typeof merged.maxWidth === "string" && pctRe.test(merged.maxWidth.trim()) ? merged.maxWidth.trim() : void 0;
713
+ if (userSrc.autoWidth === void 0) {
714
+ if (displayPct && displayPct !== "100%") {
715
+ merged.autoWidth = false;
716
+ merged.maxWidth = displayPct;
717
+ } else {
718
+ merged.autoWidth = true;
719
+ merged.maxWidth = "100%";
720
+ }
721
+ }
722
+ base.src = merged;
625
723
  }
626
724
  return base;
627
725
  },
@@ -732,25 +830,37 @@ var Table = createItemComponent({
732
830
  name: "Table",
733
831
  defaultValues: DEFAULT_VALUES9,
734
832
  propMapper: (props) => {
735
- const { headers, data, ...rest } = props;
736
- if (headers || data) {
833
+ const { headers, data, columns, rows, ...rest } = props;
834
+ if (headers || data || typeof columns === "number" || typeof rows === "number") {
737
835
  const base = mapSemanticProps(
738
836
  rest,
739
837
  DEFAULT_VALUES9,
740
838
  "Table"
741
839
  );
840
+ const colCount = headers ? headers.length : typeof columns === "number" ? columns : data?.[0]?.length ?? 0;
841
+ const blankCells = (n) => Array.from({ length: n }, () => ({ text: "", width: 0 }));
742
842
  const tableHeaders = headers ? [{ cells: headers.map((text) => ({ text, width: 0 })), height: 0 }] : [];
743
843
  const tableRows = data ? data.map((row) => ({
744
844
  cells: row.map((text) => ({ text, width: 0 })),
745
845
  height: 0
746
- })) : [];
846
+ })) : typeof rows === "number" ? (
847
+ // No data: build an empty grid sized by `columns` × `rows`.
848
+ Array.from({ length: rows }, () => ({
849
+ cells: blankCells(colCount),
850
+ height: 0
851
+ }))
852
+ ) : [];
747
853
  base.table = { headers: tableHeaders, rows: tableRows, footers: [] };
854
+ if (headers || typeof columns === "number") {
855
+ base.columns = colCount;
856
+ }
748
857
  if (headers) {
749
- base.columns = headers.length;
750
858
  base.enableHeader = true;
751
859
  }
752
860
  if (data) {
753
861
  base.rows = data.length;
862
+ } else if (typeof rows === "number") {
863
+ base.rows = rows;
754
864
  }
755
865
  return base;
756
866
  }
@@ -911,13 +1021,23 @@ ${widths.map(({ value, className }) => ` .no-stack .u-col-${className} { width:
911
1021
  }`;
912
1022
  return baseCSS + "\n" + columnCSS + "\n" + responsiveCSS;
913
1023
  }
1024
+ function toContentWidthPx(bodyValues, fallback = 500) {
1025
+ const raw = bodyValues?.contentWidth;
1026
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
1027
+ if (typeof raw === "string") {
1028
+ const n = parseInt(raw, 10);
1029
+ if (Number.isFinite(n)) return n;
1030
+ }
1031
+ return fallback;
1032
+ }
914
1033
  function renderRowToHtml(innerHTML, values, bodyValues, mode, cells, collection = "rows") {
915
1034
  const rowExporter = exporters.RowExporters[mode] || exporters.RowExporters.web;
916
1035
  const html = rowExporter(innerHTML, values, bodyValues, {
917
1036
  collection,
918
1037
  variant: mode
919
1038
  });
920
- const css = generateGridCSS(cells, mode);
1039
+ const contentWidth = toContentWidthPx(bodyValues);
1040
+ const css = generateGridCSS(cells, mode, contentWidth);
921
1041
  return css ? `<style>${css}</style>${html}` : html;
922
1042
  }
923
1043
  function processChildren(children, cells, bodyValues, rowValues, mode, _config) {
@@ -1015,11 +1135,16 @@ var Row = (props) => {
1015
1135
  };
1016
1136
  Row.displayName = "Row";
1017
1137
  var Row_default = Row;
1138
+ var DEFAULT_CONTAINER_PADDING = "10px";
1018
1139
  var DEFAULT_VALUES12 = COLUMN_DEFAULTS;
1019
1140
  function renderColumnToHtml(innerHTML, values, index, cells, bodyValues, rowValues, mode) {
1020
1141
  const columnExporter = exporters.ColumnExporters[mode] || exporters.ColumnExporters.web;
1021
1142
  return columnExporter(innerHTML, values, index, cells, bodyValues, rowValues);
1022
1143
  }
1144
+ function renderContentToHtml(innerHTML, values, bodyValues, mode) {
1145
+ const contentExporter = exporters.ContentExporters[mode] || exporters.ContentExporters.web;
1146
+ return contentExporter(innerHTML, values, bodyValues, {});
1147
+ }
1023
1148
  var Column = (props) => {
1024
1149
  const {
1025
1150
  children,
@@ -1062,10 +1187,22 @@ var Column = (props) => {
1062
1187
  if (rendered && typeof rendered === "object" && rendered.props && rendered.props.dangerouslySetInnerHTML) {
1063
1188
  const componentHTML = rendered.props.dangerouslySetInnerHTML.__html;
1064
1189
  const componentType = child.type;
1065
- const componentName = componentType?.displayName || componentType?.name || "component";
1066
- const componentProps = child.props;
1067
- const containerPadding = componentProps.values?.containerPadding || DEFAULT_VALUES12.padding || "10px";
1068
- innerHTML += `<div id="u_content_${componentName.toLowerCase()}_${childIndex + 1}" class="u_content_${componentName.toLowerCase()}" style="padding: ${containerPadding};">${componentHTML}</div>`;
1190
+ const componentName = (componentType?.displayName || componentType?.name || "component").toLowerCase();
1191
+ const childProps = child.props;
1192
+ const containerPadding = childProps.containerPadding ?? childProps.values?.containerPadding ?? DEFAULT_CONTAINER_PADDING;
1193
+ const contentValues = {
1194
+ containerPadding,
1195
+ _meta: {
1196
+ htmlID: `u_content_${componentName}_${childIndex + 1}`,
1197
+ htmlClassNames: `u_content_${componentName}`
1198
+ }
1199
+ };
1200
+ innerHTML += renderContentToHtml(
1201
+ componentHTML,
1202
+ contentValues,
1203
+ bodyValues,
1204
+ mode
1205
+ );
1069
1206
  } else if (rendered) {
1070
1207
  const name = child.type?.displayName || child.type?.name || "Unknown";
1071
1208
  console.warn(
@@ -1127,7 +1264,13 @@ function renderBodyToHtml(innerHTML, values, mode, previewText) {
1127
1264
  }
1128
1265
  }
1129
1266
  const bodyExporter = exporters.BodyExporters[mode] || exporters.BodyExporters.web;
1130
- const raw = mode === "document" ? bodyExporter(finalInnerHtml, values, { type: "" }) : bodyExporter(finalInnerHtml, values, values);
1267
+ const raw = mode === "document" ? bodyExporter(finalInnerHtml, values, { type: "" }) : mode === "email" ? (
1268
+ // The email body exporter reads body context (contentWidth,
1269
+ // contentAlign) from the `bodyValues` field of its 3rd argument.
1270
+ // Passing `values` directly left it undefined, so the Outlook (MSO)
1271
+ // table fell back to 600px regardless of contentWidth.
1272
+ bodyExporter(finalInnerHtml, values, { bodyValues: values })
1273
+ ) : bodyExporter(finalInnerHtml, values, values);
1131
1274
  return raw.replace("min-height: 100vh; ", "").replace("min-height: 100vh;", "");
1132
1275
  }
1133
1276
  var Body = (props) => {
@@ -1135,7 +1278,10 @@ var Body = (props) => {
1135
1278
  const resolvedConfig = { ...DEFAULT_CONFIG, ...configProp };
1136
1279
  const mode = modeProp ?? resolvedConfig.mode ?? "web";
1137
1280
  const _config = { ...resolvedConfig, mode };
1138
- const values = mapSemanticProps(semanticProps, DEFAULT_VALUES13, "Body");
1281
+ const values = mergeValues(
1282
+ DEFAULT_VALUES13,
1283
+ mapSemanticProps(semanticProps, DEFAULT_VALUES13, "Body")
1284
+ );
1139
1285
  const valuesWithMeta = {
1140
1286
  ...values,
1141
1287
  _meta: {
@@ -1148,7 +1294,10 @@ var Body = (props) => {
1148
1294
  if (children) {
1149
1295
  enrichedChildren = React__default.default.Children.map(children, (child) => {
1150
1296
  if (React__default.default.isValidElement(child)) {
1151
- return React__default.default.cloneElement(child, { _config });
1297
+ return React__default.default.cloneElement(child, {
1298
+ _config,
1299
+ bodyValues: values
1300
+ });
1152
1301
  }
1153
1302
  return child;
1154
1303
  });
@@ -1580,6 +1729,10 @@ function processBody(element, counters) {
1580
1729
  const semanticProps = extractSemanticProps2(element.props);
1581
1730
  const mapped = mapSemanticProps(semanticProps, BODY_DEFAULTS, "Body");
1582
1731
  const values = mergeValues(BODY_DEFAULTS, mapped);
1732
+ const previewText = element.props.previewText;
1733
+ if (previewText !== void 0) {
1734
+ values.preheaderText = previewText;
1735
+ }
1583
1736
  const valuesWithMeta = {
1584
1737
  ...values,
1585
1738
  _meta: {