jfs-components 0.0.84 → 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.
Files changed (51) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/lib/commonjs/components/AllocationComparisonChart/AllocationComparisonChart.js +299 -0
  3. package/lib/commonjs/components/AppBar/AppBar.js +36 -22
  4. package/lib/commonjs/components/AreaLineChart/AreaLineChart.js +866 -0
  5. package/lib/commonjs/components/AreaLineChart/chartMath.js +252 -0
  6. package/lib/commonjs/components/Attached/Attached.js +34 -4
  7. package/lib/commonjs/components/BubbleChart/BubbleChart.js +191 -0
  8. package/lib/commonjs/components/BubbleChart/bubblePacking.js +378 -0
  9. package/lib/commonjs/components/ClusterBubble/ClusterBubble.js +272 -0
  10. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +52 -89
  11. package/lib/commonjs/components/MetricLegendItem/MetricLegendItem.js +7 -1
  12. package/lib/commonjs/components/index.js +34 -0
  13. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  14. package/lib/commonjs/icons/registry.js +1 -1
  15. package/lib/module/components/AllocationComparisonChart/AllocationComparisonChart.js +293 -0
  16. package/lib/module/components/AppBar/AppBar.js +36 -22
  17. package/lib/module/components/AreaLineChart/AreaLineChart.js +859 -0
  18. package/lib/module/components/AreaLineChart/chartMath.js +242 -0
  19. package/lib/module/components/Attached/Attached.js +34 -4
  20. package/lib/module/components/BubbleChart/BubbleChart.js +185 -0
  21. package/lib/module/components/BubbleChart/bubblePacking.js +370 -0
  22. package/lib/module/components/ClusterBubble/ClusterBubble.js +267 -0
  23. package/lib/module/components/FullscreenModal/FullscreenModal.js +53 -90
  24. package/lib/module/components/MetricLegendItem/MetricLegendItem.js +7 -1
  25. package/lib/module/components/index.js +4 -0
  26. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  27. package/lib/module/icons/registry.js +1 -1
  28. package/lib/typescript/src/components/AllocationComparisonChart/AllocationComparisonChart.d.ts +118 -0
  29. package/lib/typescript/src/components/AreaLineChart/AreaLineChart.d.ts +212 -0
  30. package/lib/typescript/src/components/AreaLineChart/chartMath.d.ts +90 -0
  31. package/lib/typescript/src/components/BubbleChart/BubbleChart.d.ts +81 -0
  32. package/lib/typescript/src/components/BubbleChart/bubblePacking.d.ts +83 -0
  33. package/lib/typescript/src/components/ClusterBubble/ClusterBubble.d.ts +76 -0
  34. package/lib/typescript/src/components/FullscreenModal/FullscreenModal.d.ts +21 -25
  35. package/lib/typescript/src/components/MetricLegendItem/MetricLegendItem.d.ts +7 -1
  36. package/lib/typescript/src/components/index.d.ts +4 -0
  37. package/lib/typescript/src/icons/registry.d.ts +1 -1
  38. package/package.json +1 -1
  39. package/src/components/AllocationComparisonChart/AllocationComparisonChart.tsx +450 -0
  40. package/src/components/AppBar/AppBar.tsx +37 -24
  41. package/src/components/AreaLineChart/AreaLineChart.tsx +1161 -0
  42. package/src/components/AreaLineChart/chartMath.ts +265 -0
  43. package/src/components/Attached/Attached.tsx +36 -5
  44. package/src/components/BubbleChart/BubbleChart.tsx +319 -0
  45. package/src/components/BubbleChart/bubblePacking.ts +397 -0
  46. package/src/components/ClusterBubble/ClusterBubble.tsx +359 -0
  47. package/src/components/FullscreenModal/FullscreenModal.tsx +61 -119
  48. package/src/components/MetricLegendItem/MetricLegendItem.tsx +20 -6
  49. package/src/components/index.ts +4 -0
  50. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  51. package/src/icons/registry.ts +1 -1
