jfs-components 0.0.85 → 0.0.95

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 (28) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/lib/commonjs/assets.d.js +1 -0
  3. package/lib/commonjs/components/AllocationComparisonChart/AllocationComparisonChart.js +299 -0
  4. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +104 -94
  5. package/lib/commonjs/components/Icon/Icon.js +112 -0
  6. package/lib/commonjs/components/index.js +14 -0
  7. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  8. package/lib/commonjs/icons/registry.js +1 -1
  9. package/lib/module/assets.d.js +1 -0
  10. package/lib/module/components/AllocationComparisonChart/AllocationComparisonChart.js +293 -0
  11. package/lib/module/components/FullscreenModal/FullscreenModal.js +106 -96
  12. package/lib/module/components/Icon/Icon.js +106 -0
  13. package/lib/module/components/index.js +2 -0
  14. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  15. package/lib/module/icons/registry.js +1 -1
  16. package/lib/typescript/src/components/AllocationComparisonChart/AllocationComparisonChart.d.ts +118 -0
  17. package/lib/typescript/src/components/FullscreenModal/FullscreenModal.d.ts +39 -29
  18. package/lib/typescript/src/components/Icon/Icon.d.ts +75 -0
  19. package/lib/typescript/src/components/index.d.ts +2 -0
  20. package/lib/typescript/src/icons/registry.d.ts +1 -1
  21. package/package.json +1 -1
  22. package/src/assets.d.ts +24 -0
  23. package/src/components/AllocationComparisonChart/AllocationComparisonChart.tsx +450 -0
  24. package/src/components/FullscreenModal/FullscreenModal.tsx +131 -126
  25. package/src/components/Icon/Icon.tsx +167 -0
  26. package/src/components/index.ts +2 -0
  27. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  28. package/src/icons/registry.ts +1 -1
