@rocapine/react-native-onboarding-ui 1.32.0 → 1.34.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 (40) hide show
  1. package/dist/UI/Pages/ComposableScreen/Renderer.js.map +1 -1
  2. package/dist/UI/Pages/ComposableScreen/elements/ImageElement.d.ts.map +1 -1
  3. package/dist/UI/Pages/ComposableScreen/elements/ImageElement.js +72 -32
  4. package/dist/UI/Pages/ComposableScreen/elements/ImageElement.js.map +1 -1
  5. package/dist/UI/Pages/ComposableScreen/elements/RichTextElement.d.ts +305 -0
  6. package/dist/UI/Pages/ComposableScreen/elements/RichTextElement.d.ts.map +1 -0
  7. package/dist/UI/Pages/ComposableScreen/elements/RichTextElement.js +142 -0
  8. package/dist/UI/Pages/ComposableScreen/elements/RichTextElement.js.map +1 -0
  9. package/dist/UI/Pages/ComposableScreen/elements/ScrollViewElement.d.ts +16 -0
  10. package/dist/UI/Pages/ComposableScreen/elements/ScrollViewElement.d.ts.map +1 -1
  11. package/dist/UI/Pages/ComposableScreen/elements/ScrollViewElement.js +13 -2
  12. package/dist/UI/Pages/ComposableScreen/elements/ScrollViewElement.js.map +1 -1
  13. package/dist/UI/Pages/ComposableScreen/elements/StackElement.d.ts +1 -1
  14. package/dist/UI/Pages/ComposableScreen/elements/StackElement.d.ts.map +1 -1
  15. package/dist/UI/Pages/ComposableScreen/elements/TextElement.d.ts +1 -1
  16. package/dist/UI/Pages/ComposableScreen/elements/TextElement.d.ts.map +1 -1
  17. package/dist/UI/Pages/ComposableScreen/elements/TextElement.js +26 -12
  18. package/dist/UI/Pages/ComposableScreen/elements/TextElement.js.map +1 -1
  19. package/dist/UI/Pages/ComposableScreen/elements/renderElement.d.ts +1 -1
  20. package/dist/UI/Pages/ComposableScreen/elements/renderElement.d.ts.map +1 -1
  21. package/dist/UI/Pages/ComposableScreen/elements/renderElement.js +4 -0
  22. package/dist/UI/Pages/ComposableScreen/elements/renderElement.js.map +1 -1
  23. package/dist/UI/Pages/ComposableScreen/elements/shared.d.ts +12 -1
  24. package/dist/UI/Pages/ComposableScreen/elements/shared.d.ts.map +1 -1
  25. package/dist/UI/Pages/ComposableScreen/elements/shared.js +6 -1
  26. package/dist/UI/Pages/ComposableScreen/elements/shared.js.map +1 -1
  27. package/dist/UI/Pages/ComposableScreen/types.d.ts +11 -0
  28. package/dist/UI/Pages/ComposableScreen/types.d.ts.map +1 -1
  29. package/dist/UI/Pages/ComposableScreen/types.js +15 -2
  30. package/dist/UI/Pages/ComposableScreen/types.js.map +1 -1
  31. package/package.json +5 -1
  32. package/src/UI/Pages/ComposableScreen/Renderer.tsx +1 -1
  33. package/src/UI/Pages/ComposableScreen/elements/ImageElement.tsx +102 -42
  34. package/src/UI/Pages/ComposableScreen/elements/RichTextElement.tsx +188 -0
  35. package/src/UI/Pages/ComposableScreen/elements/ScrollViewElement.tsx +15 -2
  36. package/src/UI/Pages/ComposableScreen/elements/StackElement.tsx +1 -1
  37. package/src/UI/Pages/ComposableScreen/elements/TextElement.tsx +21 -11
  38. package/src/UI/Pages/ComposableScreen/elements/renderElement.tsx +6 -1
  39. package/src/UI/Pages/ComposableScreen/elements/shared.ts +19 -1
  40. package/src/UI/Pages/ComposableScreen/types.ts +25 -2
@@ -1,11 +1,22 @@
1
1
  import React from "react";
2
2
  import { z } from "zod";
3
- import { Image, View } from "react-native";
3
+ import { Image as RNImage, View } from "react-native";
4
+ import { SvgUri } from "react-native-svg";
4
5
  import { BaseBoxProps, BaseBoxPropsSchema } from "./BaseBoxProps";
5
6
  import { UIElement } from "../types";
