jfs-components 0.0.72 → 0.0.74

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 (158) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/lib/commonjs/components/AccordionCheckbox/AccordionCheckbox.js +239 -0
  3. package/lib/commonjs/components/AccountCard/AccountCard.js +247 -0
  4. package/lib/commonjs/components/AppBar/AppBar.js +17 -11
  5. package/lib/commonjs/components/BrandChip/BrandChip.js +149 -0
  6. package/lib/commonjs/components/CardBankAccount/CardBankAccount.js +229 -0
  7. package/lib/commonjs/components/CardInsight/CardInsight.js +166 -0
  8. package/lib/commonjs/components/CheckboxGroup/CheckboxGroup.js +67 -0
  9. package/lib/commonjs/components/CheckboxItem/CheckboxItem.js +140 -0
  10. package/lib/commonjs/components/CircularProgressBar/CircularProgressBar.js +56 -9
  11. package/lib/commonjs/components/CoverageBarComparison/CoverageBarComparison.js +272 -0
  12. package/lib/commonjs/components/CoverageRing/CoverageRing.js +141 -0
  13. package/lib/commonjs/components/DonutChart/DonutChart.js +309 -0
  14. package/lib/commonjs/components/DonutChartSummary/DonutChartSummary.js +155 -0
  15. package/lib/commonjs/components/Dropdown/Dropdown.js +214 -0
  16. package/lib/commonjs/components/DropdownInput/DropdownInput.js +542 -0
  17. package/lib/commonjs/components/FormField/FormField.js +328 -178
  18. package/lib/commonjs/components/LinearMeter/LinearMeter.js +9 -28
  19. package/lib/commonjs/components/LinearProgress/LinearProgress.js +68 -0
  20. package/lib/commonjs/components/LottieIntroBlock/LottieIntroBlock.js +150 -0
  21. package/lib/commonjs/components/MetricLegendItem/MetricLegendItem.js +95 -0
  22. package/lib/commonjs/components/MonthlyStatusGrid/MonthlyStatusGrid.js +286 -0
  23. package/lib/commonjs/components/OTP/OTP.js +381 -37
  24. package/lib/commonjs/components/PageHero/PageHero.js +153 -0
  25. package/lib/commonjs/components/PoweredByLabel/PoweredByLabel.js +135 -0
  26. package/lib/commonjs/components/PoweredByLabel/finvu.png +0 -0
  27. package/lib/commonjs/components/ProductOverview/ProductOverview.js +147 -0
  28. package/lib/commonjs/components/RangeTrack/RangeTrack.js +269 -0
  29. package/lib/commonjs/components/SavingsGoalSummary/SavingsGoalSummary.js +181 -0
  30. package/lib/commonjs/components/SegmentedTrack/SegmentedTrack.js +171 -0
  31. package/lib/commonjs/components/StatGroup/StatGroup.js +128 -0
  32. package/lib/commonjs/components/StatItem/StatItem.js +65 -35
  33. package/lib/commonjs/components/StrengthIndicator/StrengthIndicator.js +157 -0
  34. package/lib/commonjs/components/SummaryTile/SummaryTile.js +150 -0
  35. package/lib/commonjs/components/Text/Text.js +9 -2
  36. package/lib/commonjs/components/Tooltip/Tooltip.js +34 -27
  37. package/lib/commonjs/components/index.js +231 -1
  38. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  39. package/lib/commonjs/icons/registry.js +1 -1
  40. package/lib/commonjs/utils/index.js +7 -0
  41. package/lib/commonjs/utils/number-utils.js +57 -0
  42. package/lib/module/components/AccordionCheckbox/AccordionCheckbox.js +233 -0
  43. package/lib/module/components/AccountCard/AccountCard.js +241 -0
  44. package/lib/module/components/AppBar/AppBar.js +17 -11
  45. package/lib/module/components/BrandChip/BrandChip.js +143 -0
  46. package/lib/module/components/CardBankAccount/CardBankAccount.js +223 -0
  47. package/lib/module/components/CardInsight/CardInsight.js +161 -0
  48. package/lib/module/components/CheckboxGroup/CheckboxGroup.js +62 -0
  49. package/lib/module/components/CheckboxItem/CheckboxItem.js +134 -0
  50. package/lib/module/components/CircularProgressBar/CircularProgressBar.js +56 -9
  51. package/lib/module/components/CoverageBarComparison/CoverageBarComparison.js +266 -0
  52. package/lib/module/components/CoverageRing/CoverageRing.js +136 -0
  53. package/lib/module/components/DonutChart/DonutChart.js +303 -0
  54. package/lib/module/components/DonutChartSummary/DonutChartSummary.js +150 -0
  55. package/lib/module/components/Dropdown/Dropdown.js +206 -0
  56. package/lib/module/components/DropdownInput/DropdownInput.js +536 -0
  57. package/lib/module/components/FormField/FormField.js +330 -180
  58. package/lib/module/components/LinearMeter/LinearMeter.js +9 -28
  59. package/lib/module/components/LinearProgress/LinearProgress.js +63 -0
  60. package/lib/module/components/LottieIntroBlock/LottieIntroBlock.js +144 -0
  61. package/lib/module/components/MetricLegendItem/MetricLegendItem.js +90 -0
  62. package/lib/module/components/MonthlyStatusGrid/MonthlyStatusGrid.js +281 -0
  63. package/lib/module/components/OTP/OTP.js +381 -38
  64. package/lib/module/components/PageHero/PageHero.js +147 -0
  65. package/lib/module/components/PoweredByLabel/PoweredByLabel.js +130 -0
  66. package/lib/module/components/PoweredByLabel/finvu.png +0 -0
  67. package/lib/module/components/ProductOverview/ProductOverview.js +142 -0
  68. package/lib/module/components/RangeTrack/RangeTrack.js +263 -0
  69. package/lib/module/components/SavingsGoalSummary/SavingsGoalSummary.js +175 -0
  70. package/lib/module/components/SegmentedTrack/SegmentedTrack.js +166 -0
  71. package/lib/module/components/StatGroup/StatGroup.js +123 -0
  72. package/lib/module/components/StatItem/StatItem.js +66 -36
  73. package/lib/module/components/StrengthIndicator/StrengthIndicator.js +152 -0
  74. package/lib/module/components/SummaryTile/SummaryTile.js +145 -0
  75. package/lib/module/components/Text/Text.js +9 -2
  76. package/lib/module/components/Tooltip/Tooltip.js +34 -27
  77. package/lib/module/components/index.js +28 -2
  78. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  79. package/lib/module/icons/registry.js +1 -1
  80. package/lib/module/utils/index.js +2 -1
  81. package/lib/module/utils/number-utils.js +53 -0
  82. package/lib/typescript/src/components/AccordionCheckbox/AccordionCheckbox.d.ts +71 -0
  83. package/lib/typescript/src/components/AccountCard/AccountCard.d.ts +81 -0
  84. package/lib/typescript/src/components/BrandChip/BrandChip.d.ts +43 -0
  85. package/lib/typescript/src/components/CardBankAccount/CardBankAccount.d.ts +86 -0
  86. package/lib/typescript/src/components/CardInsight/CardInsight.d.ts +48 -0
  87. package/lib/typescript/src/components/CheckboxGroup/CheckboxGroup.d.ts +41 -0
  88. package/lib/typescript/src/components/CheckboxItem/CheckboxItem.d.ts +72 -0
  89. package/lib/typescript/src/components/CircularProgressBar/CircularProgressBar.d.ts +11 -1
  90. package/lib/typescript/src/components/CoverageBarComparison/CoverageBarComparison.d.ts +105 -0
  91. package/lib/typescript/src/components/CoverageRing/CoverageRing.d.ts +90 -0
  92. package/lib/typescript/src/components/DonutChart/DonutChart.d.ts +117 -0
  93. package/lib/typescript/src/components/DonutChartSummary/DonutChartSummary.d.ts +103 -0
  94. package/lib/typescript/src/components/Dropdown/Dropdown.d.ts +62 -0
  95. package/lib/typescript/src/components/DropdownInput/DropdownInput.d.ts +107 -0
  96. package/lib/typescript/src/components/FormField/FormField.d.ts +76 -19
  97. package/lib/typescript/src/components/LinearProgress/LinearProgress.d.ts +17 -0
  98. package/lib/typescript/src/components/LottieIntroBlock/LottieIntroBlock.d.ts +58 -0
  99. package/lib/typescript/src/components/MetricLegendItem/MetricLegendItem.d.ts +37 -0
  100. package/lib/typescript/src/components/MonthlyStatusGrid/MonthlyStatusGrid.d.ts +119 -0
  101. package/lib/typescript/src/components/OTP/OTP.d.ts +88 -2
  102. package/lib/typescript/src/components/PageHero/PageHero.d.ts +53 -0
  103. package/lib/typescript/src/components/PoweredByLabel/PoweredByLabel.d.ts +70 -0
  104. package/lib/typescript/src/components/ProductOverview/ProductOverview.d.ts +39 -0
  105. package/lib/typescript/src/components/RangeTrack/RangeTrack.d.ts +173 -0
  106. package/lib/typescript/src/components/SavingsGoalSummary/SavingsGoalSummary.d.ts +95 -0
  107. package/lib/typescript/src/components/SegmentedTrack/SegmentedTrack.d.ts +108 -0
  108. package/lib/typescript/src/components/StatGroup/StatGroup.d.ts +45 -0
  109. package/lib/typescript/src/components/StatItem/StatItem.d.ts +24 -7
  110. package/lib/typescript/src/components/StrengthIndicator/StrengthIndicator.d.ts +58 -0
  111. package/lib/typescript/src/components/SummaryTile/SummaryTile.d.ts +60 -0
  112. package/lib/typescript/src/components/Text/Text.d.ts +12 -2
  113. package/lib/typescript/src/components/Tooltip/Tooltip.d.ts +13 -2
  114. package/lib/typescript/src/components/index.d.ts +29 -3
  115. package/lib/typescript/src/icons/registry.d.ts +1 -1
  116. package/lib/typescript/src/utils/index.d.ts +1 -0
  117. package/lib/typescript/src/utils/number-utils.d.ts +29 -0
  118. package/package.json +1 -3
  119. package/src/components/AccordionCheckbox/AccordionCheckbox.tsx +323 -0
  120. package/src/components/AccountCard/AccountCard.tsx +376 -0
  121. package/src/components/AppBar/AppBar.tsx +25 -14
  122. package/src/components/BrandChip/BrandChip.tsx +235 -0
  123. package/src/components/CardBankAccount/CardBankAccount.tsx +321 -0
  124. package/src/components/CardInsight/CardInsight.tsx +239 -0
  125. package/src/components/CheckboxGroup/CheckboxGroup.tsx +86 -0
  126. package/src/components/CheckboxItem/CheckboxItem.tsx +209 -0
  127. package/src/components/CircularProgressBar/CircularProgressBar.tsx +74 -9
  128. package/src/components/CoverageBarComparison/CoverageBarComparison.tsx +378 -0
  129. package/src/components/CoverageRing/CoverageRing.tsx +225 -0
  130. package/src/components/DonutChart/DonutChart.tsx +503 -0
  131. package/src/components/DonutChartSummary/DonutChartSummary.tsx +256 -0
  132. package/src/components/Dropdown/Dropdown.tsx +331 -0
  133. package/src/components/DropdownInput/DropdownInput.tsx +819 -0
  134. package/src/components/FormField/FormField.tsx +542 -215
  135. package/src/components/LinearMeter/LinearMeter.tsx +9 -39
  136. package/src/components/LinearProgress/LinearProgress.tsx +92 -0
  137. package/src/components/LottieIntroBlock/LottieIntroBlock.tsx +202 -0
  138. package/src/components/MetricLegendItem/MetricLegendItem.tsx +167 -0
  139. package/src/components/MonthlyStatusGrid/MonthlyStatusGrid.tsx +438 -0
  140. package/src/components/OTP/OTP.tsx +476 -29
  141. package/src/components/PageHero/PageHero.tsx +200 -0
  142. package/src/components/PoweredByLabel/PoweredByLabel.tsx +221 -0
  143. package/src/components/PoweredByLabel/finvu.png +0 -0
  144. package/src/components/ProductOverview/ProductOverview.tsx +236 -0
  145. package/src/components/RangeTrack/RangeTrack.tsx +394 -0
  146. package/src/components/SavingsGoalSummary/SavingsGoalSummary.tsx +269 -0
  147. package/src/components/SegmentedTrack/SegmentedTrack.tsx +268 -0
  148. package/src/components/StatGroup/StatGroup.tsx +169 -0
  149. package/src/components/StatItem/StatItem.tsx +117 -40
  150. package/src/components/StrengthIndicator/StrengthIndicator.tsx +205 -0
  151. package/src/components/SummaryTile/SummaryTile.tsx +251 -0
  152. package/src/components/Text/Text.tsx +24 -3
  153. package/src/components/Tooltip/Tooltip.tsx +50 -25
  154. package/src/components/index.ts +47 -3
  155. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  156. package/src/icons/registry.ts +1 -1
  157. package/src/utils/index.ts +1 -0
  158. package/src/utils/number-utils.ts +60 -0
