jfs-components 0.0.86 → 0.0.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ "use strict";
@@ -1,7 +1,7 @@
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
5
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
6
6
  import { useTokens } from '../../design-tokens/JFSThemeProvider';
7
7
  import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils';
@@ -28,6 +28,19 @@ const FULLSCREEN_MODAL_FORCED_MODES = Object.freeze({
28
28
  context5: 'Fullscreen Modal'
29
29
  });
30
30
 
31
+ // ---------------------------------------------------------------------------
32
+ // Default modes
33
+ //
34
+ // A FullscreenModal is a "JioPlus" surface, so it defaults the `Page type`
35
+ // collection to `'JioPlus'`. Unlike the forced modes above this IS
36
+ // overridable — it is applied before the caller's `modes`, so passing
37
+ // `modes={{ 'Page type': 'SubPage' }}` still wins. Frozen for stable identity
38
+ // (keeps the token resolver's per-modes cache hot).
39
+ // ---------------------------------------------------------------------------
40
+ const FULLSCREEN_MODAL_DEFAULT_MODES = Object.freeze({
41
+ 'Page type': 'JioPlus'
42
+ });
43
+
31
44
  // ---------------------------------------------------------------------------
32
45
  // Hero text — the eyebrow / headline / supporting / price block. Built inline
33
46
  // (rather than reusing <PageHero>) so we can render BOTH a supporting
