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
package/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ 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.95] - 2026-06-04
8
+
9
+ - Added `Icon` — token-driven design-system icon primitive (`iconName`, `source`, `children` slot); exported from the package barrel.
10
+ - `FullscreenModal` — `heroMedia` is now a full-bleed continuous background behind hero + body; foreground scrolls over it; defaults `Page type` to `JioPlus`; transparent body (removed solid `backgroundColor`).
11
+ - Added `src/assets.d.ts` for TypeScript `require()` of image assets.
12
+
13
+ ---
14
+
15
+ ## [0.0.86] - 2026-06-04
16
+
17
+ - Added `AllocationComparisonChart` — vertical pill bars comparing current vs recommended allocation with optional baseline overlay and dashed marker.
18
+ - `FullscreenModal` — removed parallax; hero media scrolls with content and height follows media aspect ratio.
19
+
20
+ ---
21
+
7
22
  ## [0.0.85] - 2026-06-03
8
23
 
9
24
  - 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 @@
1
+ "use strict";
@@ -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,24 @@ 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;
36
+ // ---------------------------------------------------------------------------
37
+ // Default modes
38
+ //
39
+ // A FullscreenModal is a "JioPlus" surface, so it defaults the `Page type`
40
+ // collection to `'JioPlus'`. Unlike the forced modes above this IS
41
+ // overridable it is applied before the caller's `modes`, so passing
42
+ // `modes={{ 'Page type': 'SubPage' }}` still wins. Frozen for stable identity
43
+ // (keeps the token resolver's per-modes cache hot).
44
+ // ---------------------------------------------------------------------------
45
+ const FULLSCREEN_MODAL_DEFAULT_MODES = Object.freeze({
46
+ 'Page type': 'JioPlus'
47
+ });
47
48
 
48
49
  // ---------------------------------------------------------------------------
49
50
  // Hero text — the eyebrow / headline / supporting / price block. Built inline
50
51
  // (rather than reusing <PageHero>) so we can render BOTH a supporting
51
52
  // 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.
53
+ // it on the hero media without PageHero's media/button scaffolding.
53
54
  // ---------------------------------------------------------------------------
54
55
 
