jfs-components 0.1.30 → 0.1.32

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 (41) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/lib/commonjs/components/CategoryCard/CategoryCard.js +264 -0
  3. package/lib/commonjs/components/CompareTable/CompareTable.js +140 -84
  4. package/lib/commonjs/components/ContentSheet/ContentSheet.js +28 -5
  5. package/lib/commonjs/components/CounterBadge/CounterBadge.js +78 -0
  6. package/lib/commonjs/components/FavoriteToggle/FavoriteToggle.js +180 -0
  7. package/lib/commonjs/components/HelloJioInput/HelloJioInput.js +287 -0
  8. package/lib/commonjs/components/ValueBackMetric/ValueBackMetric.js +219 -0
  9. package/lib/commonjs/components/index.js +35 -0
  10. package/lib/commonjs/design-tokens/figma-modes.generated.js +3 -0
  11. package/lib/commonjs/icons/registry.js +1 -1
  12. package/lib/module/components/CategoryCard/CategoryCard.js +258 -0
  13. package/lib/module/components/CompareTable/CompareTable.js +140 -84
  14. package/lib/module/components/ContentSheet/ContentSheet.js +29 -6
  15. package/lib/module/components/CounterBadge/CounterBadge.js +73 -0
  16. package/lib/module/components/FavoriteToggle/FavoriteToggle.js +174 -0
  17. package/lib/module/components/HelloJioInput/HelloJioInput.js +281 -0
  18. package/lib/module/components/ValueBackMetric/ValueBackMetric.js +214 -0
  19. package/lib/module/components/index.js +6 -1
  20. package/lib/module/design-tokens/figma-modes.generated.js +3 -0
  21. package/lib/module/icons/registry.js +1 -1
  22. package/lib/typescript/src/components/CategoryCard/CategoryCard.d.ts +72 -0
  23. package/lib/typescript/src/components/CompareTable/CompareTable.d.ts +16 -1
  24. package/lib/typescript/src/components/ContentSheet/ContentSheet.d.ts +7 -1
  25. package/lib/typescript/src/components/CounterBadge/CounterBadge.d.ts +19 -0
  26. package/lib/typescript/src/components/FavoriteToggle/FavoriteToggle.d.ts +66 -0
  27. package/lib/typescript/src/components/HelloJioInput/HelloJioInput.d.ts +111 -0
  28. package/lib/typescript/src/components/ValueBackMetric/ValueBackMetric.d.ts +86 -0
  29. package/lib/typescript/src/components/index.d.ts +5 -0
  30. package/lib/typescript/src/icons/registry.d.ts +1 -1
  31. package/package.json +1 -1
  32. package/src/components/CategoryCard/CategoryCard.tsx +404 -0
  33. package/src/components/CompareTable/CompareTable.tsx +201 -101
  34. package/src/components/ContentSheet/ContentSheet.tsx +40 -11
  35. package/src/components/CounterBadge/CounterBadge.tsx +95 -0
  36. package/src/components/FavoriteToggle/FavoriteToggle.tsx +243 -0
  37. package/src/components/HelloJioInput/HelloJioInput.tsx +513 -0
  38. package/src/components/ValueBackMetric/ValueBackMetric.tsx +298 -0
  39. package/src/components/index.ts +5 -0
  40. package/src/design-tokens/figma-modes.generated.ts +3 -0
  41. package/src/icons/registry.ts +1 -1
