jfs-components 0.1.30 → 0.1.33

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 (46) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/D2C.md +118 -0
  3. package/lib/commonjs/components/CategoryCard/CategoryCard.js +264 -0
  4. package/lib/commonjs/components/CompareTable/CompareTable.js +140 -84
  5. package/lib/commonjs/components/ContentSheet/ContentSheet.js +28 -5
  6. package/lib/commonjs/components/CounterBadge/CounterBadge.js +78 -0
  7. package/lib/commonjs/components/FavoriteToggle/FavoriteToggle.js +180 -0
  8. package/lib/commonjs/components/HelloJioInput/HelloJioInput.js +287 -0
  9. package/lib/commonjs/components/ValueBackMetric/ValueBackMetric.js +219 -0
  10. package/lib/commonjs/components/index.js +35 -0
  11. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +40095 -1
  12. package/lib/commonjs/design-tokens/figma-modes.generated.js +3 -0
  13. package/lib/commonjs/icons/registry.js +1 -1
  14. package/lib/module/components/CategoryCard/CategoryCard.js +258 -0
  15. package/lib/module/components/CompareTable/CompareTable.js +140 -84
  16. package/lib/module/components/ContentSheet/ContentSheet.js +29 -6
  17. package/lib/module/components/CounterBadge/CounterBadge.js +73 -0
  18. package/lib/module/components/FavoriteToggle/FavoriteToggle.js +174 -0
  19. package/lib/module/components/HelloJioInput/HelloJioInput.js +281 -0
  20. package/lib/module/components/ValueBackMetric/ValueBackMetric.js +214 -0
  21. package/lib/module/components/index.js +6 -1
  22. package/lib/module/design-tokens/Coin Variables-variables-full.json +40095 -1
  23. package/lib/module/design-tokens/figma-modes.generated.js +3 -0
  24. package/lib/module/icons/registry.js +1 -1
  25. package/lib/typescript/src/components/CategoryCard/CategoryCard.d.ts +72 -0
  26. package/lib/typescript/src/components/CompareTable/CompareTable.d.ts +16 -1
  27. package/lib/typescript/src/components/ContentSheet/ContentSheet.d.ts +7 -1
  28. package/lib/typescript/src/components/CounterBadge/CounterBadge.d.ts +19 -0
  29. package/lib/typescript/src/components/FavoriteToggle/FavoriteToggle.d.ts +66 -0
  30. package/lib/typescript/src/components/HelloJioInput/HelloJioInput.d.ts +111 -0
  31. package/lib/typescript/src/components/ValueBackMetric/ValueBackMetric.d.ts +86 -0
  32. package/lib/typescript/src/components/index.d.ts +5 -0
  33. package/lib/typescript/src/icons/registry.d.ts +1 -1
  34. package/package.json +7 -1
  35. package/scripts/extract-figma-modes.js +359 -0
  36. package/src/components/CategoryCard/CategoryCard.tsx +404 -0
  37. package/src/components/CompareTable/CompareTable.tsx +201 -101
  38. package/src/components/ContentSheet/ContentSheet.tsx +40 -11
  39. package/src/components/CounterBadge/CounterBadge.tsx +95 -0
  40. package/src/components/FavoriteToggle/FavoriteToggle.tsx +243 -0
  41. package/src/components/HelloJioInput/HelloJioInput.tsx +513 -0
  42. package/src/components/ValueBackMetric/ValueBackMetric.tsx +298 -0
  43. package/src/components/index.ts +5 -0
  44. package/src/design-tokens/Coin Variables-variables-full.json +40095 -1
  45. package/src/design-tokens/figma-modes.generated.ts +3 -0
  46. package/src/icons/registry.ts +1 -1
@@ -163,6 +163,21 @@ export type CompareTableProps = {
163
163
  * no scrolling). @default undefined
164
164
  */
165
165
  columnWidth?: number;
