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.
Files changed (93) hide show
  1. package/.cursor/D2C-FLOW.md +94 -0
  2. package/.cursor/D2C-SCREEN.md +119 -0
  3. package/.cursor/commands/D2C.md +101 -0
  4. package/CHANGELOG.md +18 -0
  5. package/lib/commonjs/components/ActionFooter/ActionFooter.js +319 -135
  6. package/lib/commonjs/components/Badge/Badge.js +20 -1
  7. package/lib/commonjs/components/Button/Button.js +139 -47
  8. package/lib/commonjs/components/ButtonGroup/ButtonGroup.js +1 -1
  9. package/lib/commonjs/components/Card/Card.js +151 -112
  10. package/lib/commonjs/components/Card/assets/gold-logo-mark.png +0 -0
  11. package/lib/commonjs/components/CategoryCard/CategoryCard.js +97 -63
  12. package/lib/commonjs/components/CategoryCard/assets/sample.png +0 -0
  13. package/lib/commonjs/components/CheckboxItem/CheckboxItem.js +59 -10
  14. package/lib/commonjs/components/ExpandableCheckbox/ExpandableCheckbox.js +132 -49
  15. package/lib/commonjs/components/HelloJioInput/HelloJioInput.js +56 -14
  16. package/lib/commonjs/components/IconCapsule/IconCapsule.js +1 -1
  17. package/lib/commonjs/components/MetricData/MetricData.js +26 -13
  18. package/lib/commonjs/components/MoneyValue/MoneyValue.js +1 -1
  19. package/lib/commonjs/components/Nudge/Nudge.js +1 -1
  20. package/lib/commonjs/components/PdpCcCard/PdpCcCard.js +47 -91
  21. package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +52 -45
  22. package/lib/commonjs/components/PortfolioHero/PortfolioHero.js +1 -1
  23. package/lib/commonjs/components/Slider/Slider.js +3 -3
  24. package/lib/commonjs/components/TextInput/TextInput.js +26 -8
  25. package/lib/commonjs/components/Tooltip/Tooltip.js +3 -5
  26. package/lib/commonjs/components/index.js +7 -1
  27. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  28. package/lib/commonjs/design-tokens/figma-modes.generated.js +13 -13
  29. package/lib/commonjs/icons/registry.js +1 -1
  30. package/lib/module/components/ActionFooter/ActionFooter.js +322 -138
  31. package/lib/module/components/Badge/Badge.js +20 -1
  32. package/lib/module/components/Button/Button.js +140 -48
  33. package/lib/module/components/ButtonGroup/ButtonGroup.js +1 -1
  34. package/lib/module/components/Card/Card.js +152 -114
  35. package/lib/module/components/Card/assets/gold-logo-mark.png +0 -0
  36. package/lib/module/components/CategoryCard/CategoryCard.js +97 -63
  37. package/lib/module/components/CategoryCard/assets/sample.png +0 -0
  38. package/lib/module/components/CheckboxItem/CheckboxItem.js +59 -10
  39. package/lib/module/components/ExpandableCheckbox/ExpandableCheckbox.js +135 -52
  40. package/lib/module/components/HelloJioInput/HelloJioInput.js +56 -14
  41. package/lib/module/components/IconCapsule/IconCapsule.js +1 -1
  42. package/lib/module/components/MetricData/MetricData.js +26 -14
  43. package/lib/module/components/MoneyValue/MoneyValue.js +1 -1
  44. package/lib/module/components/Nudge/Nudge.js +1 -1
  45. package/lib/module/components/PdpCcCard/PdpCcCard.js +48 -92
  46. package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +52 -45
  47. package/lib/module/components/PortfolioHero/PortfolioHero.js +1 -1
  48. package/lib/module/components/Slider/Slider.js +3 -3
  49. package/lib/module/components/TextInput/TextInput.js +27 -9
  50. package/lib/module/components/Tooltip/Tooltip.js +3 -5
  51. package/lib/module/components/index.js +1 -1
  52. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  53. package/lib/module/design-tokens/figma-modes.generated.js +13 -13
  54. package/lib/module/icons/registry.js +1 -1
  55. package/lib/typescript/src/components/ActionFooter/ActionFooter.d.ts +50 -21
  56. package/lib/typescript/src/components/Button/Button.d.ts +24 -1
  57. package/lib/typescript/src/components/ButtonGroup/ButtonGroup.d.ts +2 -2
  58. package/lib/typescript/src/components/Card/Card.d.ts +54 -25
  59. package/lib/typescript/src/components/CategoryCard/CategoryCard.d.ts +29 -12
  60. package/lib/typescript/src/components/CheckboxItem/CheckboxItem.d.ts +27 -9
  61. package/lib/typescript/src/components/ExpandableCheckbox/ExpandableCheckbox.d.ts +37 -12
  62. package/lib/typescript/src/components/IconCapsule/IconCapsule.d.ts +1 -1
  63. package/lib/typescript/src/components/MetricData/MetricData.d.ts +7 -5
  64. package/lib/typescript/src/components/PdpCcCard/PdpCcCard.d.ts +16 -6
  65. package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +9 -2
  66. package/lib/typescript/src/components/index.d.ts +2 -2
  67. package/lib/typescript/src/icons/registry.d.ts +1 -1
  68. package/package.json +4 -2
  69. package/src/components/ActionFooter/ActionFooter.tsx +375 -141
  70. package/src/components/Badge/Badge.tsx +21 -1
  71. package/src/components/Button/Button.tsx +192 -44
  72. package/src/components/ButtonGroup/ButtonGroup.tsx +2 -2
  73. package/src/components/Card/Card.tsx +370 -212
  74. package/src/components/Card/assets/gold-logo-mark.png +0 -0
  75. package/src/components/CategoryCard/CategoryCard.tsx +130 -84
  76. package/src/components/CategoryCard/assets/sample.png +0 -0
  77. package/src/components/CheckboxItem/CheckboxItem.tsx +72 -12
  78. package/src/components/ExpandableCheckbox/ExpandableCheckbox.tsx +186 -67
  79. package/src/components/HelloJioInput/HelloJioInput.tsx +58 -17
  80. package/src/components/IconCapsule/IconCapsule.tsx +1 -1
  81. package/src/components/MetricData/MetricData.tsx +39 -22
  82. package/src/components/MoneyValue/MoneyValue.tsx +1 -1
  83. package/src/components/Nudge/Nudge.tsx +1 -1
  84. package/src/components/PdpCcCard/PdpCcCard.tsx +41 -92
  85. package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +73 -58
  86. package/src/components/PortfolioHero/PortfolioHero.tsx +1 -1
  87. package/src/components/Slider/Slider.tsx +3 -3
  88. package/src/components/TextInput/TextInput.tsx +26 -9
  89. package/src/components/Tooltip/Tooltip.tsx +3 -5
  90. package/src/components/index.ts +9 -2
  91. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  92. package/src/design-tokens/figma-modes.generated.ts +13 -13
  93. 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
