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
@@ -1,12 +1,25 @@
1
1
  "use strict";
2
2
 
3
- import React, { useEffect, useMemo, useRef } from 'react';
4
- import { Animated, Keyboard, View, Platform } from 'react-native';
3
+ import React, { useEffect, useMemo, useState } from 'react';
4
+ import { View, Text, Platform, useWindowDimensions } from 'react-native';
5
+ import Animated, { useAnimatedKeyboard, useAnimatedStyle, useSharedValue } from 'react-native-reanimated';
5
6
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
6
7
  import { EMPTY_MODES, cloneChildrenWithModes, flattenChildren } from '../../utils/react-utils';
7
8
  import { boxShadow } from '../../utils/shadow';
9
+ import ButtonGroup from '../ButtonGroup/ButtonGroup';
8
10
  import IconButton from '../IconButton/IconButton';
9
- import { jsx as _jsx } from "react/jsx-runtime";
11
+ import Slot from '../Slot/Slot';
12
+ import Stack from '../Stack/Stack';
13
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
14
+ const IS_WEB = Platform.OS === 'web';
15
+ // Native sticky pinning is the product contract. Web/Storybook stays in normal
16
+ // document flow so stories and docs layouts are not yanked out of their cards.
17
+ const STICKY_BY_DEFAULT = !IS_WEB;
18
+
19
+ // Fallback row height used only until the first onLayout measurement lands.
20
+ // Matches the common Button / IconButton intrinsic height in this library.
21
+ const FALLBACK_ACTION_ROW_HEIGHT = 48;
22
+
10
23
  // ---------------------------------------------------------------------------
11
24
  // Yoga-safe stretch
12
25
  // ---------------------------------------------------------------------------
@@ -34,172 +47,343 @@ const STRETCH_STYLE = {
34
47
  flexBasis: 0,
35
48
  minWidth: 0
36
49
  };
50
+ const COMPOSITE_STRETCH_STYLE = {
51
+ alignSelf: 'stretch',
52
+ width: '100%'
53
+ };
37
54
 
38
55
  // Upward drop shadow routed through the unified helper: native `boxShadow` on
39
56
  // web + RN 0.76+, legacy `shadow*` (iOS) / `elevation` (Android) on older RN.
57
+ // Values match `actionFooter/shadow/primary|secondary/*` tokens.
40
58
  const SHADOW = boxShadow('0px -12px 24px 0px rgba(12, 13, 16, 0.12), 0px -16px 48px 0px rgba(12, 13, 16, 0.16)', {
41
59
  elevation: 16
42
60
  });
