jfs-components 0.0.84 → 0.0.85

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 (43) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/lib/commonjs/components/AppBar/AppBar.js +36 -22
  3. package/lib/commonjs/components/AreaLineChart/AreaLineChart.js +866 -0
  4. package/lib/commonjs/components/AreaLineChart/chartMath.js +252 -0
  5. package/lib/commonjs/components/Attached/Attached.js +34 -4
  6. package/lib/commonjs/components/BubbleChart/BubbleChart.js +191 -0
  7. package/lib/commonjs/components/BubbleChart/bubblePacking.js +378 -0
  8. package/lib/commonjs/components/ClusterBubble/ClusterBubble.js +272 -0
  9. package/lib/commonjs/components/MetricLegendItem/MetricLegendItem.js +7 -1
  10. package/lib/commonjs/components/index.js +27 -0
  11. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  12. package/lib/commonjs/icons/registry.js +1 -1
  13. package/lib/module/components/AppBar/AppBar.js +36 -22
  14. package/lib/module/components/AreaLineChart/AreaLineChart.js +859 -0
  15. package/lib/module/components/AreaLineChart/chartMath.js +242 -0
  16. package/lib/module/components/Attached/Attached.js +34 -4
  17. package/lib/module/components/BubbleChart/BubbleChart.js +185 -0
  18. package/lib/module/components/BubbleChart/bubblePacking.js +370 -0
  19. package/lib/module/components/ClusterBubble/ClusterBubble.js +267 -0
  20. package/lib/module/components/MetricLegendItem/MetricLegendItem.js +7 -1
  21. package/lib/module/components/index.js +3 -0
  22. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  23. package/lib/module/icons/registry.js +1 -1
  24. package/lib/typescript/src/components/AreaLineChart/AreaLineChart.d.ts +212 -0
  25. package/lib/typescript/src/components/AreaLineChart/chartMath.d.ts +90 -0
  26. package/lib/typescript/src/components/BubbleChart/BubbleChart.d.ts +81 -0
  27. package/lib/typescript/src/components/BubbleChart/bubblePacking.d.ts +83 -0
  28. package/lib/typescript/src/components/ClusterBubble/ClusterBubble.d.ts +76 -0
  29. package/lib/typescript/src/components/MetricLegendItem/MetricLegendItem.d.ts +7 -1
  30. package/lib/typescript/src/components/index.d.ts +3 -0
  31. package/lib/typescript/src/icons/registry.d.ts +1 -1
  32. package/package.json +1 -1
  33. package/src/components/AppBar/AppBar.tsx +37 -24
  34. package/src/components/AreaLineChart/AreaLineChart.tsx +1161 -0
  35. package/src/components/AreaLineChart/chartMath.ts +265 -0
  36. package/src/components/Attached/Attached.tsx +36 -5
  37. package/src/components/BubbleChart/BubbleChart.tsx +319 -0
  38. package/src/components/BubbleChart/bubblePacking.ts +397 -0
  39. package/src/components/ClusterBubble/ClusterBubble.tsx +359 -0
  40. package/src/components/MetricLegendItem/MetricLegendItem.tsx +20 -6
  41. package/src/components/index.ts +3 -0
  42. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  43. 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
@@ -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
  />
@@ -110,6 +110,9 @@ export { default as RadioButton, type RadioButtonProps } from './RadioButton/Rad
110
110
  export { default as RechargeCard, type RechargeCardProps } from './RechargeCard/RechargeCard';
111
111
  export { default as SavingsGoalSummary, type SavingsGoalSummaryProps, type SavingsGoalSummaryItem } from './SavingsGoalSummary/SavingsGoalSummary';
112
112
  export { default as DonutChart, type DonutChartProps, type DonutChartSegmentData, type DonutChartSegmentProps, DonutChartSegment } from './DonutChart/DonutChart';
113
+ export { default as AreaLineChart, useChart, type AreaLineChartProps, type ChartSeries, type ChartPoint, type ChartInset, type GoalPinConfig, type ChartGridProps, type ChartXAxisProps, type ChartYAxisProps, type ChartGoalPinProps } from './AreaLineChart/AreaLineChart';
114
+ export { default as ClusterBubble, type ClusterBubbleProps, type ClusterBubbleLabelPlacement, type ClusterBubbleLabelDirection } from './ClusterBubble/ClusterBubble';
115
+ export { default as BubbleChart, type BubbleChartProps, type BubbleDatum } from './BubbleChart/BubbleChart';
113
116
  export { default as DonutChartSummary, type DonutChartSummaryProps, type DonutChartSummaryItem } from './DonutChartSummary/DonutChartSummary';
114
117
  export { default as RangeTrack, type RangeTrackProps, type RangeTrackTab, type RangeTrackItem } from './RangeTrack/RangeTrack';
115
118
  export { default as SegmentedTrack, type SegmentedTrackProps, type SegmentedTrackSegmentData, type SegmentedTrackSegmentProps, SegmentedTrackSegment } from './SegmentedTrack/SegmentedTrack';