jfs-components 0.1.19 → 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 (40) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/lib/commonjs/components/CheckboxItem/CheckboxItem.js +31 -8
  3. package/lib/commonjs/components/ContentSheet/ContentSheet.js +131 -0
  4. package/lib/commonjs/components/HeroSection/HeroSection.js +165 -0
  5. package/lib/commonjs/components/Image/Image.js +33 -7
  6. package/lib/commonjs/components/Link/Link.js +115 -0
  7. package/lib/commonjs/components/ListItem/ListItem.js +16 -1
  8. package/lib/commonjs/components/index.js +21 -0
  9. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  10. package/lib/commonjs/design-tokens/figma-modes.generated.js +53 -43
  11. package/lib/commonjs/icons/registry.js +1 -1
  12. package/lib/module/components/CheckboxItem/CheckboxItem.js +31 -8
  13. package/lib/module/components/ContentSheet/ContentSheet.js +126 -0
  14. package/lib/module/components/HeroSection/HeroSection.js +159 -0
  15. package/lib/module/components/Image/Image.js +34 -7
  16. package/lib/module/components/Link/Link.js +110 -0
  17. package/lib/module/components/ListItem/ListItem.js +16 -1
  18. package/lib/module/components/index.js +3 -0
  19. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  20. package/lib/module/design-tokens/figma-modes.generated.js +53 -43
  21. package/lib/module/icons/registry.js +1 -1
  22. package/lib/typescript/src/components/CheckboxItem/CheckboxItem.d.ts +26 -3
  23. package/lib/typescript/src/components/ContentSheet/ContentSheet.d.ts +71 -0
  24. package/lib/typescript/src/components/HeroSection/HeroSection.d.ts +80 -0
  25. package/lib/typescript/src/components/Image/Image.d.ts +27 -2
  26. package/lib/typescript/src/components/Link/Link.d.ts +73 -0
  27. package/lib/typescript/src/components/index.d.ts +3 -0
  28. package/lib/typescript/src/design-tokens/figma-modes.generated.d.ts +8 -0
  29. package/lib/typescript/src/icons/registry.d.ts +1 -1
  30. package/package.json +1 -1
  31. package/src/components/CheckboxItem/CheckboxItem.tsx +59 -14
  32. package/src/components/ContentSheet/ContentSheet.tsx +217 -0
  33. package/src/components/HeroSection/HeroSection.tsx +231 -0
  34. package/src/components/Image/Image.tsx +55 -3
  35. package/src/components/Link/Link.tsx +159 -0
  36. package/src/components/ListItem/ListItem.tsx +15 -0
  37. package/src/components/index.ts +3 -0
  38. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  39. package/src/design-tokens/figma-modes.generated.ts +53 -43
  40. package/src/icons/registry.ts +1 -1
