jfs-components 0.0.70 → 0.0.72

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 (66) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/lib/commonjs/components/CardAdvisory/CardAdvisory.js +203 -0
  3. package/lib/commonjs/components/CardCTA/CardCTA.js +198 -16
  4. package/lib/commonjs/components/CardFinancialCondition/CardFinancialCondition.js +213 -0
  5. package/lib/commonjs/components/Carousel/Carousel.js +9 -7
  6. package/lib/commonjs/components/CircularProgressBar/CircularProgressBar.js +147 -0
  7. package/lib/commonjs/components/CircularProgressBarDoted/CircularProgressBarDoted.js +258 -0
  8. package/lib/commonjs/components/CircularRating/CircularRating.js +161 -0
  9. package/lib/commonjs/components/Gauge/Gauge.js +223 -0
  10. package/lib/commonjs/components/HoldingsCard/HoldingsCard.js +2 -2
  11. package/lib/commonjs/components/InstitutionBadge/InstitutionBadge.js +132 -0
  12. package/lib/commonjs/components/ListGroup/ListGroup.js +3 -1
  13. package/lib/commonjs/components/Nudge/Nudge.js +179 -87
  14. package/lib/commonjs/components/Radio/Radio.js +194 -0
  15. package/lib/commonjs/components/RadioButton/RadioButton.js +21 -188
  16. package/lib/commonjs/components/index.js +56 -0
  17. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  18. package/lib/commonjs/icons/registry.js +1 -1
  19. package/lib/module/components/CardAdvisory/CardAdvisory.js +197 -0
  20. package/lib/module/components/CardCTA/CardCTA.js +199 -17
  21. package/lib/module/components/CardFinancialCondition/CardFinancialCondition.js +207 -0
  22. package/lib/module/components/Carousel/Carousel.js +9 -7
  23. package/lib/module/components/CircularProgressBar/CircularProgressBar.js +141 -0
  24. package/lib/module/components/CircularProgressBarDoted/CircularProgressBarDoted.js +253 -0
  25. package/lib/module/components/CircularRating/CircularRating.js +155 -0
  26. package/lib/module/components/Gauge/Gauge.js +217 -0
  27. package/lib/module/components/HoldingsCard/HoldingsCard.js +2 -2
  28. package/lib/module/components/InstitutionBadge/InstitutionBadge.js +127 -0
  29. package/lib/module/components/ListGroup/ListGroup.js +3 -1
  30. package/lib/module/components/Nudge/Nudge.js +178 -87
  31. package/lib/module/components/Radio/Radio.js +188 -0
  32. package/lib/module/components/RadioButton/RadioButton.js +20 -185
  33. package/lib/module/components/index.js +12 -0
  34. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  35. package/lib/module/icons/registry.js +1 -1
  36. package/lib/typescript/src/components/CardAdvisory/CardAdvisory.d.ts +49 -0
  37. package/lib/typescript/src/components/CardCTA/CardCTA.d.ts +16 -1
  38. package/lib/typescript/src/components/CardFinancialCondition/CardFinancialCondition.d.ts +50 -0
  39. package/lib/typescript/src/components/CircularProgressBar/CircularProgressBar.d.ts +27 -0
  40. package/lib/typescript/src/components/CircularProgressBarDoted/CircularProgressBarDoted.d.ts +48 -0
  41. package/lib/typescript/src/components/CircularRating/CircularRating.d.ts +49 -0
  42. package/lib/typescript/src/components/Gauge/Gauge.d.ts +53 -0
  43. package/lib/typescript/src/components/InstitutionBadge/InstitutionBadge.d.ts +30 -0
  44. package/lib/typescript/src/components/Nudge/Nudge.d.ts +14 -11
  45. package/lib/typescript/src/components/Radio/Radio.d.ts +30 -0
  46. package/lib/typescript/src/components/RadioButton/RadioButton.d.ts +20 -28
  47. package/lib/typescript/src/components/index.d.ts +13 -1
  48. package/lib/typescript/src/icons/registry.d.ts +1 -1
  49. package/package.json +1 -1
  50. package/src/components/CardAdvisory/CardAdvisory.tsx +283 -0
  51. package/src/components/CardCTA/CardCTA.tsx +236 -13
  52. package/src/components/CardFinancialCondition/CardFinancialCondition.tsx +366 -0
  53. package/src/components/Carousel/Carousel.tsx +14 -6
  54. package/src/components/CircularProgressBar/CircularProgressBar.tsx +190 -0
  55. package/src/components/CircularProgressBarDoted/CircularProgressBarDoted.tsx +357 -0
  56. package/src/components/CircularRating/CircularRating.tsx +241 -0
  57. package/src/components/Gauge/Gauge.tsx +303 -0
  58. package/src/components/HoldingsCard/HoldingsCard.tsx +2 -2
  59. package/src/components/InstitutionBadge/InstitutionBadge.tsx +216 -0
  60. package/src/components/ListGroup/ListGroup.tsx +3 -1
  61. package/src/components/Nudge/Nudge.tsx +222 -82
  62. package/src/components/Radio/Radio.tsx +227 -0
  63. package/src/components/RadioButton/RadioButton.tsx +23 -225
  64. package/src/components/index.ts +13 -1
  65. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  66. package/src/icons/registry.ts +1 -1
