jfs-components 0.1.19 → 0.1.25

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 (55) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/lib/commonjs/components/CardCTA/CardCTA.js +32 -25
  3. package/lib/commonjs/components/CheckboxItem/CheckboxItem.js +31 -8
  4. package/lib/commonjs/components/ChipSelect/ChipSelect.js +2 -1
  5. package/lib/commonjs/components/ContentSheet/ContentSheet.js +131 -0
  6. package/lib/commonjs/components/HeroSection/HeroSection.js +165 -0
  7. package/lib/commonjs/components/Image/Image.js +33 -7
  8. package/lib/commonjs/components/Link/Link.js +115 -0
  9. package/lib/commonjs/components/ListItem/ListItem.js +27 -5
  10. package/lib/commonjs/components/MetricData/MetricData.js +132 -0
  11. package/lib/commonjs/components/Rating/Rating.js +137 -0
  12. package/lib/commonjs/components/index.js +21 -0
  13. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  14. package/lib/commonjs/design-tokens/figma-modes.generated.js +55 -43
  15. package/lib/commonjs/icons/registry.js +1 -1
  16. package/lib/module/components/CardCTA/CardCTA.js +32 -25
  17. package/lib/module/components/CheckboxItem/CheckboxItem.js +31 -8
  18. package/lib/module/components/ChipSelect/ChipSelect.js +2 -1
  19. package/lib/module/components/ContentSheet/ContentSheet.js +126 -0
  20. package/lib/module/components/HeroSection/HeroSection.js +159 -0
  21. package/lib/module/components/Image/Image.js +34 -7
  22. package/lib/module/components/Link/Link.js +110 -0
  23. package/lib/module/components/ListItem/ListItem.js +27 -5
  24. package/lib/module/components/MetricData/MetricData.js +127 -0
  25. package/lib/module/components/Rating/Rating.js +132 -0
  26. package/lib/module/components/index.js +3 -0
  27. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  28. package/lib/module/design-tokens/figma-modes.generated.js +55 -43
  29. package/lib/module/icons/registry.js +1 -1
  30. package/lib/typescript/src/components/CheckboxItem/CheckboxItem.d.ts +26 -3
  31. package/lib/typescript/src/components/ChipSelect/ChipSelect.d.ts +6 -1
  32. package/lib/typescript/src/components/ContentSheet/ContentSheet.d.ts +71 -0
  33. package/lib/typescript/src/components/HeroSection/HeroSection.d.ts +80 -0
  34. package/lib/typescript/src/components/Image/Image.d.ts +27 -2
  35. package/lib/typescript/src/components/Link/Link.d.ts +73 -0
  36. package/lib/typescript/src/components/MetricData/MetricData.d.ts +53 -0
  37. package/lib/typescript/src/components/Rating/Rating.d.ts +30 -0
  38. package/lib/typescript/src/components/index.d.ts +3 -0
  39. package/lib/typescript/src/design-tokens/figma-modes.generated.d.ts +8 -0
  40. package/lib/typescript/src/icons/registry.d.ts +1 -1
  41. package/package.json +1 -1
  42. package/src/components/CardCTA/CardCTA.tsx +30 -15
  43. package/src/components/CheckboxItem/CheckboxItem.tsx +59 -14
  44. package/src/components/ChipSelect/ChipSelect.tsx +7 -1
  45. package/src/components/ContentSheet/ContentSheet.tsx +217 -0
  46. package/src/components/HeroSection/HeroSection.tsx +231 -0
  47. package/src/components/Image/Image.tsx +55 -3
  48. package/src/components/Link/Link.tsx +159 -0
  49. package/src/components/ListItem/ListItem.tsx +26 -4
  50. package/src/components/MetricData/MetricData.tsx +185 -0
  51. package/src/components/Rating/Rating.tsx +174 -0
  52. package/src/components/index.ts +3 -0
  53. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  54. package/src/design-tokens/figma-modes.generated.ts +55 -43
  55. package/src/icons/registry.ts +1 -1
