jfs-components 0.0.85 → 0.0.86

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/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@ All notable changes to this project are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [0.0.86] - 2026-06-04
8
+
9
+ - Added `AllocationComparisonChart` — vertical pill bars comparing current vs recommended allocation with optional baseline overlay and dashed marker.
10
+ - `FullscreenModal` — removed parallax; hero media scrolls with content and height follows media aspect ratio.
11
+
12
+ ---
13
+
7
14
  ## [0.0.85] - 2026-06-03
8
15
 
9
16
  - Added `AreaLineChart` — multi-series area/line chart with grid, axes, legend, goal pin, and interactive tooltip; exports `useChart` and compound `ChartGrid` / `ChartXAxis` / `ChartYAxis` / `GoalPin` parts.
@@ -0,0 +1,299 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = void 0;
7
+ var _react = _interopRequireDefault(require("react"));
8
+ var _reactNative = require("react-native");
9
+ var _reactNativeSvg = _interopRequireWildcard(require("react-native-svg"));
10
+ var _figmaVariablesResolver = require("../../design-tokens/figma-variables-resolver");
11
+ var _JFSThemeProvider = require("../../design-tokens/JFSThemeProvider");
12
+ var _reactUtils = require("../../utils/react-utils");
13
+ var _MetricLegendItem = _interopRequireDefault(require("../MetricLegendItem/MetricLegendItem"));
14
+ var _jsxRuntime = require("react/jsx-runtime");
15
+ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
16
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
17
+ /**
18
+ * One vertical pill in the {@link AllocationComparisonChartProps.data} array.
19
+ *
20
+ * Each segment renders a single bar whose **height encodes `value`** (the
21
+ * "current" reading) and, when supplied, a **`baseline`** overlay drawn from
22
+ * the bottom up with a dashed marker line (the "recommended" reading). Both
23
+ * are measured against the same shared scale so bars and baselines are
24
+ * directly comparable across segments.
25
+ */
26
+
27
+ const DEFAULT_DATA = [{
28
+ label: 'Small & Mid',
29
+ value: 65,
30
+ baseline: 35
31
+ }, {
32
+ label: 'Large',
33
+ value: 25
34
+ }, {
35
+ label: 'Others',
36
+ value: 10
37
+ }];
38
+ const toNumber = (value, fallback) => {
39
+ if (typeof value === 'number') {
40
+ return Number.isFinite(value) ? value : fallback;
41
+ }
42
+ if (typeof value === 'string') {
43
+ const parsed = Number(value);
44
+ return Number.isFinite(parsed) ? parsed : fallback;
45
+ }
46
+ return fallback;
47
+ };
48
+ const toFontWeight = (value, fallback) => {
49
+ if (typeof value === 'number') {
50
+ return String(value);
51
+ }
52
+ if (typeof value === 'string') {
53
+ return value;
54
+ }
55
+ return fallback;
56
+ };
57
+ const isShown = node => node !== undefined && node !== null && node !== false;
58
+ /**
59
+ * Internal: one vertical pill column (the Figma "Segment Indicator"). Not
60
+ * exported — the ergonomic public unit is the chart driven by `data`. The
61
+ * `segmentIndicator/*` token names are mirrored here so design ↔ code token
62
+ * alignment is preserved.
63
+ */
64
+ function SegmentBar({
65
+ segment,
66
+ barHeightPx,
67
+ baselineHeightPx,
68
+ baselineLabel,
69
+ showMarker,
70
+ theme
71
+ }) {
72
+ const {
73
+ barWidth,
74
+ pillRadius,
75
+ gap,
76
+ currentColor,
77
+ baselineColor,
78
+ lineColor,
79
+ lineSize,
80
+ labelStyle
81
+ } = theme;
82
+ const fillColor = segment.color ?? currentColor;
83
+ const overlayColor = segment.baselineColor ?? baselineColor;
84
+ const showValueLabel = isShown(segment.valueLabel);
85
+ const hasBaseline = baselineHeightPx !== null && baselineHeightPx > 0;
86
+ const overlayHeight = hasBaseline ? Math.min(baselineHeightPx, barHeightPx) : 0;
87
+ const overlayRadius = Math.min(pillRadius, barWidth / 2, overlayHeight / 2);
88
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
89
+ style: {
90
+ flex: 1,
91
+ alignItems: 'center',
92
+ justifyContent: 'flex-end',
93
+ gap
94
+ },
95
+ accessibilityLabel: segment.accessibilityLabel,
96
+ children: [showValueLabel ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
97
+ numberOfLines: 1,
98
+ style: labelStyle,
99
+ children: segment.valueLabel
100
+ }) : null, /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
101
+ style: {
102
+ width: barWidth,
103
+ height: Math.max(barHeightPx, 1),
104
+ borderRadius: pillRadius,
105
+ backgroundColor: fillColor,
106
+ position: 'relative'
107
+ },
108
+ children: hasBaseline ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
109
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
110
+ style: {
111
+ position: 'absolute',
112
+ left: 0,
113
+ right: 0,
114
+ bottom: 0,
115
+ height: overlayHeight,
116
+ backgroundColor: overlayColor,
117
+ borderBottomLeftRadius: overlayRadius,
118
+ borderBottomRightRadius: overlayRadius
119
+ }
120
+ }), showMarker ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
121
+ style: {
122
+ position: 'absolute',
123
+ left: 0,
124
+ bottom: overlayHeight,
125
+ height: 0,
126
+ flexDirection: 'row',
127
+ alignItems: 'center'
128
+ },
129
+ pointerEvents: "none",
130
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeSvg.default, {
131
+ width: barWidth,
132
+ height: Math.max(lineSize, 1),
133
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeSvg.Line, {
134
+ x1: 0,
135
+ y1: Math.max(lineSize, 1) / 2,
136
+ x2: barWidth,
137
+ y2: Math.max(lineSize, 1) / 2,
138
+ stroke: lineColor,
139
+ strokeWidth: lineSize,
140
+ strokeDasharray: "2 2"
141
+ })
142
+ }), isShown(baselineLabel) ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
143
+ numberOfLines: 1,
144
+ style: [labelStyle, {
145
+ marginLeft: 6
146
+ }],
147
+ children: baselineLabel
148
+ }) : null]
149
+ }) : null]
150
+ }) : null
151
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
152
+ numberOfLines: 1,
153
+ style: labelStyle,
154
+ children: segment.label
155
+ })]
156
+ });
157
+ }
158
+
159
+ /**
160
+ * `AllocationComparisonChart` plots a row of vertical pill bars that compare a
161
+ * **current** reading (each bar's height) against an optional **recommended**
162
+ * baseline (a filled overlay drawn from the bottom up, marked with a dashed
163
+ * line). Every bar and baseline shares a single scale, so heights are directly
164
+ * comparable across segments — no axes required.
165
+ *
166
+ * The chart is driven entirely by the `data` array: each entry pairs a
167
+ * `value`, an optional `baseline` and its `label`, so a bar can never drift
168
+ * out of sync with its caption or its baseline marker.
169
+ *
170
+ * Colors, fonts, spacing and the pill radius resolve from the Figma
171
+ * `segmentIndicator/*`, `metricLegendItem/*` and `allocationComparisonChart/*`
172
+ * tokens via the `modes` prop.
173
+ *
174
+ * @component
175
+ */
176
+ function AllocationComparisonChart({
177
+ data = DEFAULT_DATA,
178
+ max,
179
+ height = 154,
180
+ barWidth,
181
+ showLegend = true,
182
+ valueLegendLabel = 'Current',
183
+ baselineLegendLabel = 'Recommended',
184
+ formatValue = value => `${value}%`,
185
+ modes: propModes = _reactUtils.EMPTY_MODES,
186
+ style,
187
+ chartStyle,
188
+ legendStyle,
189
+ accessibilityLabel
190
+ }) {
191
+ const {
192
+ modes: globalModes
193
+ } = (0, _JFSThemeProvider.useTokens)();
194
+ const modes = _react.default.useMemo(() => ({
195
+ ...globalModes,
196
+ ...propModes
197
+ }), [globalModes, propModes]);
198
+ const trackWidth = toNumber((0, _figmaVariablesResolver.getVariableByName)('segmentIndicator/track/width', modes), 28);
199
+ const resolvedBarWidth = barWidth ?? trackWidth;
200
+ const radiusToken = toNumber((0, _figmaVariablesResolver.getVariableByName)('segmentIndicator/indicator/radius', modes), 99999);
201
+ const pillRadius = Math.min(radiusToken, resolvedBarWidth / 2);
202
+ const gap = toNumber((0, _figmaVariablesResolver.getVariableByName)('segmentIndicator/gap', modes), 4);
203
+ const chartGap = toNumber((0, _figmaVariablesResolver.getVariableByName)('allocationComparisonChart/gap', modes), 8);
204
+ const currentColor = (0, _figmaVariablesResolver.getVariableByName)('segmentIndicator/indicator/background', modes) ?? '#5d00b5';
205
+ const baselineColor = (0, _figmaVariablesResolver.getVariableByName)('segmentIndicator/indicator/foreground', modes) ?? '#b84fbd';
206
+ const lineColor = (0, _figmaVariablesResolver.getVariableByName)('segmentIndicator/indicator/line/color', modes) ?? '#ffffff';
207
+ const lineSize = toNumber((0, _figmaVariablesResolver.getVariableByName)('segmentIndicator/indicator/line/size', modes), 1);
208
+ const foreground = (0, _figmaVariablesResolver.getVariableByName)('segmentIndicator/foreground', modes) ?? '#0c0d10';
209
+ const fontFamily = (0, _figmaVariablesResolver.getVariableByName)('segmentIndicator/fontFamily', modes) ?? 'JioType Var';
210
+ const fontSize = toNumber((0, _figmaVariablesResolver.getVariableByName)('segmentIndicator/fontSize', modes), 12);
211
+ const lineHeight = toNumber((0, _figmaVariablesResolver.getVariableByName)('segmentIndicator/lineHeight', modes), 12);
212
+ const fontWeight = toFontWeight((0, _figmaVariablesResolver.getVariableByName)('segmentIndicator/fontWeight', modes), '400');
213
+ const labelStyle = {
214
+ color: foreground,
215
+ fontFamily,
216
+ fontSize,
217
+ lineHeight,
218
+ fontWeight,
219
+ textAlign: 'center'
220
+ };
221
+ const computedMax = max ?? data.reduce((acc, seg) => Math.max(acc, seg.value, seg.baseline ?? 0), 0);
222
+ const safeMax = computedMax > 0 ? computedMax : 1;
223
+ const firstBaselineIndex = data.findIndex(seg => typeof seg.baseline === 'number');
224
+ const hasAnyBaseline = firstBaselineIndex !== -1;
225
+ const theme = {
226
+ barWidth: resolvedBarWidth,
227
+ pillRadius,
228
+ gap,
229
+ currentColor,
230
+ baselineColor,
231
+ lineColor,
232
+ lineSize,
233
+ labelStyle
234
+ };
235
+ const defaultAccessibilityLabel = accessibilityLabel ?? `Allocation comparison of ${data.length} segment${data.length === 1 ? '' : 's'}: ` + data.map(seg => {
236
+ const label = typeof seg.label === 'string' ? seg.label : 'segment';
237
+ const base = typeof seg.baseline === 'number' ? `, recommended ${seg.baseline}` : '';
238
+ return `${label} ${seg.value}${base}`;
239
+ }).join('; ');
240
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
241
+ style: [{
242
+ width: '100%'
243
+ }, style],
244
+ accessibilityLabel: defaultAccessibilityLabel,
245
+ children: [showLegend ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
246
+ style: [{
247
+ flexDirection: 'row',
248
+ alignItems: 'center',
249
+ gap: 8,
250
+ marginBottom: chartGap
251
+ }, legendStyle],
252
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_MetricLegendItem.default, {
253
+ label: valueLegendLabel,
254
+ indicatorColor: currentColor,
255
+ modes: modes,
256
+ style: {
257
+ flexGrow: 0,
258
+ flexShrink: 1
259
+ }
260
+ }), hasAnyBaseline ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_MetricLegendItem.default, {
261
+ label: baselineLegendLabel,
262
+ indicatorColor: baselineColor,
263
+ modes: modes,
264
+ style: {
265
+ flexGrow: 0,
266
+ flexShrink: 1
267
+ }
268
+ }) : null]
269
+ }) : null, /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
270
+ accessibilityRole: "image",
271
+ style: [{
272
+ flexDirection: 'row',
273
+ alignItems: 'flex-end',
274
+ gap: 8,
275
+ width: '100%'
276
+ }, chartStyle],
277
+ children: data.map((segment, index) => {
278
+ const ratio = Math.max(0, Math.min(1, segment.value / safeMax));
279
+ const barHeightPx = Math.max(0, height * ratio);
280
+ const baselineHeightPx = typeof segment.baseline === 'number' ? Math.max(0, Math.min(1, segment.baseline / safeMax)) * height : null;
281
+ const baselineLabel = segment.baselineLabel === undefined ? typeof segment.baseline === 'number' ? formatValue(segment.baseline) : undefined : segment.baselineLabel;
282
+ const resolvedSegment = {
283
+ ...segment,
284
+ valueLabel: segment.valueLabel === undefined ? formatValue(segment.value) : segment.valueLabel
285
+ };
286
+ const showMarker = segment.showMarker ?? index === firstBaselineIndex;
287
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(SegmentBar, {
288
+ segment: resolvedSegment,
289
+ barHeightPx: barHeightPx,
290
+ baselineHeightPx: baselineHeightPx,
291
+ baselineLabel: baselineLabel,
292
+ showMarker: showMarker,
293
+ theme: theme
294
+ }, segment.key ?? `segment-${index}`);
295
+ })
296
+ })]
297
+ });
298
+ }
299
+ var _default = exports.default = AllocationComparisonChart;
@@ -6,7 +6,6 @@ Object.defineProperty(exports, "__esModule", {
6
6
  exports.default = void 0;
7
7
  var _react = _interopRequireWildcard(require("react"));
8
8
  var _reactNative = require("react-native");
9
- var _reactNativeReanimated = _interopRequireWildcard(require("react-native-reanimated"));
10
9
  var _figmaVariablesResolver = require("../../design-tokens/figma-variables-resolver");
11
10
  var _JFSThemeProvider = require("../../design-tokens/JFSThemeProvider");
12
11
  var _reactUtils = require("../../utils/react-utils");
@@ -34,22 +33,11 @@ const FULLSCREEN_MODAL_FORCED_MODES = Object.freeze({
34
33
  context5: 'Fullscreen Modal'
35
34
  });
36
35
 
37
- // Reanimated-driven ScrollView so the parallax handler runs on the UI thread.
38
- // Module scope so the wrapped component identity is stable across renders.
39
- const AnimatedScrollView = _reactNativeReanimated.default.createAnimatedComponent(_reactNative.ScrollView);
40
-
41
- // Parallax tuning. The hero collapses by HEIGHT only as the user scrolls up —
42
- // its full width is preserved and the media keeps a fixed aspect ratio (it is
43
- // cropped, never scaled or squished, like a `cover` background). When no
44
- // explicit `heroMinHeight` is given, the hero collapses to this fraction of
45
- // its resting height.
46
- const HERO_MIN_HEIGHT_RATIO = 0.45;
47
-
48
36
  // ---------------------------------------------------------------------------
49
37
  // Hero text — the eyebrow / headline / supporting / price block. Built inline
50
38
  // (rather than reusing <PageHero>) so we can render BOTH a supporting
51
39
  // paragraph AND a price line with the exact PageHero token gaps, and overlay
52
- // it on the parallax media without PageHero's media/button scaffolding.
40
+ // it on the hero media without PageHero's media/button scaffolding.
53
41
  // ---------------------------------------------------------------------------
54
42
 
55
43
  function HeroText({
@@ -134,8 +122,9 @@ function HeroText({
134
122
  }
135
123
 
136
124
  /**
137
- * FullscreenModal — a full-screen takeover surface with a parallax media hero,
138
- * a scrollable body, a floating close button, and a sticky `ActionFooter`.
125
+ * FullscreenModal — a full-screen takeover surface with a full-bleed media
126
+ * hero, a scrollable body, a floating close button, and a sticky
127
+ * `ActionFooter`.
139
128
  *
140
129
  * The component always themes itself with `context5: 'Fullscreen Modal'`
141
130
  * (non-overridable) so every nested component (Section, ListItem, Button,
@@ -143,14 +132,12 @@ function HeroText({
143
132
  * That mode is cascaded into `children`, the footer, and the hero text via
144
133
  * `cloneChildrenWithModes` / the merged `modes` object.
145
134
  *
146
- * ### Parallax
147
- * As the user scrolls up, the hero collapses by **height only** (from
148
- * `heroHeight` to `heroMinHeight`) its **full width is always preserved**.
149
- * The `heroMedia` is pinned to the top at a fixed size and `cover`-cropped by
150
- * the collapsing clip, so it keeps a perfect aspect ratio the whole time
151
- * (never scaled or squished). Because it collapses slower than the content
152
- * scrolls, the media lags behind for the parallax depth cue. Disable with
153
- * `parallax={false}`.
135
+ * ### Hero
136
+ * The `heroMedia` is rendered full modal width inside the scroll body and
137
+ * takes its height from its own aspect ratio. The hero text (eyebrow /
138
+ * headline / supporting / price) is overlaid on top, anchored to the bottom.
139
+ * The whole hero scrolls together with the rest of the content — there is no
140
+ * parallax effect.
154
141
  *
155
142
  * @component
156
143
  * @example
@@ -160,7 +147,7 @@ function HeroText({
160
147
  * headline="Get more from your money."
161
148
  * supportingText="JioFinance+ is your upgraded financial experience…"
162
149
  * priceText="₹999/year · ₹0 until 2027"
163
- * heroMedia={<LottiePlayer source={hero} size={{ width: 360, height: 420 }} />}
150
+ * heroMedia={<Image imageSource={hero} ratio={3 / 4} />}
164
151
  * primaryActionLabel="Upgrade for free"
165
152
  * disclaimer="By upgrading, we'll check your eligibility with Experian."
166
153
  * onPrimaryAction={() => upgrade()}
@@ -178,8 +165,6 @@ function FullscreenModal({
178
165
  priceText = '₹999/year · ₹0 until 2027',
179
166
  heroMedia,
180
167
  heroHeight = 420,
181
- heroMinHeight,
182
- parallax = true,
183
168
  showClose = true,
184
169
  onClose,
185
170
  closeAccessibilityLabel = 'Close',
@@ -206,49 +191,13 @@ function FullscreenModal({
206
191
  ...FULLSCREEN_MODAL_FORCED_MODES
207
192
  }), [globalModes, propModes]);
208
193
  const rootGap = Number((0, _figmaVariablesResolver.getVariableByName)('fullScreenModal/gap', modes)) || 16;
209
- const minHeight = heroMinHeight ?? Math.round(heroHeight * HERO_MIN_HEIGHT_RATIO);
210
- const scrollY = (0, _reactNativeReanimated.useSharedValue)(0);
211
- const onScroll = (0, _reactNativeReanimated.useAnimatedScrollHandler)(event => {
212
- scrollY.value = event.contentOffset.y;
213
- });
214
-
215
- // Collapse the hero by HEIGHT only as the user scrolls up. The clip's width
216
- // never changes and the media inside is pinned full-size at the top, so the
217
- // art is cropped (cover) rather than scaled or narrowed — it keeps a perfect
218
- // aspect ratio the whole time. Pull-down (negative offset) is clamped, so the
219
- // hero never grows past its resting height.
220
- const heroAnimatedStyle = (0, _reactNativeReanimated.useAnimatedStyle)(() => {
221
- const height = (0, _reactNativeReanimated.interpolate)(scrollY.value, [0, heroHeight], [heroHeight, minHeight], _reactNativeReanimated.Extrapolation.CLAMP);
222
- return {
223
- height
224
- };
225
- });
226
194
  const processedHeroMedia = (0, _react.useMemo)(() => heroMedia ? (0, _reactUtils.cloneChildrenWithModes)(heroMedia, modes, FULLSCREEN_MODAL_FORCED_MODES) : null, [heroMedia, modes]);
227
195
  const processedChildren = (0, _react.useMemo)(() => children ? (0, _reactUtils.cloneChildrenWithModes)(children, modes, FULLSCREEN_MODAL_FORCED_MODES) : null, [children, modes]);
228
196
 
229
- // The clip is full-width and top-pinned; its height is what animates. Width
230
- // is intentionally never animated.
231
- const heroClipBaseStyle = (0, _react.useMemo)(() => ({
232
- position: 'absolute',
233
- top: 0,
234
- left: 0,
235
- right: 0,
236
- overflow: 'hidden'
237
- }), []);
238
-
239
- // The media sits at a fixed full-size box pinned to the top of the clip, so
240
- // the collapsing clip crops it from the bottom (cover) instead of resizing
241
- // it. Full width, fixed height — a perfect, constant aspect ratio.
242
- const heroMediaWrapStyle = (0, _react.useMemo)(() => ({
243
- position: 'absolute',
244
- top: 0,
245
- left: 0,
246
- right: 0,
247
- height: heroHeight,
248
- alignItems: 'stretch'
249
- }), [heroHeight]);
250
- const heroTextRegionStyle = (0, _react.useMemo)(() => ({
251
- height: heroHeight,
197
+ // No-media fallback: without hero media the text region needs an explicit
198
+ // resting height (driven by `heroHeight`) so the hero still has presence.
199
+ const heroTextFallbackStyle = (0, _react.useMemo)(() => ({
200
+ minHeight: heroHeight,
252
201
  justifyContent: 'flex-end',
253
202
  paddingHorizontal: 16,
254
203
  paddingBottom: 16
@@ -259,15 +208,28 @@ function FullscreenModal({
259
208
  paddingTop: rootGap,
260
209
  paddingBottom: 24
261
210
  }, contentContainerStyle], [backgroundColor, rootGap, contentContainerStyle]);
262
- const heroClip = /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeReanimated.default.View, {
263
- style: [heroClipBaseStyle, parallax ? heroAnimatedStyle : {
264
- height: heroHeight
265
- }],
266
- pointerEvents: "none",
267
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
268
- style: heroMediaWrapStyle,
269
- children: processedHeroMedia
270
- })
211
+ const heroTextNode = /*#__PURE__*/(0, _jsxRuntime.jsx)(HeroText, {
212
+ eyebrow: eyebrow,
213
+ headline: headline,
214
+ supportingText: supportingText,
215
+ priceText: priceText,
216
+ modes: modes
217
+ });
218
+
219
+ // The hero scrolls inline with the body (no parallax). When media is present
220
+ // it is laid out full modal width and takes its height from its own aspect
221
+ // ratio; the hero text is overlaid on top, anchored to the bottom. Without
222
+ // media the text simply renders in flow at the fallback height.
223
+ const hero = processedHeroMedia ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
224
+ style: heroMediaContainerStyle,
225
+ children: [processedHeroMedia, /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
226
+ style: heroTextOverlayStyle,
227
+ pointerEvents: "box-none",
228
+ children: heroTextNode
229
+ })]
230
+ }) : /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
231
+ style: heroTextFallbackStyle,
232
+ children: heroTextNode
271
233
  });
