jfs-components 0.0.71 → 0.0.73

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 (141) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/lib/commonjs/components/AccordionCheckbox/AccordionCheckbox.js +239 -0
  3. package/lib/commonjs/components/BrandChip/BrandChip.js +149 -0
  4. package/lib/commonjs/components/CardAdvisory/CardAdvisory.js +2 -2
  5. package/lib/commonjs/components/CardBankAccount/CardBankAccount.js +213 -0
  6. package/lib/commonjs/components/CardFinancialCondition/CardFinancialCondition.js +213 -0
  7. package/lib/commonjs/components/CardInsight/CardInsight.js +166 -0
  8. package/lib/commonjs/components/Carousel/Carousel.js +9 -7
  9. package/lib/commonjs/components/CheckboxGroup/CheckboxGroup.js +67 -0
  10. package/lib/commonjs/components/CheckboxItem/CheckboxItem.js +125 -0
  11. package/lib/commonjs/components/CircularProgressBar/CircularProgressBar.js +56 -9
  12. package/lib/commonjs/components/CoverageBarComparison/CoverageBarComparison.js +272 -0
  13. package/lib/commonjs/components/CoverageRing/CoverageRing.js +141 -0
  14. package/lib/commonjs/components/DonutChart/DonutChart.js +309 -0
  15. package/lib/commonjs/components/DonutChartSummary/DonutChartSummary.js +155 -0
  16. package/lib/commonjs/components/HoldingsCard/HoldingsCard.js +2 -2
  17. package/lib/commonjs/components/InstitutionBadge/InstitutionBadge.js +132 -0
  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/MetricLegendItem/MetricLegendItem.js +95 -0
  21. package/lib/commonjs/components/MonthlyStatusGrid/MonthlyStatusGrid.js +286 -0
  22. package/lib/commonjs/components/OTP/OTP.js +381 -37
  23. package/lib/commonjs/components/ProductOverview/ProductOverview.js +147 -0
  24. package/lib/commonjs/components/Radio/Radio.js +194 -0
  25. package/lib/commonjs/components/RadioButton/RadioButton.js +21 -188
  26. package/lib/commonjs/components/RangeTrack/RangeTrack.js +269 -0
  27. package/lib/commonjs/components/SavingsGoalSummary/SavingsGoalSummary.js +181 -0
  28. package/lib/commonjs/components/SegmentedTrack/SegmentedTrack.js +171 -0
  29. package/lib/commonjs/components/StatGroup/StatGroup.js +128 -0
  30. package/lib/commonjs/components/StatItem/StatItem.js +65 -35
  31. package/lib/commonjs/components/StrengthIndicator/StrengthIndicator.js +157 -0
  32. package/lib/commonjs/components/SummaryTile/SummaryTile.js +150 -0
  33. package/lib/commonjs/components/index.js +192 -1
  34. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  35. package/lib/commonjs/icons/registry.js +1 -1
  36. package/lib/commonjs/utils/index.js +7 -0
  37. package/lib/commonjs/utils/number-utils.js +57 -0
  38. package/lib/module/components/AccordionCheckbox/AccordionCheckbox.js +233 -0
  39. package/lib/module/components/BrandChip/BrandChip.js +143 -0
  40. package/lib/module/components/CardAdvisory/CardAdvisory.js +2 -2
  41. package/lib/module/components/CardBankAccount/CardBankAccount.js +208 -0
  42. package/lib/module/components/CardFinancialCondition/CardFinancialCondition.js +207 -0
  43. package/lib/module/components/CardInsight/CardInsight.js +161 -0
  44. package/lib/module/components/Carousel/Carousel.js +9 -7
  45. package/lib/module/components/CheckboxGroup/CheckboxGroup.js +62 -0
  46. package/lib/module/components/CheckboxItem/CheckboxItem.js +119 -0
  47. package/lib/module/components/CircularProgressBar/CircularProgressBar.js +56 -9
  48. package/lib/module/components/CoverageBarComparison/CoverageBarComparison.js +266 -0
  49. package/lib/module/components/CoverageRing/CoverageRing.js +136 -0
  50. package/lib/module/components/DonutChart/DonutChart.js +303 -0
  51. package/lib/module/components/DonutChartSummary/DonutChartSummary.js +150 -0
  52. package/lib/module/components/HoldingsCard/HoldingsCard.js +2 -2
  53. package/lib/module/components/InstitutionBadge/InstitutionBadge.js +127 -0
  54. package/lib/module/components/LinearMeter/LinearMeter.js +9 -28
  55. package/lib/module/components/LinearProgress/LinearProgress.js +63 -0
  56. package/lib/module/components/MetricLegendItem/MetricLegendItem.js +90 -0
  57. package/lib/module/components/MonthlyStatusGrid/MonthlyStatusGrid.js +281 -0
  58. package/lib/module/components/OTP/OTP.js +381 -38
  59. package/lib/module/components/ProductOverview/ProductOverview.js +142 -0
  60. package/lib/module/components/Radio/Radio.js +188 -0
  61. package/lib/module/components/RadioButton/RadioButton.js +20 -185
  62. package/lib/module/components/RangeTrack/RangeTrack.js +263 -0
  63. package/lib/module/components/SavingsGoalSummary/SavingsGoalSummary.js +175 -0
  64. package/lib/module/components/SegmentedTrack/SegmentedTrack.js +166 -0
  65. package/lib/module/components/StatGroup/StatGroup.js +123 -0
  66. package/lib/module/components/StatItem/StatItem.js +66 -36
  67. package/lib/module/components/StrengthIndicator/StrengthIndicator.js +152 -0
  68. package/lib/module/components/SummaryTile/SummaryTile.js +145 -0
  69. package/lib/module/components/index.js +28 -1
  70. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  71. package/lib/module/icons/registry.js +1 -1
  72. package/lib/module/utils/index.js +2 -1
  73. package/lib/module/utils/number-utils.js +53 -0
  74. package/lib/typescript/src/components/AccordionCheckbox/AccordionCheckbox.d.ts +71 -0
  75. package/lib/typescript/src/components/BrandChip/BrandChip.d.ts +43 -0
  76. package/lib/typescript/src/components/CardBankAccount/CardBankAccount.d.ts +79 -0
  77. package/lib/typescript/src/components/CardFinancialCondition/CardFinancialCondition.d.ts +50 -0
  78. package/lib/typescript/src/components/CardInsight/CardInsight.d.ts +48 -0
  79. package/lib/typescript/src/components/CheckboxGroup/CheckboxGroup.d.ts +41 -0
  80. package/lib/typescript/src/components/CheckboxItem/CheckboxItem.d.ts +56 -0
  81. package/lib/typescript/src/components/CircularProgressBar/CircularProgressBar.d.ts +11 -1
  82. package/lib/typescript/src/components/CoverageBarComparison/CoverageBarComparison.d.ts +105 -0
  83. package/lib/typescript/src/components/CoverageRing/CoverageRing.d.ts +90 -0
  84. package/lib/typescript/src/components/DonutChart/DonutChart.d.ts +117 -0
  85. package/lib/typescript/src/components/DonutChartSummary/DonutChartSummary.d.ts +103 -0
  86. package/lib/typescript/src/components/InstitutionBadge/InstitutionBadge.d.ts +30 -0
  87. package/lib/typescript/src/components/LinearProgress/LinearProgress.d.ts +17 -0
  88. package/lib/typescript/src/components/MetricLegendItem/MetricLegendItem.d.ts +37 -0
  89. package/lib/typescript/src/components/MonthlyStatusGrid/MonthlyStatusGrid.d.ts +119 -0
  90. package/lib/typescript/src/components/OTP/OTP.d.ts +88 -2
  91. package/lib/typescript/src/components/ProductOverview/ProductOverview.d.ts +39 -0
  92. package/lib/typescript/src/components/Radio/Radio.d.ts +30 -0
  93. package/lib/typescript/src/components/RadioButton/RadioButton.d.ts +20 -28
  94. package/lib/typescript/src/components/RangeTrack/RangeTrack.d.ts +173 -0
  95. package/lib/typescript/src/components/SavingsGoalSummary/SavingsGoalSummary.d.ts +95 -0
  96. package/lib/typescript/src/components/SegmentedTrack/SegmentedTrack.d.ts +108 -0
  97. package/lib/typescript/src/components/StatGroup/StatGroup.d.ts +45 -0
  98. package/lib/typescript/src/components/StatItem/StatItem.d.ts +24 -7
  99. package/lib/typescript/src/components/StrengthIndicator/StrengthIndicator.d.ts +58 -0
  100. package/lib/typescript/src/components/SummaryTile/SummaryTile.d.ts +60 -0
  101. package/lib/typescript/src/components/index.d.ts +29 -2
  102. package/lib/typescript/src/icons/registry.d.ts +1 -1
  103. package/lib/typescript/src/utils/index.d.ts +1 -0
  104. package/lib/typescript/src/utils/number-utils.d.ts +29 -0
  105. package/package.json +1 -1
  106. package/src/components/AccordionCheckbox/AccordionCheckbox.tsx +323 -0
  107. package/src/components/BrandChip/BrandChip.tsx +235 -0
  108. package/src/components/CardAdvisory/CardAdvisory.tsx +2 -2
  109. package/src/components/CardBankAccount/CardBankAccount.tsx +295 -0
  110. package/src/components/CardFinancialCondition/CardFinancialCondition.tsx +366 -0
  111. package/src/components/CardInsight/CardInsight.tsx +239 -0
  112. package/src/components/Carousel/Carousel.tsx +14 -6
  113. package/src/components/CheckboxGroup/CheckboxGroup.tsx +86 -0
  114. package/src/components/CheckboxItem/CheckboxItem.tsx +174 -0
  115. package/src/components/CircularProgressBar/CircularProgressBar.tsx +74 -9
  116. package/src/components/CoverageBarComparison/CoverageBarComparison.tsx +378 -0
  117. package/src/components/CoverageRing/CoverageRing.tsx +225 -0
  118. package/src/components/DonutChart/DonutChart.tsx +503 -0
  119. package/src/components/DonutChartSummary/DonutChartSummary.tsx +256 -0
  120. package/src/components/HoldingsCard/HoldingsCard.tsx +2 -2
  121. package/src/components/InstitutionBadge/InstitutionBadge.tsx +216 -0
  122. package/src/components/LinearMeter/LinearMeter.tsx +9 -39
  123. package/src/components/LinearProgress/LinearProgress.tsx +92 -0
  124. package/src/components/MetricLegendItem/MetricLegendItem.tsx +167 -0
  125. package/src/components/MonthlyStatusGrid/MonthlyStatusGrid.tsx +438 -0
  126. package/src/components/OTP/OTP.tsx +476 -29
  127. package/src/components/ProductOverview/ProductOverview.tsx +236 -0
  128. package/src/components/Radio/Radio.tsx +227 -0
  129. package/src/components/RadioButton/RadioButton.tsx +23 -225
  130. package/src/components/RangeTrack/RangeTrack.tsx +394 -0
  131. package/src/components/SavingsGoalSummary/SavingsGoalSummary.tsx +269 -0
  132. package/src/components/SegmentedTrack/SegmentedTrack.tsx +268 -0
  133. package/src/components/StatGroup/StatGroup.tsx +169 -0
  134. package/src/components/StatItem/StatItem.tsx +117 -40
  135. package/src/components/StrengthIndicator/StrengthIndicator.tsx +205 -0
  136. package/src/components/SummaryTile/SummaryTile.tsx +251 -0
  137. package/src/components/index.ts +39 -2
  138. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  139. package/src/icons/registry.ts +1 -1
  140. package/src/utils/index.ts +1 -0
  141. package/src/utils/number-utils.ts +60 -0