@@ -21,7 +21,25 @@ export type CheckboxItemProps = {
21
21
  onValueChange?: (checked: boolean) => void
22
22
  /** Whether the row is disabled — both the checkbox and row press handler are disabled. */
23
23
  disabled?: boolean
24
- /** The label text rendered next to the checkbox. */
24
+ /**
25
+ * Content rendered in the row's label slot (between the leading and trailing
26
+ * edges). This is the primary way to supply the label and mirrors the Figma
27
+ * "slot" — pass a plain string for the default token-styled label, or any
28
+ * custom node (e.g. a `<Text>`, a row of `<Text>` + `<Badge>`, etc.) for full
29
+ * control. Custom nodes receive the same `modes` as the parent.
30
+ *
31
+ * When both `children` and the legacy {@link label} prop are provided,
32
+ * `children` wins.
33
+ */
34
+ children?: React.ReactNode
35
+ /**
36
+ * The label content rendered next to the checkbox.
37
+ *
38
+ * @deprecated Prefer the `children` slot instead
39
+ * (`<CheckboxItem>Fixed deposit • 0245</CheckboxItem>`). `label` is kept for
40
+ * backward compatibility and behaves identically — it is used only when no
41
+ * `children` are provided.
42
+ */
25
43
  label?: React.ReactNode
26
44
  /**
27
45
  * Position of the checkbox control relative to the label.
@@ -71,13 +89,18 @@ export type CheckboxItemProps = {
71
89
  * ```tsx
72
90
  * const [checked, setChecked] = useState(false)
73
91
  *
92
+ * // Recommended: pass the label via the children slot.
74
93
  * <CheckboxItem
75
- * label="Fixed deposit • 0245"
76
94
  * checked={checked}
77
95
  * onValueChange={setChecked}
78
96
  * control="leading"
79
97
  * modes={{ 'Color Mode': 'Light' }}
80
- * />
98
+ * >
99
+ * Fixed deposit • 0245
100
+ * </CheckboxItem>
101
+ *
102
+ * // Still supported (deprecated): the `label` prop.
103
+ * <CheckboxItem label="Fixed deposit • 0245" />
81
104
  * ```
82
105
  */
83
106
  const CheckboxItem = forwardRef<View, CheckboxItemProps>(function CheckboxItem({
@@ -85,7 +108,8 @@ const CheckboxItem = forwardRef<View, CheckboxItemProps>(function CheckboxItem({
85
108
  defaultChecked = false,
86
109
  onValueChange,
87
110
  disabled = false,
88
- label = 'Fixed deposit • 0245',
111
+ children,
112
+ label,
89
113
  control = 'leading',
90
114
  endSlot,
91
115
  endSlotWidth = 80,
@@ -94,6 +118,20 @@ const CheckboxItem = forwardRef<View, CheckboxItemProps>(function CheckboxItem({
94
118
  labelStyle,
95
119
  accessibilityLabel,
96
120
  }: CheckboxItemProps, ref: React.Ref<View>) {
121
+ // Label slot resolution — the `children` slot is the primary API; the legacy
122
+ // `label` prop is a backward-compatible fallback. Precedence:
123
+ // 1. `children`, when provided (non-null / non-false).
124
+ // 2. otherwise the legacy `label` prop, whenever it was passed at all —
125
+ // including an explicit `null`/`false`, which (as before) hides the
126
+ // label entirely.
127
+ // 3. otherwise the Figma placeholder, so the default story still renders
128
+ // something meaningful.
129
+ const hasChildren = children != null && children !== false
130
+ const slotContent: React.ReactNode = hasChildren
131
+ ? children
132
+ : label !== undefined
133
+ ? label
134
+ : 'Fixed deposit • 0245'
97
135
  const isTrailing = control === 'trailing'
98
136
  const isControlled = controlledChecked !== undefined
99
137
  const [internalChecked, setInternalChecked] = useState(defaultChecked)
@@ -146,7 +184,7 @@ const CheckboxItem = forwardRef<View, CheckboxItemProps>(function CheckboxItem({
146
184
  }
147
185
 
148
186
  const a11yLabel =
149
- accessibilityLabel ?? (typeof label === 'string' ? label : undefined)
187
+ accessibilityLabel ?? (typeof slotContent === 'string' ? slotContent : undefined)
150
188
 
151
189
  const checkboxNode = (
152
190
  <Checkbox
@@ -158,16 +196,23 @@ const CheckboxItem = forwardRef<View, CheckboxItemProps>(function CheckboxItem({
158
196
  />
159
197
  )
160
198
 
199
+ // A plain string/number renders with the token-driven label style. Any other
200
+ // node is treated as custom slot content: it fills the label region and
201
+ // receives the parent `modes` (so nested design-system components theme
202
+ // correctly). An explicit null/false hides the label entirely (legacy
203
+ // behaviour of the `label` prop).
204
+ const isTextSlot =
205
+ typeof slotContent === 'string' || typeof slotContent === 'number'
161
206
  const labelNode =
162
- label != null && label !== false ? (
163
- typeof label === 'string' || typeof label === 'number' ? (
164
- <Text style={[resolvedLabelStyle, labelStyle]} selectable={false}>
165
- {label}
166
- </Text>
167
- ) : (
168
- <View style={{ flex: 1, minWidth: 0 }}>{label}</View>
169
- )
170
- ) : null
207
+ slotContent == null || slotContent === false ? null : isTextSlot ? (
208
+ <Text style={[resolvedLabelStyle, labelStyle]} selectable={false}>
209
+ {slotContent}
210
+ </Text>
211
+ ) : (
212
+ <View style={{ flex: 1, minWidth: 0 }}>
213
+ {cloneChildrenWithModes(slotContent, modes)}
214
+ </View>
215
+ )
171
216
 
172
217
  const endSlotNode = endSlot ? (
173
218
  <View
@@ -21,6 +21,11 @@ export type ChipSelectProps = {
21
21
  * @default "ic_calendar_week"
22
22
  */
23
23
  icon?: string;
24
+ /**
25
+ * Whether to show the close icon when active.
26
+ * @default true
27
+ */
28
+ showCloseIcon?: boolean;
24
29
  /**
25
30
  * Modes for design token resolution.
26
31
  */
@@ -47,6 +52,7 @@ function ChipSelect({
47
52
  label = 'Date',
48
53
  active = false,
49
54
  icon = 'ic_calendar_week',
55
+ showCloseIcon = true,
50
56
  modes = EMPTY_MODES,
51
57
  style,
52
58
  labelSlot,
@@ -123,7 +129,7 @@ function ChipSelect({
123
129
 
124
130
  {renderLabel()}
125
131
 
126
- {active && (
132
+ {active && showCloseIcon && (
127
133
  <Icon name="ic_close" size={iconSize} color={textColor} />
128
134
  )}
129
135
  </TouchableOpacity>
@@ -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
  }