jfs-components 0.1.50 → 0.1.54
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/.cursor/D2C-FLOW.md +94 -0
- package/.cursor/D2C-SCREEN.md +119 -0
- package/.cursor/commands/D2C.md +101 -0
- package/CHANGELOG.md +18 -0
- package/lib/commonjs/components/ActionFooter/ActionFooter.js +319 -135
- package/lib/commonjs/components/Badge/Badge.js +20 -1
- package/lib/commonjs/components/Button/Button.js +139 -47
- package/lib/commonjs/components/ButtonGroup/ButtonGroup.js +1 -1
- package/lib/commonjs/components/Card/Card.js +151 -112
- package/lib/commonjs/components/Card/assets/gold-logo-mark.png +0 -0
- package/lib/commonjs/components/CategoryCard/CategoryCard.js +97 -63
- package/lib/commonjs/components/CategoryCard/assets/sample.png +0 -0
- package/lib/commonjs/components/CheckboxItem/CheckboxItem.js +59 -10
- package/lib/commonjs/components/ExpandableCheckbox/ExpandableCheckbox.js +132 -49
- package/lib/commonjs/components/HelloJioInput/HelloJioInput.js +56 -14
- package/lib/commonjs/components/IconCapsule/IconCapsule.js +1 -1
- package/lib/commonjs/components/MetricData/MetricData.js +26 -13
- package/lib/commonjs/components/MoneyValue/MoneyValue.js +1 -1
- package/lib/commonjs/components/Nudge/Nudge.js +1 -1
- package/lib/commonjs/components/PdpCcCard/PdpCcCard.js +47 -91
- package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +52 -45
- package/lib/commonjs/components/PortfolioHero/PortfolioHero.js +1 -1
- package/lib/commonjs/components/Slider/Slider.js +3 -3
- package/lib/commonjs/components/TextInput/TextInput.js +26 -8
- package/lib/commonjs/components/Tooltip/Tooltip.js +3 -5
- package/lib/commonjs/components/index.js +7 -1
- package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/commonjs/design-tokens/figma-modes.generated.js +13 -13
- package/lib/commonjs/icons/registry.js +1 -1
- package/lib/module/components/ActionFooter/ActionFooter.js +322 -138
- package/lib/module/components/Badge/Badge.js +20 -1
- package/lib/module/components/Button/Button.js +140 -48
- package/lib/module/components/ButtonGroup/ButtonGroup.js +1 -1
- package/lib/module/components/Card/Card.js +152 -114
- package/lib/module/components/Card/assets/gold-logo-mark.png +0 -0
- package/lib/module/components/CategoryCard/CategoryCard.js +97 -63
- package/lib/module/components/CategoryCard/assets/sample.png +0 -0
- package/lib/module/components/CheckboxItem/CheckboxItem.js +59 -10
- package/lib/module/components/ExpandableCheckbox/ExpandableCheckbox.js +135 -52
- package/lib/module/components/HelloJioInput/HelloJioInput.js +56 -14
- package/lib/module/components/IconCapsule/IconCapsule.js +1 -1
- package/lib/module/components/MetricData/MetricData.js +26 -14
- package/lib/module/components/MoneyValue/MoneyValue.js +1 -1
- package/lib/module/components/Nudge/Nudge.js +1 -1
- package/lib/module/components/PdpCcCard/PdpCcCard.js +48 -92
- package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +52 -45
- package/lib/module/components/PortfolioHero/PortfolioHero.js +1 -1
- package/lib/module/components/Slider/Slider.js +3 -3
- package/lib/module/components/TextInput/TextInput.js +27 -9
- package/lib/module/components/Tooltip/Tooltip.js +3 -5
- package/lib/module/components/index.js +1 -1
- package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/module/design-tokens/figma-modes.generated.js +13 -13
- package/lib/module/icons/registry.js +1 -1
- package/lib/typescript/src/components/ActionFooter/ActionFooter.d.ts +50 -21
- package/lib/typescript/src/components/Button/Button.d.ts +24 -1
- package/lib/typescript/src/components/ButtonGroup/ButtonGroup.d.ts +2 -2
- package/lib/typescript/src/components/Card/Card.d.ts +54 -25
- package/lib/typescript/src/components/CategoryCard/CategoryCard.d.ts +29 -12
- package/lib/typescript/src/components/CheckboxItem/CheckboxItem.d.ts +27 -9
- package/lib/typescript/src/components/ExpandableCheckbox/ExpandableCheckbox.d.ts +37 -12
- package/lib/typescript/src/components/IconCapsule/IconCapsule.d.ts +1 -1
- package/lib/typescript/src/components/MetricData/MetricData.d.ts +7 -5
- package/lib/typescript/src/components/PdpCcCard/PdpCcCard.d.ts +16 -6
- package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +9 -2
- package/lib/typescript/src/components/index.d.ts +2 -2
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/package.json +4 -2
- package/src/components/ActionFooter/ActionFooter.tsx +375 -141
- package/src/components/Badge/Badge.tsx +21 -1
- package/src/components/Button/Button.tsx +192 -44
- package/src/components/ButtonGroup/ButtonGroup.tsx +2 -2
- package/src/components/Card/Card.tsx +370 -212
- package/src/components/Card/assets/gold-logo-mark.png +0 -0
- package/src/components/CategoryCard/CategoryCard.tsx +130 -84
- package/src/components/CategoryCard/assets/sample.png +0 -0
- package/src/components/CheckboxItem/CheckboxItem.tsx +72 -12
- package/src/components/ExpandableCheckbox/ExpandableCheckbox.tsx +186 -67
- package/src/components/HelloJioInput/HelloJioInput.tsx +58 -17
- package/src/components/IconCapsule/IconCapsule.tsx +1 -1
- package/src/components/MetricData/MetricData.tsx +39 -22
- package/src/components/MoneyValue/MoneyValue.tsx +1 -1
- package/src/components/Nudge/Nudge.tsx +1 -1
- package/src/components/PdpCcCard/PdpCcCard.tsx +41 -92
- package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +73 -58
- package/src/components/PortfolioHero/PortfolioHero.tsx +1 -1
- package/src/components/Slider/Slider.tsx +3 -3
- package/src/components/TextInput/TextInput.tsx +26 -9
- package/src/components/Tooltip/Tooltip.tsx +3 -5
- package/src/components/index.ts +9 -2
- package/src/design-tokens/Coin Variables-variables-full.json +1 -1
- package/src/design-tokens/figma-modes.generated.ts +13 -13
- package/src/icons/registry.ts +1 -1
|
@@ -48,6 +48,12 @@ export type PlanComparisonCardProps = {
|
|
|
48
48
|
columns?: PlanComparisonColumn[];
|
|
49
49
|
/** Feature rows compared across the plan columns. */
|
|
50
50
|
rows?: PlanComparisonRow[];
|
|
51
|
+
/**
|
|
52
|
+
* Slot that replaces the default comparison table (Figma `Slot`). When
|
|
53
|
+
* omitted, the table is built from `columns` + `rows`. Header still
|
|
54
|
+
* renders from `columns`. `modes` are cascaded into slot children.
|
|
55
|
+
*/
|
|
56
|
+
children?: React.ReactNode;
|
|
51
57
|
/** Design token modes for theming (e.g. `{ "Color Mode": "Light" }`). */
|
|
52
58
|
modes?: Modes;
|
|
53
59
|
/** Override the outer container style. */
|
|
@@ -58,10 +64,11 @@ export type PlanComparisonCardProps = {
|
|
|
58
64
|
* current plan against one or more alternative plans across a set of feature
|
|
59
65
|
* rows. Implementation of Figma node `4498:2968` (`PlanComparisonCard`).
|
|
60
66
|
*
|
|
61
|
-
*
|
|
67
|
+
* Structure: header (from `columns`) + Slot (default table from `rows`, or
|
|
68
|
+
* custom `children`). Columns use a 1.8fr / 1fr flex ratio (label vs plan).
|
|
62
69
|
*
|
|
63
70
|
* @component
|
|
64
71
|
*/
|
|
65
|
-
declare function PlanComparisonCard({ columns, rows, modes, style, }: PlanComparisonCardProps): import("react/jsx-runtime").JSX.Element;
|
|
72
|
+
declare function PlanComparisonCard({ columns, rows, children, modes, style, }: PlanComparisonCardProps): import("react/jsx-runtime").JSX.Element;
|
|
66
73
|
export default PlanComparisonCard;
|
|
67
74
|
//# sourceMappingURL=PlanComparisonCard.d.ts.map
|
|
@@ -9,8 +9,8 @@ export { default as BenefitCard, type BenefitCardProps, type BenefitCardItem } f
|
|
|
9
9
|
export { default as BottomNav } from './BottomNav/BottomNav';
|
|
10
10
|
export { default as BottomNavItem } from './BottomNavItem/BottomNavItem';
|
|
11
11
|
export { default as BrandChip, type BrandChipProps } from './BrandChip/BrandChip';
|
|
12
|
-
export { default as Button, type ButtonProps } from './Button/Button';
|
|
13
|
-
export { default as Card } from './Card/Card';
|
|
12
|
+
export { default as Button, type ButtonProps, type ButtonType } from './Button/Button';
|
|
13
|
+
export { default as Card, type CardProps, type CardTextProps, type CardVariant, type CardVariantInput, CARD_MEDIA_ASPECT_RATIO, } from './Card/Card';
|
|
14
14
|
export { default as CardAdvisory, type CardAdvisoryProps } from './CardAdvisory/CardAdvisory';
|
|
15
15
|
export { default as CardBankAccount, type CardBankAccountProps, type CardBankAccountItem, } from './CardBankAccount/CardBankAccount';
|
|
16
16
|
export { default as Carousel } from './Carousel/Carousel';
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Auto-generated from SVG files in src/icons/
|
|
5
5
|
* DO NOT EDIT MANUALLY - Run "npm run icons:generate" to regenerate
|
|
6
6
|
*
|
|
7
|
-
* Generated: 2026-07-
|
|
7
|
+
* Generated: 2026-07-27T15:45:41.882Z
|
|
8
8
|
*/
|
|
9
9
|
export declare const iconRegistry: Record<string, {
|
|
10
10
|
path: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jfs-components",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.54",
|
|
4
4
|
"description": "React Native Jio Finance Components Library",
|
|
5
5
|
"author": "sunshuaiqi@gmail.com",
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
"src",
|
|
18
18
|
"lib",
|
|
19
19
|
"scripts/extract-figma-modes.js",
|
|
20
|
-
"D2C.md",
|
|
20
|
+
".cursor/commands/D2C.md",
|
|
21
|
+
".cursor/D2C-FLOW.md",
|
|
22
|
+
".cursor/D2C-SCREEN.md",
|
|
21
23
|
"CHANGELOG.md",
|
|
22
24
|
"!**/__tests__",
|
|
23
25
|
"!**/*.stories.*",
|
|
@@ -1,27 +1,48 @@
|
|
|
1
|
-
import React, { useEffect, useMemo,
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react'
|
|
2
2
|
import {
|
|
3
|
-
Animated,
|
|
4
|
-
Keyboard,
|
|
5
3
|
View,
|
|
4
|
+
Text,
|
|
6
5
|
Platform,
|
|
7
|
-
|
|
6
|
+
useWindowDimensions,
|
|
7
|
+
type LayoutChangeEvent,
|
|
8
|
+
type TextStyle,
|
|
8
9
|
type ViewStyle,
|
|
9
10
|
type StyleProp,
|
|
10
11
|
} from 'react-native'
|
|
12
|
+
import Animated, {
|
|
13
|
+
useAnimatedKeyboard,
|
|
14
|
+
useAnimatedStyle,
|
|
15
|
+
useSharedValue,
|
|
16
|
+
} from 'react-native-reanimated'
|
|
11
17
|
import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
12
18
|
import { EMPTY_MODES, cloneChildrenWithModes, flattenChildren } from '../../utils/react-utils'
|
|
13
19
|
import { boxShadow } from '../../utils/shadow'
|
|
20
|
+
import ButtonGroup from '../ButtonGroup/ButtonGroup'
|
|
14
21
|
import IconButton from '../IconButton/IconButton'
|
|
22
|
+
import Slot from '../Slot/Slot'
|
|
23
|
+
import Stack from '../Stack/Stack'
|
|
15
24
|
import type { Modes } from '../../design-tokens'
|
|
16
25
|
|
|
17
26
|
export type ActionFooterProps = {
|
|
18
27
|
/**
|
|
19
28
|
* Content to render inside the action footer slot.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
29
|
+
*
|
|
30
|
+
* **Legacy (fully supported):** pass loose `IconButton` / `Button` children —
|
|
31
|
+
* IconButtons keep their intrinsic square size; everything else is
|
|
32
|
+
* auto-stretched to share the remaining horizontal space equally.
|
|
33
|
+
*
|
|
34
|
+
* **Figma shape (preferred for new code):** pass a single `ButtonGroup`
|
|
35
|
+
* (or `Stack` / `Slot`) that owns its own internal layout. ActionFooter will
|
|
36
|
+
* not re-stretch that composite — it only cascades `modes` and stretches it
|
|
37
|
+
* to full width.
|
|
23
38
|
*/
|
|
24
39
|
children?: React.ReactNode
|
|
40
|
+
/**
|
|
41
|
+
* Optional centered title rendered above the action slot (Figma "title wrap").
|
|
42
|
+
* Omitted / empty string → title wrap is not mounted (identical to the
|
|
43
|
+
* pre-title layout tree for loose children).
|
|
44
|
+
*/
|
|
45
|
+
title?: string
|
|
25
46
|
/**
|
|
26
47
|
* Mode configuration passed to the token resolver.
|
|
27
48
|
* Automatically merged into every slot child via {@link cloneChildrenWithModes}
|
|
@@ -29,15 +50,30 @@ export type ActionFooterProps = {
|
|
|
29
50
|
*/
|
|
30
51
|
modes?: Modes
|
|
31
52
|
/**
|
|
32
|
-
* Optional style overrides for the
|
|
53
|
+
* Optional style overrides for the footer chrome (not the sticky shell).
|
|
33
54
|
*/
|
|
34
55
|
style?: StyleProp<ViewStyle>
|
|
35
56
|
/**
|
|
36
57
|
* Accessibility label for the footer region (announced for the toolbar).
|
|
37
58
|
*/
|
|
38
59
|
accessibilityLabel?: string
|
|
60
|
+
/**
|
|
61
|
+
* When `true` (default on native), the footer rides above the software
|
|
62
|
+
* keyboard by exactly the uncovered keyboard height. Web ignores this —
|
|
63
|
+
* there is no software keyboard inset to track.
|
|
64
|
+
*/
|
|
65
|
+
avoidKeyboard?: boolean
|
|
39
66
|
}
|
|
40
67
|
|
|
68
|
+
const IS_WEB = Platform.OS === 'web'
|
|
69
|
+
// Native sticky pinning is the product contract. Web/Storybook stays in normal
|
|
70
|
+
// document flow so stories and docs layouts are not yanked out of their cards.
|
|
71
|
+
const STICKY_BY_DEFAULT = !IS_WEB
|
|
72
|
+
|
|
73
|
+
// Fallback row height used only until the first onLayout measurement lands.
|
|
74
|
+
// Matches the common Button / IconButton intrinsic height in this library.
|
|
75
|
+
const FALLBACK_ACTION_ROW_HEIGHT = 48
|
|
76
|
+
|
|
41
77
|
// ---------------------------------------------------------------------------
|
|
42
78
|
// Yoga-safe stretch
|
|
43
79
|
// ---------------------------------------------------------------------------
|
|
@@ -66,175 +102,373 @@ const STRETCH_STYLE: ViewStyle = {
|
|
|
66
102
|
minWidth: 0,
|
|
67
103
|
}
|
|
68
104
|
|
|
105
|
+
const COMPOSITE_STRETCH_STYLE: ViewStyle = {
|
|
106
|
+
alignSelf: 'stretch',
|
|
107
|
+
width: '100%',
|
|
108
|
+
}
|
|
109
|
+
|
|
69
110
|
// Upward drop shadow routed through the unified helper: native `boxShadow` on
|
|
70
111
|
// web + RN 0.76+, legacy `shadow*` (iOS) / `elevation` (Android) on older RN.
|
|
112
|
+
// Values match `actionFooter/shadow/primary|secondary/*` tokens.
|
|
71
113
|
const SHADOW = boxShadow(
|
|
72
114
|
'0px -12px 24px 0px rgba(12, 13, 16, 0.12), 0px -16px 48px 0px rgba(12, 13, 16, 0.16)',
|
|
73
115
|
{ elevation: 16 },
|
|
74
116
|
)
|
|
75
117
|
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
118
|
+
const SHELL_STYLE: ViewStyle = {
|
|
119
|
+
// Fill the parent's cross axis without forcing callers to pass width: '100%'.
|
|
120
|
+
alignSelf: 'stretch',
|
|
121
|
+
// In a column flex parent, absorb free space above us so the footer sits on
|
|
122
|
+
// the bottom edge even when siblings above it are short. Harmless when a
|
|
123
|
+
// sibling already owns `flex: 1` (e.g. FullscreenModal's ScrollView).
|
|
124
|
+
marginTop: 'auto',
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const PINNED_STYLE: ViewStyle = {
|
|
128
|
+
position: 'absolute',
|
|
129
|
+
left: 0,
|
|
130
|
+
right: 0,
|
|
131
|
+
bottom: 0,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isElementOfType(
|
|
135
|
+
element: React.ReactElement<any>,
|
|
136
|
+
component: React.ComponentType<any>,
|
|
137
|
+
): boolean {
|
|
83
138
|
const t: any = element.type
|
|
84
|
-
if (t ===
|
|
85
|
-
|
|
139
|
+
if (t === component) return true
|
|
140
|
+
// `React.memo` / one extra `forwardRef` layer (re-exports).
|
|
141
|
+
if (t && typeof t === 'object' && t.type === component) return true
|
|
86
142
|
return false
|
|
87
143
|
}
|
|
88
144
|
|
|
145
|
+
function isIconButtonElement(element: React.ReactElement<any>): boolean {
|
|
146
|
+
return isElementOfType(element, IconButton)
|
|
147
|
+
}
|
|
148
|
+
|
|
89
149
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* it fills the parent regardless of whether the parent is a flex column,
|
|
95
|
-
* a ScrollView contentContainer, or a plain View.
|
|
96
|
-
* - The inner slot is a single row sized by its tallest child. It does NOT
|
|
97
|
-
* use `flex: 1` — that previously caused the row to collapse to zero on
|
|
98
|
-
* the first Yoga pass on native, taking the button labels with it.
|
|
99
|
-
* - `IconButton` children keep their intrinsic square size.
|
|
100
|
-
* - Every other child is auto-stretched with the Yoga-safe stretch style
|
|
101
|
-
* above so two `<Button>` siblings render at equal width on iOS, Android,
|
|
102
|
-
* and Web.
|
|
103
|
-
*
|
|
104
|
-
* The `modes` prop is automatically pushed down to every slot child via
|
|
105
|
-
* {@link cloneChildrenWithModes}; explicit child-level modes win over the
|
|
106
|
-
* parent's modes.
|
|
107
|
-
*
|
|
108
|
-
* @example
|
|
109
|
-
* ```tsx
|
|
110
|
-
* <ActionFooter modes={modes}>
|
|
111
|
-
* <IconButton iconName="ic_split" />
|
|
112
|
-
* <Button label="Request" modes={{ AppearanceBrand: 'Secondary' }} />
|
|
113
|
-
* <Button label="Pay" modes={{ AppearanceBrand: 'Primary' }} />
|
|
114
|
-
* </ActionFooter>
|
|
115
|
-
* ```
|
|
150
|
+
* Figma nests `Stack → Slot → ButtonGroup` under ActionFooter. In code, Slot
|
|
151
|
+
* still exists as a parallel primitive and Stack is the preferred successor —
|
|
152
|
+
* treat all three as "composites that own their own layout" so we never
|
|
153
|
+
* double-apply the loose-button stretch math and break their internals.
|
|
116
154
|
*/
|
|
117
|
-
function
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
// Keep the footer locked in place behind the software keyboard (Android).
|
|
125
|
-
// -------------------------------------------------------------------------
|
|
126
|
-
//
|
|
127
|
-
// The Android activity is configured with `windowSoftInputMode="adjustResize"`,
|
|
128
|
-
// which shrinks the app window by the keyboard height when the keyboard
|
|
129
|
-
// opens. A bottom-anchored footer therefore gets lifted UP by the keyboard
|
|
130
|
-
// height — exactly the jump the design does not want.
|
|
131
|
-
//
|
|
132
|
-
// To counteract that, we translate the footer back DOWN by the same keyboard
|
|
133
|
-
// height so it visually stays exactly where it was (now sitting behind the
|
|
134
|
-
// keyboard). iOS does not resize the window for the keyboard, so the footer
|
|
135
|
-
// already stays put there; we only run this on Android to avoid pushing the
|
|
136
|
-
// footer off-screen on platforms that don't lift it in the first place.
|
|
137
|
-
const keyboardOffset = useRef(new Animated.Value(0)).current
|
|
138
|
-
useEffect(() => {
|
|
139
|
-
if (Platform.OS !== 'android') return undefined
|
|
140
|
-
|
|
141
|
-
const animateTo = (toValue: number, duration?: number) => {
|
|
142
|
-
Animated.timing(keyboardOffset, {
|
|
143
|
-
toValue,
|
|
144
|
-
// Match the OS keyboard animation so the resize and our counter-shift
|
|
145
|
-
// cancel out smoothly with no visible footer movement.
|
|
146
|
-
duration: typeof duration === 'number' && duration > 0 ? duration : 150,
|
|
147
|
-
useNativeDriver: true,
|
|
148
|
-
}).start()
|
|
149
|
-
}
|
|
155
|
+
function isCompositeActionElement(element: React.ReactElement<any>): boolean {
|
|
156
|
+
return (
|
|
157
|
+
isElementOfType(element, ButtonGroup) ||
|
|
158
|
+
isElementOfType(element, Stack) ||
|
|
159
|
+
isElementOfType(element, Slot)
|
|
160
|
+
)
|
|
161
|
+
}
|
|
150
162
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
163
|
+
type ResolvedChrome = {
|
|
164
|
+
containerStyle: ViewStyle
|
|
165
|
+
slotStyle: ViewStyle
|
|
166
|
+
titleWrapStyle: ViewStyle
|
|
167
|
+
titleStyle: TextStyle
|
|
168
|
+
estimatedHeight: number
|
|
169
|
+
}
|
|
157
170
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
171
|
+
function resolveChrome(modes: Modes): ResolvedChrome {
|
|
172
|
+
const backgroundColor =
|
|
173
|
+
(getVariableByName('actionFooter/background', modes) ?? '#ffffff') as string
|
|
174
|
+
const gap = (getVariableByName('actionFooter/gap', modes) ?? 8) as number
|
|
175
|
+
const paddingHorizontal =
|
|
176
|
+
(getVariableByName('actionFooter/padding/horizontal', modes) ?? 16) as number
|
|
177
|
+
const paddingTop = (getVariableByName('actionFooter/padding/top', modes) ?? 10) as number
|
|
178
|
+
const paddingBottom = (getVariableByName('actionFooter/padding/bottom', modes) ?? 41) as number
|
|
179
|
+
const borderRadius = (getVariableByName('actionFooter/radius', modes) ??
|
|
180
|
+
getVariableByName('radius/actionFooter', modes) ??
|
|
181
|
+
0) as number
|
|
182
|
+
|
|
183
|
+
// Title wrap reuses the Drawer title tokens in Figma (same binding ContentSheet
|
|
184
|
+
// uses) so the three bottom surfaces stay typographically aligned.
|
|
185
|
+
const titleColor = (getVariableByName('drawer/title/color', modes) ?? '#0d0d0f') as string
|
|
186
|
+
const titleSize = (getVariableByName('drawer/title/fontSize', modes) ?? 14) as number
|
|
187
|
+
const titleLineHeight = (getVariableByName('drawer/title/lineHeight', modes) ?? 17) as number
|
|
188
|
+
const titleFontFamily = (getVariableByName('drawer/title/fontFamily', modes) ??
|
|
189
|
+
'JioType Var') as string
|
|
190
|
+
const titleFontWeightRaw = getVariableByName('drawer/title/fontWeight', modes) ?? 700
|
|
191
|
+
const titleFontWeight = (
|
|
192
|
+
typeof titleFontWeightRaw === 'number' ? String(titleFontWeightRaw) : titleFontWeightRaw
|
|
193
|
+
) as TextStyle['fontWeight']
|
|
194
|
+
const titleWrapPaddingTop =
|
|
195
|
+
(getVariableByName('drawer/titleWrap/padding/top', modes) ?? 16) as number
|
|
196
|
+
const titleWrapPaddingBottom =
|
|
197
|
+
(getVariableByName('drawer/titleWrap/padding/bottom', modes) ?? 8) as number
|
|
198
|
+
const titleWrapPaddingHorizontal =
|
|
199
|
+
(getVariableByName('drawer/titleWrap/padding/horizontal', modes) ?? 0) as number
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
containerStyle: {
|
|
180
203
|
alignSelf: 'stretch',
|
|
181
204
|
backgroundColor,
|
|
182
205
|
paddingLeft: paddingHorizontal,
|
|
183
206
|
paddingRight: paddingHorizontal,
|
|
184
207
|
paddingTop,
|
|
185
208
|
paddingBottom,
|
|
209
|
+
borderRadius,
|
|
186
210
|
...SHADOW,
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const slot: ViewStyle = {
|
|
211
|
+
},
|
|
212
|
+
slotStyle: {
|
|
190
213
|
flexDirection: 'row',
|
|
191
214
|
// Vertically center the IconButton against the slightly taller Buttons
|
|
192
215
|
// so the row reads as a single optical baseline.
|
|
193
216
|
alignItems: 'center',
|
|
217
|
+
alignSelf: 'stretch',
|
|
194
218
|
gap,
|
|
195
|
-
}
|
|
219
|
+
},
|
|
220
|
+
titleWrapStyle: {
|
|
221
|
+
alignSelf: 'stretch',
|
|
222
|
+
alignItems: 'center',
|
|
223
|
+
justifyContent: 'center',
|
|
224
|
+
paddingTop: titleWrapPaddingTop,
|
|
225
|
+
paddingBottom: titleWrapPaddingBottom,
|
|
226
|
+
paddingHorizontal: titleWrapPaddingHorizontal,
|
|
227
|
+
},
|
|
228
|
+
titleStyle: {
|
|
229
|
+
color: titleColor,
|
|
230
|
+
fontSize: titleSize,
|
|
231
|
+
lineHeight: titleLineHeight,
|
|
232
|
+
fontFamily: titleFontFamily,
|
|
233
|
+
fontWeight: titleFontWeight,
|
|
234
|
+
textAlign: 'center',
|
|
235
|
+
},
|
|
236
|
+
// Seed the in-flow spacer so the first frame already reserves room and
|
|
237
|
+
// scroll siblings never paint under an unmeasured footer.
|
|
238
|
+
estimatedHeight: paddingTop + paddingBottom + FALLBACK_ACTION_ROW_HEIGHT,
|
|
239
|
+
}
|
|
240
|
+
}
|
|
196
241
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
return withModes.map((child, index) => {
|
|
212
|
-
if (!React.isValidElement(child)) return child
|
|
213
|
-
const element = child as React.ReactElement<any>
|
|
214
|
-
if (isIconButtonElement(element)) return element
|
|
215
|
-
return React.cloneElement(element, {
|
|
216
|
-
key: element.key ?? `action-footer-item-${index}`,
|
|
217
|
-
style: [STRETCH_STYLE, element.props.style],
|
|
218
|
-
})
|
|
242
|
+
/**
|
|
243
|
+
* Legacy loose-children path — identical stretch semantics to the original
|
|
244
|
+
* ActionFooter so existing call sites keep pixel parity.
|
|
245
|
+
*/
|
|
246
|
+
function enhanceLooseChildren(children: React.ReactNode, modes: Modes): React.ReactNode[] {
|
|
247
|
+
const flat = flattenChildren(children)
|
|
248
|
+
const withModes = cloneChildrenWithModes(flat, modes) as React.ReactNode[]
|
|
249
|
+
return withModes.map((child, index) => {
|
|
250
|
+
if (!React.isValidElement(child)) return child
|
|
251
|
+
const element = child as React.ReactElement<any>
|
|
252
|
+
if (isIconButtonElement(element)) return element
|
|
253
|
+
return React.cloneElement(element, {
|
|
254
|
+
key: element.key ?? `action-footer-item-${index}`,
|
|
255
|
+
style: [STRETCH_STYLE, element.props.style],
|
|
219
256
|
})
|
|
220
|
-
}
|
|
257
|
+
})
|
|
258
|
+
}
|
|
221
259
|
|
|
222
|
-
|
|
260
|
+
function enhanceCompositeChild(
|
|
261
|
+
element: React.ReactElement<any>,
|
|
262
|
+
modes: Modes,
|
|
263
|
+
): React.ReactElement<any> {
|
|
264
|
+
const childModes = element.props.modes ?? EMPTY_MODES
|
|
265
|
+
return React.cloneElement(element, {
|
|
266
|
+
modes: { ...modes, ...childModes },
|
|
267
|
+
style: [COMPOSITE_STRETCH_STYLE, element.props.style],
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
type ActionFooterChromeProps = ActionFooterProps & {
|
|
272
|
+
sticky: boolean
|
|
273
|
+
animatedStyle?: object
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Shared chrome for web + native. On native (`sticky`), an in-flow shell locks
|
|
278
|
+
* the footer's height into the parent layout while the visible chrome is
|
|
279
|
+
* absolutely pinned to that shell's bottom edge — so the footer cannot scroll
|
|
280
|
+
* away or float mid-screen, and keyboard translation never collapses siblings.
|
|
281
|
+
*/
|
|
282
|
+
function ActionFooterChrome({
|
|
283
|
+
children,
|
|
284
|
+
title,
|
|
285
|
+
modes = EMPTY_MODES,
|
|
286
|
+
style,
|
|
287
|
+
accessibilityLabel,
|
|
288
|
+
sticky,
|
|
289
|
+
animatedStyle,
|
|
290
|
+
}: ActionFooterChromeProps) {
|
|
291
|
+
const { containerStyle, slotStyle, titleWrapStyle, titleStyle, estimatedHeight } = useMemo(
|
|
292
|
+
() => resolveChrome(modes),
|
|
293
|
+
[modes],
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
const showTitle = typeof title === 'string' && title.length > 0
|
|
297
|
+
|
|
298
|
+
// Decide once whether the caller passed the Figma composite shape
|
|
299
|
+
// (ButtonGroup / Stack / Slot) or the legacy loose-button list. This keeps
|
|
300
|
+
// both APIs working without forcing a migration.
|
|
301
|
+
const { actionsBody, estimatedActionsHeight } = useMemo(() => {
|
|
302
|
+
const flat = flattenChildren(children)
|
|
303
|
+
const sole =
|
|
304
|
+
flat.length === 1 && React.isValidElement(flat[0])
|
|
305
|
+
? (flat[0] as React.ReactElement<any>)
|
|
306
|
+
: null
|
|
307
|
+
|
|
308
|
+
if (sole && isCompositeActionElement(sole)) {
|
|
309
|
+
return {
|
|
310
|
+
actionsBody: enhanceCompositeChild(sole, modes),
|
|
311
|
+
estimatedActionsHeight: FALLBACK_ACTION_ROW_HEIGHT,
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
actionsBody: <View style={slotStyle}>{enhanceLooseChildren(children, modes)}</View>,
|
|
317
|
+
estimatedActionsHeight: FALLBACK_ACTION_ROW_HEIGHT,
|
|
318
|
+
}
|
|
319
|
+
}, [children, modes, slotStyle])
|
|
320
|
+
|
|
321
|
+
const body = useMemo(() => {
|
|
322
|
+
if (!showTitle) {
|
|
323
|
+
// No title → identical tree shape to the pre-title ActionFooter for the
|
|
324
|
+
// loose-children path (just the actions row / composite). No extra Stack
|
|
325
|
+
// wrapper, so existing layouts cannot regress from an added gap node.
|
|
326
|
+
return actionsBody
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Figma: title wrap (sibling) + Stack → Slot → ButtonGroup. We mount a
|
|
330
|
+
// vertical Stack only when the title is present so the gap between title
|
|
331
|
+
// and actions is token-driven (`slot/gap` via Stack, cascaded modes).
|
|
332
|
+
return (
|
|
333
|
+
<Stack layoutDirection="vertical" fillWidth modes={modes}>
|
|
334
|
+
<View style={titleWrapStyle} accessibilityRole="header">
|
|
335
|
+
<Text style={titleStyle}>{title}</Text>
|
|
336
|
+
</View>
|
|
337
|
+
{actionsBody}
|
|
338
|
+
</Stack>
|
|
339
|
+
)
|
|
340
|
+
}, [showTitle, actionsBody, modes, title, titleWrapStyle, titleStyle])
|
|
341
|
+
|
|
342
|
+
const [footerHeight, setFooterHeight] = useState(
|
|
343
|
+
estimatedHeight + (showTitle ? 41 : 0),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
// Token / title presence changes should re-seed the spacer immediately;
|
|
347
|
+
// the next onLayout then corrects to the true measured height.
|
|
348
|
+
useEffect(() => {
|
|
349
|
+
setFooterHeight(estimatedHeight + (showTitle ? 41 : 0))
|
|
350
|
+
}, [estimatedHeight, showTitle, estimatedActionsHeight])
|
|
351
|
+
|
|
352
|
+
const onFooterLayout = (event: LayoutChangeEvent) => {
|
|
353
|
+
const next = Math.ceil(event.nativeEvent.layout.height)
|
|
354
|
+
if (next > 0 && next !== footerHeight) {
|
|
355
|
+
setFooterHeight(next)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const chrome = (
|
|
223
360
|
<Animated.View
|
|
224
|
-
style={[
|
|
225
|
-
|
|
226
|
-
style,
|
|
227
|
-
// Counter-translate by the keyboard height on Android so `adjustResize`
|
|
228
|
-
// can't lift the footer above the keyboard (no-op on iOS/web where the
|
|
229
|
-
// value stays at 0).
|
|
230
|
-
{ transform: [{ translateY: keyboardOffset }] },
|
|
231
|
-
]}
|
|
361
|
+
style={[sticky ? PINNED_STYLE : null, containerStyle, style, animatedStyle]}
|
|
362
|
+
onLayout={sticky ? onFooterLayout : undefined}
|
|
232
363
|
accessibilityRole="toolbar"
|
|
233
364
|
accessibilityLabel={accessibilityLabel}
|
|
234
365
|
>
|
|
235
|
-
|
|
366
|
+
{body}
|
|
236
367
|
</Animated.View>
|
|
237
368
|
)
|
|
369
|
+
|
|
370
|
+
if (!sticky) {
|
|
371
|
+
return chrome
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return (
|
|
375
|
+
<View style={[SHELL_STYLE, { height: footerHeight }]} pointerEvents="box-none">
|
|
376
|
+
{chrome}
|
|
377
|
+
</View>
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* UI-thread keyboard tracking. Lifts by `keyboard.height - layoutShrink` so:
|
|
383
|
+
* - iOS / Android `adjustPan` / `adjustNothing` → full keyboard height
|
|
384
|
+
* - Android `adjustResize` (this app's default) → ~0 (window already shrank)
|
|
385
|
+
* Never double-counts, never depends on callers wiring KeyboardAvoidingView.
|
|
386
|
+
*/
|
|
387
|
+
function KeyboardAwareActionFooter(props: ActionFooterProps) {
|
|
388
|
+
const { height: windowHeight, width: windowWidth } = useWindowDimensions()
|
|
389
|
+
const maxWindowHeight = useSharedValue(windowHeight)
|
|
390
|
+
const layoutShrink = useSharedValue(0)
|
|
391
|
+
const keyboard = useAnimatedKeyboard()
|
|
392
|
+
|
|
393
|
+
// Orientation / split-screen width changes invalidate the height baseline.
|
|
394
|
+
// Intentionally NOT keyed on `windowHeight` — Android `adjustResize` shrinks
|
|
395
|
+
// height when the keyboard opens, and resetting the baseline there would
|
|
396
|
+
// collapse `layoutShrink` to 0 and double-lift the footer.
|
|
397
|
+
useEffect(() => {
|
|
398
|
+
maxWindowHeight.value = windowHeight
|
|
399
|
+
layoutShrink.value = 0
|
|
400
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- width-only baseline
|
|
401
|
+
}, [windowWidth])
|
|
402
|
+
|
|
403
|
+
// Track how much of the keyboard inset the window has already absorbed.
|
|
404
|
+
// Shared values keep the keyboard worklet free of React re-render lag.
|
|
405
|
+
useEffect(() => {
|
|
406
|
+
if (windowHeight > maxWindowHeight.value) {
|
|
407
|
+
maxWindowHeight.value = windowHeight
|
|
408
|
+
}
|
|
409
|
+
layoutShrink.value = Math.max(0, maxWindowHeight.value - windowHeight)
|
|
410
|
+
}, [windowHeight, maxWindowHeight, layoutShrink])
|
|
411
|
+
|
|
412
|
+
const animatedStyle = useAnimatedStyle(() => {
|
|
413
|
+
const lift = Math.max(0, keyboard.height.value - layoutShrink.value)
|
|
414
|
+
return {
|
|
415
|
+
transform: [{ translateY: -lift }],
|
|
416
|
+
}
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
return <ActionFooterChrome {...props} sticky animatedStyle={animatedStyle} />
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* ActionFooter — sticky bottom container for primary screen actions.
|
|
424
|
+
*
|
|
425
|
+
* Layout contract (native):
|
|
426
|
+
* - Self-pins to the bottom of its flex-column screen via `marginTop: 'auto'`
|
|
427
|
+
* plus an absolutely positioned chrome inside a height-locked shell.
|
|
428
|
+
* - The shell keeps scroll/flex siblings from painting under the footer —
|
|
429
|
+
* callers do not need to hand-maintain `paddingBottom`.
|
|
430
|
+
* - Keyboard avoidance is owned here: the footer moves up by exactly the
|
|
431
|
+
* keyboard height that layout has not already absorbed. Host
|
|
432
|
+
* `KeyboardAvoidingView` is unnecessary and should not wrap this.
|
|
433
|
+
*
|
|
434
|
+
* Layout contract (web):
|
|
435
|
+
* - In-flow stretch container (Storybook / docs friendly). No keyboard inset.
|
|
436
|
+
*
|
|
437
|
+
* Content contract:
|
|
438
|
+
* - Optional centered `title` (Figma title wrap; Drawer title tokens).
|
|
439
|
+
* - Loose `IconButton`/`Button` children keep the original equal-stretch row.
|
|
440
|
+
* - A sole `ButtonGroup` / `Stack` / `Slot` child is treated as the Figma
|
|
441
|
+
* composite and is not re-stretched internally.
|
|
442
|
+
*
|
|
443
|
+
* @example
|
|
444
|
+
* ```tsx
|
|
445
|
+
* // Legacy — still the recommended simple path
|
|
446
|
+
* <ActionFooter>
|
|
447
|
+
* <IconButton iconName="ic_split" />
|
|
448
|
+
* <Button label="Request" modes={{ AppearanceBrand: 'Secondary' }} />
|
|
449
|
+
* <Button label="Pay" modes={{ AppearanceBrand: 'Primary' }} />
|
|
450
|
+
* </ActionFooter>
|
|
451
|
+
*
|
|
452
|
+
* // Figma shape + title
|
|
453
|
+
* <ActionFooter title="Confirm payment">
|
|
454
|
+
* <ButtonGroup>
|
|
455
|
+
* <Button label="Request" modes={{ AppearanceBrand: 'Secondary' }} />
|
|
456
|
+
* <Button label="Pay" modes={{ AppearanceBrand: 'Primary' }} />
|
|
457
|
+
* </ButtonGroup>
|
|
458
|
+
* </ActionFooter>
|
|
459
|
+
* ```
|
|
460
|
+
*/
|
|
461
|
+
function ActionFooter({
|
|
462
|
+
avoidKeyboard = true,
|
|
463
|
+
...props
|
|
464
|
+
}: ActionFooterProps) {
|
|
465
|
+
// Distinct component types keep `useAnimatedKeyboard` hook order stable
|
|
466
|
+
// (same pattern as ContentSheet). Web never mounts the keyboard path.
|
|
467
|
+
if (STICKY_BY_DEFAULT && avoidKeyboard) {
|
|
468
|
+
return <KeyboardAwareActionFooter {...props} />
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return <ActionFooterChrome {...props} sticky={STICKY_BY_DEFAULT} />
|
|
238
472
|
}
|
|
239
473
|
|
|
240
474
|
export default React.memo(ActionFooter)
|