jfs-components 0.1.19 → 0.1.25

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 (55) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/lib/commonjs/components/CardCTA/CardCTA.js +32 -25
  3. package/lib/commonjs/components/CheckboxItem/CheckboxItem.js +31 -8
  4. package/lib/commonjs/components/ChipSelect/ChipSelect.js +2 -1
  5. package/lib/commonjs/components/ContentSheet/ContentSheet.js +131 -0
  6. package/lib/commonjs/components/HeroSection/HeroSection.js +165 -0
  7. package/lib/commonjs/components/Image/Image.js +33 -7
  8. package/lib/commonjs/components/Link/Link.js +115 -0
  9. package/lib/commonjs/components/ListItem/ListItem.js +27 -5
  10. package/lib/commonjs/components/MetricData/MetricData.js +132 -0
  11. package/lib/commonjs/components/Rating/Rating.js +137 -0
  12. package/lib/commonjs/components/index.js +21 -0
  13. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  14. package/lib/commonjs/design-tokens/figma-modes.generated.js +55 -43
  15. package/lib/commonjs/icons/registry.js +1 -1
  16. package/lib/module/components/CardCTA/CardCTA.js +32 -25
  17. package/lib/module/components/CheckboxItem/CheckboxItem.js +31 -8
  18. package/lib/module/components/ChipSelect/ChipSelect.js +2 -1
  19. package/lib/module/components/ContentSheet/ContentSheet.js +126 -0
  20. package/lib/module/components/HeroSection/HeroSection.js +159 -0
  21. package/lib/module/components/Image/Image.js +34 -7
  22. package/lib/module/components/Link/Link.js +110 -0
  23. package/lib/module/components/ListItem/ListItem.js +27 -5
  24. package/lib/module/components/MetricData/MetricData.js +127 -0
  25. package/lib/module/components/Rating/Rating.js +132 -0
  26. package/lib/module/components/index.js +3 -0
  27. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  28. package/lib/module/design-tokens/figma-modes.generated.js +55 -43
  29. package/lib/module/icons/registry.js +1 -1
  30. package/lib/typescript/src/components/CheckboxItem/CheckboxItem.d.ts +26 -3
  31. package/lib/typescript/src/components/ChipSelect/ChipSelect.d.ts +6 -1
  32. package/lib/typescript/src/components/ContentSheet/ContentSheet.d.ts +71 -0
  33. package/lib/typescript/src/components/HeroSection/HeroSection.d.ts +80 -0
  34. package/lib/typescript/src/components/Image/Image.d.ts +27 -2
  35. package/lib/typescript/src/components/Link/Link.d.ts +73 -0
  36. package/lib/typescript/src/components/MetricData/MetricData.d.ts +53 -0
  37. package/lib/typescript/src/components/Rating/Rating.d.ts +30 -0
  38. package/lib/typescript/src/components/index.d.ts +3 -0
  39. package/lib/typescript/src/design-tokens/figma-modes.generated.d.ts +8 -0
  40. package/lib/typescript/src/icons/registry.d.ts +1 -1
  41. package/package.json +1 -1
  42. package/src/components/CardCTA/CardCTA.tsx +30 -15
  43. package/src/components/CheckboxItem/CheckboxItem.tsx +59 -14
  44. package/src/components/ChipSelect/ChipSelect.tsx +7 -1
  45. package/src/components/ContentSheet/ContentSheet.tsx +217 -0
  46. package/src/components/HeroSection/HeroSection.tsx +231 -0
  47. package/src/components/Image/Image.tsx +55 -3
  48. package/src/components/Link/Link.tsx +159 -0
  49. package/src/components/ListItem/ListItem.tsx +26 -4
  50. package/src/components/MetricData/MetricData.tsx +185 -0
  51. package/src/components/Rating/Rating.tsx +174 -0
  52. package/src/components/index.ts +3 -0
  53. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  54. package/src/design-tokens/figma-modes.generated.ts +55 -43
  55. package/src/icons/registry.ts +1 -1
