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.
- package/CHANGELOG.md +13 -0
- package/lib/commonjs/components/CardCTA/CardCTA.js +32 -25
- package/lib/commonjs/components/CheckboxItem/CheckboxItem.js +31 -8
- package/lib/commonjs/components/ChipSelect/ChipSelect.js +2 -1
- package/lib/commonjs/components/ContentSheet/ContentSheet.js +131 -0
- 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 +27 -5
- package/lib/commonjs/components/MetricData/MetricData.js +132 -0
- package/lib/commonjs/components/Rating/Rating.js +137 -0
- 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 +55 -43
- package/lib/commonjs/icons/registry.js +1 -1
- package/lib/module/components/CardCTA/CardCTA.js +32 -25
- package/lib/module/components/CheckboxItem/CheckboxItem.js +31 -8
- package/lib/module/components/ChipSelect/ChipSelect.js +2 -1
- package/lib/module/components/ContentSheet/ContentSheet.js +126 -0
- 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 +27 -5
- package/lib/module/components/MetricData/MetricData.js +127 -0
- package/lib/module/components/Rating/Rating.js +132 -0
- 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 +55 -43
- package/lib/module/icons/registry.js +1 -1
- package/lib/typescript/src/components/CheckboxItem/CheckboxItem.d.ts +26 -3
- package/lib/typescript/src/components/ChipSelect/ChipSelect.d.ts +6 -1
- package/lib/typescript/src/components/ContentSheet/ContentSheet.d.ts +71 -0
- 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/MetricData/MetricData.d.ts +53 -0
- package/lib/typescript/src/components/Rating/Rating.d.ts +30 -0
- 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/CardCTA/CardCTA.tsx +30 -15
- package/src/components/CheckboxItem/CheckboxItem.tsx +59 -14
- package/src/components/ChipSelect/ChipSelect.tsx +7 -1
- package/src/components/ContentSheet/ContentSheet.tsx +217 -0
- 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 +26 -4
- package/src/components/MetricData/MetricData.tsx +185 -0
- package/src/components/Rating/Rating.tsx +174 -0
- 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 +55 -43
- 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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
)
|
|
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')
|
|
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
|
}
|