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.
- package/CHANGELOG.md +19 -0
- package/lib/commonjs/components/Card/Card.js +29 -12
- package/lib/commonjs/components/CardFeedback/CardFeedback.js +16 -2
- package/lib/commonjs/components/CardInsight/CardInsight.js +34 -13
- package/lib/commonjs/components/CardProviderInfo/CardProviderInfo.js +23 -3
- package/lib/commonjs/components/CheckboxItem/CheckboxItem.js +31 -8
- package/lib/commonjs/components/ContentSheet/ContentSheet.js +131 -0
- package/lib/commonjs/components/DebitCard/DebitCard.js +23 -3
- package/lib/commonjs/components/HeroSection/HeroSection.js +165 -0
- package/lib/commonjs/components/Image/Image.js +33 -7
- package/lib/commonjs/components/Link/Link.js +115 -0
- package/lib/commonjs/components/ListItem/ListItem.js +16 -1
- package/lib/commonjs/components/MediaCard/MediaCard.js +24 -7
- package/lib/commonjs/components/RechargeCard/RechargeCard.js +34 -13
- package/lib/commonjs/components/TestimonialsCard/TestimonialsCard.js +23 -5
- package/lib/commonjs/components/index.js +21 -0
- package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/commonjs/design-tokens/figma-modes.generated.js +53 -43
- package/lib/commonjs/icons/registry.js +1 -1
- package/lib/module/components/Card/Card.js +31 -14
- package/lib/module/components/CardFeedback/CardFeedback.js +17 -3
- package/lib/module/components/CardInsight/CardInsight.js +36 -15
- package/lib/module/components/CardProviderInfo/CardProviderInfo.js +25 -5
- package/lib/module/components/CheckboxItem/CheckboxItem.js +31 -8
- package/lib/module/components/ContentSheet/ContentSheet.js +126 -0
- package/lib/module/components/DebitCard/DebitCard.js +25 -5
- package/lib/module/components/HeroSection/HeroSection.js +159 -0
- package/lib/module/components/Image/Image.js +34 -7
- package/lib/module/components/Link/Link.js +110 -0
- package/lib/module/components/ListItem/ListItem.js +16 -1
- package/lib/module/components/MediaCard/MediaCard.js +26 -9
- package/lib/module/components/RechargeCard/RechargeCard.js +36 -15
- package/lib/module/components/TestimonialsCard/TestimonialsCard.js +25 -7
- package/lib/module/components/index.js +3 -0
- package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/module/design-tokens/figma-modes.generated.js +53 -43
- package/lib/module/icons/registry.js +1 -1
- package/lib/typescript/src/components/Card/Card.d.ts +10 -1
- package/lib/typescript/src/components/CardFeedback/CardFeedback.d.ts +10 -1
- package/lib/typescript/src/components/CardInsight/CardInsight.d.ts +10 -1
- package/lib/typescript/src/components/CardProviderInfo/CardProviderInfo.d.ts +10 -1
- package/lib/typescript/src/components/CheckboxItem/CheckboxItem.d.ts +26 -3
- package/lib/typescript/src/components/ContentSheet/ContentSheet.d.ts +71 -0
- package/lib/typescript/src/components/DebitCard/DebitCard.d.ts +10 -1
- package/lib/typescript/src/components/HeroSection/HeroSection.d.ts +80 -0
- package/lib/typescript/src/components/Image/Image.d.ts +27 -2
- package/lib/typescript/src/components/Link/Link.d.ts +73 -0
- package/lib/typescript/src/components/MediaCard/MediaCard.d.ts +10 -1
- package/lib/typescript/src/components/RechargeCard/RechargeCard.d.ts +10 -1
- package/lib/typescript/src/components/TestimonialsCard/TestimonialsCard.d.ts +8 -1
- package/lib/typescript/src/components/index.d.ts +3 -0
- package/lib/typescript/src/design-tokens/figma-modes.generated.d.ts +8 -0
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/Card/Card.tsx +46 -15
- package/src/components/CardFeedback/CardFeedback.tsx +29 -4
- package/src/components/CardInsight/CardInsight.tsx +48 -17
- package/src/components/CardProviderInfo/CardProviderInfo.tsx +36 -3
- package/src/components/CheckboxItem/CheckboxItem.tsx +59 -14
- package/src/components/ContentSheet/ContentSheet.tsx +217 -0
- package/src/components/DebitCard/DebitCard.tsx +36 -3
- package/src/components/HeroSection/HeroSection.tsx +231 -0
- package/src/components/Image/Image.tsx +55 -3
- package/src/components/Link/Link.tsx +159 -0
- package/src/components/ListItem/ListItem.tsx +15 -0
- package/src/components/MediaCard/MediaCard.tsx +40 -9
- package/src/components/RechargeCard/RechargeCard.tsx +48 -13
- package/src/components/TestimonialsCard/TestimonialsCard.tsx +37 -6
- package/src/components/index.ts +3 -0
- package/src/design-tokens/Coin Variables-variables-full.json +1 -1
- package/src/design-tokens/figma-modes.generated.ts +53 -43
- 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
|
-
|
|
153
|
-
|
|
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')
|
|
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(
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
}
|