@@ -0,0 +1,159 @@
1
+ import React, { useCallback } from 'react'
2
+ import { Text as RNText, type TextStyle, type StyleProp, type GestureResponderEvent } from 'react-native'
3
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
4
+ import { EMPTY_MODES, resolveTextLayout } from '../../utils/react-utils'
5
+ import type { Modes } from '../../design-tokens'
6
+
7
+ export type LinkProps = {
8
+ /**
9
+ * The link label. You may also pass content as JSX children
10
+ * (e.g. `<Link>Terms</Link>`); when both are provided, `children` wins.
11
+ * Mirrors the {@link Text} API so `Link` is a near-drop-in, underlined
12
+ * sibling of `Text`.
13
+ */
14
+ text?: string
15
+ /** Child nodes (string or nested `Text`/`Link`). Wins over `text`. */
16
+ children?: React.ReactNode
17
+ /**
18
+ * Called when the link is activated. Navigation is the consumer's job
19
+ * (e.g. `Linking.openURL`, a router push) — `Link` is intentionally
20
+ * navigation-agnostic and React Native friendly (no `href`/`target`).
21
+ */
22
+ onPress?: (event: GestureResponderEvent) => void
23
+ /** Disables interaction and dims the label. */
24
+ disabled?: boolean
25
+ /**
26
+ * Layout sizing (matches the Figma `autolayout` variant).
27
+ * - `Fill` (default): the label stretches to fill the parent's width, so
28
+ * `textAlign="Center"` is visible and the whole line is tappable.
29
+ * - `Hug`: the label hugs its content width.
30
+ */
31
+ autolayout?: 'Fill' | 'Hug'
32
+ /** Horizontal alignment of the label (matches `Text`). */
33
+ textAlign?: 'Left' | 'Center'
34
+ /** Modes configuration for design token resolution (cascades from `TextSegment`). */
35
+ modes?: Modes
36
+ /** Style override for the label. */
37
+ style?: StyleProp<TextStyle>
38
+ /** Number of lines to limit the label to. */
39
+ numberOfLines?: number
40
+ /** Never truncate — drops the clamp/ellipsis and lets the label overflow. See {@link Text}. */
41
+ disableTruncation?: boolean
42
+ /** Force a single non-wrapping line (implies `disableTruncation`). See {@link Text}. */
43
+ singleLine?: boolean
44
+ /** Accessibility label; defaults to the resolved text content. */
45
+ accessibilityLabel?: string
46
+ /** Accessibility hint describing what activating the link does. */
47
+ accessibilityHint?: string
48
+ }
49
+
50
+ const TEXT_ALIGN_MAP: Record<NonNullable<LinkProps['textAlign']>, TextStyle['textAlign']> = {
51
+ Left: 'left',
52
+ Center: 'center',
53
+ }
54
+
55
+ const DISABLED_OPACITY = 0.4
56
+
57
+ /**
58
+ * Link — an underlined, pressable text primitive.
59
+ *
60
+ * It renders a single React Native `<Text>` (not a `Pressable`), so it flows
61
+ * inline and can be nested inside {@link TextSegment} exactly like a `Text`
62
+ * run — the React Native equivalent of an `<a>` inside a `<p>`. Font family,
63
+ * size, weight, line-height and letter-spacing come from the dedicated `link/*`
64
+ * tokens, while the colour resolves from `text/foreground` (so a link sits on
65
+ * the same colour as the surrounding copy). The label is always underlined.
66
+ *
67
+ * @example Standalone
68
+ * ```tsx
69
+ * <Link text="Forgot PIN?" onPress={() => navigation.navigate('ResetPin')} />
70
+ * ```
71
+ *
72
+ * @example Inline inside TextSegment
73
+ * ```tsx
74
+ * <TextSegment>
75
+ * <Text>By continuing you agree to our </Text>
76
+ * <Link onPress={openTerms}>Terms</Link>
77
+ * <Text>.</Text>
78
+ * </TextSegment>
79
+ * ```
80
+ */
81
+ function Link({
82
+ text,
83
+ children,
84
+ onPress,
85
+ disabled = false,
86
+ autolayout = 'Fill',
87
+ textAlign = 'Left',
88
+ modes = EMPTY_MODES,
89
+ style,
90
+ numberOfLines,
91
+ disableTruncation,
92
+ singleLine,
93
+ accessibilityLabel,
94
+ accessibilityHint,
95
+ }: LinkProps) {
96
+ // Bindings mirror the Figma `Link` node exactly: colour from `text/foreground`
97
+ // (so the link matches the surrounding copy), everything else from the
98
+ // dedicated `link/*` tokens.
99
+ const foreground = getVariableByName('text/foreground', modes) ?? '#000000'
100
+ const fontFamily = getVariableByName('link/fontFamily', modes) ?? 'JioType Var'
101
+ const fontSize = getVariableByName('link/fontSize', modes) ?? 12
102
+ const fontWeight = getVariableByName('link/fontWeight', modes) ?? 400
103
+ const lineHeight = getVariableByName('link/lineHeight', modes) ?? 16
104
+ const letterSpacing = getVariableByName('link/letterSpacing', modes) ?? -0.5
105
+
106
+ const linkStyle: TextStyle = {
107
+ color: foreground as string,
108
+ fontFamily: fontFamily as string,
109
+ fontSize: fontSize as number,
110
+ fontWeight: String(fontWeight) as TextStyle['fontWeight'],
111
+ lineHeight: lineHeight as number,
112
+ letterSpacing: letterSpacing as number,
113
+ textAlign: TEXT_ALIGN_MAP[textAlign],
114
+ textDecorationLine: 'underline',
115
+ alignSelf: autolayout === 'Fill' ? 'stretch' : 'flex-start',
116
+ ...(disabled ? { opacity: DISABLED_OPACITY } : null),
117
+ }
118
+
119
+ const content =
120
+ children !== undefined && children !== null && children !== false
121
+ ? children
122
+ : text !== undefined
123
+ ? text
124
+ : 'Link'
125
+
126
+ const handlePress = useCallback(
127
+ (event: GestureResponderEvent) => {
128
+ if (disabled) return
129
+ onPress?.(event)
130
+ },
131
+ [disabled, onPress]
132
+ )
133
+
134
+ const { style: layoutStyle, ...truncation } = resolveTextLayout({
135
+ disableTruncation,
136
+ singleLine,
137
+ numberOfLines,
138
+ })
139
+
140
+ const resolvedLabel =
141
+ accessibilityLabel ?? (typeof content === 'string' ? content : undefined)
142
+
143
+ return (
144
+ <RNText
145
+ accessibilityRole="link"
146
+ accessibilityState={{ disabled }}
147
+ {...(resolvedLabel !== undefined ? { accessibilityLabel: resolvedLabel } : null)}
148
+ {...(accessibilityHint !== undefined ? { accessibilityHint } : null)}
149
+ {...(onPress !== undefined ? { onPress: handlePress } : null)}
150
+ disabled={disabled}
151
+ style={[linkStyle, style, layoutStyle]}
152
+ {...truncation}
153
+ >
154
+ {content}
155
+ </RNText>
156
+ )
157
+ }
158
+
159
+ export default React.memo(Link)
@@ -107,6 +107,17 @@ function resolveListItemTokens(modes: Modes): ListItemTokens {
107
107
  const paddingRight = getVariableByName('listItem/padding/right', resolvedModes) ?? 0
108
108
  const textWrapGap = (getVariableByName('listItem/text wrap', resolvedModes) ?? 0) as number
109
109
 
110
+ // Container surface — driven by the `List Item Style` collection
111
+ // (Default | Boxed | Minimal) plus `ListItem State`, `Selectable`,
112
+ // `Page type` and `Color Mode`. In the default style these all resolve to a
113
+ // transparent background / no border / 0 radius, so existing usages are
114
+ // visually unchanged; the "Boxed" style paints a filled, rounded, bordered
115
+ // surface.
116
+ const backgroundColor = getVariableByName('listItem/background/color', resolvedModes)
117
+ const borderColor = getVariableByName('listItem/border/color', resolvedModes)
118
+ const borderWidth = (getVariableByName('listItem/borderWidth', resolvedModes) ?? 0) as number
119
+ const borderRadius = (getVariableByName('listItem/radius', resolvedModes) ?? 0) as number
120
+
110
121
  const titleColor = getVariableByName('listItem/title/color', textModes)
111
122
  const titleFontSize = getVariableByName('listItem/title/fontSize', textModes)
112
123
  const titleLineHeight = getVariableByName('listItem/title/lineHeight', textModes)
@@ -129,6 +140,10 @@ function resolveListItemTokens(modes: Modes): ListItemTokens {
129
140
  paddingBottom: paddingBottom as number,
130
141
  paddingLeft: paddingLeft as number,
131
142
  paddingRight: paddingRight as number,
143
+ backgroundColor: backgroundColor as string,
144
+ borderColor: borderColor as string,
145
+ borderWidth,
146
+ borderRadius,
132
147
  },
133
148
  horizontalLayoutStyle: {
134
149
  flexDirection: 'row',
@@ -307,9 +322,16 @@ function ListItemImpl({
307
322
  const renderSupportContent = () => {
308
323
  if (processedSupportSlot) return processedSupportSlot
309
324
 
310
- // Default support text:
311
- // - vertical layout: main text (line-broken on spaces)
312
- // - horizontal layout: secondary line (clamped to 2 lines)
325
+ // Default support text — ONE `<Text>` hard-clamped to 2 lines via
326
+ // `numberOfLines={2}`, the only mechanism React Native actually enforces
327
+ // on iOS/Android (native has no `white-space: nowrap` equivalent, so any
328
+ // style-based trick cannot stop a too-wide word from wrapping).
329
+ //
330
+ // - vertical layout: line-broken on spaces so each word gets its own line
331
+ // ("Split Payments" → "Split" / "Payments"). If a word is still wider
332
+ // than the cell (or there are 3+ words), RN cuts it off at line 2 with
333
+ // a trailing ellipsis instead of ever spilling onto a third line.
334
+ // - horizontal layout: natural wrapping, same 2-line clamp.
313
335
  const displayText = layout === 'Vertical'
314
336
  ? supportText.replace(/ /g, '\n')
315
337
  : supportText
@@ -321,7 +343,7 @@ function ListItemImpl({
321
343
  ? [tokens.supportTextStyle, verticalSupportTextOverride]
322
344
  : tokens.supportTextStyle
323
345
  }
324
- {...resolveTruncation(disableTruncation, layout === 'Horizontal' ? 2 : undefined)}
346
+ {...resolveTruncation(disableTruncation, 2, 'tail')}
325
347
  >
326
348
  {displayText}
327
349
  </Text>
@@ -0,0 +1,185 @@
1
+ import React from 'react'
2
+ import {
3
+ View,
4
+ Text,
5
+ type StyleProp,
6
+ type ViewStyle,
7
+ type TextStyle,
8
+ } from 'react-native'
9
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
10
+ import { EMPTY_MODES } from '../../utils/react-utils'
11
+ import Icon from '../Icon/Icon'
12
+ import type { Modes } from '../../design-tokens'
13
+
14
+ const LINE_HEIGHT_RATIO = 1.2
15
+ const ICON_SIZE = 18
16
+ // Figma renders the value-row icon with no surrounding padding; the shared Icon
17
+ // component pulls padding from `icon/padding/*` tokens, so we zero it here.
18
+ const ICON_PADDING_RESET: ViewStyle = {
19
+ paddingLeft: 0,
20
+ paddingRight: 0,
21
+ paddingTop: 0,
22
+ paddingBottom: 0,
23
+ }
24
+
25
+ export type MetricDataProps = {
26
+ /** Top label (small, 12px). Hidden when omitted. */
27
+ title?: string
28
+ /**
29
+ * The prominent value (20px bold). Pass a string, or any node (e.g. a
30
+ * `MoneyValue`) to compose a richer value.
31
+ */
32
+ value?: string | React.ReactNode
33
+ /** Sub-label below the value (12px, muted). Hidden when omitted. */
34
+ caption?: string
35
+ /**
36
+ * Optional glyph shown to the left of the value. Pass a registry icon name
37
+ * (e.g. `'ic_card'`) or a custom node. Rendered at 18px, tinted to the value
38
+ * colour. Hidden when omitted.
39
+ */
40
+ icon?: string | React.ReactNode
41
+ /** Design-token modes for theming. */
42
+ modes?: Modes
43
+ /** Override the container styles. */
44
+ style?: StyleProp<ViewStyle>
45
+ /** Override the title text styles. */
46
+ titleStyle?: StyleProp<TextStyle>
47
+ /** Override the value text styles. */
48
+ valueStyle?: StyleProp<TextStyle>
49
+ /** Override the caption text styles. */
50
+ captionStyle?: StyleProp<TextStyle>
51
+ /**
52
+ * Accessibility label. Defaults to the resolved `title`, `value` and
53
+ * `caption` text joined together.
54
+ */
55
+ accessibilityLabel?: string
56
+ }
57
+
58
+ /**
59
+ * MetricData — a compact, centered metric block.
60
+ *
61
+ * Stacks a small `title`, a prominent `value` (with an optional leading
62
+ * `icon`), and a muted `caption`. Typography, colours, gaps and padding all
63
+ * resolve from the `metricdata/*` design tokens. `title`, `caption` and `icon`
64
+ * are optional; only `value` is shown by default.
65
+ *
66
+ * @example
67
+ * ```tsx
68
+ * <MetricData title="Balance" value="₹1,20,000" caption="as of today" />
69
+ * <MetricData title="Cards" value="12" icon="ic_card" caption="active" />
70
+ * ```
71
+ */
72
+ function MetricData({
73
+ title,
74
+ value,
75
+ caption,
76
+ icon,
77
+ modes = EMPTY_MODES,
78
+ style,
79
+ titleStyle,
80
+ valueStyle,
81
+ captionStyle,
82
+ accessibilityLabel,
83
+ }: MetricDataProps) {
84
+ const titleColor = (getVariableByName('metricdata/title/color', modes) as string | null) ?? '#000000'
85
+ const titleFontFamily = (getVariableByName('metricdata/title/fontfamily', modes) as string | null) ?? 'JioType Var'
86
+ const titleFontSize = (getVariableByName('metricdata/title/fontsize', modes) as number | null) ?? 12
87
+ const titleFontWeight = String(getVariableByName('metricdata/title/fontweight', modes) ?? 400) as TextStyle['fontWeight']
88
+
89
+ const valueColor = (getVariableByName('metricdata/value/color', modes) as string | null) ?? '#000000'
90
+ const valueFontFamily = (getVariableByName('metricdata/value/fontfamily', modes) as string | null) ?? 'JioType Var'
91
+ const valueFontSize = (getVariableByName('metricdata/value/fontsize', modes) as number | null) ?? 20
92
+ const valueFontWeight = String(getVariableByName('metricdata/value/fontweight', modes) ?? 700) as TextStyle['fontWeight']
93
+
94
+ const captionColor = (getVariableByName('metricdata/caption/color', modes) as string | null) ?? '#777777'
95
+ const captionFontFamily = (getVariableByName('metricdata/caption/fontfamily', modes) as string | null) ?? 'JioType Var'
96
+ const captionFontSize = (getVariableByName('metricdata/caption/fontsize', modes) as number | null) ?? 12
97
+ const captionFontWeight = String(getVariableByName('metricdata/caption/fontweight', modes) ?? 500) as TextStyle['fontWeight']
98
+
99
+ const gap = (getVariableByName('metricdata/gap', modes) as number | null) ?? 4
100
+ const valueWrapGap = (getVariableByName('metricdata/valueWrap/gap', modes) as number | null) ?? 4
101
+ const paddingHorizontal = (getVariableByName('metricdata/padding/horizontal', modes) as number | null) ?? 10
102
+ const paddingVertical = (getVariableByName('metricdata/padding/vertical', modes) as number | null) ?? 10
103
+
104
+ const containerStyle: ViewStyle = {
105
+ flexDirection: 'column',
106
+ alignItems: 'center',
107
+ justifyContent: 'center',
108
+ gap,
109
+ paddingHorizontal,
110
+ paddingVertical,
111
+ }
112
+
113
+ const titleTextStyle: TextStyle = {
114
+ color: titleColor,
115
+ fontFamily: titleFontFamily,
116
+ fontSize: titleFontSize,
117
+ fontWeight: titleFontWeight,
118
+ lineHeight: titleFontSize * LINE_HEIGHT_RATIO,
119
+ textAlign: 'center',
120
+ }
121
+
122
+ const valueTextStyle: TextStyle = {
123
+ color: valueColor,
124
+ fontFamily: valueFontFamily,
125
+ fontSize: valueFontSize,
126
+ fontWeight: valueFontWeight,
127
+ lineHeight: valueFontSize * LINE_HEIGHT_RATIO,
128
+ textAlign: 'center',
129
+ }
130
+
131
+ const captionTextStyle: TextStyle = {
132
+ color: captionColor,
133
+ fontFamily: captionFontFamily,
134
+ fontSize: captionFontSize,
135
+ fontWeight: captionFontWeight,
136
+ lineHeight: captionFontSize * LINE_HEIGHT_RATIO,
137
+ textAlign: 'center',
138
+ }
139
+
140
+ const iconNode =
141
+ icon == null ? null : typeof icon === 'string' ? (
142
+ <Icon
143
+ iconName={icon}
144
+ size={ICON_SIZE}
145
+ color={valueColor}
146
+ modes={modes}
147
+ style={ICON_PADDING_RESET}
148
+ />
149
+ ) : (
150
+ icon
151
+ )
152
+
153
+ const resolvedLabel =
154
+ accessibilityLabel ??
155
+ ([title, typeof value === 'string' ? value : undefined, caption]
156
+ .filter(Boolean)
157
+ .join(', ') || undefined)
158
+
159
+ return (
160
+ <View
161
+ style={[containerStyle, style]}
162
+ accessible
163
+ {...(resolvedLabel !== undefined ? { accessibilityLabel: resolvedLabel } : null)}
164
+ >
165
+ {title !== undefined && title !== '' ? (
166
+ <Text style={[titleTextStyle, titleStyle]}>{title}</Text>
167
+ ) : null}
168
+
169
+ <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: valueWrapGap }}>
170
+ {iconNode}
171
+ {typeof value === 'string' ? (
172
+ <Text style={[valueTextStyle, valueStyle]}>{value}</Text>
173
+ ) : (
174
+ value
175
+ )}
176
+ </View>
177
+
178
+ {caption !== undefined && caption !== '' ? (
179
+ <Text style={[captionTextStyle, captionStyle]}>{caption}</Text>
180
+ ) : null}
181
+ </View>
182
+ )
183
+ }
184
+
185
+ export default React.memo(MetricData)
@@ -0,0 +1,174 @@
1
+ import React, { useMemo } from 'react'
2
+ import {
3
+ Pressable,
4
+ View,
5
+ type StyleProp,
6
+ type ViewStyle,
7
+ } from 'react-native'
8
+ import Svg, { Path } from 'react-native-svg'
9
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
10
+ import { useTokens } from '../../design-tokens/JFSThemeProvider'
11
+ import { EMPTY_MODES } from '../../utils/react-utils'
12
+ import type { Modes } from '../../design-tokens'
13
+
14
+ /**
15
+ * Star glyph taken directly from the Figma `Rating` component. Filled and empty
16
+ * stars share this shape and differ only by fill color, so a single path keeps
17
+ * the row perfectly aligned.
18
+ */
19
+ const STAR_PATH =
20
+ 'M40.7187 15.3334C40.4728 14.6159 40.0426 13.9805 39.4688 13.4885C38.8951 12.9965 38.1985 12.689 37.4403 12.566L28.3018 11.1721L24.2038 2.35741C23.8759 1.66044 23.3432 1.06596 22.6875 0.635476C22.0318 0.225491 21.2737 0 20.4951 0C19.7165 0 18.9583 0.225491 18.3027 0.635476C17.647 1.04546 17.1142 1.63994 16.7864 2.35741L12.6884 11.1721L3.4679 12.566C2.73026 12.689 2.01311 12.9965 1.43939 13.4885C0.865675 13.9805 0.435385 14.6159 0.189505 15.3334C-0.0358854 16.0304 -0.0563753 16.7889 0.107545 17.5063C0.271465 18.2238 0.640285 18.9003 1.15254 19.4333L7.89374 26.362L6.29552 36.1811C6.17258 36.9396 6.27503 37.7186 6.56189 38.4156C6.84875 39.1125 7.34051 39.7275 7.9757 40.1785C8.65187 40.691 9.49196 40.978 10.3525 40.9985C11.0697 40.9985 11.7664 40.8345 12.4015 40.486L20.5771 35.9761L28.7526 40.486C29.3673 40.8345 30.0844 41.019 30.8016 40.9985C31.6621 40.9985 32.4817 40.732 33.1784 40.24C33.7931 39.789 34.2849 39.1945 34.5922 38.4771C34.8791 37.7801 34.9815 37.0011 34.8586 36.2426L33.2604 26.4235L40.0016 19.4948C40.4933 18.9413 40.8007 18.2443 40.9441 17.5063C41.0671 16.7684 40.9851 16.0099 40.7187 15.3334Z'
21
+ const STAR_VIEWBOX = '0 0 41 41'
22
+
23
+ // Fallbacks mirror the Figma `Rating Star / Output` + `Rating Star State` tokens.
24
+ const DEFAULT_STAR_SIZE = 41
25
+ const DEFAULT_GAP = 12
26
+ const SELECTED_FALLBACK = '#5D00B5'
27
+ const IDLE_FALLBACK = '#F6F3FF'
28
+
29
+ /**
30
+ * `ratingStar/background` is state-driven: the same token resolves to the filled
31
+ * color when `Rating Star State = selected` and to the empty color when `idle`.
32
+ */
33
+ const RATING_STATE_COLLECTION = 'Rating Star State'
34
+
35
+ export type RatingProps = {
36
+ /** Number of filled stars. Rounded to the nearest whole star for display. */
37
+ value?: number
38
+ /** Total number of stars to render. */
39
+ max?: number
40
+ /**
41
+ * Called with the new rating (1..max) when a star is pressed. Providing this
42
+ * makes the control interactive; omit it (or set `readOnly`) for a display.
43
+ */
44
+ onChange?: (value: number) => void
45
+ /** Force a read-only display even when `onChange` is provided. */
46
+ readOnly?: boolean
47
+ /** Override the star edge length. Defaults to the `ratingStar/width` token. */
48
+ starSize?: number
49
+ /** Override the spacing between stars. Defaults to the `rating/gap` token. */
50
+ gap?: number
51
+ /** Design token modes forwarded to token lookups. */
52
+ modes?: Modes
53
+ /** Optional container style overrides. */
54
+ style?: StyleProp<ViewStyle>
55
+ /** Accessibility label for the whole rating. Defaults to "X of Y stars". */
56
+ accessibilityLabel?: string
57
+ } & Omit<React.ComponentProps<typeof View>, 'style'>
58
+
59
+ const toNumber = (value: unknown, fallback: number) => {
60
+ const n = typeof value === 'string' ? Number(value) : value
61
+ return typeof n === 'number' && Number.isFinite(n) ? n : fallback
62
+ }
63
+
64
+ function Star({
65
+ width,
66
+ height,
67
+ color,
68
+ }: {
69
+ width: number
70
+ height: number
71
+ color: string
72
+ }) {
73
+ return (
74
+ <Svg width={width} height={height} viewBox={STAR_VIEWBOX}>
75
+ <Path d={STAR_PATH} fill={color} />
76
+ </Svg>
77
+ )
78
+ }
79
+
80
+ function Rating({
81
+ value = 0,
82
+ max = 5,
83
+ onChange,
84
+ readOnly = false,
85
+ starSize,
86
+ gap,
87
+ modes: propModes = EMPTY_MODES,
88
+ style,
89
+ accessibilityLabel,
90
+ ...rest
91
+ }: RatingProps) {
92
+ const { modes: globalModes } = useTokens()
93
+ const modes = useMemo(
94
+ () =>
95
+ globalModes === EMPTY_MODES && propModes === EMPTY_MODES
96
+ ? EMPTY_MODES
97
+ : { ...globalModes, ...propModes },
98
+ [globalModes, propModes]
99
+ )
100
+
101
+ const { selectedColor, idleColor, starWidth, starHeight, resolvedGap } =
102
+ useMemo(() => {
103
+ const selected = getVariableByName('ratingStar/background', {
104
+ ...modes,
105
+ [RATING_STATE_COLLECTION]: 'selected',
106
+ }) as string | undefined
107
+ const idle = getVariableByName('ratingStar/background', {
108
+ ...modes,
109
+ [RATING_STATE_COLLECTION]: 'idle',
110
+ }) as string | undefined
111
+
112
+ return {
113
+ selectedColor: selected ?? SELECTED_FALLBACK,
114
+ idleColor: idle ?? IDLE_FALLBACK,
115
+ starWidth:
116
+ starSize ??
117
+ toNumber(
118
+ getVariableByName('ratingStar/width', modes),
119
+ DEFAULT_STAR_SIZE
120
+ ),
121
+ starHeight:
122
+ starSize ??
123
+ toNumber(
124
+ getVariableByName('ratingStar/height', modes),
125
+ DEFAULT_STAR_SIZE
126
+ ),
127
+ resolvedGap:
128
+ gap ?? toNumber(getVariableByName('rating/gap', modes), DEFAULT_GAP),
129
+ }
130
+ }, [modes, starSize, gap])
131
+
132
+ const total = Math.max(0, Math.round(max))
133
+ const filledCount = Math.max(0, Math.min(total, Math.round(value)))
134
+ const interactive = !readOnly && typeof onChange === 'function'
135
+ const label = accessibilityLabel ?? `${filledCount} of ${total} stars`
136
+
137
+ return (
138
+ <View
139
+ {...rest}
140
+ style={[
141
+ { flexDirection: 'row', alignItems: 'center', gap: resolvedGap },
142
+ style,
143
+ ]}
144
+ accessibilityRole={interactive ? 'adjustable' : 'image'}
145
+ accessibilityLabel={label}
146
+ accessibilityValue={
147
+ interactive ? { min: 0, max: total, now: filledCount } : undefined
148
+ }
149
+ >
150
+ {Array.from({ length: total }, (_, index) => {
151
+ const color = index < filledCount ? selectedColor : idleColor
152
+ const star = <Star width={starWidth} height={starHeight} color={color} />
153
+
154
+ if (!interactive) {
155
+ return <React.Fragment key={index}>{star}</React.Fragment>
156
+ }
157
+
158
+ return (
159
+ <Pressable
160
+ key={index}
161
+ onPress={() => onChange?.(index + 1)}
162
+ accessibilityRole="button"
163
+ accessibilityLabel={`Rate ${index + 1} of ${total} stars`}
164
+ hitSlop={8}
165
+ >
166
+ {star}
167
+ </Pressable>
168
+ )
169
+ })}
170
+ </View>
171
+ )
172
+ }
173
+
174
+ export default React.memo(Rating)
@@ -36,6 +36,7 @@ export { default as FullscreenModal, type FullscreenModalProps } from './Fullscr
36
36
  export { default as Form, type FormProps } from './Form/Form';
37
37
  export { useFormContext } from './Form/Form';
38
38
  export { default as FormField, type FormFieldProps, type FormFieldType } from './FormField/FormField';
39
+ export { default as ContentSheet, type ContentSheetProps } from './ContentSheet/ContentSheet';
39
40
  export { default as CircularProgressBar, type CircularProgressBarProps } from './CircularProgressBar/CircularProgressBar'
40
41
  export { default as CircularProgressBarDoted, type CircularProgressBarDotedProps } from './CircularProgressBarDoted/CircularProgressBarDoted'
41
42
  export { default as CircularRating, type CircularRatingProps } from './CircularRating/CircularRating'
@@ -45,6 +46,7 @@ export { default as ComparisonBar, type ComparisonBarProps, type ComparisonBarIt
45
46
  export { default as AllocationComparisonChart, type AllocationComparisonChartProps, type AllocationSegment } from './AllocationComparisonChart/AllocationComparisonChart'
46
47
  export { default as MonthlyStatusGrid, CalendarGlyph, type MonthlyStatusGridProps, type MonthlyStatusGridMonth, type MonthlyStatus, type CalendarGlyphProps } from './MonthlyStatusGrid/MonthlyStatusGrid'
47
48
  export { default as Gauge, type GaugeProps } from './Gauge/Gauge';
49
+ export { default as HeroSection, type HeroSectionProps } from './HeroSection/HeroSection';
48
50
  export { default as HoldingsCard, type HoldingsCardProps } from './HoldingsCard/HoldingsCard';
49
51
  export { default as HStack, type HStackProps } from './HStack/HStack';
50
52
  export { default as Icon, type IconProps } from './Icon/Icon';
@@ -52,6 +54,7 @@ export { default as IconButton } from './IconButton/IconButton';
52
54
  export { default as IconCapsule } from './IconCapsule/IconCapsule';
53
55
  export { default as Image, type ImageProps } from './Image/Image';
54
56
  export { default as LazyList } from './LazyList/LazyList';
57
+ export { default as Link, type LinkProps } from './Link/Link';
55
58
  export { default as LinearMeter, type LinearMeterProps } from './LinearMeter/LinearMeter';
56
59
  export { default as LinearProgress, type LinearProgressProps } from './LinearProgress/LinearProgress';
57
60
  export { default as ListGroup } from './ListGroup/ListGroup';