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