- * Columns use a 1.8fr / 1fr flex ratio (label vs plan), matching the Figma grid.
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-25T10:59:52.406Z
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.50",
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, useRef } from 'react'
1
+ import React, { useEffect, useMemo, useState } from 'react'
2
2
  import {
3
- Animated,
4
- Keyboard,
5
3
  View,
4
+ Text,
6
5
  Platform,
7
- type KeyboardEvent,
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
- * Typically includes `IconButton` and `Button` components.
21
- * `IconButton` children keep their intrinsic square size; everything else
22
- * is auto-stretched to share the remaining horizontal space equally.
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 outer container.
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
- // The runtime token a slot child must equal (by reference) to be treated as
77
- // an IconButton. `IconButton` is exported wrapped in `React.memo`, so the
78
- // element.type identity comparison works for both `<IconButton />` from the
79
- // same module and any `React.memo`-wrapped re-export. The fallback check
80
- // (`type.type === IconButton`) catches one extra layer of `forwardRef` /
81
- // `memo` wrapping which can happen when consumers re-export the component.
82
- function isIconButtonElement(element: React.ReactElement<any>): boolean {
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 === IconButton) return true
85
- if (t && typeof t === 'object' && t.type === IconButton) return true
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
- * ActionFooter a sticky bottom container for primary screen actions.
91
- *
92
- * Layout contract:
93
- * - The outer container stretches horizontally (`alignSelf: 'stretch'`) so
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 ActionFooter({
118
- children,
119
- modes = EMPTY_MODES,
120
- style,
121
- accessibilityLabel,
122
- }: ActionFooterProps) {
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
- const showSub = Keyboard.addListener('keyboardDidShow', (e: KeyboardEvent) => {
152
- animateTo(e?.endCoordinates?.height ?? 0, e?.duration)
153
- })
154
- const hideSub = Keyboard.addListener('keyboardDidHide', (e: KeyboardEvent) => {
155
- animateTo(0, e?.duration)
156
- })
163
+ type ResolvedChrome = {
164
+ containerStyle: ViewStyle
165
+ slotStyle: ViewStyle
166
+ titleWrapStyle: ViewStyle
167
+ titleStyle: TextStyle
168
+ estimatedHeight: number
169
+ }
157
170
 
158
- return () => {
159
- showSub.remove()
160
- hideSub.remove()
161
- }
162
- }, [keyboardOffset])
163
-
164
- // All token reads collapsed into a single useMemo keyed on `modes`. With
165
- // the shared `EMPTY_MODES` default this resolves once for the common path
166
- // and never re-allocates the container/slot style objects between renders.
167
- const { containerStyle, slotStyle } = useMemo(() => {
168
- const backgroundColor =
169
- (getVariableByName('actionFooter/background', modes) ?? '#ffffff') as string
170
- const gap = (getVariableByName('actionFooter/gap', modes) ?? 8) as number
171
- const paddingHorizontal =
172
- (getVariableByName('actionFooter/padding/horizontal', modes) ?? 16) as number
173
- const paddingTop = (getVariableByName('actionFooter/padding/top', modes) ?? 10) as number
174
- const paddingBottom = (getVariableByName('actionFooter/padding/bottom', modes) ?? 41) as number
175
-
176
- const container: ViewStyle = {
177
- // `alignSelf: 'stretch'` is the cross-platform way to ask "fill the
178
- // parent's cross axis" — in the common case (column parent) this gives
179
- // us full-width without the caller needing to pass `width: '100%'`.
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
- return { containerStyle: container, slotStyle: slot }
198
- }, [modes])
199
-
200
- // Process children once per (children, modes) tuple:
201
- // 1. Flatten Fragments so each action is its own keyed sibling.
202
- // 2. Push `modes` down so callers don't have to thread it manually.
203
- // 3. Auto-stretch every non-IconButton with the Yoga-safe stretch style.
204
- //
205
- // The result identity is stable across re-renders when the inputs don't
206
- // change, which keeps the `React.memo`-wrapped Button/IconButton children
207
- // from re-rendering for no reason.
208
- const enhancedChildren = useMemo(() => {
209
- const flat = flattenChildren(children)
210
- const withModes = cloneChildrenWithModes(flat, modes) as React.ReactNode[]
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
- }, [children, modes])
257
+ })
258
+ }
221
259
 
222
- return (
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
- containerStyle,
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
- <View style={slotStyle}>{enhancedChildren}</View>
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)