166
+ /**
167
+ * When `true` (default), each section's header row (the gray header label)
168
+ * sticks to the top of the viewport when scrolling vertically, with multiple
169
+ * sticky headers stacking up as each reaches the top. Only applies in
170
+ * fixed-width scroll mode (`columnWidth` is set).
171
+ * @default true
172
+ */
173
+ stickyHeaders?: boolean;
174
+ /**
175
+ * When `true` (default), the section header label stays fixed on the left
176
+ * when scrolling horizontally. Only applies in fixed-width scroll mode
177
+ * (`columnWidth` is set).
178
+ * @default true
179
+ */
180
+ stickyHeaderLabel?: boolean;
166
181
  };
167
182
 
168
183
  const DEFAULT_COLUMNS: CompareTableColumn[] = [
@@ -205,6 +220,8 @@ function CompareTable({
205
220
  style,
206
221
  disableTruncation,
207
222
  columnWidth,
223
+ stickyHeaders = true,
224
+ stickyHeaderLabel = true,
208
225
  }: CompareTableProps) {
209
226
  // --- selection card tokens ------------------------------------------------
210
227
  const cardBg = (getVariableByName('selectionCard/background/color', modes) as string) ?? '#ffffff'
@@ -278,13 +295,13 @@ function CompareTable({
278
295
  // Stable per-section ref-callback instances so React doesn't detach/reattach
279
296
  // on every render (which would churn the map and re-fire scrollTo).
280
297
  const bodyRefCallbacks = useRef<Map<number, (r: ScrollView | null) => void>>(new Map())
281
- /** Last offset we synced everyone to. Echoed onScroll events reporting this
282
- * same value are ignoredthis is what prevents feedback loops. */
298
+ /** Tracks which ScrollView the user is currently dragging. Only the
299
+ * actively-dragged ScrollView drives horizontal sync programmatic
300
+ * scrollTo echoes from synced peers are ignored, eliminating jitter. */
301
+ const draggingSource = useRef<'cards' | number | null>(null)
283
302
  const lastSyncX = useRef(0)
284
303
 
285
- const applySync = useCallback((x: number, source: 'cards' | number) => {
286
- if (Math.abs(x - lastSyncX.current) < 0.5) return
287
- lastSyncX.current = x
304
+ const syncOthers = useCallback((source: 'cards' | number, x: number) => {
288
305
  if (source !== 'cards' && cardsScrollRef.current) {
289
306
  cardsScrollRef.current.scrollTo({ x, animated: false })
290
307
  }
@@ -295,18 +312,54 @@ function CompareTable({
295
312
  })
296
313
  }, [])
297
314
 
298
- const onCardsScroll = useCallback(
299
- (e: NativeSyntheticEvent<NativeScrollEvent>) => {
300
- applySync(e.nativeEvent.contentOffset.x, 'cards')
315
+ const onScrollBeginDrag = useCallback(
316
+ (source: 'cards' | number) => () => {
317
+ draggingSource.current = source
301
318
  },
302
- [applySync],
319
+ [],
303
320
  )
304
321
 
305
- const onBodyScroll = useCallback(
306
- (idx: number) => (e: NativeSyntheticEvent<NativeScrollEvent>) => {
307
- applySync(e.nativeEvent.contentOffset.x, idx)
308
- },
309
- [applySync],
322
+ const onScrollEndDrag = useCallback(
323
+ (source: 'cards' | number) =>
324
+ (e: NativeSyntheticEvent<NativeScrollEvent>) => {
325
+ const x = Math.round(e.nativeEvent.contentOffset.x)
326
+ if (x === lastSyncX.current) return
327
+ lastSyncX.current = x
328
+ syncOthers(source, x)
329
+ },
330
+ [syncOthers],
331
+ )
332
+
333
+ const onMomentumScrollEnd = useCallback(
334
+ (source: 'cards' | number) =>
335
+ (e: NativeSyntheticEvent<NativeScrollEvent>) => {
336
+ if (draggingSource.current === source) {
337
+ draggingSource.current = null
338
+ }
339
+ const x = Math.round(e.nativeEvent.contentOffset.x)
340
+ if (x === lastSyncX.current) return
341
+ lastSyncX.current = x
342
+ syncOthers(source, x)
343
+ },
344
+ [syncOthers],
345
+ )
346
+
347
+ /** Real-time scroll sync: only the actively-dragged ScrollView drives
348
+ * sync on native — echoed onScroll events from programmatic scrollTo
349
+ * on other ScrollViews are ignored because draggingSource won't match.
350
+ * On web, onScrollBeginDrag never fires for mouse/trackpad, so skip
351
+ * the guard and rely on the lastSyncX rounded-value check instead. */
352
+ const onScroll = useCallback(
353
+ (source: 'cards' | number) =>
354
+ (e: NativeSyntheticEvent<NativeScrollEvent>) => {
355
+ if (Platform.OS !== 'web' && draggingSource.current !== source) return
356
+
357
+ const x = Math.round(e.nativeEvent.contentOffset.x)
358
+ if (x === lastSyncX.current) return
359
+ lastSyncX.current = x
360
+ syncOthers(source, x)
361
+ },
362
+ [syncOthers],
310
363
  )
311
364
 
312
365
  const getBodyRefCallback = useCallback((idx: number) => {
@@ -576,72 +629,62 @@ function CompareTable({
576
629
  </Table>
577
630
  )
578
631
 
579
- // Render a single section in fixed-width scroll mode. Unlike `renderTable`
580
- // (used in flex mode), the gray header row is rendered full-width OUTSIDE
581
- // the body's horizontal ScrollView so it stays put horizontally (it does
582
- // not slide with the cells) while still scrolling vertically with the table.
583
- // The body rows live inside a horizontal ScrollView that is scroll-synced
584
- // with the cards row and every other section's body.
585
- const renderSection = (section: CompareTableSection, index: number) => (
586
- <Accordion
587
- key={section.key ?? section.title ?? index}
588
- title={section.title}
589
- defaultExpanded={section.defaultExpanded ?? index === 0}
590
- modes={modes}
591
- >
592
- {section.header != null && (
593
- <View
594
- style={{
595
- width: '100%',
596
- backgroundColor: headerBg,
597
- paddingHorizontal: headerPaddingH,
598
- paddingVertical: headerPaddingV,
599
- borderBottomWidth: cellBorderSize,
600
- borderBottomColor: cellBorderColor,
601
- }}
602
- >
603
- <Text style={headerTextStyle}>{section.header}</Text>
604
- </View>
605
- )}
606
- <ScrollView
607
- ref={getBodyRefCallback(index)}
608
- horizontal
609
- showsHorizontalScrollIndicator={false}
610
- scrollEventThrottle={16}
611
- onScroll={onBodyScroll(index)}
632
+ const renderHeaderContent = (section: CompareTableSection) =>
633
+ section.header != null ? (
634
+ <View
635
+ style={{
636
+ backgroundColor: headerBg,
637
+ paddingHorizontal: headerPaddingH,
638
+ paddingVertical: headerPaddingV,
639
+ borderBottomWidth: cellBorderSize,
640
+ borderBottomColor: cellBorderColor,
641
+ }}
612
642
  >
613
- <View style={{ width: contentWidth }}>
614
- {section.rows.map((row, rowIndex) => {
615
- const isLastRow = rowIndex === section.rows.length - 1
616
- return (
617
- <View
618
- key={row.key ?? rowIndex}
619
- style={{ flexDirection: 'row', width: '100%' }}
620
- >
621
- {Array.from({ length: columnCount }).map((_, colIndex) =>
622
- renderTableCell(
623
- renderCellContent(
624
- row.values?.[colIndex],
625
- `${rowIndex}-${colIndex}`,
626
- ),
627
- colIndex,
628
- isLastRow,
629
- colIndex,
643
+ <Text style={headerTextStyle}>{section.header}</Text>
644
+ </View>
645
+ ) : null
646
+
647
+ const renderBodyContent = (section: CompareTableSection, index: number) => (
648
+ <ScrollView
649
+ ref={getBodyRefCallback(index)}
650
+ horizontal
651
+ showsHorizontalScrollIndicator={false}
652
+ onScroll={onScroll(index)}
653
+ onScrollBeginDrag={onScrollBeginDrag(index)}
654
+ onScrollEndDrag={onScrollEndDrag(index)}
655
+ onMomentumScrollEnd={onMomentumScrollEnd(index)}
656
+ >
657
+ <View style={{ width: contentWidth }}>
658
+ {section.rows.map((row, rowIndex) => {
659
+ const isLastRow = rowIndex === section.rows.length - 1
660
+ return (
661
+ <View
662
+ key={row.key ?? rowIndex}
663
+ style={{ flexDirection: 'row', width: '100%' }}
664
+ >
665
+ {Array.from({ length: columnCount }).map((_, colIndex) =>
666
+ renderTableCell(
667
+ renderCellContent(
668
+ row.values?.[colIndex],
669
+ `${rowIndex}-${colIndex}`,
630
670
  ),
671
+ colIndex,
672
+ isLastRow,
673
+ colIndex,
674
+ ),
675
+ )}
676
+ {showAddCard &&
677
+ renderTableCell(
678
+ null,
679
+ gridColumnCount - 1,
680
+ isLastRow,
681
+ '__add_spacer__',
631
682
  )}
632
- {showAddCard &&
633
- renderTableCell(
634
- null,
635
- gridColumnCount - 1,
636
- isLastRow,
637
- '__add_spacer__',
638
- )}
639
- </View>
640
- )
641
- })}
642
- </View>
643
- </ScrollView>
644
- </Accordion>
683
+ </View>
684
+ )
685
+ })}
686
+ </View>
687
+ </ScrollView>
645
688
  )
646
689
 
647
690
  const outerStyle: ViewStyle = {
@@ -680,37 +723,94 @@ function CompareTable({
680
723
  }
681
724
 
682
725
  // --- fixed-width scroll mode: chained horizontal sync + sticky cards -----
683
- // The cards row (index 0) is a sticky header of the vertical ScrollView so
684
- // it pins to the top while the sections scroll beneath. The cards row is
685
- // itself a horizontal ScrollView, synced with every section body. For the
686
- // vertical scroll (and thus the sticky behaviour) to engage, the host must
687
- // give CompareTable a bounded height (e.g. a sized container or flex:1).
726
+ // Build children and stickyHeaderIndices for the vertical ScrollView.
727
+ // When stickyHeaders is on, section headers are injected as direct children
728
+ // so React Native's stickyHeaderIndices can pin them with stacking.
729
+ const scrollChildren: React.ReactNode[] = []
730
+ const stickyIndices: number[] = []
731
+
732
+ if (showCardsRow) {
733
+ stickyIndices.push(scrollChildren.length)
734
+ scrollChildren.push(
735
+ <ScrollView
736
+ key="cards-row"
737
+ ref={cardsScrollRef}
738
+ horizontal
739
+ showsHorizontalScrollIndicator={false}
740
+ scrollEventThrottle={16}
741
+ onScroll={onScroll('cards')}
742
+ onScrollBeginDrag={onScrollBeginDrag('cards')}
743
+ onScrollEndDrag={onScrollEndDrag('cards')}
744
+ onMomentumScrollEnd={onMomentumScrollEnd('cards')}
745
+ >
746
+ <View style={{ flexDirection: 'row', width: contentWidth }}>
747
+ {columns.map(renderCard)}
748
+ {showAddCard && renderAddCard()}
749
+ </View>
750
+ </ScrollView>,
751
+ )
752
+ }
753
+
754
+ sections.forEach((section, index) => {
755
+ const sectionKey = section.key ?? section.title ?? index
756
+ const headerContent = renderHeaderContent(section)
757
+
758
+ if (stickyHeaders && headerContent != null) {
759
+ stickyIndices.push(scrollChildren.length)
760
+ scrollChildren.push(
761
+ <View
762
+ key={`sticky-header-${sectionKey}`}
763
+ style={{ width: '100%' }}
764
+ >
765
+ {stickyHeaderLabel ? (
766
+ headerContent
767
+ ) : (
768
+ <ScrollView
769
+ horizontal
770
+ showsHorizontalScrollIndicator={false}
771
+ >
772
+ <View style={{ width: contentWidth }}>
773
+ {headerContent}
774
+ </View>
775
+ </ScrollView>
776
+ )}
777
+ </View>,
778
+ )
779
+ }
780
+
781
+ scrollChildren.push(
782
+ <Accordion
783
+ key={sectionKey}
784
+ title={section.title}
785
+ defaultExpanded={section.defaultExpanded ?? index === 0}
786
+ modes={modes}
787
+ >
788
+ {!stickyHeaders && headerContent != null && (
789
+ stickyHeaderLabel ? headerContent : (
790
+ <ScrollView
791
+ horizontal
792
+ showsHorizontalScrollIndicator={false}
793
+ >
794
+ <View style={{ width: contentWidth }}>
795
+ {headerContent}
796
+ </View>
797
+ </ScrollView>
798
+ )
799
+ )}
800
+ {renderBodyContent(section, index)}
801
+ </Accordion>,
802
+ )
803
+ })
804
+
688
805
  return (
689
806
  <View style={[outerStyle, style, { flexDirection: 'column', flex: 1 }]}>
690
807
  <ScrollView
691
808
  style={{ width: '100%', flex: 1 }}
692
- stickyHeaderIndices={showCardsRow ? [0] : []}
809
+ stickyHeaderIndices={stickyIndices}
693
810
  showsVerticalScrollIndicator={false}
694
811
  directionalLockEnabled
695
812
  >
696
- {showCardsRow && (
697
- <ScrollView
698
- ref={cardsScrollRef}
699
- horizontal
700
- showsHorizontalScrollIndicator={false}
701
- scrollEventThrottle={16}
702
- onScroll={onCardsScroll}
703
- >
704
- <View style={{ flexDirection: 'row', width: contentWidth }}>
705
- {columns.map(renderCard)}
706
- {showAddCard && renderAddCard()}
707
- </View>
708
- </ScrollView>
709
- )}
710
-
711
- <View style={{ width: '100%' }}>
712
- {sections.map(renderSection)}
713
- </View>
813
+ {scrollChildren}
714
814
  </ScrollView>
715
815
  </View>
716
816
  )
@@ -1,6 +1,7 @@
1
1
  import React, { useContext, useEffect, useMemo } from 'react'
2
2
  import {
3
3
  Platform,
4
+ ScrollView,
4
5
  Text,
5
6
  View,
6
7
  useWindowDimensions,
@@ -100,6 +101,12 @@ export type ContentSheetProps = {
100
101
  * `useAnimatedStyle`. Default `true`.
101
102
  */
102
103
  visible?: boolean
104
+ /**
105
+ * Max height of the sheet as a fraction (0–1) of the screen height.
106
+ * When content reaches this height the sheet becomes vertically scrollable.
107
+ * Default `0.7`.
108
+ */
109
+ maxHeightPercent?: number
103
110
  /** Optional style override applied to the sheet container. */
104
111
  style?: StyleProp<ViewStyle>
105
112
  } & Omit<ViewProps, 'style' | 'children'>
@@ -202,6 +209,7 @@ function ContentSheet({
202
209
  safeAreaBottom = true,
203
210
  pinToBottom = true,
204
211
  visible = true,
212
+ maxHeightPercent = 0.7,
205
213
  style,
206
214
  ...rest
207
215
  }: ContentSheetProps) {
@@ -220,22 +228,24 @@ function ContentSheet({
220
228
  const paddingBottom =
221
229
  resolved.paddingBottom + (safeAreaBottom ? bottomInset : 0)
222
230
 
231
+ // Entrance / exit "pop up from bottom" animation — mirrors the Drawer. The
232
+ // sheet is bottom-anchored, so a positive `translateY` equal to the window
233
+ // height parks it fully below the bottom edge. Springing it back to `0`
234
+ // produces the same bottom pop-up the Drawer uses. The shared value is
235
+ // initialised off-screen so the very first mount pops in (no flash).
236
+ const { height: windowHeight } = useWindowDimensions()
237
+ const maxHeight = windowHeight * maxHeightPercent
238
+
223
239
  const containerStyle = useMemo<StyleProp<ViewStyle>>(
224
240
  () => [
225
241
  pinToBottom ? pinnedStyle : null,
226
242
  resolved.container,
227
- { paddingBottom },
243
+ { maxHeight, paddingBottom },
228
244
  style,
229
245
  ],
230
- [pinToBottom, resolved.container, paddingBottom, style]
246
+ [pinToBottom, resolved.container, maxHeight, paddingBottom, style]
231
247
  )
232
248
 
233
- // Entrance / exit "pop up from bottom" animation — mirrors the Drawer. The
234
- // sheet is bottom-anchored, so a positive `translateY` equal to the window
235
- // height parks it fully below the bottom edge. Springing it back to `0`
236
- // produces the same bottom pop-up the Drawer uses. The shared value is
237
- // initialised off-screen so the very first mount pops in (no flash).
238
- const { height: windowHeight } = useWindowDimensions()
239
249
  const entrance = useSharedValue(windowHeight)
240
250
  useEffect(() => {
241
251
  entrance.value = withSpring(visible ? 0 : windowHeight, SPRING_CONFIG)
@@ -259,6 +269,7 @@ function ContentSheet({
259
269
  style={containerStyle}
260
270
  spacing={keyboardSpacing}
261
271
  entrance={entrance}
272
+ maxHeight={maxHeight}
262
273
  {...rest}
263
274
  >
264
275
  {header}
@@ -268,7 +279,7 @@ function ContentSheet({
268
279
  }
269
280
 
270
281
  return (
271
- <AnimatedSheet style={containerStyle} entrance={entrance} {...rest}>
282
+ <AnimatedSheet style={containerStyle} entrance={entrance} maxHeight={maxHeight} {...rest}>
272
283
  {header}
273
284
  {processedChildren}
274
285
  </AnimatedSheet>
@@ -279,12 +290,14 @@ type KeyboardAwareSheetProps = {
279
290
  style: StyleProp<ViewStyle>
280
291
  spacing: number
281
292
  entrance: SharedValue<number>
293
+ maxHeight: number
282
294
  children: React.ReactNode
283
295
  } & Omit<ViewProps, 'style' | 'children'>
284
296
 
285
297
  type AnimatedSheetProps = {
286
298
  style: StyleProp<ViewStyle>
287
299
  entrance: SharedValue<number>
300
+ maxHeight: number
288
301
  children: React.ReactNode
289
302
  } & Omit<ViewProps, 'style' | 'children'>
290
303
 
@@ -300,6 +313,7 @@ function KeyboardAwareSheet({
300
313
  style,
301
314
  spacing,
302
315
  entrance,
316
+ maxHeight,
303
317
  children,
304
318
  ...rest
305
319
  }: KeyboardAwareSheetProps) {
@@ -315,7 +329,14 @@ function KeyboardAwareSheet({
315
329
 
316
330
  return (
317
331
  <Animated.View style={[style, animatedStyle]} {...rest}>
318
- {children}
332
+ <ScrollView
333
+ style={{ maxHeight }}
334
+ bounces={false}
335
+ showsVerticalScrollIndicator={false}
336
+ keyboardShouldPersistTaps="handled"
337
+ >
338
+ {children}
339
+ </ScrollView>
319
340
  </Animated.View>
320
341
  )
321
342
  }
@@ -328,6 +349,7 @@ function KeyboardAwareSheet({
328
349
  function AnimatedSheet({
329
350
  style,
330
351
  entrance,
352
+ maxHeight,
331
353
  children,
332
354
  ...rest
333
355
  }: AnimatedSheetProps) {
@@ -339,7 +361,14 @@ function AnimatedSheet({
339
361
 
340
362
  return (
341
363
  <Animated.View style={[style, animatedStyle]} {...rest}>
342
- {children}
364
+ <ScrollView
365
+ style={{ maxHeight }}
366
+ bounces={false}
367
+ showsVerticalScrollIndicator={false}
368
+ keyboardShouldPersistTaps="handled"
369
+ >
370
+ {children}
371
+ </ScrollView>
343
372
  </Animated.View>
344
373
  )
345
374
  }
@@ -0,0 +1,95 @@
1
+ import React from 'react'
2
+ import {
3
+ View,
4
+ Text,
5
+ type ViewStyle,
6
+ type TextStyle,
7
+ } from 'react-native'
8
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
9
+ import { EMPTY_MODES } from '../../utils/react-utils'
10
+ import type { Modes } from '../../design-tokens'
11
+
12
+ export type CounterBadgeProps = {
13
+ /** The value shown inside the badge. */
14
+ value?: string
15
+ /** Modes used to resolve design tokens. Counter Badge exposes a single `Default` mode. */
16
+ modes?: Modes
17
+ style?: ViewStyle
18
+ } & Omit<React.ComponentProps<typeof View>, 'style'>
19
+
20
+ /**
21
+ * Counter Badge — a small token-driven pill that displays a value.
22
+ *
23
+ * All visual attributes resolve from the Figma `counterBadge/*` tokens via
24
+ * `getVariableByName`.
25
+ */
26
+ function CounterBadge({
27
+ value = '99',
28
+ modes = EMPTY_MODES,
29
+ style,
30
+ ...rest
31
+ }: CounterBadgeProps) {
32
+ // Resolve token values (fall back to the Figma defaults for the single
33
+ // `Default` mode so the badge still renders if a token is missing).
34
+ const backgroundColor =
35
+ (getVariableByName('counterBadge/background', modes) as string) ?? '#ebebec'
36
+ const foreground =
37
+ (getVariableByName('counterBadge/foreground', modes) as string) ?? '#0c0d10'
38
+ const fontFamily =
39
+ (getVariableByName('counterBadge/fontFamily', modes) as string) ?? 'JioType Var'
40
+ const fontWeightRaw =
41
+ getVariableByName('counterBadge/fontWeight', modes) ?? 500
42
+ const fontWeight =
43
+ typeof fontWeightRaw === 'number' ? String(fontWeightRaw) : (fontWeightRaw as string)
44
+ const fontSize =
45
+ Number(getVariableByName('counterBadge/fontSize', modes) ?? 8)
46
+ const lineHeight =
47
+ Number(getVariableByName('counterBadge/lineHeight', modes) ?? 8)
48
+ const paddingHorizontal =
49
+ Number(getVariableByName('counterBadge/padding/horizontal', modes) ?? 4)
50
+ // The Figma design pins vertical padding to 4px (matching horizontal), but the
51
+ // committed variables snapshot resolves `counterBadge/padding/vertical` to 5px
52
+ // (a stale export). Figma is the source of truth here, so hardcode 4px rather
53
+ // than trust the token, keeping the badge one pixel shorter to match the design.
54
+ const paddingVertical = 4
55
+ const borderRadius =
56
+ Number(getVariableByName('counterBadge/radius', modes) ?? 999)
57
+
58
+ const containerStyle: ViewStyle = {
59
+ backgroundColor,
60
+ paddingHorizontal,
61
+ paddingVertical,
62
+ borderRadius,
63
+ alignSelf: 'flex-start',
64
+ alignItems: 'center',
65
+ justifyContent: 'center',
66
+ ...((style as any) || {}),
67
+ }
68
+
69
+ const textStyle: TextStyle = {
70
+ color: foreground,
71
+ fontFamily,
72
+ fontWeight: fontWeight as TextStyle['fontWeight'],
73
+ fontSize,
74
+ lineHeight,
75
+ textAlign: 'center',
76
+ }
77
+
78
+ return (
79
+ <View style={containerStyle} {...rest}>
80
+ {/* Figma `wrap`: a centered box with a 10px minimum. */}
81
+ <View style={WRAP_STYLE}>
82
+ <Text style={textStyle}>{value}</Text>
83
+ </View>
84
+ </View>
85
+ )
86
+ }
87
+
88
+ // Figma `wrap` node (data-node-id 7701:10879): min-width 10, centered.
89
+ const WRAP_STYLE: ViewStyle = {
90
+ minWidth: 10,
91
+ alignItems: 'center',
92
+ justifyContent: 'center',
93
+ }
94
+
95
+ export default CounterBadge