jfs-components 0.1.18 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/lib/commonjs/components/Card/Card.js +29 -12
  3. package/lib/commonjs/components/CardFeedback/CardFeedback.js +16 -2
  4. package/lib/commonjs/components/CardInsight/CardInsight.js +34 -13
  5. package/lib/commonjs/components/CardProviderInfo/CardProviderInfo.js +23 -3
  6. package/lib/commonjs/components/CheckboxItem/CheckboxItem.js +31 -8
  7. package/lib/commonjs/components/ContentSheet/ContentSheet.js +131 -0
  8. package/lib/commonjs/components/DebitCard/DebitCard.js +23 -3
  9. package/lib/commonjs/components/HeroSection/HeroSection.js +165 -0
  10. package/lib/commonjs/components/Image/Image.js +33 -7
  11. package/lib/commonjs/components/Link/Link.js +115 -0
  12. package/lib/commonjs/components/ListItem/ListItem.js +16 -1
  13. package/lib/commonjs/components/MediaCard/MediaCard.js +24 -7
  14. package/lib/commonjs/components/RechargeCard/RechargeCard.js +34 -13
  15. package/lib/commonjs/components/TestimonialsCard/TestimonialsCard.js +23 -5
  16. package/lib/commonjs/components/index.js +21 -0
  17. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  18. package/lib/commonjs/design-tokens/figma-modes.generated.js +53 -43
  19. package/lib/commonjs/icons/registry.js +1 -1
  20. package/lib/module/components/Card/Card.js +31 -14
  21. package/lib/module/components/CardFeedback/CardFeedback.js +17 -3
  22. package/lib/module/components/CardInsight/CardInsight.js +36 -15
  23. package/lib/module/components/CardProviderInfo/CardProviderInfo.js +25 -5
  24. package/lib/module/components/CheckboxItem/CheckboxItem.js +31 -8
  25. package/lib/module/components/ContentSheet/ContentSheet.js +126 -0
  26. package/lib/module/components/DebitCard/DebitCard.js +25 -5
  27. package/lib/module/components/HeroSection/HeroSection.js +159 -0
  28. package/lib/module/components/Image/Image.js +34 -7
  29. package/lib/module/components/Link/Link.js +110 -0
  30. package/lib/module/components/ListItem/ListItem.js +16 -1
  31. package/lib/module/components/MediaCard/MediaCard.js +26 -9
  32. package/lib/module/components/RechargeCard/RechargeCard.js +36 -15
  33. package/lib/module/components/TestimonialsCard/TestimonialsCard.js +25 -7
  34. package/lib/module/components/index.js +3 -0
  35. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  36. package/lib/module/design-tokens/figma-modes.generated.js +53 -43
  37. package/lib/module/icons/registry.js +1 -1
  38. package/lib/typescript/src/components/Card/Card.d.ts +10 -1
  39. package/lib/typescript/src/components/CardFeedback/CardFeedback.d.ts +10 -1
  40. package/lib/typescript/src/components/CardInsight/CardInsight.d.ts +10 -1
  41. package/lib/typescript/src/components/CardProviderInfo/CardProviderInfo.d.ts +10 -1
  42. package/lib/typescript/src/components/CheckboxItem/CheckboxItem.d.ts +26 -3
  43. package/lib/typescript/src/components/ContentSheet/ContentSheet.d.ts +71 -0
  44. package/lib/typescript/src/components/DebitCard/DebitCard.d.ts +10 -1
  45. package/lib/typescript/src/components/HeroSection/HeroSection.d.ts +80 -0
  46. package/lib/typescript/src/components/Image/Image.d.ts +27 -2
  47. package/lib/typescript/src/components/Link/Link.d.ts +73 -0
  48. package/lib/typescript/src/components/MediaCard/MediaCard.d.ts +10 -1
  49. package/lib/typescript/src/components/RechargeCard/RechargeCard.d.ts +10 -1
  50. package/lib/typescript/src/components/TestimonialsCard/TestimonialsCard.d.ts +8 -1
  51. package/lib/typescript/src/components/index.d.ts +3 -0
  52. package/lib/typescript/src/design-tokens/figma-modes.generated.d.ts +8 -0
  53. package/lib/typescript/src/icons/registry.d.ts +1 -1
  54. package/package.json +1 -1
  55. package/src/components/Card/Card.tsx +46 -15
  56. package/src/components/CardFeedback/CardFeedback.tsx +29 -4
  57. package/src/components/CardInsight/CardInsight.tsx +48 -17
  58. package/src/components/CardProviderInfo/CardProviderInfo.tsx +36 -3
  59. package/src/components/CheckboxItem/CheckboxItem.tsx +59 -14
  60. package/src/components/ContentSheet/ContentSheet.tsx +217 -0
  61. package/src/components/DebitCard/DebitCard.tsx +36 -3
  62. package/src/components/HeroSection/HeroSection.tsx +231 -0
  63. package/src/components/Image/Image.tsx +55 -3
  64. package/src/components/Link/Link.tsx +159 -0
  65. package/src/components/ListItem/ListItem.tsx +15 -0
  66. package/src/components/MediaCard/MediaCard.tsx +40 -9
  67. package/src/components/RechargeCard/RechargeCard.tsx +48 -13
  68. package/src/components/TestimonialsCard/TestimonialsCard.tsx +37 -6
  69. package/src/components/index.ts +3 -0
  70. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  71. package/src/design-tokens/figma-modes.generated.ts +53 -43
  72. package/src/icons/registry.ts +1 -1
