jfs-components 0.0.85 → 0.0.95

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 (28) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/lib/commonjs/assets.d.js +1 -0
  3. package/lib/commonjs/components/AllocationComparisonChart/AllocationComparisonChart.js +299 -0
  4. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +104 -94
  5. package/lib/commonjs/components/Icon/Icon.js +112 -0
  6. package/lib/commonjs/components/index.js +14 -0
  7. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  8. package/lib/commonjs/icons/registry.js +1 -1
  9. package/lib/module/assets.d.js +1 -0
  10. package/lib/module/components/AllocationComparisonChart/AllocationComparisonChart.js +293 -0
  11. package/lib/module/components/FullscreenModal/FullscreenModal.js +106 -96
  12. package/lib/module/components/Icon/Icon.js +106 -0
  13. package/lib/module/components/index.js +2 -0
  14. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  15. package/lib/module/icons/registry.js +1 -1
  16. package/lib/typescript/src/components/AllocationComparisonChart/AllocationComparisonChart.d.ts +118 -0
  17. package/lib/typescript/src/components/FullscreenModal/FullscreenModal.d.ts +39 -29
  18. package/lib/typescript/src/components/Icon/Icon.d.ts +75 -0
  19. package/lib/typescript/src/components/index.d.ts +2 -0
  20. package/lib/typescript/src/icons/registry.d.ts +1 -1
  21. package/package.json +1 -1
  22. package/src/assets.d.ts +24 -0
  23. package/src/components/AllocationComparisonChart/AllocationComparisonChart.tsx +450 -0
  24. package/src/components/FullscreenModal/FullscreenModal.tsx +131 -126
  25. package/src/components/Icon/Icon.tsx +167 -0
  26. package/src/components/index.ts +2 -0
  27. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  28. package/src/icons/registry.ts +1 -1
@@ -1,19 +1,12 @@
1
- import React, { useMemo } from 'react'
1
+ import React, { useMemo, useRef } from 'react'
2
2
  import {
3
3
  View,
4
4
  Text,
5
- ScrollView,
5
+ Animated,
6
6
  type StyleProp,
7
7
  type ViewStyle,
8
8
  type TextStyle,
9
9
  } from 'react-native'
10
- import Animated, {
11
- Extrapolation,
12
- interpolate,
13
- useAnimatedScrollHandler,
14
- useAnimatedStyle,
15
- useSharedValue,
16
- } from 'react-native-reanimated'
17
10
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
18
11
  import { useTokens } from '../../design-tokens/JFSThemeProvider'
19
12
  import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils'
@@ -37,16 +30,16 @@ import Slot from '../Slot/Slot'
37
30
  // ---------------------------------------------------------------------------
38
31
  const FULLSCREEN_MODAL_FORCED_MODES = Object.freeze({ context5: 'Fullscreen Modal' })
39
32
 
40
- // Reanimated-driven ScrollView so the parallax handler runs on the UI thread.
41
- // Module scope so the wrapped component identity is stable across renders.
42
- const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView)
43
-
44
- // Parallax tuning. The hero collapses by HEIGHT only as the user scrolls up —
45
- // its full width is preserved and the media keeps a fixed aspect ratio (it is
46
- // cropped, never scaled or squished, like a `cover` background). When no
47
- // explicit `heroMinHeight` is given, the hero collapses to this fraction of
48
- // its resting height.
49
- const HERO_MIN_HEIGHT_RATIO = 0.45
33
+ // ---------------------------------------------------------------------------
34
+ // Default modes
35
+ //
36
+ // A FullscreenModal is a "JioPlus" surface, so it defaults the `Page type`
37
+ // collection to `'JioPlus'`. Unlike the forced modes above this IS
38
+ // overridable it is applied before the caller's `modes`, so passing
39
+ // `modes={{ 'Page type': 'SubPage' }}` still wins. Frozen for stable identity
40
+ // (keeps the token resolver's per-modes cache hot).
41
+ // ---------------------------------------------------------------------------
42
+ const FULLSCREEN_MODAL_DEFAULT_MODES = Object.freeze({ 'Page type': 'JioPlus' })
50
43
 