6
7
  import { RenderContext, buildShadowStyle, dim } from "./shared";
7
8
  import { GradientBox } from "./GradientBox";
8
9
 
10
+ // expo-image decodes webp/avif reliably across platforms (RN's built-in Image is
11
+ // flaky for webp on iOS). Optional peer dep — fall back to RN Image when absent,
12
+ // mirroring GradientBox's expo-linear-gradient handling.
13
+ let ExpoImage: React.ComponentType<any> | null = null;
14
+ try {
15
+ ExpoImage = require("expo-image").Image;
16
+ } catch {
17
+ // expo-image not installed — RN Image fallback below
18
+ }
19
+
9
20
  export type ImageElementProps = BaseBoxProps & {
10
21
  url: string;
11
22
  aspectRatio?: number;
@@ -18,6 +29,39 @@ export const ImageElementPropsSchema = BaseBoxPropsSchema.extend({
18
29
  resizeMode: z.enum(["cover", "contain", "stretch", "center"]).optional(),
19
30
  });
20
31
 
32
+ type ResizeMode = "cover" | "contain" | "stretch" | "center";
33
+
34
+ // RN resizeMode → expo-image contentFit.
35
+ const CONTENT_FIT: Record<ResizeMode, "cover" | "contain" | "fill" | "none"> = {
36
+ cover: "cover",
37
+ contain: "contain",
38
+ stretch: "fill",
39
+ center: "none",
40
+ };
41
+
42
+ // RN resizeMode → SVG preserveAspectRatio (SvgUri has no resizeMode).
43
+ const SVG_ASPECT: Record<ResizeMode, string> = {
44
+ cover: "xMidYMid slice",
45
+ contain: "xMidYMid meet",
46
+ center: "xMidYMid meet",
47
+ stretch: "none",
48
+ };
49
+
50
+ // SVGs need react-native-svg's SvgUri — RN/expo Image can't decode SVG XML. Auto
51
+ // -detected by file extension (query-string / hash tolerant) so existing payloads
52
+ // with `.svg` URLs just work, no schema change.
53
+ const isSvgUrl = (url: string): boolean =>
54
+ url.split(/[?#]/)[0].toLowerCase().endsWith(".svg");
55
+
56
+ // Pick expo-image when installed (better webp/avif), else RN Image. resizeMode
57
+ // passes through unchanged on RN; maps to contentFit on expo-image.
58
+ const renderRaster = (url: string, resizeMode: ResizeMode | undefined, style: any): React.ReactElement =>
59
+ ExpoImage ? (
60
+ <ExpoImage source={url} contentFit={CONTENT_FIT[resizeMode ?? "cover"]} style={style} />
61
+ ) : (
62
+ <RNImage source={{ uri: url }} resizeMode={resizeMode} style={style} />
63
+ );
64
+
21
65
  type ImageUIElement = Extract<UIElement, { type: "Image" }>;
22
66
 
23
67
  type Props = {
@@ -27,6 +71,7 @@ type Props = {
27
71
 
28
72
  export const ImageElementComponent = ({ element }: Props): React.ReactElement => {
29
73
  const p = element.props;
74
+ const isSvg = isSvgUrl(p.url);
30
75
  const hasShadow = p.shadowColor != null || p.elevation != null;
31
76
  // iOS clips shadows when overflow:hidden, so a shadow-bearing Image needs a
32
77
  // wrapper View carrying the shadow (no overflow clip) and the Image inside
@@ -58,17 +103,21 @@ export const ImageElementComponent = ({ element }: Props): React.ReactElement =>
58
103
  paddingVertical: p.paddingVertical,
59
104
  ...(shadowStyle ?? {}),
60
105
  };
61
- const innerImage = (
62
- <Image
63
- source={{ uri: p.url }}
64
- resizeMode={p.resizeMode}
65
- style={{
66
- width: "100%",
67
- height: "100%",
68
- borderRadius: p.borderRadius,
69
- overflow: (p.overflow ?? "hidden") as any,
70
- }}
106
+ // Inner content fills the wrapper (which carries layout + corner clip).
107
+ const innerImage = isSvg ? (
108
+ <SvgUri
109
+ uri={p.url}
110
+ width="100%"
111
+ height="100%"
112
+ preserveAspectRatio={SVG_ASPECT[p.resizeMode ?? "contain"]}
71
113
  />
114
+ ) : (
115
+ renderRaster(p.url, p.resizeMode, {
116
+ width: "100%",
117
+ height: "100%",
118
+ borderRadius: p.borderRadius,
119
+ overflow: (p.overflow ?? "hidden") as any,
120
+ })
72
121
  );
73
122
  if (p.backgroundGradient) {
74
123
  return (
@@ -80,35 +129,46 @@ export const ImageElementComponent = ({ element }: Props): React.ReactElement =>
80
129
  return <View style={wrapperStyle as any}>{innerImage}</View>;
81
130
  }
82
131
 
83
- return (
84
- <Image
85
- source={{ uri: p.url }}
86
- resizeMode={p.resizeMode}
87
- style={({
88
- flex: p.flex,
89
- flexShrink: p.flexShrink,
90
- flexGrow: p.flexGrow,
91
- alignSelf: p.alignSelf,
92
- aspectRatio: p.aspectRatio,
93
- width: dim(p.width),
94
- height: dim(p.height),
95
- minWidth: p.minWidth,
96
- maxWidth: p.maxWidth,
97
- minHeight: p.minHeight,
98
- maxHeight: p.maxHeight,
99
- backgroundColor: p.backgroundColor,
100
- overflow: p.overflow,
101
- borderRadius: p.borderRadius,
102
- borderWidth: p.borderWidth,
103
- borderColor: p.borderColor,
104
- opacity: p.opacity,
105
- margin: p.margin,
106
- marginHorizontal: p.marginHorizontal,
107
- marginVertical: p.marginVertical,
108
- padding: p.padding,
109
- paddingHorizontal: p.paddingHorizontal,
110
- paddingVertical: p.paddingVertical,
111
- }) as any}
112
- />
113
- );
132
+ const simpleStyle = {
133
+ flex: p.flex,
134
+ flexShrink: p.flexShrink,
135
+ flexGrow: p.flexGrow,
136
+ alignSelf: p.alignSelf,
137
+ aspectRatio: p.aspectRatio,
138
+ width: dim(p.width),
139
+ height: dim(p.height),
140
+ minWidth: p.minWidth,
141
+ maxWidth: p.maxWidth,
142
+ minHeight: p.minHeight,
143
+ maxHeight: p.maxHeight,
144
+ backgroundColor: p.backgroundColor,
145
+ overflow: p.overflow,
146
+ borderRadius: p.borderRadius,
147
+ borderWidth: p.borderWidth,
148
+ borderColor: p.borderColor,
149
+ opacity: p.opacity,
150
+ margin: p.margin,
151
+ marginHorizontal: p.marginHorizontal,
152
+ marginVertical: p.marginVertical,
153
+ padding: p.padding,
154
+ paddingHorizontal: p.paddingHorizontal,
155
+ paddingVertical: p.paddingVertical,
156
+ } as any;
157
+
158
+ // SvgUri can't carry the full RN layout style itself, so wrap it in a View that
159
+ // does, and let the SVG fill it.
160
+ if (isSvg) {
161
+ return (
162
+ <View style={simpleStyle}>
163
+ <SvgUri
164
+ uri={p.url}
165
+ width="100%"
166
+ height="100%"
167
+ preserveAspectRatio={SVG_ASPECT[p.resizeMode ?? "contain"]}
168
+ />
169
+ </View>
170
+ );
171
+ }
172
+
173
+ return renderRaster(p.url, p.resizeMode, simpleStyle);
114
174
  };
@@ -0,0 +1,188 @@
1
+ import React from "react";
2
+ import { z } from "zod";
3
+ import { evaluateCondition } from "@rocapine/react-native-onboarding";
4
+ import { BaseBoxProps, BaseBoxPropsSchema } from "./BaseBoxProps";
5
+ import { UIElement } from "../types";
6
+ import { RenderContext, dim, interpolate, RichTextStyleContext, InheritedTextStyle } from "./shared";
7
+ import { GradientBox } from "./GradientBox";
8
+
9
+ // Mirror of the headless RichTextElement schema. Kept in lockstep with
10
+ // packages/onboarding/src/steps/ComposableScreen/elements/RichTextElement.ts —
11
+ // TS won't catch drift because this re-declares its own type.
12
+ export type RichTextElementProps = BaseBoxProps & {
13
+ gap?: number;
14
+ alignItems?: "flex-start" | "center" | "flex-end" | "baseline" | "stretch";
15
+ justifyContent?: "flex-start" | "center" | "flex-end" | "space-between" | "space-around";
16
+ flexWrap?: "wrap" | "nowrap";
17
+ fontSize?: number;
18
+ fontWeight?: string;
19
+ fontFamily?: string | "inherit";
20
+ fontStyle?: "normal" | "italic";
21
+ color?: string;
22
+ textAlign?: "left" | "center" | "right";
23
+ letterSpacing?: number;
24
+ lineHeight?: number;
25
+ };
26
+
27
+ export const RichTextElementPropsSchema = BaseBoxPropsSchema.extend({
28
+ gap: z.number().optional(),
29
+ alignItems: z.enum(["flex-start", "center", "flex-end", "baseline", "stretch"]).optional(),
30
+ justifyContent: z.enum(["flex-start", "center", "flex-end", "space-between", "space-around"]).optional(),
31
+ flexWrap: z.enum(["wrap", "nowrap"]).optional(),
32
+ fontSize: z.number().optional(),
33
+ fontWeight: z.string().optional(),
34
+ fontFamily: z.string().optional(),
35
+ fontStyle: z.enum(["normal", "italic"]).optional(),
36
+ color: z.string().optional(),
37
+ textAlign: z.enum(["left", "center", "right"]).optional(),
38
+ letterSpacing: z.number().optional(),
39
+ lineHeight: z.number().optional(),
40
+ });
41
+
42
+ type RichTextUIElement = Extract<UIElement, { type: "RichText" }>;
43
+ type TextChild = Extract<UIElement, { type: "Text" }>;
44
+
45
+ type Props = {
46
+ element: RichTextUIElement;
47
+ ctx: RenderContext;
48
+ parentType?: "XStack" | "YStack" | "ZStack" | "RichText" | "XScroll";
49
+ };
50
+
51
+ // `textAlign` aligns text *inside* a Text box, but RichText splits each word into
52
+ // its own shrink-wrapped flex item, so textAlign is a no-op on the row. The row's
53
+ // horizontal distribution is governed by `justifyContent` — map textAlign onto it
54
+ // (explicit `justifyContent` still wins) so authors get the alignment they expect.
55
+ const ALIGN_TO_JUSTIFY = {
56
+ left: "flex-start",
57
+ center: "center",
58
+ right: "flex-end",
59
+ } as const;
60
+
61
+ // A plain-text child (no box styling, no motion) is split into one flex item per
62
+ // word so the row wraps word-by-word like real text — the chip pattern from
63
+ // host apps (parseTitleWithChips). A child carrying box styling (backgroundColor
64
+ // / borderRadius / border / padding) or motion is a "chip" and stays atomic so
65
+ // its box renders as a single rounded unit.
66
+ const isFlowingText = (child: TextChild): boolean => {
67
+ const p = child.props;
68
+ return (
69
+ typeof p.content === "string" &&
70
+ p.backgroundColor == null &&
71
+ p.backgroundGradient == null &&
72
+ p.borderRadius == null &&
73
+ p.borderWidth == null &&
74
+ p.padding == null &&
75
+ p.paddingHorizontal == null &&
76
+ p.paddingVertical == null &&
77
+ // margin / explicit size would be applied to *every* word if split, so a
78
+ // child carrying any of them is treated as an atomic chip too.
79
+ p.margin == null &&
80
+ p.marginHorizontal == null &&
81
+ p.marginVertical == null &&
82
+ p.width == null &&
83
+ p.height == null &&
84
+ p.minWidth == null &&
85
+ p.maxWidth == null &&
86
+ p.minHeight == null &&
87
+ p.maxHeight == null &&
88
+ p.animation == null &&
89
+ p.transform == null
90
+ );
91
+ };
92
+
93
+ /**
94
+ * Wrapping flex row of child `Text` elements. Plain-text children are split into
95
+ * per-word flex items so text wraps word-by-word (like a paragraph); box-styled
96
+ * children render as atomic "chips" (padded/rounded/rotated pills) that honor
97
+ * their own box props, `renderWhen`, `expression`, and motion. The result is a
98
+ * title where chips sit inline with naturally-wrapping words.
99
+ *
100
+ * The container's text-style props are published via `RichTextStyleContext` so
101
+ * every child `Text` inherits them as defaults (child overrides win).
102
+ */
103
+ export const RichTextElementComponent = ({ element, ctx, parentType }: Props): React.ReactElement => {
104
+ const p = element.props;
105
+
106
+ const inheritedTextStyle: InheritedTextStyle = {
107
+ fontSize: p.fontSize,
108
+ fontWeight: p.fontWeight,
109
+ fontFamily: p.fontFamily,
110
+ fontStyle: p.fontStyle,
111
+ color: p.color,
112
+ textAlign: p.textAlign,
113
+ letterSpacing: p.letterSpacing,
114
+ lineHeight: p.lineHeight,
115
+ };
116
+
117
+ // Expand children into the actual flex items to render: split flowing text into
118
+ // words, keep chips whole. renderWhen is evaluated once per source child here
119
+ // (flowing-text words then render unconditionally); chips keep their renderWhen
120
+ // and are gated by renderElement.
121
+ const flatVars = Object.fromEntries(
122
+ Object.entries(ctx.variables).map(([k, v]) => [k, v?.value])
123
+ );
124
+ const expanded: UIElement[] = [];
125
+ for (const child of element.children) {
126
+ if (child.renderWhen && !evaluateCondition(child.renderWhen, flatVars)) continue;
127
+
128
+ if (isFlowingText(child)) {
129
+ const raw = child.props.content as string;
130
+ const text = child.props.mode === "expression" ? interpolate(raw, ctx.variables) : raw;
131
+ const tokens = text.split(/(\s+)/).filter((t) => t.length > 0);
132
+ tokens.forEach((tok, i) => {
133
+ expanded.push({
134
+ ...child,
135
+ id: `${child.id}-w${i}`,
136
+ renderWhen: undefined,
137
+ // mode dropped (undefined): content is already interpolated above, and
138
+ // undefined is truer to the schema than the non-enum "plain".
139
+ props: { ...child.props, content: tok, mode: undefined },
140
+ });
141
+ });
142
+ } else {
143
+ // Chip: renderWhen already passed above; null it so renderElement doesn't
144
+ // re-evaluate the same condition (symmetry with the word path).
145
+ expanded.push({ ...child, renderWhen: undefined });
146
+ }
147
+ }
148
+
149
+ return (
150
+ <GradientBox
151
+ gradient={p.backgroundGradient}
152
+ style={{
153
+ flexDirection: "row",
154
+ flexWrap: p.flexWrap ?? "wrap",
155
+ gap: p.gap,
156
+ alignItems: p.alignItems ?? "center",
157
+ justifyContent:
158
+ p.justifyContent ?? (p.textAlign ? ALIGN_TO_JUSTIFY[p.textAlign] : "center"),
159
+ flex: p.flex,
160
+ flexShrink: p.flexShrink ?? (parentType === "XStack" ? 1 : undefined),
161
+ flexGrow: p.flexGrow,
162
+ alignSelf: p.alignSelf,
163
+ width: dim(p.width),
164
+ height: dim(p.height),
165
+ minWidth: p.minWidth,
166
+ maxWidth: p.maxWidth,
167
+ minHeight: p.minHeight,
168
+ maxHeight: p.maxHeight,
169
+ padding: p.padding,
170
+ paddingHorizontal: p.paddingHorizontal,
171
+ paddingVertical: p.paddingVertical,
172
+ margin: p.margin,
173
+ marginHorizontal: p.marginHorizontal,
174
+ marginVertical: p.marginVertical,
175
+ backgroundColor: p.backgroundGradient ? undefined : p.backgroundColor,
176
+ borderWidth: p.borderWidth,
177
+ borderRadius: p.borderRadius,
178
+ borderColor: p.borderColor,
179
+ overflow: p.overflow,
180
+ opacity: p.opacity,
181
+ }}
182
+ >
183
+ <RichTextStyleContext.Provider value={inheritedTextStyle}>
184
+ {ctx.renderChildren(expanded, "RichText")}
185
+ </RichTextStyleContext.Provider>
186
+ </GradientBox>
187
+ );
188
+ };
@@ -23,6 +23,8 @@ export type ScrollViewElementProps = BaseBoxProps & {
23
23
  contentInset?: ScrollViewContentInset;
24
24
  contentContainerPadding?: number;
25
25
  keyboardShouldPersistTaps?: "always" | "never" | "handled";
26
+ alignItems?: "flex-start" | "center" | "flex-end" | "stretch" | "baseline";
27
+ justifyContent?: "flex-start" | "center" | "flex-end" | "space-between" | "space-around";
26
28
  };
27
29
 
28
30
  const ContentInsetSchema = z.object({
@@ -42,6 +44,8 @@ export const ScrollViewElementPropsSchema = BaseBoxPropsSchema.extend({
42
44
  contentInset: ContentInsetSchema.optional(),
43
45
  contentContainerPadding: z.number().min(0).optional(),
44
46
  keyboardShouldPersistTaps: z.enum(["always", "never", "handled"]).optional(),
47
+ alignItems: z.enum(["flex-start", "center", "flex-end", "stretch", "baseline"]).optional(),
48
+ justifyContent: z.enum(["flex-start", "center", "flex-end", "space-between", "space-around"]).optional(),
45
49
  });
46
50
 
47
51
  type ScrollViewUIElement = Extract<UIElement, { type: "ScrollView" }>;
@@ -81,8 +85,17 @@ export const ScrollViewElementComponent = ({ element, ctx }: Props): React.React
81
85
  opacity: p.opacity,
82
86
  };
83
87
 
88
+ // Horizontal: children must keep their intrinsic width and overflow so the row
89
+ // can scroll — so NO flexGrow (which would pin the content to the viewport
90
+ // width) and children render with parentType "XScroll" (row layout, no
91
+ // flexShrink default). Vertical keeps flexGrow:1 so a short payload still fills
92
+ // the scroll viewport. alignItems/justifyContent let authors control cross-axis
93
+ // alignment + distribution along the scroll axis.
84
94
  const contentContainerStyle = {
85
- flexGrow: 1,
95
+ flexDirection: horizontal ? ("row" as const) : ("column" as const),
96
+ flexGrow: horizontal ? undefined : 1,
97
+ alignItems: p.alignItems,
98
+ justifyContent: p.justifyContent,
86
99
  padding: p.contentContainerPadding,
87
100
  };
88
101
 
@@ -99,7 +112,7 @@ export const ScrollViewElementComponent = ({ element, ctx }: Props): React.React
99
112
  style={hasGradient ? { flex: 1 } : containerStyle}
100
113
  contentContainerStyle={contentContainerStyle}
101
114
  >
102
- {ctx.renderChildren(element.children, horizontal ? "XStack" : "YStack")}
115
+ {ctx.renderChildren(element.children, horizontal ? "XScroll" : "YStack")}
103
116
  </ScrollView>
104
117
  );
105
118
 
@@ -24,7 +24,7 @@ type StackUIElement = Extract<UIElement, { type: "YStack" | "XStack" }>;
24
24
  type Props = {
25
25
  element: StackUIElement;
26
26
  ctx: RenderContext;
27
- parentType?: "XStack" | "YStack" | "ZStack";
27
+ parentType?: "XStack" | "YStack" | "ZStack" | "RichText" | "XScroll";
28
28
  };
29
29
 
30
30
  export const StackElementComponent = ({ element, ctx, parentType }: Props): React.ReactElement => {
@@ -4,7 +4,7 @@ import { Text } from "react-native";
4
4
  import { useResolvedFontStyle } from "@rocapine/react-native-onboarding";
5
5
  import { BaseBoxProps, BaseBoxPropsSchema } from "./BaseBoxProps";
6
6
  import { UIElement } from "../types";
7
- import { RenderContext, interpolate, dim, resolveInheritedFontFamily } from "./shared";
7
+ import { RenderContext, interpolate, dim, resolveInheritedFontFamily, RichTextStyleContext } from "./shared";
8
8
  import { GradientBox } from "./GradientBox";
9
9
 
10
10
  export type TextSpan = {
@@ -118,12 +118,22 @@ type TextUIElement = Extract<UIElement, { type: "Text" }>;
118
118
  type Props = {
119
119
  element: TextUIElement;
120
120
  ctx: RenderContext;
121
- parentType?: "XStack" | "YStack" | "ZStack";
121
+ parentType?: "XStack" | "YStack" | "ZStack" | "RichText" | "XScroll";
122
122
  };
123
123
 
124
124
  export const TextElementComponent = ({ element, ctx, parentType }: Props): React.ReactElement => {
125
125
  const { theme, variables } = ctx;
126
126
  const p = element.props;
127
+ // Text-style defaults inherited from a parent `RichText` container (empty when
128
+ // this Text isn't inside one). Element props always win over inherited values.
129
+ const inherited = React.useContext(RichTextStyleContext);
130
+ const fontSize = p.fontSize ?? inherited.fontSize;
131
+ const fontWeight = p.fontWeight ?? inherited.fontWeight;
132
+ const fontStyle = p.fontStyle ?? inherited.fontStyle;
133
+ const color = p.color ?? inherited.color;
134
+ const textAlign = p.textAlign ?? inherited.textAlign;
135
+ const letterSpacing = p.letterSpacing ?? inherited.letterSpacing;
136
+ const lineHeight = p.lineHeight ?? inherited.lineHeight;
127
137
  const isExpression = p.mode === "expression";
128
138
  const content: string | TextSpan[] = Array.isArray(p.content)
129
139
  ? isExpression
@@ -133,10 +143,10 @@ export const TextElementComponent = ({ element, ctx, parentType }: Props): React
133
143
  ? interpolate(p.content, variables)
134
144
  : p.content;
135
145
  const inheritedFontFamily = resolveInheritedFontFamily(
136
- p.fontFamily,
146
+ p.fontFamily ?? inherited.fontFamily,
137
147
  theme.typography.defaultFontFamily
138
148
  );
139
- const resolvedFont = useResolvedFontStyle(inheritedFontFamily, p.fontWeight);
149
+ const resolvedFont = useResolvedFontStyle(inheritedFontFamily, fontWeight);
140
150
 
141
151
  const textNode = (
142
152
  <Text
@@ -151,14 +161,14 @@ export const TextElementComponent = ({ element, ctx, parentType }: Props): React
151
161
  maxWidth: p.backgroundGradient ? undefined : p.maxWidth,
152
162
  minHeight: p.backgroundGradient ? undefined : p.minHeight,
153
163
  maxHeight: p.backgroundGradient ? undefined : p.maxHeight,
154
- fontSize: p.fontSize,
155
- fontWeight: resolvedFont.resolvedToVariant ? undefined : (p.fontWeight as any),
164
+ fontSize,
165
+ fontWeight: resolvedFont.resolvedToVariant ? undefined : (fontWeight as any),
156
166
  fontFamily: resolvedFont.fontFamily,
157
- fontStyle: p.fontStyle,
158
- color: p.color ?? theme.colors.text.primary,
159
- textAlign: p.textAlign,
160
- letterSpacing: p.letterSpacing,
161
- lineHeight: p.lineHeight,
167
+ fontStyle,
168
+ color: color ?? theme.colors.text.primary,
169
+ textAlign,
170
+ letterSpacing,
171
+ lineHeight,
162
172
  backgroundColor: p.backgroundGradient ? undefined : p.backgroundColor,
163
173
  padding: p.backgroundGradient ? undefined : p.padding,
164
174
  paddingHorizontal: p.backgroundGradient ? undefined : p.paddingHorizontal,
@@ -5,6 +5,7 @@ import { BaseBoxProps } from "./BaseBoxProps";
5
5
  import { RenderContext } from "./shared";
6
6
  import { StackElementComponent } from "./StackElement";
7
7
  import { TextElementComponent } from "./TextElement";
8
+ import { RichTextElementComponent } from "./RichTextElement";
8
9
  import { ImageElementComponent } from "./ImageElement";
9
10
  import { LottieElementComponent } from "./LottieElement";
10
11
  import { RiveElementRenderer } from "./RiveElement";
@@ -27,7 +28,7 @@ import { AnimatedBox } from "./AnimatedBox";
27
28
  export const renderElement = (
28
29
  element: UIElement,
29
30
  ctx: RenderContext,
30
- parentType?: "XStack" | "YStack" | "ZStack"
31
+ parentType?: "XStack" | "YStack" | "ZStack" | "RichText" | "XScroll"
31
32
  ): React.ReactNode => {
32
33
  if (element.renderWhen) {
33
34
  const flatVars = Object.fromEntries(
@@ -47,6 +48,10 @@ export const renderElement = (
47
48
  return <TextElementComponent key={element.id} element={element} ctx={ctx} parentType={parentType} />;
48
49
  }
49
50
 
51
+ if (element.type === "RichText") {
52
+ return <RichTextElementComponent key={element.id} element={element} ctx={ctx} parentType={parentType} />;
53
+ }
54
+
50
55
  if (element.type === "Image") {
51
56
  return <ImageElementComponent key={element.id} element={element} ctx={ctx} />;
52
57
  }
@@ -11,9 +11,27 @@ export type RenderContext = {
11
11
  setVariable: (key: string, entry: ComposableVariableEntry) => void;
12
12
  onContinue: () => void;
13
13
  customActions: CustomActions;
14
- renderChildren: (elements: UIElement[], parentType: "XStack" | "YStack" | "ZStack") => React.ReactNode;
14
+ renderChildren: (elements: UIElement[], parentType: "XStack" | "YStack" | "ZStack" | "RichText" | "XScroll") => React.ReactNode;
15
15
  };
16
16
 
17
+ // Text-style defaults a `RichText` container hands down to its child `Text`
18
+ // elements. A `<View>` doesn't propagate text style to nested `<Text>`, so the
19
+ // RichText renderer publishes these via context and `TextElementComponent`
20
+ // merges them under its own props (child always wins). Empty default ({}) means
21
+ // Text elements outside a RichText behave unchanged.
22
+ export type InheritedTextStyle = {
23
+ fontSize?: number;
24
+ fontWeight?: string;
25
+ fontFamily?: string | "inherit";
26
+ fontStyle?: "normal" | "italic";
27
+ color?: string;
28
+ textAlign?: "left" | "center" | "right";
29
+ letterSpacing?: number;
30
+ lineHeight?: number;
31
+ };
32
+
33
+ export const RichTextStyleContext = React.createContext<InheritedTextStyle>({});
34
+
17
35
  export const interpolate = (template: string, variables: Record<string, ComposableVariableEntry>): string =>
18
36
  template.replace(/\{\{([^}]+?)\}\}/g, (_, key) => variables[key]?.label ?? variables[key]?.value ?? "");
19
37
 
@@ -10,6 +10,7 @@ import {
10
10
  import { CustomPayloadSchema } from "../types";
11
11
  import { type StackElementProps, StackElementPropsSchema } from "./elements/StackElement";
12
12
  import { type TextElementProps, TextElementPropsSchema } from "./elements/TextElement";
13
+ import { type RichTextElementProps, RichTextElementPropsSchema } from "./elements/RichTextElement";
13
14
  import { type ImageElementProps, ImageElementPropsSchema } from "./elements/ImageElement";
14
15
  import { type LottieElementProps, LottieElementPropsSchema } from "./elements/LottieElement";
15
16
  import { type RiveElementProps, RiveElementPropsSchema } from "./elements/RiveElement";
@@ -37,6 +38,7 @@ export type { BaseBoxProps } from "./elements/BaseBoxProps";
37
38
  export { BaseBoxPropsSchema } from "./elements/BaseBoxProps";
38
39
  export type { StackElementProps } from "./elements/StackElement";
39
40
  export type { TextElementProps } from "./elements/TextElement";
41
+ export type { RichTextElementProps } from "./elements/RichTextElement";
40
42
  export type { ImageElementProps } from "./elements/ImageElement";
41
43
  export type { LottieElementProps } from "./elements/LottieElement";
42
44
  export type { RiveElementProps } from "./elements/RiveElement";
@@ -76,6 +78,14 @@ export type UIElement =
76
78
  type: "Text";
77
79
  props: TextElementProps;
78
80
  }
81
+ | {
82
+ id: string;
83
+ name?: string;
84
+ renderWhen?: LeafCondition | ConditionGroup;
85
+ type: "RichText";
86
+ props: RichTextElementProps;
87
+ children: Array<Extract<UIElement, { type: "Text" }>>;
88
+ }
79
89
  | {
80
90
  id: string;
81
91
  name?: string;
@@ -201,6 +211,17 @@ export type UIElement =
201
211
  props: ProgressIndicatorElementProps;
202
212
  };
203
213
 
214
+ // The `Text` variant, extracted so `RichText` can restrict its children to
215
+ // Text-only (children: z.array(TextUIElementSchema)) while the union references
216
+ // the same object.
217
+ const TextUIElementSchema = z.object({
218
+ id: z.string(),
219
+ name: z.string().optional(),
220
+ renderWhen: z.union([LeafConditionSchema, ConditionGroupSchema]).optional(),
221
+ type: z.literal("Text"),
222
+ props: TextElementPropsSchema,
223
+ });
224
+
204
225
  export const UIElementSchema: z.ZodType<UIElement> = z.lazy(() =>
205
226
  z.union([
206
227
  z.object({
@@ -211,12 +232,14 @@ export const UIElementSchema: z.ZodType<UIElement> = z.lazy(() =>
211
232
  props: StackElementPropsSchema,
212
233
  children: z.array(UIElementSchema),
213
234
  }),
235
+ TextUIElementSchema,
214
236
  z.object({
215
237
  id: z.string(),
216
238
  name: z.string().optional(),
217
239
  renderWhen: z.union([LeafConditionSchema, ConditionGroupSchema]).optional(),
218
- type: z.literal("Text"),
219
- props: TextElementPropsSchema,
240
+ type: z.literal("RichText"),
241
+ props: RichTextElementPropsSchema,
242
+ children: z.array(TextUIElementSchema),
220
243
  }),
221
244
  z.object({
222
245
  id: z.string(),