@@ -0,0 +1,404 @@
1
+ import React, { useCallback, useMemo } from 'react'
2
+ import {
3
+ Pressable,
4
+ Text,
5
+ View,
6
+ type AccessibilityState,
7
+ type ImageSourcePropType,
8
+ type PressableStateCallbackType,
9
+ type StyleProp,
10
+ type TextStyle,
11
+ type ViewStyle,
12
+ } from 'react-native'
13
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
14
+ import { useTokens } from '../../design-tokens/JFSThemeProvider'
15
+ import {
16
+ EMPTY_MODES,
17
+ cloneChildrenWithModes,
18
+ resolveTextLayout,
19
+ } from '../../utils/react-utils'
20
+ import {
21
+ usePressableWebSupport,
22
+ type SafePressableProps,
23
+ type WebAccessibilityProps,
24
+ } from '../../utils/web-platform-utils'
25
+ import Badge from '../Badge/Badge'
26
+ import Image from '../Image/Image'
27
+ import type { Modes } from '../../design-tokens'
28
+
29
+ const CATEGORY_CARD_STATE = 'Category Card State'
30
+ const BADGE_OVERLAP = 11
31
+ const DEFAULT_CARD_WIDTH = 60
32
+
33
+ // Default modes from the Figma CategoryCard component. Overridable via `modes`.
34
+ const CATEGORY_CARD_DEFAULT_MODES = Object.freeze({
35
+ AppearanceBrand: 'Tertiary',
36
+ 'Badge Size': 'Small',
37
+ Context4: 'Badge',
38
+ "Radius": "None"
39
+ })
40
+
41
+ const toNumber = (value: unknown, fallback: number): number => {
42
+ if (typeof value === 'number') {
43
+ return Number.isFinite(value) ? value : fallback
44
+ }
45
+ if (typeof value === 'string') {
46
+ const parsed = Number(value)
47
+ return Number.isFinite(parsed) ? parsed : fallback
48
+ }
49
+ return fallback
50
+ }
51
+
52
+ const toFontWeight = (
53
+ value: unknown,
54
+ fallback: TextStyle['fontWeight']
55
+ ): TextStyle['fontWeight'] => {
56
+ if (typeof value === 'number') return String(value) as TextStyle['fontWeight']
57
+ if (typeof value === 'string') return value as TextStyle['fontWeight']
58
+ return fallback
59
+ }
60
+
61
+ export type CategoryCardProps = SafePressableProps & {
62
+ /** Bottom label (e.g. `"Popular"`). */
63
+ label?: string
64
+ /**
65
+ * Image shown in the card centre. Ignored when `imageSlot` is provided.
66
+ * Accepts a remote URL string, `{ uri }` object, or `require()`-ed asset.
67
+ */
68
+ imageSource?: ImageSourcePropType | string
69
+ /**
70
+ * Slot replacing the default `Image`. Receives `modes` recursively so inner
71
+ * components inherit theming.
72
+ */
73
+ imageSlot?: React.ReactNode
74
+ /**
75
+ * Optional badge label rendered as an overlapping pill at the top edge.
76
+ * Omit or pass `undefined` to hide the badge.
77
+ */
78
+ badge?: string
79
+ /** Whether the card is in the selected / active state. */
80
+ active?: boolean
81
+ /**
82
+ * Fixed width of the card in dp. Defaults to `60` per the Figma spec.
83
+ */
84
+ width?: number
85
+ /**
86
+ * When `true`, disables text truncation: the trailing ellipsis (`…`) is never
87
+ * shown and any `numberOfLines` clamp is ignored, so the label wraps to as many
88
+ * lines as it needs even when horizontal space is tight. Same behaviour as
89
+ * {@link Text}'s `disableTruncation`.
90
+ */
91
+ disableTruncation?: boolean
92
+ /**
93
+ * When `true`, forces the label onto a single line with no wrapping (implies
94
+ * `disableTruncation`). On web the line overflows horizontally instead of
95
+ * wrapping or truncating. Same behaviour as {@link Text}'s `singleLine`.
96
+ */
97
+ singleLine?: boolean
98
+ /** Press handler — the card is always a `Pressable`. */
99
+ onPress?: () => void
100
+ disabled?: boolean
101
+ /** Design token modes for theming (e.g. `{ 'Color Mode': 'Light' }`). */
102
+ modes?: Modes
103
+ /** Container style override. */
104
+ style?: StyleProp<ViewStyle>
105
+ /** Label style override. */
106
+ labelStyle?: StyleProp<TextStyle>
107
+ accessibilityLabel?: string
108
+ accessibilityHint?: string
109
+ accessibilityState?: AccessibilityState
110
+ webAccessibilityProps?: WebAccessibilityProps
111
+ testID?: string
112
+ }
113
+
114
+ interface CategoryCardTokens {
115
+ containerStyle: ViewStyle
116
+ labelStyle: TextStyle
117
+ imageSize: number
118
+ imageRadius: number
119
+ }
120
+
121
+ function resolveCategoryCardTokens(
122
+ modes: Modes,
123
+ active: boolean
124
+ ): CategoryCardTokens {
125
+ const idleModes = { ...modes, [CATEGORY_CARD_STATE]: 'Idle' }
126
+ const activeModes = { ...modes, [CATEGORY_CARD_STATE]: 'Active' }
127
+ const resolvedModes = active ? activeModes : idleModes
128
+
129
+ const gap = toNumber(getVariableByName('cardTab/gap', resolvedModes), 6)
130
+ const paddingHorizontal = toNumber(
131
+ getVariableByName('cardTab/padding/horizontal', resolvedModes),
132
+ 8
133
+ )
134
+ const paddingVertical = toNumber(
135
+ getVariableByName('cardTab/padding/vertical', resolvedModes),
136
+ 8
137
+ )
138
+ const radius = toNumber(getVariableByName('cardTab/radius', resolvedModes), 8)
139
+ const strokeWidth = toNumber(
140
+ getVariableByName('cardTab/strokeWidth', resolvedModes),
141
+ 1
142
+ )
143
+ const strokeColor =
144
+ (getVariableByName('cardTab/stroke/color', resolvedModes) as string | null) ??
145
+ '#f5f5f5'
146
+
147
+ const idleBackground =
148
+ (getVariableByName('cardTab/background/color', idleModes) as string | null) ??
149
+ '#ffffff'
150
+ const activeBackground =
151
+ (getVariableByName('cardTab/background/color', activeModes) as string | null) ??
152
+ '#f5f5f5'
153
+ const backgroundColor = active ? activeBackground : idleBackground
154
+
155
+ const imageSize = toNumber(
156
+ getVariableByName('cardTab/image/width', resolvedModes),
157
+ 36
158
+ )
159
+ const imageRadius = toNumber(
160
+ getVariableByName('image/radius', resolvedModes),
161
+ 0
162
+ )
163
+
164
+ const fontFamily =
165
+ (getVariableByName('cardTab/label/fontFamily', resolvedModes) as string | null) ??
166
+ 'JioType Var'
167
+ const fontSize = toNumber(
168
+ getVariableByName('cardTab/label/fontSize', resolvedModes),
169
+ 12
170
+ )
171
+ const lineHeight = toNumber(
172
+ getVariableByName('cardTab/label/lineHeight', resolvedModes),
173
+ Math.round(fontSize * 1.3)
174
+ )
175
+ const fontWeight = toFontWeight(
176
+ getVariableByName('cardTab/label/fontWeight', resolvedModes),
177
+ '500'
178
+ )
179
+ const labelColor =
180
+ (getVariableByName('cardTab/label/color', resolvedModes) as string | null) ??
181
+ '#000000'
182
+
183
+ return {
184
+ containerStyle: {
185
+ position: 'relative',
186
+ alignItems: 'center',
187
+ justifyContent: 'center',
188
+ backgroundColor,
189
+ borderColor: strokeColor,
190
+ borderWidth: strokeWidth,
191
+ borderStyle: 'solid',
192
+ borderRadius: radius,
193
+ paddingHorizontal,
194
+ paddingTop: paddingVertical,
195
+ paddingBottom: paddingVertical,
196
+ gap,
197
+ },
198
+ labelStyle: {
199
+ color: labelColor,
200
+ fontFamily,
201
+ fontSize,
202
+ fontWeight,
203
+ lineHeight,
204
+ textAlign: 'center',
205
+ },
206
+ imageSize,
207
+ imageRadius,
208
+ }
209
+ }
210
+
211
+ const pressedOverlayStyle: ViewStyle = { opacity: 0.85 }
212
+ const disabledOverlayStyle: ViewStyle = { opacity: 0.5 }
213
+
214
+ /**
215
+ * `CategoryCard` is a compact, tappable tile for category pickers and
216
+ * merchandising rails. It stacks a square image above a short label and can
217
+ * optionally show an overlapping badge at the top edge. Cards are typically
218
+ * laid out in a horizontal `ScrollView` or `HStack`.
219
+ *
220
+ * All visual values resolve through the `cardTab/*` design tokens with
221
+ * sensible Figma defaults so the card renders correctly out of the box.
222
+ *
223
+ * @component
224
+ * @param {CategoryCardProps} props
225
+ */
226
+ function CategoryCard({
227
+ label = 'Popular',
228
+ imageSource,
229
+ imageSlot,
230
+ badge,
231
+ active = false,
232
+ width = DEFAULT_CARD_WIDTH,
233
+ disableTruncation,
234
+ singleLine,
235
+ onPress,
236
+ disabled = false,
237
+ modes: propModes = EMPTY_MODES,
238
+ style,
239
+ labelStyle,
240
+ accessibilityLabel,
241
+ accessibilityHint,
242
+ accessibilityState,
243
+ webAccessibilityProps,
244
+ testID,
245
+ ...rest
246
+ }: CategoryCardProps) {
247
+ const { modes: globalModes } = useTokens()
248
+ const modes = useMemo(
249
+ () => ({
250
+ ...globalModes,
251
+ ...CATEGORY_CARD_DEFAULT_MODES,
252
+ ...propModes,
253
+ }),
254
+ [globalModes, propModes]
255
+ )
256
+
257
+ const resolvedModes = useMemo(
258
+ () => ({
259
+ ...modes,
260
+ [CATEGORY_CARD_STATE]: active ? 'Active' : 'Idle',
261
+ }),
262
+ [modes, active]
263
+ )
264
+
265
+ const tokens = useMemo(
266
+ () => resolveCategoryCardTokens(resolvedModes, active),
267
+ [resolvedModes, active]
268
+ )
269
+
270
+ const { style: labelLayoutStyle, ...labelTruncation } = useMemo(
271
+ () =>
272
+ resolveTextLayout({
273
+ disableTruncation,
274
+ singleLine,
275
+ numberOfLines: 1,
276
+ ellipsizeMode: 'tail',
277
+ }),
278
+ [disableTruncation, singleLine]
279
+ )
280
+
281
+ const processedImageSlot = useMemo(() => {
282
+ if (!imageSlot) return null
283
+ const processed = cloneChildrenWithModes(imageSlot, resolvedModes)
284
+ return processed.length === 1 ? processed[0] : processed
285
+ }, [imageSlot, resolvedModes])
286
+
287
+ const imageNode =
288
+ processedImageSlot ??
289
+ (imageSource !== undefined ? (
290
+ <Image
291
+ imageSource={imageSource}
292
+ width={tokens.imageSize}
293
+ height={tokens.imageSize}
294
+ ratio={1}
295
+ resizeMode="cover"
296
+ borderRadius={tokens.imageRadius}
297
+ accessibilityElementsHidden
298
+ importantForAccessibility="no"
299
+ />
300
+ ) : (
301
+ <Image
302
+ imageSource="https://www.figma.com/api/mcp/asset/0ee34871-646f-4736-be06-22fb6c0a4eed"
303
+ width={tokens.imageSize}
304
+ height={tokens.imageSize}
305
+ ratio={1}
306
+ resizeMode="cover"
307
+ borderRadius={tokens.imageRadius}
308
+ accessibilityElementsHidden
309
+ importantForAccessibility="no"
310
+ />
311
+ ))
312
+
313
+ const a11yLabel = accessibilityLabel ?? (badge ? `${label}, ${badge}` : label)
314
+
315
+ const webProps = usePressableWebSupport({
316
+ restProps: rest,
317
+ onPress: disabled ? undefined : onPress,
318
+ disabled,
319
+ accessibilityLabel: a11yLabel,
320
+ webAccessibilityProps,
321
+ })
322
+
323
+ const badgeNode = badge ? (
324
+ <View style={BADGE_ANCHOR} pointerEvents="none">
325
+ <Badge label={badge} modes={resolvedModes} style={BADGE_CENTER_STYLE} />
326
+ </View>
327
+ ) : null
328
+
329
+ // Truncation needs a width bound so the ellipsis engages. `singleLine`
330
+ // must NOT fill the content box — otherwise the text is pinned to the left
331
+ // padding edge and only grows rightward. Intrinsic width + centered
332
+ // alignment lets overflow expand equally on both sides.
333
+ const labelWidthStyle = singleLine
334
+ ? LABEL_OVERFLOW_CENTER_STYLE
335
+ : LABEL_FILL_STYLE
336
+
337
+ const pressableStyle = useCallback(
338
+ ({ pressed }: PressableStateCallbackType): StyleProp<ViewStyle> => [
339
+ tokens.containerStyle,
340
+ { width },
341
+ badge ? { marginTop: BADGE_OVERLAP } : null,
342
+ disabled ? disabledOverlayStyle : null,
343
+ pressed && !disabled ? pressedOverlayStyle : null,
344
+ style,
345
+ ],
346
+ [tokens.containerStyle, width, badge, disabled, style]
347
+ )
348
+
349
+ return (
350
+ <Pressable
351
+ accessibilityRole="button"
352
+ accessibilityLabel={a11yLabel}
353
+ accessibilityHint={accessibilityHint}
354
+ accessibilityState={{
355
+ disabled,
356
+ selected: active,
357
+ ...accessibilityState,
358
+ }}
359
+ onPress={onPress}
360
+ disabled={disabled}
361
+ style={pressableStyle}
362
+ testID={testID}
363
+ {...webProps}
364
+ >
365
+ {badgeNode}
366
+ {imageNode}
367
+ <Text
368
+ style={[tokens.labelStyle, labelWidthStyle, labelLayoutStyle, labelStyle]}
369
+ {...labelTruncation}
370
+ accessibilityElementsHidden
371
+ importantForAccessibility="no"
372
+ >
373
+ {label}
374
+ </Text>
375
+ </Pressable>
376
+ )
377
+ }
378
+
379
+ const BADGE_ANCHOR: ViewStyle = {
380
+ position: 'absolute',
381
+ top: -BADGE_OVERLAP,
382
+ left: 0,
383
+ right: 0,
384
+ alignItems: 'center',
385
+ justifyContent: 'center',
386
+ zIndex: 1,
387
+ }
388
+
389
+ const BADGE_CENTER_STYLE: ViewStyle = { alignSelf: 'center' }
390
+
391
+ /** Lets the label fill the card width so the one-line ellipsis has a bound. */
392
+ const LABEL_FILL_STYLE: TextStyle = { width: '100%', alignSelf: 'stretch' }
393
+
394
+ /**
395
+ * Intrinsic width, horizontally centred. When the label is wider than the
396
+ * card it overflows left and right equally instead of growing only rightward
397
+ * from the padded content edge.
398
+ */
399
+ const LABEL_OVERFLOW_CENTER_STYLE: TextStyle = {
400
+ alignSelf: 'center',
401
+ textAlign: 'center',
402
+ }
403
+
404
+ export default React.memo(CategoryCard)