jfs-components 0.0.86 → 0.0.99

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 (34) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/lib/commonjs/assets.d.js +1 -0
  3. package/lib/commonjs/components/Drawer/Drawer.js +146 -82
  4. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +118 -51
  5. package/lib/commonjs/components/Icon/Icon.js +112 -0
  6. package/lib/commonjs/components/Spinner/Spinner.js +5 -1
  7. package/lib/commonjs/components/index.js +7 -0
  8. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  9. package/lib/commonjs/icons/registry.js +1 -1
  10. package/lib/commonjs/skeleton/Skeleton.js +10 -2
  11. package/lib/module/assets.d.js +1 -0
  12. package/lib/module/components/Drawer/Drawer.js +148 -84
  13. package/lib/module/components/FullscreenModal/FullscreenModal.js +120 -53
  14. package/lib/module/components/Icon/Icon.js +106 -0
  15. package/lib/module/components/Spinner/Spinner.js +6 -2
  16. package/lib/module/components/index.js +1 -0
  17. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  18. package/lib/module/icons/registry.js +1 -1
  19. package/lib/module/skeleton/Skeleton.js +11 -3
  20. package/lib/typescript/src/components/Drawer/Drawer.d.ts +23 -4
  21. package/lib/typescript/src/components/FullscreenModal/FullscreenModal.d.ts +35 -21
  22. package/lib/typescript/src/components/Icon/Icon.d.ts +75 -0
  23. package/lib/typescript/src/components/index.d.ts +2 -1
  24. package/lib/typescript/src/icons/registry.d.ts +1 -1
  25. package/package.json +1 -1
  26. package/src/assets.d.ts +24 -0
  27. package/src/components/Drawer/Drawer.tsx +94 -15
  28. package/src/components/FullscreenModal/FullscreenModal.tsx +146 -63
  29. package/src/components/Icon/Icon.tsx +167 -0
  30. package/src/components/Spinner/Spinner.tsx +2 -2
  31. package/src/components/index.ts +2 -1
  32. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  33. package/src/icons/registry.ts +1 -1
  34. package/src/skeleton/Skeleton.tsx +10 -3
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to this project are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [0.0.95] - 2026-06-04
8
+
9
+ - Added `Icon` — token-driven design-system icon primitive (`iconName`, `source`, `children` slot); exported from the package barrel.
10
+ - `FullscreenModal` — `heroMedia` is now a full-bleed continuous background behind hero + body; foreground scrolls over it; defaults `Page type` to `JioPlus`; transparent body (removed solid `backgroundColor`).
11
+ - Added `src/assets.d.ts` for TypeScript `require()` of image assets.
12
+
13
+ ---
14
+
7
15
  ## [0.0.86] - 2026-06-04
8
16
 
9
17
  - Added `AllocationComparisonChart` — vertical pill bars comparing current vs recommended allocation with optional baseline overlay and dashed marker.
@@ -0,0 +1 @@
1
+ "use strict";
@@ -45,11 +45,17 @@ function rubberBand(value, min, max, friction = 0.55) {
45
45
  }
46
46
  return value;
47
47
  }
48
+
49
+ /**
50
+ * Imperative handle exposed via `ref` for programmatic control of the drawer.
51
+ * Each method animates using the same spring as gesture-driven transitions.
52
+ */
53
+
48
54
  /**
49
55
  * Drawer component with nested scrolling support.
50
56
  * Uses react-native-gesture-handler and react-native-reanimated.
51
57
  */
