jfs-components 0.0.95 → 0.1.0

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 (31) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/commonjs/components/Drawer/Drawer.js +146 -82
  3. package/lib/commonjs/components/Dropdown/Dropdown.js +37 -18
  4. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +22 -1
  5. package/lib/commonjs/components/Spinner/Spinner.js +5 -1
  6. package/lib/commonjs/components/TestimonialsCard/TestimonialsCard.js +121 -0
  7. package/lib/commonjs/components/index.js +7 -0
  8. package/lib/commonjs/icons/registry.js +1 -1
  9. package/lib/commonjs/skeleton/Skeleton.js +10 -2
  10. package/lib/module/components/Drawer/Drawer.js +148 -84
  11. package/lib/module/components/Dropdown/Dropdown.js +37 -18
  12. package/lib/module/components/FullscreenModal/FullscreenModal.js +22 -1
  13. package/lib/module/components/Spinner/Spinner.js +6 -2
  14. package/lib/module/components/TestimonialsCard/TestimonialsCard.js +116 -0
  15. package/lib/module/components/index.js +1 -0
  16. package/lib/module/icons/registry.js +1 -1
  17. package/lib/module/skeleton/Skeleton.js +11 -3
  18. package/lib/typescript/src/components/Drawer/Drawer.d.ts +23 -4
  19. package/lib/typescript/src/components/FullscreenModal/FullscreenModal.d.ts +7 -1
  20. package/lib/typescript/src/components/TestimonialsCard/TestimonialsCard.d.ts +51 -0
  21. package/lib/typescript/src/components/index.d.ts +2 -1
  22. package/lib/typescript/src/icons/registry.d.ts +1 -1
  23. package/package.json +1 -1
  24. package/src/components/Drawer/Drawer.tsx +94 -15
  25. package/src/components/Dropdown/Dropdown.tsx +38 -18
  26. package/src/components/FullscreenModal/FullscreenModal.tsx +29 -2
  27. package/src/components/Spinner/Spinner.tsx +2 -2
  28. package/src/components/TestimonialsCard/TestimonialsCard.tsx +162 -0
  29. package/src/components/index.ts +2 -1
  30. package/src/icons/registry.ts +1 -1
  31. package/src/skeleton/Skeleton.tsx +10 -3
@@ -21,12 +21,20 @@ const SOLID_OVERLAY_COLOR = 'rgba(255, 255, 255, 1)';
21
21
  // `pointerEvents: 'none'` lives on the style (not the deprecated prop) so it
22
22
  // works on both native and React Native Web without warnings.
23
23
  const absoluteFillStyle = {
24
- ..._reactNative.StyleSheet.absoluteFillObject,
24
+ position: 'absolute',
25
+ top: 0,
26
+ left: 0,
27
+ right: 0,
28
+ bottom: 0,
25
29
  overflow: 'hidden',
26
30
  pointerEvents: 'none'
27
31
  };
28
32
  const solidOverlayStyle = {
29
- ..._reactNative.StyleSheet.absoluteFillObject,
33
+ position: 'absolute',
34
+ top: 0,
35
+ left: 0,
36
+ right: 0,
37
+ bottom: 0,
30
38
  backgroundColor: SOLID_OVERLAY_COLOR
31
39
  };
32
40
 
@@ -1,8 +1,8 @@
1
1
  "use strict";
2
2
 
3
- import React, { useCallback, useEffect, useState, useRef } from 'react';
3
+ import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
4
4
  import { Platform, StyleSheet, Text, useWindowDimensions, View } from 'react-native';
5
- import { Gesture, GestureDetector, GestureHandlerRootView, ScrollView } from 'react-native-gesture-handler';
5
+ import { Gesture, GestureDetector, ScrollView } from 'react-native-gesture-handler';
6
6
  import Animated, { runOnJS, useAnimatedProps, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
7
7
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
8
8
  import { EMPTY_MODES } from '../../utils/react-utils';
@@ -40,11 +40,17 @@ function rubberBand(value, min, max, friction = 0.55) {
40
40
  }
41
41
  return value;
42
42
  }
43
+
44
+ /**
45
+ * Imperative handle exposed via `ref` for programmatic control of the drawer.
46
+ * Each method animates using the same spring as gesture-driven transitions.
47
+ */
48
+
43
49
  /**
44
50
  * Drawer component with nested scrolling support.
45
51
  * Uses react-native-gesture-handler and react-native-reanimated.
46
52
  */
