jfs-components 0.0.74 → 0.0.77
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 +92 -0
- package/lib/commonjs/components/ActionFooter/ActionFooter.js +147 -82
- package/lib/commonjs/components/Avatar/Avatar.js +20 -0
- package/lib/commonjs/components/Badge/Badge.js +23 -0
- package/lib/commonjs/components/Button/Button.js +37 -0
- package/lib/commonjs/components/IconButton/IconButton.js +20 -0
- package/lib/commonjs/components/Image/Image.js +26 -1
- package/lib/commonjs/components/LottiePlayer/LottiePlayer.js +116 -0
- package/lib/commonjs/components/LottiePlayer/LottiePlayer.web.js +82 -0
- package/lib/commonjs/components/LottiePlayer/loadNativeLottieView.js +74 -0
- package/lib/commonjs/components/LottiePlayer/loadWebLottieView.js +50 -0
- package/lib/commonjs/components/PageHero/PageHero.js +41 -5
- package/lib/commonjs/components/RechargeCard/RechargeCard.js +32 -17
- package/lib/commonjs/components/Text/Text.js +31 -1
- package/lib/commonjs/components/index.js +7 -0
- package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/commonjs/icons/Icon.js +16 -0
- package/lib/commonjs/icons/registry.js +1 -1
- package/lib/commonjs/index.js +12 -0
- package/lib/commonjs/skeleton/Skeleton.js +234 -0
- package/lib/commonjs/skeleton/SkeletonGroup.js +140 -0
- package/lib/commonjs/skeleton/index.js +58 -0
- package/lib/commonjs/skeleton/shimmer-tokens.js +189 -0
- package/lib/commonjs/skeleton/useReducedMotion.js +64 -0
- package/lib/module/components/ActionFooter/ActionFooter.js +146 -82
- package/lib/module/components/Avatar/Avatar.js +19 -0
- package/lib/module/components/Badge/Badge.js +23 -0
- package/lib/module/components/Button/Button.js +37 -0
- package/lib/module/components/IconButton/IconButton.js +20 -0
- package/lib/module/components/Image/Image.js +25 -1
- package/lib/module/components/LottiePlayer/LottiePlayer.js +111 -0
- package/lib/module/components/LottiePlayer/LottiePlayer.web.js +77 -0
- package/lib/module/components/LottiePlayer/loadNativeLottieView.js +69 -0
- package/lib/module/components/LottiePlayer/loadWebLottieView.js +45 -0
- package/lib/module/components/PageHero/PageHero.js +41 -5
- package/lib/module/components/RechargeCard/RechargeCard.js +33 -17
- package/lib/module/components/Text/Text.js +31 -1
- package/lib/module/components/index.js +1 -0
- package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/module/icons/Icon.js +16 -0
- package/lib/module/icons/registry.js +1 -1
- package/lib/module/index.js +2 -1
- package/lib/module/skeleton/Skeleton.js +229 -0
- package/lib/module/skeleton/SkeletonGroup.js +133 -0
- package/lib/module/skeleton/index.js +6 -0
- package/lib/module/skeleton/shimmer-tokens.js +181 -0
- package/lib/module/skeleton/useReducedMotion.js +61 -0
- package/lib/typescript/src/components/ActionFooter/ActionFooter.d.ts +26 -21
- package/lib/typescript/src/components/Avatar/Avatar.d.ts +7 -1
- package/lib/typescript/src/components/Badge/Badge.d.ts +7 -1
- package/lib/typescript/src/components/Button/Button.d.ts +8 -1
- package/lib/typescript/src/components/IconButton/IconButton.d.ts +7 -1
- package/lib/typescript/src/components/Image/Image.d.ts +8 -1
- package/lib/typescript/src/components/LottiePlayer/LottiePlayer.d.ts +85 -0
- package/lib/typescript/src/components/LottiePlayer/LottiePlayer.web.d.ts +28 -0
- package/lib/typescript/src/components/LottiePlayer/loadNativeLottieView.d.ts +11 -0
- package/lib/typescript/src/components/LottiePlayer/loadWebLottieView.d.ts +11 -0
- package/lib/typescript/src/components/PageHero/PageHero.d.ts +31 -5
- package/lib/typescript/src/components/Text/Text.d.ts +20 -1
- package/lib/typescript/src/components/index.d.ts +1 -0
- package/lib/typescript/src/icons/Icon.d.ts +7 -1
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/lib/typescript/src/index.d.ts +1 -0
- package/lib/typescript/src/skeleton/Skeleton.d.ts +60 -0
- package/lib/typescript/src/skeleton/SkeletonGroup.d.ts +78 -0
- package/lib/typescript/src/skeleton/index.d.ts +5 -0
- package/lib/typescript/src/skeleton/shimmer-tokens.d.ts +160 -0
- package/lib/typescript/src/skeleton/useReducedMotion.d.ts +15 -0
- package/package.json +11 -1
- package/src/components/ActionFooter/ActionFooter.tsx +152 -86
- package/src/components/Avatar/Avatar.tsx +26 -0
- package/src/components/Badge/Badge.tsx +27 -0
- package/src/components/Button/Button.tsx +40 -0
- package/src/components/IconButton/IconButton.tsx +27 -0
- package/src/components/Image/Image.tsx +25 -0
- package/src/components/LottiePlayer/LottiePlayer.tsx +145 -0
- package/src/components/LottiePlayer/LottiePlayer.web.tsx +94 -0
- package/src/components/LottiePlayer/loadNativeLottieView.tsx +87 -0
- package/src/components/LottiePlayer/loadWebLottieView.tsx +64 -0
- package/src/components/PageHero/PageHero.tsx +61 -4
- package/src/components/RechargeCard/RechargeCard.tsx +32 -24
- package/src/components/Text/Text.tsx +54 -0
- package/src/components/index.ts +1 -0
- package/src/design-tokens/Coin Variables-variables-full.json +1 -1
- package/src/icons/Icon.tsx +17 -0
- package/src/icons/registry.ts +1 -1
- package/src/index.ts +1 -0
- package/src/skeleton/Skeleton.tsx +298 -0
- package/src/skeleton/SkeletonGroup.tsx +193 -0
- package/src/skeleton/index.ts +10 -0
- package/src/skeleton/shimmer-tokens.ts +221 -0
- package/src/skeleton/useReducedMotion.ts +72 -0
|
@@ -14,6 +14,8 @@ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
|
14
14
|
import { usePressableWebSupport, type SafePressableProps, type WebAccessibilityProps } from '../../utils/web-platform-utils'
|
|
15
15
|
import { EMPTY_MODES } from '../../utils/react-utils'
|
|
16
16
|
import Icon from '../../icons/Icon'
|
|
17
|
+
import Skeleton from '../../skeleton/Skeleton'
|
|
18
|
+
import { useSkeleton } from '../../skeleton/SkeletonGroup'
|
|
17
19
|
|
|
18
20
|
export type ButtonProps = SafePressableProps & {
|
|
19
21
|
label?: string;
|
|
@@ -54,6 +56,13 @@ export type ButtonProps = SafePressableProps & {
|
|
|
54
56
|
* Web-specific accessibility props (only used on web platform)
|
|
55
57
|
*/
|
|
56
58
|
webAccessibilityProps?: WebAccessibilityProps;
|
|
59
|
+
/**
|
|
60
|
+
* Explicit per-instance loading override. When `true`, the button renders
|
|
61
|
+
* as a pill-shaped skeleton of the same size; when `false`, the
|
|
62
|
+
* surrounding `<SkeletonGroup>` is ignored. Defaults to inheriting from
|
|
63
|
+
* the group.
|
|
64
|
+
*/
|
|
65
|
+
loading?: boolean;
|
|
57
66
|
};
|
|
58
67
|
|
|
59
68
|
// ---------------------------------------------------------------------------
|
|
@@ -224,6 +233,7 @@ function ButtonImpl({
|
|
|
224
233
|
accessibilityHint,
|
|
225
234
|
accessibilityState,
|
|
226
235
|
webAccessibilityProps,
|
|
236
|
+
loading,
|
|
227
237
|
...rest
|
|
228
238
|
}: ButtonProps) {
|
|
229
239
|
// Hover state is web-only in practice; the setter is gated so native taps
|
|
@@ -248,6 +258,12 @@ function ButtonImpl({
|
|
|
248
258
|
[modes, disabled]
|
|
249
259
|
)
|
|
250
260
|
|
|
261
|
+
// Skeleton context — read unconditionally so React's hook order stays
|
|
262
|
+
// stable. The actual short-circuit return happens AFTER all remaining
|
|
263
|
+
// hooks have been called below.
|
|
264
|
+
const { active: groupActive } = useSkeleton()
|
|
265
|
+
const isLoading = loading ?? groupActive
|
|
266
|
+
|
|
251
267
|
// Active label color: base by default; hover override (web-only) when hovered.
|
|
252
268
|
// Press color is intentionally NOT applied to the label on native — applying
|
|
253
269
|
// it would require a React render per touch and re-introduce the flicker.
|
|
@@ -354,6 +370,30 @@ function ButtonImpl({
|
|
|
354
370
|
}
|
|
355
371
|
}
|
|
356
372
|
|
|
373
|
+
if (isLoading) {
|
|
374
|
+
const { container, baseLabel, iconSize, accessoryOffset } = tokens
|
|
375
|
+
const paddingHorizontal = (container.paddingHorizontal as number) ?? 20
|
|
376
|
+
const paddingVertical = (container.paddingVertical as number) ?? 12
|
|
377
|
+
const lineHeight = (baseLabel.lineHeight as number) ?? 19
|
|
378
|
+
const fontSize = (baseLabel.fontSize as number) ?? 16
|
|
379
|
+
const labelText = typeof label === 'string' ? label : 'Button'
|
|
380
|
+
const charWidth = fontSize * 0.55
|
|
381
|
+
const labelWidth = Math.max(labelText.length, 4) * charWidth
|
|
382
|
+
const hasAccessory = !!(leading || trailing || icon)
|
|
383
|
+
const accessoryWidth = hasAccessory ? iconSize + accessoryOffset * 2 : 0
|
|
384
|
+
const skeletonWidth = paddingHorizontal * 2 + labelWidth + accessoryWidth
|
|
385
|
+
const skeletonHeight = paddingVertical * 2 + lineHeight
|
|
386
|
+
return (
|
|
387
|
+
<Skeleton
|
|
388
|
+
kind="other"
|
|
389
|
+
width={skeletonWidth}
|
|
390
|
+
height={skeletonHeight}
|
|
391
|
+
style={style as any}
|
|
392
|
+
modes={modes}
|
|
393
|
+
/>
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
|
|
357
397
|
return (
|
|
358
398
|
<Pressable
|
|
359
399
|
accessibilityRole="button"
|
|
@@ -12,6 +12,8 @@ import Icon from '../../icons/Icon'
|
|
|
12
12
|
import { usePressableWebSupport, type SafePressableProps, type WebAccessibilityProps } from '../../utils/web-platform-utils'
|
|
13
13
|
import { EMPTY_MODES } from '../../utils/react-utils'
|
|
14
14
|
import type { UnifiedSource } from '../../utils/MediaSource'
|
|
15
|
+
import Skeleton from '../../skeleton/Skeleton'
|
|
16
|
+
import { useSkeleton } from '../../skeleton/SkeletonGroup'
|
|
15
17
|
|
|
16
18
|
type IconButtonProps = SafePressableProps & {
|
|
17
19
|
/** Built-in icon name from the registry (default state). */
|
|
@@ -65,6 +67,12 @@ type IconButtonProps = SafePressableProps & {
|
|
|
65
67
|
* Web-specific accessibility props (only used on web platform)
|
|
66
68
|
*/
|
|
67
69
|
webAccessibilityProps?: WebAccessibilityProps;
|
|
70
|
+
/**
|
|
71
|
+
* Explicit per-instance loading override. When `true`, renders a
|
|
72
|
+
* same-size pill-shaped skeleton instead of the button. Defaults to
|
|
73
|
+
* inheriting from the surrounding `<SkeletonGroup>`.
|
|
74
|
+
*/
|
|
75
|
+
loading?: boolean;
|
|
68
76
|
};
|
|
69
77
|
|
|
70
78
|
// ---------------------------------------------------------------------------
|
|
@@ -155,6 +163,7 @@ function IconButton({
|
|
|
155
163
|
inactiveIcon,
|
|
156
164
|
inactiveSource,
|
|
157
165
|
isActive = false,
|
|
166
|
+
loading,
|
|
158
167
|
...rest
|
|
159
168
|
}: IconButtonProps) {
|
|
160
169
|
// Merge explicit props with modes for token resolution. Memoize the merged
|
|
@@ -174,6 +183,11 @@ function IconButton({
|
|
|
174
183
|
[componentModes, disabled]
|
|
175
184
|
)
|
|
176
185
|
|
|
186
|
+
// Hook called unconditionally — short-circuit below comes AFTER all hooks
|
|
187
|
+
// to keep React's hook order stable across renders.
|
|
188
|
+
const { active: groupActive } = useSkeleton()
|
|
189
|
+
const isLoading = loading ?? groupActive
|
|
190
|
+
|
|
177
191
|
const [isFocused, setIsFocused] = useState(false)
|
|
178
192
|
const [isHovered, setIsHovered] = useState(false)
|
|
179
193
|
|
|
@@ -271,6 +285,19 @@ function IconButton({
|
|
|
271
285
|
[tokens.baseContainerStyle, style, isHovered, isFocused, disabled]
|
|
272
286
|
)
|
|
273
287
|
|
|
288
|
+
if (isLoading) {
|
|
289
|
+
const size = tokens.baseContainerStyle.width as number
|
|
290
|
+
return (
|
|
291
|
+
<Skeleton
|
|
292
|
+
kind="other"
|
|
293
|
+
width={size}
|
|
294
|
+
height={size}
|
|
295
|
+
style={style as any}
|
|
296
|
+
modes={componentModes}
|
|
297
|
+
/>
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
274
301
|
return (
|
|
275
302
|
<Pressable
|
|
276
303
|
accessibilityRole="button"
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
type ViewStyle,
|
|
9
9
|
type ImageResizeMode,
|
|
10
10
|
} from 'react-native'
|
|
11
|
+
import Skeleton from '../../skeleton/Skeleton'
|
|
12
|
+
import { useSkeleton } from '../../skeleton/SkeletonGroup'
|
|
11
13
|
|
|
12
14
|
export type ImageProps = {
|
|
13
15
|
/**
|
|
@@ -52,6 +54,13 @@ export type ImageProps = {
|
|
|
52
54
|
| 'no'
|
|
53
55
|
| 'no-hide-descendants'
|
|
54
56
|
| undefined
|
|
57
|
+
/**
|
|
58
|
+
* Explicit per-instance loading override. When `true`, the image renders as
|
|
59
|
+
* a skeleton placeholder at the same box size; when `false`, the
|
|
60
|
+
* surrounding `<SkeletonGroup>` is ignored. Defaults to inheriting from
|
|
61
|
+
* the group.
|
|
62
|
+
*/
|
|
63
|
+
loading?: boolean | undefined
|
|
55
64
|
}
|
|
56
65
|
|
|
57
66
|
function normalizeSource(
|
|
@@ -94,6 +103,7 @@ function Image({
|
|
|
94
103
|
accessibilityLabel,
|
|
95
104
|
accessibilityElementsHidden,
|
|
96
105
|
importantForAccessibility,
|
|
106
|
+
loading,
|
|
97
107
|
}: ImageProps) {
|
|
98
108
|
const source = useMemo(() => normalizeSource(imageSource), [imageSource])
|
|
99
109
|
|
|
@@ -112,6 +122,21 @@ function Image({
|
|
|
112
122
|
return s
|
|
113
123
|
}, [ratio, width, height, borderRadius])
|
|
114
124
|
|
|
125
|
+
const { active: groupActive } = useSkeleton()
|
|
126
|
+
const isLoading = loading ?? groupActive
|
|
127
|
+
if (isLoading) {
|
|
128
|
+
// Match the loaded image's exact box. If height is unknown but a ratio
|
|
129
|
+
// is set, the skeleton uses `aspectRatio` the same way the loaded image
|
|
130
|
+
// would, so layout never jumps when the load resolves.
|
|
131
|
+
const skeletonStyle: ViewStyle = {
|
|
132
|
+
width: (width ?? '100%') as ViewStyle['width'],
|
|
133
|
+
...(height != null
|
|
134
|
+
? { height: height as number }
|
|
135
|
+
: { aspectRatio: ratio }),
|
|
136
|
+
}
|
|
137
|
+
return <Skeleton kind="image" style={skeletonStyle} />
|
|
138
|
+
}
|
|
139
|
+
|
|
115
140
|
if (!source) {
|
|
116
141
|
return <View style={[layoutStyle, style as StyleProp<ViewStyle>]} />
|
|
117
142
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import React, { useMemo } from 'react'
|
|
2
|
+
import { View, type StyleProp, type ViewStyle } from 'react-native'
|
|
3
|
+
import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
4
|
+
import { useTokens } from '../../design-tokens/JFSThemeProvider'
|
|
5
|
+
import { EMPTY_MODES } from '../../utils/react-utils'
|
|
6
|
+
import { getNativeLottieView } from './loadNativeLottieView'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A parsed Lottie animation. The JSON object you get from
|
|
10
|
+
* `require('./animation.json')` or `fetch().then(r => r.json())`. We keep the
|
|
11
|
+
* type intentionally loose because both `lottie-react-native` and `lottie-react`
|
|
12
|
+
* accept slightly different shapes — `LottiePlayer` narrows back to the
|
|
13
|
+
* platform-specific type internally.
|
|
14
|
+
*/
|
|
15
|
+
export type LottieAnimationSource = Record<string, unknown>
|
|
16
|
+
|
|
17
|
+
export type LottiePlayerProps = {
|
|
18
|
+
/**
|
|
19
|
+
* Parsed Lottie animation JSON. Use `require('./animation.json')` in React
|
|
20
|
+
* Native or `import animation from './animation.json'` on web.
|
|
21
|
+
*
|
|
22
|
+
* URI sources (`{ uri: '...' }`) are intentionally not accepted here — web
|
|
23
|
+
* Lottie players require the animation data to be pre-parsed. Fetch and
|
|
24
|
+
* parse the JSON yourself before passing it in if you need a remote source.
|
|
25
|
+
*/
|
|
26
|
+
source: LottieAnimationSource
|
|
27
|
+
/**
|
|
28
|
+
* Override the rendered size. Pass a number for a square box, or
|
|
29
|
+
* `{ width, height }` for non-square.
|
|
30
|
+
*
|
|
31
|
+
* When omitted, size is resolved from the `media/width` and `media/height`
|
|
32
|
+
* design tokens (default `117 × 117`). The `Media / Output` collection
|
|
33
|
+
* exposes `L | M | S` modes (117 / 70 / 20) — pass
|
|
34
|
+
* `modes={{ 'Media / Output': 'M' }}` to render at 70×70, etc.
|
|
35
|
+
*/
|
|
36
|
+
size?: number | { width: number; height: number }
|
|
37
|
+
/** Play the animation on mount. Defaults to `true`. */
|
|
38
|
+
autoPlay?: boolean
|
|
39
|
+
/** Loop the animation. Defaults to `true`. */
|
|
40
|
+
loop?: boolean
|
|
41
|
+
/** Mode configuration for design-token theming. */
|
|
42
|
+
modes?: Record<string, any>
|
|
43
|
+
/** Style overrides applied to the underlying view. */
|
|
44
|
+
style?: StyleProp<ViewStyle>
|
|
45
|
+
/** Accessibility label. Lottie is decorative by default. */
|
|
46
|
+
accessibilityLabel?: string
|
|
47
|
+
testID?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const DEFAULT_SIZE = 117
|
|
51
|
+
|
|
52
|
+
function resolveSize(
|
|
53
|
+
size: LottiePlayerProps['size'],
|
|
54
|
+
modes: Record<string, any>
|
|
55
|
+
) {
|
|
56
|
+
if (typeof size === 'number') return { width: size, height: size }
|
|
57
|
+
if (size && typeof size === 'object') return size
|
|
58
|
+
const width =
|
|
59
|
+
Number(getVariableByName('media/width', modes)) || DEFAULT_SIZE
|
|
60
|
+
const height =
|
|
61
|
+
Number(getVariableByName('media/height', modes)) || DEFAULT_SIZE
|
|
62
|
+
return { width, height }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Renders a Lottie animation using the consumer's installed
|
|
67
|
+
* `lottie-react-native` (native) or `lottie-react` (web) — both are declared
|
|
68
|
+
* as **optional peer dependencies** of `jfs-components`, so installing the
|
|
69
|
+
* library does not pull them in. Add the relevant package to your app only
|
|
70
|
+
* if you actually use `LottiePlayer`:
|
|
71
|
+
*
|
|
72
|
+
* ```sh
|
|
73
|
+
* # React Native (iOS / Android)
|
|
74
|
+
* npm install lottie-react-native
|
|
75
|
+
* cd ios && pod install
|
|
76
|
+
*
|
|
77
|
+
* # Web (or react-native-web)
|
|
78
|
+
* npm install lottie-react
|
|
79
|
+
* ```
|
|
80
|
+
*
|
|
81
|
+
* The web build (`LottiePlayer.web.tsx`) is picked automatically by Metro /
|
|
82
|
+
* webpack via platform extensions — same pattern as `MediaCard/GlassFill`.
|
|
83
|
+
*
|
|
84
|
+
* Token-driven sizing: when `size` is omitted, `LottiePlayer` reads
|
|
85
|
+
* `media/width` and `media/height` from the Figma variables resolver, so the
|
|
86
|
+
* animation matches the surrounding component's `Media / Output` mode
|
|
87
|
+
* automatically. This is the same sizing contract `PageHero` and
|
|
88
|
+
* `LottieIntroBlock` use for their `media` slots.
|
|
89
|
+
*
|
|
90
|
+
* @component
|
|
91
|
+
* @example
|
|
92
|
+
* ```tsx
|
|
93
|
+
* import animation from './assets/loader.json';
|
|
94
|
+
*
|
|
95
|
+
* <LottiePlayer source={animation} /> // 117 × 117 (default)
|
|
96
|
+
* <LottiePlayer source={animation} size={70} /> // 70 × 70
|
|
97
|
+
* <LottiePlayer source={animation} modes={{ 'Media / Output': 'S' }} /> // 20 × 20
|
|
98
|
+
* <PageHero media={<LottiePlayer source={animation} />} />
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
function LottiePlayer({
|
|
102
|
+
source,
|
|
103
|
+
size,
|
|
104
|
+
autoPlay = true,
|
|
105
|
+
loop = true,
|
|
106
|
+
modes: propModes = EMPTY_MODES,
|
|
107
|
+
style,
|
|
108
|
+
accessibilityLabel,
|
|
109
|
+
testID,
|
|
110
|
+
}: LottiePlayerProps) {
|
|
111
|
+
const { modes: globalModes } = useTokens()
|
|
112
|
+
const modes = useMemo(
|
|
113
|
+
() =>
|
|
114
|
+
globalModes === EMPTY_MODES && propModes === EMPTY_MODES
|
|
115
|
+
? EMPTY_MODES
|
|
116
|
+
: { ...globalModes, ...propModes },
|
|
117
|
+
[globalModes, propModes]
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
const { width, height } = useMemo(
|
|
121
|
+
() => resolveSize(size, modes),
|
|
122
|
+
[size, modes]
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const NativeLottieView = useMemo(() => getNativeLottieView(), [])
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<View
|
|
129
|
+
style={[{ width, height }, style]}
|
|
130
|
+
testID={testID}
|
|
131
|
+
accessibilityLabel={accessibilityLabel}
|
|
132
|
+
accessibilityElementsHidden={accessibilityLabel ? undefined : true}
|
|
133
|
+
importantForAccessibility={accessibilityLabel ? 'auto' : 'no'}
|
|
134
|
+
>
|
|
135
|
+
<NativeLottieView
|
|
136
|
+
source={source}
|
|
137
|
+
autoPlay={autoPlay}
|
|
138
|
+
loop={loop}
|
|
139
|
+
style={{ width: '100%', height: '100%' }}
|
|
140
|
+
/>
|
|
141
|
+
</View>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export default React.memo(LottiePlayer)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import React, { useMemo } from 'react'
|
|
2
|
+
import { type StyleProp, type ViewStyle } from 'react-native'
|
|
3
|
+
import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
4
|
+
import { useTokens } from '../../design-tokens/JFSThemeProvider'
|
|
5
|
+
import { EMPTY_MODES } from '../../utils/react-utils'
|
|
6
|
+
import { getWebLottieView } from './loadWebLottieView'
|
|
7
|
+
|
|
8
|
+
export type LottieAnimationSource = Record<string, unknown>
|
|
9
|
+
|
|
10
|
+
export type LottiePlayerProps = {
|
|
11
|
+
source: LottieAnimationSource
|
|
12
|
+
size?: number | { width: number; height: number }
|
|
13
|
+
autoPlay?: boolean
|
|
14
|
+
loop?: boolean
|
|
15
|
+
modes?: Record<string, any>
|
|
16
|
+
style?: StyleProp<ViewStyle>
|
|
17
|
+
accessibilityLabel?: string
|
|
18
|
+
testID?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_SIZE = 117
|
|
22
|
+
|
|
23
|
+
function resolveSize(
|
|
24
|
+
size: LottiePlayerProps['size'],
|
|
25
|
+
modes: Record<string, any>
|
|
26
|
+
) {
|
|
27
|
+
if (typeof size === 'number') return { width: size, height: size }
|
|
28
|
+
if (size && typeof size === 'object') return size
|
|
29
|
+
const width =
|
|
30
|
+
Number(getVariableByName('media/width', modes)) || DEFAULT_SIZE
|
|
31
|
+
const height =
|
|
32
|
+
Number(getVariableByName('media/height', modes)) || DEFAULT_SIZE
|
|
33
|
+
return { width, height }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Web build of `LottiePlayer` — picked automatically by webpack /
|
|
38
|
+
* Metro-for-web via the `.web.tsx` platform extension. Uses `lottie-react`
|
|
39
|
+
* (which wraps `lottie-web`) and renders a plain DOM container.
|
|
40
|
+
*
|
|
41
|
+
* Public API mirrors `LottiePlayer.tsx` (native). See that file for the
|
|
42
|
+
* documented prop reference and usage patterns.
|
|
43
|
+
*/
|
|
44
|
+
function LottiePlayer({
|
|
45
|
+
source,
|
|
46
|
+
size,
|
|
47
|
+
autoPlay = true,
|
|
48
|
+
loop = true,
|
|
49
|
+
modes: propModes = EMPTY_MODES,
|
|
50
|
+
style,
|
|
51
|
+
accessibilityLabel,
|
|
52
|
+
testID,
|
|
53
|
+
}: LottiePlayerProps) {
|
|
54
|
+
const { modes: globalModes } = useTokens()
|
|
55
|
+
const modes = useMemo(
|
|
56
|
+
() =>
|
|
57
|
+
globalModes === EMPTY_MODES && propModes === EMPTY_MODES
|
|
58
|
+
? EMPTY_MODES
|
|
59
|
+
: { ...globalModes, ...propModes },
|
|
60
|
+
[globalModes, propModes]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const { width, height } = useMemo(
|
|
64
|
+
() => resolveSize(size, modes),
|
|
65
|
+
[size, modes]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const WebLottieView = useMemo(() => getWebLottieView(), [])
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
style={{
|
|
73
|
+
width,
|
|
74
|
+
height,
|
|
75
|
+
display: 'flex',
|
|
76
|
+
alignItems: 'center',
|
|
77
|
+
justifyContent: 'center',
|
|
78
|
+
...(style as React.CSSProperties),
|
|
79
|
+
}}
|
|
80
|
+
data-testid={testID}
|
|
81
|
+
aria-label={accessibilityLabel}
|
|
82
|
+
aria-hidden={accessibilityLabel ? undefined : true}
|
|
83
|
+
>
|
|
84
|
+
<WebLottieView
|
|
85
|
+
animationData={source}
|
|
86
|
+
autoplay={autoPlay}
|
|
87
|
+
loop={loop}
|
|
88
|
+
style={{ width: '100%', height: '100%' }}
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export default React.memo(LottiePlayer)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Text, View, type ViewStyle } from 'react-native'
|
|
3
|
+
|
|
4
|
+
/** Props we forward to the underlying native Lottie view. */
|
|
5
|
+
export type NativeLottieViewProps = {
|
|
6
|
+
source: Record<string, unknown>
|
|
7
|
+
autoPlay?: boolean
|
|
8
|
+
loop?: boolean
|
|
9
|
+
style?: ViewStyle
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const INSTALL_HINT =
|
|
13
|
+
'LottiePlayer requires lottie-react-native in your app.\n' +
|
|
14
|
+
' npm install lottie-react-native\n' +
|
|
15
|
+
' cd ios && pod install'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Metro resolves `require('lottie-react-native')` at bundle time even inside
|
|
19
|
+
* try/catch, which breaks apps that import `jfs-components` without having
|
|
20
|
+
* the optional peer installed. Splitting the module id into a runtime string
|
|
21
|
+
* keeps Metro from statically linking it — the native module is loaded only
|
|
22
|
+
* when present in the consumer's node_modules.
|
|
23
|
+
*/
|
|
24
|
+
function resolveNativeLottieModuleName() {
|
|
25
|
+
return ['lottie', '-react', '-native'].join('')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function LottieUnavailableView({ style }: Pick<NativeLottieViewProps, 'style'>) {
|
|
29
|
+
if (__DEV__) {
|
|
30
|
+
return (
|
|
31
|
+
<View
|
|
32
|
+
style={[
|
|
33
|
+
style,
|
|
34
|
+
{
|
|
35
|
+
alignItems: 'center',
|
|
36
|
+
justifyContent: 'center',
|
|
37
|
+
backgroundColor: 'rgba(255, 196, 0, 0.12)',
|
|
38
|
+
borderWidth: 1,
|
|
39
|
+
borderColor: 'rgba(255, 196, 0, 0.45)',
|
|
40
|
+
borderRadius: 8,
|
|
41
|
+
padding: 8,
|
|
42
|
+
},
|
|
43
|
+
]}
|
|
44
|
+
>
|
|
45
|
+
<Text
|
|
46
|
+
style={{
|
|
47
|
+
color: '#8a6d00',
|
|
48
|
+
fontSize: 11,
|
|
49
|
+
textAlign: 'center',
|
|
50
|
+
lineHeight: 15,
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
{INSTALL_HINT}
|
|
54
|
+
</Text>
|
|
55
|
+
</View>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return <View style={style} />
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function LottieUnavailable(props: NativeLottieViewProps) {
|
|
63
|
+
React.useEffect(() => {
|
|
64
|
+
if (__DEV__) {
|
|
65
|
+
console.warn(`[jfs-components/LottiePlayer] ${INSTALL_HINT}`)
|
|
66
|
+
}
|
|
67
|
+
}, [])
|
|
68
|
+
|
|
69
|
+
return <LottieUnavailableView style={props.style} />
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let cachedView: React.ComponentType<NativeLottieViewProps> | undefined
|
|
73
|
+
|
|
74
|
+
export function getNativeLottieView(): React.ComponentType<NativeLottieViewProps> {
|
|
75
|
+
if (cachedView !== undefined) return cachedView
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const mod = require(resolveNativeLottieModuleName()) as {
|
|
79
|
+
default?: React.ComponentType<NativeLottieViewProps>
|
|
80
|
+
}
|
|
81
|
+
cachedView = mod.default ?? LottieUnavailable
|
|
82
|
+
} catch {
|
|
83
|
+
cachedView = LottieUnavailable
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return cachedView
|
|
87
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import type { CSSProperties } from 'react'
|
|
3
|
+
|
|
4
|
+
/** Props we forward to the underlying web Lottie view. */
|
|
5
|
+
export type WebLottieViewProps = {
|
|
6
|
+
animationData: Record<string, unknown>
|
|
7
|
+
autoplay?: boolean
|
|
8
|
+
loop?: boolean
|
|
9
|
+
style?: CSSProperties
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const INSTALL_HINT =
|
|
13
|
+
'LottiePlayer requires lottie-react in your app.\n' +
|
|
14
|
+
' npm install lottie-react'
|
|
15
|
+
|
|
16
|
+
function resolveWebLottieModuleName() {
|
|
17
|
+
return ['lottie', '-react'].join('')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function LottieUnavailable(props: WebLottieViewProps) {
|
|
21
|
+
React.useEffect(() => {
|
|
22
|
+
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
23
|
+
console.warn(`[jfs-components/LottiePlayer] ${INSTALL_HINT}`)
|
|
24
|
+
}
|
|
25
|
+
}, [])
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
style={{
|
|
30
|
+
...props.style,
|
|
31
|
+
display: 'flex',
|
|
32
|
+
alignItems: 'center',
|
|
33
|
+
justifyContent: 'center',
|
|
34
|
+
backgroundColor: 'rgba(255, 196, 0, 0.12)',
|
|
35
|
+
border: '1px solid rgba(255, 196, 0, 0.45)',
|
|
36
|
+
borderRadius: 8,
|
|
37
|
+
padding: 8,
|
|
38
|
+
color: '#8a6d00',
|
|
39
|
+
fontSize: 11,
|
|
40
|
+
textAlign: 'center',
|
|
41
|
+
lineHeight: '15px',
|
|
42
|
+
}}
|
|
43
|
+
>
|
|
44
|
+
{typeof __DEV__ !== 'undefined' && __DEV__ ? INSTALL_HINT : null}
|
|
45
|
+
</div>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let cachedView: React.ComponentType<WebLottieViewProps> | undefined
|
|
50
|
+
|
|
51
|
+
export function getWebLottieView(): React.ComponentType<WebLottieViewProps> {
|
|
52
|
+
if (cachedView !== undefined) return cachedView
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const mod = require(resolveWebLottieModuleName()) as {
|
|
56
|
+
default?: React.ComponentType<WebLottieViewProps>
|
|
57
|
+
}
|
|
58
|
+
cachedView = mod.default ?? LottieUnavailable
|
|
59
|
+
} catch {
|
|
60
|
+
cachedView = LottieUnavailable
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return cachedView
|
|
64
|
+
}
|
|
@@ -32,6 +32,23 @@ export type PageHeroProps = {
|
|
|
32
32
|
* `modes` are automatically cascaded into this slot.
|
|
33
33
|
*/
|
|
34
34
|
buttonSlot?: React.ReactNode
|
|
35
|
+
/**
|
|
36
|
+
* Optional media element shown above the text block (eyebrow + headline).
|
|
37
|
+
*
|
|
38
|
+
* Intentionally typed as `React.ReactNode` so the consumer can bring any
|
|
39
|
+
* renderer they like — `<Image />`, `<IconCapsule />`, a Lottie player
|
|
40
|
+
* (`lottie-react-native` / `@lottiefiles/dotlottie-react`), an `<SvgXml />`
|
|
41
|
+
* from `react-native-svg`, a `<Video />` from `react-native-video`, a
|
|
42
|
+
* gradient view, or a custom illustration. The library deliberately does
|
|
43
|
+
* NOT wrap Lottie / video runtimes (they require native modules + pod
|
|
44
|
+
* autolinking on every consumer's app), so PageHero just allocates a
|
|
45
|
+
* token-sized container and lets the slot render whatever it wants.
|
|
46
|
+
*
|
|
47
|
+
* The slot is rendered inside a `width × height` box driven by
|
|
48
|
+
* `media/width` and `media/height` tokens (default 117×117). `modes` are
|
|
49
|
+
* automatically cascaded into the slot via `cloneChildrenWithModes`.
|
|
50
|
+
*/
|
|
51
|
+
media?: React.ReactNode
|
|
35
52
|
/** Mode configuration for design-token theming. */
|
|
36
53
|
modes?: Record<string, any>
|
|
37
54
|
/** Style overrides applied to the outer container. */
|
|
@@ -41,12 +58,14 @@ export type PageHeroProps = {
|
|
|
41
58
|
|
|
42
59
|
/**
|
|
43
60
|
* PageHero displays a centered hero block typically used at the top of a page
|
|
44
|
-
* or feature screen. It contains an
|
|
45
|
-
*
|
|
61
|
+
* or feature screen. It contains an optional media slot (illustration / image
|
|
62
|
+
* / Lottie / SVG / video — consumer's choice), an eyebrow line, a large
|
|
63
|
+
* headline, an optional supporting line (e.g. price / timeline), and an
|
|
64
|
+
* optional action button.
|
|
46
65
|
*
|
|
47
66
|
* All visual values are resolved from Figma design tokens via
|
|
48
|
-
* `getVariableByName`.
|
|
49
|
-
*
|
|
67
|
+
* `getVariableByName`. Slots cascade the active `modes` to their children
|
|
68
|
+
* through `cloneChildrenWithModes`.
|
|
50
69
|
*
|
|
51
70
|
* @component
|
|
52
71
|
* @example
|
|
@@ -57,6 +76,13 @@ export type PageHeroProps = {
|
|
|
57
76
|
* supportingText="₹999/year · ₹0 until 2027"
|
|
58
77
|
* buttonLabel="Renew for free"
|
|
59
78
|
* onButtonPress={() => navigate('Upgrade')}
|
|
79
|
+
* media={
|
|
80
|
+
* <Image
|
|
81
|
+
* imageSource={require('./assets/upgrade.png')}
|
|
82
|
+
* width={117}
|
|
83
|
+
* height={117}
|
|
84
|
+
* />
|
|
85
|
+
* }
|
|
60
86
|
* />
|
|
61
87
|
* ```
|
|
62
88
|
*/
|
|
@@ -69,6 +95,7 @@ function PageHero({
|
|
|
69
95
|
onButtonPress,
|
|
70
96
|
showButton = true,
|
|
71
97
|
buttonSlot,
|
|
98
|
+
media,
|
|
72
99
|
modes: propModes = EMPTY_MODES,
|
|
73
100
|
style,
|
|
74
101
|
testID,
|
|
@@ -86,6 +113,12 @@ function PageHero({
|
|
|
86
113
|
const textWrapGap =
|
|
87
114
|
Number(getVariableByName('PageHero/textWrap/gap', modes)) || 8
|
|
88
115
|
|
|
116
|
+
// Media slot box — matches the 117×117 frame in Figma (node 4540:7845).
|
|
117
|
+
// Tokens fall back to 117 when not defined in the variables collection,
|
|
118
|
+
// so the layout stays stable on consumers that haven't tokenized this yet.
|
|
119
|
+
const mediaWidth = Number(getVariableByName('media/width', modes)) || 117
|
|
120
|
+
const mediaHeight = Number(getVariableByName('media/height', modes)) || 117
|
|
121
|
+
|
|
89
122
|
const eyebrowColor =
|
|
90
123
|
getVariableByName('PageHero/eyebrow/color', modes) || '#ffffff'
|
|
91
124
|
const eyebrowFontFamily =
|
|
@@ -183,8 +216,32 @@ function PageHero({
|
|
|
183
216
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
184
217
|
}, [buttonSlot, showButton, buttonLabel, onButtonPress, modes])
|
|
185
218
|
|
|
219
|
+
// Sized container for the media slot. Always rendered when `media` is
|
|
220
|
+
// provided, so the slot has a predictable box (matches Figma frame
|
|
221
|
+
// 4540:7845 — 117×117 by default) even if the inner element omits its
|
|
222
|
+
// own width/height. `overflow: 'hidden'` mirrors the Figma frame's
|
|
223
|
+
// `clipsContent` so a slightly oversized illustration doesn't break
|
|
224
|
+
// the centered layout.
|
|
225
|
+
const mediaContent = useMemo<React.ReactNode>(() => {
|
|
226
|
+
if (media === undefined || media === null) return null
|
|
227
|
+
return (
|
|
228
|
+
<View
|
|
229
|
+
style={{
|
|
230
|
+
width: mediaWidth,
|
|
231
|
+
height: mediaHeight,
|
|
232
|
+
alignItems: 'center',
|
|
233
|
+
justifyContent: 'center',
|
|
234
|
+
overflow: 'hidden',
|
|
235
|
+
}}
|
|
236
|
+
>
|
|
237
|
+
{cloneChildrenWithModes(media, modes)}
|
|
238
|
+
</View>
|
|
239
|
+
)
|
|
240
|
+
}, [media, mediaWidth, mediaHeight, modes])
|
|
241
|
+
|
|
186
242
|
return (
|
|
187
243
|
<View style={[containerStyle, style]} testID={testID}>
|
|
244
|
+
{mediaContent}
|
|
188
245
|
<View style={textWrapStyle}>
|
|
189
246
|
{eyebrow ? <Text style={eyebrowStyle}>{eyebrow}</Text> : null}
|
|
190
247
|
{headline ? <Text style={headlineStyle}>{headline}</Text> : null}
|