@@ -0,0 +1 @@
1
+ "use strict";
@@ -0,0 +1,293 @@
1
+ "use strict";
2
+
3
+ import React from 'react';
4
+ import { View, Text } from 'react-native';
5
+ import Svg, { Line } from 'react-native-svg';
6
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
7
+ import { useTokens } from '../../design-tokens/JFSThemeProvider';
8
+ import { EMPTY_MODES } from '../../utils/react-utils';
9
+ import MetricLegendItem from '../MetricLegendItem/MetricLegendItem';
10
+
11
+ /**
12
+ * One vertical pill in the {@link AllocationComparisonChartProps.data} array.
13
+ *
14
+ * Each segment renders a single bar whose **height encodes `value`** (the
15
+ * "current" reading) and, when supplied, a **`baseline`** overlay drawn from
16
+ * the bottom up with a dashed marker line (the "recommended" reading). Both
17
+ * are measured against the same shared scale so bars and baselines are
18
+ * directly comparable across segments.
19
+ */
20
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
21
+ const DEFAULT_DATA = [{
22
+ label: 'Small & Mid',
23
+ value: 65,
24
+ baseline: 35
25
+ }, {
26
+ label: 'Large',
27
+ value: 25
28
+ }, {
29
+ label: 'Others',
30
+ value: 10
31
+ }];
32
+ const toNumber = (value, fallback) => {
33
+ if (typeof value === 'number') {
34
+ return Number.isFinite(value) ? value : fallback;
35
+ }
36
+ if (typeof value === 'string') {
37
+ const parsed = Number(value);
38
+ return Number.isFinite(parsed) ? parsed : fallback;
39
+ }
40
+ return fallback;
41
+ };
42
+ const toFontWeight = (value, fallback) => {
43
+ if (typeof value === 'number') {
44
+ return String(value);
45
+ }
46
+ if (typeof value === 'string') {
47
+ return value;
48
+ }
49
+ return fallback;
50
+ };
51
+ const isShown = node => node !== undefined && node !== null && node !== false;
52
+ /**
53
+ * Internal: one vertical pill column (the Figma "Segment Indicator"). Not
54
+ * exported — the ergonomic public unit is the chart driven by `data`. The
55
+ * `segmentIndicator/*` token names are mirrored here so design ↔ code token
56
+ * alignment is preserved.
57
+ */
58
+ function SegmentBar({
59
+ segment,
60
+ barHeightPx,
61
+ baselineHeightPx,
62
+ baselineLabel,
63
+ showMarker,
64
+ theme
65
+ }) {
66
+ const {
67
+ barWidth,
68
+ pillRadius,
69
+ gap,
70
+ currentColor,
71
+ baselineColor,
72
+ lineColor,
73
+ lineSize,
74
+ labelStyle
75
+ } = theme;
76
+ const fillColor = segment.color ?? currentColor;
77
+ const overlayColor = segment.baselineColor ?? baselineColor;
78
+ const showValueLabel = isShown(segment.valueLabel);
79
+ const hasBaseline = baselineHeightPx !== null && baselineHeightPx > 0;
80
+ const overlayHeight = hasBaseline ? Math.min(baselineHeightPx, barHeightPx) : 0;
81
+ const overlayRadius = Math.min(pillRadius, barWidth / 2, overlayHeight / 2);
82
+ return /*#__PURE__*/_jsxs(View, {
83
+ style: {
84
+ flex: 1,
85
+ alignItems: 'center',
86
+ justifyContent: 'flex-end',
87
+ gap
88
+ },
89
+ accessibilityLabel: segment.accessibilityLabel,
90
+ children: [showValueLabel ? /*#__PURE__*/_jsx(Text, {
91
+ numberOfLines: 1,
92
+ style: labelStyle,
93
+ children: segment.valueLabel
94
+ }) : null, /*#__PURE__*/_jsx(View, {
95
+ style: {
96
+ width: barWidth,
97
+ height: Math.max(barHeightPx, 1),
98
+ borderRadius: pillRadius,
99
+ backgroundColor: fillColor,
100
+ position: 'relative'
101
+ },
102
+ children: hasBaseline ? /*#__PURE__*/_jsxs(_Fragment, {
103
+ children: [/*#__PURE__*/_jsx(View, {
104
+ style: {
105
+ position: 'absolute',
106
+ left: 0,
107
+ right: 0,
108
+ bottom: 0,
109
+ height: overlayHeight,
110
+ backgroundColor: overlayColor,
111
+ borderBottomLeftRadius: overlayRadius,
112
+ borderBottomRightRadius: overlayRadius
113
+ }
114
+ }), showMarker ? /*#__PURE__*/_jsxs(View, {
115
+ style: {
116
+ position: 'absolute',
117
+ left: 0,
118
+ bottom: overlayHeight,
119
+ height: 0,
120
+ flexDirection: 'row',
121
+ alignItems: 'center'
122
+ },
123
+ pointerEvents: "none",
124
+ children: [/*#__PURE__*/_jsx(Svg, {
125
+ width: barWidth,
126
+ height: Math.max(lineSize, 1),
127
+ children: /*#__PURE__*/_jsx(Line, {
128
+ x1: 0,
129
+ y1: Math.max(lineSize, 1) / 2,
130
+ x2: barWidth,
131
+ y2: Math.max(lineSize, 1) / 2,
132
+ stroke: lineColor,
133
+ strokeWidth: lineSize,
134
+ strokeDasharray: "2 2"
135
+ })
136
+ }), isShown(baselineLabel) ? /*#__PURE__*/_jsx(Text, {
137
+ numberOfLines: 1,
138
+ style: [labelStyle, {
139
+ marginLeft: 6
140
+ }],
141
+ children: baselineLabel
142
+ }) : null]
143
+ }) : null]
144
+ }) : null
145
+ }), /*#__PURE__*/_jsx(Text, {
146
+ numberOfLines: 1,
147
+ style: labelStyle,
148
+ children: segment.label
149
+ })]
150
+ });
151
+ }
152
+
153
+ /**
154
+ * `AllocationComparisonChart` plots a row of vertical pill bars that compare a
155
+ * **current** reading (each bar's height) against an optional **recommended**
156
+ * baseline (a filled overlay drawn from the bottom up, marked with a dashed
157
+ * line). Every bar and baseline shares a single scale, so heights are directly
158
+ * comparable across segments — no axes required.
159
+ *
160
+ * The chart is driven entirely by the `data` array: each entry pairs a
161
+ * `value`, an optional `baseline` and its `label`, so a bar can never drift
162
+ * out of sync with its caption or its baseline marker.
163
+ *
164
+ * Colors, fonts, spacing and the pill radius resolve from the Figma
165
+ * `segmentIndicator/*`, `metricLegendItem/*` and `allocationComparisonChart/*`
166
+ * tokens via the `modes` prop.
167
+ *
168
+ * @component
169
+ */
170
+ function AllocationComparisonChart({
171
+ data = DEFAULT_DATA,
172
+ max,
173
+ height = 154,
174
+ barWidth,
175
+ showLegend = true,
176
+ valueLegendLabel = 'Current',
177
+ baselineLegendLabel = 'Recommended',
178
+ formatValue = value => `${value}%`,
179
+ modes: propModes = EMPTY_MODES,
180
+ style,
181
+ chartStyle,
182
+ legendStyle,
183
+ accessibilityLabel
184
+ }) {
185
+ const {
186
+ modes: globalModes
187
+ } = useTokens();
188
+ const modes = React.useMemo(() => ({
189
+ ...globalModes,
190
+ ...propModes
191
+ }), [globalModes, propModes]);
192
+ const trackWidth = toNumber(getVariableByName('segmentIndicator/track/width', modes), 28);
193
+ const resolvedBarWidth = barWidth ?? trackWidth;
194
+ const radiusToken = toNumber(getVariableByName('segmentIndicator/indicator/radius', modes), 99999);
195
+ const pillRadius = Math.min(radiusToken, resolvedBarWidth / 2);
196
+ const gap = toNumber(getVariableByName('segmentIndicator/gap', modes), 4);
197
+ const chartGap = toNumber(getVariableByName('allocationComparisonChart/gap', modes), 8);
198
+ const currentColor = getVariableByName('segmentIndicator/indicator/background', modes) ?? '#5d00b5';
199
+ const baselineColor = getVariableByName('segmentIndicator/indicator/foreground', modes) ?? '#b84fbd';
200
+ const lineColor = getVariableByName('segmentIndicator/indicator/line/color', modes) ?? '#ffffff';
201
+ const lineSize = toNumber(getVariableByName('segmentIndicator/indicator/line/size', modes), 1);
202
+ const foreground = getVariableByName('segmentIndicator/foreground', modes) ?? '#0c0d10';
203
+ const fontFamily = getVariableByName('segmentIndicator/fontFamily', modes) ?? 'JioType Var';
204
+ const fontSize = toNumber(getVariableByName('segmentIndicator/fontSize', modes), 12);
205
+ const lineHeight = toNumber(getVariableByName('segmentIndicator/lineHeight', modes), 12);
206
+ const fontWeight = toFontWeight(getVariableByName('segmentIndicator/fontWeight', modes), '400');
207
+ const labelStyle = {
208
+ color: foreground,
209
+ fontFamily,
210
+ fontSize,
211
+ lineHeight,
212
+ fontWeight,
213
+ textAlign: 'center'
214
+ };
215
+ const computedMax = max ?? data.reduce((acc, seg) => Math.max(acc, seg.value, seg.baseline ?? 0), 0);
216
+ const safeMax = computedMax > 0 ? computedMax : 1;
217
+ const firstBaselineIndex = data.findIndex(seg => typeof seg.baseline === 'number');
218
+ const hasAnyBaseline = firstBaselineIndex !== -1;
219
+ const theme = {
220
+ barWidth: resolvedBarWidth,
221
+ pillRadius,
222
+ gap,
223
+ currentColor,
224
+ baselineColor,
225
+ lineColor,
226
+ lineSize,
227
+ labelStyle
228
+ };
229
+ const defaultAccessibilityLabel = accessibilityLabel ?? `Allocation comparison of ${data.length} segment${data.length === 1 ? '' : 's'}: ` + data.map(seg => {
230
+ const label = typeof seg.label === 'string' ? seg.label : 'segment';
231
+ const base = typeof seg.baseline === 'number' ? `, recommended ${seg.baseline}` : '';
232
+ return `${label} ${seg.value}${base}`;
233
+ }).join('; ');
234
+ return /*#__PURE__*/_jsxs(View, {
235
+ style: [{
236
+ width: '100%'
237
+ }, style],
238
+ accessibilityLabel: defaultAccessibilityLabel,
239
+ children: [showLegend ? /*#__PURE__*/_jsxs(View, {
240
+ style: [{
241
+ flexDirection: 'row',
242
+ alignItems: 'center',
243
+ gap: 8,
244
+ marginBottom: chartGap
245
+ }, legendStyle],
246
+ children: [/*#__PURE__*/_jsx(MetricLegendItem, {
247
+ label: valueLegendLabel,
248
+ indicatorColor: currentColor,
249
+ modes: modes,
250
+ style: {
251
+ flexGrow: 0,
252
+ flexShrink: 1
253
+ }
254
+ }), hasAnyBaseline ? /*#__PURE__*/_jsx(MetricLegendItem, {
255
+ label: baselineLegendLabel,
256
+ indicatorColor: baselineColor,
257
+ modes: modes,
258
+ style: {
259
+ flexGrow: 0,
260
+ flexShrink: 1
261
+ }
262
+ }) : null]
263
+ }) : null, /*#__PURE__*/_jsx(View, {
264
+ accessibilityRole: "image",
265
+ style: [{
266
+ flexDirection: 'row',
267
+ alignItems: 'flex-end',
268
+ gap: 8,
269
+ width: '100%'
270
+ }, chartStyle],
271
+ children: data.map((segment, index) => {
272
+ const ratio = Math.max(0, Math.min(1, segment.value / safeMax));
273
+ const barHeightPx = Math.max(0, height * ratio);
274
+ const baselineHeightPx = typeof segment.baseline === 'number' ? Math.max(0, Math.min(1, segment.baseline / safeMax)) * height : null;
275
+ const baselineLabel = segment.baselineLabel === undefined ? typeof segment.baseline === 'number' ? formatValue(segment.baseline) : undefined : segment.baselineLabel;
276
+ const resolvedSegment = {
277
+ ...segment,
278
+ valueLabel: segment.valueLabel === undefined ? formatValue(segment.value) : segment.valueLabel
279
+ };
280
+ const showMarker = segment.showMarker ?? index === firstBaselineIndex;
281
+ return /*#__PURE__*/_jsx(SegmentBar, {
282
+ segment: resolvedSegment,
283
+ barHeightPx: barHeightPx,
284
+ baselineHeightPx: baselineHeightPx,
285
+ baselineLabel: baselineLabel,
286
+ showMarker: showMarker,
287
+ theme: theme
288
+ }, segment.key ?? `segment-${index}`);
289
+ })
290
+ })]
291
+ });
292
+ }
293
+ export default AllocationComparisonChart;
@@ -1,8 +1,7 @@
1
1
  "use strict";
