jfs-components 0.0.78 → 0.0.79
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 +11 -0
- package/lib/commonjs/components/Attached/Attached.js +144 -0
- package/lib/commonjs/components/Card/Card.js +25 -2
- package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +4 -6
- package/lib/commonjs/components/ListItem/ListItem.js +22 -15
- package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +328 -0
- package/lib/commonjs/components/Slot/Slot.js +73 -0
- package/lib/commonjs/components/index.js +21 -0
- package/lib/commonjs/icons/registry.js +1 -1
- package/lib/module/components/Attached/Attached.js +139 -0
- package/lib/module/components/Card/Card.js +25 -2
- package/lib/module/components/FullscreenModal/FullscreenModal.js +4 -6
- package/lib/module/components/ListItem/ListItem.js +22 -15
- package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +322 -0
- package/lib/module/components/Slot/Slot.js +68 -0
- package/lib/module/components/index.js +3 -0
- package/lib/module/icons/registry.js +1 -1
- package/lib/typescript/src/components/Attached/Attached.d.ts +61 -0
- package/lib/typescript/src/components/Card/Card.d.ts +9 -2
- package/lib/typescript/src/components/ListItem/ListItem.d.ts +15 -5
- package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +64 -0
- package/lib/typescript/src/components/Slot/Slot.d.ts +52 -0
- package/lib/typescript/src/components/index.d.ts +3 -0
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/Attached/Attached.tsx +181 -0
- package/src/components/Card/Card.tsx +28 -1
- package/src/components/FullscreenModal/FullscreenModal.tsx +3 -3
- package/src/components/ListItem/ListItem.tsx +35 -16
- package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +426 -0
- package/src/components/Slot/Slot.tsx +91 -0
- package/src/components/index.ts +3 -0
- package/src/icons/registry.ts +1 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import React, { useCallback, useMemo, useState } from 'react';
|
|
4
|
+
import { View } from 'react-native';
|
|
5
|
+
import { useTokens } from '../../design-tokens/JFSThemeProvider';
|
|
6
|
+
import { cloneChildrenWithModes, EMPTY_MODES } from '../../utils/react-utils';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Anchor point on the main content where the attached `badge` is centered.
|
|
10
|
+
* Mirrors the nine Figma `position` variants (corners, edge midpoints, center).
|
|
11
|
+
*/
|
|
12
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
13
|
+
const ZERO_SIZE = {
|
|
14
|
+
width: 0,
|
|
15
|
+
height: 0
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Fraction (0 | 0.5 | 1) of the main content's width/height at which the badge
|
|
20
|
+
* center should sit, derived from the `position` anchor.
|
|
21
|
+
*/
|
|
22
|
+
function resolveAnchorFractions(position) {
|
|
23
|
+
const fx = position.includes('left') ? 0 : position.includes('right') ? 1 : 0.5;
|
|
24
|
+
const fy = position.startsWith('top') ? 0 : position.startsWith('bottom') ? 1 : 0.5;
|
|
25
|
+
return {
|
|
26
|
+
fx,
|
|
27
|
+
fy
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Attached — overlays a small `badge` on top of arbitrary main content,
|
|
33
|
+
* centered on one of nine anchor points (corners, edge midpoints, or center).
|
|
34
|
+
*
|
|
35
|
+
* The badge straddles the chosen anchor regardless of either element's size:
|
|
36
|
+
* both the main content and the badge are measured via `onLayout`, then the
|
|
37
|
+
* badge is absolutely positioned so its center lands exactly on the anchor.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```tsx
|
|
41
|
+
* <Attached position="bottom-right" badge={<InstitutionBadge modes={modes} />} modes={modes}>
|
|
42
|
+
* <IconCapsule iconName="ic_card" modes={modes} />
|
|
43
|
+
* </Attached>
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
function Attached({
|
|
47
|
+
children,
|
|
48
|
+
badge,
|
|
49
|
+
position = 'bottom-right',
|
|
50
|
+
circular = true,
|
|
51
|
+
modes: propModes = EMPTY_MODES,
|
|
52
|
+
style,
|
|
53
|
+
...rest
|
|
54
|
+
}) {
|
|
55
|
+
const {
|
|
56
|
+
modes: globalModes
|
|
57
|
+
} = useTokens();
|
|
58
|
+
const modes = useMemo(() => globalModes === EMPTY_MODES && propModes === EMPTY_MODES ? EMPTY_MODES : {
|
|
59
|
+
...globalModes,
|
|
60
|
+
...propModes
|
|
61
|
+
}, [globalModes, propModes]);
|
|
62
|
+
const [mainSize, setMainSize] = useState(ZERO_SIZE);
|
|
63
|
+
const [badgeSize, setBadgeSize] = useState(ZERO_SIZE);
|
|
64
|
+
const onMainLayout = useCallback(e => {
|
|
65
|
+
const {
|
|
66
|
+
width,
|
|
67
|
+
height
|
|
68
|
+
} = e.nativeEvent.layout;
|
|
69
|
+
setMainSize(prev => prev.width === width && prev.height === height ? prev : {
|
|
70
|
+
width,
|
|
71
|
+
height
|
|
72
|
+
});
|
|
73
|
+
}, []);
|
|
74
|
+
const onBadgeLayout = useCallback(e => {
|
|
75
|
+
const {
|
|
76
|
+
width,
|
|
77
|
+
height
|
|
78
|
+
} = e.nativeEvent.layout;
|
|
79
|
+
setBadgeSize(prev => prev.width === width && prev.height === height ? prev : {
|
|
80
|
+
width,
|
|
81
|
+
height
|
|
82
|
+
});
|
|
83
|
+
}, []);
|
|
84
|
+
const mainChildren = useMemo(() => children != null ? cloneChildrenWithModes(children, modes) : null, [children, modes]);
|
|
85
|
+
const badgeChildren = useMemo(() => badge != null ? cloneChildrenWithModes(badge, modes) : null, [badge, modes]);
|
|
86
|
+
const badgePlacement = useMemo(() => {
|
|
87
|
+
const {
|
|
88
|
+
fx,
|
|
89
|
+
fy
|
|
90
|
+
} = resolveAnchorFractions(position);
|
|
91
|
+
const measured = mainSize.width > 0 && badgeSize.width > 0;
|
|
92
|
+
let anchorX;
|
|
93
|
+
let anchorY;
|
|
94
|
+
if (circular) {
|
|
95
|
+
// Project the anchor onto the circle inscribed in the bounding box, so
|
|
96
|
+
// corner badges land on the circumference (45°) instead of the box corner.
|
|
97
|
+
const cx = mainSize.width / 2;
|
|
98
|
+
const cy = mainSize.height / 2;
|
|
99
|
+
const radius = Math.min(mainSize.width, mainSize.height) / 2;
|
|
100
|
+
const dx = (fx - 0.5) * 2; // -1 | 0 | 1
|
|
101
|
+
const dy = (fy - 0.5) * 2; // -1 | 0 | 1
|
|
102
|
+
const len = Math.hypot(dx, dy) || 1; // 'center' → 0, guard against /0
|
|
103
|
+
anchorX = cx + dx / len * radius;
|
|
104
|
+
anchorY = cy + dy / len * radius;
|
|
105
|
+
} else {
|
|
106
|
+
anchorX = mainSize.width * fx;
|
|
107
|
+
anchorY = mainSize.height * fy;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
position: 'absolute',
|
|
111
|
+
left: anchorX - badgeSize.width / 2,
|
|
112
|
+
top: anchorY - badgeSize.height / 2,
|
|
113
|
+
// Hide until both elements are measured to avoid a one-frame flash at (0,0).
|
|
114
|
+
opacity: measured ? 1 : 0
|
|
115
|
+
};
|
|
116
|
+
}, [position, circular, mainSize, badgeSize]);
|
|
117
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
118
|
+
style: [styles.container, style],
|
|
119
|
+
...rest,
|
|
120
|
+
children: [/*#__PURE__*/_jsx(View, {
|
|
121
|
+
onLayout: onMainLayout,
|
|
122
|
+
children: mainChildren
|
|
123
|
+
}), badgeChildren != null && /*#__PURE__*/_jsx(View, {
|
|
124
|
+
style: badgePlacement,
|
|
125
|
+
onLayout: onBadgeLayout,
|
|
126
|
+
pointerEvents: "box-none",
|
|
127
|
+
children: badgeChildren
|
|
128
|
+
})]
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const styles = {
|
|
132
|
+
// alignSelf flex-start so the wrapper hugs the main content; anchors are then
|
|
133
|
+
// computed relative to the content size rather than a stretched parent.
|
|
134
|
+
container: {
|
|
135
|
+
position: 'relative',
|
|
136
|
+
alignSelf: 'flex-start'
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
export default /*#__PURE__*/React.memo(Attached);
|
|
@@ -16,9 +16,11 @@ const CardContext = /*#__PURE__*/createContext({});
|
|
|
16
16
|
* Card component implementation from Figma node 765:6186.
|
|
17
17
|
*
|
|
18
18
|
* Supports a `media` slot (with aspect ratio) and a content area.
|
|
19
|
+
* Supports an optional `header` slot (e.g. a brand logo), a `media` slot
|
|
20
|
+
* (with aspect ratio) and a content area.
|
|
19
21
|
* Usage:
|
|
20
22
|
* ```tsx
|
|
21
|
-
* <Card media={<Image source={...} />} modes={modes}>
|
|
23
|
+
* <Card header={<GoldLogo />} media={<Image source={...} />} modes={modes}>
|
|
22
24
|
* <Card.SupportText>Support text</Card.SupportText>
|
|
23
25
|
* <Card.Title>Title</Card.Title>
|
|
24
26
|
* <Card.SupportText>Support text</Card.SupportText>
|
|
@@ -26,6 +28,7 @@ const CardContext = /*#__PURE__*/createContext({});
|
|
|
26
28
|
* ```
|
|
27
29
|
*/
|
|
28
30
|
export function Card({
|
|
31
|
+
header,
|
|
29
32
|
media,
|
|
30
33
|
children,
|
|
31
34
|
modes = EMPTY_MODES,
|
|
@@ -53,6 +56,14 @@ export function Card({
|
|
|
53
56
|
...modes
|
|
54
57
|
}
|
|
55
58
|
}) : media;
|
|
59
|
+
|
|
60
|
+
// Clone header to pass modes if it's a valid element
|
|
61
|
+
const headerWithModes = /*#__PURE__*/isValidElement(header) ? /*#__PURE__*/cloneElement(header, {
|
|
62
|
+
modes: {
|
|
63
|
+
...header.props.modes,
|
|
64
|
+
...modes
|
|
65
|
+
}
|
|
66
|
+
}) : header;
|
|
56
67
|
const containerStyle = {
|
|
57
68
|
backgroundColor,
|
|
58
69
|
borderColor,
|
|
@@ -63,6 +74,15 @@ export function Card({
|
|
|
63
74
|
paddingVertical,
|
|
64
75
|
overflow: 'hidden' // Ensure border radius clips content
|
|
65
76
|
};
|
|
77
|
+
|
|
78
|
+
// Header wrap uses fixed padding from Figma (no dedicated tokens defined).
|
|
79
|
+
const headerWrapperStyle = {
|
|
80
|
+
width: '100%',
|
|
81
|
+
flexDirection: 'row',
|
|
82
|
+
alignItems: 'flex-start',
|
|
83
|
+
paddingHorizontal: 12,
|
|
84
|
+
paddingVertical: 16
|
|
85
|
+
};
|
|
66
86
|
const mediaWrapperStyle = {
|
|
67
87
|
width: '100%',
|
|
68
88
|
aspectRatio: mediaAspectRatio,
|
|
@@ -83,7 +103,10 @@ export function Card({
|
|
|
83
103
|
},
|
|
84
104
|
children: /*#__PURE__*/_jsxs(View, {
|
|
85
105
|
style: [containerStyle, style],
|
|
86
|
-
children: [
|
|
106
|
+
children: [header && /*#__PURE__*/_jsx(View, {
|
|
107
|
+
style: headerWrapperStyle,
|
|
108
|
+
children: headerWithModes
|
|
109
|
+
}), media && /*#__PURE__*/_jsx(View, {
|
|
87
110
|
style: mediaWrapperStyle,
|
|
88
111
|
children: mediaWithModes
|
|
89
112
|
}), /*#__PURE__*/_jsx(View, {
|
|
@@ -10,6 +10,7 @@ import Button from '../Button/Button';
|
|
|
10
10
|
import Disclaimer from '../Disclaimer/Disclaimer';
|
|
11
11
|
import IconButton from '../IconButton/IconButton';
|
|
12
12
|
import ActionFooter from '../ActionFooter/ActionFooter';
|
|
13
|
+
import Slot from '../Slot/Slot';
|
|
13
14
|
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
// Forced modes
|
|
@@ -269,8 +270,9 @@ function FullscreenModal({
|
|
|
269
270
|
if (footer) {
|
|
270
271
|
footerContent = footer;
|
|
271
272
|
} else if (primaryActionLabel) {
|
|
272
|
-
footerContent = /*#__PURE__*/_jsxs(
|
|
273
|
-
|
|
273
|
+
footerContent = /*#__PURE__*/_jsxs(Slot, {
|
|
274
|
+
layoutDirection: "vertical",
|
|
275
|
+
modes: modes,
|
|
274
276
|
children: [/*#__PURE__*/_jsx(Button, {
|
|
275
277
|
label: primaryActionLabel,
|
|
276
278
|
modes: modes,
|
|
@@ -335,10 +337,6 @@ const scrollViewStyle = {
|
|
|
335
337
|
const scrollContentStyle = {
|
|
336
338
|
flexGrow: 1
|
|
337
339
|
};
|
|
338
|
-
const footerColumnStyle = {
|
|
339
|
-
width: '100%',
|
|
340
|
-
gap: 8
|
|
341
|
-
};
|
|
342
340
|
const fullWidthStyle = {
|
|
343
341
|
width: '100%'
|
|
344
342
|
};
|
|
@@ -15,9 +15,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
15
15
|
const IS_IOS = Platform.OS === 'ios';
|
|
16
16
|
const PRESS_DELAY = IS_IOS ? 130 : 0;
|
|
17
17
|
|
|
18
|
-
// Forced modes for the
|
|
19
|
-
// overridden by external modes. Frozen so identity is stable across
|
|
20
|
-
|
|
18
|
+
// Forced modes for the leading/trailing slots — `Context: 'ListItem'` can
|
|
19
|
+
// never be overridden by external modes. Frozen so identity is stable across
|
|
20
|
+
// renders. Applied to both slots so they cascade modes identically.
|
|
21
|
+
const SLOT_FORCED_MODES = Object.freeze({
|
|
21
22
|
Context: 'ListItem'
|
|
22
23
|
});
|
|
23
24
|
|
|
@@ -32,7 +33,7 @@ const pressedOverlayStyle = {
|
|
|
32
33
|
// ---------------------------------------------------------------------------
|
|
33
34
|
|
|
34
35
|
function resolveListItemTokens(modes) {
|
|
35
|
-
// Modes used to cascade into slot children (leading / supportSlot /
|
|
36
|
+
// Modes used to cascade into slot children (leading / supportSlot / trailing).
|
|
36
37
|
// We do NOT inject an `AppearanceBrand` default here: slot content such as
|
|
37
38
|
// Buttons or Badges carry their own intended appearance, so forcing one onto
|
|
38
39
|
// them would be surprising.
|
|
@@ -131,9 +132,11 @@ const verticalSupportTextOverride = {
|
|
|
131
132
|
* - **design-token driven styling** via `getVariableByName` and `modes`
|
|
132
133
|
*
|
|
133
134
|
* Wherever the Figma layer name contains "Slot", this component exposes a
|
|
134
|
-
* dedicated React "slot" prop
|
|
135
|
+
* dedicated React "slot" prop. The leading and trailing edges share a
|
|
136
|
+
* symmetric `leading` / `trailing` slot API:
|
|
137
|
+
* - Slot "leading" → `leading`
|
|
135
138
|
* - Slot "support text" → `supportSlot`
|
|
136
|
-
* - Slot "
|
|
139
|
+
* - Slot "trailing" → `trailing`
|
|
137
140
|
*
|
|
138
141
|
* @component
|
|
139
142
|
* @param {Object} props
|
|
@@ -141,9 +144,9 @@ const verticalSupportTextOverride = {
|
|
|
141
144
|
* @param {string} [props.title='Title'] - Primary title used in the horizontal layout.
|
|
142
145
|
* @param {string} [props.supportText='Support Text'] - Support text used in both layouts when `supportSlot` is not provided.
|
|
143
146
|
* @param {boolean} [props.showSupportText=true] - Toggles rendering of the support text in Horizontal layout.
|
|
144
|
-
* @param {React.ReactNode} [props.leading] - Optional leading
|
|
147
|
+
* @param {React.ReactNode} [props.leading] - Optional leading slot. Defaults to `IconCapsule`.
|
|
145
148
|
* @param {React.ReactNode} [props.supportSlot] - Optional custom slot used instead of the default support text block.
|
|
146
|
-
* @param {React.ReactNode} [props.
|
|
149
|
+
* @param {React.ReactNode} [props.trailing] - Optional trailing slot (Figma Slot "trailing"). Horizontal layout only.
|
|
147
150
|
* @param {boolean} [props.navArrow=true] - Whether to show NavArrow on the far right (Horizontal layout only).
|
|
148
151
|
* @param {Object} [props.modes={}] - Modes object passed to `getVariableByName` for all design tokens.
|
|
149
152
|
* @param {Function} [props.onPress] - When provided, the entire item becomes pressable (navigation variant).
|
|
@@ -172,6 +175,7 @@ function ListItemImpl({
|
|
|
172
175
|
showSupportText = true,
|
|
173
176
|
leading,
|
|
174
177
|
supportSlot,
|
|
178
|
+
trailing,
|
|
175
179
|
endSlot,
|
|
176
180
|
navArrow = true,
|
|
177
181
|
modes = EMPTY_MODES,
|
|
@@ -209,7 +213,7 @@ function ListItemImpl({
|
|
|
209
213
|
// Process leading slot to pass modes to children. Memoized on
|
|
210
214
|
// (leading, resolvedModes) so a parent re-render doesn't re-walk the tree.
|
|
211
215
|
const leadingElement = useMemo(() => {
|
|
212
|
-
const processed = leading ? cloneChildrenWithModes(React.Children.toArray(leading), tokens.resolvedModes) : [];
|
|
216
|
+
const processed = leading ? cloneChildrenWithModes(React.Children.toArray(leading), tokens.resolvedModes, SLOT_FORCED_MODES) : [];
|
|
213
217
|
if (processed.length === 0) {
|
|
214
218
|
return /*#__PURE__*/_jsx(IconCapsule, {
|
|
215
219
|
modes: tokens.resolvedModes,
|
|
@@ -223,11 +227,14 @@ function ListItemImpl({
|
|
|
223
227
|
const processed = cloneChildrenWithModes(React.Children.toArray(supportSlot), tokens.resolvedModes);
|
|
224
228
|
return processed.length === 1 ? processed[0] : processed;
|
|
225
229
|
}, [supportSlot, tokens.resolvedModes]);
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
230
|
+
|
|
231
|
+
// `trailing` wins; `endSlot` is the deprecated alias kept for back-compat.
|
|
232
|
+
const trailingContent = trailing ?? endSlot;
|
|
233
|
+
const processedTrailing = useMemo(() => {
|
|
234
|
+
if (!trailingContent) return null;
|
|
235
|
+
const processed = cloneChildrenWithModes(React.Children.toArray(trailingContent), tokens.resolvedModes, SLOT_FORCED_MODES);
|
|
229
236
|
return processed.length === 1 ? processed[0] : processed;
|
|
230
|
-
}, [
|
|
237
|
+
}, [trailingContent, tokens.resolvedModes]);
|
|
231
238
|
const renderSupportContent = () => {
|
|
232
239
|
if (processedSupportSlot) return processedSupportSlot;
|
|
233
240
|
|
|
@@ -273,9 +280,9 @@ function ListItemImpl({
|
|
|
273
280
|
numberOfLines: 1,
|
|
274
281
|
children: title
|
|
275
282
|
}), showSupportText && renderSupportContent()]
|
|
276
|
-
}),
|
|
283
|
+
}), processedTrailing ? /*#__PURE__*/_jsx(View, {
|
|
277
284
|
style: tokens.trailingWrapperStyle,
|
|
278
|
-
children:
|
|
285
|
+
children: processedTrailing
|
|
279
286
|
}) : null, navArrow && /*#__PURE__*/_jsx(NavArrow, {
|
|
280
287
|
direction: "Forward",
|
|
281
288
|
modes: tokens.resolvedModes
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useCallback } from 'react';
|
|
4
|
+
import { View, Text, Pressable, Platform } from 'react-native';
|
|
5
|
+
import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
|
|
6
|
+
import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils';
|
|
7
|
+
import Icon from '../../icons/Icon';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A single plan column header (the label column has no header of its own).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Value rendered inside a plan cell.
|
|
15
|
+
* - `string` / `number` → rendered as value text.
|
|
16
|
+
* - `false` → renders the muted "not available" cross icon.
|
|
17
|
+
* - any React node → rendered as-is (e.g. a `Badge`, `MoneyValue`, icon…).
|
|
18
|
+
* - `null` / `undefined` / `true` → empty cell.
|
|
19
|
+
*/
|
|
20
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
21
|
+
const DEFAULT_COLUMNS = [{
|
|
22
|
+
label: 'Your plan'
|
|
23
|
+
}, {
|
|
24
|
+
label: 'JioFinance+',
|
|
25
|
+
brand: true
|
|
26
|
+
}];
|
|
27
|
+
const DEFAULT_ROWS = [{
|
|
28
|
+
label: 'JioPoints multiplier',
|
|
29
|
+
values: ['1x', '1.25x']
|
|
30
|
+
}, {
|
|
31
|
+
label: 'Cashback',
|
|
32
|
+
showInfo: true,
|
|
33
|
+
values: [false, 'Upto ₹5000']
|
|
34
|
+
}, {
|
|
35
|
+
label: 'Bonus JioGold',
|
|
36
|
+
showInfo: true,
|
|
37
|
+
values: [false, '1%']
|
|
38
|
+
}];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* PlanComparisonCard renders a compact comparison table that pits the user's
|
|
42
|
+
* current plan against one or more alternative plans across a set of feature
|
|
43
|
+
* rows. Implementation of Figma node `4498:2968` (`PlanComparisonCard`).
|
|
44
|
+
*
|
|
45
|
+
* The leading column holds feature labels (with an optional info icon); every
|
|
46
|
+
* other column maps to a plan in `columns`. Each cell value can be plain text,
|
|
47
|
+
* a "not available" cross (`false`), or any custom React node.
|
|
48
|
+
*
|
|
49
|
+
* @component
|
|
50
|
+
* @example
|
|
51
|
+
* ```tsx
|
|
52
|
+
* <PlanComparisonCard
|
|
53
|
+
* columns={[{ label: 'Your plan' }, { label: 'JioFinance+', brand: true }]}
|
|
54
|
+
* rows={[
|
|
55
|
+
* { label: 'JioPoints multiplier', values: ['1x', '1.25x'] },
|
|
56
|
+
* { label: 'Cashback', showInfo: true, values: [false, 'Upto ₹5000'] },
|
|
57
|
+
* ]}
|
|
58
|
+
* />
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
/** Keeps every text layer on a single line; columns grow to fit content. */
|
|
62
|
+
const NO_WRAP_TEXT = {
|
|
63
|
+
flexShrink: 0,
|
|
64
|
+
...(Platform.OS === 'web' ? {
|
|
65
|
+
whiteSpace: 'nowrap'
|
|
66
|
+
} : {})
|
|
67
|
+
};
|
|
68
|
+
function PlanComparisonCard({
|
|
69
|
+
columns = DEFAULT_COLUMNS,
|
|
70
|
+
rows = DEFAULT_ROWS,
|
|
71
|
+
labelColumnFlex = 0,
|
|
72
|
+
modes = EMPTY_MODES,
|
|
73
|
+
style
|
|
74
|
+
}) {
|
|
75
|
+
/** Natural widths from header labels (plan columns only). */
|
|
76
|
+
const [headerWidths, setHeaderWidths] = useState([]);
|
|
77
|
+
/** Natural widths from table body columns. */
|
|
78
|
+
const [bodyWidths, setBodyWidths] = useState([]);
|
|
79
|
+
const setMeasuredWidth = useCallback((setter, index, width) => {
|
|
80
|
+
setter(prev => {
|
|
81
|
+
if (prev[index] === width) return prev;
|
|
82
|
+
const next = [...prev];
|
|
83
|
+
next[index] = width;
|
|
84
|
+
return next;
|
|
85
|
+
});
|
|
86
|
+
}, []);
|
|
87
|
+
const onHeaderColumnLayout = useCallback((index, event) => {
|
|
88
|
+
setMeasuredWidth(setHeaderWidths, index, event.nativeEvent.layout.width);
|
|
89
|
+
}, [setMeasuredWidth]);
|
|
90
|
+
const onBodyColumnLayout = useCallback((index, event) => {
|
|
91
|
+
setMeasuredWidth(setBodyWidths, index, event.nativeEvent.layout.width);
|
|
92
|
+
}, [setMeasuredWidth]);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Shared width for header + body cells in a column (max of natural header
|
|
96
|
+
* label vs body content). No columnGap between columns — gaps would shift
|
|
97
|
+
* headers relative to the flush table grid below.
|
|
98
|
+
*/
|
|
99
|
+
const columnWidthStyle = index => {
|
|
100
|
+
const width = Math.max(headerWidths[index] ?? 0, bodyWidths[index] ?? 0);
|
|
101
|
+
if (width > 0) {
|
|
102
|
+
return {
|
|
103
|
+
width,
|
|
104
|
+
minWidth: width,
|
|
105
|
+
flexShrink: 0,
|
|
106
|
+
flexGrow: 0
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
flexShrink: 0,
|
|
111
|
+
flexGrow: 0
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Container
|
|
116
|
+
const gap = getVariableByName('planComparisonCard/gap', modes) ?? 16;
|
|
117
|
+
|
|
118
|
+
// Header
|
|
119
|
+
const headerFg = getVariableByName('planComparisonCard/header/fg', modes) ?? '#ffffff';
|
|
120
|
+
const headerBrandFg = getVariableByName('planComparisonCard/header/brand/fg', modes) ?? '#cea15a';
|
|
121
|
+
const headerFontSize = getVariableByName('planComparisonCard/header/fontSize', modes) ?? 14;
|
|
122
|
+
const headerFontFamily = getVariableByName('planComparisonCard/header/fontFamily', modes) ?? 'JioType Var';
|
|
123
|
+
const headerLineHeight = getVariableByName('planComparisonCard/header/lineHeight', modes) ?? 18;
|
|
124
|
+
const headerFontWeight = getVariableByName('planComparisonCard/header/fontWeight', modes) ?? '500';
|
|
125
|
+
|
|
126
|
+
// Table
|
|
127
|
+
const tableBackground = getVariableByName('planComparisonCard/tableRow/background', modes) ?? '#141414';
|
|
128
|
+
const tableRadius = getVariableByName('planComparisonCard/tableRow/radius', modes) ?? 16;
|
|
129
|
+
const tableBorderSize = getVariableByName('planComparisonCard/tableRow/border/size', modes) ?? 1;
|
|
130
|
+
const tableBorderColor = getVariableByName('planComparisonCard/tableRow/border/color', modes) ?? '#1e1a14';
|
|
131
|
+
|
|
132
|
+
// Cell
|
|
133
|
+
const cellPadding = getVariableByName('planComparisonCard/tableCell/padding', modes) ?? 12;
|
|
134
|
+
const cellGap = getVariableByName('planComparisonCard/tableCell/gap', modes) ?? 2;
|
|
135
|
+
const cellMinHeight = getVariableByName('planComparisonCard/tableCell/height', modes) ?? 46;
|
|
136
|
+
const cellBorderSize = getVariableByName('planComparisonCard/tableCell/border/size', modes) ?? 1;
|
|
137
|
+
const cellBorderColor = getVariableByName('planComparisonCard/tableCell/border/color', modes) ?? '#1e1a14';
|
|
138
|
+
|
|
139
|
+
// Cell label
|
|
140
|
+
const labelColor = getVariableByName('planComparisonCard/tableCell/label/color', modes) ?? '#ffffff';
|
|
141
|
+
const labelDisabledColor = getVariableByName('planComparisonCard/tableCell/label/disabled/color', modes) ?? '#91949c';
|
|
142
|
+
const labelFontSize = getVariableByName('planComparisonCard/tableCell/label/fontSize', modes) ?? 12;
|
|
143
|
+
const labelFontFamily = getVariableByName('planComparisonCard/tableCell/label/fontFamily', modes) ?? 'JioType Var';
|
|
144
|
+
const labelLineHeight = getVariableByName('planComparisonCard/tableCell/label/lineHeight', modes) ?? 16;
|
|
145
|
+
const labelFontWeight = getVariableByName('planComparisonCard/tableCell/label/fontWeight', modes) ?? '400';
|
|
146
|
+
|
|
147
|
+
// Cell value
|
|
148
|
+
const valueColor = getVariableByName('planComparisonCard/tableCell/value/color', modes) ?? '#ffffff';
|
|
149
|
+
const valueFontSize = getVariableByName('planComparisonCard/tableCell/value/fontSize', modes) ?? 12;
|
|
150
|
+
const valueFontFamily = getVariableByName('planComparisonCard/tableCell/value/fontFamily', modes) ?? 'JioType Var';
|
|
151
|
+
const valueLineHeight = getVariableByName('planComparisonCard/tableCell/value/lineHeight', modes) ?? 16;
|
|
152
|
+
const valueFontWeight = getVariableByName('planComparisonCard/tableCell/value/fontWeight', modes) ?? '500';
|
|
153
|
+
|
|
154
|
+
// Icon
|
|
155
|
+
const iconColor = getVariableByName('planComparisonCard/icon/color', modes) ?? '#ffffff';
|
|
156
|
+
const iconSize = getVariableByName('planComparisonCard/icon/size', modes) ?? 16;
|
|
157
|
+
const toWeight = w => typeof w === 'number' ? `${w}` : w;
|
|
158
|
+
const headerTextStyle = {
|
|
159
|
+
...NO_WRAP_TEXT,
|
|
160
|
+
fontFamily: headerFontFamily,
|
|
161
|
+
fontSize: headerFontSize,
|
|
162
|
+
lineHeight: headerLineHeight,
|
|
163
|
+
fontWeight: toWeight(headerFontWeight),
|
|
164
|
+
textAlign: 'center'
|
|
165
|
+
};
|
|
166
|
+
const labelTextStyle = {
|
|
167
|
+
...NO_WRAP_TEXT,
|
|
168
|
+
color: labelColor,
|
|
169
|
+
fontFamily: labelFontFamily,
|
|
170
|
+
fontSize: labelFontSize,
|
|
171
|
+
lineHeight: labelLineHeight,
|
|
172
|
+
fontWeight: toWeight(labelFontWeight)
|
|
173
|
+
};
|
|
174
|
+
const valueTextStyle = {
|
|
175
|
+
...NO_WRAP_TEXT,
|
|
176
|
+
color: valueColor,
|
|
177
|
+
fontFamily: valueFontFamily,
|
|
178
|
+
fontSize: valueFontSize,
|
|
179
|
+
lineHeight: valueLineHeight,
|
|
180
|
+
fontWeight: toWeight(valueFontWeight),
|
|
181
|
+
textAlign: 'center'
|
|
182
|
+
};
|
|
183
|
+
const planHeaderColumnStyle = {
|
|
184
|
+
alignItems: 'center',
|
|
185
|
+
justifyContent: 'center'
|
|
186
|
+
};
|
|
187
|
+
const renderValue = (value, cellKey) => {
|
|
188
|
+
// "Not available" → muted cross icon.
|
|
189
|
+
if (value === false) {
|
|
190
|
+
return /*#__PURE__*/_jsx(Icon, {
|
|
191
|
+
name: "ic_close",
|
|
192
|
+
size: iconSize,
|
|
193
|
+
color: labelDisabledColor
|
|
194
|
+
}, cellKey);
|
|
195
|
+
}
|
|
196
|
+
// Empty cell.
|
|
197
|
+
if (value === null || value === undefined || value === true) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
// Text content.
|
|
201
|
+
if (typeof value === 'string' || typeof value === 'number') {
|
|
202
|
+
return /*#__PURE__*/_jsx(Text, {
|
|
203
|
+
style: valueTextStyle,
|
|
204
|
+
children: value
|
|
205
|
+
}, cellKey);
|
|
206
|
+
}
|
|
207
|
+
// Custom node — forward modes so themed children stay in sync.
|
|
208
|
+
return cloneChildrenWithModes(value, modes);
|
|
209
|
+
};
|
|
210
|
+
const labelCellStyle = {
|
|
211
|
+
flexDirection: 'row',
|
|
212
|
+
alignItems: 'center',
|
|
213
|
+
gap: cellGap,
|
|
214
|
+
padding: cellPadding,
|
|
215
|
+
minHeight: cellMinHeight,
|
|
216
|
+
flexShrink: 0
|
|
217
|
+
};
|
|
218
|
+
const valueCellStyle = {
|
|
219
|
+
flexDirection: 'row',
|
|
220
|
+
alignItems: 'center',
|
|
221
|
+
justifyContent: 'center',
|
|
222
|
+
padding: cellPadding,
|
|
223
|
+
minHeight: cellMinHeight,
|
|
224
|
+
flexShrink: 0
|
|
225
|
+
};
|
|
226
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
227
|
+
style: [{
|
|
228
|
+
gap,
|
|
229
|
+
alignSelf: 'flex-start'
|
|
230
|
+
}, style],
|
|
231
|
+
children: [/*#__PURE__*/_jsxs(View, {
|
|
232
|
+
style: {
|
|
233
|
+
flexDirection: 'row',
|
|
234
|
+
alignItems: 'flex-end'
|
|
235
|
+
},
|
|
236
|
+
children: [/*#__PURE__*/_jsx(View, {
|
|
237
|
+
style: [columnWidthStyle(0), labelColumnFlex > 0 ? {
|
|
238
|
+
flexGrow: labelColumnFlex
|
|
239
|
+
} : undefined]
|
|
240
|
+
}), columns.map((column, index) => {
|
|
241
|
+
const colIndex = index + 1;
|
|
242
|
+
return /*#__PURE__*/_jsx(View, {
|
|
243
|
+
onLayout: e => onHeaderColumnLayout(colIndex, e),
|
|
244
|
+
style: [columnWidthStyle(colIndex), planHeaderColumnStyle],
|
|
245
|
+
children: /*#__PURE__*/_jsx(Text, {
|
|
246
|
+
style: [headerTextStyle, {
|
|
247
|
+
color: column.brand ? headerBrandFg : headerFg,
|
|
248
|
+
alignSelf: 'center'
|
|
249
|
+
}],
|
|
250
|
+
children: column.label
|
|
251
|
+
})
|
|
252
|
+
}, column.label ?? index);
|
|
253
|
+
})]
|
|
254
|
+
}), /*#__PURE__*/_jsxs(View, {
|
|
255
|
+
style: {
|
|
256
|
+
flexDirection: 'row',
|
|
257
|
+
alignSelf: 'flex-start',
|
|
258
|
+
backgroundColor: tableBackground,
|
|
259
|
+
borderWidth: tableBorderSize,
|
|
260
|
+
borderColor: tableBorderColor,
|
|
261
|
+
borderRadius: tableRadius,
|
|
262
|
+
overflow: 'hidden'
|
|
263
|
+
},
|
|
264
|
+
children: [/*#__PURE__*/_jsx(View, {
|
|
265
|
+
onLayout: e => onBodyColumnLayout(0, e),
|
|
266
|
+
style: [columnWidthStyle(0), labelColumnFlex > 0 ? {
|
|
267
|
+
flexGrow: labelColumnFlex
|
|
268
|
+
} : undefined],
|
|
269
|
+
children: rows.map((row, rowIndex) => {
|
|
270
|
+
const isLast = rowIndex === rows.length - 1;
|
|
271
|
+
const showInfo = row.showInfo || row.onInfoPress != null;
|
|
272
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
273
|
+
style: [labelCellStyle, {
|
|
274
|
+
borderBottomWidth: isLast ? 0 : cellBorderSize,
|
|
275
|
+
borderBottomColor: cellBorderColor
|
|
276
|
+
}],
|
|
277
|
+
children: [/*#__PURE__*/_jsx(Text, {
|
|
278
|
+
style: labelTextStyle,
|
|
279
|
+
children: row.label
|
|
280
|
+
}), showInfo && (row.onInfoPress ? /*#__PURE__*/_jsx(Pressable, {
|
|
281
|
+
onPress: row.onInfoPress,
|
|
282
|
+
accessibilityRole: "button",
|
|
283
|
+
accessibilityLabel: `More information about ${row.label}`,
|
|
284
|
+
hitSlop: 8,
|
|
285
|
+
children: /*#__PURE__*/_jsx(Icon, {
|
|
286
|
+
name: "ic_info",
|
|
287
|
+
size: iconSize,
|
|
288
|
+
color: iconColor
|
|
289
|
+
})
|
|
290
|
+
}) : /*#__PURE__*/_jsx(Icon, {
|
|
291
|
+
name: "ic_info",
|
|
292
|
+
size: iconSize,
|
|
293
|
+
color: iconColor
|
|
294
|
+
}))]
|
|
295
|
+
}, row.key ?? `${row.label}-${rowIndex}`);
|
|
296
|
+
})
|
|
297
|
+
}), columns.map((column, colIndex) => {
|
|
298
|
+
const colIndexWidth = colIndex + 1;
|
|
299
|
+
return /*#__PURE__*/_jsx(View, {
|
|
300
|
+
onLayout: e => onBodyColumnLayout(colIndexWidth, e),
|
|
301
|
+
style: [columnWidthStyle(colIndexWidth), planHeaderColumnStyle],
|
|
302
|
+
children: rows.map((row, rowIndex) => {
|
|
303
|
+
const isLast = rowIndex === rows.length - 1;
|
|
304
|
+
return /*#__PURE__*/_jsx(View, {
|
|
305
|
+
style: [valueCellStyle, {
|
|
306
|
+
borderBottomWidth: isLast ? 0 : cellBorderSize,
|
|
307
|
+
borderBottomColor: cellBorderColor
|
|
308
|
+
}],
|
|
309
|
+
children: /*#__PURE__*/_jsx(View, {
|
|
310
|
+
style: {
|
|
311
|
+
flexShrink: 0
|
|
312
|
+
},
|
|
313
|
+
children: renderValue(row.values?.[colIndex], `${rowIndex}-${colIndex}`)
|
|
314
|
+
})
|
|
315
|
+
}, row.key ?? `${row.label}-${rowIndex}`);
|
|
316
|
+
})
|
|
317
|
+
}, column.label ?? colIndex);
|
|
318
|
+
})]
|
|
319
|
+
})]
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
export default PlanComparisonCard;
|