@@ -0,0 +1,359 @@
1
+ import React, { useMemo, useState } from 'react'
2
+ import {
3
+ Pressable,
4
+ StyleSheet,
5
+ Text,
6
+ View,
7
+ type LayoutChangeEvent,
8
+ type StyleProp,
9
+ type TextStyle,
10
+ type ViewStyle,
11
+ } from 'react-native'
12
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
13
+ import { useTokens } from '../../design-tokens/JFSThemeProvider'
14
+ import { EMPTY_MODES } from '../../utils/react-utils'
15
+
16
+ /** Where the value/label text sits relative to the circle. */
17
+ export type ClusterBubbleLabelPlacement = 'inside' | 'outside' | 'auto'
18
+
19
+ /** Which side of the circle an *outside* label is anchored to. */
20
+ export type ClusterBubbleLabelDirection = 'top' | 'bottom' | 'left' | 'right'
21
+
22
+ export type ClusterBubbleProps = {
23
+ /**
24
+ * The bold, primary content rendered in/under the bubble — e.g. `"40%"`,
25
+ * `"₹270K"`. Strings are auto-wrapped in a `<Text>`; pass a node for full
26
+ * control (e.g. a `MoneyValue`).
27
+ */
28
+ value?: React.ReactNode
29
+ /** The secondary caption shown beside the value — e.g. `"Recommended"`. */
30
+ label?: React.ReactNode
31
+ /** Diameter of the circle in px. Defaults to `120`. */
32
+ size?: number
33
+ /**
34
+ * `Appearance / DataViz` mode used to resolve the fill from the
35
+ * `dataViz/bg` token (e.g. `Primary`, `Secondary`, `Tertiary`).
36
+ * Defaults to `Primary`. The *emphasis* of the fill is taken from the
37
+ * `Emphasis / DataViz` mode in `modes`.
38
+ */
39
+ appearance?: string
40
+ /** Hard-override the circle fill color (bypasses token resolution). */
41
+ color?: string
42
+ /**
43
+ * Where the text sits. `inside` centers it within the circle, `outside`
44
+ * anchors it just beyond the circle's edge, and `auto` (default) picks
45
+ * `inside` when the bubble is at least `autoInsideMinSize` px, otherwise
46
+ * `outside`.
47
+ */
48
+ labelPlacement?: ClusterBubbleLabelPlacement
49
+ /**
50
+ * Which side an *outside* label is placed on. The label is positioned
51
+ * exactly `labelGap` px beyond the circle's radius in this direction.
52
+ * Defaults to `bottom`.
53
+ */
54
+ labelDirection?: ClusterBubbleLabelDirection
55
+ /** Gap in px between the circle's edge and an outside label. Defaults to `8`. */
56
+ labelGap?: number
57
+ /** Diameter (px) at/above which `auto` places the text inside. Defaults to `88`. */
58
+ autoInsideMinSize?: number
59
+ /**
60
+ * Text color when the label sits *inside*. Defaults to an automatic
61
+ * black/white choice based on the fill's luminance for legibility.
62
+ */
63
+ insideTextColor?: string
64
+ /** Press handler — wraps the bubble in a `Pressable` when provided. */
65
+ onPress?: () => void
66
+ /** Style override for the value text. */
67
+ valueStyle?: StyleProp<TextStyle>
68
+ /** Style override for the label text. */
69
+ labelStyle?: StyleProp<TextStyle>
70
+ /** Style override for the circle view. */
71
+ circleStyle?: StyleProp<ViewStyle>
72
+ /** Style override for the outer container. */
73
+ style?: StyleProp<ViewStyle>
74
+ /** Design token modes for theming (e.g. `{ 'Color Mode': 'Light' }`). */
75
+ modes?: Record<string, any>
76
+ /** Accessibility label. Defaults to a `value + label` composite. */
77
+ accessibilityLabel?: string
78
+ }
79
+
80
+ const DEFAULT_FILL = '#5d00b5'
81
+
82
+ /** Parse `#rgb`, `#rrggbb`, `rgb()` / `rgba()` into 0–255 channels. */
83
+ function parseColor(input: string): { r: number; g: number; b: number } | null {
84
+ if (typeof input !== 'string') return null
85
+ const value = input.trim()
86
+
87
+ if (value[0] === '#') {
88
+ let hex = value.slice(1)
89
+ if (hex.length === 3) {
90
+ hex = hex
91
+ .split('')
92
+ .map((ch) => ch + ch)
93
+ .join('')
94
+ }
95
+ if (hex.length >= 6) {
96
+ const r = parseInt(hex.slice(0, 2), 16)
97
+ const g = parseInt(hex.slice(2, 4), 16)
98
+ const b = parseInt(hex.slice(4, 6), 16)
99
+ if ([r, g, b].every((n) => Number.isFinite(n))) return { r, g, b }
100
+ }
101
+ return null
102
+ }
103
+
104
+ const match = value.match(/rgba?\(([^)]+)\)/i)
105
+ if (match) {
106
+ const parts = match[1].split(',').map((p) => parseFloat(p))
107
+ if (parts.length >= 3 && parts.slice(0, 3).every((n) => Number.isFinite(n))) {
108
+ return { r: parts[0], g: parts[1], b: parts[2] }
109
+ }
110
+ }
111
+ return null
112
+ }
113
+
114
+ /** Pick a legible foreground (near-black or white) for a given background. */
115
+ function readableTextColor(background: string): string {
116
+ const rgb = parseColor(background)
117
+ if (!rgb) return '#ffffff'
118
+ // Perceived luminance (ITU-R BT.601).
119
+ const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255
120
+ return luminance > 0.6 ? '#0f0d0a' : '#ffffff'
121
+ }
122
+
123
+ /**
124
+ * `ClusterBubble` is the atomic circle that composes a `BubbleChart`. It renders
125
+ * a single token-colored disc with a bold `value` and a secondary `label`. The
126
+ * text can sit inside the circle or anchor just outside its edge on any side
127
+ * (`labelDirection`) at a precise `labelGap` distance — so consumers (or the
128
+ * chart) can steer labels toward free space. The inside text color adapts to
129
+ * the fill for legibility. It is fully usable standalone.
130
+ *
131
+ * @component
132
+ */
133
+ function ClusterBubble({
134
+ value,
135
+ label,
136
+ size = 120,
137
+ appearance = 'Primary',
138
+ color,
139
+ labelPlacement = 'auto',
140
+ labelDirection = 'bottom',
141
+ labelGap = 8,
142
+ autoInsideMinSize = 88,
143
+ insideTextColor,
144
+ onPress,
145
+ valueStyle,
146
+ labelStyle,
147
+ circleStyle,
148
+ style,
149
+ modes: propModes = EMPTY_MODES,
150
+ accessibilityLabel,
151
+ }: ClusterBubbleProps) {
152
+ const { modes: globalModes } = useTokens()
153
+ const modes = useMemo(() => ({ ...globalModes, ...propModes }), [globalModes, propModes])
154
+
155
+ // Emphasis is read from the `Emphasis / DataViz` mode (defaulting to the
156
+ // token's own default) rather than a dedicated prop.
157
+ const fill = useMemo(() => {
158
+ if (color) return color
159
+ return (
160
+ (getVariableByName('dataViz/bg', {
161
+ ...modes,
162
+ 'Appearance / DataViz': appearance,
163
+ }) as string | null) ?? DEFAULT_FILL
164
+ )
165
+ }, [color, modes, appearance])
166
+
167
+ const fontFamily =
168
+ (getVariableByName('text/fontFamily', modes) as string | null) ?? 'JioType'
169
+ const outsideTextColor =
170
+ (getVariableByName('text/foreground', modes) as string | null) ?? '#0f0d0a'
171
+
172
+ const placement: 'inside' | 'outside' =
173
+ labelPlacement === 'auto'
174
+ ? size >= autoInsideMinSize
175
+ ? 'inside'
176
+ : 'outside'
177
+ : labelPlacement
178
+
179
+ // Measure the outside label so it can be anchored precisely on any side
180
+ // without guessing its dimensions.
181
+ const [labelSize, setLabelSize] = useState<{ w: number; h: number } | null>(null)
182
+ const handleLabelLayout = (e: LayoutChangeEvent) => {
183
+ const { width, height } = e.nativeEvent.layout
184
+ setLabelSize((prev) =>
185
+ prev && Math.abs(prev.w - width) < 0.5 && Math.abs(prev.h - height) < 0.5
186
+ ? prev
187
+ : { w: width, h: height }
188
+ )
189
+ }
190
+
191
+ // Default typography scales with the bubble when inside (so it fits the
192
+ // disc); fixed comfortable sizes when anchored outside.
193
+ const valueFontSize =
194
+ placement === 'inside' ? Math.round(Math.min(48, Math.max(13, size * 0.17))) : 24
195
+ const labelFontSize =
196
+ placement === 'inside' ? Math.round(Math.min(18, Math.max(10, size * 0.085))) : 14
197
+
198
+ const textColor =
199
+ placement === 'inside' ? insideTextColor ?? readableTextColor(fill) : outsideTextColor
200
+
201
+ const renderText = (
202
+ node: React.ReactNode,
203
+ baseStyle: TextStyle,
204
+ override: StyleProp<TextStyle>
205
+ ) => {
206
+ if (node === undefined || node === null || node === false) return null
207
+ if (typeof node === 'string' || typeof node === 'number') {
208
+ return (
209
+ <Text style={[baseStyle, override]} numberOfLines={2}>
210
+ {node}
211
+ </Text>
212
+ )
213
+ }
214
+ return node
215
+ }
216
+
217
+ const valueNode = renderText(
218
+ value,
219
+ {
220
+ color: textColor,
221
+ fontFamily,
222
+ fontSize: valueFontSize,
223
+ lineHeight: Math.round(valueFontSize * 1.15),
224
+ fontWeight: '700',
225
+ textAlign: 'center',
226
+ letterSpacing: -0.5,
227
+ },
228
+ valueStyle
229
+ )
230
+
231
+ const labelNode = renderText(
232
+ label,
233
+ {
234
+ color: textColor,
235
+ fontFamily,
236
+ fontSize: labelFontSize,
237
+ lineHeight: Math.round(labelFontSize * 1.3),
238
+ fontWeight: '400',
239
+ textAlign: 'center',
240
+ letterSpacing: -0.2,
241
+ },
242
+ labelStyle
243
+ )
244
+
245
+ const hasText = !!valueNode || !!labelNode
246
+
247
+ const textBlock = hasText ? (
248
+ <View style={styles.textBlock}>
249
+ {valueNode}
250
+ {labelNode}
251
+ </View>
252
+ ) : null
253
+
254
+ const derivedA11y = [value, label]
255
+ .filter((v) => typeof v === 'string' || typeof v === 'number')
256
+ .join(', ')
257
+ const a11yLabel = accessibilityLabel ?? (derivedA11y || undefined)
258
+
259
+ const circle = (
260
+ <View
261
+ style={[
262
+ styles.circle,
263
+ { width: size, height: size, borderRadius: size / 2, backgroundColor: fill },
264
+ circleStyle,
265
+ ]}
266
+ >
267
+ {placement === 'inside' ? textBlock : null}
268
+ </View>
269
+ )
270
+
271
+ let content: React.ReactNode
272
+ if (placement === 'inside' || !textBlock) {
273
+ content = <View style={[styles.inlineContainer, style]}>{circle}</View>
274
+ } else {
275
+ // Anchor the label exactly `labelGap` beyond the radius on the chosen
276
+ // side. Hidden until measured to avoid a positioning flash.
277
+ const offset = labelOffset(labelDirection, size, labelGap, labelSize)
278
+ content = (
279
+ <View style={[{ width: size, height: size }, style]}>
280
+ {circle}
281
+ <View
282
+ onLayout={handleLabelLayout}
283
+ style={[
284
+ styles.outsideLabel,
285
+ { left: offset.left, top: offset.top, opacity: labelSize ? 1 : 0 },
286
+ ]}
287
+ pointerEvents="none"
288
+ >
289
+ {textBlock}
290
+ </View>
291
+ </View>
292
+ )
293
+ }
294
+
295
+ if (onPress) {
296
+ return (
297
+ <Pressable
298
+ onPress={onPress}
299
+ accessibilityRole="button"
300
+ accessibilityLabel={a11yLabel}
301
+ style={({ pressed }) => (pressed ? styles.pressed : undefined)}
302
+ >
303
+ {content}
304
+ </Pressable>
305
+ )
306
+ }
307
+
308
+ return (
309
+ <View accessibilityRole="image" accessibilityLabel={a11yLabel}>
310
+ {content}
311
+ </View>
312
+ )
313
+ }
314
+
315
+ /** Compute the absolute `left/top` of the outside label box for a direction. */
316
+ function labelOffset(
317
+ direction: ClusterBubbleLabelDirection,
318
+ size: number,
319
+ gap: number,
320
+ labelSize: { w: number; h: number } | null
321
+ ): { left: number; top: number } {
322
+ const center = size / 2
323
+ const w = labelSize?.w ?? 0
324
+ const h = labelSize?.h ?? 0
325
+ switch (direction) {
326
+ case 'top':
327
+ return { left: center - w / 2, top: -(gap + h) }
328
+ case 'bottom':
329
+ return { left: center - w / 2, top: size + gap }
330
+ case 'left':
331
+ return { left: -(gap + w), top: center - h / 2 }
332
+ case 'right':
333
+ return { left: size + gap, top: center - h / 2 }
334
+ }
335
+ }
336
+
337
+ const styles = StyleSheet.create({
338
+ inlineContainer: {
339
+ alignItems: 'center',
340
+ },
341
+ circle: {
342
+ alignItems: 'center',
343
+ justifyContent: 'center',
344
+ overflow: 'hidden',
345
+ },
346
+ textBlock: {
347
+ alignItems: 'center',
348
+ justifyContent: 'center',
349
+ paddingHorizontal: 8,
350
+ },
351
+ outsideLabel: {
352
+ position: 'absolute',
353
+ },
354
+ pressed: {
355
+ opacity: 0.85,
356
+ },
357
+ })
358
+
359
+ export default ClusterBubble
@@ -3,17 +3,11 @@ import {
3
3
  View,
4
4
  Text,
5
5
  ScrollView,
6
+ StyleSheet,
6
7
  type StyleProp,
7
8
  type ViewStyle,
8
9
  type TextStyle,
9
10
  } from 'react-native'
