jfs-components 0.0.77 → 0.0.79

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 (87) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/lib/commonjs/components/Accordion/Accordion.js +55 -55
  3. package/lib/commonjs/components/ActionFooter/ActionFooter.js +48 -2
  4. package/lib/commonjs/components/Attached/Attached.js +144 -0
  5. package/lib/commonjs/components/Card/Card.js +25 -2
  6. package/lib/commonjs/components/Checkbox/Checkbox.js +21 -9
  7. package/lib/commonjs/components/DropdownInput/DropdownInput.js +30 -16
  8. package/lib/commonjs/components/ExpandableCheckbox/ExpandableCheckbox.js +167 -0
  9. package/lib/commonjs/components/FormField/FormField.js +14 -1
  10. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +353 -0
  11. package/lib/commonjs/components/ListItem/ListItem.js +46 -24
  12. package/lib/commonjs/components/MessageField/MessageField.js +318 -0
  13. package/lib/commonjs/components/NavArrow/NavArrow.js +58 -17
  14. package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +328 -0
  15. package/lib/commonjs/components/Slot/Slot.js +73 -0
  16. package/lib/commonjs/components/Stepper/Step.js +47 -60
  17. package/lib/commonjs/components/Stepper/StepLabel.js +40 -10
  18. package/lib/commonjs/components/Stepper/Stepper.js +15 -17
  19. package/lib/commonjs/components/SuggestiveSearch/SuggestiveSearch.js +487 -0
  20. package/lib/commonjs/components/TextInput/TextInput.js +16 -1
  21. package/lib/commonjs/components/Title/Title.js +10 -2
  22. package/lib/commonjs/components/index.js +49 -0
  23. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  24. package/lib/commonjs/icons/registry.js +1 -1
  25. package/lib/module/components/Accordion/Accordion.js +56 -56
  26. package/lib/module/components/ActionFooter/ActionFooter.js +50 -4
  27. package/lib/module/components/Attached/Attached.js +139 -0
  28. package/lib/module/components/Card/Card.js +25 -2
  29. package/lib/module/components/Checkbox/Checkbox.js +22 -10
  30. package/lib/module/components/DropdownInput/DropdownInput.js +30 -16
  31. package/lib/module/components/ExpandableCheckbox/ExpandableCheckbox.js +161 -0
  32. package/lib/module/components/FormField/FormField.js +16 -3
  33. package/lib/module/components/FullscreenModal/FullscreenModal.js +348 -0
  34. package/lib/module/components/ListItem/ListItem.js +46 -24
  35. package/lib/module/components/MessageField/MessageField.js +313 -0
  36. package/lib/module/components/NavArrow/NavArrow.js +59 -18
  37. package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +322 -0
  38. package/lib/module/components/Slot/Slot.js +68 -0
  39. package/lib/module/components/Stepper/Step.js +48 -61
  40. package/lib/module/components/Stepper/StepLabel.js +40 -10
  41. package/lib/module/components/Stepper/Stepper.js +15 -17
  42. package/lib/module/components/SuggestiveSearch/SuggestiveSearch.js +481 -0
  43. package/lib/module/components/TextInput/TextInput.js +17 -2
  44. package/lib/module/components/Title/Title.js +10 -2
  45. package/lib/module/components/index.js +7 -0
  46. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  47. package/lib/module/icons/registry.js +1 -1
  48. package/lib/typescript/src/components/Accordion/Accordion.d.ts +14 -20
  49. package/lib/typescript/src/components/Attached/Attached.d.ts +61 -0
  50. package/lib/typescript/src/components/Card/Card.d.ts +9 -2
  51. package/lib/typescript/src/components/ExpandableCheckbox/ExpandableCheckbox.d.ts +63 -0
  52. package/lib/typescript/src/components/FullscreenModal/FullscreenModal.d.ts +99 -0
  53. package/lib/typescript/src/components/ListItem/ListItem.d.ts +15 -5
  54. package/lib/typescript/src/components/MessageField/MessageField.d.ts +81 -0
  55. package/lib/typescript/src/components/NavArrow/NavArrow.d.ts +10 -5
  56. package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +64 -0
  57. package/lib/typescript/src/components/Slot/Slot.d.ts +52 -0
  58. package/lib/typescript/src/components/Stepper/Step.d.ts +4 -1
  59. package/lib/typescript/src/components/Stepper/StepLabel.d.ts +4 -1
  60. package/lib/typescript/src/components/Stepper/Stepper.d.ts +3 -1
  61. package/lib/typescript/src/components/SuggestiveSearch/SuggestiveSearch.d.ts +123 -0
  62. package/lib/typescript/src/components/index.d.ts +10 -3
  63. package/lib/typescript/src/icons/registry.d.ts +1 -1
  64. package/package.json +1 -1
  65. package/src/components/Accordion/Accordion.tsx +113 -73
  66. package/src/components/ActionFooter/ActionFooter.tsx +56 -4
  67. package/src/components/Attached/Attached.tsx +181 -0
  68. package/src/components/Card/Card.tsx +28 -1
  69. package/src/components/Checkbox/Checkbox.tsx +22 -9
  70. package/src/components/DropdownInput/DropdownInput.tsx +67 -39
  71. package/src/components/ExpandableCheckbox/ExpandableCheckbox.tsx +237 -0
  72. package/src/components/FormField/FormField.tsx +19 -3
  73. package/src/components/FullscreenModal/FullscreenModal.tsx +414 -0
  74. package/src/components/ListItem/ListItem.tsx +55 -25
  75. package/src/components/MessageField/MessageField.tsx +543 -0
  76. package/src/components/NavArrow/NavArrow.tsx +81 -17
  77. package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +426 -0
  78. package/src/components/Slot/Slot.tsx +91 -0
  79. package/src/components/Stepper/Step.tsx +52 -51
  80. package/src/components/Stepper/StepLabel.tsx +46 -9
  81. package/src/components/Stepper/Stepper.tsx +20 -15
  82. package/src/components/SuggestiveSearch/SuggestiveSearch.tsx +756 -0
  83. package/src/components/TextInput/TextInput.tsx +14 -1
  84. package/src/components/Title/Title.tsx +13 -2
  85. package/src/components/index.ts +10 -3
  86. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  87. package/src/icons/registry.ts +1 -1