@@ -0,0 +1,217 @@
1
+ import React, { useContext, useMemo } from 'react'
2
+ import {
3
+ Platform,
4
+ View,
5
+ type StyleProp,
6
+ type ViewProps,
7
+ type ViewStyle,
8
+ } from 'react-native'
9
+ import Animated, {
10
+ useAnimatedKeyboard,
11
+ useAnimatedStyle,
12
+ } from 'react-native-reanimated'
13
+ import { SafeAreaInsetsContext } from 'react-native-safe-area-context'
14
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
15
+ import { EMPTY_MODES, cloneChildrenWithModes, flattenChildren } from '../../utils/react-utils'
16
+ import type { Modes } from '../../design-tokens'
17
+
18
+ const IS_WEB = Platform.OS === 'web'
19
+
20
+ export type ContentSheetProps = {
21
+ /**
22
+ * Sheet content. The sheet is one big slot: whatever you place here is
23
+ * stacked in a column (separated by the `contentSheet/gap` token) inside the
24
+ * sheet's padding. The sheet has **no fixed height** — it grows and shrinks
25
+ * to fit these children automatically.
26
+ */
27
+ children?: React.ReactNode
28
+ /**
29
+ * Design-token modes. Cascaded to every slot child via
30
+ * {@link cloneChildrenWithModes} so nested `ListItem`/`Section`/etc. resolve
31
+ * the same theme without you wiring `modes` onto each one.
32
+ */
33
+ modes?: Modes
34
+ /**
35
+ * Keep the sheet above the on-screen keyboard. When the keyboard opens the
36
+ * sheet rises by exactly the keyboard height; when it closes the sheet drops
37
+ * back. The follow animation is driven entirely on the UI thread
38
+ * (`useAnimatedKeyboard`) so it never schedules a React re-render and stays
39
+ * in perfect sync with the keyboard on both iOS and Android.
40
+ *
41
+ * On web this is a no-op (soft keyboards there don't overlay content).
42
+ * Default `true`.
43
+ */
44
+ avoidKeyboard?: boolean
45
+ /**
46
+ * Extra gap (px) inserted between the top of the keyboard and the bottom of
47
+ * the sheet while the keyboard is open. Default `0` (sheet sits flush on the
48
+ * keyboard).
49
+ */
50
+ keyboardSpacing?: number
51
+ /**
52
+ * Add the device's bottom safe-area inset (home indicator) on top of the
53
+ * sheet's bottom padding, so content never sits under the indicator when the
54
+ * sheet rests at the bottom of the screen. Default `true`.
55
+ */
56
+ safeAreaBottom?: boolean
57
+ /**
58
+ * Pin the sheet to the bottom of its parent (absolute, full width). This is
59
+ * the default behaviour — the sheet "just stays at the bottom".
60
+ *
61
+ * Set to `false` to render the sheet in normal flow instead (e.g. when you
62
+ * compose it inside your own layout container). Keyboard avoidance still
63
+ * works in both modes.
64
+ */
65
+ pinToBottom?: boolean
66
+ /** Optional style override applied to the sheet container. */
67
+ style?: StyleProp<ViewStyle>
68
+ } & Omit<ViewProps, 'style' | 'children'>
69
+
70
+ interface ContentSheetStyle {
71
+ container: ViewStyle
72
+ paddingBottom: number
73
+ }
74
+
75
+ function resolveContentSheetStyle(modes: Modes): ContentSheetStyle {
76
+ const backgroundColor = getVariableByName('contentSheet/bg', modes) as string
77
+ const gap = getVariableByName('contentSheet/gap', modes) as number
78
+ const paddingHorizontal = getVariableByName('contentSheet/padding/horizontal', modes) as number
79
+ const paddingTop = getVariableByName('contentSheet/padding/top', modes) as number
80
+ const paddingBottom = getVariableByName('contentSheet/padding/bottom', modes) as number
81
+ const radiusTop = getVariableByName('contentSheet/padding/radius/top', modes) as number
82
+ const radiusBottom = getVariableByName('contentSheet/padding/radius/bottom', modes) as number
83
+
84
+ return {
85
+ container: {
86
+ backgroundColor,
87
+ gap,
88
+ paddingHorizontal,
89
+ paddingTop,
90
+ // paddingBottom is applied separately so the safe-area inset can be added.
91
+ borderTopLeftRadius: radiusTop,
92
+ borderTopRightRadius: radiusTop,
93
+ borderBottomLeftRadius: radiusBottom,
94
+ borderBottomRightRadius: radiusBottom,
95
+ flexDirection: 'column',
96
+ alignItems: 'stretch',
97
+ overflow: 'hidden',
98
+ },
99
+ paddingBottom,
100
+ }
101
+ }
102
+
103
+ const pinnedStyle: ViewStyle = {
104
+ position: 'absolute',
105
+ left: 0,
106
+ right: 0,
107
+ bottom: 0,
108
+ }
109
+
110
+ /**
111
+ * ContentSheet — a bottom-anchored surface that is essentially one big slot
112
+ * with padding and rounded top corners, mirroring the Figma "Content Sheet".
113
+ *
114
+ * Behaviour highlights:
115
+ * - **Auto height (free & automatic).** The sheet never sets an explicit
116
+ * height. React Native's layout engine (Yoga) sizes it to its children on
117
+ * the native thread, so when the slot content grows or shrinks the sheet
118
+ * follows with zero JS measurement and zero extra re-renders — the most
119
+ * performant option possible.
120
+ * - **Keyboard avoidance** on iOS and Android via `avoidKeyboard` (UI-thread
121
+ * `useAnimatedKeyboard`, no re-renders).
122
+ * - **Bottom pinned** by default; opt out with `pinToBottom={false}`.
123
+ * - **Token-driven** styling via `getVariableByName` + `modes`, with `modes`
124
+ * cascaded to all slot children.
125
+ */
126
+ function ContentSheet({
127
+ children,
128
+ modes = EMPTY_MODES,
129
+ avoidKeyboard = true,
130
+ keyboardSpacing = 0,
131
+ safeAreaBottom = true,
132
+ pinToBottom = true,
133
+ style,
134
+ ...rest
135
+ }: ContentSheetProps) {
136
+ // Read the safe-area inset directly from context (rather than
137
+ // `useSafeAreaInsets`, which throws when no provider is mounted). This keeps
138
+ // the sheet usable without a `SafeAreaProvider` — it simply falls back to 0.
139
+ const safeAreaInsets = useContext(SafeAreaInsetsContext)
140
+ const bottomInset = safeAreaInsets?.bottom ?? 0
141
+ const resolved = useMemo(() => resolveContentSheetStyle(modes), [modes])
142
+
143
+ const processedChildren = useMemo(
144
+ () => cloneChildrenWithModes(flattenChildren(children), modes),
145
+ [children, modes]
146
+ )
147
+
148
+ const paddingBottom =
149
+ resolved.paddingBottom + (safeAreaBottom ? bottomInset : 0)
150
+
151
+ const containerStyle = useMemo<StyleProp<ViewStyle>>(
152
+ () => [
153
+ pinToBottom ? pinnedStyle : null,
154
+ resolved.container,
155
+ { paddingBottom },
156
+ style,
157
+ ],
158
+ [pinToBottom, resolved.container, paddingBottom, style]
159
+ )
160
+
161
+ // Switching between the keyboard-aware and plain implementation is keyed off
162
+ // `avoidKeyboard` (and platform). Because they are distinct component types,
163
+ // React remounts on toggle, so the conditional `useAnimatedKeyboard` hook
164
+ // inside `KeyboardAwareSheet` always runs in a stable hook order.
165
+ if (avoidKeyboard && !IS_WEB) {
166
+ return (
167
+ <KeyboardAwareSheet
168
+ style={containerStyle}
169
+ spacing={keyboardSpacing}
170
+ {...rest}
171
+ >
172
+ {processedChildren}
173
+ </KeyboardAwareSheet>
174
+ )
175
+ }
176
+
177
+ return (
178
+ <View style={containerStyle} {...rest}>
179
+ {processedChildren}
180
+ </View>
181
+ )
182
+ }
183
+
184
+ type KeyboardAwareSheetProps = {
185
+ style: StyleProp<ViewStyle>
186
+ spacing: number
187
+ children: React.ReactNode
188
+ } & Omit<ViewProps, 'style' | 'children'>
189
+
190
+ /**
191
+ * Native-only wrapper that lifts the sheet by the live keyboard height. The
192
+ * translation is computed on the UI thread from `useAnimatedKeyboard`, so the
193
+ * sheet tracks the keyboard frame-for-frame without any JS work.
194
+ */
195
+ function KeyboardAwareSheet({
196
+ style,
197
+ spacing,
198
+ children,
199
+ ...rest
200
+ }: KeyboardAwareSheetProps) {
201
+ const keyboard = useAnimatedKeyboard()
202
+
203
+ const keyboardStyle = useAnimatedStyle(() => {
204
+ const height = keyboard.height.value
205
+ return {
206
+ transform: [{ translateY: height > 0 ? -(height + spacing) : 0 }],
207
+ }
208
+ })
209
+
210
+ return (
211
+ <Animated.View style={[style, keyboardStyle]} {...rest}>
212
+ {children}
213
+ </Animated.View>
214
+ )
215
+ }
216
+
217
+ export default ContentSheet
@@ -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)