jfs-components 0.1.18 → 0.1.23

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 (72) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/lib/commonjs/components/Card/Card.js +29 -12
  3. package/lib/commonjs/components/CardFeedback/CardFeedback.js +16 -2
  4. package/lib/commonjs/components/CardInsight/CardInsight.js +34 -13
  5. package/lib/commonjs/components/CardProviderInfo/CardProviderInfo.js +23 -3
  6. package/lib/commonjs/components/CheckboxItem/CheckboxItem.js +31 -8
  7. package/lib/commonjs/components/ContentSheet/ContentSheet.js +131 -0
  8. package/lib/commonjs/components/DebitCard/DebitCard.js +23 -3
  9. package/lib/commonjs/components/HeroSection/HeroSection.js +165 -0
  10. package/lib/commonjs/components/Image/Image.js +33 -7
  11. package/lib/commonjs/components/Link/Link.js +115 -0
  12. package/lib/commonjs/components/ListItem/ListItem.js +16 -1
  13. package/lib/commonjs/components/MediaCard/MediaCard.js +24 -7
  14. package/lib/commonjs/components/RechargeCard/RechargeCard.js +34 -13
  15. package/lib/commonjs/components/TestimonialsCard/TestimonialsCard.js +23 -5
  16. package/lib/commonjs/components/index.js +21 -0
  17. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  18. package/lib/commonjs/design-tokens/figma-modes.generated.js +53 -43
  19. package/lib/commonjs/icons/registry.js +1 -1
  20. package/lib/module/components/Card/Card.js +31 -14
  21. package/lib/module/components/CardFeedback/CardFeedback.js +17 -3
  22. package/lib/module/components/CardInsight/CardInsight.js +36 -15
  23. package/lib/module/components/CardProviderInfo/CardProviderInfo.js +25 -5
  24. package/lib/module/components/CheckboxItem/CheckboxItem.js +31 -8
  25. package/lib/module/components/ContentSheet/ContentSheet.js +126 -0
  26. package/lib/module/components/DebitCard/DebitCard.js +25 -5
  27. package/lib/module/components/HeroSection/HeroSection.js +159 -0
  28. package/lib/module/components/Image/Image.js +34 -7
  29. package/lib/module/components/Link/Link.js +110 -0
  30. package/lib/module/components/ListItem/ListItem.js +16 -1
  31. package/lib/module/components/MediaCard/MediaCard.js +26 -9
  32. package/lib/module/components/RechargeCard/RechargeCard.js +36 -15
  33. package/lib/module/components/TestimonialsCard/TestimonialsCard.js +25 -7
  34. package/lib/module/components/index.js +3 -0
  35. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  36. package/lib/module/design-tokens/figma-modes.generated.js +53 -43
  37. package/lib/module/icons/registry.js +1 -1
  38. package/lib/typescript/src/components/Card/Card.d.ts +10 -1
  39. package/lib/typescript/src/components/CardFeedback/CardFeedback.d.ts +10 -1
  40. package/lib/typescript/src/components/CardInsight/CardInsight.d.ts +10 -1
  41. package/lib/typescript/src/components/CardProviderInfo/CardProviderInfo.d.ts +10 -1
  42. package/lib/typescript/src/components/CheckboxItem/CheckboxItem.d.ts +26 -3
  43. package/lib/typescript/src/components/ContentSheet/ContentSheet.d.ts +71 -0
  44. package/lib/typescript/src/components/DebitCard/DebitCard.d.ts +10 -1
  45. package/lib/typescript/src/components/HeroSection/HeroSection.d.ts +80 -0
  46. package/lib/typescript/src/components/Image/Image.d.ts +27 -2
  47. package/lib/typescript/src/components/Link/Link.d.ts +73 -0
  48. package/lib/typescript/src/components/MediaCard/MediaCard.d.ts +10 -1
  49. package/lib/typescript/src/components/RechargeCard/RechargeCard.d.ts +10 -1
  50. package/lib/typescript/src/components/TestimonialsCard/TestimonialsCard.d.ts +8 -1
  51. package/lib/typescript/src/components/index.d.ts +3 -0
  52. package/lib/typescript/src/design-tokens/figma-modes.generated.d.ts +8 -0
  53. package/lib/typescript/src/icons/registry.d.ts +1 -1
  54. package/package.json +1 -1
  55. package/src/components/Card/Card.tsx +46 -15
  56. package/src/components/CardFeedback/CardFeedback.tsx +29 -4
  57. package/src/components/CardInsight/CardInsight.tsx +48 -17
  58. package/src/components/CardProviderInfo/CardProviderInfo.tsx +36 -3
  59. package/src/components/CheckboxItem/CheckboxItem.tsx +59 -14
  60. package/src/components/ContentSheet/ContentSheet.tsx +217 -0
  61. package/src/components/DebitCard/DebitCard.tsx +36 -3
  62. package/src/components/HeroSection/HeroSection.tsx +231 -0
  63. package/src/components/Image/Image.tsx +55 -3
  64. package/src/components/Link/Link.tsx +159 -0
  65. package/src/components/ListItem/ListItem.tsx +15 -0
  66. package/src/components/MediaCard/MediaCard.tsx +40 -9
  67. package/src/components/RechargeCard/RechargeCard.tsx +48 -13
  68. package/src/components/TestimonialsCard/TestimonialsCard.tsx +37 -6
  69. package/src/components/index.ts +3 -0
  70. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  71. package/src/design-tokens/figma-modes.generated.ts +53 -43
  72. package/src/icons/registry.ts +1 -1
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { View, Text, Image, type ViewStyle, type TextStyle, type ImageStyle, type StyleProp, type ImageSourcePropType } from 'react-native'
2
+ import { View, Text, Image, Pressable, type ViewStyle, type TextStyle, type ImageStyle, type StyleProp, type ImageSourcePropType } from 'react-native'
3
3
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
4
4
  import { useTokens } from '../../design-tokens/JFSThemeProvider'