55
56
  function HeroText({
@@ -134,8 +135,9 @@ function HeroText({
134
135
  }
135
136
 
136
137
  /**
137
- * FullscreenModal — a full-screen takeover surface with a parallax media hero,
138
- * a scrollable body, a floating close button, and a sticky `ActionFooter`.
138
+ * FullscreenModal — a full-screen takeover surface with a full-bleed media
139
+ * hero, a scrollable body, a floating close button, and a sticky
140
+ * `ActionFooter`.
139
141
  *
140
142
  * The component always themes itself with `context5: 'Fullscreen Modal'`
141
143
  * (non-overridable) so every nested component (Section, ListItem, Button,
@@ -143,14 +145,21 @@ function HeroText({
143
145
  * That mode is cascaded into `children`, the footer, and the hero text via
144
146
  * `cloneChildrenWithModes` / the merged `modes` object.
145
147
  *
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}`.
148
+ * ### Background media
149
+ * The `heroMedia` is a single full-bleed background pinned to the top of the
150
+ * modal at the full width and its own natural aspect ratio. It lives at the
151
+ * ROOT behind both the scrolling content and the (transparent) footer so
152
+ * it fills the whole surface and is NEVER clipped to the content height. It
153
+ * also contributes ZERO scroll height: the scroll extent is driven purely by
154
+ * the in-flow foreground (hero text + `children`), so the number of body
155
+ * elements dictates how far the surface scrolls. It still scrolls in lockstep
156
+ * WITH the content (the background is translated by the scroll offset), so the
157
+ * content reads as sitting ON one continuous image that moves with it — there
158
+ * is no parallax and no separate solid body box.
159
+ *
160
+ * Pass a background sized to the full width at its natural ratio
161
+ * (e.g. `<Image imageSource={bg} ratio={1080 / 4140} />`). Use an asset at
162
+ * least as tall as the surface so it covers the full modal.
154
163
  *
155
164
  * @component
156
165
  * @example
@@ -160,7 +169,7 @@ function HeroText({
160
169
  * headline="Get more from your money."
161
170
  * supportingText="JioFinance+ is your upgraded financial experience…"
162
171
  * priceText="₹999/year · ₹0 until 2027"
163
- * heroMedia={<LottiePlayer source={hero} size={{ width: 360, height: 420 }} />}
172
+ * heroMedia={<Image imageSource={hero} ratio={1080 / 4140} />}
164
173
  * primaryActionLabel="Upgrade for free"
165
174
  * disclaimer="By upgrading, we'll check your eligibility with Experian."
166
175
  * onPrimaryAction={() => upgrade()}
@@ -178,8 +187,6 @@ function FullscreenModal({
178
187
  priceText = '₹999/year · ₹0 until 2027',
179
188
  heroMedia,
180
189
  heroHeight = 420,
181
- heroMinHeight,
182
- parallax = true,
183
190
  showClose = true,
184
191
  onClose,
185
192
  closeAccessibilityLabel = 'Close',
@@ -187,7 +194,6 @@ function FullscreenModal({
187
194
  primaryActionLabel = 'Upgrade for free',
188
195
  onPrimaryAction,
189
196
  disclaimer = "By upgrading, we'll check your eligibility with Experian.",
190
- backgroundColor = '#0f0d0a',
191
197
  children,
192
198
  modes: propModes = _reactUtils.EMPTY_MODES,
193
199
  style,
@@ -198,76 +204,62 @@ function FullscreenModal({
198
204
  modes: globalModes
199
205
  } = (0, _JFSThemeProvider.useTokens)();
200
206
 
201
- // context5 is appended last so it always wins, regardless of what the
202
- // caller (or the global theme) passes.
207
+ // Merge order (low high priority):
208
+ // global theme → component defaults (Page type: JioPlus) → caller modes →
209
+ // forced modes (context5). So `Page type` defaults to JioPlus but the
210
+ // caller can override it, while `context5` always wins. This single `modes`
211
+ // object is what cascades to the body, hero media, and the ActionFooter.
203
212
  const modes = (0, _react.useMemo)(() => ({
204
213
  ...globalModes,
214
+ ...FULLSCREEN_MODAL_DEFAULT_MODES,
205
215
  ...propModes,
206
216
  ...FULLSCREEN_MODAL_FORCED_MODES
207
217
  }), [globalModes, propModes]);
208
218
  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
219
 
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
- });
220
+ // Drives the background's parallax-free sync with the scroll. The hero media
221
+ // lives at the ROOT (so it is never clipped to the content height and sits
222
+ // behind the transparent footer), but we translate it up by the exact scroll
223
+ // offset so it moves in lockstep with the content i.e. it scrolls WITH the
224
+ // body without ever contributing to the scroll height.
225
+ const scrollY = (0, _react.useRef)(new _reactNative.Animated.Value(0)).current;
226
+ const onScroll = (0, _react.useMemo)(() => _reactNative.Animated.event([{
227
+ nativeEvent: {
228
+ contentOffset: {
229
+ y: scrollY
230
+ }
231
+ }
232
+ }], {
233
+ useNativeDriver: true
234
+ }), [scrollY]);
235
+ const heroTranslateY = (0, _react.useMemo)(() => _reactNative.Animated.multiply(scrollY, -1), [scrollY]);
226
236
  const processedHeroMedia = (0, _react.useMemo)(() => heroMedia ? (0, _reactUtils.cloneChildrenWithModes)(heroMedia, modes, FULLSCREEN_MODAL_FORCED_MODES) : null, [heroMedia, modes]);
227
237
  const processedChildren = (0, _react.useMemo)(() => children ? (0, _reactUtils.cloneChildrenWithModes)(children, modes, FULLSCREEN_MODAL_FORCED_MODES) : null, [children, modes]);
228
238
 
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]);
239
+ // The hero text region always reserves `heroHeight` and anchors its content
240
+ // to the bottom, so the eyebrow/headline block sits in the lower part of the
241
+ // first screenful — over the background media when present, in flow
242
+ // otherwise.
250
243
  const heroTextRegionStyle = (0, _react.useMemo)(() => ({
251
- height: heroHeight,
244
+ minHeight: heroHeight,
252
245
  justifyContent: 'flex-end',
253
246
  paddingHorizontal: 16,
254
247
  paddingBottom: 16
255
248
  }), [heroHeight]);
249
+
250
+ // Body is intentionally transparent — the background media shows through
251
+ // behind it. There is no solid "body box" stacked on top of the image.
256
252
  const bodyStyle = (0, _react.useMemo)(() => [{
257
- backgroundColor,
258
253
  gap: rootGap,
259
254
  paddingTop: rootGap,
260
255
  paddingBottom: 24
261
- }, 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
- })
256
+ }, contentContainerStyle], [rootGap, contentContainerStyle]);
257
+ const heroTextNode = /*#__PURE__*/(0, _jsxRuntime.jsx)(HeroText, {
258
+ eyebrow: eyebrow,
259
+ headline: headline,
260
+ supportingText: supportingText,
261
+ priceText: priceText,
262
+ modes: modes
271
263
  });
272
264
 
273
265
  // Footer: a fully custom node, or the default Button + Disclaimer column.
@@ -292,11 +284,17 @@ function FullscreenModal({
292
284
  });
293
285
  }
294
286
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
295
- style: [rootStyle, {
296
- backgroundColor
297
- }, style],
287
+ style: [rootStyle, style],
298
288
  testID: testID,
299
- children: [processedHeroMedia ? heroClip : null, /*#__PURE__*/(0, _jsxRuntime.jsxs)(AnimatedScrollView, {
289
+ children: [processedHeroMedia ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Animated.View, {
290
+ style: [heroBackgroundStyle, {
291
+ transform: [{
292
+ translateY: heroTranslateY
293
+ }]
294
+ }],
295
+ pointerEvents: "none",
296
+ children: processedHeroMedia
297
+ }) : null, /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Animated.ScrollView, {
300
298
  style: scrollViewStyle,
301
299
  contentContainerStyle: scrollContentStyle,
302
300
  showsVerticalScrollIndicator: false,
@@ -306,19 +304,16 @@ function FullscreenModal({
306
304
  // the keyboard is already open (default 'never' eats that tap).
307
305
  ,
308
306
  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, {
319
- style: bodyStyle,
320
- children: processedChildren
321
- })]
307
+ children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
308
+ style: foregroundFlowStyle,
309
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
310
+ style: heroTextRegionStyle,
311
+ children: heroTextNode
312
+ }), processedChildren ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
313
+ style: bodyStyle,
314
+ children: processedChildren
315
+ }) : null]
316
+ })
322
317
  }), footerContent ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_ActionFooter.default, {
323
318
  modes: modes,
324
319
  children: footerContent
@@ -354,4 +349,19 @@ const closeButtonStyle = {
354
349
  top: 12,
355
350
  right: 12
356
351
  };
352
+ // Root-level full-bleed background media. Pinned to the top at full modal
353
+ // width; the media inside keeps its own natural aspect ratio (only `top` is
354
+ // pinned — no `bottom`/`overflow` clip), so it is NEVER cut to the content
355
+ // height and fills the surface behind the scrolling content and the footer.
356
+ // Living outside the ScrollView, it adds nothing to the scroll height.
357
+ const heroBackgroundStyle = {
358
+ position: 'absolute',
359
+ top: 0,
360
+ left: 0,
361
+ right: 0
362
+ };
363
+ // The foreground always flows normally — its content drives the scroll height.
364
+ const foregroundFlowStyle = {
365
+ width: '100%'
366
+ };
357
367
  var _default = exports.default = FullscreenModal;