@@ -0,0 +1,394 @@
1
+ import React, { useCallback, useState } from 'react'
2
+ import { View, type StyleProp, type ViewStyle } from 'react-native'
3
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
4
+ import { useTokens } from '../../design-tokens/JFSThemeProvider'
5
+ import { EMPTY_MODES } from '../../utils/react-utils'
6
+ import MetricLegendItem from '../MetricLegendItem/MetricLegendItem'
7
+ import SegmentedTrack, {
8
+ type SegmentedTrackSegmentData,
9
+ } from '../SegmentedTrack/SegmentedTrack'
10
+ import Tabs from '../Tabs/Tabs'
11
+ import TabItem from '../Tabs/TabItem'
12
+
13
+ /**
14
+ * One row of data inside a `RangeTrack` tab.
15
+ *
16
+ * Each item drives BOTH a `SegmentedTrack` segment and the matching
17
+ * `MetricLegendItem` row, so the segment color and legend indicator
18
+ * always share the same color by construction (same `Emphasis / DataViz`
19
+ * cascade as the standalone `SegmentedTrack`).
20
+ */
21
+ export type RangeTrackItem = {
22
+ /** Stable React key. Falls back to the item label / index. */
23
+ key?: React.Key
24
+ /** The descriptive label rendered next to the indicator dot. */
25
+ label?: React.ReactNode
26
+ /**
27
+ * The data point — drives the segment's proportional width. Same
28
+ * semantics as `SegmentedTrackSegmentData.value`. When the legend's
29
+ * right-side text is shown, the resolution order is:
30
+ * `displayValue` → `formatValue(value)` → hide.
31
+ */
32
+ value: number
33
+ /**
34
+ * Optional override for the legend row's right-side text. Falls
35
+ * back to `formatValue(value)` if a parent-level `formatValue` is
36
+ * provided; otherwise the value slot is hidden (matches the
37
+ * `data` boolean toggle on `MetricLegendItem`). Pass `null` or
38
+ * `false` to explicitly hide on a per-item basis.
39
+ */
40
+ displayValue?: React.ReactNode
41
+ /**
42
+ * Hard-override the shared color used for both the segment and the
43
+ * legend indicator. Bypasses `dataViz/bg` token resolution.
44
+ */
45
+ color?: string
46
+ /**
47
+ * Per-item design token mode overrides. Merged on top of parent
48
+ * `modes` and the per-index `Emphasis / DataViz` defaults
49
+ * (`High`, `Medium`, `Low`, then cycles).
50
+ */
51
+ modes?: Record<string, any>
52
+ /** Accessibility label for the segment + legend row pairing. */
53
+ accessibilityLabel?: string
54
+ }
55
+
56
+ /**
57
+ * One tab inside a `RangeTrack`. Each tab carries its own `items`
58
+ * array, which is the single source of truth for both the
59
+ * `SegmentedTrack` segments AND the `MetricLegendItem` legend rows
60
+ * rendered for that tab.
61
+ */
62
+ export type RangeTrackTab = {
63
+ /** Stable React key. Falls back to the tab label / index. */
64
+ key?: React.Key
65
+ /** The label shown inside the `TabItem` at the top of the component. */
66
+ label: string
67
+ /**
68
+ * Data-driven rows. Each entry produces one `SegmentedTrack`
69
+ * segment AND one matching `MetricLegendItem` legend row — the
70
+ * count of legend rows is locked in step with the count of segments
71
+ * by construction.
72
+ */
73
+ items: RangeTrackItem[]
74
+ /**
75
+ * Per-tab accessibility label for the tab item button. Falls back
76
+ * to `label`.
77
+ */
78
+ accessibilityLabel?: string
79
+ }
80
+
81
+ export type RangeTrackProps = {
82
+ /**
83
+ * Tabs to render across the top. Each tab carries its own `items`
84
+ * array, which is the single source of truth for that tab's
85
+ * segments and legend rows. Switching tabs swaps both the segments
86
+ * and the legend together.
87
+ */
88
+ tabs: RangeTrackTab[]
89
+ /**
90
+ * Optional formatter applied to each item's numeric `value` when
91
+ * the item does not provide an explicit `displayValue`. Useful for
92
+ * the common "format every weight the same way" case
93
+ * (e.g. `(v) => `${v}%``). Applies to the legend rows of the
94
+ * currently active tab.
95
+ */
96
+ formatValue?: (value: number) => React.ReactNode
97
+ /**
98
+ * Controlled active tab index. When provided, the component is
99
+ * fully controlled and `onTabChange` should be wired up. When
100
+ * `undefined`, the component manages the active tab internally
101
+ * starting from `defaultActiveTabIndex`.
102
+ */
103
+ activeTabIndex?: number
104
+ /**
105
+ * Initial active tab index when the component is uncontrolled.
106
+ * Ignored if `activeTabIndex` is provided.
107
+ * @default 0
108
+ */
109
+ defaultActiveTabIndex?: number
110
+ /**
111
+ * Fired when the user taps a tab. Receives the new index and the
112
+ * matching `RangeTrackTab` from the `tabs` array.
113
+ */
114
+ onTabChange?: (index: number, tab: RangeTrackTab) => void
115
+ /**
116
+ * When true, the tab row scrolls horizontally — useful when there
117
+ * are many tabs.
118
+ * @default false
119
+ */
120
+ scrollableTabs?: boolean
121
+ /** Design token modes for theming (e.g. `{ 'Color Mode': 'Light' }`). */
122
+ modes?: Record<string, any>
123
+ /** Override the outer container styles. */
124
+ style?: StyleProp<ViewStyle>
125
+ /** Override the tab row styles. */
126
+ tabsStyle?: StyleProp<ViewStyle>
127
+ /** Override the `SegmentedTrack` styles. */
128
+ trackStyle?: StyleProp<ViewStyle>
129
+ /** Override the legend list container styles. */
130
+ legendStyle?: StyleProp<ViewStyle>
131
+ /** Accessibility label announced for the whole component. */
132
+ accessibilityLabel?: string
133
+ }
134
+
135
+ const DEFAULT_EMPHASIS_CYCLE = ['High', 'Medium', 'Low'] as const
136
+
137
+ const DEFAULT_TABS: RangeTrackTab[] = [
138
+ {
139
+ label: 'Tab item',
140
+ items: [
141
+ { label: 'Large cap', value: 1, displayValue: '25%' },
142
+ { label: 'Mid cap', value: 1, displayValue: '25%' },
143
+ { label: 'Small cap', value: 1, displayValue: '25%' },
144
+ ],
145
+ },
146
+ {
147
+ label: 'Tab item',
148
+ items: [
149
+ { label: 'Large cap', value: 1, displayValue: '25%' },
150
+ { label: 'Mid cap', value: 1, displayValue: '25%' },
151
+ { label: 'Small cap', value: 1, displayValue: '25%' },
152
+ ],
153
+ },
154
+ {
155
+ label: 'Tab item',
156
+ items: [
157
+ { label: 'Large cap', value: 1, displayValue: '25%' },
158
+ { label: 'Mid cap', value: 1, displayValue: '25%' },
159
+ { label: 'Small cap', value: 1, displayValue: '25%' },
160
+ ],
161
+ },
162
+ ]
163
+
164
+ const defaultEmphasisFor = (index: number) =>
165
+ DEFAULT_EMPHASIS_CYCLE[index % DEFAULT_EMPHASIS_CYCLE.length]
166
+
167
+ /**
168
+ * Resolve the shared color for an item. Honors any explicit `color`
169
+ * override, then falls back to `dataViz/bg` for the merged mode set,
170
+ * then to the Figma reference value.
171
+ *
172
+ * Mirrors the cascade used by `SegmentedTrack` (per-index `Emphasis /
173
+ * DataViz` defaults of `High` → `Medium` → `Low`, cycling) so the
174
+ * pre-computed color we pass to both the segment and the legend
175
+ * indicator matches what the `SegmentedTrack` would have computed on
176
+ * its own. This is what keeps segments and legend rows in sync by
177
+ * construction.
178
+ */
179
+ function resolveItemColor(
180
+ parentModes: Record<string, any>,
181
+ item: RangeTrackItem,
182
+ index: number
183
+ ): string {
184
+ if (item.color) return item.color
185
+ const itemModes = {
186
+ ...parentModes,
187
+ 'Emphasis / DataViz': defaultEmphasisFor(index),
188
+ ...(item.modes || {}),
189
+ }
190
+ return (
191
+ (getVariableByName('dataViz/bg', itemModes) as string | null) ?? '#cea15a'
192
+ )
193
+ }
194
+
195
+ /**
196
+ * Resolve what to render in the legend row's right-side slot. The
197
+ * order of precedence is:
198
+ * 1. `item.displayValue` if explicitly provided (including `null` /
199
+ * `false`, which the underlying `MetricLegendItem` treats as
200
+ * "hide the value slot").
201
+ * 2. `formatValue(item.value)` when a parent-level formatter exists.
202
+ * 3. `undefined` — the value slot is hidden.
203
+ */
204
+ function resolveLegendValue(
205
+ item: RangeTrackItem,
206
+ formatValue: ((value: number) => React.ReactNode) | undefined
207
+ ): React.ReactNode {
208
+ if (item.displayValue !== undefined) return item.displayValue
209
+ if (formatValue) return formatValue(item.value)
210
+ return undefined
211
+ }
212
+
213
+ /**
214
+ * `RangeTrack` pairs a tab row with a `SegmentedTrack` and a vertical
215
+ * stack of `MetricLegendItem` rows. Each tab carries its own `items`
216
+ * array which is the **single source of truth** for both the segments
217
+ * and the legend rows of that tab — every segment has exactly one
218
+ * legend row and they share the same color through the same
219
+ * `Emphasis / DataViz` cascade as the standalone `SegmentedTrack`.
220
+ *
221
+ * Switching tabs swaps the segments and the legend together so the
222
+ * two visualizations can never drift out of sync.
223
+ *
224
+ * The default 3-item layout per tab receives per-index
225
+ * `Emphasis / DataViz` defaults (item 1 → `High`, 2 → `Medium`, 3 →
226
+ * `Low`, then cycles). Override `Appearance / DataViz` on the parent
227
+ * `modes` to retheme the whole component, or set `modes` per item to
228
+ * remix.
229
+ *
230
+ * The component supports both **controlled** and **uncontrolled**
231
+ * tab selection — pass `activeTabIndex` + `onTabChange` for the
232
+ * controlled mode, or omit them and the component will manage the
233
+ * selection internally starting from `defaultActiveTabIndex`.
234
+ *
235
+ * @component
236
+ * @param {RangeTrackProps} props
237
+ *
238
+ * @example
239
+ * ```tsx
240
+ * <RangeTrack
241
+ * tabs={[
242
+ * {
243
+ * label: 'Sectoral',
244
+ * items: [
245
+ * { label: 'Large cap', value: 50, displayValue: '50%' },
246
+ * { label: 'Mid cap', value: 30, displayValue: '30%' },
247
+ * { label: 'Small cap', value: 20, displayValue: '20%' },
248
+ * ],
249
+ * },
250
+ * {
251
+ * label: 'Geography',
252
+ * items: [
253
+ * { label: 'India', value: 70, displayValue: '70%' },
254
+ * { label: 'US', value: 20, displayValue: '20%' },
255
+ * { label: 'Other', value: 10, displayValue: '10%' },
256
+ * ],
257
+ * },
258
+ * ]}
259
+ * />
260
+ * ```
261
+ */
262
+ function RangeTrack({
263
+ tabs,
264
+ formatValue,
265
+ activeTabIndex,
266
+ defaultActiveTabIndex = 0,
267
+ onTabChange,
268
+ scrollableTabs = false,
269
+ modes: propModes = EMPTY_MODES,
270
+ style,
271
+ tabsStyle,
272
+ trackStyle,
273
+ legendStyle,
274
+ accessibilityLabel,
275
+ }: RangeTrackProps) {
276
+ const { modes: globalModes } = useTokens()
277
+ const modes = { ...globalModes, ...propModes }
278
+
279
+ const resolvedTabs = tabs && tabs.length > 0 ? tabs : DEFAULT_TABS
280
+
281
+ const [internalIndex, setInternalIndex] = useState(() =>
282
+ clampIndex(defaultActiveTabIndex, resolvedTabs.length)
283
+ )
284
+
285
+ const isControlled = activeTabIndex !== undefined
286
+ const rawIndex = isControlled ? activeTabIndex : internalIndex
287
+ const safeIndex = clampIndex(rawIndex, resolvedTabs.length)
288
+
289
+ const handleTabPress = useCallback(
290
+ (index: number) => {
291
+ const nextTab = resolvedTabs[index]
292
+ if (!nextTab) return
293
+ if (!isControlled) setInternalIndex(index)
294
+ onTabChange?.(index, nextTab)
295
+ },
296
+ [isControlled, onTabChange, resolvedTabs]
297
+ )
298
+
299
+ const containerGap =
300
+ (getVariableByName('rangeTrack/gap', modes) as number | null) ?? 28
301
+ // Vertical gap between legend rows is not exposed as its own token —
302
+ // the Figma design uses 8px between rows, mirroring the
303
+ // `donutChartSummary/legend/gap` default. Keep the value in step
304
+ // with `DonutChartSummary` so the two summary components feel
305
+ // cohesive when stacked.
306
+ const legendRowGap = 8
307
+
308
+ const activeTab = resolvedTabs[safeIndex]
309
+ const activeItems = activeTab?.items ?? []
310
+
311
+ const segments: SegmentedTrackSegmentData[] = activeItems.map(
312
+ (item, index) => ({
313
+ key: item.key ?? `segment-${index}`,
314
+ value: item.value,
315
+ color: resolveItemColor(modes, item, index),
316
+ accessibilityLabel: item.accessibilityLabel,
317
+ })
318
+ )
319
+
320
+ return (
321
+ <View
322
+ accessibilityRole="summary"
323
+ accessibilityLabel={accessibilityLabel}
324
+ style={[
325
+ {
326
+ width: '100%',
327
+ flexDirection: 'column',
328
+ alignItems: 'flex-start',
329
+ gap: containerGap,
330
+ },
331
+ style,
332
+ ]}
333
+ >
334
+ <Tabs
335
+ modes={modes}
336
+ scrollable={scrollableTabs}
337
+ style={tabsStyle}
338
+ >
339
+ {resolvedTabs.map((tab, index) => (
340
+ <TabItem
341
+ key={tab.key ?? tab.label ?? `tab-${index}`}
342
+ label={tab.label}
343
+ active={index === safeIndex}
344
+ onPress={() => handleTabPress(index)}
345
+ accessibilityLabel={tab.accessibilityLabel ?? tab.label}
346
+ />
347
+ ))}
348
+ </Tabs>
349
+
350
+ <SegmentedTrack
351
+ modes={modes}
352
+ segments={segments}
353
+ style={trackStyle}
354
+ accessibilityLabel={activeTab?.accessibilityLabel ?? activeTab?.label}
355
+ />
356
+
357
+ <View
358
+ style={[
359
+ {
360
+ width: '100%',
361
+ flexDirection: 'column',
362
+ alignItems: 'flex-start',
363
+ gap: legendRowGap,
364
+ },
365
+ legendStyle,
366
+ ]}
367
+ >
368
+ {activeItems.map((item, index) => (
369
+ <MetricLegendItem
370
+ key={item.key ?? `legend-${index}`}
371
+ label={item.label}
372
+ value={resolveLegendValue(item, formatValue)}
373
+ indicatorColor={resolveItemColor(modes, item, index)}
374
+ modes={modes}
375
+ />
376
+ ))}
377
+ </View>
378
+ </View>
379
+ )
380
+ }
381
+
382
+ /**
383
+ * Clamp a tab index into `[0, length)`. Negative or out-of-bounds
384
+ * values fall back to `0` to avoid rendering an undefined tab.
385
+ */
386
+ function clampIndex(index: number, length: number): number {
387
+ if (length <= 0) return 0
388
+ if (!Number.isFinite(index)) return 0
389
+ if (index < 0) return 0
390
+ if (index >= length) return length - 1
391
+ return Math.floor(index)
392
+ }
393
+
394
+ export default RangeTrack
@@ -0,0 +1,269 @@
1
+ import React, { useMemo } from 'react'
2
+ import {
3
+ View,
4
+ type StyleProp,
5
+ type ViewStyle,
6
+ type TextStyle,
7
+ } from 'react-native'
8
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
9
+ import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils'
10
+ import { formatIndianNumber } from '../../utils/number-utils'
11
+ import Title from '../Title/Title'
12
+ import LinearProgress from '../LinearProgress/LinearProgress'
13
+ import MetricLegendItem from '../MetricLegendItem/MetricLegendItem'
14
+
15
+ /**
16
+ * A single row in the savings-goal legend (current vs. target).
17
+ *
18
+ * `value` is a **numeric amount** (e.g. `240000`). It serves two purposes:
19
+ *
20
+ * 1. Display: rendered on the right side of the row using Indian numeric
21
+ * notation via {@link formatIndianNumber} and prefixed with `currency`
22
+ * (e.g. `240000` → `"₹2.4L"`).
23
+ * 2. Progress derivation: the bar fills to `current.value / target.value`
24
+ * automatically. There is no separate `progress` prop.
25
+ *
26
+ * Pass `value: undefined` to render a label-only row (the underlying
27
+ * {@link MetricLegendItem} hides the right slot in that case).
28
+ */
29
+ export type SavingsGoalSummaryItem = {
30
+ /** Label shown next to the indicator dot (e.g. "Current (4 months)"). */
31
+ label?: React.ReactNode
32
+ /**
33
+ * Numeric amount used for both display formatting and progress derivation.
34
+ * Use `undefined` to render a label-only row.
35
+ */
36
+ value?: number
37
+ /** Override the indicator dot colour. */
38
+ indicatorColor?: string
39
+ }
40
+
41
+ export type SavingsGoalSummaryProps = {
42
+ /**
43
+ * Currency symbol (e.g. `'₹'`, `'$'`) prefixed to every legend value.
44
+ * Defaults to `'₹'`. The symbol is rendered as part of the formatted
45
+ * string — this component does not use `MoneyValue`.
46
+ */
47
+ currency?: string
48
+ /**
49
+ * "Current" row in the legend. The indicator dot defaults to the
50
+ * `LinearProgress` indicator colour. Pass `null` to hide the row.
51
+ *
52
+ * `current.value` (numeric) drives both the displayed amount and — together
53
+ * with `target.value` — the progress bar fill.
54
+ */
55
+ current?: SavingsGoalSummaryItem | null
56
+ /**
57
+ * "Target" / recommended row in the legend. The indicator dot defaults to
58
+ * the `LinearProgress` track colour. Pass `null` to hide the row.
59
+ */
60
+ target?: SavingsGoalSummaryItem | null
61
+ /**
62
+ * Custom legend slot. When provided, replaces the default `current`/`target`
63
+ * rows entirely — pass any number of `MetricLegendItem`s (or any other
64
+ * nodes) to fully control the legend.
65
+ *
66
+ * Note: the progress bar is still derived from `current.value` / `target.value`,
67
+ * so pass them even when overriding the legend if you want a non-zero bar.
68
+ */
69
+ children?: React.ReactNode
70
+ /**
71
+ * Design token modes for theming. Defaults to `{ 'LinearProgress Size': 'L' }`
72
+ * which renders the thicker progress bar from the Figma reference. Caller
73
+ * modes are merged on top and can override every default key.
74
+ */
75
+ modes?: Record<string, any>
76
+ /** Override container styles. */
77
+ style?: StyleProp<ViewStyle>
78
+ /**
79
+ * Override styles for the auto-generated percentage text. Useful for
80
+ * tweaking colour or alignment; the value (e.g. `"50%"`) is always
81
+ * computed from `current` / `target` and cannot be replaced.
82
+ */
83
+ titleStyle?: StyleProp<TextStyle>
84
+ /** Override the inner legend container styles. */
85
+ legendStyle?: StyleProp<ViewStyle>
86
+ }
87
+
88
+ const DEFAULT_LEGEND_PADDING = 8
89
+
90
+ const DEFAULT_MODES: Readonly<Record<string, any>> = Object.freeze({
91
+ 'LinearProgress Size': 'L',
92
+ })
93
+
94
+ const DEFAULT_CURRENT: SavingsGoalSummaryItem = {
95
+ label: 'Current (4 months)',
96
+ value: 240000,
97
+ }
98
+
99
+ const DEFAULT_TARGET: SavingsGoalSummaryItem = {
100
+ label: 'Recommended (8 months)',
101
+ value: 480000,
102
+ }
103
+
104
+ /**
105
+ * `SavingsGoalSummary` visualises progress toward a savings goal as:
106
+ *
107
+ * 1. A `Title` showing the percentage — **computed automatically** from
108
+ * `current.value / target.value`. There is no `progress` prop; the
109
+ * component owns the calculation so the title, bar and legend are always
110
+ * in sync.
111
+ * 2. A `LinearProgress` bar driven by the same derived ratio.
112
+ * 3. A two-row legend comparing **current** vs. **target**, where each numeric
113
+ * `value` is auto-formatted with Indian notation
114
+ * ({@link formatIndianNumber}) and prefixed with the `currency` symbol.
115
+ *
116
+ * The component is intentionally narrow in scope — it is the body of a savings
117
+ * insight card. Wrap it in `CardInsight` (or any container of your choice) to
118
+ * add a heading, badge or footer.
119
+ *
120
+ * @component
121
+ * @param {SavingsGoalSummaryProps} props
122
+ */
123
+ function SavingsGoalSummary({
124
+ currency = '₹',
125
+ current = DEFAULT_CURRENT,
126
+ target = DEFAULT_TARGET,
127
+ children,
128
+ modes = EMPTY_MODES,
129
+ style,
130
+ titleStyle,
131
+ legendStyle,
132
+ }: SavingsGoalSummaryProps) {
133
+ // Merge caller modes on top of the defaults so callers can override
134
+ // (e.g. switch to `LinearProgress Size: M`) while still receiving the
135
+ // sensible component-level default.
136
+ const mergedModes = useMemo(
137
+ () => (modes === EMPTY_MODES ? DEFAULT_MODES : { ...DEFAULT_MODES, ...modes }),
138
+ [modes],
139
+ )
140
+
141
+ // Resolve the `LinearProgress` track / indicator colours so the legend
142
+ // dots automatically stay in sync with the progress bar without callers
143
+ // needing to plumb through `indicatorColor`.
144
+ //
145
+ // The token names AND the merge strategy must match `LinearProgress.tsx`
146
+ // exactly, otherwise the dot colours can drift from the bar:
147
+ // • Token aliases live in the `Emphasis` collection (modes High|Medium|Low),
148
+ // NOT `Emphasis / DataViz` — passing the wrong mode key would collapse
149
+ // both dots to the same default-mode colour.
150
+ // • Defaults are placed *before* `mergedModes` is spread, so callers can
151
+ // still override `Emphasis` via the `modes` prop (matches
152
+ // `LinearProgress`'s philosophy).
153
+ const indicatorColorFromTokens =
154
+ (getVariableByName('linearProgress/indicator/background', {
155
+ Emphasis: 'High',
156
+ ...mergedModes,
157
+ }) as string | null) ?? '#5d00b5'
158
+ const trackColorFromTokens =
159
+ (getVariableByName('linearProgress/track/background', {
160
+ Emphasis: 'Low',
161
+ ...mergedModes,
162
+ }) as string | null) ?? '#ede7ff'
163
+
164
+ // Single source of truth for the bar fill, the title percentage and the
165
+ // formatted legend amounts. There is intentionally no consumer-facing
166
+ // `progress` prop — the only way to change the bar is to change the
167
+ // numeric `current` / `target` values. This keeps the three views (title,
168
+ // bar, legend) impossible to desynchronise.
169
+ const resolvedProgress = useMemo(() => {
170
+ const cv = current?.value
171
+ const tv = target?.value
172
+ if (typeof cv !== 'number' || typeof tv !== 'number' || tv <= 0) {
173
+ return 0
174
+ }
175
+ return Math.min(Math.max(cv / tv, 0), 1)
176
+ }, [current, target])
177
+
178
+ const percentageLabel = `${Math.round(resolvedProgress * 100)}%`
179
+
180
+ const gap = (getVariableByName('savingsGoalSummary/gap', mergedModes) as number | null) ?? 23
181
+ const legendGap =
182
+ (getVariableByName('savingsGoalSummary/legend/gap', mergedModes) as number | null) ?? 16
183
+
184
+ const customLegend = children
185
+ ? cloneChildrenWithModes(children, mergedModes)
186
+ : null
187
+
188
+ const defaultLegend = !customLegend && (current || target) ? (
189
+ <>
190
+ {current && (
191
+ <MetricLegendItem
192
+ modes={mergedModes}
193
+ label={current.label}
194
+ value={formatLegendValue(current.value, currency)}
195
+ indicatorColor={current.indicatorColor ?? indicatorColorFromTokens}
196
+ />
197
+ )}
198
+ {target && (
199
+ <MetricLegendItem
200
+ modes={mergedModes}
201
+ label={target.label}
202
+ value={formatLegendValue(target.value, currency)}
203
+ indicatorColor={target.indicatorColor ?? trackColorFromTokens}
204
+ />
205
+ )}
206
+ </>
207
+ ) : null
208
+
209
+ const legendNode = customLegend ?? defaultLegend
210
+ const showLegend = legendNode != null
211
+
212
+ return (
213
+ <View
214
+ style={[
215
+ {
216
+ width: '100%',
217
+ gap,
218
+ alignItems: 'stretch',
219
+ },
220
+ style,
221
+ ]}
222
+ accessibilityLabel={`Savings goal progress, ${percentageLabel}`}
223
+ >
224
+ <Title
225
+ title={percentageLabel}
226
+ modes={mergedModes}
227
+ style={TITLE_CONTAINER_STYLE}
228
+ textStyle={titleStyle}
229
+ />
230
+
231
+ <LinearProgress value={resolvedProgress} modes={mergedModes} />
232
+
233
+ {showLegend && (
234
+ <View
235
+ style={[
236
+ {
237
+ width: '100%',
238
+ padding: DEFAULT_LEGEND_PADDING,
239
+ gap: legendGap,
240
+ alignItems: 'stretch',
241
+ },
242
+ legendStyle,
243
+ ]}
244
+ >
245
+ {legendNode}
246
+ </View>
247
+ )}
248
+ </View>
249
+ )
250
+ }
251
+
252
+ /**
253
+ * Format a single legend `value` for display. Returns `undefined` when the
254
+ * value is missing so the underlying {@link MetricLegendItem} hides the right
255
+ * slot (matches the Figma `data` toggle = off).
256
+ */
257
+ function formatLegendValue(value: number | undefined, currency: string): string | undefined {
258
+ if (typeof value !== 'number') return undefined
259
+ return formatIndianNumber(value, { prefix: currency })
260
+ }
261
+
262
+ // Neutralise the `Title` component's default page-level padding so it sits
263
+ // flush inside the summary card (the parent container owns spacing via `gap`).
264
+ const TITLE_CONTAINER_STYLE: ViewStyle = {
265
+ paddingHorizontal: 0,
266
+ paddingVertical: 0,
267
+ }
268
+
269
+ export default SavingsGoalSummary