@@ -1,5 +1,5 @@
1
1
  import React, { createContext, useContext, isValidElement, cloneElement, useMemo } from 'react';
2
- import { View, Text, StyleSheet, type ViewStyle, type TextStyle, type StyleProp } from 'react-native';
2
+ import { View, Text, Pressable, type ViewStyle, type TextStyle, type StyleProp } from 'react-native';
3
3
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
4
4
  import { EMPTY_MODES } from '../../utils/react-utils';
5
5
  import type { Modes } from '../../design-tokens'
@@ -39,6 +39,15 @@ export interface CardProps {
39
39
  * Style overrides for the card container.
40
40
  */
41
41
  style?: StyleProp<ViewStyle>;
42
+ /**
43
+ * Press handler for the whole card. When set, the card becomes pressable
44
+ * (rendered through a `Pressable` with `accessibilityRole="button"`).
45
+ */
46
+ onPress?: () => void;
47
+ /** Disable interaction when `onPress` is set. */
48
+ disabled?: boolean;
49
+ /** Accessibility label forwarded to the pressable wrapper. */
50
+ accessibilityLabel?: string;
42
51
  }
43
52
 
44
53
  /**
@@ -63,6 +72,9 @@ export function Card({
63
72
  modes = EMPTY_MODES,
64
73
  mediaAspectRatio = 154 / 116,
65
74
  style,
75
+ onPress,
76
+ disabled,
77
+ accessibilityLabel,
66
78
  }: CardProps) {
67
79
  // Resolve Card Container Tokens
68
80
  const backgroundColor = getVariableByName('card/background/color', modes);
@@ -124,23 +136,42 @@ export function Card({
124
136
  paddingVertical: contentPaddingVertical,
125
137
  };
126
138
 
139
+ const content = (
140
+ <>
141
+ {header && (
142
+ <View style={headerWrapperStyle}>
143
+ {headerWithModes}
144
+ </View>
145
+ )}
146
+ {media && (
147
+ <View style={mediaWrapperStyle}>
148
+ {mediaWithModes}
149
+ </View>
150
+ )}
151
+ <View style={contentWrapperStyle}>
152
+ {children}
153
+ </View>
154
+ </>
155
+ );
156
+
127
157
  return (
128
158
  <CardContext.Provider value={{ modes }}>
129
- <View style={[containerStyle, style]}>
130
- {header && (
131
- <View style={headerWrapperStyle}>
132
- {headerWithModes}
133
- </View>
134
- )}
135
- {media && (
136
- <View style={mediaWrapperStyle}>
137
- {mediaWithModes}
138
- </View>
139
- )}
140
- <View style={contentWrapperStyle}>
141
- {children}
159
+ {onPress ? (
160
+ <Pressable
161
+ style={[containerStyle, style]}
162
+ onPress={onPress}
163
+ disabled={disabled}
164
+ accessibilityRole="button"
165
+ accessibilityLabel={accessibilityLabel}
166
+ accessibilityState={{ disabled: !!disabled }}
167
+ >
168
+ {content}
169
+ </Pressable>
170
+ ) : (
171
+ <View style={[containerStyle, style]} accessibilityLabel={accessibilityLabel}>
172
+ {content}
142
173
  </View>
143
- </View>
174
+ )}
144
175
  </CardContext.Provider>
145
176
  );
146
177
  }
@@ -1,5 +1,5 @@
1
1
  import React, { createContext, useContext, isValidElement, cloneElement } from 'react';
2
- import { View, Text, StyleSheet, type ViewStyle, type TextStyle, type StyleProp, Image } from 'react-native';
2
+ import { View, Text, Pressable, type ViewStyle, type TextStyle, type StyleProp, Image } from 'react-native';
3
3
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
4
4
  import IconComponent from '../../icons/Icon';
5
5
  import type { Modes } from '../../design-tokens'
@@ -22,6 +22,15 @@ export interface CardFeedbackProps {
22
22
  * Style overrides for the card container.
23
23
  */
