jfs-components 0.0.86 → 0.0.95

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.
@@ -10,18 +10,21 @@ export type FullscreenModalProps = {
10
10
  /** Secondary line below the supporting paragraph (e.g. a price / timeline). */
11
11
  priceText?: string;
12
12
  /**
13
- * Background media rendered full-bleed behind the hero text. Bring any
14
- * renderer most commonly an `Image`, but a `LottiePlayer`, `Video`, or
15
- * `SvgXml` works too. It is laid out at the full modal width; size it with an
16
- * aspect ratio (e.g. `<Image ratio={3 / 4} />`) so its height follows the
17
- * width naturally. The media scrolls together with the rest of the content
18
- * (no parallax). `modes` are cascaded into it.
13
+ * Full-bleed background media for the whole modal. It is pinned to the top
14
+ * and laid out at the full modal width; size it with an aspect ratio
15
+ * (e.g. `<Image ratio={1080 / 4140} />`) so its height follows the width
16
+ * naturally. It renders as a single continuous background BEHIND both the
17
+ * hero text and the body content there is no separate body box stacked on
18
+ * top of it. Bring any renderer most commonly an `Image`, but a
19
+ * `LottiePlayer`, `Video`, or `SvgXml` works too. It never intercepts
20
+ * touches and the foreground content scrolls over it (no parallax).
21
+ * `modes` are cascaded into it.
19
22
  */
20
23
  heroMedia?: React.ReactNode;
21
24
  /**
22
- * Fallback height for the hero text region when no `heroMedia` is provided.
23
- * When `heroMedia` is present, the hero height is driven entirely by the
24
- * media's own aspect ratio and this value is ignored. Defaults to 420.
25
+ * Height reserved for the hero text region (eyebrow / headline / supporting
26
+ * / price), whose content is anchored to the bottom. Applies whether or not
27
+ * `heroMedia` is present. Defaults to 420.
25
28
  */
26
29
  heroHeight?: number;
27
30
  /** Whether to render the floating close button (top-right). Defaults to true. */
@@ -41,15 +44,17 @@ export type FullscreenModalProps = {
41
44
  onPrimaryAction?: () => void;
42
45
  /** Disclaimer text shown below the default primary action button. */
43
46
  disclaimer?: string;
44
- /** Solid backdrop color for the scrollable body. Defaults to a near-black. */
45
- backgroundColor?: string;
46
47
  /** Body content (typically `Section`s). `modes` are cascaded automatically. */
47
48
  children?: React.ReactNode;
48
- /** Mode configuration. `context5` is always forced to `'Fullscreen Modal'`. */
49
+ /**
50
+ * Mode configuration. `Page type` defaults to `'JioPlus'` (overridable here),
51
+ * and `context5` is always forced to `'Fullscreen Modal'` (non-overridable).
52
+ * The resolved modes cascade to the body, hero media, and the `ActionFooter`.
53
+ */
49
54
  modes?: Record<string, any>;
50
55
  /** Style overrides for the outer container. */
51
56
  style?: StyleProp<ViewStyle>;
52
- /** Style overrides for the scroll body wrapper (the dark content area). */
57
+ /** Style overrides for the transparent body wrapper. */
53
58
  contentContainerStyle?: StyleProp<ViewStyle>;
54
59
  testID?: string;
55
60
  };
@@ -64,12 +69,21 @@ export type FullscreenModalProps = {
64
69
  * That mode is cascaded into `children`, the footer, and the hero text via
65
70
  * `cloneChildrenWithModes` / the merged `modes` object.
66
71
  *
67
- * ### Hero
68
- * The `heroMedia` is rendered full modal width inside the scroll body and
69
- * takes its height from its own aspect ratio. The hero text (eyebrow /
70
- * headline / supporting / price) is overlaid on top, anchored to the bottom.
71
- * The whole hero scrolls together with the rest of the content there is no
72
- * parallax effect.
72
+ * ### Background media
73
+ * The `heroMedia` is a single full-bleed background pinned to the top of the
74
+ * modal at the full width and its own natural aspect ratio. It lives at the
75
+ * ROOT behind both the scrolling content and the (transparent) footer so
76
+ * it fills the whole surface and is NEVER clipped to the content height. It
77
+ * also contributes ZERO scroll height: the scroll extent is driven purely by
78
+ * the in-flow foreground (hero text + `children`), so the number of body
79
+ * elements dictates how far the surface scrolls. It still scrolls in lockstep
80
+ * WITH the content (the background is translated by the scroll offset), so the
81
+ * content reads as sitting ON one continuous image that moves with it — there
82
+ * is no parallax and no separate solid body box.
83
+ *
84
+ * Pass a background sized to the full width at its natural ratio
85
+ * (e.g. `<Image imageSource={bg} ratio={1080 / 4140} />`). Use an asset at
86
+ * least as tall as the surface so it covers the full modal.
73
87
  *
74
88
  * @component
75
89
  * @example
@@ -79,7 +93,7 @@ export type FullscreenModalProps = {
79
93
  * headline="Get more from your money."
80
94
  * supportingText="JioFinance+ is your upgraded financial experience…"
81
95
  * priceText="₹999/year · ₹0 until 2027"
82
- * heroMedia={<Image imageSource={hero} ratio={3 / 4} />}
96
+ * heroMedia={<Image imageSource={hero} ratio={1080 / 4140} />}
83
97
  * primaryActionLabel="Upgrade for free"
84
98
  * disclaimer="By upgrading, we'll check your eligibility with Experian."
85
99
  * onPrimaryAction={() => upgrade()}
@@ -90,6 +104,6 @@ export type FullscreenModalProps = {
90
104
  * </FullscreenModal>
91
105
  * ```
92
106
  */
93
- declare function FullscreenModal({ eyebrow, headline, supportingText, priceText, heroMedia, heroHeight, showClose, onClose, closeAccessibilityLabel, footer, primaryActionLabel, onPrimaryAction, disclaimer, backgroundColor, children, modes: propModes, style, contentContainerStyle, testID, }: FullscreenModalProps): import("react/jsx-runtime").JSX.Element;
107
+ declare function FullscreenModal({ eyebrow, headline, supportingText, priceText, heroMedia, heroHeight, showClose, onClose, closeAccessibilityLabel, footer, primaryActionLabel, onPrimaryAction, disclaimer, children, modes: propModes, style, contentContainerStyle, testID, }: FullscreenModalProps): import("react/jsx-runtime").JSX.Element;
94
108
  export default FullscreenModal;
95
109
  //# sourceMappingURL=FullscreenModal.d.ts.map
@@ -0,0 +1,75 @@
1
+ import React from 'react';
2
+ import { type AccessibilityProps, type StyleProp, type ViewStyle } from 'react-native';
3
+ import type { UnifiedSource } from '../../utils/MediaSource';
4
+ type IconProps = AccessibilityProps & {
5
+ /**
6
+ * Built-in icon name from the registry, in the `ic_something` format
7
+ * (e.g. `'ic_card'`, `'ic_scan_qr_code'`). When omitted and no `source` or
8
+ * `children` slot is supplied, defaults to `'ic_card'` to match the Figma
9
+ * design's default glyph.
10
+ */
11
+ iconName?: string;
12
+ /**
13
+ * Unified fallback source rendered when `iconName` is missing or not in the
14
+ * registry. Accepts a remote URI, an inline SVG XML string, a `require()`
15
+ * asset, an SVG React component, or an already-rendered element. The media
16
+ * is tinted with the mode-resolved icon color so it follows design tokens
17
+ * just like a built-in icon. See {@link UnifiedSource}.
18
+ */
19
+ source?: UnifiedSource;
20
+ /**
21
+ * Icon slot. Render any node here (another `Icon`, a custom SVG component,
22
+ * etc.) and it takes precedence over `iconName`/`source`. `modes` cascade
23
+ * into the slotted children automatically.
24
+ */
25
+ children?: React.ReactNode;
26
+ /**
27
+ * Override the mode-resolved icon color. When omitted the value comes from
28
+ * the `icon/color` design token.
29
+ */
30
+ color?: string;
31
+ /**
32
+ * Override the mode-resolved icon size (in px). When omitted the value comes
33
+ * from the `icon/size` design token.
34
+ */
35
+ size?: number;
36
+ modes?: Record<string, any>;
37
+ style?: StyleProp<ViewStyle>;
38
+ };
39
+ /**
40
+ * Icon component — a design-token-driven wrapper around a single glyph.
41
+ *
42
+ * It mirrors the Figma "Icon" component: a padded, centered container whose
43
+ * color and size are resolved from the `icon/*` design tokens via `modes`.
44
+ * The glyph itself can be supplied three ways, in order of precedence:
45
+ *
46
+ * 1. `children` — a real slot for any node (custom SVG component, nested
47
+ * `Icon`, etc.). `modes` cascade into the slot automatically.
48
+ * 2. `iconName` — a registry icon in the `ic_something` format.
49
+ * 3. `source` — a {@link UnifiedSource} fallback (remote URI, inline SVG XML,
50
+ * `require()` asset, SVG component, or React element), tinted with the
51
+ * mode-resolved icon color.
52
+ *
53
+ * `color` and `size` props let consumers override the token values per
54
+ * instance without touching `modes`.
55
+ *
56
+ * @example
57
+ * ```tsx
58
+ * // Built-in registry icon (default path).
59
+ * <Icon iconName="ic_card" modes={{ 'Color Mode': 'Light' }} />
60
+ *
61
+ * // Per-instance overrides.
62
+ * <Icon iconName="ic_ccv" color="#5c00b5" size={24} />
63
+ *
64
+ * // Fallback to an external source when the name isn't in the registry.
65
+ * <Icon source="https://cdn.example.com/glyph.svg" />
66
+ *
67
+ * // Slot: render any node as the icon.
68
+ * <Icon><BrandLogo /></Icon>
69
+ * ```
70
+ */
71
+ declare function Icon({ iconName, source, children, color, size, modes: propModes, style: styleProp, ...rest }: IconProps): import("react/jsx-runtime").JSX.Element;
72
+ declare const _default: React.MemoExoticComponent<typeof Icon>;
73
+ export default _default;
74
+ export type { IconProps };
75
+ //# sourceMappingURL=Icon.d.ts.map
@@ -43,6 +43,7 @@ export { default as MonthlyStatusGrid, CalendarGlyph, type MonthlyStatusGridProp
43
43
  export { default as Gauge, type GaugeProps } from './Gauge/Gauge';
44
44
  export { default as HoldingsCard, type HoldingsCardProps } from './HoldingsCard/HoldingsCard';
45
45
  export { default as HStack, type HStackProps } from './HStack/HStack';
46
+ export { default as Icon, type IconProps } from './Icon/Icon';
46
47
  export { default as IconButton } from './IconButton/IconButton';
47
48
  export { default as IconCapsule } from './IconCapsule/IconCapsule';
48
49
  export { default as Image, type ImageProps } from './Image/Image';
@@ -4,7 +4,7 @@
4
4
  * Auto-generated from SVG files in src/icons/
5
5
  * DO NOT EDIT MANUALLY - Run "npm run icons:generate" to regenerate
6
6
  *
7
- * Generated: 2026-06-03T15:59:02.370Z
7
+ * Generated: 2026-06-04T14:40:09.533Z
8
8
  */
9
9
  export declare const iconRegistry: Record<string, {
10
10
  path: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jfs-components",
3
- "version": "0.0.86",
3
+ "version": "0.0.95",
4
4
  "description": "React Native Jio Finance Components Library",
5
5
  "author": "sunshuaiqi@gmail.com",
6
6
  "license": "MIT",
@@ -0,0 +1,24 @@
1
+ declare module '*.png' {
2
+ const value: string
3
+ export default value
4
+ }
5
+
6
+ declare module '*.jpg' {
7
+ const value: string
8
+ export default value
9
+ }
10
+
11
+ declare module '*.jpeg' {
12
+ const value: string
13
+ export default value
14
+ }
15
+
16
+ declare module '*.gif' {
17
+ const value: string
18
+ export default value
19
+ }
20
+
21
+ declare module '*.webp' {
22
+ const value: string
23
+ export default value
24
+ }
@@ -1,9 +1,8 @@
1
- import React, { useMemo } from 'react'
1
+ import React, { useMemo, useRef } from 'react'
2
2
  import {
3
3
  View,
4
4
  Text,
5
- ScrollView,
6
- StyleSheet,
5
+ Animated,
7
6
  type StyleProp,
8
7
  type ViewStyle,
9
8
  type TextStyle,
@@ -31,6 +30,17 @@ import Slot from '../Slot/Slot'
31
30
  // ---------------------------------------------------------------------------
32
31
  const FULLSCREEN_MODAL_FORCED_MODES = Object.freeze({ context5: 'Fullscreen Modal' })
33
32
 
33
+ // ---------------------------------------------------------------------------
34
+ // Default modes
35
+ //
36
+ // A FullscreenModal is a "JioPlus" surface, so it defaults the `Page type`
37
+ // collection to `'JioPlus'`. Unlike the forced modes above this IS
38
+ // overridable — it is applied before the caller's `modes`, so passing
39
+ // `modes={{ 'Page type': 'SubPage' }}` still wins. Frozen for stable identity
40
+ // (keeps the token resolver's per-modes cache hot).
41
+ // ---------------------------------------------------------------------------
42
+ const FULLSCREEN_MODAL_DEFAULT_MODES = Object.freeze({ 'Page type': 'JioPlus' })
43
+
34
44
  export type FullscreenModalProps = {
35
45
  /** Small eyebrow line above the headline. */
36
46
  eyebrow?: string
@@ -41,18 +51,21 @@ export type FullscreenModalProps = {
41
51
  /** Secondary line below the supporting paragraph (e.g. a price / timeline). */
42
52
  priceText?: string
43
53
  /**
44
- * Background media rendered full-bleed behind the hero text. Bring any
45
- * renderer most commonly an `Image`, but a `LottiePlayer`, `Video`, or
46
- * `SvgXml` works too. It is laid out at the full modal width; size it with an
47
- * aspect ratio (e.g. `<Image ratio={3 / 4} />`) so its height follows the
48
- * width naturally. The media scrolls together with the rest of the content
49
- * (no parallax). `modes` are cascaded into it.
54
+ * Full-bleed background media for the whole modal. It is pinned to the top
55
+ * and laid out at the full modal width; size it with an aspect ratio
56
+ * (e.g. `<Image ratio={1080 / 4140} />`) so its height follows the width
57
+ * naturally. It renders as a single continuous background BEHIND both the
58
+ * hero text and the body content there is no separate body box stacked on
59
+ * top of it. Bring any renderer most commonly an `Image`, but a
60
+ * `LottiePlayer`, `Video`, or `SvgXml` works too. It never intercepts
61
+ * touches and the foreground content scrolls over it (no parallax).
62
+ * `modes` are cascaded into it.
50
63
  */
51
64
  heroMedia?: React.ReactNode
52
65
  /**
53
- * Fallback height for the hero text region when no `heroMedia` is provided.
54
- * When `heroMedia` is present, the hero height is driven entirely by the
55
- * media's own aspect ratio and this value is ignored. Defaults to 420.
66
+ * Height reserved for the hero text region (eyebrow / headline / supporting
67
+ * / price), whose content is anchored to the bottom. Applies whether or not
68
+ * `heroMedia` is present. Defaults to 420.
56
69
  */
57
70
  heroHeight?: number
58
71
  /** Whether to render the floating close button (top-right). Defaults to true. */
@@ -72,15 +85,17 @@ export type FullscreenModalProps = {
72
85
  onPrimaryAction?: () => void
73
86
  /** Disclaimer text shown below the default primary action button. */
74
87
  disclaimer?: string
75
- /** Solid backdrop color for the scrollable body. Defaults to a near-black. */
76
- backgroundColor?: string
77
88
  /** Body content (typically `Section`s). `modes` are cascaded automatically. */
78
89
  children?: React.ReactNode
79
- /** Mode configuration. `context5` is always forced to `'Fullscreen Modal'`. */
90
+ /**
91
+ * Mode configuration. `Page type` defaults to `'JioPlus'` (overridable here),
92
+ * and `context5` is always forced to `'Fullscreen Modal'` (non-overridable).
93
+ * The resolved modes cascade to the body, hero media, and the `ActionFooter`.
94
+ */
80
95
  modes?: Record<string, any>
81
96
  /** Style overrides for the outer container. */
82
97
  style?: StyleProp<ViewStyle>
83
- /** Style overrides for the scroll body wrapper (the dark content area). */
98
+ /** Style overrides for the transparent body wrapper. */
84
99
  contentContainerStyle?: StyleProp<ViewStyle>
85
100
  testID?: string
86
101
  }
@@ -171,12 +186,21 @@ function HeroText({ eyebrow, headline, supportingText, priceText, modes }: HeroT
171
186
  * That mode is cascaded into `children`, the footer, and the hero text via
172
187
  * `cloneChildrenWithModes` / the merged `modes` object.
173
188
  *
174
- * ### Hero
175
- * The `heroMedia` is rendered full modal width inside the scroll body and
176
- * takes its height from its own aspect ratio. The hero text (eyebrow /
177
- * headline / supporting / price) is overlaid on top, anchored to the bottom.
178
- * The whole hero scrolls together with the rest of the content there is no
179
- * parallax effect.
189
+ * ### Background media
190
+ * The `heroMedia` is a single full-bleed background pinned to the top of the
191
+ * modal at the full width and its own natural aspect ratio. It lives at the
192
+ * ROOT behind both the scrolling content and the (transparent) footer so
193
+ * it fills the whole surface and is NEVER clipped to the content height. It
194
+ * also contributes ZERO scroll height: the scroll extent is driven purely by
195
+ * the in-flow foreground (hero text + `children`), so the number of body
196
+ * elements dictates how far the surface scrolls. It still scrolls in lockstep
197
+ * WITH the content (the background is translated by the scroll offset), so the
198
+ * content reads as sitting ON one continuous image that moves with it — there
199
+ * is no parallax and no separate solid body box.
200
+ *
201
+ * Pass a background sized to the full width at its natural ratio
202
+ * (e.g. `<Image imageSource={bg} ratio={1080 / 4140} />`). Use an asset at
203
+ * least as tall as the surface so it covers the full modal.
180
204
  *
181
205
  * @component
182
206
  * @example
@@ -186,7 +210,7 @@ function HeroText({ eyebrow, headline, supportingText, priceText, modes }: HeroT
186
210
  * headline="Get more from your money."
187
211
  * supportingText="JioFinance+ is your upgraded financial experience…"
188
212
  * priceText="₹999/year · ₹0 until 2027"
189
- * heroMedia={<Image imageSource={hero} ratio={3 / 4} />}
213
+ * heroMedia={<Image imageSource={hero} ratio={1080 / 4140} />}
190
214
  * primaryActionLabel="Upgrade for free"
191
215
  * disclaimer="By upgrading, we'll check your eligibility with Experian."
192
216
  * onPrimaryAction={() => upgrade()}
@@ -211,7 +235,6 @@ function FullscreenModal({
211
235
  primaryActionLabel = 'Upgrade for free',
212
236
  onPrimaryAction,
213
237
  disclaimer = "By upgrading, we'll check your eligibility with Experian.",
214
- backgroundColor = '#0f0d0a',
215
238
  children,
216
239
  modes: propModes = EMPTY_MODES,
217
240
  style,
@@ -220,15 +243,38 @@ function FullscreenModal({
220
243
  }: FullscreenModalProps) {
221
244
  const { modes: globalModes } = useTokens()
222
245
 
223
- // context5 is appended last so it always wins, regardless of what the
224
- // caller (or the global theme) passes.
246
+ // Merge order (low high priority):
247
+ // global theme → component defaults (Page type: JioPlus) → caller modes →
248
+ // forced modes (context5). So `Page type` defaults to JioPlus but the
249
+ // caller can override it, while `context5` always wins. This single `modes`
250
+ // object is what cascades to the body, hero media, and the ActionFooter.
225
251
  const modes = useMemo(
226
- () => ({ ...globalModes, ...propModes, ...FULLSCREEN_MODAL_FORCED_MODES }),
252
+ () => ({
253
+ ...globalModes,
254
+ ...FULLSCREEN_MODAL_DEFAULT_MODES,
255
+ ...propModes,
256
+ ...FULLSCREEN_MODAL_FORCED_MODES,
257
+ }),
227
258
  [globalModes, propModes]
228
259
  )
229
260
 
230
261
  const rootGap = Number(getVariableByName('fullScreenModal/gap', modes)) || 16
231
262
 
263
+ // Drives the background's parallax-free sync with the scroll. The hero media
264
+ // lives at the ROOT (so it is never clipped to the content height and sits
265
+ // behind the transparent footer), but we translate it up by the exact scroll
266
+ // offset so it moves in lockstep with the content — i.e. it scrolls WITH the
267
+ // body without ever contributing to the scroll height.
268
+ const scrollY = useRef(new Animated.Value(0)).current
269
+ const onScroll = useMemo(
270
+ () =>
271
+ Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
272
+ useNativeDriver: true,
273
+ }),
274
+ [scrollY]
275
+ )
276
+ const heroTranslateY = useMemo(() => Animated.multiply(scrollY, -1), [scrollY])
277
+
232
278
  const processedHeroMedia = useMemo(
233
279
  () =>
234
280
  heroMedia ? cloneChildrenWithModes(heroMedia, modes, FULLSCREEN_MODAL_FORCED_MODES) : null,
@@ -241,9 +287,11 @@ function FullscreenModal({
241
287
  [children, modes]
242
288
  )
243
289
 
244
- // No-media fallback: without hero media the text region needs an explicit
245
- // resting height (driven by `heroHeight`) so the hero still has presence.
246
- const heroTextFallbackStyle = useMemo<ViewStyle>(
290
+ // The hero text region always reserves `heroHeight` and anchors its content
291
+ // to the bottom, so the eyebrow/headline block sits in the lower part of the
292
+ // first screenful — over the background media when present, in flow
293
+ // otherwise.
294
+ const heroTextRegionStyle = useMemo<ViewStyle>(
247
295
  () => ({
248
296
  minHeight: heroHeight,
249
297
  justifyContent: 'flex-end',
@@ -253,17 +301,18 @@ function FullscreenModal({
253
301
  [heroHeight]
254
302
  )
255
303
 
304
+ // Body is intentionally transparent — the background media shows through
305
+ // behind it. There is no solid "body box" stacked on top of the image.
256
306
  const bodyStyle = useMemo<StyleProp<ViewStyle>>(
257
307
  () => [
258
308
  {
259
- backgroundColor,
260
309
  gap: rootGap,
261
310
  paddingTop: rootGap,
262
311
  paddingBottom: 24,
263
312
  },
264
313
  contentContainerStyle,
265
314
  ],
266
- [backgroundColor, rootGap, contentContainerStyle]
315
+ [rootGap, contentContainerStyle]
267
316
  )
268
317
 
269
318
  const heroTextNode = (
@@ -276,21 +325,6 @@ function FullscreenModal({
276
325
  />
277
326
  )
278
327
 
279
- // The hero scrolls inline with the body (no parallax). When media is present
280
- // it is laid out full modal width and takes its height from its own aspect
281
- // ratio; the hero text is overlaid on top, anchored to the bottom. Without
282
- // media the text simply renders in flow at the fallback height.
283
- const hero = processedHeroMedia ? (
284
- <View style={heroMediaContainerStyle}>
285
- {processedHeroMedia}
286
- <View style={heroTextOverlayStyle} pointerEvents="box-none">
287
- {heroTextNode}
288
- </View>
289
- </View>
290
- ) : (
291
- <View style={heroTextFallbackStyle}>{heroTextNode}</View>
292
- )
293
-
294
328
  // Footer: a fully custom node, or the default Button + Disclaimer column.
295
329
  let footerContent: React.ReactNode = null
296
330
  if (footer) {
@@ -310,18 +344,48 @@ function FullscreenModal({
310
344
  }
311
345
 
312
346
  return (
313
- <View style={[rootStyle, { backgroundColor }, style]} testID={testID}>
314
- <ScrollView
347
+ <View style={[rootStyle, style]} testID={testID}>
348
+ {/*
349
+ * Layout model:
350
+ * - `processedHeroMedia` is a ROOT-LEVEL full-bleed background pinned to
351
+ * the top at full modal width and rendered at its own natural aspect
352
+ * ratio — it is NEVER clipped to the content height, so it extends as
353
+ * far down as its own height allows and fills the surface BEHIND both
354
+ * the scrolling content and the (transparent) footer. Because it lives
355
+ * outside the ScrollView it contributes ZERO scroll height, yet we
356
+ * translate it by the scroll offset (`heroTranslateY`) so it visually
357
+ * scrolls in lockstep WITH the content (no parallax, no clip).
358
+ * `pointerEvents="none"` lets touches reach the content/footer.
359
+ * - The ScrollView is transparent and holds only the foreground (hero
360
+ * text + body) IN FLOW, so the number of actual elements dictates how
361
+ * far the surface scrolls.
362
+ */}
363
+ {processedHeroMedia ? (
364
+ <Animated.View
365
+ style={[heroBackgroundStyle, { transform: [{ translateY: heroTranslateY }] }]}
366
+ pointerEvents="none"
367
+ >
368
+ {processedHeroMedia}
369
+ </Animated.View>
370
+ ) : null}
371
+
372
+ <Animated.ScrollView
315
373
  style={scrollViewStyle}
316
374
  contentContainerStyle={scrollContentStyle}
317
375
  showsVerticalScrollIndicator={false}
376
+ onScroll={onScroll}
377
+ scrollEventThrottle={16}
318
378
  // Tap an input in the body and it focuses on the FIRST tap, even when
319
379
  // the keyboard is already open (default 'never' eats that tap).
320
380
  keyboardShouldPersistTaps="handled"
321
381
  >
322
- {hero}
323
- <View style={bodyStyle}>{processedChildren}</View>
324
- </ScrollView>
382
+ <View style={foregroundFlowStyle}>
383
+ <View style={heroTextRegionStyle}>{heroTextNode}</View>
384
+ {processedChildren ? (
385
+ <View style={bodyStyle}>{processedChildren}</View>
386
+ ) : null}
387
+ </View>
388
+ </Animated.ScrollView>
325
389
 
326
390
  {footerContent ? (
327
391
  <ActionFooter modes={modes}>{footerContent}</ActionFooter>
@@ -346,14 +410,13 @@ const scrollViewStyle: ViewStyle = { flex: 1 }
346
410
  const scrollContentStyle: ViewStyle = { flexGrow: 1 }
347
411
  const fullWidthStyle: ViewStyle = { width: '100%' }
348
412
  const closeButtonStyle: ViewStyle = { position: 'absolute', top: 12, right: 12 }
349
- // Full-width hero wrapper; height comes from the media's own aspect ratio.
350
- const heroMediaContainerStyle: ViewStyle = { width: '100%', position: 'relative' }
351
- // Hero text overlaid on the media, anchored to the bottom edge.
352
- const heroTextOverlayStyle: ViewStyle = {
353
- ...StyleSheet.absoluteFillObject,
354
- justifyContent: 'flex-end',
355
- paddingHorizontal: 16,
356
- paddingBottom: 16,
357
- }
413
+ // Root-level full-bleed background media. Pinned to the top at full modal
414
+ // width; the media inside keeps its own natural aspect ratio (only `top` is
415
+ // pinned no `bottom`/`overflow` clip), so it is NEVER cut to the content
416
+ // height and fills the surface behind the scrolling content and the footer.
417
+ // Living outside the ScrollView, it adds nothing to the scroll height.
418
+ const heroBackgroundStyle: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0 }
419
+ // The foreground always flows normally — its content drives the scroll height.
420
+ const foregroundFlowStyle: ViewStyle = { width: '100%' }
358
421
 
359
422
  export default FullscreenModal