51
44
  export type FullscreenModalProps = {
52
45
  /** Small eyebrow line above the headline. */
@@ -58,23 +51,23 @@ export type FullscreenModalProps = {
58
51
  /** Secondary line below the supporting paragraph (e.g. a price / timeline). */
59
52
  priceText?: string
60
53
  /**
61
- * Media rendered full-bleed behind the hero text and driven by the parallax
62
- * scroll effect. Bring any renderer most commonly a `LottiePlayer`, but an
63
- * `Image`, `Video`, or `SvgXml` works too. Size it to fill the hero box
64
- * (`heroHeight` tall, full modal width) and let it `cover` so that as the
65
- * hero collapses in height the art is cropped, never distorted. `modes` are
66
- * cascaded into it.
54
+ * Full-bleed background media for the whole modal. It is pinned to the top
55
+ * and laid out at the full modal width; size it with an aspect ratio
56
+ * (e.g. `<Image ratio={1080 / 4140} />`) so its height follows the width
57
+ * naturally. It renders as a single continuous background BEHIND both the
58
+ * hero text and the body content — there is no separate body box stacked on
59
+ * top of it. Bring any renderer — most commonly an `Image`, but a
60
+ * `LottiePlayer`, `Video`, or `SvgXml` works too. It never intercepts
61
+ * touches and the foreground content scrolls over it (no parallax).
62
+ * `modes` are cascaded into it.
67
63
  */
68
64
  heroMedia?: React.ReactNode
69
- /** Resting height of the hero region. Defaults to 420. */
70
- heroHeight?: number
71
65
  /**
72
- * Collapsed height the hero shrinks to at full scroll. Defaults to
73
- * `heroHeight * 0.45`. Only the height changes the width is always full.
66
+ * Height reserved for the hero text region (eyebrow / headline / supporting
67
+ * / price), whose content is anchored to the bottom. Applies whether or not
68
+ * `heroMedia` is present. Defaults to 420.
74
69
  */
75
- heroMinHeight?: number
76
- /** Enable the scroll-driven hero collapse. Defaults to true. */
77
- parallax?: boolean
70
+ heroHeight?: number
78
71
  /** Whether to render the floating close button (top-right). Defaults to true. */
79
72
  showClose?: boolean
80
73
  /** Press handler for the close button. */
@@ -92,15 +85,17 @@ export type FullscreenModalProps = {
92
85
  onPrimaryAction?: () => void
93
86
  /** Disclaimer text shown below the default primary action button. */
94
87
  disclaimer?: string
95
- /** Solid backdrop color for the scrollable body. Defaults to a near-black. */
96
- backgroundColor?: string
97
88
  /** Body content (typically `Section`s). `modes` are cascaded automatically. */
98
89
  children?: React.ReactNode
99
- /** Mode configuration. `context5` is always forced to `'Fullscreen Modal'`. */
90
+ /**
91
+ * Mode configuration. `Page type` defaults to `'JioPlus'` (overridable here),
92
+ * and `context5` is always forced to `'Fullscreen Modal'` (non-overridable).
93
+ * The resolved modes cascade to the body, hero media, and the `ActionFooter`.
94
+ */
100
95
  modes?: Record<string, any>
101
96
  /** Style overrides for the outer container. */
102
97
  style?: StyleProp<ViewStyle>
103
- /** Style overrides for the scroll body wrapper (the dark content area). */
98
+ /** Style overrides for the transparent body wrapper. */
104
99
  contentContainerStyle?: StyleProp<ViewStyle>
105
100
  testID?: string
106
101
  }
@@ -109,7 +104,7 @@ export type FullscreenModalProps = {
109
104
  // Hero text — the eyebrow / headline / supporting / price block. Built inline
110
105
  // (rather than reusing <PageHero>) so we can render BOTH a supporting
111
106
  // paragraph AND a price line with the exact PageHero token gaps, and overlay
112
- // it on the parallax media without PageHero's media/button scaffolding.
107
+ // it on the hero media without PageHero's media/button scaffolding.
113
108
  // ---------------------------------------------------------------------------
114
109
  type HeroTextProps = {
115
110
  eyebrow?: string
@@ -181,8 +176,9 @@ function HeroText({ eyebrow, headline, supportingText, priceText, modes }: HeroT
181
176
  }
182
177
 
183
178
  /**
184
- * FullscreenModal — a full-screen takeover surface with a parallax media hero,
185
- * a scrollable body, a floating close button, and a sticky `ActionFooter`.
179
+ * FullscreenModal — a full-screen takeover surface with a full-bleed media
180
+ * hero, a scrollable body, a floating close button, and a sticky
181
+ * `ActionFooter`.
186
182
  *
187
183
  * The component always themes itself with `context5: 'Fullscreen Modal'`
188
184
  * (non-overridable) so every nested component (Section, ListItem, Button,
@@ -190,14 +186,21 @@ function HeroText({ eyebrow, headline, supportingText, priceText, modes }: HeroT
190
186
  * That mode is cascaded into `children`, the footer, and the hero text via
191
187
  * `cloneChildrenWithModes` / the merged `modes` object.
192
188
  *
193
- * ### Parallax
194
- * As the user scrolls up, the hero collapses by **height only** (from
195
- * `heroHeight` to `heroMinHeight`) its **full width is always preserved**.
196
- * The `heroMedia` is pinned to the top at a fixed size and `cover`-cropped by
197
- * the collapsing clip, so it keeps a perfect aspect ratio the whole time
198
- * (never scaled or squished). Because it collapses slower than the content
199
- * scrolls, the media lags behind for the parallax depth cue. Disable with
200
- * `parallax={false}`.
189
+ * ### Background media
190
+ * The `heroMedia` is a single full-bleed background pinned to the top of the
191
+ * modal at the full width and its own natural aspect ratio. It lives at the
192
+ * ROOT behind both the scrolling content and the (transparent) footer so
193
+ * it fills the whole surface and is NEVER clipped to the content height. It
194
+ * also contributes ZERO scroll height: the scroll extent is driven purely by
195
+ * the in-flow foreground (hero text + `children`), so the number of body
196
+ * elements dictates how far the surface scrolls. It still scrolls in lockstep
197
+ * WITH the content (the background is translated by the scroll offset), so the
198
+ * content reads as sitting ON one continuous image that moves with it — there
199
+ * is no parallax and no separate solid body box.
200
+ *
201
+ * Pass a background sized to the full width at its natural ratio
202
+ * (e.g. `<Image imageSource={bg} ratio={1080 / 4140} />`). Use an asset at
203
+ * least as tall as the surface so it covers the full modal.
201
204
  *
202
205
  * @component
203
206
  * @example
@@ -207,7 +210,7 @@ function HeroText({ eyebrow, headline, supportingText, priceText, modes }: HeroT
207
210
  * headline="Get more from your money."
208
211
  * supportingText="JioFinance+ is your upgraded financial experience…"
209
212
  * priceText="₹999/year · ₹0 until 2027"
210
- * heroMedia={<LottiePlayer source={hero} size={{ width: 360, height: 420 }} />}
213
+ * heroMedia={<Image imageSource={hero} ratio={1080 / 4140} />}
211
214
  * primaryActionLabel="Upgrade for free"
212
215
  * disclaimer="By upgrading, we'll check your eligibility with Experian."
213
216
  * onPrimaryAction={() => upgrade()}
@@ -225,8 +228,6 @@ function FullscreenModal({
225
228
  priceText = '₹999/year · ₹0 until 2027',
226
229
  heroMedia,
227
230
  heroHeight = 420,
228
- heroMinHeight,
229
- parallax = true,
230
231
  showClose = true,
231
232
  onClose,
232
233
  closeAccessibilityLabel = 'Close',
@@ -234,7 +235,6 @@ function FullscreenModal({
234
235
  primaryActionLabel = 'Upgrade for free',
235
236
  onPrimaryAction,
236
237
  disclaimer = "By upgrading, we'll check your eligibility with Experian.",
237
- backgroundColor = '#0f0d0a',
238
238
  children,
239
239
  modes: propModes = EMPTY_MODES,
240
240
  style,
@@ -243,36 +243,37 @@ function FullscreenModal({
243
243
  }: FullscreenModalProps) {
244
244
  const { modes: globalModes } = useTokens()
245
245
 
246
- // context5 is appended last so it always wins, regardless of what the
247
- // caller (or the global theme) passes.
246
+ // Merge order (low high priority):
247
+ // global theme → component defaults (Page type: JioPlus) → caller modes →
248
+ // forced modes (context5). So `Page type` defaults to JioPlus but the
249
+ // caller can override it, while `context5` always wins. This single `modes`
250
+ // object is what cascades to the body, hero media, and the ActionFooter.
248
251
  const modes = useMemo(
249
- () => ({ ...globalModes, ...propModes, ...FULLSCREEN_MODAL_FORCED_MODES }),
252
+ () => ({
253
+ ...globalModes,
254
+ ...FULLSCREEN_MODAL_DEFAULT_MODES,
255
+ ...propModes,
256
+ ...FULLSCREEN_MODAL_FORCED_MODES,
257
+ }),
250
258
  [globalModes, propModes]
251
259
  )
252
260
 
253
261
  const rootGap = Number(getVariableByName('fullScreenModal/gap', modes)) || 16
254
262
 
255
- const minHeight = heroMinHeight ?? Math.round(heroHeight * HERO_MIN_HEIGHT_RATIO)
256
-
257
- const scrollY = useSharedValue(0)
258
- const onScroll = useAnimatedScrollHandler((event) => {
259
- scrollY.value = event.contentOffset.y
260
- })
261
-
262
- // Collapse the hero by HEIGHT only as the user scrolls up. The clip's width
263
- // never changes and the media inside is pinned full-size at the top, so the
264
- // art is cropped (cover) rather than scaled or narrowed — it keeps a perfect
265
- // aspect ratio the whole time. Pull-down (negative offset) is clamped, so the
266
- // hero never grows past its resting height.
267
- const heroAnimatedStyle = useAnimatedStyle(() => {
268
- const height = interpolate(
269
- scrollY.value,
270
- [0, heroHeight],
271
- [heroHeight, minHeight],
272
- Extrapolation.CLAMP
273
- )
274
- return { height }
275
- })
263
+ // Drives the background's parallax-free sync with the scroll. The hero media
264
+ // lives at the ROOT (so it is never clipped to the content height and sits
265
+ // behind the transparent footer), but we translate it up by the exact scroll
266
+ // offset so it moves in lockstep with the content — i.e. it scrolls WITH the
267
+ // body without ever contributing to the scroll height.
268
+ const scrollY = useRef(new Animated.Value(0)).current
269
+ const onScroll = useMemo(
270
+ () =>
271
+ Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
272
+ useNativeDriver: true,
273
+ }),
274
+ [scrollY]
275
+ )
276
+ const heroTranslateY = useMemo(() => Animated.multiply(scrollY, -1), [scrollY])
276
277
 
277
278
  const processedHeroMedia = useMemo(
278
279
  () =>
@@ -286,37 +287,13 @@ function FullscreenModal({
286
287
  [children, modes]
287
288
  )
288
289
 
289
- // The clip is full-width and top-pinned; its height is what animates. Width
290
- // is intentionally never animated.
291
- const heroClipBaseStyle = useMemo<ViewStyle>(
292
- () => ({
293
- position: 'absolute',
294
- top: 0,
295
- left: 0,
296
- right: 0,
297
- overflow: 'hidden',
298
- }),
299
- []
300
- )
301
-
302
- // The media sits at a fixed full-size box pinned to the top of the clip, so
303
- // the collapsing clip crops it from the bottom (cover) instead of resizing
304
- // it. Full width, fixed height — a perfect, constant aspect ratio.
305
- const heroMediaWrapStyle = useMemo<ViewStyle>(
306
- () => ({
307
- position: 'absolute',
308
- top: 0,
309
- left: 0,
310
- right: 0,
311
- height: heroHeight,
312
- alignItems: 'stretch',
313
- }),
314
- [heroHeight]
315
- )
316
-
290
+ // The hero text region always reserves `heroHeight` and anchors its content
291
+ // to the bottom, so the eyebrow/headline block sits in the lower part of the
292
+ // first screenful — over the background media when present, in flow
293
+ // otherwise.
317
294
  const heroTextRegionStyle = useMemo<ViewStyle>(
318
295
  () => ({
319
- height: heroHeight,
296
+ minHeight: heroHeight,
320
297
  justifyContent: 'flex-end',
321
298
  paddingHorizontal: 16,
322
299
  paddingBottom: 16,
@@ -324,26 +301,28 @@ function FullscreenModal({
324
301
  [heroHeight]
325
302
  )
326
303
 
304
+ // Body is intentionally transparent — the background media shows through
305
+ // behind it. There is no solid "body box" stacked on top of the image.
327
306
  const bodyStyle = useMemo<StyleProp<ViewStyle>>(
328
307
  () => [
329
308
  {
330
- backgroundColor,
331
309
  gap: rootGap,
332
310
  paddingTop: rootGap,
333
311
  paddingBottom: 24,
334
312
  },
335
313
  contentContainerStyle,
336
314
  ],
337
- [backgroundColor, rootGap, contentContainerStyle]
315
+ [rootGap, contentContainerStyle]
338
316
  )
339
317
 
340
- const heroClip = (
341
- <Animated.View
342
- style={[heroClipBaseStyle, parallax ? heroAnimatedStyle : { height: heroHeight }]}
343
- pointerEvents="none"
344
- >
345
- <View style={heroMediaWrapStyle}>{processedHeroMedia}</View>
346
- </Animated.View>
318
+ const heroTextNode = (
319
+ <HeroText
320
+ eyebrow={eyebrow}
321
+ headline={headline}
322
+ supportingText={supportingText}
323
+ priceText={priceText}
324
+ modes={modes}
325
+ />
347
326
  )
348
327
 
349
328
  // Footer: a fully custom node, or the default Button + Disclaimer column.
@@ -365,10 +344,32 @@ function FullscreenModal({
365
344
  }
366
345
 
367
346
  return (
368
- <View style={[rootStyle, { backgroundColor }, style]} testID={testID}>
369
- {processedHeroMedia ? heroClip : null}
347
+ <View style={[rootStyle, style]} testID={testID}>
348
+ {/*
349
+ * Layout model:
350
+ * - `processedHeroMedia` is a ROOT-LEVEL full-bleed background pinned to
351
+ * the top at full modal width and rendered at its own natural aspect
352
+ * ratio — it is NEVER clipped to the content height, so it extends as
353
+ * far down as its own height allows and fills the surface BEHIND both
354
+ * the scrolling content and the (transparent) footer. Because it lives
355
+ * outside the ScrollView it contributes ZERO scroll height, yet we
356
+ * translate it by the scroll offset (`heroTranslateY`) so it visually
357
+ * scrolls in lockstep WITH the content (no parallax, no clip).
358
+ * `pointerEvents="none"` lets touches reach the content/footer.
359
+ * - The ScrollView is transparent and holds only the foreground (hero
360
+ * text + body) IN FLOW, so the number of actual elements dictates how
361
+ * far the surface scrolls.
362
+ */}
363
+ {processedHeroMedia ? (
364
+ <Animated.View
365
+ style={[heroBackgroundStyle, { transform: [{ translateY: heroTranslateY }] }]}
366
+ pointerEvents="none"
367
+ >
368
+ {processedHeroMedia}
369
+ </Animated.View>
370
+ ) : null}
370
371
 
371
- <AnimatedScrollView
372
+ <Animated.ScrollView
372
373
  style={scrollViewStyle}
373
374
  contentContainerStyle={scrollContentStyle}
374
375
  showsVerticalScrollIndicator={false}
@@ -378,17 +379,13 @@ function FullscreenModal({
378
379
  // the keyboard is already open (default 'never' eats that tap).
379
380
  keyboardShouldPersistTaps="handled"
380
381
  >
381
- <View style={heroTextRegionStyle}>
382
- <HeroText
383
- eyebrow={eyebrow}
384
- headline={headline}
385
- supportingText={supportingText}
386
- priceText={priceText}
387
- modes={modes}
388
- />
382
+ <View style={foregroundFlowStyle}>
383
+ <View style={heroTextRegionStyle}>{heroTextNode}</View>
384
+ {processedChildren ? (
385
+ <View style={bodyStyle}>{processedChildren}</View>
386
+ ) : null}
389
387
  </View>
390
- <View style={bodyStyle}>{processedChildren}</View>
391
- </AnimatedScrollView>
388
+ </Animated.ScrollView>
392
389
 
393
390
  {footerContent ? (
394
391
  <ActionFooter modes={modes}>{footerContent}</ActionFooter>
@@ -413,5 +410,13 @@ const scrollViewStyle: ViewStyle = { flex: 1 }
413
410
  const scrollContentStyle: ViewStyle = { flexGrow: 1 }
414
411
  const fullWidthStyle: ViewStyle = { width: '100%' }
415
412
  const closeButtonStyle: ViewStyle = { position: 'absolute', top: 12, right: 12 }
413
+ // Root-level full-bleed background media. Pinned to the top at full modal
414
+ // width; the media inside keeps its own natural aspect ratio (only `top` is
415
+ // pinned — no `bottom`/`overflow` clip), so it is NEVER cut to the content
416
+ // height and fills the surface behind the scrolling content and the footer.
417
+ // Living outside the ScrollView, it adds nothing to the scroll height.
418
+ const heroBackgroundStyle: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0 }
419
+ // The foreground always flows normally — its content drives the scroll height.
420
+ const foregroundFlowStyle: ViewStyle = { width: '100%' }
416
421
 
417
422
  export default FullscreenModal
@@ -0,0 +1,167 @@
1
+ import React, { useMemo } from 'react'
2
+ import {
3
+ View,
4
+ type AccessibilityProps,
5
+ type StyleProp,
6
+ type ViewStyle,
7
+ } from 'react-native'
8
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
9
+ import { useTokens } from '../../design-tokens/JFSThemeProvider'
10
+ import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils'
11
+ import BaseIcon from '../../icons/Icon'
12
+ import type { UnifiedSource } from '../../utils/MediaSource'
13
+
14
+ type IconProps = AccessibilityProps & {
15
+ /**
16
+ * Built-in icon name from the registry, in the `ic_something` format
17
+ * (e.g. `'ic_card'`, `'ic_scan_qr_code'`). When omitted and no `source` or
18
+ * `children` slot is supplied, defaults to `'ic_card'` to match the Figma
19
+ * design's default glyph.
20
+ */
21
+ iconName?: string
22
+ /**
23
+ * Unified fallback source rendered when `iconName` is missing or not in the
24
+ * registry. Accepts a remote URI, an inline SVG XML string, a `require()`
25
+ * asset, an SVG React component, or an already-rendered element. The media
26
+ * is tinted with the mode-resolved icon color so it follows design tokens
27
+ * just like a built-in icon. See {@link UnifiedSource}.
28
+ */
29
+ source?: UnifiedSource
30
+ /**
31
+ * Icon slot. Render any node here (another `Icon`, a custom SVG component,
32
+ * etc.) and it takes precedence over `iconName`/`source`. `modes` cascade
33
+ * into the slotted children automatically.
34
+ */
35
+ children?: React.ReactNode
36
+ /**
37
+ * Override the mode-resolved icon color. When omitted the value comes from
38
+ * the `icon/color` design token.
39
+ */
40
+ color?: string
41
+ /**
42
+ * Override the mode-resolved icon size (in px). When omitted the value comes
43
+ * from the `icon/size` design token.
44
+ */
45
+ size?: number
46
+ modes?: Record<string, any>
47
+ style?: StyleProp<ViewStyle>
48
+ }
49
+
50
+ interface IconTokens {
51
+ containerStyle: ViewStyle
52
+ iconColor: string
53
+ iconSize: number
54
+ }
55
+
56
+ function resolveIconTokens(modes: Record<string, any>): IconTokens {
57
+ const iconColor = (getVariableByName('icon/color', modes) || '#ad8444') as string
58
+ const iconSize = (getVariableByName('icon/size', modes) || 18) as number
59
+ const paddingLeft = (getVariableByName('icon/padding/left', modes) || 0) as number
60
+ const paddingTop = (getVariableByName('icon/padding/top', modes) || 0) as number
61
+ const paddingRight = (getVariableByName('icon/padding/right', modes) || 0) as number
62
+ const paddingBottom = (getVariableByName('icon/padding/bottom', modes) || 0) as number
63
+
64
+ return {
65
+ containerStyle: {
66
+ flexDirection: 'column',
67
+ alignItems: 'center',
68
+ justifyContent: 'center',
69
+ overflow: 'hidden',
70
+ paddingLeft,
71
+ paddingTop,
72
+ paddingRight,
73
+ paddingBottom,
74
+ },
75
+ iconColor,
76
+ iconSize,
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Icon component — a design-token-driven wrapper around a single glyph.
82
+ *
83
+ * It mirrors the Figma "Icon" component: a padded, centered container whose
84
+ * color and size are resolved from the `icon/*` design tokens via `modes`.
85
+ * The glyph itself can be supplied three ways, in order of precedence:
86
+ *
87
+ * 1. `children` — a real slot for any node (custom SVG component, nested
88
+ * `Icon`, etc.). `modes` cascade into the slot automatically.
89
+ * 2. `iconName` — a registry icon in the `ic_something` format.
90
+ * 3. `source` — a {@link UnifiedSource} fallback (remote URI, inline SVG XML,
91
+ * `require()` asset, SVG component, or React element), tinted with the
92
+ * mode-resolved icon color.
93
+ *
94
+ * `color` and `size` props let consumers override the token values per
95
+ * instance without touching `modes`.
96
+ *
97
+ * @example
98
+ * ```tsx
99
+ * // Built-in registry icon (default path).
100
+ * <Icon iconName="ic_card" modes={{ 'Color Mode': 'Light' }} />
101
+ *
102
+ * // Per-instance overrides.
103
+ * <Icon iconName="ic_ccv" color="#5c00b5" size={24} />
104
+ *
105
+ * // Fallback to an external source when the name isn't in the registry.
106
+ * <Icon source="https://cdn.example.com/glyph.svg" />
107
+ *
108
+ * // Slot: render any node as the icon.
109
+ * <Icon><BrandLogo /></Icon>
110
+ * ```
111
+ */
112
+ function Icon({
113
+ iconName,
114
+ source,
115
+ children,
116
+ color,
117
+ size,
118
+ modes: propModes = EMPTY_MODES,
119
+ style: styleProp,
120
+ ...rest
121
+ }: IconProps) {
122
+ const { modes: globalModes } = useTokens()
123
+
124
+ const modes = useMemo(
125
+ () => (globalModes === EMPTY_MODES && propModes === EMPTY_MODES
126
+ ? EMPTY_MODES
127
+ : { ...globalModes, ...propModes }),
128
+ [globalModes, propModes]
129
+ )
130
+
131
+ const tokens = useMemo(() => resolveIconTokens(modes), [modes])
132
+
133
+ const composedStyle = useMemo<StyleProp<ViewStyle>>(
134
+ () => (styleProp ? [tokens.containerStyle, styleProp] : tokens.containerStyle),
135
+ [tokens.containerStyle, styleProp]
136
+ )
137
+
138
+ const hasSlot = React.Children.count(children) > 0
139
+
140
+ // Only fall back to the default glyph when nothing at all is provided so an
141
+ // explicit `source` (without an `iconName`) isn't shadowed by `ic_card`.
142
+ const resolvedName =
143
+ iconName ?? (source === undefined ? 'ic_card' : undefined)
144
+
145
+ const iconColor = color ?? tokens.iconColor
146
+ const iconSize = size ?? tokens.iconSize
147
+
148
+ return (
149
+ <View style={composedStyle} {...rest}>
150
+ {hasSlot ? (
151
+ cloneChildrenWithModes(children, modes)
152
+ ) : (
153
+ <BaseIcon
154
+ name={resolvedName}
155
+ {...(source !== undefined ? { source } : {})}
156
+ size={iconSize}
157
+ color={iconColor}
158
+ accessibilityElementsHidden={true}
159
+ importantForAccessibility="no"
160
+ />
161
+ )}
162
+ </View>
163
+ )
164
+ }
165
+
166
+ export default React.memo(Icon)
167
+ export type { IconProps }
@@ -38,10 +38,12 @@ export { default as CircularProgressBarDoted, type CircularProgressBarDotedProps
38
38
  export { default as CircularRating, type CircularRatingProps } from './CircularRating/CircularRating'
39
39
  export { default as CoverageRing, type CoverageRingProps } from './CoverageRing/CoverageRing'
40
40
  export { default as CoverageBarComparison, type CoverageBarComparisonProps, type CoverageBarComparisonItem } from './CoverageBarComparison/CoverageBarComparison'
41
+ export { default as AllocationComparisonChart, type AllocationComparisonChartProps, type AllocationSegment } from './AllocationComparisonChart/AllocationComparisonChart'
41
42
  export { default as MonthlyStatusGrid, CalendarGlyph, type MonthlyStatusGridProps, type MonthlyStatusGridMonth, type MonthlyStatus, type CalendarGlyphProps } from './MonthlyStatusGrid/MonthlyStatusGrid'
42
43
  export { default as Gauge, type GaugeProps } from './Gauge/Gauge';
43
44
  export { default as HoldingsCard, type HoldingsCardProps } from './HoldingsCard/HoldingsCard';
44
45
  export { default as HStack, type HStackProps } from './HStack/HStack';
46
+ export { default as Icon, type IconProps } from './Icon/Icon';
45
47
  export { default as IconButton } from './IconButton/IconButton';
46
48
  export { default as IconCapsule } from './IconCapsule/IconCapsule';
47
49
  export { default as Image, type ImageProps } from './Image/Image';