@@ -1,227 +1,25 @@
1
- import React, { useMemo, useState } from 'react'
2
- import {
3
- Pressable,
4
- View,
5
- StyleSheet,
6
- Platform,
7
- ViewStyle,
8
- DimensionValue,
9
- StyleProp,
10
- } from 'react-native'
11
- import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
12
- import { EMPTY_MODES } from '../../utils/react-utils'
13
-
14
- // ---------------------------------------------------------------------------
15
- // Props
16
- // ---------------------------------------------------------------------------
17
-
18
- export interface RadioButtonProps {
19
- /**
20
- * Whether the radio button is selected.
21
- */
22
- selected?: boolean
23
- /**
24
- * Whether the radio button is disabled.
25
- */
26
- disabled?: boolean
27
- /**
28
- * Function to call when the radio button is pressed.
29
- */
30
- onPress?: () => void
31
- /**
32
- * Modes object for design-token resolution.
33
- */
34
- modes?: Record<string, any>
35
- /**
36
- * Custom style for the radio button container.
37
- */
38
- style?: StyleProp<ViewStyle>
39
- /**
40
- * Test ID for testing.
41
- */
42
- testID?: string
43
- }
44
-
45
- // ---------------------------------------------------------------------------
46
- // RadioButton
47
- // ---------------------------------------------------------------------------
48
-
49
- export function RadioButton({
50
- selected = false,
51
- disabled = false,
52
- onPress,
53
- modes = EMPTY_MODES,
54
- style,
55
- testID,
56
- }: RadioButtonProps) {
57
- // ---- Refs & State ----
58
- const [hovered, setHovered] = useState(false)
59
- const [focused, setFocused] = useState(false)
60
- const [pressed, setPressed] = useState(false)
61
-
62
- // ---- Dimensions ----
63
- const widthStr = getVariableByName('radio/width', modes) || '18'
64
- const heightStr = getVariableByName('radio/height', modes) || '18'
65
- const selectorSizeStr = getVariableByName('radio/selector/size', modes) || '10'
66
-
67
- const width = parseFloat(widthStr?.toString() || '18')
68
- const height = parseFloat(heightStr?.toString() || '18')
69
- const selectorSize = parseFloat(selectorSizeStr?.toString() || '10')
70
-
71
- // ---- State Logic ----
72
- // Priority: Disabled -> Focused -> Hover/Pressed -> Idle
73
- // Note: Design treats Active (Pressed) similar to Selected for some styles,
74
- // but usually in Radio Buttons, Pressed is a transient state.
75
- // We will map:
76
- // - Disabled -> 'disabled'
77
- // - Focused -> 'focus'
78
- // - Hovered -> 'hover'
79
- // - Idle -> 'idle'
80
-
81
- // We handle `selected` as a separate dimension derived from state.
82
-
83
- let visualState = 'idle'
84
- if (disabled) {
85
- visualState = 'disabled'
86
- } else if (focused) {
87
- visualState = 'focus'
88
- } else if (hovered || pressed) {
89
- visualState = 'hover'
90
- }
91
-
92
- // Construct token paths based on state + selected
93
- let prefix = `radio/${visualState}`
94
- if (visualState === 'idle' && selected) {
95
- prefix = `radio/selected`
96
- } else if (selected) {
97
- prefix = `radio/${visualState}Selected`
98
- }
99
-
100
- // ---- Colors & Border ----
101
-
102
- const resolveColor = (path: string, fallback: string) => {
103
- return getVariableByName(path, modes) || fallback
104
- }
105
-
106
- // Background Color
107
- let bgColorVar = `${prefix}/background/color`
108
- // Fix for disabledSelected weirdness if needed
109
- if (visualState === 'disabled' && selected) {
110
- // Check specific path from dump: `radio/disabledSelected/background`
111
- if (!getVariableByName(`${prefix}/background/color`, modes)) {
112
- bgColorVar = `${prefix}/background`
113
- }
114
- }
115
-
116
- // Border Color
117
- let borderColorVar = `${prefix}/border/color`
118
-
119
- // Border Width
120
- let borderWidthVar = `${prefix}/border/size`
121
- // Fix for huge path: `radio/disabled/radio/disabled/border/size`
122
- if (visualState === 'disabled' && !selected) {
123
- if (getVariableByName('radio/disabled/radio/disabled/border/size', modes)) {
124
- borderWidthVar = 'radio/disabled/radio/disabled/border/size'
125
- }
126
- }
127
-
128
- // Selector Color
129
- let selectorBgVar = `${prefix}/selector/background/color`
130
- if (!selected) {
131
- selectorBgVar = 'transparent'
132
- }
133
-
134
- // Shadows (Glow)
135
- let shadowSizeVar = `${prefix}/boxShadow/size`
136
- let shadowColorVar = `${prefix}/shadow/color`
137
-
138
- // Resolve Values
139
- const backgroundColor = resolveColor(bgColorVar, 'transparent')
140
- const borderColor = resolveColor(borderColorVar, 'transparent')
141
- const borderWidth = parseFloat(getVariableByName(borderWidthVar, modes)?.toString() || '1')
142
- const selectorColor = resolveColor(selectorBgVar, 'transparent')
143
-
144
- const shadowSize = parseFloat(getVariableByName(shadowSizeVar, modes)?.toString() || '0')
145
- const shadowColor = resolveColor(shadowColorVar, 'transparent')
146
-
147
- // Styles
148
- const containerStyle: any = {
149
- width,
150
- height,
151
- borderRadius: width / 2, // 9999px -> circle
152
- borderWidth,
153
- borderColor,
154
- backgroundColor,
155
- justifyContent: 'center',
156
- alignItems: 'center',
157
- // Web shadow (ring)
158
- ...(Platform.OS === 'web' && shadowSize > 0 ? {
159
- boxShadow: `0px 0px 0px ${shadowSize}px ${shadowColor}`,
160
- } : {}),
161
- }
162
-
163
- const selectorStyle: ViewStyle = {
164
- width: selectorSize,
165
- height: selectorSize,
166
- borderRadius: selectorSize / 2,
167
- backgroundColor: selectorColor,
168
- }
169
-
170
- // Dummy block for token extraction (static analysis)
171
- if (false as boolean) {
172
- getVariableByName('radio/idle/background/color')
173
- getVariableByName('radio/idle/border/color')
174
- getVariableByName('radio/selector/size')
175
- getVariableByName('radio/width')
176
- getVariableByName('radio/height')
177
- getVariableByName('radio/background/color')
178
- getVariableByName('radio/hover/background/color')
179
- getVariableByName('radio/hover/border/color')
180
- getVariableByName('radio/hover/boxShadow/size')
181
- getVariableByName('radio/hover/shadow/color')
182
- getVariableByName('radio/selected/background/color')
183
- getVariableByName('radio/selected/border/color')
184
- getVariableByName('radio/selected/selector/background/color')
185
- getVariableByName('radio/hoverSelected/background/color')
186
- getVariableByName('radio/hoverSelected/border/color')
187
- getVariableByName('radio/hoverSelected/boxShadow/size')
188
- getVariableByName('radio/hoverSelected/shadow/color')
189
- getVariableByName('radio/hoverSelected/selector/background/color')
190
- getVariableByName('radio/focus/background/color')
191
- getVariableByName('radio/focus/border/color')
192
- getVariableByName('radio/focus/border/size')
193
- getVariableByName('radio/focus/boxShadow/size')
194
- getVariableByName('radio/focus/shadow/color')
195
- getVariableByName('radio/focusSelected/background/color')
196
- getVariableByName('radio/focusSelected/selector/background/color')
197
- getVariableByName('radio/focusSelected/border/size')
198
- getVariableByName('radio/disabled/radio/disabled/border/size')
199
- getVariableByName('radio/disabled/background/color')
200
- getVariableByName('radio/disabled/border/color')
201
- getVariableByName('radio/disabledSelected/selector/background/color')
202
- getVariableByName('radio/disabledSelected/background')
203
- getVariableByName('radio/disabledSelected/border/color')
204
- }
205
-
206
- return (
207
- <Pressable
208
- testID={testID}
209
- disabled={disabled}
210
- onPress={onPress}
211
- onHoverIn={() => setHovered(true)}
212
- onHoverOut={() => setHovered(false)}
213
- onFocus={() => setFocused(true)}
214
- onBlur={() => setFocused(false)}
215
- onPressIn={() => setPressed(true)}
216
- onPressOut={() => setPressed(false)}
217
- style={({ pressed: isPressed }) => [
218
- containerStyle,
219
- style,
220
- ]}
221
- >
222
- <View style={selectorStyle} />
223
- </Pressable>
224
- )
225
- }
1
+ /**
2
+ * @deprecated `RadioButton` has been renamed to `Radio`.
3
+ *
4
+ * This file is kept as a backward-compatibility shim for teams that may be
5
+ * importing `RadioButton` directly from this deep path:
6
+ *
7
+ * import RadioButton from 'jfs-components/src/components/RadioButton/RadioButton'
8
+ * import { RadioButton, RadioButtonProps } from '...'
9
+ *
10
+ * The recommended public import is now:
11
+ *
12
+ * import { Radio, type RadioProps } from 'jfs-components'
13
+ *
14
+ * Going forward, this component is called `Radio`. This shim only re-exports
15
+ * the new `Radio` component under the old `RadioButton` names; please migrate
16
+ * existing usages to `Radio` at your earliest convenience.
17
+ */
18
+
19
+ import { Radio, type RadioProps } from '../Radio/Radio'
20
+
21
+ export type RadioButtonProps = RadioProps
22
+
23
+ export const RadioButton = Radio
226
24
 
227
25
  export default RadioButton
@@ -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