@@ -0,0 +1,414 @@
1
+ import React, { useMemo } from 'react'
2
+ import {
3
+ View,
4
+ Text,
5
+ ScrollView,
6
+ type StyleProp,
7
+ type ViewStyle,
8
+ type TextStyle,
9
+ } from 'react-native'
10
+ import Animated, {
11
+ Extrapolation,
12
+ interpolate,
13
+ useAnimatedScrollHandler,
14
+ useAnimatedStyle,
15
+ useSharedValue,
16
+ } from 'react-native-reanimated'
17
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
18
+ import { useTokens } from '../../design-tokens/JFSThemeProvider'
19
+ import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils'
20
+ import Button from '../Button/Button'
21
+ import Disclaimer from '../Disclaimer/Disclaimer'
22
+ import IconButton from '../IconButton/IconButton'
23
+ import ActionFooter from '../ActionFooter/ActionFooter'
24
+ import Slot from '../Slot/Slot'
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Forced modes
28
+ //
29
+ // `FullscreenModal` always themes itself with the `context5: 'Fullscreen Modal'`
30
+ // collection mode. This is what flips the section / list-item / hero text
31
+ // tokens to their white-on-dark values (see the Figma "Fullscreen Modal"
32
+ // context). It is intentionally NON-overridable: callers can pass any other
33
+ // modes (Color Mode, AppearanceBrand, …) but never context5. The frozen
34
+ // object keeps its identity stable so the token resolver's per-modes cache
35
+ // stays hot, and so `cloneChildrenWithModes` can use it as the
36
+ // always-wins `forcedModes` argument.
37
+ // ---------------------------------------------------------------------------
38
+ const FULLSCREEN_MODAL_FORCED_MODES = Object.freeze({ context5: 'Fullscreen Modal' })
39
+
40
+ // Reanimated-driven ScrollView so the parallax handler runs on the UI thread.
41
+ // Module scope so the wrapped component identity is stable across renders.
42
+ const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView)
43
+
44
+ // Parallax tuning. The hero collapses by HEIGHT only as the user scrolls up —
45
+ // its full width is preserved and the media keeps a fixed aspect ratio (it is
46
+ // cropped, never scaled or squished, like a `cover` background). When no
47
+ // explicit `heroMinHeight` is given, the hero collapses to this fraction of
48
+ // its resting height.
49
+ const HERO_MIN_HEIGHT_RATIO = 0.45
50
+
51
+ export type FullscreenModalProps = {
52
+ /** Small eyebrow line above the headline. */
53
+ eyebrow?: string
54
+ /** Large hero headline. */
55
+ headline?: string
56
+ /** Supporting paragraph shown below the headline. */
57
+ supportingText?: string
58
+ /** Secondary line below the supporting paragraph (e.g. a price / timeline). */
59
+ priceText?: string
60
+ /**
61
+ * Media rendered full-bleed behind the hero text and driven by the parallax
62
+ * scroll effect. Bring any renderer — most commonly a `LottiePlayer`, but an
63
+ * `Image`, `Video`, or `SvgXml` works too. Size it to fill the hero box
64
+ * (`heroHeight` tall, full modal width) and let it `cover` so that as the
65
+ * hero collapses in height the art is cropped, never distorted. `modes` are
66
+ * cascaded into it.
67
+ */
68
+ heroMedia?: React.ReactNode
69
+ /** Resting height of the hero region. Defaults to 420. */
70
+ heroHeight?: number
71
+ /**
72
+ * Collapsed height the hero shrinks to at full scroll. Defaults to
73
+ * `heroHeight * 0.45`. Only the height changes — the width is always full.
74
+ */
75
+ heroMinHeight?: number
76
+ /** Enable the scroll-driven hero collapse. Defaults to true. */
77
+ parallax?: boolean
78
+ /** Whether to render the floating close button (top-right). Defaults to true. */
79
+ showClose?: boolean
80
+ /** Press handler for the close button. */
81
+ onClose?: () => void
82
+ /** Accessibility label for the close button. */
83
+ closeAccessibilityLabel?: string
84
+ /**
85
+ * Fully custom footer content rendered inside the sticky `ActionFooter`.
86
+ * When provided, `primaryActionLabel` / `disclaimer` are ignored.
87
+ */
88
+ footer?: React.ReactNode
89
+ /** Label for the default primary action button in the footer. */
90
+ primaryActionLabel?: string
91
+ /** Press handler for the default primary action button. */
92
+ onPrimaryAction?: () => void
93
+ /** Disclaimer text shown below the default primary action button. */
94
+ disclaimer?: string
95
+ /** Solid backdrop color for the scrollable body. Defaults to a near-black. */
96
+ backgroundColor?: string
97
+ /** Body content (typically `Section`s). `modes` are cascaded automatically. */
98
+ children?: React.ReactNode
99
+ /** Mode configuration. `context5` is always forced to `'Fullscreen Modal'`. */
100
+ modes?: Record<string, any>
101
+ /** Style overrides for the outer container. */
102
+ style?: StyleProp<ViewStyle>
103
+ /** Style overrides for the scroll body wrapper (the dark content area). */
104
+ contentContainerStyle?: StyleProp<ViewStyle>
105
+ testID?: string
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Hero text — the eyebrow / headline / supporting / price block. Built inline
110
+ // (rather than reusing <PageHero>) so we can render BOTH a supporting
111
+ // paragraph AND a price line with the exact PageHero token gaps, and overlay
112
+ // it on the parallax media without PageHero's media/button scaffolding.
113
+ // ---------------------------------------------------------------------------
114
+ type HeroTextProps = {
115
+ eyebrow?: string
116
+ headline?: string
117
+ supportingText?: string
118
+ priceText?: string
119
+ modes: Record<string, any>
120
+ }
121
+
122
+ function HeroText({ eyebrow, headline, supportingText, priceText, modes }: HeroTextProps) {
123
+ const styles = useMemo(() => {
124
+ const gap = Number(getVariableByName('PageHero/gap', modes)) || 16
125
+ const textWrapGap = Number(getVariableByName('PageHero/textWrap/gap', modes)) || 8
126
+
127
+ const eyebrowStyle: TextStyle = {
128
+ color: (getVariableByName('PageHero/eyebrow/color', modes) as string) || '#ffffff',
129
+ fontFamily: (getVariableByName('PageHero/eyebrow/fontFamily', modes) as string) || 'System',
130
+ fontSize: Number(getVariableByName('PageHero/eyebrow/fontSize', modes)) || 18,
131
+ fontWeight: String(getVariableByName('PageHero/eyebrow/fontWeight', modes) || 700) as TextStyle['fontWeight'],
132
+ lineHeight: Number(getVariableByName('PageHero/eyebrow/lineHeight', modes)) || 20,
133
+ textAlign: 'center',
134
+ }
135
+ const headlineStyle: TextStyle = {
136
+ color: (getVariableByName('PageHero/headline/color', modes) as string) || '#ffffff',
137
+ fontFamily: (getVariableByName('PageHero/headline/fontFamily', modes) as string) || 'System',
138
+ fontSize: Number(getVariableByName('PageHero/headline/fontSize', modes)) || 29,
139
+ fontWeight: String(getVariableByName('PageHero/headline/fontWeight', modes) || 900) as TextStyle['fontWeight'],
140
+ lineHeight: Number(getVariableByName('PageHero/headline/lineHeight', modes)) || 29,
141
+ textAlign: 'center',
142
+ width: '100%',
143
+ }
144
+ const supportingTextStyle: TextStyle = {
145
+ color: (getVariableByName('PageHero/supportingText/color', modes) as string) || '#ffffff',
146
+ fontFamily: (getVariableByName('PageHero/supportingText/fontFamily', modes) as string) || 'System',
147
+ fontSize: Number(getVariableByName('PageHero/supportingText/fontSize', modes)) || 12,
148
+ fontWeight: String(getVariableByName('PageHero/supportingText/fontWeight', modes) || 500) as TextStyle['fontWeight'],
149
+ lineHeight: Number(getVariableByName('PageHero/supportingText/lineHeight', modes)) || 16,
150
+ textAlign: 'center',
151
+ }
152
+ const priceTextStyle: TextStyle = {
153
+ color: (getVariableByName('PageHero/body/color', modes) as string) || '#ffffff',
154
+ fontFamily: (getVariableByName('PageHero/body/fontFamily', modes) as string) || 'System',
155
+ fontSize: Number(getVariableByName('PageHero/body/fontSize', modes)) || 12,
156
+ fontWeight: String(getVariableByName('PageHero/body/fontWeight', modes) || 500) as TextStyle['fontWeight'],
157
+ lineHeight: Number(getVariableByName('PageHero/body/lineHeight', modes)) || 16,
158
+ textAlign: 'center',
159
+ }
160
+
161
+ return {
162
+ container: { width: '100%', alignItems: 'center', gap } as ViewStyle,
163
+ textWrap: { width: '100%', alignItems: 'center', gap: textWrapGap } as ViewStyle,
164
+ eyebrowStyle,
165
+ headlineStyle,
166
+ supportingTextStyle,
167
+ priceTextStyle,
168
+ }
169
+ }, [modes])
170
+
171
+ return (
172
+ <View style={styles.container}>
173
+ <View style={styles.textWrap}>
174
+ {eyebrow ? <Text style={styles.eyebrowStyle}>{eyebrow}</Text> : null}
175
+ {headline ? <Text style={styles.headlineStyle}>{headline}</Text> : null}
176
+ </View>
177
+ {supportingText ? <Text style={styles.supportingTextStyle}>{supportingText}</Text> : null}
178
+ {priceText ? <Text style={styles.priceTextStyle}>{priceText}</Text> : null}
179
+ </View>
180
+ )
181
+ }
182
+
183
+ /**
184
+ * FullscreenModal — a full-screen takeover surface with a parallax media hero,
185
+ * a scrollable body, a floating close button, and a sticky `ActionFooter`.
186
+ *
187
+ * The component always themes itself with `context5: 'Fullscreen Modal'`
188
+ * (non-overridable) so every nested component (Section, ListItem, Button,
189
+ * Disclaimer, …) resolves the white-on-dark "fullscreen modal" token values.
190
+ * That mode is cascaded into `children`, the footer, and the hero text via
191
+ * `cloneChildrenWithModes` / the merged `modes` object.
192
+ *
193
+ * ### Parallax
194
+ * As the user scrolls up, the hero collapses by **height only** (from
195
+ * `heroHeight` to `heroMinHeight`) — its **full width is always preserved**.
196
+ * The `heroMedia` is pinned to the top at a fixed size and `cover`-cropped by
197
+ * the collapsing clip, so it keeps a perfect aspect ratio the whole time
198
+ * (never scaled or squished). Because it collapses slower than the content
199
+ * scrolls, the media lags behind for the parallax depth cue. Disable with
200
+ * `parallax={false}`.
201
+ *
202
+ * @component
203
+ * @example
204
+ * ```tsx
205
+ * <FullscreenModal
206
+ * eyebrow="Upgrade to JioFinance+"
207
+ * headline="Get more from your money."
208
+ * supportingText="JioFinance+ is your upgraded financial experience…"
209
+ * priceText="₹999/year · ₹0 until 2027"
210
+ * heroMedia={<LottiePlayer source={hero} size={{ width: 360, height: 420 }} />}
211
+ * primaryActionLabel="Upgrade for free"
212
+ * disclaimer="By upgrading, we'll check your eligibility with Experian."
213
+ * onPrimaryAction={() => upgrade()}
214
+ * onClose={() => navigation.goBack()}
215
+ * >
216
+ * <Section title="Key Benefits" slotDirection="column" slot={…} />
217
+ * <Section title="Compare plans" slotDirection="column" slot={…} />
218
+ * </FullscreenModal>
219
+ * ```
220
+ */
221
+ function FullscreenModal({
222
+ eyebrow = 'Upgrade to JioFinance+',
223
+ headline = 'Get more from your money.',
224
+ supportingText = 'JioFinance+ is your upgraded financial experience, designed to work harder in the background so your money works smarter in real life.',
225
+ priceText = '₹999/year · ₹0 until 2027',
226
+ heroMedia,
227
+ heroHeight = 420,
228
+ heroMinHeight,
229
+ parallax = true,
230
+ showClose = true,
231
+ onClose,
232
+ closeAccessibilityLabel = 'Close',
233
+ footer,
234
+ primaryActionLabel = 'Upgrade for free',
235
+ onPrimaryAction,
236
+ disclaimer = "By upgrading, we'll check your eligibility with Experian.",
237
+ backgroundColor = '#0f0d0a',
238
+ children,
239
+ modes: propModes = EMPTY_MODES,
240
+ style,
241
+ contentContainerStyle,
242
+ testID,
243
+ }: FullscreenModalProps) {
244
+ const { modes: globalModes } = useTokens()
245
+
246
+ // context5 is appended last so it always wins, regardless of what the
247
+ // caller (or the global theme) passes.
248
+ const modes = useMemo(
249
+ () => ({ ...globalModes, ...propModes, ...FULLSCREEN_MODAL_FORCED_MODES }),
250
+ [globalModes, propModes]
251
+ )
252
+
253
+ const rootGap = Number(getVariableByName('fullScreenModal/gap', modes)) || 16
254
+
255
+ const minHeight = heroMinHeight ?? Math.round(heroHeight * HERO_MIN_HEIGHT_RATIO)
256
+
257
+ const scrollY = useSharedValue(0)
258
+ const onScroll = useAnimatedScrollHandler((event) => {
259
+ scrollY.value = event.contentOffset.y
260
+ })
261
+
262
+ // Collapse the hero by HEIGHT only as the user scrolls up. The clip's width
263
+ // never changes and the media inside is pinned full-size at the top, so the
264
+ // art is cropped (cover) rather than scaled or narrowed — it keeps a perfect
265
+ // aspect ratio the whole time. Pull-down (negative offset) is clamped, so the
266
+ // hero never grows past its resting height.
267
+ const heroAnimatedStyle = useAnimatedStyle(() => {
268
+ const height = interpolate(
269
+ scrollY.value,
270
+ [0, heroHeight],
271
+ [heroHeight, minHeight],
272
+ Extrapolation.CLAMP
273
+ )
274
+ return { height }
275
+ })
276
+
277
+ const processedHeroMedia = useMemo(
278
+ () =>
279
+ heroMedia ? cloneChildrenWithModes(heroMedia, modes, FULLSCREEN_MODAL_FORCED_MODES) : null,
280
+ [heroMedia, modes]
281
+ )
282
+
283
+ const processedChildren = useMemo(
284
+ () =>
285
+ children ? cloneChildrenWithModes(children, modes, FULLSCREEN_MODAL_FORCED_MODES) : null,
286
+ [children, modes]
287
+ )
288
+
289
+ // The clip is full-width and top-pinned; its height is what animates. Width
290
+ // is intentionally never animated.
291
+ const heroClipBaseStyle = useMemo<ViewStyle>(
292
+ () => ({
293
+ position: 'absolute',
294
+ top: 0,
295
+ left: 0,
296
+ right: 0,
297
+ overflow: 'hidden',
298
+ }),
299
+ []
300
+ )
301
+
302
+ // The media sits at a fixed full-size box pinned to the top of the clip, so
303
+ // the collapsing clip crops it from the bottom (cover) instead of resizing
304
+ // it. Full width, fixed height — a perfect, constant aspect ratio.
305
+ const heroMediaWrapStyle = useMemo<ViewStyle>(
306
+ () => ({
307
+ position: 'absolute',
308
+ top: 0,
309
+ left: 0,
310
+ right: 0,
311
+ height: heroHeight,
312
+ alignItems: 'stretch',
313
+ }),
314
+ [heroHeight]
315
+ )
316
+
317
+ const heroTextRegionStyle = useMemo<ViewStyle>(
318
+ () => ({
319
+ height: heroHeight,
320
+ justifyContent: 'flex-end',
321
+ paddingHorizontal: 16,
322
+ paddingBottom: 16,
323
+ }),
324
+ [heroHeight]
325
+ )
326
+
327
+ const bodyStyle = useMemo<StyleProp<ViewStyle>>(
328
+ () => [
329
+ {
330
+ backgroundColor,
331
+ gap: rootGap,
332
+ paddingTop: rootGap,
333
+ paddingBottom: 24,
334
+ },
335
+ contentContainerStyle,
336
+ ],
337
+ [backgroundColor, rootGap, contentContainerStyle]
338
+ )
339
+
340
+ const heroClip = (
341
+ <Animated.View
342
+ style={[heroClipBaseStyle, parallax ? heroAnimatedStyle : { height: heroHeight }]}
343
+ pointerEvents="none"
344
+ >
345
+ <View style={heroMediaWrapStyle}>{processedHeroMedia}</View>
346
+ </Animated.View>
347
+ )
348
+
349
+ // Footer: a fully custom node, or the default Button + Disclaimer column.
350
+ let footerContent: React.ReactNode = null
351
+ if (footer) {
352
+ footerContent = footer
353
+ } else if (primaryActionLabel) {
354
+ footerContent = (
355
+ <Slot layoutDirection="vertical" modes={modes}>
356
+ <Button
357
+ label={primaryActionLabel}
358
+ modes={modes}
359
+ style={fullWidthStyle}
360
+ {...(onPrimaryAction ? { onPress: onPrimaryAction } : {})}
361
+ />
362
+ {disclaimer ? <Disclaimer disclaimer={disclaimer} modes={modes} /> : null}
363
+ </Slot>
364
+ )
365
+ }
366
+
367
+ return (
368
+ <View style={[rootStyle, { backgroundColor }, style]} testID={testID}>
369
+ {processedHeroMedia ? heroClip : null}
370
+
371
+ <AnimatedScrollView
372
+ style={scrollViewStyle}
373
+ contentContainerStyle={scrollContentStyle}
374
+ showsVerticalScrollIndicator={false}
375
+ onScroll={onScroll}
376
+ scrollEventThrottle={16}
377
+ >
378
+ <View style={heroTextRegionStyle}>
379
+ <HeroText
380
+ eyebrow={eyebrow}
381
+ headline={headline}
382
+ supportingText={supportingText}
383
+ priceText={priceText}
384
+ modes={modes}
385
+ />
386
+ </View>
387
+ <View style={bodyStyle}>{processedChildren}</View>
388
+ </AnimatedScrollView>
389
+
390
+ {footerContent ? (
391
+ <ActionFooter modes={modes}>{footerContent}</ActionFooter>
392
+ ) : null}
393
+
394
+ {showClose ? (
395
+ <IconButton
396
+ iconName="ic_close"
397
+ modes={modes}
398
+ accessibilityLabel={closeAccessibilityLabel}
399
+ style={closeButtonStyle}
400
+ {...(onClose ? { onPress: onClose } : {})}
401
+ />
402
+ ) : null}
403
+ </View>
404
+ )
405
+ }
406
+
407
+ // Module-scope style constants — never re-allocated per render.
408
+ const rootStyle: ViewStyle = { flex: 1, width: '100%', position: 'relative' }
409
+ const scrollViewStyle: ViewStyle = { flex: 1 }
410
+ const scrollContentStyle: ViewStyle = { flexGrow: 1 }
411
+ const fullWidthStyle: ViewStyle = { width: '100%' }
412
+ const closeButtonStyle: ViewStyle = { position: 'absolute', top: 12, right: 12 }
413
+
414
+ export default FullscreenModal
@@ -21,8 +21,16 @@ type ListItemProps = {
21
21
  title?: string;
22
22
  supportText?: string;
23
23
  showSupportText?: boolean;
24
+ /** Leading slot (Figma "leading"). Defaults to an `IconCapsule` when omitted. */
24
25
  leading?: React.ReactNode;
25
26
  supportSlot?: React.ReactNode;
27
+ /** Trailing slot (Figma "trailing"), e.g. `MoneyValue` or `Button`. Horizontal layout only. */
28
+ trailing?: React.ReactNode;
29
+ /**
30
+ * @deprecated Renamed to `trailing` for a symmetric `leading` / `trailing`
31
+ * slot API. Still honored for backward compatibility; `trailing` wins when
32
+ * both are provided. Will be removed in a future major version.
33
+ */
26
34
  endSlot?: React.ReactNode;
27
35
  /** Whether to show the NavArrow on the far right (Horizontal layout only). Defaults to true. */
28
36
  navArrow?: boolean;
@@ -46,9 +54,10 @@ type ListItemProps = {
46
54
  const IS_IOS = Platform.OS === 'ios'
47
55
  const PRESS_DELAY = IS_IOS ? 130 : 0
48
56
 
49
- // Forced modes for the endSlot — `Context: 'ListItem'` can never be
50
- // overridden by external modes. Frozen so identity is stable across renders.
51
- const END_SLOT_FORCED_MODES = Object.freeze({ Context: 'ListItem' })
57
+ // Forced modes for the leading/trailing slots — `Context: 'ListItem'` can
58
+ // never be overridden by external modes. Frozen so identity is stable across
59
+ // renders. Applied to both slots so they cascade modes identically.
60
+ const SLOT_FORCED_MODES = Object.freeze({ Context: 'ListItem' })
52
61
 
53
62
  // Pressed visual is applied on the host view through Pressable's style
54
63
  // callback, so a scroll-cancelled touch never schedules a React render.
@@ -72,8 +81,19 @@ interface ListItemTokens {
72
81
  }
73
82
 
74
83
  function resolveListItemTokens(modes: Record<string, any>): ListItemTokens {
84
+ // Modes used to cascade into slot children (leading / supportSlot / trailing).
85
+ // We do NOT inject an `AppearanceBrand` default here: slot content such as
86
+ // Buttons or Badges carry their own intended appearance, so forcing one onto
87
+ // them would be surprising.
75
88
  const resolvedModes = { ...modes, Context: 'ListItem' }
76
89
 
90
+ // Modes used to resolve the ListItem's OWN title + support text. Within this
91
+ // component, `AppearanceBrand` only affects `listItem/title/color` and
92
+ // `listItem/supportText/color`, so the text defaults to the "Neutral"
93
+ // appearance (in both Vertical and Horizontal layouts). A caller-supplied
94
+ // `AppearanceBrand` still wins; `Context` is always forced to 'ListItem'.
95
+ const textModes = { AppearanceBrand: 'Neutral', ...modes, Context: 'ListItem' }
96
+
77
97
  const gap = (getVariableByName('listItem/gap', resolvedModes) ?? 8) as number
78
98
  const paddingTop = getVariableByName('listItem/padding/top', resolvedModes) ?? 0
79
99
  const paddingBottom = getVariableByName('listItem/padding/bottom', resolvedModes) ?? 0
@@ -81,19 +101,19 @@ function resolveListItemTokens(modes: Record<string, any>): ListItemTokens {
81
101
  const paddingRight = getVariableByName('listItem/padding/right', resolvedModes) ?? 0
82
102
  const textWrapGap = (getVariableByName('listItem/text wrap', resolvedModes) ?? 0) as number
83
103
 
84
- const titleColor = getVariableByName('listItem/title/color', resolvedModes) || '#0f0d0a'
85
- const titleFontSize = getVariableByName('listItem/title/fontSize', resolvedModes) || 14
86
- const titleLineHeight = getVariableByName('listItem/title/lineHeight', resolvedModes) || 16
87
- const titleFontFamily = getVariableByName('listItem/title/fontFamily', resolvedModes) || 'System'
88
- const titleFontWeightRaw = getVariableByName('listItem/title/fontWeight', resolvedModes) || 700
104
+ const titleColor = getVariableByName('listItem/title/color', textModes) || '#0f0d0a'
105
+ const titleFontSize = getVariableByName('listItem/title/fontSize', textModes) || 14
106
+ const titleLineHeight = getVariableByName('listItem/title/lineHeight', textModes) || 16
107
+ const titleFontFamily = getVariableByName('listItem/title/fontFamily', textModes) || 'System'
108
+ const titleFontWeightRaw = getVariableByName('listItem/title/fontWeight', textModes) || 700
89
109
  const titleFontWeight =
90
110
  typeof titleFontWeightRaw === 'number' ? titleFontWeightRaw.toString() : titleFontWeightRaw
91
111
 
92
- const supportColor = getVariableByName('listItem/supportText/color', resolvedModes) || '#1f1a14'
93
- const supportFontSize = getVariableByName('listItem/supportText/fontSize', resolvedModes) || 12
94
- const supportLineHeight = getVariableByName('listItem/supportText/lineHeight', resolvedModes) || 14
95
- const supportFontFamily = getVariableByName('listItem/supportText/fontFamily', resolvedModes) || 'System'
96
- const supportFontWeightRaw = getVariableByName('listItem/supportText/fontWeight', resolvedModes) || 500
112
+ const supportColor = getVariableByName('listItem/supportText/color', textModes) || '#1f1a14'
113
+ const supportFontSize = getVariableByName('listItem/supportText/fontSize', textModes) || 12
114
+ const supportLineHeight = getVariableByName('listItem/supportText/lineHeight', textModes) || 14
115
+ const supportFontFamily = getVariableByName('listItem/supportText/fontFamily', textModes) || 'System'
116
+ const supportFontWeightRaw = getVariableByName('listItem/supportText/fontWeight', textModes) || 500
97
117
  const supportFontWeight =
98
118
  typeof supportFontWeightRaw === 'number' ? supportFontWeightRaw.toString() : supportFontWeightRaw
99
119
 
@@ -156,9 +176,11 @@ const verticalSupportTextOverride: TextStyle = { textAlign: 'center' }
156
176
  * - **design-token driven styling** via `getVariableByName` and `modes`
157
177
  *
158
178
  * Wherever the Figma layer name contains "Slot", this component exposes a
159
- * dedicated React "slot" prop:
179
+ * dedicated React "slot" prop. The leading and trailing edges share a
180
+ * symmetric `leading` / `trailing` slot API:
181
+ * - Slot "leading" → `leading`
160
182
  * - Slot "support text" → `supportSlot`
161
- * - Slot "end" → `endSlot`
183
+ * - Slot "trailing" → `trailing`
162
184
  *
163
185
  * @component
164
186
  * @param {Object} props
@@ -166,9 +188,9 @@ const verticalSupportTextOverride: TextStyle = { textAlign: 'center' }
166
188
  * @param {string} [props.title='Title'] - Primary title used in the horizontal layout.
167
189
  * @param {string} [props.supportText='Support Text'] - Support text used in both layouts when `supportSlot` is not provided.
168
190
  * @param {boolean} [props.showSupportText=true] - Toggles rendering of the support text in Horizontal layout.
169
- * @param {React.ReactNode} [props.leading] - Optional leading element. Defaults to `IconCapsule`.
191
+ * @param {React.ReactNode} [props.leading] - Optional leading slot. Defaults to `IconCapsule`.
170
192
  * @param {React.ReactNode} [props.supportSlot] - Optional custom slot used instead of the default support text block.
171
- * @param {React.ReactNode} [props.endSlot] - Optional custom trailing slot (Figma Slot "end").
193
+ * @param {React.ReactNode} [props.trailing] - Optional trailing slot (Figma Slot "trailing"). Horizontal layout only.
172
194
  * @param {boolean} [props.navArrow=true] - Whether to show NavArrow on the far right (Horizontal layout only).
173
195
  * @param {Object} [props.modes={}] - Modes object passed to `getVariableByName` for all design tokens.
174
196
  * @param {Function} [props.onPress] - When provided, the entire item becomes pressable (navigation variant).
@@ -197,6 +219,7 @@ function ListItemImpl({
197
219
  showSupportText = true,
198
220
  leading,
199
221
  supportSlot,
222
+ trailing,
200
223
  endSlot,
201
224
  navArrow = true,
202
225
  modes = EMPTY_MODES,
@@ -241,7 +264,11 @@ function ListItemImpl({
241
264
  // (leading, resolvedModes) so a parent re-render doesn't re-walk the tree.
242
265
  const leadingElement = useMemo(() => {
243
266
  const processed = leading
244
- ? cloneChildrenWithModes(React.Children.toArray(leading), tokens.resolvedModes)
267
+ ? cloneChildrenWithModes(
268
+ React.Children.toArray(leading),
269
+ tokens.resolvedModes,
270
+ SLOT_FORCED_MODES
271
+ )
245
272
  : []
246
273
  if (processed.length === 0) {
247
274
  return <IconCapsule modes={tokens.resolvedModes} accessibilityLabel={undefined} />
@@ -258,15 +285,18 @@ function ListItemImpl({
258
285
  return processed.length === 1 ? processed[0] : processed
259
286
  }, [supportSlot, tokens.resolvedModes])
260
287
 
261
- const processedEndSlot = useMemo(() => {
262
- if (!endSlot) return null
288
+ // `trailing` wins; `endSlot` is the deprecated alias kept for back-compat.
289
+ const trailingContent = trailing ?? endSlot
290
+
291
+ const processedTrailing = useMemo(() => {
292
+ if (!trailingContent) return null
263
293
  const processed = cloneChildrenWithModes(
264
- React.Children.toArray(endSlot),
294
+ React.Children.toArray(trailingContent),
265
295
  tokens.resolvedModes,
266
- END_SLOT_FORCED_MODES
296
+ SLOT_FORCED_MODES
267
297
  )
268
298
  return processed.length === 1 ? processed[0] : processed
269
- }, [endSlot, tokens.resolvedModes])
299
+ }, [trailingContent, tokens.resolvedModes])
270
300
 
271
301
  const renderSupportContent = () => {
272
302
  if (processedSupportSlot) return processedSupportSlot
@@ -359,8 +389,8 @@ function ListItemImpl({
359
389
  </Text>
360
390
  {showSupportText && renderSupportContent()}
361
391
  </View>
362
- {processedEndSlot ? (
363
- <View style={tokens.trailingWrapperStyle}>{processedEndSlot}</View>
392
+ {processedTrailing ? (
393
+ <View style={tokens.trailingWrapperStyle}>{processedTrailing}</View>
364
394
  ) : null}
365
395
  {navArrow && <NavArrow direction="Forward" modes={tokens.resolvedModes} />}
366
396
  </View>