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.
- package/CHANGELOG.md +8 -0
- package/lib/commonjs/assets.d.js +1 -0
- package/lib/commonjs/components/Drawer/Drawer.js +146 -82
- package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +118 -51
- package/lib/commonjs/components/Icon/Icon.js +112 -0
- package/lib/commonjs/components/Spinner/Spinner.js +5 -1
- package/lib/commonjs/components/index.js +7 -0
- package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/commonjs/icons/registry.js +1 -1
- package/lib/commonjs/skeleton/Skeleton.js +10 -2
- package/lib/module/assets.d.js +1 -0
- package/lib/module/components/Drawer/Drawer.js +148 -84
- package/lib/module/components/FullscreenModal/FullscreenModal.js +120 -53
- package/lib/module/components/Icon/Icon.js +106 -0
- package/lib/module/components/Spinner/Spinner.js +6 -2
- package/lib/module/components/index.js +1 -0
- package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/module/icons/registry.js +1 -1
- package/lib/module/skeleton/Skeleton.js +11 -3
- package/lib/typescript/src/components/Drawer/Drawer.d.ts +23 -4
- package/lib/typescript/src/components/FullscreenModal/FullscreenModal.d.ts +35 -21
- package/lib/typescript/src/components/Icon/Icon.d.ts +75 -0
- package/lib/typescript/src/components/index.d.ts +2 -1
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/package.json +1 -1
- package/src/assets.d.ts +24 -0
- package/src/components/Drawer/Drawer.tsx +94 -15
- package/src/components/FullscreenModal/FullscreenModal.tsx +146 -63
- package/src/components/Icon/Icon.tsx +167 -0
- package/src/components/Spinner/Spinner.tsx +2 -2
- package/src/components/index.ts +2 -1
- package/src/design-tokens/Coin Variables-variables-full.json +1 -1
- package/src/icons/registry.ts +1 -1
- 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
|
|
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
|
|
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(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
* ###
|
|
136
|
-
* The `heroMedia` is
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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={
|
|
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
|
-
//
|
|
187
|
-
//
|
|
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
|
-
//
|
|
198
|
-
//
|
|
199
|
-
|
|
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], [
|
|
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.
|
|
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:
|
|
270
|
-
style:
|
|
271
|
-
children:
|
|
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
|
-
//
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
//
|
|
314
|
-
const
|
|
315
|
-
|
|
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;
|