24
24
  style?: StyleProp<ViewStyle>;
25
+ /**
26
+ * Press handler for the whole card. When set, the card becomes pressable
27
+ * (rendered through a `Pressable` with `accessibilityRole="button"`).
28
+ */
29
+ onPress?: () => void;
30
+ /** Disable interaction when `onPress` is set. */
31
+ disabled?: boolean;
32
+ /** Accessibility label forwarded to the pressable wrapper. */
33
+ accessibilityLabel?: string;
25
34
  }
26
35
 
27
36
  /**
@@ -33,6 +42,9 @@ export function CardFeedback({
33
42
  children,
34
43
  modes: propModes,
35
44
  style,
45
+ onPress,
46
+ disabled,
47
+ accessibilityLabel,
36
48
  }: CardFeedbackProps) {
37
49
  const modes = {
38
50
  'Appearance.System': 'positive',
@@ -61,9 +73,22 @@ export function CardFeedback({
61
73
 
62
74
  return (
63
75
  <CardFeedbackContext.Provider value={{ modes }}>
64
- <View style={[containerStyle, style]}>
65
- {children}
66
- </View>
76
+ {onPress ? (
77
+ <Pressable
78
+ style={[containerStyle, style]}
79
+ onPress={onPress}
80
+ disabled={disabled}
81
+ accessibilityRole="button"
82
+ accessibilityLabel={accessibilityLabel}
83
+ accessibilityState={{ disabled: !!disabled }}
84
+ >
85
+ {children}
86
+ </Pressable>
87
+ ) : (
88
+ <View style={[containerStyle, style]} accessibilityLabel={accessibilityLabel}>
89
+ {children}
90
+ </View>
91
+ )}
67
92
  </CardFeedbackContext.Provider>
68
93
  );
69
94
  }
@@ -2,6 +2,7 @@ import React from 'react'
2
2
  import {
3
3
  View,
4
4
  Text,
5
+ Pressable,
5
6
  type StyleProp,
6
7
  type ViewStyle,
7
8
  type TextStyle,
@@ -45,6 +46,15 @@ export type CardInsightProps = {
45
46
  modes?: Modes
46
47
  /** Override container styles. */
47
48
  style?: StyleProp<ViewStyle>
49
+ /**
50
+ * Press handler for the whole card. When set, the card becomes pressable
51
+ * (rendered through a `Pressable` with `accessibilityRole="button"`).
52
+ */
53
+ onPress?: () => void
54
+ /** Disable interaction when `onPress` is set. */
55
+ disabled?: boolean
56
+ /** Accessibility label forwarded to the pressable wrapper (defaults to `title`). */
57
+ accessibilityLabel?: string
48
58
  }
49
59
 