@@ -0,0 +1,283 @@
1
+ import React, { useMemo } from 'react'
2
+ import {
3
+ Text,
4
+ View,
5
+ type StyleProp,
6
+ type TextStyle,
7
+ type ViewStyle,
8
+ } from 'react-native'
9
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
10
+ import { useTokens } from '../../design-tokens/JFSThemeProvider'
11
+ import Icon from '../../icons/Icon'
12
+ import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils'
13
+ import CircularProgressBar from '../CircularProgressBar/CircularProgressBar'
14
+ import Nudge from '../Nudge/Nudge'
15
+
16
+ type CardAdvisoryBaseProps = Omit<React.ComponentProps<typeof View>, 'children' | 'style'>
17
+
18
+ export type CardAdvisoryProps = CardAdvisoryBaseProps & {
19
+ /** Main heading text. */
20
+ title?: string
21
+ /** Description shown below the heading. */
22
+ description?: string
23
+ /** Progress score displayed in the circular progress bar. */
24
+ value?: number
25
+ /** Optional formatted score label. */
26
+ valueLabel?: string
27
+ /** Show the info icon beside the title. */
28
+ showInfoIcon?: boolean
29
+ /** Show the bottom advisory nudge. */
30
+ showNudge?: boolean
31
+ /** Body text for the default nudge. */
32
+ nudgeBody?: string
33
+ /** Button label for the default nudge. */
34
+ nudgeButtonLabel?: string
35
+ /** Callback for the default nudge button. */
36
+ onPressNudgeButton?: () => void
37
+ /** Optional slot replacing the title info icon. Receives `modes` recursively. */
38
+ titleEndSlot?: React.ReactNode
39
+ /** Optional slot replacing the circular progress bar. Receives `modes` recursively. */
40
+ progressSlot?: React.ReactNode
41
+ /** Optional slot replacing the bottom nudge. Receives `modes` recursively. */
42
+ nudgeSlot?: React.ReactNode
43
+ /** Design token modes forwarded to token lookups and child components. */
44
+ modes?: Record<string, any>
45
+ /** Optional container style override. */
46
+ style?: StyleProp<ViewStyle>
47
+ /** Optional main content row style override. */
48
+ mainContentStyle?: StyleProp<ViewStyle>
49
+ /** Optional title text style override. */
50
+ titleStyle?: StyleProp<TextStyle>
51
+ /** Optional description text style override. */
52
+ descriptionStyle?: StyleProp<TextStyle>
53
+ /** Optional progress wrapper style override. */
54
+ progressStyle?: StyleProp<ViewStyle>
55
+ /** Optional nudge style override. */
56
+ nudgeStyle?: StyleProp<ViewStyle>
57
+ /** Accessibility label for the full card. */
58
+ accessibilityLabel?: string
59
+ }
60
+
61
+ interface CardAdvisoryTokens {
62
+ containerStyle: ViewStyle
63
+ mainContentStyle: ViewStyle
64
+ contentStyle: ViewStyle
65
+ headerStyle: ViewStyle
66
+ titleStyle: TextStyle
67
+ descriptionStyle: TextStyle
68
+ iconColor: string
69
+ iconSize: number
70
+ }
71
+
72
+ const toNumber = (value: unknown, fallback: number) => {
73
+ if (typeof value === 'number') {
74
+ return Number.isFinite(value) ? value : fallback
75
+ }
76
+
77
+ if (typeof value === 'string') {
78
+ const parsed = Number(value)
79
+ return Number.isFinite(parsed) ? parsed : fallback
80
+ }
81
+
82
+ return fallback
83
+ }
84
+
85
+ const toFontWeight = (value: unknown, fallback: TextStyle['fontWeight']) => {
86
+ if (typeof value === 'number') {
87
+ return String(value) as TextStyle['fontWeight']
88
+ }
89
+
90
+ if (typeof value === 'string') {
91
+ return value as TextStyle['fontWeight']
92
+ }
93
+
94
+ return fallback
95
+ }
96
+
97
+ function resolveCardAdvisoryTokens(modes: Record<string, any>): CardAdvisoryTokens {
98
+ const width = toNumber(getVariableByName('cardAdvisory/width', modes), 360)
99
+ const gap = toNumber(getVariableByName('cardAdvisory/gap', modes), 16)
100
+ const paddingHorizontal = toNumber(getVariableByName('cardAdvisory/padding/horizontal', modes), 0)
101
+ const paddingVertical = toNumber(getVariableByName('cardAdvisory/padding/vertical', modes), 0)
102
+ const radius = toNumber(getVariableByName('cardAdvisory/radius', modes), 0)
103
+ const background = getVariableByName('cardAdvisory/background', modes) || '#ffffff'
104
+
105
+ const mainContentGap = toNumber(getVariableByName('cardAdvisory/mainContent/gap', modes), 16)
106
+ const contentGap = toNumber(getVariableByName('cardAdvisory/content/gap', modes), 6)
107
+ const headerGap = toNumber(getVariableByName('cardAdvisory/header/gap', modes), 8)
108
+
109
+ const titleColor = getVariableByName('cardAdvisory/title/foreground', modes) || '#0d0d0f'
110
+ const titleFontSize = toNumber(getVariableByName('cardAdvisory/title/fontSize', modes), 26)
111
+ const titleFontFamily = getVariableByName('cardAdvisory/title/fontFamily', modes) || 'JioType Var'
112
+ const titleLineHeight = toNumber(getVariableByName('cardAdvisory/title/lineHeight', modes), 26)
113
+ const titleFontWeight = toFontWeight(getVariableByName('cardAdvisory/title/fontWeight', modes), '900')
114
+ const titleDescenderAllowance = Math.ceil(titleFontSize * 0.16)
115
+
116
+ const descriptionColor = getVariableByName('cardAdvisory/description/foreground', modes) || '#24262b'
117
+ const descriptionFontSize = toNumber(getVariableByName('cardAdvisory/description/fontSize', modes), 12)
118
+ const descriptionFontFamily = getVariableByName('cardAdvisory/description/fontFamily', modes) || 'JioType Var'
119
+ const descriptionLineHeight = toNumber(getVariableByName('cardAdvisory/description/lineHeight', modes), 16)
120
+ const descriptionFontWeight = toFontWeight(
121
+ getVariableByName('cardAdvisory/description/fontWeight', modes),
122
+ '500'
123
+ )
124
+
125
+ return {
126
+ containerStyle: {
127
+ alignItems: 'flex-start',
128
+ backgroundColor: background as string,
129
+ borderRadius: radius,
130
+ gap,
131
+ overflow: 'hidden',
132
+ paddingHorizontal,
133
+ paddingVertical,
134
+ width,
135
+ },
136
+ mainContentStyle: {
137
+ alignItems: 'flex-start',
138
+ flexDirection: 'row',
139
+ gap: mainContentGap,
140
+ width: '100%',
141
+ },
142
+ contentStyle: {
143
+ alignItems: 'flex-start',
144
+ flex: 1,
145
+ gap: contentGap,
146
+ minWidth: 1,
147
+ },
148
+ headerStyle: {
149
+ alignItems: 'center',
150
+ flexDirection: 'row',
151
+ gap: headerGap,
152
+ width: '100%',
153
+ },
154
+ titleStyle: {
155
+ color: titleColor as string,
156
+ fontFamily: titleFontFamily as string,
157
+ fontSize: titleFontSize,
158
+ fontWeight: titleFontWeight,
159
+ lineHeight: titleLineHeight,
160
+ marginBottom: -titleDescenderAllowance,
161
+ paddingBottom: titleDescenderAllowance,
162
+ },
163
+ descriptionStyle: {
164
+ color: descriptionColor as string,
165
+ fontFamily: descriptionFontFamily as string,
166
+ fontSize: descriptionFontSize,
167
+ fontWeight: descriptionFontWeight,
168
+ lineHeight: descriptionLineHeight,
169
+ width: '100%',
170
+ },
171
+ iconColor: (getVariableByName('cardAdvisory/icon/color', modes) || '#1a1c1f') as string,
172
+ iconSize: toNumber(getVariableByName('cardAdvisory/icon/size', modes), 18),
173
+ }
174
+ }
175
+
176
+ function CardAdvisory({
177
+ title = 'Spending',
178
+ description = 'Track your spending habits and stay within your budget.',
179
+ value = 70,
180
+ valueLabel,
181
+ showInfoIcon = true,
182
+ showNudge = true,
183
+ nudgeBody = 'Data confidence is low, add more accounts for better insights.',
184
+ nudgeButtonLabel = 'Button',
185
+ onPressNudgeButton,
186
+ titleEndSlot,
187
+ progressSlot,
188
+ nudgeSlot,
189
+ modes: propModes = EMPTY_MODES,
190
+ style,
191
+ mainContentStyle,
192
+ titleStyle,
193
+ descriptionStyle,
194
+ progressStyle,
195
+ nudgeStyle,
196
+ accessibilityLabel,
197
+ ...rest
198
+ }: CardAdvisoryProps) {
199
+ const { modes: globalModes } = useTokens()
200
+ const modes = useMemo(
201
+ () => (globalModes === EMPTY_MODES && propModes === EMPTY_MODES
202
+ ? EMPTY_MODES
203
+ : { ...globalModes, ...propModes }),
204
+ [globalModes, propModes]
205
+ )
206
+ const tokens = useMemo(() => resolveCardAdvisoryTokens(modes), [modes])
207
+
208
+ const processedTitleEndSlot = useMemo(() => {
209
+ if (!titleEndSlot) return null
210
+ const processed = cloneChildrenWithModes(React.Children.toArray(titleEndSlot), modes)
211
+ return processed.length === 1 ? processed[0] : processed
212
+ }, [titleEndSlot, modes])
213
+
214
+ const processedProgressSlot = useMemo(() => {
215
+ if (!progressSlot) return null
216
+ const processed = cloneChildrenWithModes(React.Children.toArray(progressSlot), modes)
217
+ return processed.length === 1 ? processed[0] : processed
218
+ }, [progressSlot, modes])
219
+
220
+ const processedNudgeSlot = useMemo(() => {
221
+ if (!nudgeSlot) return null
222
+ const processed = cloneChildrenWithModes(React.Children.toArray(nudgeSlot), modes)
223
+ return processed.length === 1 ? processed[0] : processed
224
+ }, [nudgeSlot, modes])
225
+
226
+ const defaultAccessibilityLabel =
227
+ accessibilityLabel ?? `${title}. ${description}. ${Math.round(value)} out of 100. ${nudgeBody}`
228
+
229
+ return (
230
+ <View
231
+ accessibilityLabel={defaultAccessibilityLabel}
232
+ style={[tokens.containerStyle, style]}
233
+ {...rest}
234
+ >
235
+ <View style={[tokens.mainContentStyle, mainContentStyle]}>
236
+ <View style={tokens.contentStyle}>
237
+ <View style={tokens.headerStyle}>
238
+ <Text numberOfLines={1} style={[tokens.titleStyle, titleStyle]}>
239
+ {title}
240
+ </Text>
241
+ {processedTitleEndSlot || (showInfoIcon ? (
242
+ <Icon
243
+ name="ic_info"
244
+ size={tokens.iconSize}
245
+ color={tokens.iconColor}
246
+ accessibilityElementsHidden={true}
247
+ importantForAccessibility="no"
248
+ />
249
+ ) : null)}
250
+ </View>
251
+ <Text style={[tokens.descriptionStyle, descriptionStyle]}>
252
+ {description}
253
+ </Text>
254
+ </View>
255
+
256
+ {processedProgressSlot || (
257
+ <CircularProgressBar
258
+ state="Active"
259
+ value={value}
260
+ modes={modes}
261
+ style={progressStyle}
262
+ {...(valueLabel ? { valueLabel } : {})}
263
+ />
264
+ )}
265
+ </View>
266
+
267
+ {showNudge ? (
268
+ processedNudgeSlot || (
269
+ <Nudge
270
+ type="inline-compact"
271
+ body={nudgeBody}
272
+ buttonLabel={nudgeButtonLabel}
273
+ modes={modes}
274
+ style={[{ width: '100%' }, nudgeStyle]}
275
+ {...(onPressNudgeButton ? { onPressButton: onPressNudgeButton } : {})}
276
+ />
277
+ )
278
+ ) : null}
279
+ </View>
280
+ )
281
+ }
282
+
283
+ export default React.memo(CardAdvisory)
@@ -1,12 +1,36 @@
1
1
  import React from 'react'
