jfs-components 0.0.86 → 0.0.99

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 (34) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/lib/commonjs/assets.d.js +1 -0
  3. package/lib/commonjs/components/Drawer/Drawer.js +146 -82
  4. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +118 -51
  5. package/lib/commonjs/components/Icon/Icon.js +112 -0
  6. package/lib/commonjs/components/Spinner/Spinner.js +5 -1
  7. package/lib/commonjs/components/index.js +7 -0
  8. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  9. package/lib/commonjs/icons/registry.js +1 -1
  10. package/lib/commonjs/skeleton/Skeleton.js +10 -2
  11. package/lib/module/assets.d.js +1 -0
  12. package/lib/module/components/Drawer/Drawer.js +148 -84
  13. package/lib/module/components/FullscreenModal/FullscreenModal.js +120 -53
  14. package/lib/module/components/Icon/Icon.js +106 -0
  15. package/lib/module/components/Spinner/Spinner.js +6 -2
  16. package/lib/module/components/index.js +1 -0
  17. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  18. package/lib/module/icons/registry.js +1 -1
  19. package/lib/module/skeleton/Skeleton.js +11 -3
  20. package/lib/typescript/src/components/Drawer/Drawer.d.ts +23 -4
  21. package/lib/typescript/src/components/FullscreenModal/FullscreenModal.d.ts +35 -21
  22. package/lib/typescript/src/components/Icon/Icon.d.ts +75 -0
  23. package/lib/typescript/src/components/index.d.ts +2 -1
  24. package/lib/typescript/src/icons/registry.d.ts +1 -1
  25. package/package.json +1 -1
  26. package/src/assets.d.ts +24 -0
  27. package/src/components/Drawer/Drawer.tsx +94 -15
  28. package/src/components/FullscreenModal/FullscreenModal.tsx +146 -63
  29. package/src/components/Icon/Icon.tsx +167 -0
  30. package/src/components/Spinner/Spinner.tsx +2 -2
  31. package/src/components/index.ts +2 -1
  32. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  33. package/src/icons/registry.ts +1 -1
  34. package/src/skeleton/Skeleton.tsx +10 -3
@@ -1,13 +1,13 @@
1
- import React, { useMemo } from 'react'
1
+ import React, { useMemo, useRef } from 'react'
2
2
  import {
3
3
  View,
4
4
  Text,
5
- ScrollView,
6
- StyleSheet,
5
+ Animated,
7
6
  type StyleProp,
8
7
  type ViewStyle,
9
8
  type TextStyle,
10
9
  } from 'react-native'
10
+ import { useSafeAreaInsets } from 'react-native-safe-area-context'
11
11
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
12
12
  import { useTokens } from '../../design-tokens/JFSThemeProvider'
13
13
  import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils'
@@ -31,6 +31,17 @@ import Slot from '../Slot/Slot'
31
31
  // ---------------------------------------------------------------------------
32
32
  const FULLSCREEN_MODAL_FORCED_MODES = Object.freeze({ context5: 'Fullscreen Modal' })
33
33
 