52
- function Drawer({
58
+ function DrawerInner({
53
59
  modes = _reactUtils.EMPTY_MODES,
54
60
  style,
55
61
  title,
@@ -58,6 +64,7 @@ function Drawer({
58
64
  collapsedHeight = 200,
59
65
  expandedRatio = 0.90,
60
66
  initialState = 'collapsed',
67
+ state,
61
68
  contentStyle,
62
69
  sheetStyle,
63
70
  accessibilityLabel,
@@ -66,7 +73,7 @@ function Drawer({
66
73
  showsVerticalScrollIndicator = false,
67
74
  bottomInset = 80,
68
75
  onStateChange
69
- }) {
76
+ }, ref) {
70
77
  const {
71
78
  height: screenHeight
72
79
  } = (0, _reactNative.useWindowDimensions)();
@@ -128,15 +135,58 @@ function Drawer({
128
135
  translateY.value = (0, _reactNativeReanimated.withSpring)(destination, SPRING_CONFIG);
129
136
  }, [translateY]);
130
137
 
131
- // Update JS state for accessibility/logic if needed
138
+ // Update the JS-side mode. Pure: only schedules the state update. Side
139
+ // effects (notifying the parent via onStateChange) MUST NOT live inside the
140
+ // setState updater — doing so calls the parent's setState during React's
141
+ // render phase, which throws "Cannot update a component while rendering a
142
+ // different component" and causes an infinite update loop. We report changes
143
+ // from a dedicated effect below instead.
132
144
  const updateMode = (0, _react.useCallback)(newMode => {
133
- setMode(prev => {
134
- if (prev !== newMode) {
135
- onStateChange?.(newMode);
136
- }
137
- return newMode;
138
- });
139
- }, [onStateChange]);
145
+ setMode(newMode);
146
+ }, []);
147
+
148
+ // Notify the parent exactly once per settled mode change, after commit.
149
+ const reportedModeRef = (0, _react.useRef)(mode);
150
+ (0, _react.useEffect)(() => {
151
+ if (reportedModeRef.current !== mode) {
152
+ reportedModeRef.current = mode;
153
+ onStateChange?.(mode);
154
+ }
155
+ }, [mode, onStateChange]);
156
+
157
+ // Programmatic transition (JS thread). Reuses the same spring as gestures:
158
+ // animates `translateY`, keeps the scroll-gate (`isFullyExpanded`) in sync,
159
+ // and updates `mode` (which notifies via the onStateChange effect above).
160
+ const applyMode = (0, _react.useCallback)(newMode => {
161
+ const target = newMode === 'expanded' ? minTranslateY : maxTranslateY;
162
+ translateY.value = (0, _reactNativeReanimated.withSpring)(target, SPRING_CONFIG);
163
+ isFullyExpanded.value = newMode === 'expanded';
164
+ setMode(newMode);
165
+ }, [minTranslateY, maxTranslateY, translateY, isFullyExpanded]);
166
+
167
+ // Controlled mode: react ONLY to genuine changes of the `state` prop, tracked
168
+ // against its previous value. We must NOT reconcile `state` against the
169
+ // internal `mode` on every render: a gesture updates `mode` optimistically
170
+ // one render before the parent echoes it back through `onStateChange` into
171
+ // `state`, so a `state !== mode` check would "correct" the gesture and fight
172
+ // the echo, ping-ponging forever (Maximum update depth exceeded).
173
+ // `applyMode` is idempotent, so re-applying the already-current mode is a
174
+ // no-op when the parent simply mirrors our own change back.
175
+ const prevStateProp = (0, _react.useRef)(state);
176
+ (0, _react.useEffect)(() => {
177
+ if (state === undefined) return;
178
+ if (state === prevStateProp.current) return;
179
+ prevStateProp.current = state;
180
+ applyMode(state);
181
+ }, [state, applyMode]);
182
+
183
+ // Imperative API for parents holding a ref.
184
+ (0, _react.useImperativeHandle)(ref, () => ({
185
+ expand: () => applyMode('expanded'),
186
+ collapse: () => applyMode('collapsed'),
187
+ toggle: () => applyMode(mode === 'expanded' ? 'collapsed' : 'expanded'),
188
+ getState: () => mode
189
+ }), [applyMode, mode]);
140
190
 
141
191
  // Gesture policy:
142
192
  // • activeOffsetY: require a clear *vertical* drag (10px) before this
@@ -309,84 +359,96 @@ function Drawer({
309
359
  default: {}
310
360
  });
311
361
  const defaultAccessibilityLabel = accessibilityLabel || title || 'Drawer';
312
- return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeGestureHandler.GestureHandlerRootView, {
313
- style: [styles.host, style],
314
- pointerEvents: "box-none",
315
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeGestureHandler.GestureDetector, {
316
- gesture: gesture,
317
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeReanimated.default.View, {
318
- style: [styles.sheet, {
319
- // Constraint the height strictly to the expanded height
320
- // This ensures the ScrollView has a finite frame to scroll within
321
- height: expandedHeight,
322
- backgroundColor,
323
- borderTopLeftRadius: radius,
324
- borderTopRightRadius: radius
325
- }, shadowStyle, sheetStyle, animatedStyle],
326
- accessible: true,
327
- ...(_reactNative.Platform.OS === 'web' ? {
328
- accessibilityRole: 'dialog'
329
- } : undefined),
330
- accessibilityLabel: undefined,
331
- accessibilityHint: accessibilityHint || 'Swipe up to expand, swipe down to collapse',
332
- children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
333
- style: [styles.sheetInner, {
362
+ return (
363
+ /*#__PURE__*/
364
+ // IMPORTANT: the host is a plain box-none View, NOT a GestureHandlerRootView.
365
+ // On Android, GestureHandlerRootView renders a *native* root view that
366
+ // intercepts every touch within its bounds (to feed the gesture system) and
367
+ // does NOT honor pointerEvents="box-none". Because this host fills the whole
368
+ // screen as an overlay, using GestureHandlerRootView here swallowed all
369
+ // touches to content rendered behind the drawer (buttons, page content).
370
+ // Per the standard react-native-gesture-handler architecture, a single
371
+ // GestureHandlerRootView must wrap the app root; this overlay only needs to
372
+ // let touches fall through where the sheet isn't.
373
+ (0, _jsxRuntime.jsx)(_reactNative.View, {
374
+ style: [styles.host, style],
375
+ pointerEvents: "box-none",
376
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeGestureHandler.GestureDetector, {
377
+ gesture: gesture,
378
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeReanimated.default.View, {
379
+ style: [styles.sheet, {
380
+ // Constraint the height strictly to the expanded height
381
+ // This ensures the ScrollView has a finite frame to scroll within
382
+ height: expandedHeight,
383
+ backgroundColor,
334
384
  borderTopLeftRadius: radius,
335
- borderTopRightRadius: radius,
336
- paddingLeft,
337
- paddingRight,
338
- paddingBottom,
339
- rowGap: drawerGap
340
- }],
341
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
342
- style: [styles.handleArea, !title && !header && {
343
- paddingBottom: 0
385
+ borderTopRightRadius: radius
386
+ }, shadowStyle, sheetStyle, animatedStyle],
387
+ accessible: true,
388
+ ...(_reactNative.Platform.OS === 'web' ? {
389
+ accessibilityRole: 'dialog'
390
+ } : undefined),
391
+ accessibilityLabel: undefined,
392
+ accessibilityHint: accessibilityHint || 'Swipe up to expand, swipe down to collapse',
393
+ children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
394
+ style: [styles.sheetInner, {
395
+ borderTopLeftRadius: radius,
396
+ borderTopRightRadius: radius,
397
+ paddingLeft,
398
+ paddingRight,
399
+ paddingBottom,
400
+ rowGap: drawerGap
344
401
  }],
345
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
402
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
403
+ style: [styles.handleArea, !title && !header && {
404
+ paddingBottom: 0
405
+ }],
406
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
407
+ style: [{
408
+ backgroundColor: handleColor,
409
+ width: handleWidth,
410
+ height: handleHeight,
411
+ borderRadius: handleRadius
412
+ }]
413
+ })
414
+ }), header, title && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
346
415
  style: [{
347
- backgroundColor: handleColor,
348
- width: handleWidth,
349
- height: handleHeight,
350
- borderRadius: handleRadius
351
- }]
352
- })
353
- }), header, title && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
354
- style: [{
355
- color: titleColor,
356
- fontSize: titleSize,
357
- fontWeight: titleWeight,
358
- lineHeight: titleLineHeight,
359
- marginBottom: titlePaddingBottom
360
- }],
361
- children: title
362
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(AnimatedScrollView, {
363
- ref: scrollRef,
364
- style: [styles.content, contentStyle],
365
- contentContainerStyle: [{
366
- paddingBottom: paddingBottom + bottomInset,
367
- gap: drawerGap,
368
- flexDirection: 'column',
369
- alignItems: 'stretch'
370
- }, contentContainerStyle],
371
- showsVerticalScrollIndicator: showsVerticalScrollIndicator
372
- // Let a tap on an input inside the sheet focus it on the FIRST tap
373
- // even while the keyboard is already open (default 'never' would
374
- // eat that tap just to dismiss the keyboard).
375
- ,
376
- keyboardShouldPersistTaps: "handled",
377
- animatedProps: animatedScrollProps,
378
- alwaysBounceVertical: false,
379
- overScrollMode: "always",
380
- onScroll: (0, _reactNativeReanimated.useAnimatedScrollHandler)(event => {
381
- scrollY.value = event.contentOffset.y;
382
- }),
383
- scrollEventThrottle: 16,
384
- children: children
385
- })]
416
+ color: titleColor,
417
+ fontSize: titleSize,
418
+ fontWeight: titleWeight,
419
+ lineHeight: titleLineHeight,
420
+ marginBottom: titlePaddingBottom
421
+ }],
422
+ children: title
423
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(AnimatedScrollView, {
424
+ ref: scrollRef,
425
+ style: [styles.content, contentStyle],
426
+ contentContainerStyle: [{
427
+ paddingBottom: paddingBottom + bottomInset,
428
+ gap: drawerGap,
429
+ flexDirection: 'column',
430
+ alignItems: 'stretch'
431
+ }, contentContainerStyle],
432
+ showsVerticalScrollIndicator: showsVerticalScrollIndicator
433
+ // Let a tap on an input inside the sheet focus it on the FIRST tap
434
+ // even while the keyboard is already open (default 'never' would
435
+ // eat that tap just to dismiss the keyboard).
436
+ ,
437
+ keyboardShouldPersistTaps: "handled",
438
+ animatedProps: animatedScrollProps,
439
+ alwaysBounceVertical: false,
440
+ overScrollMode: "always",
441
+ onScroll: (0, _reactNativeReanimated.useAnimatedScrollHandler)(event => {
442
+ scrollY.value = event.contentOffset.y;
443
+ }),
444
+ scrollEventThrottle: 16,
445
+ children: children
446
+ })]
447
+ })
386
448
  })