@@ -127,12 +140,21 @@ function HeroText({
127
140
  * That mode is cascaded into `children`, the footer, and the hero text via
128
141
  * `cloneChildrenWithModes` / the merged `modes` object.
129
142
  *
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.
143
+ * ### Background media
144
+ * The `heroMedia` is a single full-bleed background pinned to the top of the
145
+ * modal at the full width and its own natural aspect ratio. It lives at the
146
+ * ROOT behind both the scrolling content and the (transparent) footer so
147
+ * it fills the whole surface and is NEVER clipped to the content height. It
148
+ * also contributes ZERO scroll height: the scroll extent is driven purely by
149
+ * the in-flow foreground (hero text + `children`), so the number of body
150
+ * elements dictates how far the surface scrolls. It still scrolls in lockstep
151
+ * WITH the content (the background is translated by the scroll offset), so the
152
+ * content reads as sitting ON one continuous image that moves with it — there
153
+ * is no parallax and no separate solid body box.
154
+ *
155
+ * Pass a background sized to the full width at its natural ratio
156
+ * (e.g. `<Image imageSource={bg} ratio={1080 / 4140} />`). Use an asset at
157
+ * least as tall as the surface so it covers the full modal.
136
158
  *
137
159
  * @component
138
160
  * @example
@@ -142,7 +164,7 @@ function HeroText({
142
164
  * headline="Get more from your money."
143
165
  * supportingText="JioFinance+ is your upgraded financial experience…"
144
166
  * priceText="₹999/year · ₹0 until 2027"
145
- * heroMedia={<Image imageSource={hero} ratio={3 / 4} />}
167
+ * heroMedia={<Image imageSource={hero} ratio={1080 / 4140} />}
146
168
  * primaryActionLabel="Upgrade for free"
147
169
  * disclaimer="By upgrading, we'll check your eligibility with Experian."
148
170
  * onPrimaryAction={() => upgrade()}
@@ -167,7 +189,6 @@ function FullscreenModal({
167
189
  primaryActionLabel = 'Upgrade for free',
168
190
  onPrimaryAction,
169
191
  disclaimer = "By upgrading, we'll check your eligibility with Experian.",
170
- backgroundColor = '#0f0d0a',
171
192
  children,
172
193
  modes: propModes = EMPTY_MODES,
173
194
  style,
@@ -178,31 +199,56 @@ function FullscreenModal({
178
199
  modes: globalModes
179
200
  } = useTokens();
180
201
 
181
- // context5 is appended last so it always wins, regardless of what the
182
- // caller (or the global theme) passes.
202
+ // Merge order (low high priority):
203
+ // global theme → component defaults (Page type: JioPlus) → caller modes →
204
+ // forced modes (context5). So `Page type` defaults to JioPlus but the
205
+ // caller can override it, while `context5` always wins. This single `modes`
206
+ // object is what cascades to the body, hero media, and the ActionFooter.
183
207
  const modes = useMemo(() => ({
184
208
  ...globalModes,
209
+ ...FULLSCREEN_MODAL_DEFAULT_MODES,
185
210
  ...propModes,
186
211
  ...FULLSCREEN_MODAL_FORCED_MODES
187
212
  }), [globalModes, propModes]);
188
213
  const rootGap = Number(getVariableByName('fullScreenModal/gap', modes)) || 16;
214
+
215
+ // Drives the background's parallax-free sync with the scroll. The hero media
216
+ // lives at the ROOT (so it is never clipped to the content height and sits
217
+ // behind the transparent footer), but we translate it up by the exact scroll
218
+ // offset so it moves in lockstep with the content — i.e. it scrolls WITH the
219
+ // body without ever contributing to the scroll height.
220
+ const scrollY = useRef(new Animated.Value(0)).current;
221
+ const onScroll = useMemo(() => Animated.event([{
222
+ nativeEvent: {
223
+ contentOffset: {
224
+ y: scrollY
225
+ }
226
+ }
227
+ }], {
228
+ useNativeDriver: true
229
+ }), [scrollY]);
230
+ const heroTranslateY = useMemo(() => Animated.multiply(scrollY, -1), [scrollY]);
189
231
  const processedHeroMedia = useMemo(() => heroMedia ? cloneChildrenWithModes(heroMedia, modes, FULLSCREEN_MODAL_FORCED_MODES) : null, [heroMedia, modes]);
190
232
  const processedChildren = useMemo(() => children ? cloneChildrenWithModes(children, modes, FULLSCREEN_MODAL_FORCED_MODES) : null, [children, modes]);
191
233
 
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(() => ({
234
+ // The hero text region always reserves `heroHeight` and anchors its content
235
+ // to the bottom, so the eyebrow/headline block sits in the lower part of the
236
+ // first screenful over the background media when present, in flow
237
+ // otherwise.
238
+ const heroTextRegionStyle = useMemo(() => ({
195
239
  minHeight: heroHeight,
196
240
  justifyContent: 'flex-end',
197
241
  paddingHorizontal: 16,
198
242
  paddingBottom: 16
199
243
  }), [heroHeight]);
244
+
245
+ // Body is intentionally transparent — the background media shows through
246
+ // behind it. There is no solid "body box" stacked on top of the image.
200
247
  const bodyStyle = useMemo(() => [{
201
- backgroundColor,
202
248
  gap: rootGap,
203
249
  paddingTop: rootGap,
204
250
  paddingBottom: 24
205
- }, contentContainerStyle], [backgroundColor, rootGap, contentContainerStyle]);
251
+ }, contentContainerStyle], [rootGap, contentContainerStyle]);
206
252
  const heroTextNode = /*#__PURE__*/_jsx(HeroText, {
207
253
  eyebrow: eyebrow,
208
254
  headline: headline,
@@ -211,22 +257,6 @@ function FullscreenModal({
211
257
  modes: modes
212
258
  });
213
259
 
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
260
  // Footer: a fully custom node, or the default Button + Disclaimer column.
231
261
  let footerContent = null;
232
262
  if (footer) {
@@ -249,22 +279,36 @@ function FullscreenModal({
249
279
  });
250
280
  }
251
281
  return /*#__PURE__*/_jsxs(View, {
252
- style: [rootStyle, {
253
- backgroundColor
254
- }, style],
282
+ style: [rootStyle, style],
255
283
  testID: testID,
256
- children: [/*#__PURE__*/_jsxs(ScrollView, {
284
+ children: [processedHeroMedia ? /*#__PURE__*/_jsx(Animated.View, {
285
+ style: [heroBackgroundStyle, {
286
+ transform: [{
287
+ translateY: heroTranslateY
288
+ }]
289
+ }],
290
+ pointerEvents: "none",
291
+ children: processedHeroMedia
292
+ }) : null, /*#__PURE__*/_jsx(Animated.ScrollView, {
257
293
  style: scrollViewStyle,
258
294
  contentContainerStyle: scrollContentStyle,
259
- showsVerticalScrollIndicator: false
295
+ showsVerticalScrollIndicator: false,
296
+ onScroll: onScroll,
297
+ scrollEventThrottle: 16
260
298
  // Tap an input in the body and it focuses on the FIRST tap, even when
261
299
  // the keyboard is already open (default 'never' eats that tap).
262
300
  ,
263
301
  keyboardShouldPersistTaps: "handled",
264
- children: [hero, /*#__PURE__*/_jsx(View, {
265
- style: bodyStyle,
266
- children: processedChildren
267
- })]
302
+ children: /*#__PURE__*/_jsxs(View, {
303
+ style: foregroundFlowStyle,
304
+ children: [/*#__PURE__*/_jsx(View, {
305
+ style: heroTextRegionStyle,
306
+ children: heroTextNode
307
+ }), processedChildren ? /*#__PURE__*/_jsx(View, {
308
+ style: bodyStyle,
309
+ children: processedChildren
310
+ }) : null]
311
+ })
268
312
  }), footerContent ? /*#__PURE__*/_jsx(ActionFooter, {
269
313
  modes: modes,
270
314
  children: footerContent
@@ -300,16 +344,19 @@ const closeButtonStyle = {
300
344
  top: 12,
301
345
  right: 12
302
346
  };
303
- // Full-width hero wrapper; height comes from the media's own aspect ratio.
304
- const heroMediaContainerStyle = {
305
- width: '100%',
306
- position: 'relative'
347
+ // Root-level full-bleed background media. Pinned to the top at full modal
348
+ // width; the media inside keeps its own natural aspect ratio (only `top` is
349
+ // pinned — no `bottom`/`overflow` clip), so it is NEVER cut to the content
350
+ // height and fills the surface behind the scrolling content and the footer.
351
+ // Living outside the ScrollView, it adds nothing to the scroll height.
352
+ const heroBackgroundStyle = {
353
+ position: 'absolute',
354
+ top: 0,
355
+ left: 0,
356
+ right: 0
307
357
  };
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
358
+ // The foreground always flows normally its content drives the scroll height.
359
+ const foregroundFlowStyle = {
360
+ width: '100%'
314
361
  };
315
362
  export default FullscreenModal;
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+
3
+ import React, { useMemo } from 'react';
4
+ import { View } from 'react-native';
5
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
6
+ import { useTokens } from '../../design-tokens/JFSThemeProvider';
7
+ import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils';
8
+ import BaseIcon from '../../icons/Icon';
9
+ import { jsx as _jsx } from "react/jsx-runtime";
10
+ function resolveIconTokens(modes) {
11
+ const iconColor = getVariableByName('icon/color', modes) || '#ad8444';
12
+ const iconSize = getVariableByName('icon/size', modes) || 18;
13
+ const paddingLeft = getVariableByName('icon/padding/left', modes) || 0;
14
+ const paddingTop = getVariableByName('icon/padding/top', modes) || 0;
15
+ const paddingRight = getVariableByName('icon/padding/right', modes) || 0;
16
+ const paddingBottom = getVariableByName('icon/padding/bottom', modes) || 0;
17
+ return {
18
+ containerStyle: {
19
+ flexDirection: 'column',
20
+ alignItems: 'center',
21
+ justifyContent: 'center',
22
+ overflow: 'hidden',
23
+ paddingLeft,
24
+ paddingTop,
25
+ paddingRight,
26
+ paddingBottom
27
+ },
28
+ iconColor,
29
+ iconSize
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Icon component — a design-token-driven wrapper around a single glyph.
35
+ *
36
+ * It mirrors the Figma "Icon" component: a padded, centered container whose
37
+ * color and size are resolved from the `icon/*` design tokens via `modes`.
38
+ * The glyph itself can be supplied three ways, in order of precedence:
39
+ *
40
+ * 1. `children` — a real slot for any node (custom SVG component, nested
41
+ * `Icon`, etc.). `modes` cascade into the slot automatically.
42
+ * 2. `iconName` — a registry icon in the `ic_something` format.
43
+ * 3. `source` — a {@link UnifiedSource} fallback (remote URI, inline SVG XML,
44
+ * `require()` asset, SVG component, or React element), tinted with the
45
+ * mode-resolved icon color.
46
+ *
47
+ * `color` and `size` props let consumers override the token values per
48
+ * instance without touching `modes`.
49
+ *
50
+ * @example
51
+ * ```tsx
52
+ * // Built-in registry icon (default path).
53
+ * <Icon iconName="ic_card" modes={{ 'Color Mode': 'Light' }} />
54
+ *
55
+ * // Per-instance overrides.
56
+ * <Icon iconName="ic_ccv" color="#5c00b5" size={24} />
57
+ *
58
+ * // Fallback to an external source when the name isn't in the registry.
59
+ * <Icon source="https://cdn.example.com/glyph.svg" />
60
+ *
61
+ * // Slot: render any node as the icon.
62
+ * <Icon><BrandLogo /></Icon>
63
+ * ```
64
+ */
65
+ function Icon({
66
+ iconName,
67
+ source,
68
+ children,
69
+ color,
70
+ size,
71
+ modes: propModes = EMPTY_MODES,
72
+ style: styleProp,
73
+ ...rest
74
+ }) {
75
+ const {
76
+ modes: globalModes
77
+ } = useTokens();
78
+ const modes = useMemo(() => globalModes === EMPTY_MODES && propModes === EMPTY_MODES ? EMPTY_MODES : {
79
+ ...globalModes,
80
+ ...propModes
81
+ }, [globalModes, propModes]);
82
+ const tokens = useMemo(() => resolveIconTokens(modes), [modes]);
83
+ const composedStyle = useMemo(() => styleProp ? [tokens.containerStyle, styleProp] : tokens.containerStyle, [tokens.containerStyle, styleProp]);
84
+ const hasSlot = React.Children.count(children) > 0;
85
+
86
+ // Only fall back to the default glyph when nothing at all is provided so an
87
+ // explicit `source` (without an `iconName`) isn't shadowed by `ic_card`.
88
+ const resolvedName = iconName ?? (source === undefined ? 'ic_card' : undefined);
89
+ const iconColor = color ?? tokens.iconColor;
90
+ const iconSize = size ?? tokens.iconSize;
91
+ return /*#__PURE__*/_jsx(View, {
92
+ style: composedStyle,
93
+ ...rest,
94
+ children: hasSlot ? cloneChildrenWithModes(children, modes) : /*#__PURE__*/_jsx(BaseIcon, {
95
+ name: resolvedName,
96
+ ...(source !== undefined ? {
97
+ source
98
+ } : {}),
99
+ size: iconSize,
100
+ color: iconColor,
101
+ accessibilityElementsHidden: true,
102
+ importantForAccessibility: "no"
103
+ })
104
+ });
105
+ }
106
+ export default /*#__PURE__*/React.memo(Icon);
@@ -44,6 +44,7 @@ export { default as MonthlyStatusGrid, CalendarGlyph } from './MonthlyStatusGrid
44
44
  export { default as Gauge } from './Gauge/Gauge';
45
45
  export { default as HoldingsCard } from './HoldingsCard/HoldingsCard';
46
46
  export { default as HStack } from './HStack/HStack';
47
+ export { default as Icon } from './Icon/Icon';
47
48
  export { default as IconButton } from './IconButton/IconButton';
48
49
  export { default as IconCapsule } from './IconCapsule/IconCapsule';
49
50
  export { default as Image } from './Image/Image';