2
2
 
3
- import React, { useMemo } from 'react';
4
- import { View, Text, ScrollView } from 'react-native';
5
- import Animated, { Extrapolation, interpolate, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue } from 'react-native-reanimated';
3
+ import React, { useMemo, useRef } from 'react';
4
+ import { View, Text, Animated } from 'react-native';
6
5
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
7
6
  import { useTokens } from '../../design-tokens/JFSThemeProvider';
8
7
  import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils';
@@ -29,22 +28,24 @@ const FULLSCREEN_MODAL_FORCED_MODES = Object.freeze({
29
28
  context5: 'Fullscreen Modal'
30
29
  });
31
30
 
32
- // Reanimated-driven ScrollView so the parallax handler runs on the UI thread.
33
- // Module scope so the wrapped component identity is stable across renders.
34
- const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView);
35
-
36
- // Parallax tuning. The hero collapses by HEIGHT only as the user scrolls up —
37
- // its full width is preserved and the media keeps a fixed aspect ratio (it is
38
- // cropped, never scaled or squished, like a `cover` background). When no
39
- // explicit `heroMinHeight` is given, the hero collapses to this fraction of
40
- // its resting height.
41
- const HERO_MIN_HEIGHT_RATIO = 0.45;
31
+ // ---------------------------------------------------------------------------
32
+ // Default modes
33
+ //
34
+ // A FullscreenModal is a "JioPlus" surface, so it defaults the `Page type`
35
+ // collection to `'JioPlus'`. Unlike the forced modes above this IS
36
+ // overridable it is applied before the caller's `modes`, so passing
37
+ // `modes={{ 'Page type': 'SubPage' }}` still wins. Frozen for stable identity
38
+ // (keeps the token resolver's per-modes cache hot).
39
+ // ---------------------------------------------------------------------------
40
+ const FULLSCREEN_MODAL_DEFAULT_MODES = Object.freeze({
41
+ 'Page type': 'JioPlus'
42
+ });
42
43
 