10
- import Animated, {
11
- Extrapolation,
12
- interpolate,
13
- useAnimatedScrollHandler,
14
- useAnimatedStyle,
15
- useSharedValue,
16
- } from 'react-native-reanimated'
17
11
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
18
12
  import { useTokens } from '../../design-tokens/JFSThemeProvider'
19
13
  import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils'
@@ -37,17 +31,6 @@ import Slot from '../Slot/Slot'
37
31
  // ---------------------------------------------------------------------------
38
32
  const FULLSCREEN_MODAL_FORCED_MODES = Object.freeze({ context5: 'Fullscreen Modal' })
39
33
 
40
- // Reanimated-driven ScrollView so the parallax handler runs on the UI thread.
41
- // Module scope so the wrapped component identity is stable across renders.
42
- const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView)
43
-
44
- // Parallax tuning. The hero collapses by HEIGHT only as the user scrolls up —
45
- // its full width is preserved and the media keeps a fixed aspect ratio (it is
46
- // cropped, never scaled or squished, like a `cover` background). When no
47
- // explicit `heroMinHeight` is given, the hero collapses to this fraction of
48
- // its resting height.
49
- const HERO_MIN_HEIGHT_RATIO = 0.45
50
-
51
34
  export type FullscreenModalProps = {
52
35
  /** Small eyebrow line above the headline. */
53
36
  eyebrow?: string
@@ -58,23 +41,20 @@ export type FullscreenModalProps = {
58
41
  /** Secondary line below the supporting paragraph (e.g. a price / timeline). */
59
42
  priceText?: string
60
43
  /**
61
- * Media rendered full-bleed behind the hero text and driven by the parallax
62
- * scroll effect. Bring any renderer — most commonly a `LottiePlayer`, but an
63
- * `Image`, `Video`, or `SvgXml` works too. Size it to fill the hero box
64
- * (`heroHeight` tall, full modal width) and let it `cover` so that as the
65
- * hero collapses in height the art is cropped, never distorted. `modes` are
66
- * cascaded into it.
44
+ * Background media rendered full-bleed behind the hero text. Bring any
45
+ * renderer — most commonly an `Image`, but a `LottiePlayer`, `Video`, or
46
+ * `SvgXml` works too. It is laid out at the full modal width; size it with an
47
+ * aspect ratio (e.g. `<Image ratio={3 / 4} />`) so its height follows the
48
+ * width naturally. The media scrolls together with the rest of the content
49
+ * (no parallax). `modes` are cascaded into it.
67
50
  */
68
51
  heroMedia?: React.ReactNode
69
- /** Resting height of the hero region. Defaults to 420. */
70
- heroHeight?: number
71
52
  /**
72
- * Collapsed height the hero shrinks to at full scroll. Defaults to
73
- * `heroHeight * 0.45`. Only the height changes the width is always full.
53
+ * Fallback height for the hero text region when no `heroMedia` is provided.
54
+ * When `heroMedia` is present, the hero height is driven entirely by the
55
+ * media's own aspect ratio and this value is ignored. Defaults to 420.
74
56
  */
75
- heroMinHeight?: number
76
- /** Enable the scroll-driven hero collapse. Defaults to true. */
77
- parallax?: boolean
57
+ heroHeight?: number
78
58
  /** Whether to render the floating close button (top-right). Defaults to true. */
79
59
  showClose?: boolean
80
60
  /** Press handler for the close button. */
@@ -109,7 +89,7 @@ export type FullscreenModalProps = {
109
89
  // Hero text — the eyebrow / headline / supporting / price block. Built inline
110
90
  // (rather than reusing <PageHero>) so we can render BOTH a supporting
111
91
  // paragraph AND a price line with the exact PageHero token gaps, and overlay
112
- // it on the parallax media without PageHero's media/button scaffolding.
92
+ // it on the hero media without PageHero's media/button scaffolding.
113
93
  // ---------------------------------------------------------------------------
114
94
  type HeroTextProps = {
115
95
  eyebrow?: string
@@ -181,8 +161,9 @@ function HeroText({ eyebrow, headline, supportingText, priceText, modes }: HeroT
181
161
  }
182
162
 
183
163
  /**
184
- * FullscreenModal — a full-screen takeover surface with a parallax media hero,
185
- * a scrollable body, a floating close button, and a sticky `ActionFooter`.
164
+ * FullscreenModal — a full-screen takeover surface with a full-bleed media
165
+ * hero, a scrollable body, a floating close button, and a sticky
166
+ * `ActionFooter`.
186
167
  *
187
168
  * The component always themes itself with `context5: 'Fullscreen Modal'`
188
169
  * (non-overridable) so every nested component (Section, ListItem, Button,
@@ -190,14 +171,12 @@ function HeroText({ eyebrow, headline, supportingText, priceText, modes }: HeroT
190
171
  * That mode is cascaded into `children`, the footer, and the hero text via
191
172
  * `cloneChildrenWithModes` / the merged `modes` object.
192
173
  *
193
- * ### Parallax
194
- * As the user scrolls up, the hero collapses by **height only** (from
195
- * `heroHeight` to `heroMinHeight`) its **full width is always preserved**.
196
- * The `heroMedia` is pinned to the top at a fixed size and `cover`-cropped by
197
- * the collapsing clip, so it keeps a perfect aspect ratio the whole time
198
- * (never scaled or squished). Because it collapses slower than the content
199
- * scrolls, the media lags behind for the parallax depth cue. Disable with
200
- * `parallax={false}`.
174
+ * ### Hero
175
+ * The `heroMedia` is rendered full modal width inside the scroll body and
176
+ * takes its height from its own aspect ratio. The hero text (eyebrow /
177
+ * headline / supporting / price) is overlaid on top, anchored to the bottom.
178
+ * The whole hero scrolls together with the rest of the content — there is no
179
+ * parallax effect.
201
180
  *
202
181
  * @component
203
182
  * @example
@@ -207,7 +186,7 @@ function HeroText({ eyebrow, headline, supportingText, priceText, modes }: HeroT
207
186
  * headline="Get more from your money."
208
187
  * supportingText="JioFinance+ is your upgraded financial experience…"
209
188
  * priceText="₹999/year · ₹0 until 2027"
210
- * heroMedia={<LottiePlayer source={hero} size={{ width: 360, height: 420 }} />}
189
+ * heroMedia={<Image imageSource={hero} ratio={3 / 4} />}
211
190
  * primaryActionLabel="Upgrade for free"
212
191
  * disclaimer="By upgrading, we'll check your eligibility with Experian."
213
192
  * onPrimaryAction={() => upgrade()}
@@ -225,8 +204,6 @@ function FullscreenModal({
225
204
  priceText = '₹999/year · ₹0 until 2027',
226
205
  heroMedia,
227
206
  heroHeight = 420,
228
- heroMinHeight,
229
- parallax = true,
230
207
  showClose = true,
231
208
  onClose,
232
209
  closeAccessibilityLabel = 'Close',
@@ -252,28 +229,6 @@ function FullscreenModal({
252
229
 
253
230
  const rootGap = Number(getVariableByName('fullScreenModal/gap', modes)) || 16
254
231
 
255
- const minHeight = heroMinHeight ?? Math.round(heroHeight * HERO_MIN_HEIGHT_RATIO)
256
-
257
- const scrollY = useSharedValue(0)
258
- const onScroll = useAnimatedScrollHandler((event) => {
259
- scrollY.value = event.contentOffset.y
260
- })
261
-
262
- // Collapse the hero by HEIGHT only as the user scrolls up. The clip's width
263
- // never changes and the media inside is pinned full-size at the top, so the
264
- // art is cropped (cover) rather than scaled or narrowed — it keeps a perfect
265
- // aspect ratio the whole time. Pull-down (negative offset) is clamped, so the
266
- // hero never grows past its resting height.
267
- const heroAnimatedStyle = useAnimatedStyle(() => {
268
- const height = interpolate(
269
- scrollY.value,
270
- [0, heroHeight],
271
- [heroHeight, minHeight],
272
- Extrapolation.CLAMP
273
- )
274
- return { height }
275
- })
276
-
277
232
  const processedHeroMedia = useMemo(
278
233
  () =>
279
234
  heroMedia ? cloneChildrenWithModes(heroMedia, modes, FULLSCREEN_MODAL_FORCED_MODES) : null,
@@ -286,37 +241,11 @@ function FullscreenModal({
286
241
  [children, modes]
287
242
  )
288
243
 
289
- // The clip is full-width and top-pinned; its height is what animates. Width
290
- // is intentionally never animated.
291
- const heroClipBaseStyle = useMemo<ViewStyle>(
292
- () => ({
293
- position: 'absolute',
294
- top: 0,
295
- left: 0,
296
- right: 0,
297
- overflow: 'hidden',
298
- }),
299
- []
300
- )
301
-
302
- // The media sits at a fixed full-size box pinned to the top of the clip, so
303
- // the collapsing clip crops it from the bottom (cover) instead of resizing
304
- // it. Full width, fixed height — a perfect, constant aspect ratio.
305
- const heroMediaWrapStyle = useMemo<ViewStyle>(
244
+ // No-media fallback: without hero media the text region needs an explicit
245
+ // resting height (driven by `heroHeight`) so the hero still has presence.
246
+ const heroTextFallbackStyle = useMemo<ViewStyle>(
306
247
  () => ({
307
- position: 'absolute',
308
- top: 0,
309
- left: 0,
310
- right: 0,
311
- height: heroHeight,
312
- alignItems: 'stretch',
313
- }),
314
- [heroHeight]
315
- )
316
-
317
- const heroTextRegionStyle = useMemo<ViewStyle>(
318
- () => ({
319
- height: heroHeight,
248
+ minHeight: heroHeight,
320
249
  justifyContent: 'flex-end',
321
250
  paddingHorizontal: 16,
322
251
  paddingBottom: 16,
@@ -337,13 +266,29 @@ function FullscreenModal({
337
266
  [backgroundColor, rootGap, contentContainerStyle]
338
267
  )
339
268
 
340
- const heroClip = (
341
- <Animated.View
342
- style={[heroClipBaseStyle, parallax ? heroAnimatedStyle : { height: heroHeight }]}
343
- pointerEvents="none"
344
- >
345
- <View style={heroMediaWrapStyle}>{processedHeroMedia}</View>
346
- </Animated.View>
269
+ const heroTextNode = (
270
+ <HeroText
271
+ eyebrow={eyebrow}
272
+ headline={headline}
273
+ supportingText={supportingText}
274
+ priceText={priceText}
275
+ modes={modes}
276
+ />
277
+ )
278
+
279
+ // The hero scrolls inline with the body (no parallax). When media is present
280
+ // it is laid out full modal width and takes its height from its own aspect
281
+ // ratio; the hero text is overlaid on top, anchored to the bottom. Without
282
+ // media the text simply renders in flow at the fallback height.
283
+ const hero = processedHeroMedia ? (
284
+ <View style={heroMediaContainerStyle}>
285
+ {processedHeroMedia}
286
+ <View style={heroTextOverlayStyle} pointerEvents="box-none">
287
+ {heroTextNode}
288
+ </View>
289
+ </View>
290
+ ) : (
291
+ <View style={heroTextFallbackStyle}>{heroTextNode}</View>
347
292
  )
348
293
 
349
294
  // Footer: a fully custom node, or the default Button + Disclaimer column.
@@ -366,29 +311,17 @@ function FullscreenModal({
366
311
 
367
312
  return (
368
313
  <View style={[rootStyle, { backgroundColor }, style]} testID={testID}>
369
- {processedHeroMedia ? heroClip : null}
370
-
371
- <AnimatedScrollView
314
+ <ScrollView
372
315
  style={scrollViewStyle}
373
316
  contentContainerStyle={scrollContentStyle}
374
317
  showsVerticalScrollIndicator={false}
375
- onScroll={onScroll}
376
- scrollEventThrottle={16}
377
318
  // Tap an input in the body and it focuses on the FIRST tap, even when
378
319
  // the keyboard is already open (default 'never' eats that tap).
379
320
  keyboardShouldPersistTaps="handled"
380
321
  >
381
- <View style={heroTextRegionStyle}>
382
- <HeroText
383
- eyebrow={eyebrow}
384
- headline={headline}
385
- supportingText={supportingText}
386
- priceText={priceText}
387
- modes={modes}
388
- />
389
- </View>
322
+ {hero}
390
323
  <View style={bodyStyle}>{processedChildren}</View>
391
- </AnimatedScrollView>
324
+ </ScrollView>
392
325
 
393
326
  {footerContent ? (
394
327
  <ActionFooter modes={modes}>{footerContent}</ActionFooter>
@@ -413,5 +346,14 @@ const scrollViewStyle: ViewStyle = { flex: 1 }
413
346
  const scrollContentStyle: ViewStyle = { flexGrow: 1 }
414
347
  const fullWidthStyle: ViewStyle = { width: '100%' }
415
348
  const closeButtonStyle: ViewStyle = { position: 'absolute', top: 12, right: 12 }
349
+ // Full-width hero wrapper; height comes from the media's own aspect ratio.
350
+ const heroMediaContainerStyle: ViewStyle = { width: '100%', position: 'relative' }
351
+ // Hero text overlaid on the media, anchored to the bottom edge.
352
+ const heroTextOverlayStyle: ViewStyle = {
353
+ ...StyleSheet.absoluteFillObject,
354
+ justifyContent: 'flex-end',
355
+ paddingHorizontal: 16,
356
+ paddingBottom: 16,
357
+ }
416
358
 
417
359
  export default FullscreenModal
@@ -22,6 +22,12 @@ export type MetricLegendItemProps = {
22
22
  * `metricLegendItem/indicator/bg` design token.
23
23
  */
24
24
  indicatorColor?: string
25
+ /**
26
+ * Shape of the leading indicator. `'dot'` (default) renders the small
27
+ * circle used in categorical legends; `'line'` renders a short
28
+ * horizontal bar, matching the legend of a line chart.
29
+ */
30
+ indicatorShape?: 'dot' | 'line'
25
31
  /** Design token modes for theming (e.g. `{ 'Color Mode': 'Light' }`). */
26
32
  modes?: Record<string, any>
27
33
  /** Override container styles. */
@@ -46,6 +52,7 @@ function MetricLegendItem({
46
52
  label = 'Current (4 months)',
47
53
  value,
48
54
  indicatorColor,
55
+ indicatorShape = 'dot',
49
56
  modes = EMPTY_MODES,
50
57
  style,
51
58
  indicatorStyle,
@@ -107,12 +114,19 @@ function MetricLegendItem({
107
114
  >
108
115
  <View
109
116
  style={[
110
- {
111
- width: indicatorSize,
112
- height: indicatorSize,
113
- borderRadius: indicatorRadius,
114
- backgroundColor: indicatorBg,
115
- },
117
+ indicatorShape === 'line'
118
+ ? {
119
+ width: indicatorSize * 2,
120
+ height: Math.max(2, Math.round(indicatorSize / 4)),
121
+ borderRadius: indicatorRadius,
122
+ backgroundColor: indicatorBg,
123
+ }
124
+ : {
125
+ width: indicatorSize,
126
+ height: indicatorSize,
127
+ borderRadius: indicatorRadius,
128
+ backgroundColor: indicatorBg,
129
+ },
116
130
  indicatorStyle,
117
131
  ]}
118
132
  />