50
60
  /**
@@ -67,6 +77,9 @@ function CardInsight({
67
77
  divider,
68
78
  modes = EMPTY_MODES,
69
79
  style,
80
+ onPress,
81
+ disabled,
82
+ accessibilityLabel,
70
83
  }: CardInsightProps) {
71
84
  const background =
72
85
  (getVariableByName('cardInsight/background', modes) as string | null) ?? '#ffffff'
@@ -160,23 +173,20 @@ function CardInsight({
160
173
  const footerNode = renderFooter()
161
174
  const showDivider = footerNode !== null && divider !== false && divider !== null
162
175
 
163
- return (
164
- <View
165
- style={[
166
- {
167
- backgroundColor: background,
168
- borderRadius: radius,
169
- borderWidth,
170
- borderColor,
171
- paddingHorizontal,
172
- paddingVertical,
173
- gap,
174
- overflow: 'hidden',
175
- alignItems: 'stretch',
176
- },
177
- style,
178
- ]}
179
- >
176
+ const containerStyle: ViewStyle = {
177
+ backgroundColor: background,
178
+ borderRadius: radius,
179
+ borderWidth,
180
+ borderColor,
181
+ paddingHorizontal,
182
+ paddingVertical,
183
+ gap,
184
+ overflow: 'hidden',
185
+ alignItems: 'stretch',
186
+ }
187
+
188
+ const content = (
189
+ <>
180
190
  <View
181
191
  style={{
182
192
  flexDirection: 'row',
@@ -237,6 +247,27 @@ function CardInsight({
237
247
  {showDivider ? renderDivider() : null}
238
248
 
239
249
  {footerNode}
250
+ </>
251
+ )
252
+
253
+ if (onPress) {
254
+ return (
255
+ <Pressable
256
+ style={[containerStyle, style]}
257
+ onPress={onPress}
258
+ disabled={disabled}
259
+ accessibilityRole="button"
260
+ accessibilityLabel={accessibilityLabel ?? title}
261
+ accessibilityState={{ disabled: !!disabled }}
262
+ >
263
+ {content}
264
+ </Pressable>
265
+ )
266
+ }
267
+
268
+ return (
269
+ <View style={[containerStyle, style]} accessibilityLabel={accessibilityLabel}>
270
+ {content}
240
271
  </View>
241
272
  )
242
273
  }
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { View, type ViewStyle, type StyleProp, type ImageSourcePropType } from 'react-native'
2
+ import { View, Pressable, type ViewStyle, type StyleProp, type ImageSourcePropType } from 'react-native'
3
3
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
4
4
  import ProductLabel from '../ProductLabel/ProductLabel'
5
5
  import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils'
@@ -16,6 +16,15 @@ export type CardProviderInfoProps = {
16
16
  modes?: Modes
17
17
  /** Override container styles. */
18
18
  style?: StyleProp<ViewStyle>
19
+ /**
20
+ * Press handler for the whole card. When set, the card becomes pressable
21
+ * (rendered through a `Pressable` with `accessibilityRole="button"`).
22
+ */
23
+ onPress?: () => void
24
+ /** Disable interaction when `onPress` is set. */
25
+ disabled?: boolean
26
+ /** Accessibility label forwarded to the pressable wrapper (defaults to `label`). */
27
+ accessibilityLabel?: string
19
28
  }
20
29
 
21
30
  /**
@@ -31,6 +40,9 @@ function CardProviderInfo({
31
40
  children,
32
41
  modes = EMPTY_MODES,
33
42
  style,
43
+ onPress,
44
+ disabled,
45
+ accessibilityLabel,
34
46
  }: CardProviderInfoProps) {
35
47
  const background = getVariableByName('card/providerInfo/background', modes) ?? '#fef4e5'
36
48
  const border = getVariableByName('card/providerInfo/border', modes) ?? '#fef4e5'
@@ -59,8 +71,8 @@ function CardProviderInfo({
59
71
  rows.push(childArray.slice(i, i + 2))
60
72
  }
61
73
 
62
- return (
63
- <View style={[containerStyle, style]}>
74
+ const content = (
75
+ <>
64
76
  <ProductLabel label={label} imageSource={imageSource} modes={modes} />
65
77
  {childArray.length > 0 && (
66
78
  <View style={{ rowGap: gridGapNum }}>
@@ -75,6 +87,27 @@ function CardProviderInfo({
75
87
  ))}
76
88
  </View>
77
89
  )}
90
+ </>
91
+ )
92
+
93
+ if (onPress) {
94
+ return (
95
+ <Pressable
96
+ style={[containerStyle, style]}
97
+ onPress={onPress}
98
+ disabled={disabled}
99
+ accessibilityRole="button"
100
+ accessibilityLabel={accessibilityLabel ?? label}
101
+ accessibilityState={{ disabled: !!disabled }}
102
+ >
103
+ {content}
104
+ </Pressable>
105
+ )
106
+ }
107
+
108
+ return (
109
+ <View style={[containerStyle, style]} accessibilityLabel={accessibilityLabel}>
110
+ {content}
78
111
  </View>
79
112
  )
80
113
  }
@@ -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
@@ -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