43
44
  // ---------------------------------------------------------------------------
44
45
  // Hero text — the eyebrow / headline / supporting / price block. Built inline
45
46
  // (rather than reusing <PageHero>) so we can render BOTH a supporting
46
47
  // paragraph AND a price line with the exact PageHero token gaps, and overlay
47
- // it on the parallax media without PageHero's media/button scaffolding.
48
+ // it on the hero media without PageHero's media/button scaffolding.
48
49
  // ---------------------------------------------------------------------------
49
50
 
50
51
  function HeroText({
@@ -129,8 +130,9 @@ function HeroText({
129
130
  }
130
131
 
131
132
  /**
132
- * FullscreenModal — a full-screen takeover surface with a parallax media hero,
133
- * a scrollable body, a floating close button, and a sticky `ActionFooter`.
133
+ * FullscreenModal — a full-screen takeover surface with a full-bleed media
134
+ * hero, a scrollable body, a floating close button, and a sticky
135
+ * `ActionFooter`.
134
136
  *
135
137
  * The component always themes itself with `context5: 'Fullscreen Modal'`
136
138
  * (non-overridable) so every nested component (Section, ListItem, Button,
@@ -138,14 +140,21 @@ function HeroText({
138
140
  * That mode is cascaded into `children`, the footer, and the hero text via
139
141
  * `cloneChildrenWithModes` / the merged `modes` object.
140
142
  *
141
- * ### Parallax
142
- * As the user scrolls up, the hero collapses by **height only** (from
143
- * `heroHeight` to `heroMinHeight`) its **full width is always preserved**.
144
- * The `heroMedia` is pinned to the top at a fixed size and `cover`-cropped by
145
- * the collapsing clip, so it keeps a perfect aspect ratio the whole time
146
- * (never scaled or squished). Because it collapses slower than the content
147
- * scrolls, the media lags behind for the parallax depth cue. Disable with
148
- * `parallax={false}`.
143
+ * ### Background media
144
+ * The `heroMedia` is a single full-bleed background pinned to the top of the
145
+ * modal at the full width and its own natural aspect ratio. It lives at the
146
+ * ROOT behind both the scrolling content and the (transparent) footer so
147
+ * it fills the whole surface and is NEVER clipped to the content height. It
148
+ * also contributes ZERO scroll height: the scroll extent is driven purely by
149
+ * the in-flow foreground (hero text + `children`), so the number of body
150
+ * elements dictates how far the surface scrolls. It still scrolls in lockstep
151
+ * WITH the content (the background is translated by the scroll offset), so the
152
+ * content reads as sitting ON one continuous image that moves with it — there
153
+ * is no parallax and no separate solid body box.
154
+ *
155
+ * Pass a background sized to the full width at its natural ratio
156
+ * (e.g. `<Image imageSource={bg} ratio={1080 / 4140} />`). Use an asset at
157
+ * least as tall as the surface so it covers the full modal.
149
158
  *
150
159
  * @component
151
160
  * @example
@@ -155,7 +164,7 @@ function HeroText({
155
164
  * headline="Get more from your money."
156
165
  * supportingText="JioFinance+ is your upgraded financial experience…"
157
166
  * priceText="₹999/year · ₹0 until 2027"
158
- * heroMedia={<LottiePlayer source={hero} size={{ width: 360, height: 420 }} />}
167
+ * heroMedia={<Image imageSource={hero} ratio={1080 / 4140} />}
159
168
  * primaryActionLabel="Upgrade for free"
160
169
  * disclaimer="By upgrading, we'll check your eligibility with Experian."
161
170
  * onPrimaryAction={() => upgrade()}
@@ -173,8 +182,6 @@ function FullscreenModal({
173
182
  priceText = '₹999/year · ₹0 until 2027',
174
183
  heroMedia,
175
184
  heroHeight = 420,
176
- heroMinHeight,
177
- parallax = true,
178
185
  showClose = true,
179
186
  onClose,
180
187
  closeAccessibilityLabel = 'Close',
@@ -182,7 +189,6 @@ function FullscreenModal({
182
189
  primaryActionLabel = 'Upgrade for free',
183
190
  onPrimaryAction,
184
191
  disclaimer = "By upgrading, we'll check your eligibility with Experian.",
185
- backgroundColor = '#0f0d0a',
186
192
  children,
187
193
  modes: propModes = EMPTY_MODES,
188
194
  style,
@@ -193,76 +199,62 @@ function FullscreenModal({
193
199
  modes: globalModes
194
200
  } = useTokens();
195
201
 
196
- // context5 is appended last so it always wins, regardless of what the
197
- // caller (or the global theme) passes.
202
+ // Merge order (low high priority):
203
+ // global theme → component defaults (Page type: JioPlus) → caller modes →
204
+ // forced modes (context5). So `Page type` defaults to JioPlus but the
205
+ // caller can override it, while `context5` always wins. This single `modes`
206
+ // object is what cascades to the body, hero media, and the ActionFooter.
198
207
  const modes = useMemo(() => ({
199
208
  ...globalModes,
209
+ ...FULLSCREEN_MODAL_DEFAULT_MODES,
200
210
  ...propModes,
201
211
  ...FULLSCREEN_MODAL_FORCED_MODES
202
212
  }), [globalModes, propModes]);
203
213
  const rootGap = Number(getVariableByName('fullScreenModal/gap', modes)) || 16;
204
- const minHeight = heroMinHeight ?? Math.round(heroHeight * HERO_MIN_HEIGHT_RATIO);
205
- const scrollY = useSharedValue(0);
206
- const onScroll = useAnimatedScrollHandler(event => {
207
- scrollY.value = event.contentOffset.y;
208
- });
209
214
 
210
- // Collapse the hero by HEIGHT only as the user scrolls up. The clip's width
211
- // never changes and the media inside is pinned full-size at the top, so the
212
- // art is cropped (cover) rather than scaled or narrowed it keeps a perfect
213
- // aspect ratio the whole time. Pull-down (negative offset) is clamped, so the
214
- // hero never grows past its resting height.
215
- const heroAnimatedStyle = useAnimatedStyle(() => {
216
- const height = interpolate(scrollY.value, [0, heroHeight], [heroHeight, minHeight], Extrapolation.CLAMP);
217
- return {
218
- height
219
- };
220
- });
215
+ // Drives the background's parallax-free sync with the scroll. The hero media
216
+ // lives at the ROOT (so it is never clipped to the content height and sits
217
+ // behind the transparent footer), but we translate it up by the exact scroll
218
+ // offset so it moves in lockstep with the content i.e. it scrolls WITH the
219
+ // body without ever contributing to the scroll height.
220
+ const scrollY = useRef(new Animated.Value(0)).current;
221
+ const onScroll = useMemo(() => Animated.event([{
222
+ nativeEvent: {
223
+ contentOffset: {
224
+ y: scrollY
225
+ }
226
+ }
227
+ }], {
228
+ useNativeDriver: true
229
+ }), [scrollY]);
230
+ const heroTranslateY = useMemo(() => Animated.multiply(scrollY, -1), [scrollY]);
221
231
  const processedHeroMedia = useMemo(() => heroMedia ? cloneChildrenWithModes(heroMedia, modes, FULLSCREEN_MODAL_FORCED_MODES) : null, [heroMedia, modes]);
222
232
  const processedChildren = useMemo(() => children ? cloneChildrenWithModes(children, modes, FULLSCREEN_MODAL_FORCED_MODES) : null, [children, modes]);
223
233
 
224
- // The clip is full-width and top-pinned; its height is what animates. Width
225
- // is intentionally never animated.
226
- const heroClipBaseStyle = useMemo(() => ({
227
- position: 'absolute',
228
- top: 0,
229
- left: 0,
230
- right: 0,
231
- overflow: 'hidden'
232
- }), []);
233
-
234
- // The media sits at a fixed full-size box pinned to the top of the clip, so
235
- // the collapsing clip crops it from the bottom (cover) instead of resizing
236
- // it. Full width, fixed height — a perfect, constant aspect ratio.
237
- const heroMediaWrapStyle = useMemo(() => ({
238
- position: 'absolute',
239
- top: 0,
240
- left: 0,
241
- right: 0,
242
- height: heroHeight,
243
- alignItems: 'stretch'
244
- }), [heroHeight]);
234
+ // The hero text region always reserves `heroHeight` and anchors its content
235
+ // to the bottom, so the eyebrow/headline block sits in the lower part of the
236
+ // first screenful over the background media when present, in flow
237
+ // otherwise.
245
238
  const heroTextRegionStyle = useMemo(() => ({
246
- height: heroHeight,
239
+ minHeight: heroHeight,
247
240
  justifyContent: 'flex-end',
248
241
  paddingHorizontal: 16,
249
242
  paddingBottom: 16
250
243
  }), [heroHeight]);
244
+
245
+ // Body is intentionally transparent — the background media shows through
246
+ // behind it. There is no solid "body box" stacked on top of the image.
251
247
  const bodyStyle = useMemo(() => [{
252
- backgroundColor,
253
248
  gap: rootGap,
254
249
  paddingTop: rootGap,
255
250
  paddingBottom: 24
256
- }, contentContainerStyle], [backgroundColor, rootGap, contentContainerStyle]);
257
- const heroClip = /*#__PURE__*/_jsx(Animated.View, {
258
- style: [heroClipBaseStyle, parallax ? heroAnimatedStyle : {
259
- height: heroHeight
260
- }],
261
- pointerEvents: "none",
262
- children: /*#__PURE__*/_jsx(View, {
263
- style: heroMediaWrapStyle,
264
- children: processedHeroMedia
265
- })
251
+ }, contentContainerStyle], [rootGap, contentContainerStyle]);
252
+ const heroTextNode = /*#__PURE__*/_jsx(HeroText, {
253
+ eyebrow: eyebrow,
254
+ headline: headline,
255
+ supportingText: supportingText,
256
+ priceText: priceText,
257
+ modes: modes
266
258
  });
267
259
 
268
260
  // Footer: a fully custom node, or the default Button + Disclaimer column.
@@ -287,11 +279,17 @@ function FullscreenModal({
287
279
  });
288
280
  }
289
281
  return /*#__PURE__*/_jsxs(View, {
290
- style: [rootStyle, {
291
- backgroundColor
292
- }, style],
282
+ style: [rootStyle, style],
293
283
  testID: testID,
294
- children: [processedHeroMedia ? heroClip : null, /*#__PURE__*/_jsxs(AnimatedScrollView, {
284
+ children: [processedHeroMedia ? /*#__PURE__*/_jsx(Animated.View, {
285
+ style: [heroBackgroundStyle, {
286
+ transform: [{
287
+ translateY: heroTranslateY
288
+ }]
289
+ }],
290
+ pointerEvents: "none",
291
+ children: processedHeroMedia
292
+ }) : null, /*#__PURE__*/_jsx(Animated.ScrollView, {
295
293
  style: scrollViewStyle,
296
294
  contentContainerStyle: scrollContentStyle,
297
295
  showsVerticalScrollIndicator: false,
@@ -301,19 +299,16 @@ function FullscreenModal({
301
299
  // the keyboard is already open (default 'never' eats that tap).
302
300
  ,
303
301
  keyboardShouldPersistTaps: "handled",
304
- children: [/*#__PURE__*/_jsx(View, {
305
- style: heroTextRegionStyle,
306
- children: /*#__PURE__*/_jsx(HeroText, {
307
- eyebrow: eyebrow,
308
- headline: headline,
309
- supportingText: supportingText,
310
- priceText: priceText,
311
- modes: modes
312
- })
313
- }), /*#__PURE__*/_jsx(View, {
314
- style: bodyStyle,
315
- children: processedChildren
316
- })]
302
+ children: /*#__PURE__*/_jsxs(View, {
303
+ style: foregroundFlowStyle,
304
+ children: [/*#__PURE__*/_jsx(View, {
305
+ style: heroTextRegionStyle,
306
+ children: heroTextNode
307
+ }), processedChildren ? /*#__PURE__*/_jsx(View, {
308
+ style: bodyStyle,
309
+ children: processedChildren
310
+ }) : null]
311
+ })
317
312
  }), footerContent ? /*#__PURE__*/_jsx(ActionFooter, {
318
313
  modes: modes,
319
314
  children: footerContent
@@ -349,4 +344,19 @@ const closeButtonStyle = {
349
344
  top: 12,
350
345
  right: 12
351
346
  };
347
+ // Root-level full-bleed background media. Pinned to the top at full modal
348
+ // width; the media inside keeps its own natural aspect ratio (only `top` is
349
+ // pinned — no `bottom`/`overflow` clip), so it is NEVER cut to the content
350
+ // height and fills the surface behind the scrolling content and the footer.
351
+ // Living outside the ScrollView, it adds nothing to the scroll height.
352
+ const heroBackgroundStyle = {
353
+ position: 'absolute',
354
+ top: 0,
355
+ left: 0,
356
+ right: 0
357
+ };
358
+ // The foreground always flows normally — its content drives the scroll height.
359
+ const foregroundFlowStyle = {
360
+ width: '100%'
361
+ };
352
362
  export default FullscreenModal;