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
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
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,
|
|
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
|
-
* ###
|
|
131
|
-
* The `heroMedia` is
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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={
|
|
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
|
-
//
|
|
182
|
-
//
|
|
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
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
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], [
|
|
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__*/
|
|
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:
|
|
265
|
-
style:
|
|
266
|
-
children:
|
|
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
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
//
|
|
309
|
-
const
|
|
310
|
-
|
|
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;
|