2
2
  import { View, Text, type ViewStyle, type TextStyle, type StyleProp } from 'react-native'
3
- import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
3
+ import { findVariablesByPattern, getVariableByName } from '../../design-tokens/figma-variables-resolver'
4
4
  import { useTokens } from '../../design-tokens/JFSThemeProvider'
5
5
  import { cloneChildrenWithModes, EMPTY_MODES } from '../../utils/react-utils'
6
6
  import IconCapsule from '../IconCapsule/IconCapsule'
7
7
  import Button from '../Button/Button'
8
+ import Badge from '../Badge/Badge'
9
+ import ButtonGroup from '../ButtonGroup/ButtonGroup'
10
+ import IconButton from '../IconButton/IconButton'
11
+
12
+ export type CardCTAType = 'cta' | 'rating'
13
+
14
+ const optionalTokenAvailability = new Map<string, boolean>()
15
+
16
+ function getOptionalVariableByName<T>(name: string, modes: Record<string, any>, fallback: T): T {
17
+ let isAvailable = optionalTokenAvailability.get(name)
18
+
19
+ if (isAvailable === undefined) {
20
+ isAvailable = findVariablesByPattern(name).some((variable) => variable.name === name)
21
+ optionalTokenAvailability.set(name, isAvailable)
22
+ }
23
+
24
+ if (!isAvailable) {
25
+ return fallback
26
+ }
27
+
28
+ return (getVariableByName(name, modes) ?? fallback) as T
29
+ }
8
30
 