5
5
  import { cloneChildrenWithModes, EMPTY_MODES } from '../../utils/react-utils'
@@ -28,6 +28,15 @@ export type DebitCardProps = {
28
28
  modes?: Modes;
29
29
  /** Container style overrides */
30
30
  style?: StyleProp<ViewStyle>;
31
+ /**
32
+ * Press handler for the whole card. When set, the card becomes pressable
33
+ * (rendered through a `Pressable` with `accessibilityRole="button"`).
34
+ */
35
+ onPress?: () => void;
36
+ /** Disable interaction when `onPress` is set. */
37
+ disabled?: boolean;
38
+ /** Accessibility label forwarded to the pressable wrapper. */
39
+ accessibilityLabel?: string;
31
40
  };
32
41
 
33
42
  function DebitCard({
@@ -41,6 +50,9 @@ function DebitCard({
41
50
  providerLogoSlot,
42
51
  modes: propModes = EMPTY_MODES,
43
52
  style,
53
+ onPress,
54
+ disabled,
55
+ accessibilityLabel,
44
56
  }: DebitCardProps) {
45
57
  const { modes: globalModes } = useTokens()
46
58
  const modes = { ...globalModes, ...propModes }
@@ -149,8 +161,8 @@ function DebitCard({
149
161
  return cardArtSource
150
162
  }, [cardArtSource])
151
163
 
152
- return (
153
- <View style={[containerStyle, style]}>
164
+ const content = (
165
+ <>
154
166
  <View style={headerStyle}>
155
167
  <View style={headerLeftStyle}>
156
168
  {bankLogoSlot ? cloneChildrenWithModes(bankLogoSlot, modes) : null}
@@ -179,6 +191,27 @@ function DebitCard({
179
191
  <Text style={textStyle}>{expireDate}</Text>
180
192
  </View>
181
193
  </View>
194
+ </>
195
+ )
196
+
197
+ if (onPress) {
198
+ return (
199
+ <Pressable
200
+ style={[containerStyle, style]}
201
+ onPress={onPress}
202
+ disabled={disabled}
203
+ accessibilityRole="button"
204
+ accessibilityLabel={accessibilityLabel ?? `${bankName} ${cardType} ${cardNumber}`}
205
+ accessibilityState={{ disabled: !!disabled }}
206
+ >
207
+ {content}
208
+ </Pressable>
209
+ )
210
+ }
211
+
212
+ return (
213
+ <View style={[containerStyle, style]} accessibilityLabel={accessibilityLabel}>
214
+ {content}
182
215
  </View>
183
216
  )
184
217
  }
@@ -0,0 +1,231 @@
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, cloneChildrenWithModes } from '../../utils/react-utils'
6
+ import Title from '../Title/Title'
7
+ import InputSearch from '../InputSearch/InputSearch'
8
+ import IconButton from '../IconButton/IconButton'
9
+ import type { Modes } from '../../design-tokens'
10
+
11
+ // The filter button in the design is a tonal "secondary" variant (light-purple
12
+ // fill, purple icon). These modes reproduce `iconButton/background = #dbcfff`
13
+ // and `iconButton/icon/color = #5d00b5`. Applied as defaults so the button
14
+ // matches the Figma design out of the box; consumer-provided `modes` still win.
15
+ const FILTER_BUTTON_MODES: Modes = {
16
+ AppearanceBrand: 'Secondary',
17
+ Emphasis: 'Medium',
18
+ }
19
+
20
+ // Inside a Hero Section the Title is flush with the section padding — the
21
+ // `title/padding/*` tokens default to 16 but resolve to 0 in the `Page Hero`
22
+ // context (matching the Figma design). Cascaded so the title lines up with the
23
+ // search row instead of getting a double 16px inset.
24
+ const TITLE_MODES: Modes = {
25
+ context7: 'Page Hero',
26
+ }
27
+
28
+ export type HeroSectionProps = {
29
+ /** Headline text rendered by the internal {@link Title}. */
30
+ title?: string
31
+ /** Optional subtitle shown below the title. Omit to hide it. */
32
+ subtitle?: string
33
+ /** Horizontal alignment of the title/subtitle block. */
34
+ titleTextAlign?: 'Left' | 'Center'
35
+ /** Whether to render the title block. Ignored when `titleSlot` is provided. */
36
+ showTitle?: boolean
37
+ /**
38
+ * Fully replace the default title block. When provided, `title`, `subtitle`,
39
+ * `titleTextAlign`, and `showTitle` are ignored. `modes` cascade into the slot.
40
+ */
41
+ titleSlot?: React.ReactNode
42
+
43
+ /**
44
+ * Whether to render the search + filter row. Ignored when `searchSlot`
45
+ * is provided.
46
+ */
47
+ showSearch?: boolean
48
+ /** Controlled value for the search input. */
49
+ searchValue?: string
50
+ /** Change handler for the search input. */
51
+ onSearchChange?: (text: string) => void
52
+ /** Placeholder for the search input. */
53
+ searchPlaceholder?: string
54
+ /**
55
+ * Fully replace the search input (the flexible left part of the row). When
56
+ * provided, `searchValue`, `onSearchChange`, and `searchPlaceholder` are
57
+ * ignored. `modes` cascade into the slot.
58
+ */
59
+ searchSlot?: React.ReactNode
60
+
61
+ /** Whether to render the trailing filter icon button. */
62
+ showFilter?: boolean
63
+ /** Icon name for the filter button (from the icon registry). */
64
+ filterIcon?: string
65
+ /** Press handler for the filter button. */
66
+ onFilterPress?: () => void
67
+ /** Accessibility label for the filter button. */
68
+ filterAccessibilityLabel?: string
69
+ /**
70
+ * Fully replace the trailing filter control. When provided, `filterIcon`,
71
+ * `onFilterPress`, and `showFilter` are ignored. `modes` cascade into the slot.
72
+ */
73
+ filterSlot?: React.ReactNode
74
+
75
+ /** Content rendered in the body area below the search row. */
76
+ children?: React.ReactNode
77
+
78
+ /** Mode configuration for design-token theming. */
79
+ modes?: Modes
80
+ /** Style overrides applied to the outer container. */
81
+ style?: StyleProp<ViewStyle>
82
+ testID?: string
83
+ }
84
+
85
+ /**
86
+ * HeroSection is a page-level header block: a title (with optional subtitle),
87
+ * an optional search row (a flexible search input plus a trailing filter
88
+ * button), and a free-form content slot below.
89
+ *
90
+ * All spacing/background values resolve from the `HeroSection / Output` design
91
+ * token collection via `getVariableByName`. `modes` cascade to every slot and
92
+ * child component so downstream tokens stay in sync.
93
+ *
94
+ * @example
95
+ * ```tsx
96
+ * <HeroSection
97
+ * title="Transactions"
98
+ * subtitle="Last 30 days"
99
+ * searchValue={query}
100
+ * onSearchChange={setQuery}
101
+ * onFilterPress={openFilters}
102
+ * >
103
+ * <TransactionList data={items} />
104
+ * </HeroSection>
105
+ * ```
106
+ */
107
+ function HeroSection({
108
+ title = 'Page Title',
109
+ subtitle = 'Subtitle',
110
+ titleTextAlign = 'Left',
111
+ showTitle = true,
112
+ titleSlot,
113
+ showSearch = true,
114
+ searchValue,
115
+ onSearchChange,
116
+ searchPlaceholder = 'Search',
117
+ searchSlot,
118
+ showFilter = true,
119
+ filterIcon = 'ic_filter',
120
+ onFilterPress,
121
+ filterAccessibilityLabel = 'Filter',
122
+ filterSlot,
123
+ children,
124
+ modes: propModes = EMPTY_MODES,
125
+ style,
126
+ testID,
127
+ }: HeroSectionProps) {
128
+ const { modes: globalModes } = useTokens()
129
+ const modes = useMemo(
130
+ () => ({ ...globalModes, ...propModes }),
131
+ [globalModes, propModes]
132
+ )
133
+
134
+ const gap = Number(getVariableByName('heroSection/gap', modes))
135
+ const paddingHorizontal = Number(
136
+ getVariableByName('heroSection/padding/horizontal', modes)
137
+ )
138
+ const paddingVertical = Number(
139
+ getVariableByName('heroSection/padding/vertical', modes)
140
+ )
141
+
142
+ const containerStyle: ViewStyle = {
143
+ backgroundColor: '#ffffff',
144
+ flexDirection: 'column',
145
+ alignItems: 'flex-start',
146
+ gap,
147
+ paddingHorizontal,
148
+ paddingVertical,
149
+ width: '100%',
150
+ }
151
+
152
+ const titleContent = useMemo<React.ReactNode>(() => {
153
+ if (titleSlot !== undefined && titleSlot !== null) {
154
+ return cloneChildrenWithModes(titleSlot, modes)
155
+ }
156
+ if (!showTitle) return null
157
+ return (
158
+ <Title
159
+ title={title}
160
+ subtitle={subtitle}
161
+ textAlign={titleTextAlign}
162
+ modes={{ ...TITLE_MODES, ...modes }}
163
+ />
164
+ )
165
+ }, [titleSlot, showTitle, title, subtitle, titleTextAlign, modes])
166
+
167
+ const searchInput = useMemo<React.ReactNode>(() => {
168
+ if (searchSlot !== undefined && searchSlot !== null) {
169
+ return cloneChildrenWithModes(searchSlot, modes)
170
+ }
171
+ return (
172
+ <InputSearch
173
+ supportText={false}
174
+ placeholder={searchPlaceholder}
175
+ value={searchValue}
176
+ onChangeText={onSearchChange}
177
+ containerStyle={{ flex: 1 }}
178
+ modes={modes}
179
+ />
180
+ )
181
+ }, [searchSlot, searchPlaceholder, searchValue, onSearchChange, modes])
182
+
183
+ const filterControl = useMemo<React.ReactNode>(() => {
184
+ if (filterSlot !== undefined && filterSlot !== null) {
185
+ return cloneChildrenWithModes(filterSlot, modes)
186
+ }
187
+ if (!showFilter) return null
188
+ return (
189
+ <IconButton
190
+ iconName={filterIcon}
191
+ onPress={onFilterPress}
192
+ accessibilityLabel={filterAccessibilityLabel}
193
+ modes={{ ...FILTER_BUTTON_MODES, ...modes }}
194
+ />
195
+ )
196
+ }, [filterSlot, showFilter, filterIcon, onFilterPress, filterAccessibilityLabel, modes])
197
+
198
+ const searchRow =
199
+ searchSlot !== undefined && searchSlot !== null
200
+ ? true
201
+ : showSearch
202
+
203
+ const bodyContent = useMemo<React.ReactNode>(() => {
204
+ if (children === undefined || children === null) return null
205
+ return cloneChildrenWithModes(children, modes)
206
+ }, [children, modes])
207
+
208
+ return (
209
+ <View style={[containerStyle, style]} testID={testID}>
210
+ {titleContent}
211
+ {searchRow ? (
212
+ <View
213
+ style={{
214
+ flexDirection: 'row',
215
+ alignItems: 'center',
216
+ gap,
217
+ width: '100%',
218
+ }}
219
+ >
220
+ {searchInput}
221
+ {filterControl}
222
+ </View>
223
+ ) : null}
224
+ {bodyContent != null ? (
225
+ <View style={{ width: '100%' }}>{bodyContent}</View>
226
+ ) : null}
227
+ </View>
228
+ )
229
+ }
230
+
231
+ export default HeroSection
@@ -7,9 +7,23 @@ import {
7
7
  type StyleProp,
8
8
  type ViewStyle,
9
9
  type ImageResizeMode,
10
+ type ImageLoadEventData,
11
+ type ImageErrorEventData,
12
+ type NativeSyntheticEvent,
10
13
  } from 'react-native'
11
14
 
12
15
  type ImageResizeMethod = 'auto' | 'resize' | 'scale' | 'none'
16
+
17
+ /**
18
+ * iOS URL cache control (maps to RN's `source.cache`, no-op on Android/web):
19
+ * - `'default'` — use the native platform's default caching.
20
+ * - `'reload'` — ignore any cache, always fetch from the network.
21
+ * - `'force-cache'` — use the cached response regardless of age; fetch
22
+ * only if absent.
23
+ * - `'only-if-cached'` — use the cache only; never hit the network.
24
+ */
25
+ type ImageCacheControl = 'default' | 'reload' | 'force-cache' | 'only-if-cached'
26
+
13
27
  import Skeleton from '../../skeleton/Skeleton'
14
28
  import { useSkeleton } from '../../skeleton/SkeletonGroup'
15
29
 
@@ -78,13 +92,43 @@ export type ImageProps = {
78
92
  * the group.
79
93
  */
80
94
  loading?: boolean | undefined
95
+ /**
96
+ * iOS URL cache strategy applied to remote (`{ uri }`) sources. Injected into
97
+ * the RN `source.cache` field so callers don't have to re-shape the source
98
+ * object themselves. No-op for local/required assets and on Android/web.
99
+ */
100
+ cache?: ImageCacheControl | undefined
101
+ /**
102
+ * Called when the image finishes loading successfully. Forwarded straight to
103
+ * RN's `<Image onLoad>`.
104
+ */
105
+ onLoad?: ((event: NativeSyntheticEvent<ImageLoadEventData>) => void) | undefined
106
+ /**
107
+ * Called when the image fails to load. Forwarded straight to RN's
108
+ * `<Image onError>`.
109
+ */
110
+ onError?:
111
+ | ((event: NativeSyntheticEvent<ImageErrorEventData>) => void)
112
+ | undefined
81
113
  }
82
114
 
83
115
  function normalizeSource(
84
- imageSource: ImageProps['imageSource']
116
+ imageSource: ImageProps['imageSource'],
117
+ cache?: ImageCacheControl
85
118
  ): ImageSourcePropType | undefined {
86
119
  if (imageSource == null) return undefined
87
- if (typeof imageSource === 'string') return { uri: imageSource }
120
+ if (typeof imageSource === 'string') {
121
+ return cache ? { uri: imageSource, cache } : { uri: imageSource }
122
+ }
123
+ // Only remote sources (single object with a `uri`) can carry a cache policy.
124
+ if (
125
+ cache &&
126
+ !Array.isArray(imageSource) &&
127
+ typeof imageSource === 'object' &&
128
+ 'uri' in imageSource
129
+ ) {
130
+ return { ...imageSource, cache }
131
+ }
88
132
  return imageSource
89
133
  }
90
134
 
@@ -122,8 +166,14 @@ function Image({
122
166
  accessibilityElementsHidden,
123
167
  importantForAccessibility,
124
168
  loading,
169
+ cache,
170
+ onLoad,
171
+ onError,
125
172
  }: ImageProps) {
126
- const source = useMemo(() => normalizeSource(imageSource), [imageSource])
173
+ const source = useMemo(
174
+ () => normalizeSource(imageSource, cache),
175
+ [imageSource, cache]
176
+ )
127
177
 
128
178
  // Explicit { width, height } means a "fill an exact box" layout — typically a
129
179
  // full-bleed hero/background where the asset is high-res and sharpness
@@ -173,6 +223,8 @@ function Image({
173
223
  accessibilityLabel={accessibilityLabel}
174
224
  accessibilityElementsHidden={accessibilityElementsHidden}
175
225
  importantForAccessibility={importantForAccessibility}
226
+ onLoad={onLoad}
227
+ onError={onError}
176
228
  />
177
229
  )
178
230
  }
@@ -0,0 +1,159 @@
1
+ import React, { useCallback } from 'react'
2
+ import { Text as RNText, type TextStyle, type StyleProp, type GestureResponderEvent } from 'react-native'
3
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
4
+ import { EMPTY_MODES, resolveTextLayout } from '../../utils/react-utils'
5
+ import type { Modes } from '../../design-tokens'
6
+
7
+ export type LinkProps = {
8
+ /**
9
+ * The link label. You may also pass content as JSX children
10
+ * (e.g. `<Link>Terms</Link>`); when both are provided, `children` wins.
11
+ * Mirrors the {@link Text} API so `Link` is a near-drop-in, underlined
12
+ * sibling of `Text`.
13
+ */
14
+ text?: string
15
+ /** Child nodes (string or nested `Text`/`Link`). Wins over `text`. */
16
+ children?: React.ReactNode
17
+ /**
18
+ * Called when the link is activated. Navigation is the consumer's job
19
+ * (e.g. `Linking.openURL`, a router push) — `Link` is intentionally
20
+ * navigation-agnostic and React Native friendly (no `href`/`target`).
21
+ */
22
+ onPress?: (event: GestureResponderEvent) => void
23
+ /** Disables interaction and dims the label. */
24
+ disabled?: boolean
25
+ /**
26
+ * Layout sizing (matches the Figma `autolayout` variant).
27
+ * - `Fill` (default): the label stretches to fill the parent's width, so
28
+ * `textAlign="Center"` is visible and the whole line is tappable.
29
+ * - `Hug`: the label hugs its content width.
30
+ */
31
+ autolayout?: 'Fill' | 'Hug'
32
+ /** Horizontal alignment of the label (matches `Text`). */
33
+ textAlign?: 'Left' | 'Center'
34
+ /** Modes configuration for design token resolution (cascades from `TextSegment`). */
35
+ modes?: Modes
36
+ /** Style override for the label. */
37
+ style?: StyleProp<TextStyle>
38
+ /** Number of lines to limit the label to. */
39
+ numberOfLines?: number
40
+ /** Never truncate — drops the clamp/ellipsis and lets the label overflow. See {@link Text}. */
41
+ disableTruncation?: boolean
42
+ /** Force a single non-wrapping line (implies `disableTruncation`). See {@link Text}. */
43
+ singleLine?: boolean
44
+ /** Accessibility label; defaults to the resolved text content. */
45
+ accessibilityLabel?: string
46
+ /** Accessibility hint describing what activating the link does. */
47
+ accessibilityHint?: string
48
+ }
49
+
50
+ const TEXT_ALIGN_MAP: Record<NonNullable<LinkProps['textAlign']>, TextStyle['textAlign']> = {
51
+ Left: 'left',
52
+ Center: 'center',
53
+ }
54
+
55
+ const DISABLED_OPACITY = 0.4
56
+
57
+ /**
58
+ * Link — an underlined, pressable text primitive.
59
+ *
60
+ * It renders a single React Native `<Text>` (not a `Pressable`), so it flows
61
+ * inline and can be nested inside {@link TextSegment} exactly like a `Text`
62
+ * run — the React Native equivalent of an `<a>` inside a `<p>`. Font family,
63
+ * size, weight, line-height and letter-spacing come from the dedicated `link/*`
64
+ * tokens, while the colour resolves from `text/foreground` (so a link sits on
65
+ * the same colour as the surrounding copy). The label is always underlined.
66
+ *
67
+ * @example Standalone
68
+ * ```tsx
69
+ * <Link text="Forgot PIN?" onPress={() => navigation.navigate('ResetPin')} />
70
+ * ```
71
+ *
72
+ * @example Inline inside TextSegment
73
+ * ```tsx
74
+ * <TextSegment>
75
+ * <Text>By continuing you agree to our </Text>
76
+ * <Link onPress={openTerms}>Terms</Link>
77
+ * <Text>.</Text>
78
+ * </TextSegment>
79
+ * ```
80
+ */
81
+ function Link({
82
+ text,
83
+ children,
84
+ onPress,
85
+ disabled = false,
86
+ autolayout = 'Fill',
87
+ textAlign = 'Left',
88
+ modes = EMPTY_MODES,
89
+ style,
90
+ numberOfLines,
91
+ disableTruncation,
92
+ singleLine,
93
+ accessibilityLabel,
94
+ accessibilityHint,
95
+ }: LinkProps) {
96
+ // Bindings mirror the Figma `Link` node exactly: colour from `text/foreground`
97
+ // (so the link matches the surrounding copy), everything else from the
98
+ // dedicated `link/*` tokens.
99
+ const foreground = getVariableByName('text/foreground', modes) ?? '#000000'
100
+ const fontFamily = getVariableByName('link/fontFamily', modes) ?? 'JioType Var'
101
+ const fontSize = getVariableByName('link/fontSize', modes) ?? 12
102
+ const fontWeight = getVariableByName('link/fontWeight', modes) ?? 400
103
+ const lineHeight = getVariableByName('link/lineHeight', modes) ?? 16
104
+ const letterSpacing = getVariableByName('link/letterSpacing', modes) ?? -0.5
105
+
106
+ const linkStyle: TextStyle = {
107
+ color: foreground as string,
108
+ fontFamily: fontFamily as string,
109
+ fontSize: fontSize as number,
110
+ fontWeight: String(fontWeight) as TextStyle['fontWeight'],
111
+ lineHeight: lineHeight as number,
112
+ letterSpacing: letterSpacing as number,
113
+ textAlign: TEXT_ALIGN_MAP[textAlign],
114
+ textDecorationLine: 'underline',
115
+ alignSelf: autolayout === 'Fill' ? 'stretch' : 'flex-start',
116
+ ...(disabled ? { opacity: DISABLED_OPACITY } : null),
117
+ }
118
+
119
+ const content =
120
+ children !== undefined && children !== null && children !== false
121
+ ? children
122
+ : text !== undefined
123
+ ? text
124
+ : 'Link'
125
+
126
+ const handlePress = useCallback(
127
+ (event: GestureResponderEvent) => {
128
+ if (disabled) return
129
+ onPress?.(event)
130
+ },
131
+ [disabled, onPress]
132
+ )
133
+
134
+ const { style: layoutStyle, ...truncation } = resolveTextLayout({
135
+ disableTruncation,
136
+ singleLine,
137
+ numberOfLines,
138
+ })
139
+
140
+ const resolvedLabel =
141
+ accessibilityLabel ?? (typeof content === 'string' ? content : undefined)
142
+
143
+ return (
144
+ <RNText
145
+ accessibilityRole="link"
146
+ accessibilityState={{ disabled }}
147
+ {...(resolvedLabel !== undefined ? { accessibilityLabel: resolvedLabel } : null)}
148
+ {...(accessibilityHint !== undefined ? { accessibilityHint } : null)}
149
+ {...(onPress !== undefined ? { onPress: handlePress } : null)}
150
+ disabled={disabled}
151
+ style={[linkStyle, style, layoutStyle]}
152
+ {...truncation}
153
+ >
154
+ {content}
155
+ </RNText>
156
+ )
157
+ }
158
+
159
+ export default React.memo(Link)
@@ -107,6 +107,17 @@ function resolveListItemTokens(modes: Modes): ListItemTokens {
107
107
  const paddingRight = getVariableByName('listItem/padding/right', resolvedModes) ?? 0
108
108
  const textWrapGap = (getVariableByName('listItem/text wrap', resolvedModes) ?? 0) as number
109
109
 
110
+ // Container surface — driven by the `List Item Style` collection
111
+ // (Default | Boxed | Minimal) plus `ListItem State`, `Selectable`,
112
+ // `Page type` and `Color Mode`. In the default style these all resolve to a
113
+ // transparent background / no border / 0 radius, so existing usages are
114
+ // visually unchanged; the "Boxed" style paints a filled, rounded, bordered
115
+ // surface.
116
+ const backgroundColor = getVariableByName('listItem/background/color', resolvedModes)
117
+ const borderColor = getVariableByName('listItem/border/color', resolvedModes)
118
+ const borderWidth = (getVariableByName('listItem/borderWidth', resolvedModes) ?? 0) as number
119
+ const borderRadius = (getVariableByName('listItem/radius', resolvedModes) ?? 0) as number
120
+
110
121
  const titleColor = getVariableByName('listItem/title/color', textModes)
111
122
  const titleFontSize = getVariableByName('listItem/title/fontSize', textModes)
112
123
  const titleLineHeight = getVariableByName('listItem/title/lineHeight', textModes)
@@ -129,6 +140,10 @@ function resolveListItemTokens(modes: Modes): ListItemTokens {
129
140
  paddingBottom: paddingBottom as number,
130
141
  paddingLeft: paddingLeft as number,
131
142
  paddingRight: paddingRight as number,
143
+ backgroundColor: backgroundColor as string,
144
+ borderColor: borderColor as string,
145
+ borderWidth,
146
+ borderRadius,
132
147
  },
133
148
  horizontalLayoutStyle: {
134
149
  flexDirection: 'row',
@@ -1,5 +1,5 @@
1
1
  import React, { createContext, useContext } from 'react'
2
- import { View, Text, StyleSheet, type ViewStyle, type TextStyle, type StyleProp, type ImageSourcePropType } from 'react-native'
2
+ import { View, Text, Pressable, StyleSheet, type ViewStyle, type TextStyle, type StyleProp, type ImageSourcePropType } from 'react-native'
3
3
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
4
4
  import Image from '../Image/Image'
5
5
  import GlassFill, { type GlassTint } from '../../utils/GlassFill/GlassFill'
@@ -40,6 +40,15 @@ export interface MediaCardProps {
40
40
  * Style overrides for the card container.
41
41
  */
42
42
  style?: StyleProp<ViewStyle>
43
+ /**
44
+ * Press handler for the whole card. When set, the card becomes pressable
45
+ * (rendered through a `Pressable` with `accessibilityRole="button"`).
46
+ */
47
+ onPress?: () => void
48
+ /** Disable interaction when `onPress` is set. */
49
+ disabled?: boolean
50
+ /** Accessibility label forwarded to the pressable wrapper. */
51
+ accessibilityLabel?: string
43
52
  }
44
53
 
45
54
  /**
@@ -67,6 +76,9 @@ export function MediaCard({
67
76
  children,
68
77
  modes = EMPTY_MODES,
69
78
  style,
79
+ onPress,
80
+ disabled,
81
+ accessibilityLabel,
70
82
  }: MediaCardProps) {
71
83
  const radius = parseFloat(getVariableByName('cardMedia/radius', modes))
72
84
 
@@ -88,16 +100,35 @@ export function MediaCard({
88
100
  ) : null
89
101
  )
90
102
 
103
+ const content = (
104
+ <>
105
+ {background}
106
+ {children != null ? (
107
+ <View style={StyleSheet.absoluteFill} pointerEvents="box-none">
108
+ {children}
109
+ </View>
110
+ ) : null}
111
+ </>
112
+ )
113
+
91
114
  return (
92
115
  <MediaCardContext.Provider value={{ modes }}>
93
- <View style={[containerStyle, style]}>
94
- {background}
95
- {children != null ? (
96
- <View style={StyleSheet.absoluteFill} pointerEvents="box-none">
97
- {children}
98
- </View>
99
- ) : null}
100
- </View>
116
+ {onPress ? (
117
+ <Pressable
118
+ style={[containerStyle, style]}
119
+ onPress={onPress}
120
+ disabled={disabled}
121
+ accessibilityRole="button"
122
+ accessibilityLabel={accessibilityLabel}
123
+ accessibilityState={{ disabled: !!disabled }}
124
+ >
125
+ {content}
126
+ </Pressable>
127
+ ) : (
128
+ <View style={[containerStyle, style]} accessibilityLabel={accessibilityLabel}>
129
+ {content}
130
+ </View>
131
+ )}
101
132
  </MediaCardContext.Provider>
102
133
  )
103
134
  }