47
- function Drawer({
53
+ function DrawerInner({
48
54
  modes = EMPTY_MODES,
49
55
  style,
50
56
  title,
@@ -53,6 +59,7 @@ function Drawer({
53
59
  collapsedHeight = 200,
54
60
  expandedRatio = 0.90,
55
61
  initialState = 'collapsed',
62
+ state,
56
63
  contentStyle,
57
64
  sheetStyle,
58
65
  accessibilityLabel,
@@ -61,7 +68,7 @@ function Drawer({
61
68
  showsVerticalScrollIndicator = false,
62
69
  bottomInset = 80,
63
70
  onStateChange
64
- }) {
71
+ }, ref) {
65
72
  const {
66
73
  height: screenHeight
67
74
  } = useWindowDimensions();
@@ -123,15 +130,58 @@ function Drawer({
123
130
  translateY.value = withSpring(destination, SPRING_CONFIG);
124
131
  }, [translateY]);
125
132
 
126
- // Update JS state for accessibility/logic if needed
133
+ // Update the JS-side mode. Pure: only schedules the state update. Side
134
+ // effects (notifying the parent via onStateChange) MUST NOT live inside the
135
+ // setState updater — doing so calls the parent's setState during React's
136
+ // render phase, which throws "Cannot update a component while rendering a
137
+ // different component" and causes an infinite update loop. We report changes
138
+ // from a dedicated effect below instead.
127
139
  const updateMode = useCallback(newMode => {
128
- setMode(prev => {
129
- if (prev !== newMode) {
130
- onStateChange?.(newMode);
131
- }
132
- return newMode;
133
- });
134
- }, [onStateChange]);
140
+ setMode(newMode);
141
+ }, []);
142
+
143
+ // Notify the parent exactly once per settled mode change, after commit.
144
+ const reportedModeRef = useRef(mode);
145
+ useEffect(() => {
146
+ if (reportedModeRef.current !== mode) {
147
+ reportedModeRef.current = mode;
148
+ onStateChange?.(mode);
149
+ }
150
+ }, [mode, onStateChange]);
151
+
152
+ // Programmatic transition (JS thread). Reuses the same spring as gestures:
153
+ // animates `translateY`, keeps the scroll-gate (`isFullyExpanded`) in sync,
154
+ // and updates `mode` (which notifies via the onStateChange effect above).
155
+ const applyMode = useCallback(newMode => {
156
+ const target = newMode === 'expanded' ? minTranslateY : maxTranslateY;
157
+ translateY.value = withSpring(target, SPRING_CONFIG);
158
+ isFullyExpanded.value = newMode === 'expanded';
159
+ setMode(newMode);
160
+ }, [minTranslateY, maxTranslateY, translateY, isFullyExpanded]);
161
+
162
+ // Controlled mode: react ONLY to genuine changes of the `state` prop, tracked
163
+ // against its previous value. We must NOT reconcile `state` against the
164
+ // internal `mode` on every render: a gesture updates `mode` optimistically
165
+ // one render before the parent echoes it back through `onStateChange` into
166
+ // `state`, so a `state !== mode` check would "correct" the gesture and fight
167
+ // the echo, ping-ponging forever (Maximum update depth exceeded).
168
+ // `applyMode` is idempotent, so re-applying the already-current mode is a
169
+ // no-op when the parent simply mirrors our own change back.
170
+ const prevStateProp = useRef(state);
171
+ useEffect(() => {
172
+ if (state === undefined) return;
173
+ if (state === prevStateProp.current) return;
174
+ prevStateProp.current = state;
175
+ applyMode(state);
176
+ }, [state, applyMode]);
177
+
178
+ // Imperative API for parents holding a ref.
179
+ useImperativeHandle(ref, () => ({
180
+ expand: () => applyMode('expanded'),
181
+ collapse: () => applyMode('collapsed'),
182
+ toggle: () => applyMode(mode === 'expanded' ? 'collapsed' : 'expanded'),
183
+ getState: () => mode
184
+ }), [applyMode, mode]);
135
185
 
136
186
  // Gesture policy:
137
187
  // • activeOffsetY: require a clear *vertical* drag (10px) before this
@@ -304,84 +354,96 @@ function Drawer({
304
354
  default: {}
305
355
  });
306
356
  const defaultAccessibilityLabel = accessibilityLabel || title || 'Drawer';
307
- return /*#__PURE__*/_jsx(GestureHandlerRootView, {
308
- style: [styles.host, style],
309
- pointerEvents: "box-none",
310
- children: /*#__PURE__*/_jsx(GestureDetector, {
311
- gesture: gesture,
312
- children: /*#__PURE__*/_jsx(Animated.View, {
313
- style: [styles.sheet, {
314
- // Constraint the height strictly to the expanded height
315
- // This ensures the ScrollView has a finite frame to scroll within
316
- height: expandedHeight,
317
- backgroundColor,
318
- borderTopLeftRadius: radius,
319
- borderTopRightRadius: radius
320
- }, shadowStyle, sheetStyle, animatedStyle],
321
- accessible: true,
322
- ...(Platform.OS === 'web' ? {
323
- accessibilityRole: 'dialog'
324
- } : undefined),
325
- accessibilityLabel: undefined,
326
- accessibilityHint: accessibilityHint || 'Swipe up to expand, swipe down to collapse',
327
- children: /*#__PURE__*/_jsxs(View, {
328
- style: [styles.sheetInner, {
357
+ return (
358
+ /*#__PURE__*/
359
+ // IMPORTANT: the host is a plain box-none View, NOT a GestureHandlerRootView.
360
+ // On Android, GestureHandlerRootView renders a *native* root view that
361
+ // intercepts every touch within its bounds (to feed the gesture system) and
362
+ // does NOT honor pointerEvents="box-none". Because this host fills the whole
363
+ // screen as an overlay, using GestureHandlerRootView here swallowed all
364
+ // touches to content rendered behind the drawer (buttons, page content).
365
+ // Per the standard react-native-gesture-handler architecture, a single
366
+ // GestureHandlerRootView must wrap the app root; this overlay only needs to
367
+ // let touches fall through where the sheet isn't.
368
+ _jsx(View, {
369
+ style: [styles.host, style],
370
+ pointerEvents: "box-none",
371
+ children: /*#__PURE__*/_jsx(GestureDetector, {
372
+ gesture: gesture,
373
+ children: /*#__PURE__*/_jsx(Animated.View, {
374
+ style: [styles.sheet, {
375
+ // Constraint the height strictly to the expanded height
376
+ // This ensures the ScrollView has a finite frame to scroll within
377
+ height: expandedHeight,
378
+ backgroundColor,
329
379
  borderTopLeftRadius: radius,
330
- borderTopRightRadius: radius,
331
- paddingLeft,
332
- paddingRight,
333
- paddingBottom,
334
- rowGap: drawerGap
335
- }],
336
- children: [/*#__PURE__*/_jsx(View, {
337
- style: [styles.handleArea, !title && !header && {
338
- paddingBottom: 0
380
+ borderTopRightRadius: radius
381
+ }, shadowStyle, sheetStyle, animatedStyle],
382
+ accessible: true,
383
+ ...(Platform.OS === 'web' ? {
384
+ accessibilityRole: 'dialog'
385
+ } : undefined),
386
+ accessibilityLabel: undefined,
387
+ accessibilityHint: accessibilityHint || 'Swipe up to expand, swipe down to collapse',
388
+ children: /*#__PURE__*/_jsxs(View, {
389
+ style: [styles.sheetInner, {
390
+ borderTopLeftRadius: radius,
391
+ borderTopRightRadius: radius,
392
+ paddingLeft,
393
+ paddingRight,
394
+ paddingBottom,
395
+ rowGap: drawerGap
339
396
  }],
340
- children: /*#__PURE__*/_jsx(View, {
397
+ children: [/*#__PURE__*/_jsx(View, {
398
+ style: [styles.handleArea, !title && !header && {
399
+ paddingBottom: 0
400
+ }],
401
+ children: /*#__PURE__*/_jsx(View, {
402
+ style: [{
403
+ backgroundColor: handleColor,
404
+ width: handleWidth,
405
+ height: handleHeight,
406
+ borderRadius: handleRadius
407
+ }]
408
+ })
409
+ }), header, title && /*#__PURE__*/_jsx(Text, {
341
410
  style: [{
342
- backgroundColor: handleColor,
343
- width: handleWidth,
344
- height: handleHeight,
345
- borderRadius: handleRadius
346
- }]
347
- })
348
- }), header, title && /*#__PURE__*/_jsx(Text, {
349
- style: [{
350
- color: titleColor,
351
- fontSize: titleSize,
352
- fontWeight: titleWeight,
353
- lineHeight: titleLineHeight,
354
- marginBottom: titlePaddingBottom
355
- }],
356
- children: title
357
- }), /*#__PURE__*/_jsx(AnimatedScrollView, {
358
- ref: scrollRef,
359
- style: [styles.content, contentStyle],
360
- contentContainerStyle: [{
361
- paddingBottom: paddingBottom + bottomInset,
362
- gap: drawerGap,
363
- flexDirection: 'column',
364
- alignItems: 'stretch'
365
- }, contentContainerStyle],
366
- showsVerticalScrollIndicator: showsVerticalScrollIndicator
367
- // Let a tap on an input inside the sheet focus it on the FIRST tap
368
- // even while the keyboard is already open (default 'never' would
369
- // eat that tap just to dismiss the keyboard).
370
- ,
371
- keyboardShouldPersistTaps: "handled",
372
- animatedProps: animatedScrollProps,
373
- alwaysBounceVertical: false,
374
- overScrollMode: "always",
375
- onScroll: useAnimatedScrollHandler(event => {
376
- scrollY.value = event.contentOffset.y;
377
- }),
378
- scrollEventThrottle: 16,
379
- children: children
380
- })]
411
+ color: titleColor,
412
+ fontSize: titleSize,
413
+ fontWeight: titleWeight,
414
+ lineHeight: titleLineHeight,
415
+ marginBottom: titlePaddingBottom
416
+ }],
417
+ children: title
418
+ }), /*#__PURE__*/_jsx(AnimatedScrollView, {
419
+ ref: scrollRef,
420
+ style: [styles.content, contentStyle],
421
+ contentContainerStyle: [{
422
+ paddingBottom: paddingBottom + bottomInset,
423
+ gap: drawerGap,
424
+ flexDirection: 'column',
425
+ alignItems: 'stretch'
426
+ }, contentContainerStyle],
427
+ showsVerticalScrollIndicator: showsVerticalScrollIndicator
428
+ // Let a tap on an input inside the sheet focus it on the FIRST tap
429
+ // even while the keyboard is already open (default 'never' would
430
+ // eat that tap just to dismiss the keyboard).
431
+ ,
432
+ keyboardShouldPersistTaps: "handled",
433
+ animatedProps: animatedScrollProps,
434
+ alwaysBounceVertical: false,
435
+ overScrollMode: "always",
436
+ onScroll: useAnimatedScrollHandler(event => {
437
+ scrollY.value = event.contentOffset.y;
438
+ }),
439
+ scrollEventThrottle: 16,
440
+ children: children
441
+ })]
442
+ })
381
443
  })
382
444
  })
383
445
  })
384
- });
446
+ );
385
447
  }
386
448
  const styles = StyleSheet.create({
387
449
  host: {
@@ -411,4 +473,6 @@ const styles = StyleSheet.create({
411
473
  flex: 1
412
474
  }
413
475
  });
476
+ const Drawer = /*#__PURE__*/forwardRef(DrawerInner);
477
+ Drawer.displayName = 'Drawer';
414
478
  export default Drawer;
@@ -170,18 +170,34 @@ export function Dropdown({
170
170
  const shadowOffsetX = parseInt(getVariableByName('dropdown/shadow/offsetX', modes), 10) || 0;
171
171
  const shadowOffsetY = parseInt(getVariableByName('dropdown/shadow/offsetY', modes), 10) || 4;
172
172
  const shadowBlur = parseInt(getVariableByName('dropdown/shadow/blur', modes), 10) || 16;
173
- const containerStyle = {
173
+
174
+ // Shadow lives on the OUTER view, which must NOT set `overflow: 'hidden'`.
175
+ // On native, clipping a view also clips its shadow (iOS clips the layer
176
+ // shadow; Android clips the elevation shadow), so the soft popup shadow
177
+ // that renders fine on web (CSS box-shadow paints outside the box) would
178
+ // get cut off. The rounded-corner clipping is moved to a separate inner
179
+ // view below.
180
+ //
181
+ // The `boxShadow` style prop (RN 0.76+ / react-native-web) is used as the
182
+ // single source of truth so the designed color/offset/blur are honored on
183
+ // iOS, Android AND web. We intentionally do NOT also set the legacy
184
+ // `shadow*` / `elevation` props: on the new architecture (and web) those
185
+ // are translated into a box-shadow internally, so combining them with an
186
+ // explicit `boxShadow` would stack two shadows. Android's legacy
187
+ // `elevation` is also undesirable here because it ignores the shadow color
188
+ // and only paints a generic gray shadow.
189
+ const shadowStyle = {
174
190
  backgroundColor: background,
191
+ borderRadius: radius,
192
+ boxShadow: `${shadowOffsetX}px ${shadowOffsetY}px ${shadowBlur}px 0px ${shadowColor}`
193
+ };
194
+
195
+ // Inner view carries the rounded corners + clipping so list/scroll content
196
+ // stays inside the radius without affecting the outer view's shadow.
197
+ const clipStyle = {
175
198
  borderRadius: radius,
176
199
  overflow: 'hidden',
177
- shadowColor,
178
- shadowOffset: {
179
- width: shadowOffsetX,
180
- height: shadowOffsetY
181
- },
182
- shadowOpacity: 1,
183
- shadowRadius: shadowBlur / 2,
184
- elevation: 4
200
+ backgroundColor: background
185
201
  };
186
202
  const content = /*#__PURE__*/_jsx(View, {
187
203
  style: {
@@ -190,17 +206,20 @@ export function Dropdown({
190
206
  children: cloneChildrenWithModes(children, modes)
191
207
  });
192
208
  return /*#__PURE__*/_jsx(View, {
193
- style: [containerStyle, style],
209
+ style: [shadowStyle, style],
194
210
  accessibilityRole: "menu",
195
211
  accessibilityLabel: accessibilityLabel || 'Dropdown menu',
196
- children: maxHeight != null ? /*#__PURE__*/_jsx(ScrollView, {
197
- style: {
198
- maxHeight
199
- },
200
- showsVerticalScrollIndicator: true,
201
- keyboardShouldPersistTaps: "handled",
202
- children: content
203
- }) : content
212
+ children: /*#__PURE__*/_jsx(View, {
213
+ style: clipStyle,
214
+ children: maxHeight != null ? /*#__PURE__*/_jsx(ScrollView, {
215
+ style: {
216
+ maxHeight
217
+ },
218
+ showsVerticalScrollIndicator: true,
219
+ keyboardShouldPersistTaps: "handled",
220
+ children: content
221
+ }) : content
222
+ })
204
223
  });
205
224
  }
206
225
  export default Dropdown;
@@ -2,6 +2,7 @@
2
2
 
3
3
  import React, { useMemo, useRef } from 'react';
4
4
  import { View, Text, Animated } from 'react-native';
5
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
6
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
6
7
  import { useTokens } from '../../design-tokens/JFSThemeProvider';
7
8
  import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils';
@@ -183,6 +184,7 @@ function FullscreenModal({
183
184
  heroMedia,
184
185
  heroHeight = 420,
185
186
  showClose = true,
187
+ closeOffsetY = 0,
186
188
  onClose,
187
189
  closeAccessibilityLabel = 'Close',
188
190
  footer,
@@ -212,6 +214,24 @@ function FullscreenModal({
212
214
  }), [globalModes, propModes]);
213
215
  const rootGap = Number(getVariableByName('fullScreenModal/gap', modes)) || 16;
214
216
 
217
+ // Safe-area insets so the floating chrome clears the system bars: the close
218
+ // button drops below the status bar / notch, and the sticky footer keeps its
219
+ // designed bottom padding ON TOP of the bottom inset (home indicator /
220
+ // Android gesture or nav bar). On web — and anywhere without a
221
+ // SafeAreaProvider — every inset is 0, so the layout is unchanged.
222
+ const insets = useSafeAreaInsets();
223
+ const closeButtonInsetStyle = useMemo(() => ({
224
+ top: 12 + insets.top + closeOffsetY
225
+ }), [insets.top, closeOffsetY]);
226
+ // Extend (not replace) the footer's token bottom padding by the bottom inset
227
+ // so the action button never sits under the system navigation area.
228
+ const footerInsetStyle = useMemo(() => {
229
+ const base = Number(getVariableByName('actionFooter/padding/bottom', modes)) || 41;
230
+ return {
231
+ paddingBottom: base + insets.bottom
232
+ };
233
+ }, [modes, insets.bottom]);
234
+
215
235
  // Drives the background's parallax-free sync with the scroll. The hero media
216
236
  // lives at the ROOT (so it is never clipped to the content height and sits
217
237
  // behind the transparent footer), but we translate it up by the exact scroll
@@ -311,12 +331,13 @@ function FullscreenModal({
311
331
  })
312
332
  }), footerContent ? /*#__PURE__*/_jsx(ActionFooter, {
313
333
  modes: modes,
334
+ style: footerInsetStyle,
314
335
  children: footerContent
315
336
  }) : null, showClose ? /*#__PURE__*/_jsx(IconButton, {
316
337
  iconName: "ic_close",
317
338
  modes: modes,
318
339
  accessibilityLabel: closeAccessibilityLabel,
319
- style: closeButtonStyle,
340
+ style: [closeButtonStyle, closeButtonInsetStyle],
320
341
  ...(onClose ? {
321
342
  onPress: onClose
322
343
  } : {})
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  import React, { useEffect } from 'react';
4
- import { StyleSheet, View } from 'react-native';
4
+ import { View } from 'react-native';
5
5
  import Animated, { Easing, cancelAnimation, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated';
6
6
  import Svg, { Path } from 'react-native-svg';
7
7
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
@@ -115,7 +115,11 @@ const useSegmentRotation = (clock, index, gravity, spreadMinRad, spreadMaxRad, s
115
115
  };
116
116
  }, [gravity, index, spreadMinRad, spreadMaxRad, spreadOutFrac]);
117
117
  const fullSize = {
118
- ...StyleSheet.absoluteFillObject
118
+ position: 'absolute',
119
+ top: 0,
120
+ left: 0,
121
+ right: 0,
122
+ bottom: 0
119
123
  };
120
124
  function Spinner({
121
125
  size = DEFAULT_SIZE,
@@ -0,0 +1,116 @@
1
+ "use strict";
2
+
3
+ import React from 'react';
4
+ import { View, Text } from 'react-native';
5
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
6
+ import { EMPTY_MODES } from '../../utils/react-utils';
7
+ import Avatar from '../Avatar/Avatar';
8
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
+ /**
10
+ * TestimonialsCard renders a compact, fixed-width card with a circular avatar,
11
+ * a bold title, and a body paragraph. It is typically used inside a horizontal
12
+ * carousel of customer testimonials.
13
+ *
14
+ * All styling values are resolved from Figma design tokens using the provided
15
+ * `modes`.
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * <TestimonialsCard
20
+ * title="Aarav S."
21
+ * body="I was dreading renewing my car insurance, but JioFinance made it a breeze."
22
+ * />
23
+ * ```
24
+ */
25
+ function TestimonialsCard({
26
+ title = 'Title',
27
+ body = 'I was dreading renewing my car insurance, but JioFinance made it a breeze.',
28
+ modes = EMPTY_MODES,
29
+ style,
30
+ avatarProps,
31
+ accessibilityLabel
32
+ }) {
33
+ // Container tokens
34
+ const background = getVariableByName('testimonialsCard/background', modes) ?? '#ffffff';
35
+ const borderColor = getVariableByName('testimonialsCard/border/color', modes) ?? '#e8e8e8';
36
+ const radius = getVariableByName('testimonialsCard/radius', modes) ?? 8;
37
+ const gap = getVariableByName('testimonialsCard/gap', modes) ?? 8;
38
+ const paddingHorizontal = getVariableByName('testimonialsCard/padding/horizontal', modes) ?? 12;
39
+ const paddingVertical = getVariableByName('testimonialsCard/padding/vertical', modes) ?? 12;
40
+
41
+ // Title typography tokens
42
+ const titleColor = getVariableByName('testimonialsCard/subtitle/color', modes) ?? '#1a1c1f';
43
+ const titleFontSize = getVariableByName('testimonialsCard/title/fontSize', modes) ?? 14;
44
+ const titleFontFamily = getVariableByName('testimonialsCard/title/fontFamily', modes) ?? 'JioType Var';
45
+ const titleFontWeightRaw = getVariableByName('testimonialsCard/title/fontWeight', modes) ?? 700;
46
+ const titleFontWeight = typeof titleFontWeightRaw === 'number' ? titleFontWeightRaw.toString() : titleFontWeightRaw;
47
+
48
+ // Body typography tokens
49
+ const bodyColor = getVariableByName('testimonialsCard/title/color', modes) ?? '#010101';
50
+ const bodyFontSize = getVariableByName('testimonialsCard/subtitle/fontSize', modes) ?? 12;
51
+ const bodyLineHeight = getVariableByName('testimonialsCard/subtitle/lineHeight', modes) ?? 16;
52
+ const bodyFontFamily = getVariableByName('testimonialsCard/subtitle/fontFamily', modes) ?? 'JioType Var';
53
+ const bodyFontWeightRaw = getVariableByName('testimonialsCard/subtitle/fontWeight', modes) ?? 400;
54
+ const bodyFontWeight = typeof bodyFontWeightRaw === 'number' ? bodyFontWeightRaw.toString() : bodyFontWeightRaw;
55
+ const containerStyle = {
56
+ width: 180,
57
+ backgroundColor: background,
58
+ borderColor: borderColor,
59
+ borderWidth: 1,
60
+ borderRadius: radius,
61
+ paddingHorizontal: paddingHorizontal,
62
+ paddingVertical: paddingVertical,
63
+ alignItems: 'flex-start',
64
+ gap: gap
65
+ };
66
+ const titleTextStyle = {
67
+ color: titleColor,
68
+ fontSize: titleFontSize,
69
+ lineHeight: bodyLineHeight,
70
+ fontFamily: titleFontFamily,
71
+ fontWeight: titleFontWeight,
72
+ width: '100%'
73
+ };
74
+ const bodyTextStyle = {
75
+ color: bodyColor,
76
+ fontSize: bodyFontSize,
77
+ lineHeight: bodyLineHeight,
78
+ fontFamily: bodyFontFamily,
79
+ fontWeight: bodyFontWeight,
80
+ width: '100%'
81
+ };
82
+ const avatarModes = {
83
+ ...modes,
84
+ ...(avatarProps?.modes || {})
85
+ };
86
+ const resolvedAccessibilityLabel = accessibilityLabel ?? `Testimonial${title ? ` from ${title}` : ''}${body ? `: ${body}` : ''}`;
87
+ return /*#__PURE__*/_jsxs(View, {
88
+ style: [containerStyle, style],
89
+ accessibilityRole: "text",
90
+ accessibilityLabel: resolvedAccessibilityLabel,
91
+ children: [/*#__PURE__*/_jsx(Avatar, {
92
+ style: "Image",
93
+ modes: avatarModes,
94
+ ...avatarProps
95
+ }), /*#__PURE__*/_jsxs(View, {
96
+ style: textContainerStyle,
97
+ children: [!!title && /*#__PURE__*/_jsx(Text, {
98
+ style: titleTextStyle,
99
+ accessibilityElementsHidden: true,
100
+ importantForAccessibility: "no-hide-descendants",
101
+ children: title
102
+ }), !!body && /*#__PURE__*/_jsx(Text, {
103
+ style: bodyTextStyle,
104
+ accessibilityElementsHidden: true,
105
+ importantForAccessibility: "no-hide-descendants",
106
+ children: body
107
+ })]
108
+ })]
109
+ });
110
+ }
111
+ const textContainerStyle = {
112
+ width: '100%',
113
+ alignItems: 'flex-start',
114
+ gap: 4
115
+ };
116
+ export default /*#__PURE__*/React.memo(TestimonialsCard);
@@ -131,6 +131,7 @@ export { default as StatItem } from './StatItem/StatItem';
131
131
  export { default as StatGroup } from './StatGroup/StatGroup';
132
132
  export { default as StrengthIndicator } from './StrengthIndicator/StrengthIndicator';
133
133
  export { default as SummaryTile } from './SummaryTile/SummaryTile';
134
+ export { default as TestimonialsCard } from './TestimonialsCard/TestimonialsCard';
134
135
  export { default as Text } from './Text/Text';
135
136
  export { default as SegmentedControl } from './SegmentedControl/SegmentedControl';
136
137
  export { default as Toggle } from './Toggle/Toggle';