387
449
  })
388
450
  })
389
- });
451
+ );
390
452
  }
391
453
  const styles = _reactNative.StyleSheet.create({
392
454
  host: {
@@ -416,4 +478,6 @@ const styles = _reactNative.StyleSheet.create({
416
478
  flex: 1
417
479
  }
418
480
  });
481
+ const Drawer = /*#__PURE__*/(0, _react.forwardRef)(DrawerInner);
482
+ Drawer.displayName = 'Drawer';
419
483
  var _default = exports.default = Drawer;
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", {
6
6
  exports.default = void 0;
7
7
  var _react = _interopRequireWildcard(require("react"));
8
8
  var _reactNative = require("react-native");
9
+ var _reactNativeSafeAreaContext = require("react-native-safe-area-context");
9
10
  var _figmaVariablesResolver = require("../../design-tokens/figma-variables-resolver");
10
11
  var _JFSThemeProvider = require("../../design-tokens/JFSThemeProvider");
11
12
  var _reactUtils = require("../../utils/react-utils");
@@ -33,6 +34,19 @@ const FULLSCREEN_MODAL_FORCED_MODES = Object.freeze({
33
34
  context5: 'Fullscreen Modal'
34
35
  });
35
36
 
37
+ // ---------------------------------------------------------------------------
38
+ // Default modes
39
+ //
40
+ // A FullscreenModal is a "JioPlus" surface, so it defaults the `Page type`
41
+ // collection to `'JioPlus'`. Unlike the forced modes above this IS
42
+ // overridable — it is applied before the caller's `modes`, so passing
43
+ // `modes={{ 'Page type': 'SubPage' }}` still wins. Frozen for stable identity
44
+ // (keeps the token resolver's per-modes cache hot).
45
+ // ---------------------------------------------------------------------------
46
+ const FULLSCREEN_MODAL_DEFAULT_MODES = Object.freeze({
47
+ 'Page type': 'JioPlus'
48
+ });
49
+
36
50
  // ---------------------------------------------------------------------------
37
51
  // Hero text — the eyebrow / headline / supporting / price block. Built inline
38
52
  // (rather than reusing <PageHero>) so we can render BOTH a supporting
@@ -132,12 +146,21 @@ function HeroText({
132
146
  * That mode is cascaded into `children`, the footer, and the hero text via
133
147
  * `cloneChildrenWithModes` / the merged `modes` object.
134
148
  *
135
- * ### Hero
136
- * The `heroMedia` is rendered full modal width inside the scroll body and
137
- * takes its height from its own aspect ratio. The hero text (eyebrow /
138
- * headline / supporting / price) is overlaid on top, anchored to the bottom.
139
- * The whole hero scrolls together with the rest of the content there is no
140
- * parallax effect.
149
+ * ### Background media
150
+ * The `heroMedia` is a single full-bleed background pinned to the top of the
151
+ * modal at the full width and its own natural aspect ratio. It lives at the
152
+ * ROOT behind both the scrolling content and the (transparent) footer so
153
+ * it fills the whole surface and is NEVER clipped to the content height. It
154
+ * also contributes ZERO scroll height: the scroll extent is driven purely by
155
+ * the in-flow foreground (hero text + `children`), so the number of body
156
+ * elements dictates how far the surface scrolls. It still scrolls in lockstep
157
+ * WITH the content (the background is translated by the scroll offset), so the
158
+ * content reads as sitting ON one continuous image that moves with it — there
159
+ * is no parallax and no separate solid body box.
160
+ *
161
+ * Pass a background sized to the full width at its natural ratio
162
+ * (e.g. `<Image imageSource={bg} ratio={1080 / 4140} />`). Use an asset at
163
+ * least as tall as the surface so it covers the full modal.
141
164
  *
142
165
  * @component
143
166
  * @example
@@ -147,7 +170,7 @@ function HeroText({
147
170
  * headline="Get more from your money."
148
171
  * supportingText="JioFinance+ is your upgraded financial experience…"
149
172
  * priceText="₹999/year · ₹0 until 2027"
150
- * heroMedia={<Image imageSource={hero} ratio={3 / 4} />}
173
+ * heroMedia={<Image imageSource={hero} ratio={1080 / 4140} />}
151
174
  * primaryActionLabel="Upgrade for free"
152
175
  * disclaimer="By upgrading, we'll check your eligibility with Experian."
153
176
  * onPrimaryAction={() => upgrade()}
@@ -172,7 +195,6 @@ function FullscreenModal({
172
195
  primaryActionLabel = 'Upgrade for free',
173
196
  onPrimaryAction,
174
197
  disclaimer = "By upgrading, we'll check your eligibility with Experian.",
175
- backgroundColor = '#0f0d0a',
176
198
  children,
177
199
  modes: propModes = _reactUtils.EMPTY_MODES,
178
200
  style,
@@ -183,31 +205,74 @@ function FullscreenModal({
183
205
  modes: globalModes
184
206
  } = (0, _JFSThemeProvider.useTokens)();
185
207
 
186
- // context5 is appended last so it always wins, regardless of what the
187
- // caller (or the global theme) passes.
208
+ // Merge order (low high priority):
209
+ // global theme → component defaults (Page type: JioPlus) → caller modes →
210
+ // forced modes (context5). So `Page type` defaults to JioPlus but the
211
+ // caller can override it, while `context5` always wins. This single `modes`
212
+ // object is what cascades to the body, hero media, and the ActionFooter.
188
213
  const modes = (0, _react.useMemo)(() => ({
189
214
  ...globalModes,
215
+ ...FULLSCREEN_MODAL_DEFAULT_MODES,
190
216
  ...propModes,
191
217
  ...FULLSCREEN_MODAL_FORCED_MODES
192
218
  }), [globalModes, propModes]);
193
219
  const rootGap = Number((0, _figmaVariablesResolver.getVariableByName)('fullScreenModal/gap', modes)) || 16;
220
+
221
+ // Safe-area insets so the floating chrome clears the system bars: the close
222
+ // button drops below the status bar / notch, and the sticky footer keeps its
223
+ // designed bottom padding ON TOP of the bottom inset (home indicator /
224
+ // Android gesture or nav bar). On web — and anywhere without a
225
+ // SafeAreaProvider — every inset is 0, so the layout is unchanged.
226
+ const insets = (0, _reactNativeSafeAreaContext.useSafeAreaInsets)();
227
+ const closeButtonInsetStyle = (0, _react.useMemo)(() => ({
228
+ top: 12 + insets.top
229
+ }), [insets.top]);
230
+ // Extend (not replace) the footer's token bottom padding by the bottom inset
231
+ // so the action button never sits under the system navigation area.
232
+ const footerInsetStyle = (0, _react.useMemo)(() => {
233
+ const base = Number((0, _figmaVariablesResolver.getVariableByName)('actionFooter/padding/bottom', modes)) || 41;
234
+ return {
235
+ paddingBottom: base + insets.bottom
236
+ };
237
+ }, [modes, insets.bottom]);
238
+
239
+ // Drives the background's parallax-free sync with the scroll. The hero media
240
+ // lives at the ROOT (so it is never clipped to the content height and sits
241
+ // behind the transparent footer), but we translate it up by the exact scroll
242
+ // offset so it moves in lockstep with the content — i.e. it scrolls WITH the
243
+ // body without ever contributing to the scroll height.
244
+ const scrollY = (0, _react.useRef)(new _reactNative.Animated.Value(0)).current;
245
+ const onScroll = (0, _react.useMemo)(() => _reactNative.Animated.event([{
246
+ nativeEvent: {
247
+ contentOffset: {
248
+ y: scrollY
249
+ }
250
+ }
251
+ }], {
252
+ useNativeDriver: true
253
+ }), [scrollY]);
254
+ const heroTranslateY = (0, _react.useMemo)(() => _reactNative.Animated.multiply(scrollY, -1), [scrollY]);
194
255
  const processedHeroMedia = (0, _react.useMemo)(() => heroMedia ? (0, _reactUtils.cloneChildrenWithModes)(heroMedia, modes, FULLSCREEN_MODAL_FORCED_MODES) : null, [heroMedia, modes]);
195
256
  const processedChildren = (0, _react.useMemo)(() => children ? (0, _reactUtils.cloneChildrenWithModes)(children, modes, FULLSCREEN_MODAL_FORCED_MODES) : null, [children, modes]);
196
257
 
197
- // No-media fallback: without hero media the text region needs an explicit
198
- // resting height (driven by `heroHeight`) so the hero still has presence.
199
- const heroTextFallbackStyle = (0, _react.useMemo)(() => ({
258
+ // The hero text region always reserves `heroHeight` and anchors its content
259
+ // to the bottom, so the eyebrow/headline block sits in the lower part of the
260
+ // first screenful — over the background media when present, in flow
261
+ // otherwise.
262
+ const heroTextRegionStyle = (0, _react.useMemo)(() => ({
200
263
  minHeight: heroHeight,
201
264
  justifyContent: 'flex-end',
202
265
  paddingHorizontal: 16,
203
266
  paddingBottom: 16
204
267
  }), [heroHeight]);
268
+
269
+ // Body is intentionally transparent — the background media shows through
270
+ // behind it. There is no solid "body box" stacked on top of the image.
205
271
  const bodyStyle = (0, _react.useMemo)(() => [{
206
- backgroundColor,
207
272
  gap: rootGap,
208
273
  paddingTop: rootGap,
209
274
  paddingBottom: 24
210
- }, contentContainerStyle], [backgroundColor, rootGap, contentContainerStyle]);
275
+ }, contentContainerStyle], [rootGap, contentContainerStyle]);
211
276
  const heroTextNode = /*#__PURE__*/(0, _jsxRuntime.jsx)(HeroText, {
212
277
  eyebrow: eyebrow,
213
278
  headline: headline,
@@ -216,22 +281,6 @@ function FullscreenModal({
216
281
  modes: modes
217
282
  });
218
283
 
219
- // The hero scrolls inline with the body (no parallax). When media is present
220
- // it is laid out full modal width and takes its height from its own aspect
221
- // ratio; the hero text is overlaid on top, anchored to the bottom. Without
222
- // media the text simply renders in flow at the fallback height.
223
- const hero = processedHeroMedia ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
224
- style: heroMediaContainerStyle,
225
- children: [processedHeroMedia, /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
226
- style: heroTextOverlayStyle,
227
- pointerEvents: "box-none",
228
- children: heroTextNode
229
- })]
230
- }) : /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
231
- style: heroTextFallbackStyle,
232
- children: heroTextNode
233
- });
234
-
235
284
  // Footer: a fully custom node, or the default Button + Disclaimer column.
236
285
  let footerContent = null;
237
286
  if (footer) {
@@ -254,30 +303,45 @@ function FullscreenModal({
254
303
  });
255
304
  }
256
305
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
257
- style: [rootStyle, {
258
- backgroundColor
259
- }, style],
306
+ style: [rootStyle, style],
260
307
  testID: testID,
261
- children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.ScrollView, {
308
+ children: [processedHeroMedia ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Animated.View, {
309
+ style: [heroBackgroundStyle, {
310
+ transform: [{
311
+ translateY: heroTranslateY
312
+ }]
313
+ }],
314
+ pointerEvents: "none",
315
+ children: processedHeroMedia
316
+ }) : null, /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Animated.ScrollView, {
262
317
  style: scrollViewStyle,
263
318
  contentContainerStyle: scrollContentStyle,
264
- showsVerticalScrollIndicator: false
319
+ showsVerticalScrollIndicator: false,
320
+ onScroll: onScroll,
321
+ scrollEventThrottle: 16
265
322
  // Tap an input in the body and it focuses on the FIRST tap, even when
266
323
  // the keyboard is already open (default 'never' eats that tap).
267
324
  ,
268
325
  keyboardShouldPersistTaps: "handled",
269
- children: [hero, /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
270
- style: bodyStyle,
271
- children: processedChildren
272
- })]
326
+ children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
327
+ style: foregroundFlowStyle,
328
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
329
+ style: heroTextRegionStyle,
330
+ children: heroTextNode
331
+ }), processedChildren ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
332
+ style: bodyStyle,
333
+ children: processedChildren
334
+ }) : null]
335
+ })
273
336
  }), footerContent ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_ActionFooter.default, {
274
337
  modes: modes,
338
+ style: footerInsetStyle,
275
339
  children: footerContent
276
340
  }) : null, showClose ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_IconButton.default, {
277
341
  iconName: "ic_close",
278
342
  modes: modes,
279
343
  accessibilityLabel: closeAccessibilityLabel,
280
- style: closeButtonStyle,
344
+ style: [closeButtonStyle, closeButtonInsetStyle],
281
345
  ...(onClose ? {
282
346
  onPress: onClose
283
347
  } : {})
@@ -305,16 +369,19 @@ const closeButtonStyle = {
305
369
  top: 12,
306
370
  right: 12
307
371
  };
308
- // Full-width hero wrapper; height comes from the media's own aspect ratio.
309
- const heroMediaContainerStyle = {
310
- width: '100%',
311
- position: 'relative'
372
+ // Root-level full-bleed background media. Pinned to the top at full modal
373
+ // width; the media inside keeps its own natural aspect ratio (only `top` is
374
+ // pinned — no `bottom`/`overflow` clip), so it is NEVER cut to the content
375
+ // height and fills the surface behind the scrolling content and the footer.
376
+ // Living outside the ScrollView, it adds nothing to the scroll height.
377
+ const heroBackgroundStyle = {
378
+ position: 'absolute',
379
+ top: 0,
380
+ left: 0,
381
+ right: 0
312
382
  };
313
- // Hero text overlaid on the media, anchored to the bottom edge.
314
- const heroTextOverlayStyle = {
315
- ..._reactNative.StyleSheet.absoluteFillObject,
316
- justifyContent: 'flex-end',
317
- paddingHorizontal: 16,
318
- paddingBottom: 16
383
+ // The foreground always flows normally its content drives the scroll height.
384
+ const foregroundFlowStyle = {
385
+ width: '100%'
319
386
  };
320
387
  var _default = exports.default = FullscreenModal;