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.
- package/CHANGELOG.md +15 -0
- package/lib/commonjs/assets.d.js +1 -0
- package/lib/commonjs/components/AllocationComparisonChart/AllocationComparisonChart.js +299 -0
- package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +104 -94
- package/lib/commonjs/components/Icon/Icon.js +112 -0
- package/lib/commonjs/components/index.js +14 -0
- package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/commonjs/icons/registry.js +1 -1
- package/lib/module/assets.d.js +1 -0
- package/lib/module/components/AllocationComparisonChart/AllocationComparisonChart.js +293 -0
- package/lib/module/components/FullscreenModal/FullscreenModal.js +106 -96
- package/lib/module/components/Icon/Icon.js +106 -0
- package/lib/module/components/index.js +2 -0
- package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/module/icons/registry.js +1 -1
- package/lib/typescript/src/components/AllocationComparisonChart/AllocationComparisonChart.d.ts +118 -0
- package/lib/typescript/src/components/FullscreenModal/FullscreenModal.d.ts +39 -29
- package/lib/typescript/src/components/Icon/Icon.d.ts +75 -0
- package/lib/typescript/src/components/index.d.ts +2 -0
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/package.json +1 -1
- package/src/assets.d.ts +24 -0
- package/src/components/AllocationComparisonChart/AllocationComparisonChart.tsx +450 -0
- package/src/components/FullscreenModal/FullscreenModal.tsx +131 -126
- package/src/components/Icon/Icon.tsx +167 -0
- package/src/components/index.ts +2 -0
- package/src/design-tokens/Coin Variables-variables-full.json +1 -1
- 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
|
-
|
|
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
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
const
|
|
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
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* hero
|
|
66
|
-
*
|
|
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
|
-
*
|
|
73
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
185
|
-
* a scrollable body, a floating close button, and a sticky
|
|
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
|
-
* ###
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
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={<
|
|
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
|
-
//
|
|
247
|
-
//
|
|
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
|
-
() => ({
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
290
|
-
//
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
315
|
+
[rootGap, contentContainerStyle]
|
|
338
316
|
)
|
|
339
317
|
|
|
340
|
-
const
|
|
341
|
-
<
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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,
|
|
369
|
-
{
|
|
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
|
-
<
|
|
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={
|
|
382
|
-
<
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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 }
|
package/src/components/index.ts
CHANGED
|
@@ -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';
|