43
-
44
- // The runtime token a slot child must equal (by reference) to be treated as
45
- // an IconButton. `IconButton` is exported wrapped in `React.memo`, so the
46
- // element.type identity comparison works for both `<IconButton />` from the
47
- // same module and any `React.memo`-wrapped re-export. The fallback check
48
- // (`type.type === IconButton`) catches one extra layer of `forwardRef` /
49
- // `memo` wrapping which can happen when consumers re-export the component.
50
- function isIconButtonElement(element) {
61
+ const SHELL_STYLE = {
62
+ // Fill the parent's cross axis without forcing callers to pass width: '100%'.
63
+ alignSelf: 'stretch',
64
+ // In a column flex parent, absorb free space above us so the footer sits on
65
+ // the bottom edge even when siblings above it are short. Harmless when a
66
+ // sibling already owns `flex: 1` (e.g. FullscreenModal's ScrollView).
67
+ marginTop: 'auto'
68
+ };
69
+ const PINNED_STYLE = {
70
+ position: 'absolute',
71
+ left: 0,
72
+ right: 0,
73
+ bottom: 0
74
+ };
75
+ function isElementOfType(element, component) {
51
76
  const t = element.type;
52
- if (t === IconButton) return true;
53
- if (t && typeof t === 'object' && t.type === IconButton) return true;
77
+ if (t === component) return true;
78
+ // `React.memo` / one extra `forwardRef` layer (re-exports).
79
+ if (t && typeof t === 'object' && t.type === component) return true;
54
80
  return false;
55
81
  }
82
+ function isIconButtonElement(element) {
83
+ return isElementOfType(element, IconButton);
84
+ }
56
85
 
57
86
  /**
58
- * ActionFooter a sticky bottom container for primary screen actions.
59
- *
60
- * Layout contract:
61
- * - The outer container stretches horizontally (`alignSelf: 'stretch'`) so
62
- * it fills the parent regardless of whether the parent is a flex column,
63
- * a ScrollView contentContainer, or a plain View.
64
- * - The inner slot is a single row sized by its tallest child. It does NOT
65
- * use `flex: 1` — that previously caused the row to collapse to zero on
66
- * the first Yoga pass on native, taking the button labels with it.
67
- * - `IconButton` children keep their intrinsic square size.
68
- * - Every other child is auto-stretched with the Yoga-safe stretch style
69
- * above so two `<Button>` siblings render at equal width on iOS, Android,
70
- * and Web.
71
- *
72
- * The `modes` prop is automatically pushed down to every slot child via
73
- * {@link cloneChildrenWithModes}; explicit child-level modes win over the
74
- * parent's modes.
75
- *
76
- * @example
77
- * ```tsx
78
- * <ActionFooter modes={modes}>
79
- * <IconButton iconName="ic_split" />
80
- * <Button label="Request" modes={{ AppearanceBrand: 'Secondary' }} />
81
- * <Button label="Pay" modes={{ AppearanceBrand: 'Primary' }} />
82
- * </ActionFooter>
83
- * ```
87
+ * Figma nests `Stack Slot ButtonGroup` under ActionFooter. In code, Slot
88
+ * still exists as a parallel primitive and Stack is the preferred successor —
89
+ * treat all three as "composites that own their own layout" so we never
90
+ * double-apply the loose-button stretch math and break their internals.
84
91
  */
85
- function ActionFooter({
86
- children,
87
- modes = EMPTY_MODES,
88
- style,
89
- accessibilityLabel
90
- }) {
91
- // -------------------------------------------------------------------------
92
- // Keep the footer locked in place behind the software keyboard (Android).
93
- // -------------------------------------------------------------------------
94
- //
95
- // The Android activity is configured with `windowSoftInputMode="adjustResize"`,
96
- // which shrinks the app window by the keyboard height when the keyboard
97
- // opens. A bottom-anchored footer therefore gets lifted UP by the keyboard
98
- // height — exactly the jump the design does not want.
99
- //
100
- // To counteract that, we translate the footer back DOWN by the same keyboard
101
- // height so it visually stays exactly where it was (now sitting behind the
102
- // keyboard). iOS does not resize the window for the keyboard, so the footer
103
- // already stays put there; we only run this on Android to avoid pushing the
104
- // footer off-screen on platforms that don't lift it in the first place.
105
- const keyboardOffset = useRef(new Animated.Value(0)).current;
106
- useEffect(() => {
107
- if (Platform.OS !== 'android') return undefined;
108
- const animateTo = (toValue, duration) => {
109
- Animated.timing(keyboardOffset, {
110
- toValue,
111
- // Match the OS keyboard animation so the resize and our counter-shift
112
- // cancel out smoothly with no visible footer movement.
113
- duration: typeof duration === 'number' && duration > 0 ? duration : 150,
114
- useNativeDriver: true
115
- }).start();
116
- };
117
- const showSub = Keyboard.addListener('keyboardDidShow', e => {
118
- animateTo(e?.endCoordinates?.height ?? 0, e?.duration);
119
- });
120
- const hideSub = Keyboard.addListener('keyboardDidHide', e => {
121
- animateTo(0, e?.duration);
122
- });
123
- return () => {
124
- showSub.remove();
125
- hideSub.remove();
126
- };
127
- }, [keyboardOffset]);
92
+ function isCompositeActionElement(element) {
93
+ return isElementOfType(element, ButtonGroup) || isElementOfType(element, Stack) || isElementOfType(element, Slot);
94
+ }
95
+ function resolveChrome(modes) {
96
+ const backgroundColor = getVariableByName('actionFooter/background', modes) ?? '#ffffff';
97
+ const gap = getVariableByName('actionFooter/gap', modes) ?? 8;
98
+ const paddingHorizontal = getVariableByName('actionFooter/padding/horizontal', modes) ?? 16;
99
+ const paddingTop = getVariableByName('actionFooter/padding/top', modes) ?? 10;
100
+ const paddingBottom = getVariableByName('actionFooter/padding/bottom', modes) ?? 41;
101
+ const borderRadius = getVariableByName('actionFooter/radius', modes) ?? getVariableByName('radius/actionFooter', modes) ?? 0;
128
102
 
129
- // All token reads collapsed into a single useMemo keyed on `modes`. With
130
- // the shared `EMPTY_MODES` default this resolves once for the common path
131
- // and never re-allocates the container/slot style objects between renders.
132
- const {
133
- containerStyle,
134
- slotStyle
135
- } = useMemo(() => {
136
- const backgroundColor = getVariableByName('actionFooter/background', modes) ?? '#ffffff';
137
- const gap = getVariableByName('actionFooter/gap', modes) ?? 8;
138
- const paddingHorizontal = getVariableByName('actionFooter/padding/horizontal', modes) ?? 16;
139
- const paddingTop = getVariableByName('actionFooter/padding/top', modes) ?? 10;
140
- const paddingBottom = getVariableByName('actionFooter/padding/bottom', modes) ?? 41;
141
- const container = {
142
- // `alignSelf: 'stretch'` is the cross-platform way to ask "fill the
143
- // parent's cross axis" — in the common case (column parent) this gives
144
- // us full-width without the caller needing to pass `width: '100%'`.
103
+ // Title wrap reuses the Drawer title tokens in Figma (same binding ContentSheet
104
+ // uses) so the three bottom surfaces stay typographically aligned.
105
+ const titleColor = getVariableByName('drawer/title/color', modes) ?? '#0d0d0f';
106
+ const titleSize = getVariableByName('drawer/title/fontSize', modes) ?? 14;
107
+ const titleLineHeight = getVariableByName('drawer/title/lineHeight', modes) ?? 17;
108
+ const titleFontFamily = getVariableByName('drawer/title/fontFamily', modes) ?? 'JioType Var';
109
+ const titleFontWeightRaw = getVariableByName('drawer/title/fontWeight', modes) ?? 700;
110
+ const titleFontWeight = typeof titleFontWeightRaw === 'number' ? String(titleFontWeightRaw) : titleFontWeightRaw;
111
+ const titleWrapPaddingTop = getVariableByName('drawer/titleWrap/padding/top', modes) ?? 16;
112
+ const titleWrapPaddingBottom = getVariableByName('drawer/titleWrap/padding/bottom', modes) ?? 8;
113
+ const titleWrapPaddingHorizontal = getVariableByName('drawer/titleWrap/padding/horizontal', modes) ?? 0;
114
+ return {
115
+ containerStyle: {
145
116
  alignSelf: 'stretch',
146
117
  backgroundColor,
147
118
  paddingLeft: paddingHorizontal,
148
119
  paddingRight: paddingHorizontal,
149
120
  paddingTop,
150
121
  paddingBottom,
122
+ borderRadius,
151
123
  ...SHADOW
152
- };
153
- const slot = {
124
+ },
125
+ slotStyle: {
154
126
  flexDirection: 'row',
155
127
  // Vertically center the IconButton against the slightly taller Buttons
156
128
  // so the row reads as a single optical baseline.
157
129
  alignItems: 'center',
130
+ alignSelf: 'stretch',
158
131
  gap
159
- };
132
+ },
133
+ titleWrapStyle: {
134
+ alignSelf: 'stretch',
135
+ alignItems: 'center',
136
+ justifyContent: 'center',
137
+ paddingTop: titleWrapPaddingTop,
138
+ paddingBottom: titleWrapPaddingBottom,
139
+ paddingHorizontal: titleWrapPaddingHorizontal
140
+ },
141
+ titleStyle: {
142
+ color: titleColor,
143
+ fontSize: titleSize,
144
+ lineHeight: titleLineHeight,
145
+ fontFamily: titleFontFamily,
146
+ fontWeight: titleFontWeight,
147
+ textAlign: 'center'
148
+ },
149
+ // Seed the in-flow spacer so the first frame already reserves room and
150
+ // scroll siblings never paint under an unmeasured footer.
151
+ estimatedHeight: paddingTop + paddingBottom + FALLBACK_ACTION_ROW_HEIGHT
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Legacy loose-children path — identical stretch semantics to the original
157
+ * ActionFooter so existing call sites keep pixel parity.
158
+ */
159
+ function enhanceLooseChildren(children, modes) {
160
+ const flat = flattenChildren(children);
161
+ const withModes = cloneChildrenWithModes(flat, modes);
162
+ return withModes.map((child, index) => {
163
+ if (! /*#__PURE__*/React.isValidElement(child)) return child;
164
+ const element = child;
165
+ if (isIconButtonElement(element)) return element;
166
+ return /*#__PURE__*/React.cloneElement(element, {
167
+ key: element.key ?? `action-footer-item-${index}`,
168
+ style: [STRETCH_STYLE, element.props.style]
169
+ });
170
+ });
171
+ }
172
+ function enhanceCompositeChild(element, modes) {
173
+ const childModes = element.props.modes ?? EMPTY_MODES;
174
+ return /*#__PURE__*/React.cloneElement(element, {
175
+ modes: {
176
+ ...modes,
177
+ ...childModes
178
+ },
179
+ style: [COMPOSITE_STRETCH_STYLE, element.props.style]
180
+ });
181
+ }
182
+ /**
183
+ * Shared chrome for web + native. On native (`sticky`), an in-flow shell locks
184
+ * the footer's height into the parent layout while the visible chrome is
185
+ * absolutely pinned to that shell's bottom edge — so the footer cannot scroll
186
+ * away or float mid-screen, and keyboard translation never collapses siblings.
187
+ */
188
+ function ActionFooterChrome({
189
+ children,
190
+ title,
191
+ modes = EMPTY_MODES,
192
+ style,
193
+ accessibilityLabel,
194
+ sticky,
195
+ animatedStyle
196
+ }) {
197
+ const {
198
+ containerStyle,
199
+ slotStyle,
200
+ titleWrapStyle,
201
+ titleStyle,
202
+ estimatedHeight
203
+ } = useMemo(() => resolveChrome(modes), [modes]);
204
+ const showTitle = typeof title === 'string' && title.length > 0;
205
+
206
+ // Decide once whether the caller passed the Figma composite shape
207
+ // (ButtonGroup / Stack / Slot) or the legacy loose-button list. This keeps
208
+ // both APIs working without forcing a migration.
209
+ const {
210
+ actionsBody,
211
+ estimatedActionsHeight
212
+ } = useMemo(() => {
213
+ const flat = flattenChildren(children);
214
+ const sole = flat.length === 1 && /*#__PURE__*/React.isValidElement(flat[0]) ? flat[0] : null;
215
+ if (sole && isCompositeActionElement(sole)) {
216
+ return {
217
+ actionsBody: enhanceCompositeChild(sole, modes),
218
+ estimatedActionsHeight: FALLBACK_ACTION_ROW_HEIGHT
219
+ };
220
+ }
160
221
  return {
161
- containerStyle: container,
162
- slotStyle: slot
222
+ actionsBody: /*#__PURE__*/_jsx(View, {
223
+ style: slotStyle,
224
+ children: enhanceLooseChildren(children, modes)
225
+ }),
226
+ estimatedActionsHeight: FALLBACK_ACTION_ROW_HEIGHT
163
227
  };
164
- }, [modes]);
228
+ }, [children, modes, slotStyle]);
229
+ const body = useMemo(() => {
230
+ if (!showTitle) {
231
+ // No title → identical tree shape to the pre-title ActionFooter for the
232
+ // loose-children path (just the actions row / composite). No extra Stack
233
+ // wrapper, so existing layouts cannot regress from an added gap node.
234
+ return actionsBody;
235
+ }
165
236
 
166
- // Process children once per (children, modes) tuple:
167
- // 1. Flatten Fragments so each action is its own keyed sibling.
168
- // 2. Push `modes` down so callers don't have to thread it manually.
169
- // 3. Auto-stretch every non-IconButton with the Yoga-safe stretch style.
170
- //
171
- // The result identity is stable across re-renders when the inputs don't
172
- // change, which keeps the `React.memo`-wrapped Button/IconButton children
173
- // from re-rendering for no reason.
174
- const enhancedChildren = useMemo(() => {
175
- const flat = flattenChildren(children);
176
- const withModes = cloneChildrenWithModes(flat, modes);
177
- return withModes.map((child, index) => {
178
- if (! /*#__PURE__*/React.isValidElement(child)) return child;
179
- const element = child;
180
- if (isIconButtonElement(element)) return element;
181
- return /*#__PURE__*/React.cloneElement(element, {
182
- key: element.key ?? `action-footer-item-${index}`,
183
- style: [STRETCH_STYLE, element.props.style]
184
- });
237
+ // Figma: title wrap (sibling) + Stack → Slot → ButtonGroup. We mount a
238
+ // vertical Stack only when the title is present so the gap between title
239
+ // and actions is token-driven (`slot/gap` via Stack, cascaded modes).
240
+ return /*#__PURE__*/_jsxs(Stack, {
241
+ layoutDirection: "vertical",
242
+ fillWidth: true,
243
+ modes: modes,
244
+ children: [/*#__PURE__*/_jsx(View, {
245
+ style: titleWrapStyle,
246
+ accessibilityRole: "header",
247
+ children: /*#__PURE__*/_jsx(Text, {
248
+ style: titleStyle,
249
+ children: title
250
+ })
251
+ }), actionsBody]
185
252
  });
186
- }, [children, modes]);
187
- return /*#__PURE__*/_jsx(Animated.View, {
188
- style: [containerStyle, style,
189
- // Counter-translate by the keyboard height on Android so `adjustResize`
190
- // can't lift the footer above the keyboard (no-op on iOS/web where the
191
- // value stays at 0).
192
- {
193
- transform: [{
194
- translateY: keyboardOffset
195
- }]
196
- }],
253
+ }, [showTitle, actionsBody, modes, title, titleWrapStyle, titleStyle]);
254
+ const [footerHeight, setFooterHeight] = useState(estimatedHeight + (showTitle ? 41 : 0));
255
+
256
+ // Token / title presence changes should re-seed the spacer immediately;
257
+ // the next onLayout then corrects to the true measured height.
258
+ useEffect(() => {
259
+ setFooterHeight(estimatedHeight + (showTitle ? 41 : 0));
260
+ }, [estimatedHeight, showTitle, estimatedActionsHeight]);
261
+ const onFooterLayout = event => {
262
+ const next = Math.ceil(event.nativeEvent.layout.height);
263
+ if (next > 0 && next !== footerHeight) {
264
+ setFooterHeight(next);
265
+ }
266
+ };
267
+ const chrome = /*#__PURE__*/_jsx(Animated.View, {
268
+ style: [sticky ? PINNED_STYLE : null, containerStyle, style, animatedStyle],
269
+ onLayout: sticky ? onFooterLayout : undefined,
197
270
  accessibilityRole: "toolbar",
198
271
  accessibilityLabel: accessibilityLabel,
199
- children: /*#__PURE__*/_jsx(View, {
200
- style: slotStyle,
201
- children: enhancedChildren
202
- })
272
+ children: body
273
+ });
274
+ if (!sticky) {
275
+ return chrome;
276
+ }
277
+ return /*#__PURE__*/_jsx(View, {
278
+ style: [SHELL_STYLE, {
279
+ height: footerHeight
280
+ }],
281
+ pointerEvents: "box-none",
282
+ children: chrome
283
+ });
284
+ }
285
+
286
+ /**
287
+ * UI-thread keyboard tracking. Lifts by `keyboard.height - layoutShrink` so:
288
+ * - iOS / Android `adjustPan` / `adjustNothing` → full keyboard height
289
+ * - Android `adjustResize` (this app's default) → ~0 (window already shrank)
290
+ * Never double-counts, never depends on callers wiring KeyboardAvoidingView.
291
+ */
292
+ function KeyboardAwareActionFooter(props) {
293
+ const {
294
+ height: windowHeight,
295
+ width: windowWidth
296
+ } = useWindowDimensions();
297
+ const maxWindowHeight = useSharedValue(windowHeight);
298
+ const layoutShrink = useSharedValue(0);
299
+ const keyboard = useAnimatedKeyboard();
300
+
301
+ // Orientation / split-screen width changes invalidate the height baseline.
302
+ // Intentionally NOT keyed on `windowHeight` — Android `adjustResize` shrinks
303
+ // height when the keyboard opens, and resetting the baseline there would
304
+ // collapse `layoutShrink` to 0 and double-lift the footer.
305
+ useEffect(() => {
306
+ maxWindowHeight.value = windowHeight;
307
+ layoutShrink.value = 0;
308
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- width-only baseline
309
+ }, [windowWidth]);
310
+
311
+ // Track how much of the keyboard inset the window has already absorbed.
312
+ // Shared values keep the keyboard worklet free of React re-render lag.
313
+ useEffect(() => {
314
+ if (windowHeight > maxWindowHeight.value) {
315
+ maxWindowHeight.value = windowHeight;
316
+ }
317
+ layoutShrink.value = Math.max(0, maxWindowHeight.value - windowHeight);
318
+ }, [windowHeight, maxWindowHeight, layoutShrink]);
319
+ const animatedStyle = useAnimatedStyle(() => {
320
+ const lift = Math.max(0, keyboard.height.value - layoutShrink.value);
321
+ return {
322
+ transform: [{
323
+ translateY: -lift
324
+ }]
325
+ };
326
+ });
327
+ return /*#__PURE__*/_jsx(ActionFooterChrome, {
328
+ ...props,
329
+ sticky: true,
330
+ animatedStyle: animatedStyle
331
+ });
332
+ }
333
+
334
+ /**
335
+ * ActionFooter — sticky bottom container for primary screen actions.
336
+ *
337
+ * Layout contract (native):
338
+ * - Self-pins to the bottom of its flex-column screen via `marginTop: 'auto'`
339
+ * plus an absolutely positioned chrome inside a height-locked shell.
340
+ * - The shell keeps scroll/flex siblings from painting under the footer —
341
+ * callers do not need to hand-maintain `paddingBottom`.
342
+ * - Keyboard avoidance is owned here: the footer moves up by exactly the
343
+ * keyboard height that layout has not already absorbed. Host
344
+ * `KeyboardAvoidingView` is unnecessary and should not wrap this.
345
+ *
346
+ * Layout contract (web):
347
+ * - In-flow stretch container (Storybook / docs friendly). No keyboard inset.
348
+ *
349
+ * Content contract:
350
+ * - Optional centered `title` (Figma title wrap; Drawer title tokens).
351
+ * - Loose `IconButton`/`Button` children keep the original equal-stretch row.
352
+ * - A sole `ButtonGroup` / `Stack` / `Slot` child is treated as the Figma
353
+ * composite and is not re-stretched internally.
354
+ *
355
+ * @example
356
+ * ```tsx
357
+ * // Legacy — still the recommended simple path
358
+ * <ActionFooter>
359
+ * <IconButton iconName="ic_split" />
360
+ * <Button label="Request" modes={{ AppearanceBrand: 'Secondary' }} />
361
+ * <Button label="Pay" modes={{ AppearanceBrand: 'Primary' }} />
362
+ * </ActionFooter>
363
+ *
364
+ * // Figma shape + title
365
+ * <ActionFooter title="Confirm payment">
366
+ * <ButtonGroup>
367
+ * <Button label="Request" modes={{ AppearanceBrand: 'Secondary' }} />
368
+ * <Button label="Pay" modes={{ AppearanceBrand: 'Primary' }} />
369
+ * </ButtonGroup>
370
+ * </ActionFooter>
371
+ * ```
372
+ */
373
+ function ActionFooter({
374
+ avoidKeyboard = true,
375
+ ...props
376
+ }) {
377
+ // Distinct component types keep `useAnimatedKeyboard` hook order stable
378
+ // (same pattern as ContentSheet). Web never mounts the keyboard path.
379
+ if (STICKY_BY_DEFAULT && avoidKeyboard) {
380
+ return /*#__PURE__*/_jsx(KeyboardAwareActionFooter, {
381
+ ...props
382
+ });
383
+ }
384
+ return /*#__PURE__*/_jsx(ActionFooterChrome, {
385
+ ...props,
386
+ sticky: STICKY_BY_DEFAULT
203
387
  });
204
388
  }
205
389
  export default /*#__PURE__*/React.memo(ActionFooter);
@@ -8,6 +8,25 @@ import Skeleton from '../../skeleton/Skeleton';
8
8
  import { useSkeleton } from '../../skeleton/SkeletonGroup';
9
9
  import GlassFill from '../../utils/GlassFill/GlassFill';
10
10
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
+ /**
12
+ * Figma still binds `badge/color/label`, but that alias is broken for
13
+ * `Semantic Intent=System` + `Emphasis=High` (missing VariableID:2811:2401).
14
+ * Use the published tokens that the chain is supposed to land on:
15
+ * - Brand → `Emphasis/badge/brand/fg`
16
+ * - System + High → `Emphasis/badge/high/fg Copy` (AppearanceSystem)
17
+ * - System + Medium/Low → `Emphasis/badge/system/fg`
18
+ */
19
+ function badgeLabelColorToken(modes) {
20
+ const intent = modes['Semantic Intent'] ?? 'Brand';
21
+ const emphasis = modes.Emphasis ?? 'High';
22
+ if (intent === 'System' && emphasis === 'High') {
23
+ return 'Emphasis/badge/high/fg Copy';
24
+ }
25
+ if (intent === 'System') {
26
+ return 'Emphasis/badge/system/fg';
27
+ }
28
+ return 'Emphasis/badge/brand/fg';
29
+ }
11
30
  function Badge({
12
31
  label = 'Label',
13
32
  type = 'default',
@@ -32,7 +51,7 @@ function Badge({
32
51
  const backgroundColor = getVariableByName('badge/color/background', modes);
33
52
  const glassBackground = getVariableByName('badge/glass/color/background', modes);
34
53
  const glassLabelColor = getVariableByName('badge/glass/color/label', modes);
35
- const labelColor = isGlass ? glassLabelColor : getVariableByName('badge/color/label', modes);
54
+ const labelColor = isGlass ? glassLabelColor : getVariableByName(badgeLabelColorToken(modes), modes);
36
55
  const fontSize = Number(getVariableByName('badge/label/fontSize', modes));
37
56
  const fontFamily = getVariableByName('badge/fontFamily', modes);
38
57
  const fontWeight = getVariableByName('badge/label/fontWeight', modes);