34
+ // ---------------------------------------------------------------------------
35
+ // Default modes
36
+ //
37
+ // A FullscreenModal is a "JioPlus" surface, so it defaults the `Page type`
38
+ // collection to `'JioPlus'`. Unlike the forced modes above this IS
39
+ // overridable — it is applied before the caller's `modes`, so passing
40
+ // `modes={{ 'Page type': 'SubPage' }}` still wins. Frozen for stable identity
41
+ // (keeps the token resolver's per-modes cache hot).
42
+ // ---------------------------------------------------------------------------
43
+ const FULLSCREEN_MODAL_DEFAULT_MODES = Object.freeze({ 'Page type': 'JioPlus' })
44
+
34
45
  export type FullscreenModalProps = {
35
46
  /** Small eyebrow line above the headline. */
36
47
  eyebrow?: string
@@ -41,18 +52,21 @@ export type FullscreenModalProps = {
41
52
  /** Secondary line below the supporting paragraph (e.g. a price / timeline). */
42
53
  priceText?: string
43
54
  /**
44
- * Background media rendered full-bleed behind the hero text. Bring any
45
- * renderer most commonly an `Image`, but a `LottiePlayer`, `Video`, or
46
- * `SvgXml` works too. It is laid out at the full modal width; size it with an
47
- * aspect ratio (e.g. `<Image ratio={3 / 4} />`) so its height follows the
48
- * width naturally. The media scrolls together with the rest of the content
49
- * (no parallax). `modes` are cascaded into it.
55
+ * Full-bleed background media for the whole modal. It is pinned to the top
56
+ * and laid out at the full modal width; size it with an aspect ratio
57
+ * (e.g. `<Image ratio={1080 / 4140} />`) so its height follows the width
58
+ * naturally. It renders as a single continuous background BEHIND both the
59
+ * hero text and the body content there is no separate body box stacked on
60
+ * top of it. Bring any renderer most commonly an `Image`, but a
61
+ * `LottiePlayer`, `Video`, or `SvgXml` works too. It never intercepts
62
+ * touches and the foreground content scrolls over it (no parallax).
63
+ * `modes` are cascaded into it.
50
64
  */
51
65
  heroMedia?: React.ReactNode
52
66
  /**
53
- * Fallback height for the hero text region when no `heroMedia` is provided.
54
- * When `heroMedia` is present, the hero height is driven entirely by the
55
- * media's own aspect ratio and this value is ignored. Defaults to 420.
67
+ * Height reserved for the hero text region (eyebrow / headline / supporting
68
+ * / price), whose content is anchored to the bottom. Applies whether or not
69
+ * `heroMedia` is present. Defaults to 420.
56
70
  */
57
71
  heroHeight?: number
58
72
  /** Whether to render the floating close button (top-right). Defaults to true. */
@@ -72,15 +86,17 @@ export type FullscreenModalProps = {
72
86
  onPrimaryAction?: () => void
73
87
  /** Disclaimer text shown below the default primary action button. */
74
88
  disclaimer?: string
75
- /** Solid backdrop color for the scrollable body. Defaults to a near-black. */
76
- backgroundColor?: string
77
89
  /** Body content (typically `Section`s). `modes` are cascaded automatically. */
78
90
  children?: React.ReactNode
79
- /** Mode configuration. `context5` is always forced to `'Fullscreen Modal'`. */
91
+ /**
92
+ * Mode configuration. `Page type` defaults to `'JioPlus'` (overridable here),
93
+ * and `context5` is always forced to `'Fullscreen Modal'` (non-overridable).
94
+ * The resolved modes cascade to the body, hero media, and the `ActionFooter`.
95
+ */
80
96
  modes?: Record<string, any>
81
97
  /** Style overrides for the outer container. */
82
98
  style?: StyleProp<ViewStyle>
83
- /** Style overrides for the scroll body wrapper (the dark content area). */
99
+ /** Style overrides for the transparent body wrapper. */
84
100
  contentContainerStyle?: StyleProp<ViewStyle>
85
101
  testID?: string
86
102
  }
@@ -171,12 +187,21 @@ function HeroText({ eyebrow, headline, supportingText, priceText, modes }: HeroT
171
187
  * That mode is cascaded into `children`, the footer, and the hero text via
172
188
  * `cloneChildrenWithModes` / the merged `modes` object.
173
189
  *
174
- * ### Hero
175
- * The `heroMedia` is rendered full modal width inside the scroll body and
176
- * takes its height from its own aspect ratio. The hero text (eyebrow /
177
- * headline / supporting / price) is overlaid on top, anchored to the bottom.
178
- * The whole hero scrolls together with the rest of the content there is no
179
- * parallax effect.
190
+ * ### Background media
191
+ * The `heroMedia` is a single full-bleed background pinned to the top of the
192
+ * modal at the full width and its own natural aspect ratio. It lives at the
193
+ * ROOT behind both the scrolling content and the (transparent) footer so
194
+ * it fills the whole surface and is NEVER clipped to the content height. It
195
+ * also contributes ZERO scroll height: the scroll extent is driven purely by
196
+ * the in-flow foreground (hero text + `children`), so the number of body
197
+ * elements dictates how far the surface scrolls. It still scrolls in lockstep
198
+ * WITH the content (the background is translated by the scroll offset), so the
199
+ * content reads as sitting ON one continuous image that moves with it — there
200
+ * is no parallax and no separate solid body box.
201
+ *
202
+ * Pass a background sized to the full width at its natural ratio
203
+ * (e.g. `<Image imageSource={bg} ratio={1080 / 4140} />`). Use an asset at
204
+ * least as tall as the surface so it covers the full modal.
180
205
  *
181
206
  * @component
182
207
  * @example
@@ -186,7 +211,7 @@ function HeroText({ eyebrow, headline, supportingText, priceText, modes }: HeroT
186
211
  * headline="Get more from your money."
187
212
  * supportingText="JioFinance+ is your upgraded financial experience…"
188
213
  * priceText="₹999/year · ₹0 until 2027"
189
- * heroMedia={<Image imageSource={hero} ratio={3 / 4} />}
214
+ * heroMedia={<Image imageSource={hero} ratio={1080 / 4140} />}
190
215
  * primaryActionLabel="Upgrade for free"
191
216
  * disclaimer="By upgrading, we'll check your eligibility with Experian."
192
217
  * onPrimaryAction={() => upgrade()}
@@ -211,7 +236,6 @@ function FullscreenModal({
211
236
  primaryActionLabel = 'Upgrade for free',
212
237
  onPrimaryAction,
213
238
  disclaimer = "By upgrading, we'll check your eligibility with Experian.",
214
- backgroundColor = '#0f0d0a',
215
239
  children,
216
240
  modes: propModes = EMPTY_MODES,
217
241
  style,
@@ -220,15 +244,55 @@ function FullscreenModal({
220
244
  }: FullscreenModalProps) {
221
245
  const { modes: globalModes } = useTokens()
222
246
 
223
- // context5 is appended last so it always wins, regardless of what the
224
- // caller (or the global theme) passes.
247
+ // Merge order (low high priority):
248
+ // global theme → component defaults (Page type: JioPlus) → caller modes →
249
+ // forced modes (context5). So `Page type` defaults to JioPlus but the
250
+ // caller can override it, while `context5` always wins. This single `modes`
251
+ // object is what cascades to the body, hero media, and the ActionFooter.
225
252
  const modes = useMemo(
226
- () => ({ ...globalModes, ...propModes, ...FULLSCREEN_MODAL_FORCED_MODES }),
253
+ () => ({
254
+ ...globalModes,
255
+ ...FULLSCREEN_MODAL_DEFAULT_MODES,
256
+ ...propModes,
257
+ ...FULLSCREEN_MODAL_FORCED_MODES,
258
+ }),
227
259
  [globalModes, propModes]
228
260
  )
229
261
 
230
262
  const rootGap = Number(getVariableByName('fullScreenModal/gap', modes)) || 16
231
263
 
264
+ // Safe-area insets so the floating chrome clears the system bars: the close
265
+ // button drops below the status bar / notch, and the sticky footer keeps its
266
+ // designed bottom padding ON TOP of the bottom inset (home indicator /
267
+ // Android gesture or nav bar). On web — and anywhere without a
268
+ // SafeAreaProvider — every inset is 0, so the layout is unchanged.
269
+ const insets = useSafeAreaInsets()
270
+ const closeButtonInsetStyle = useMemo<ViewStyle>(
271
+ () => ({ top: 12 + insets.top }),
272
+ [insets.top]
273
+ )
274
+ // Extend (not replace) the footer's token bottom padding by the bottom inset
275
+ // so the action button never sits under the system navigation area.
276
+ const footerInsetStyle = useMemo<ViewStyle>(() => {
277
+ const base = Number(getVariableByName('actionFooter/padding/bottom', modes)) || 41
278
+ return { paddingBottom: base + insets.bottom }
279
+ }, [modes, insets.bottom])
280
+
281
+ // Drives the background's parallax-free sync with the scroll. The hero media
282
+ // lives at the ROOT (so it is never clipped to the content height and sits
283
+ // behind the transparent footer), but we translate it up by the exact scroll
284
+ // offset so it moves in lockstep with the content — i.e. it scrolls WITH the
285
+ // body without ever contributing to the scroll height.
286
+ const scrollY = useRef(new Animated.Value(0)).current
287
+ const onScroll = useMemo(
288
+ () =>
289
+ Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
290
+ useNativeDriver: true,
291
+ }),
292
+ [scrollY]
293
+ )
294
+ const heroTranslateY = useMemo(() => Animated.multiply(scrollY, -1), [scrollY])
295
+
232
296
  const processedHeroMedia = useMemo(
233
297
  () =>
234
298
  heroMedia ? cloneChildrenWithModes(heroMedia, modes, FULLSCREEN_MODAL_FORCED_MODES) : null,
@@ -241,9 +305,11 @@ function FullscreenModal({
241
305
  [children, modes]
242
306
  )
243
307
 
244
- // No-media fallback: without hero media the text region needs an explicit
245
- // resting height (driven by `heroHeight`) so the hero still has presence.
246
- const heroTextFallbackStyle = useMemo<ViewStyle>(
308
+ // The hero text region always reserves `heroHeight` and anchors its content
309
+ // to the bottom, so the eyebrow/headline block sits in the lower part of the
310
+ // first screenful — over the background media when present, in flow
311
+ // otherwise.
312
+ const heroTextRegionStyle = useMemo<ViewStyle>(
247
313
  () => ({
248
314
  minHeight: heroHeight,
249
315
  justifyContent: 'flex-end',
@@ -253,17 +319,18 @@ function FullscreenModal({
253
319
  [heroHeight]
254
320
  )
255
321
 
322
+ // Body is intentionally transparent — the background media shows through
323
+ // behind it. There is no solid "body box" stacked on top of the image.
256
324
  const bodyStyle = useMemo<StyleProp<ViewStyle>>(
257
325
  () => [
258
326
  {
259
- backgroundColor,
260
327
  gap: rootGap,
261
328
  paddingTop: rootGap,
262
329
  paddingBottom: 24,
263
330
  },
264
331
  contentContainerStyle,
265
332
  ],
266
- [backgroundColor, rootGap, contentContainerStyle]
333
+ [rootGap, contentContainerStyle]
267
334
  )
268
335
 
269
336
  const heroTextNode = (
@@ -276,21 +343,6 @@ function FullscreenModal({
276
343
  />
277
344
  )
278
345
 
279
- // The hero scrolls inline with the body (no parallax). When media is present
280
- // it is laid out full modal width and takes its height from its own aspect
281
- // ratio; the hero text is overlaid on top, anchored to the bottom. Without
282
- // media the text simply renders in flow at the fallback height.
283
- const hero = processedHeroMedia ? (
284
- <View style={heroMediaContainerStyle}>
285
- {processedHeroMedia}
286
- <View style={heroTextOverlayStyle} pointerEvents="box-none">
287
- {heroTextNode}
288
- </View>
289
- </View>
290
- ) : (
291
- <View style={heroTextFallbackStyle}>{heroTextNode}</View>
292
- )
293
-
294
346
  // Footer: a fully custom node, or the default Button + Disclaimer column.
295
347
  let footerContent: React.ReactNode = null
296
348
  if (footer) {
@@ -310,21 +362,53 @@ function FullscreenModal({
310
362
  }
311
363
 
312
364
  return (
313
- <View style={[rootStyle, { backgroundColor }, style]} testID={testID}>
314
- <ScrollView
365
+ <View style={[rootStyle, style]} testID={testID}>
366
+ {/*
367
+ * Layout model:
368
+ * - `processedHeroMedia` is a ROOT-LEVEL full-bleed background pinned to
369
+ * the top at full modal width and rendered at its own natural aspect
370
+ * ratio — it is NEVER clipped to the content height, so it extends as
371
+ * far down as its own height allows and fills the surface BEHIND both
372
+ * the scrolling content and the (transparent) footer. Because it lives
373
+ * outside the ScrollView it contributes ZERO scroll height, yet we
374
+ * translate it by the scroll offset (`heroTranslateY`) so it visually
375
+ * scrolls in lockstep WITH the content (no parallax, no clip).
376
+ * `pointerEvents="none"` lets touches reach the content/footer.
377
+ * - The ScrollView is transparent and holds only the foreground (hero
378
+ * text + body) IN FLOW, so the number of actual elements dictates how
379
+ * far the surface scrolls.
380
+ */}
381
+ {processedHeroMedia ? (
382
+ <Animated.View
383
+ style={[heroBackgroundStyle, { transform: [{ translateY: heroTranslateY }] }]}
384
+ pointerEvents="none"
385
+ >
386
+ {processedHeroMedia}
387
+ </Animated.View>
388
+ ) : null}
389
+
390
+ <Animated.ScrollView
315
391
  style={scrollViewStyle}
316
392
  contentContainerStyle={scrollContentStyle}
317
393
  showsVerticalScrollIndicator={false}
394
+ onScroll={onScroll}
395
+ scrollEventThrottle={16}
318
396
  // Tap an input in the body and it focuses on the FIRST tap, even when
319
397
  // the keyboard is already open (default 'never' eats that tap).
320
398
  keyboardShouldPersistTaps="handled"
321
399
  >
322
- {hero}
323
- <View style={bodyStyle}>{processedChildren}</View>
324
- </ScrollView>
400
+ <View style={foregroundFlowStyle}>
401
+ <View style={heroTextRegionStyle}>{heroTextNode}</View>
402
+ {processedChildren ? (
403
+ <View style={bodyStyle}>{processedChildren}</View>
404
+ ) : null}
405
+ </View>
406
+ </Animated.ScrollView>
325
407
 
326
408
  {footerContent ? (
327
- <ActionFooter modes={modes}>{footerContent}</ActionFooter>
409
+ <ActionFooter modes={modes} style={footerInsetStyle}>
410
+ {footerContent}
411
+ </ActionFooter>
328
412
  ) : null}
329
413
 
330
414
  {showClose ? (
@@ -332,7 +416,7 @@ function FullscreenModal({
332
416
  iconName="ic_close"
333
417
  modes={modes}
334
418
  accessibilityLabel={closeAccessibilityLabel}
335
- style={closeButtonStyle}
419
+ style={[closeButtonStyle, closeButtonInsetStyle]}
336
420
  {...(onClose ? { onPress: onClose } : {})}
337
421
  />
338
422
  ) : null}
@@ -346,14 +430,13 @@ const scrollViewStyle: ViewStyle = { flex: 1 }
346
430
  const scrollContentStyle: ViewStyle = { flexGrow: 1 }
347
431
  const fullWidthStyle: ViewStyle = { width: '100%' }
348
432
  const closeButtonStyle: ViewStyle = { position: 'absolute', top: 12, right: 12 }
349
- // Full-width hero wrapper; height comes from the media's own aspect ratio.
350
- const heroMediaContainerStyle: ViewStyle = { width: '100%', position: 'relative' }
351
- // Hero text overlaid on the media, anchored to the bottom edge.
352
- const heroTextOverlayStyle: ViewStyle = {
353
- ...StyleSheet.absoluteFillObject,
354
- justifyContent: 'flex-end',
355
- paddingHorizontal: 16,
356
- paddingBottom: 16,
357
- }
433
+ // Root-level full-bleed background media. Pinned to the top at full modal
434
+ // width; the media inside keeps its own natural aspect ratio (only `top` is
435
+ // pinned no `bottom`/`overflow` clip), so it is NEVER cut to the content
436
+ // height and fills the surface behind the scrolling content and the footer.
437
+ // Living outside the ScrollView, it adds nothing to the scroll height.
438
+ const heroBackgroundStyle: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0 }
439
+ // The foreground always flows normally — its content drives the scroll height.
440
+ const foregroundFlowStyle: ViewStyle = { width: '100%' }
358
441
 
359
442
  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 }
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect } from 'react'
2
- import { StyleSheet, View, type StyleProp, type ViewProps, type ViewStyle } from 'react-native'
2
+ import { View, type StyleProp, type ViewProps, type ViewStyle } from 'react-native'
3
3
  import Animated, {
4
4
  Easing,
5
5
  cancelAnimation,
@@ -171,7 +171,7 @@ const useSegmentRotation = (
171
171
  }
172
172
  }, [gravity, index, spreadMinRad, spreadMaxRad, spreadOutFrac])
173
173
 
174
- const fullSize: ViewStyle = { ...StyleSheet.absoluteFillObject }
174
+ const fullSize: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }
175
175
 
176
176
  function Spinner({
177
177
  size = DEFAULT_SIZE,
@@ -22,7 +22,7 @@ export { default as CardFinancialCondition, type CardFinancialConditionProps } f
22
22
  export { default as CardInsight, type CardInsightProps } from './CardInsight/CardInsight';
23
23
  export { default as Disclaimer } from './Disclaimer/Disclaimer';
24
24
  export { default as Divider, type DividerProps, type DividerDirection } from './Divider/Divider';
25
- export { default as Drawer } from './Drawer/Drawer';
25
+ export { default as Drawer, type DrawerProps, type DrawerHandle } from './Drawer/Drawer';
26
26
  export { default as Dropdown, DropdownItem, type DropdownProps, type DropdownItemProps } from './Dropdown/Dropdown';
27
27
  export { default as DropdownInput, type DropdownInputProps, type DropdownInputOption, type DropdownInputOptionValue } from './DropdownInput/DropdownInput';
28
28
  export { default as SuggestiveSearch, type SuggestiveSearchProps, type SuggestiveSearchOption, type SuggestiveSearchOptionValue, type SuggestiveSearchItem } from './SuggestiveSearch/SuggestiveSearch';
@@ -43,6 +43,7 @@ export { default as MonthlyStatusGrid, CalendarGlyph, type MonthlyStatusGridProp
43
43
  export { default as Gauge, type GaugeProps } from './Gauge/Gauge';
44
44
  export { default as HoldingsCard, type HoldingsCardProps } from './HoldingsCard/HoldingsCard';
45
45
  export { default as HStack, type HStackProps } from './HStack/HStack';
46
+ export { default as Icon, type IconProps } from './Icon/Icon';
46
47
  export { default as IconButton } from './IconButton/IconButton';
47
48
  export { default as IconCapsule } from './IconCapsule/IconCapsule';
48
49
  export { default as Image, type ImageProps } from './Image/Image';