9
31
  export type CardCTAProps = {
32
+ /** Visual layout variant */
33
+ type?: CardCTAType;
10
34
  /** Title text */
11
35
  title?: string;
12
36
  /** Body / subtitle text */
@@ -17,29 +41,49 @@ export type CardCTAProps = {
17
41
  buttonLabel?: string;
18
42
  /** Callback for the default Button press */
19
43
  onPressButton?: () => void;
44
+ /** Label shown in the rating badge */
45
+ ratingLabel?: string;
46
+ /** Show like/dislike actions in the rating footer */
47
+ showRatingActions?: boolean;
48
+ /** Callback for the default like action */
49
+ onPressLike?: () => void;
50
+ /** Callback for the default dislike action */
51
+ onPressDislike?: () => void;
20
52
  /** Mode configuration for design token resolution */
21
53
  modes?: Record<string, any>;
22
54
  /** Slot: replaces the default icon area (right side) */
23
55
  iconSlot?: React.ReactNode;
24
56
  /** Slot: replaces the default Button */
25
57
  buttonSlot?: React.ReactNode;
58
+ /** Slot: replaces the default rating badge */
59
+ ratingBadgeSlot?: React.ReactNode;
60
+ /** Slot: replaces the default like/dislike action group */
61
+ ratingActionsSlot?: React.ReactNode;
26
62
  /** Container style overrides */
27
63
  style?: StyleProp<ViewStyle>;
28
64
  };
29
65
 
30
66
  function CardCTA({
67
+ type = 'cta',
31
68
  title = 'If you have 1 line',
32
69
  body = 'Then you can have up to 3 lines in the subtext as well. This is for demonstration.',
33
70
  iconName = 'ic_upi_number',
34
- buttonLabel = 'Button',
71
+ buttonLabel,
35
72
  onPressButton,
73
+ ratingLabel = '+28 Rating',
74
+ showRatingActions = true,
75
+ onPressLike,
76
+ onPressDislike,
36
77
  modes: propModes = EMPTY_MODES,
37
78
  iconSlot,
38
79
  buttonSlot,
80
+ ratingBadgeSlot,
81
+ ratingActionsSlot,
39
82
  style,
40
83
  }: CardCTAProps) {
41
84
  const { modes: globalModes } = useTokens()
42
85
  const modes = { ...globalModes, ...propModes }
86
+ const isRating = type === 'rating'
43
87
 
44
88
  const background = getVariableByName('cardCTA/background', modes) || '#ffffff'
45
89
  const radius = getVariableByName('cardCTA/radius', modes) || 12
@@ -69,13 +113,50 @@ function CardCTA({
69
113
  const bodyFontWeightRaw = getVariableByName('cardCTA/body/fontWeight', modes) || 400
70
114
  const bodyFontWeight = typeof bodyFontWeightRaw === 'number' ? bodyFontWeightRaw.toString() : bodyFontWeightRaw
71
115
 
116
+ const ratingContentGap = getOptionalVariableByName('cardCTA/rating/content/gap', modes, 12)
117
+ const ratingContentPaddingH = getOptionalVariableByName('cardCTA/rating/content/padding/horizontal', modes, 16)
118
+ const ratingContentPaddingV = getOptionalVariableByName('cardCTA/rating/content/padding/vertical', modes, 12)
119
+
120
+ const ratingFooterPaddingH = getOptionalVariableByName('cardCTA/rating/footer/horizontal', modes, 16)
121
+ const ratingFooterPaddingTop = getOptionalVariableByName('cardCTA/rating/footer/top', modes, 0)
122
+ const ratingFooterPaddingBottom = getOptionalVariableByName('cardCTA/rating/footer/bottom', modes, 12)
123
+
124
+ const buttonModes = {...modes, AppearanceBrand: 'Secondary', 'Button / Size': 'S'}
125
+ const iconButtonModes = {'Button / Size': 'S', 'Emphasis': 'Low', 'AppearanceBrand':'Neutral',...modes }
126
+ const effectiveButtonLabel = buttonLabel ?? (isRating ? 'Save' : 'Button')
127
+ const nonWrappingButtonLabel = effectiveButtonLabel.replace(/\s/g, '\u00A0')
128
+ const [measuredButtonLabelWidth, setMeasuredButtonLabelWidth] = React.useState<number | null>(null)
129
+
130
+ const buttonPaddingH = getVariableByName('button/padding/horizontal', buttonModes) || 20
131
+ const buttonBorderSize = getVariableByName('button/border/size', buttonModes) ?? 1
132
+ const measuredButtonWidth = measuredButtonLabelWidth === null
133
+ ? undefined
134
+ : Math.ceil(measuredButtonLabelWidth + (buttonPaddingH as number) * 2 + (buttonBorderSize as number) * 2)
135
+
136
+ const handleButtonLabelTextLayout = React.useCallback((event: any) => {
137
+ const lines = event?.nativeEvent?.lines
138
+ if (!Array.isArray(lines) || lines.length === 0) return
139
+
140
+ const nextWidth = Math.ceil(
141
+ lines.reduce((sum, line) => sum + (typeof line?.width === 'number' ? line.width : 0), 0)
142
+ )
143
+ if (nextWidth <= 0) return
144
+
145
+ setMeasuredButtonLabelWidth((currentWidth) => {
146
+ if (currentWidth !== null && Math.abs(currentWidth - nextWidth) < 1) {
147
+ return currentWidth
148
+ }
149
+ return nextWidth
150
+ })
151
+ }, [])
152
+
72
153
  const containerStyle: ViewStyle = {
73
154
  backgroundColor: background,
74
155
  borderRadius: radius,
75
156
  borderWidth: borderSize,
76
157
  borderColor,
77
- flexDirection: 'row',
78
- overflow: 'hidden',
158
+ flexDirection: isRating ? 'column' : 'row',
159
+ overflow: 'visible',
79
160
  }
80
161
 
81
162
  // NOTE: `minWidth: 0` + explicit `flexShrink: 1` are required on native.
@@ -93,6 +174,8 @@ function CardCTA({
93
174
  gap: leftGap,
94
175
  alignItems: 'flex-start',
95
176
  justifyContent: 'center',
177
+ overflow: 'visible',
178
+ zIndex: 1,
96
179
  }
97
180
 
98
181
  // NOTE: rightWrap must NOT shrink on native. On Android (Yoga), the default
@@ -123,6 +206,28 @@ function CardCTA({
123
206
  minWidth: 0,
124
207
  }
125
208
 
209
+ // Keep text shrink/wrap behavior on the left column, but let the CTA keep
210
+ // its own intrinsic width. On native, Yoga otherwise measures the Button
211
+ // with the left column's available width and the single-line label
212
+ // truncates even when the Button itself has no width/maxWidth constraint.
213
+ const buttonWrapStyle: ViewStyle = {
214
+ alignSelf: 'flex-start',
215
+ flexGrow: 0,
216
+ flexShrink: 0,
217
+ flexBasis: 'auto',
218
+ overflow: 'visible',
219
+ zIndex: 1,
220
+ }
221
+
222
+ const buttonStyle: ViewStyle = {
223
+ alignSelf: 'flex-start',
224
+ flexGrow: 0,
225
+ flexShrink: 0,
226
+ flexBasis: 'auto',
227
+ overflow: 'visible',
228
+ ...(measuredButtonWidth !== undefined ? { width: measuredButtonWidth } : {}),
229
+ }
230
+
126
231
  const titleStyle: TextStyle = {
127
232
  color: titleColor,
128
233
  fontFamily: titleFontFamily,
@@ -139,6 +244,113 @@ function CardCTA({
139
244
  fontWeight: bodyFontWeight,
140
245
  }
141
246
 
247
+ const ratingContentStyle: ViewStyle = {
248
+ paddingHorizontal: ratingContentPaddingH,
249
+ paddingVertical: ratingContentPaddingV,
250
+ gap: ratingContentGap,
251
+ alignItems: 'flex-start',
252
+ }
253
+
254
+ const ratingFooterStyle: ViewStyle = {
255
+ flexDirection: 'row',
256
+ alignItems: 'flex-start',
257
+ justifyContent: 'space-between',
258
+ paddingHorizontal: ratingFooterPaddingH,
259
+ paddingTop: ratingFooterPaddingTop,
260
+ paddingBottom: ratingFooterPaddingBottom,
261
+ overflow: 'visible',
262
+ }
263
+
264
+ const buttonLabelStyle: TextStyle = {
265
+ flexGrow: 0,
266
+ flexShrink: 0,
267
+ flexWrap: 'nowrap',
268
+ }
269
+
270
+ // Keep the rating CTA on an overflow-visible, non-shrinking path. The
271
+ // footer's row width stays fixed by the card, but Yoga must not use that
272
+ // width to shrink or clip the button label.
273
+ const ratingButtonWrapStyle: ViewStyle = {
274
+ flexGrow: 0,
275
+ flexShrink: 0,
276
+ flexBasis: 'auto',
277
+ alignItems: 'flex-start',
278
+ overflow: 'visible',
279
+ }
280
+
281
+ const ratingButtonStyle: ViewStyle = {
282
+ alignSelf: 'flex-start',
283
+ flexGrow: 0,
284
+ flexShrink: 0,
285
+ flexBasis: 'auto',
286
+ overflow: 'visible',
287
+ ...(measuredButtonWidth !== undefined ? { width: measuredButtonWidth } : {}),
288
+ }
289
+
290
+ const ratingButtonLabelStyle: TextStyle = {
291
+ flexGrow: 0,
292
+ flexShrink: 0,
293
+ flexWrap: 'nowrap',
294
+ }
295
+
296
+ if (isRating) {
297
+ return (
298
+ <View style={[containerStyle, style]}>
299
+ <View style={ratingContentStyle}>
300
+ {ratingBadgeSlot ? (
301
+ cloneChildrenWithModes(ratingBadgeSlot, modes)
302
+ ) : (
303
+ <Badge label={ratingLabel} modes={modes} />
304
+ )}
305
+ <View style={textWrapStyle}>
306
+ <Text style={titleStyle}>{title}</Text>
307
+ <Text style={bodyStyle}>{body}</Text>
308
+ </View>
309
+ </View>
310
+ <View style={ratingFooterStyle}>
311
+ <View style={ratingButtonWrapStyle}>
312
+ {buttonSlot ? (
313
+ cloneChildrenWithModes(buttonSlot, buttonModes)
314
+ ) : (
315
+ <Button
316
+ label={effectiveButtonLabel}
317
+ onPress={onPressButton || (() => {})}
318
+ modes={buttonModes}
319
+ style={ratingButtonStyle}
320
+ renderContent={(labelStyles) => (
321
+ <Text
322
+ onTextLayout={handleButtonLabelTextLayout}
323
+ style={[labelStyles, ratingButtonLabelStyle]}
324
+ >
325
+ {nonWrappingButtonLabel}
326
+ </Text>
327
+ )}
328
+ />
329
+ )}
330
+ </View>
331
+ {showRatingActions ? (
332
+ ratingActionsSlot ? (
333
+ cloneChildrenWithModes(ratingActionsSlot, iconButtonModes)
334
+ ) : (
335
+ <ButtonGroup modes={iconButtonModes}>
336
+ <IconButton
337
+ iconName="ic_like"
338
+ accessibilityLabel="Like"
339
+ {...(onPressLike ? { onPress: onPressLike } : {})}
340
+ />
341
+ <IconButton
342
+ iconName="ic_dislike"
343
+ accessibilityLabel="Dislike"
344
+ {...(onPressDislike ? { onPress: onPressDislike } : {})}
345
+ />
346
+ </ButtonGroup>
347
+ )
348
+ ) : null}
349
+ </View>
350
+ </View>
351
+ )
352
+ }
353
+
142
354
  return (
143
355
  <View style={[containerStyle, style]}>
144
356
  <View style={leftWrapStyle}>
@@ -146,15 +358,26 @@ function CardCTA({
146
358
  <Text style={titleStyle}>{title}</Text>
147
359
  <Text style={bodyStyle}>{body}</Text>
148
360
  </View>
149
- {buttonSlot ? (
150
- cloneChildrenWithModes(buttonSlot, {...modes, AppearanceBrand: 'Secondary', 'Button / Size': 'S'})
151
- ) : (
152
- <Button
153
- label={buttonLabel}
154
- onPress={onPressButton || (() => {})}
155
- modes={{...modes, AppearanceBrand: 'Secondary', 'Button / Size': 'S'}}
156
- />
157
- )}
361
+ <View style={buttonWrapStyle}>
362
+ {buttonSlot ? (
363
+ cloneChildrenWithModes(buttonSlot, buttonModes)
364
+ ) : (
365
+ <Button
366
+ label={effectiveButtonLabel}
367
+ onPress={onPressButton || (() => {})}
368
+ modes={buttonModes}
369
+ style={buttonStyle}
370
+ renderContent={(labelStyles) => (
371
+ <Text
372
+ onTextLayout={handleButtonLabelTextLayout}
373
+ style={[labelStyles, buttonLabelStyle]}
374
+ >
375
+ {nonWrappingButtonLabel}
376
+ </Text>
377
+ )}
378
+ />
379
+ )}
380
+ </View>
158
381
  </View>
159
382
  <View style={rightWrapStyle}>
160
383
  {iconSlot ? (