272
234
 
273
235
  // Footer: a fully custom node, or the default Button + Disclaimer column.
@@ -296,26 +258,15 @@ function FullscreenModal({
296
258
  backgroundColor
297
259
  }, style],
298
260
  testID: testID,
299
- children: [processedHeroMedia ? heroClip : null, /*#__PURE__*/(0, _jsxRuntime.jsxs)(AnimatedScrollView, {
261
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.ScrollView, {
300
262
  style: scrollViewStyle,
301
263
  contentContainerStyle: scrollContentStyle,
302
- showsVerticalScrollIndicator: false,
303
- onScroll: onScroll,
304
- scrollEventThrottle: 16
264
+ showsVerticalScrollIndicator: false
305
265
  // Tap an input in the body and it focuses on the FIRST tap, even when
306
266
  // the keyboard is already open (default 'never' eats that tap).
307
267
  ,
308
268
  keyboardShouldPersistTaps: "handled",
309
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
310
- style: heroTextRegionStyle,
311
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(HeroText, {
312
- eyebrow: eyebrow,
313
- headline: headline,
314
- supportingText: supportingText,
315
- priceText: priceText,
316
- modes: modes
317
- })
318
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
269
+ children: [hero, /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
319
270
  style: bodyStyle,
320
271
  children: processedChildren
321
272
  })]
@@ -354,4 +305,16 @@ const closeButtonStyle = {
354
305
  top: 12,
355
306
  right: 12
356
307
  };
308
+ // Full-width hero wrapper; height comes from the media's own aspect ratio.
309
+ const heroMediaContainerStyle = {
310
+ width: '100%',
311
+ position: 'relative'
312
+ };
313
+ // Hero text overlaid on the media, anchored to the bottom edge.
314
+ const heroTextOverlayStyle = {
315
+ ..._reactNative.StyleSheet.absoluteFillObject,
316
+ justifyContent: 'flex-end',
317
+ paddingHorizontal: 16,
318
+ paddingBottom: 16
319
+ };
357
320
  var _default = exports.default = FullscreenModal;
@@ -33,6 +33,12 @@ Object.defineProperty(exports, "ActionTile", {
33
33
  return _ActionTile.default;
34
34
  }
35
35
  });
36
+ Object.defineProperty(exports, "AllocationComparisonChart", {
37
+ enumerable: true,
38
+ get: function () {
39
+ return _AllocationComparisonChart.default;
40
+ }
41
+ });
36
42
  Object.defineProperty(exports, "AmountInput", {
37
43
  enumerable: true,
38
44
  get: function () {
@@ -881,6 +887,7 @@ var _CircularProgressBarDoted = _interopRequireDefault(require("./CircularProgre
881
887
  var _CircularRating = _interopRequireDefault(require("./CircularRating/CircularRating"));
882
888
  var _CoverageRing = _interopRequireDefault(require("./CoverageRing/CoverageRing"));
883
889
  var _CoverageBarComparison = _interopRequireDefault(require("./CoverageBarComparison/CoverageBarComparison"));
890
+ var _AllocationComparisonChart = _interopRequireDefault(require("./AllocationComparisonChart/AllocationComparisonChart"));
884
891
  var _MonthlyStatusGrid = _interopRequireWildcard(require("./MonthlyStatusGrid/MonthlyStatusGrid"));
885
892
  var _Gauge = _interopRequireDefault(require("./Gauge/Gauge"));
886
893
  var _HoldingsCard = _interopRequireDefault(require("./HoldingsCard/HoldingsCard"));