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.
- package/CHANGELOG.md +36 -0
- package/lib/commonjs/components/AllocationComparisonChart/AllocationComparisonChart.js +299 -0
- package/lib/commonjs/components/AppBar/AppBar.js +36 -22
- package/lib/commonjs/components/AreaLineChart/AreaLineChart.js +866 -0
- package/lib/commonjs/components/AreaLineChart/chartMath.js +252 -0
- package/lib/commonjs/components/Attached/Attached.js +34 -4
- package/lib/commonjs/components/BubbleChart/BubbleChart.js +191 -0
- package/lib/commonjs/components/BubbleChart/bubblePacking.js +378 -0
- package/lib/commonjs/components/ClusterBubble/ClusterBubble.js +272 -0
- package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +52 -89
- package/lib/commonjs/components/MetricLegendItem/MetricLegendItem.js +7 -1
- package/lib/commonjs/components/index.js +34 -0
- package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/commonjs/icons/registry.js +1 -1
- package/lib/module/components/AllocationComparisonChart/AllocationComparisonChart.js +293 -0
- package/lib/module/components/AppBar/AppBar.js +36 -22
- package/lib/module/components/AreaLineChart/AreaLineChart.js +859 -0
- package/lib/module/components/AreaLineChart/chartMath.js +242 -0
- package/lib/module/components/Attached/Attached.js +34 -4
- package/lib/module/components/BubbleChart/BubbleChart.js +185 -0
- package/lib/module/components/BubbleChart/bubblePacking.js +370 -0
- package/lib/module/components/ClusterBubble/ClusterBubble.js +267 -0
- package/lib/module/components/FullscreenModal/FullscreenModal.js +53 -90
- package/lib/module/components/MetricLegendItem/MetricLegendItem.js +7 -1
- package/lib/module/components/index.js +4 -0
- package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/module/icons/registry.js +1 -1
- package/lib/typescript/src/components/AllocationComparisonChart/AllocationComparisonChart.d.ts +118 -0
- package/lib/typescript/src/components/AreaLineChart/AreaLineChart.d.ts +212 -0
- package/lib/typescript/src/components/AreaLineChart/chartMath.d.ts +90 -0
- package/lib/typescript/src/components/BubbleChart/BubbleChart.d.ts +81 -0
- package/lib/typescript/src/components/BubbleChart/bubblePacking.d.ts +83 -0
- package/lib/typescript/src/components/ClusterBubble/ClusterBubble.d.ts +76 -0
- package/lib/typescript/src/components/FullscreenModal/FullscreenModal.d.ts +21 -25
- package/lib/typescript/src/components/MetricLegendItem/MetricLegendItem.d.ts +7 -1
- package/lib/typescript/src/components/index.d.ts +4 -0
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/AllocationComparisonChart/AllocationComparisonChart.tsx +450 -0
- package/src/components/AppBar/AppBar.tsx +37 -24
- package/src/components/AreaLineChart/AreaLineChart.tsx +1161 -0
- package/src/components/AreaLineChart/chartMath.ts +265 -0
- package/src/components/Attached/Attached.tsx +36 -5
- package/src/components/BubbleChart/BubbleChart.tsx +319 -0
- package/src/components/BubbleChart/bubblePacking.ts +397 -0
- package/src/components/ClusterBubble/ClusterBubble.tsx +359 -0
- package/src/components/FullscreenModal/FullscreenModal.tsx +61 -119
- package/src/components/MetricLegendItem/MetricLegendItem.tsx +20 -6
- package/src/components/index.ts +4 -0
- package/src/design-tokens/Coin Variables-variables-full.json +1 -1
- 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
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* `
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
-
*
|
|
73
|
-
* `
|
|
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
|
-
|
|
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
|
|
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
|
|
185
|
-
* a scrollable body, a floating close button, and a sticky
|
|
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
|
-
* ###
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
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={<
|
|
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
|
-
//
|
|
290
|
-
//
|
|
291
|
-
const
|
|
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
|
-
|
|
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
|
|
341
|
-
<
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
/>
|