jfs-components 0.0.74 → 0.0.78

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 (146) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/lib/commonjs/components/Accordion/Accordion.js +55 -55
  3. package/lib/commonjs/components/ActionFooter/ActionFooter.js +193 -82
  4. package/lib/commonjs/components/Avatar/Avatar.js +20 -0
  5. package/lib/commonjs/components/Badge/Badge.js +23 -0
  6. package/lib/commonjs/components/Button/Button.js +37 -0
  7. package/lib/commonjs/components/Checkbox/Checkbox.js +21 -9
  8. package/lib/commonjs/components/DropdownInput/DropdownInput.js +30 -16
  9. package/lib/commonjs/components/ExpandableCheckbox/ExpandableCheckbox.js +167 -0
  10. package/lib/commonjs/components/FormField/FormField.js +14 -1
  11. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +355 -0
  12. package/lib/commonjs/components/IconButton/IconButton.js +20 -0
  13. package/lib/commonjs/components/Image/Image.js +26 -1
  14. package/lib/commonjs/components/ListItem/ListItem.js +25 -10
  15. package/lib/commonjs/components/LottiePlayer/LottiePlayer.js +116 -0
  16. package/lib/commonjs/components/LottiePlayer/LottiePlayer.web.js +82 -0
  17. package/lib/commonjs/components/LottiePlayer/loadNativeLottieView.js +74 -0
  18. package/lib/commonjs/components/LottiePlayer/loadWebLottieView.js +50 -0
  19. package/lib/commonjs/components/MessageField/MessageField.js +318 -0
  20. package/lib/commonjs/components/NavArrow/NavArrow.js +58 -17
  21. package/lib/commonjs/components/PageHero/PageHero.js +41 -5
  22. package/lib/commonjs/components/RechargeCard/RechargeCard.js +32 -17
  23. package/lib/commonjs/components/Stepper/Step.js +47 -60
  24. package/lib/commonjs/components/Stepper/StepLabel.js +40 -10
  25. package/lib/commonjs/components/Stepper/Stepper.js +15 -17
  26. package/lib/commonjs/components/SuggestiveSearch/SuggestiveSearch.js +487 -0
  27. package/lib/commonjs/components/Text/Text.js +31 -1
  28. package/lib/commonjs/components/TextInput/TextInput.js +16 -1
  29. package/lib/commonjs/components/Title/Title.js +10 -2
  30. package/lib/commonjs/components/index.js +35 -0
  31. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  32. package/lib/commonjs/icons/Icon.js +16 -0
  33. package/lib/commonjs/icons/registry.js +1 -1
  34. package/lib/commonjs/index.js +12 -0
  35. package/lib/commonjs/skeleton/Skeleton.js +234 -0
  36. package/lib/commonjs/skeleton/SkeletonGroup.js +140 -0
  37. package/lib/commonjs/skeleton/index.js +58 -0
  38. package/lib/commonjs/skeleton/shimmer-tokens.js +189 -0
  39. package/lib/commonjs/skeleton/useReducedMotion.js +64 -0
  40. package/lib/module/components/Accordion/Accordion.js +56 -56
  41. package/lib/module/components/ActionFooter/ActionFooter.js +193 -83
  42. package/lib/module/components/Avatar/Avatar.js +19 -0
  43. package/lib/module/components/Badge/Badge.js +23 -0
  44. package/lib/module/components/Button/Button.js +37 -0
  45. package/lib/module/components/Checkbox/Checkbox.js +22 -10
  46. package/lib/module/components/DropdownInput/DropdownInput.js +30 -16
  47. package/lib/module/components/ExpandableCheckbox/ExpandableCheckbox.js +161 -0
  48. package/lib/module/components/FormField/FormField.js +16 -3
  49. package/lib/module/components/FullscreenModal/FullscreenModal.js +350 -0
  50. package/lib/module/components/IconButton/IconButton.js +20 -0
  51. package/lib/module/components/Image/Image.js +25 -1
  52. package/lib/module/components/ListItem/ListItem.js +25 -10
  53. package/lib/module/components/LottiePlayer/LottiePlayer.js +111 -0
  54. package/lib/module/components/LottiePlayer/LottiePlayer.web.js +77 -0
  55. package/lib/module/components/LottiePlayer/loadNativeLottieView.js +69 -0
  56. package/lib/module/components/LottiePlayer/loadWebLottieView.js +45 -0
  57. package/lib/module/components/MessageField/MessageField.js +313 -0
  58. package/lib/module/components/NavArrow/NavArrow.js +59 -18
  59. package/lib/module/components/PageHero/PageHero.js +41 -5
  60. package/lib/module/components/RechargeCard/RechargeCard.js +33 -17
  61. package/lib/module/components/Stepper/Step.js +48 -61
  62. package/lib/module/components/Stepper/StepLabel.js +40 -10
  63. package/lib/module/components/Stepper/Stepper.js +15 -17
  64. package/lib/module/components/SuggestiveSearch/SuggestiveSearch.js +481 -0
  65. package/lib/module/components/Text/Text.js +31 -1
  66. package/lib/module/components/TextInput/TextInput.js +17 -2
  67. package/lib/module/components/Title/Title.js +10 -2
  68. package/lib/module/components/index.js +5 -0
  69. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  70. package/lib/module/icons/Icon.js +16 -0
  71. package/lib/module/icons/registry.js +1 -1
  72. package/lib/module/index.js +2 -1
  73. package/lib/module/skeleton/Skeleton.js +229 -0
  74. package/lib/module/skeleton/SkeletonGroup.js +133 -0
  75. package/lib/module/skeleton/index.js +6 -0
  76. package/lib/module/skeleton/shimmer-tokens.js +181 -0
  77. package/lib/module/skeleton/useReducedMotion.js +61 -0
  78. package/lib/typescript/src/components/Accordion/Accordion.d.ts +14 -20
  79. package/lib/typescript/src/components/ActionFooter/ActionFooter.d.ts +26 -21
  80. package/lib/typescript/src/components/Avatar/Avatar.d.ts +7 -1
  81. package/lib/typescript/src/components/Badge/Badge.d.ts +7 -1
  82. package/lib/typescript/src/components/Button/Button.d.ts +8 -1
  83. package/lib/typescript/src/components/ExpandableCheckbox/ExpandableCheckbox.d.ts +63 -0
  84. package/lib/typescript/src/components/FullscreenModal/FullscreenModal.d.ts +99 -0
  85. package/lib/typescript/src/components/IconButton/IconButton.d.ts +7 -1
  86. package/lib/typescript/src/components/Image/Image.d.ts +8 -1
  87. package/lib/typescript/src/components/LottiePlayer/LottiePlayer.d.ts +85 -0
  88. package/lib/typescript/src/components/LottiePlayer/LottiePlayer.web.d.ts +28 -0
  89. package/lib/typescript/src/components/LottiePlayer/loadNativeLottieView.d.ts +11 -0
  90. package/lib/typescript/src/components/LottiePlayer/loadWebLottieView.d.ts +11 -0
  91. package/lib/typescript/src/components/MessageField/MessageField.d.ts +81 -0
  92. package/lib/typescript/src/components/NavArrow/NavArrow.d.ts +10 -5
  93. package/lib/typescript/src/components/PageHero/PageHero.d.ts +31 -5
  94. package/lib/typescript/src/components/Stepper/Step.d.ts +4 -1
  95. package/lib/typescript/src/components/Stepper/StepLabel.d.ts +4 -1
  96. package/lib/typescript/src/components/Stepper/Stepper.d.ts +3 -1
  97. package/lib/typescript/src/components/SuggestiveSearch/SuggestiveSearch.d.ts +123 -0
  98. package/lib/typescript/src/components/Text/Text.d.ts +20 -1
  99. package/lib/typescript/src/components/index.d.ts +8 -3
  100. package/lib/typescript/src/icons/Icon.d.ts +7 -1
  101. package/lib/typescript/src/icons/registry.d.ts +1 -1
  102. package/lib/typescript/src/index.d.ts +1 -0
  103. package/lib/typescript/src/skeleton/Skeleton.d.ts +60 -0
  104. package/lib/typescript/src/skeleton/SkeletonGroup.d.ts +78 -0
  105. package/lib/typescript/src/skeleton/index.d.ts +5 -0
  106. package/lib/typescript/src/skeleton/shimmer-tokens.d.ts +160 -0
  107. package/lib/typescript/src/skeleton/useReducedMotion.d.ts +15 -0
  108. package/package.json +11 -1
  109. package/src/components/Accordion/Accordion.tsx +113 -73
  110. package/src/components/ActionFooter/ActionFooter.tsx +210 -92
  111. package/src/components/Avatar/Avatar.tsx +26 -0
  112. package/src/components/Badge/Badge.tsx +27 -0
  113. package/src/components/Button/Button.tsx +40 -0
  114. package/src/components/Checkbox/Checkbox.tsx +22 -9
  115. package/src/components/DropdownInput/DropdownInput.tsx +67 -39
  116. package/src/components/ExpandableCheckbox/ExpandableCheckbox.tsx +237 -0
  117. package/src/components/FormField/FormField.tsx +19 -3
  118. package/src/components/FullscreenModal/FullscreenModal.tsx +414 -0
  119. package/src/components/IconButton/IconButton.tsx +27 -0
  120. package/src/components/Image/Image.tsx +25 -0
  121. package/src/components/ListItem/ListItem.tsx +21 -10
  122. package/src/components/LottiePlayer/LottiePlayer.tsx +145 -0
  123. package/src/components/LottiePlayer/LottiePlayer.web.tsx +94 -0
  124. package/src/components/LottiePlayer/loadNativeLottieView.tsx +87 -0
  125. package/src/components/LottiePlayer/loadWebLottieView.tsx +64 -0
  126. package/src/components/MessageField/MessageField.tsx +543 -0
  127. package/src/components/NavArrow/NavArrow.tsx +81 -17
  128. package/src/components/PageHero/PageHero.tsx +61 -4
  129. package/src/components/RechargeCard/RechargeCard.tsx +32 -24
  130. package/src/components/Stepper/Step.tsx +52 -51
  131. package/src/components/Stepper/StepLabel.tsx +46 -9
  132. package/src/components/Stepper/Stepper.tsx +20 -15
  133. package/src/components/SuggestiveSearch/SuggestiveSearch.tsx +756 -0
  134. package/src/components/Text/Text.tsx +54 -0
  135. package/src/components/TextInput/TextInput.tsx +14 -1
  136. package/src/components/Title/Title.tsx +13 -2
  137. package/src/components/index.ts +8 -3
  138. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  139. package/src/icons/Icon.tsx +17 -0
  140. package/src/icons/registry.ts +1 -1
  141. package/src/index.ts +1 -0
  142. package/src/skeleton/Skeleton.tsx +298 -0
  143. package/src/skeleton/SkeletonGroup.tsx +193 -0
  144. package/src/skeleton/index.ts +10 -0
  145. package/src/skeleton/shimmer-tokens.ts +221 -0
  146. package/src/skeleton/useReducedMotion.ts +72 -0
@@ -1,4 +1,4 @@
1
- import React, { useState } from 'react'
1
+ import React, { useMemo, useState } from 'react'
2
2
  import {
3
3
  View,
4
4
  Text,
@@ -21,9 +21,49 @@ if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental
21
21
  UIManager.setLayoutAnimationEnabledExperimental(true)
22
22
  }
23
23
 
24
+ type AccordionStateMode = 'Idle' | 'Hover' | 'Open' | 'Open Hover' | 'Disabled'
25
+
26
+ function resolveAccordionStateMode(
27
+ disabled: boolean,
28
+ isExpanded: boolean,
29
+ isHovered: boolean,
30
+ contained: boolean,
31
+ ): AccordionStateMode {
32
+ if (disabled) return 'Disabled'
33
+
34
+ if (contained) {
35
+ return isExpanded ? 'Open Hover' : 'Hover'
36
+ }
37
+
38
+ if (isExpanded) {
39
+ return isHovered ? 'Open Hover' : 'Open'
40
+ }
41
+
42
+ return isHovered ? 'Hover' : 'Idle'
43
+ }
44
+
45
+ function toFontWeight(value: unknown, fallback: TextStyle['fontWeight']): TextStyle['fontWeight'] {
46
+ if (typeof value === 'number') return String(value) as TextStyle['fontWeight']
47
+ if (typeof value === 'string') {
48
+ const normalized = value.trim().toLowerCase()
49
+ if (normalized === 'bold') return '700'
50
+ if (normalized === 'medium') return '500'
51
+ if (normalized === 'regular' || normalized === 'normal') return '400'
52
+ if (/^\d+$/.test(normalized)) return normalized as TextStyle['fontWeight']
53
+ return value as TextStyle['fontWeight']
54
+ }
55
+ return fallback
56
+ }
57
+
24
58
  export type AccordionProps = {
25
59
  /** The accordion header title */
26
60
  title?: string;
61
+ /**
62
+ * When `true`, the header always uses the filled background treatment
63
+ * (Figma Hover / Open Hover visuals). Defaults to `false` (transparent at
64
+ * rest, filled only while hovered or pressed).
65
+ */
66
+ contained?: boolean;
27
67
  /** Initial expanded state. Defaults to false (collapsed) */
28
68
  defaultExpanded?: boolean;
29
69
  /** Controlled expanded state. When provided, the component becomes controlled */
@@ -51,31 +91,20 @@ export type AccordionProps = {
51
91
  /**
52
92
  * Accordion component that mirrors the Figma "Accordion" component.
53
93
  *
54
- * This component supports:
55
- * - **Expandable/collapsible content** with smooth animation
56
- * - **States**: Idle, Hover, Open, Disabled
57
- * - **Slot** for custom content
58
- * - **Design-token driven styling** via `getVariableByName` and `modes`
94
+ * Supports two visual treatments via the `contained` prop:
95
+ * - **`contained={false}`** (default) transparent header at rest; filled
96
+ * background on hover / press.
97
+ * - **`contained={true}`** header always uses the filled background.
59
98
  *
60
- * Wherever the Figma layer name contains "Slot", this component exposes a
61
- * dedicated React "slot" prop:
62
- * - Slot "content" `children`
99
+ * Interaction states (Idle, Hover, Open, Disabled) are resolved automatically
100
+ * from `expanded`, `disabled`, hover, and `contained` — consumers should not
101
+ * pass `'Accordion States'` in `modes`.
63
102
  *
64
103
  * @component
65
- * @param {Object} props
66
- * @param {string} [props.title='Accordion title'] - The accordion header title
67
- * @param {boolean} [props.defaultExpanded=false] - Initial expanded state
68
- * @param {boolean} [props.expanded] - Controlled expanded state
69
- * @param {Function} [props.onExpandedChange] - Callback fired when expanded state changes
70
- * @param {boolean} [props.disabled=false] - Whether the accordion is disabled
71
- * @param {React.ReactNode} [props.children] - Content to display when expanded
72
- * @param {Object} [props.modes={}] - Modes object passed to `getVariableByName` for all design tokens
73
- * @param {Object} [props.style] - Optional container style overrides
74
- * @param {string} [props.accessibilityLabel] - Accessibility label for the accordion. If not provided, uses title
75
- * @param {string} [props.accessibilityHint] - Additional accessibility hint for screen readers
76
104
  */
77
105
  function Accordion({
78
106
  title = 'Accordion title',
107
+ contained = false,
79
108
  defaultExpanded = false,
80
109
  expanded: controlledExpanded,
81
110
  onExpandedChange,
@@ -89,23 +118,31 @@ function Accordion({
89
118
  webAccessibilityProps,
90
119
  ...rest
91
120
  }: AccordionProps) {
92
- // Internal state for uncontrolled mode
93
121
  const [internalExpanded, setInternalExpanded] = useState(defaultExpanded)
94
-
95
- // Determine if controlled or uncontrolled
122
+ const [isHovered, setIsHovered] = useState(false)
123
+
96
124
  const isControlled = controlledExpanded !== undefined
97
125
  const isExpanded = isControlled ? controlledExpanded : internalExpanded
98
-
99
- // Hover state for web
100
- const [isHovered, setIsHovered] = useState(false)
101
-
102
- // Handle toggle
126
+
127
+ const resolvedModes = useMemo(() => {
128
+ const accordionState = resolveAccordionStateMode(
129
+ disabled,
130
+ isExpanded,
131
+ isHovered,
132
+ contained,
133
+ )
134
+
135
+ return {
136
+ ...modes,
137
+ 'Accordion States': accordionState,
138
+ }
139
+ }, [contained, disabled, isExpanded, isHovered, modes])
140
+
103
141
  const handleToggle = () => {
104
142
  if (disabled) return
105
-
106
- // Animate the layout change
143
+
107
144
  LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
108
-
145
+
109
146
  if (isControlled) {
110
147
  onExpandedChange?.(!isExpanded)
111
148
  } else {
@@ -113,38 +150,45 @@ function Accordion({
113
150
  onExpandedChange?.(!isExpanded)
114
151
  }
115
152
  }
116
-
117
- // Resolve design tokens
118
- const titleColor = disabled
119
- ? '#999999'
120
- : getVariableByName('accordion/title/color', modes) || '#0d0d0d'
121
- const titleFontSize = getVariableByName('accordion/title/fontSize', modes) || 18
122
- const titleLineHeight = getVariableByName('accordion/title/lineHeight', modes) || 20
123
- const titleFontFamily = getVariableByName('accordion/title/fontFamily', modes) || 'System'
124
-
125
- const iconColor = getVariableByName('accordion/icon/color', modes) || '#141414'
126
- const iconSize = getVariableByName('accordion/icon/size', modes) || 24
127
-
128
- const headerGap = getVariableByName('accordion/header/gap', modes) || 12
129
- const headerPaddingVertical = getVariableByName('accordion/header/padding/vertical', modes) || 24
130
- const headerBackground = isHovered && !disabled
131
- ? '#f2f2f2'
132
- : getVariableByName('accordion/header/background', modes) || 'transparent'
133
-
134
- const contentGap = getVariableByName('accordion/content/gap', modes) || 12
135
- const contentPaddingTop = getVariableByName('accordion/content/padding/top', modes) || 8
136
- const contentPaddingBottom = isExpanded
137
- ? (getVariableByName('accordion/content/padding/bottom', modes) ?? 24)
138
- : 8
139
-
140
- const borderColor = getVariableByName('accordion/border/color', modes) || '#e6e6e6'
141
-
142
- // Styles
153
+
154
+ const titleColor =
155
+ (getVariableByName('accordion/title/color', resolvedModes) as string | null) ?? '#0d0d0d'
156
+ const titleFontSize =
157
+ (getVariableByName('accordion/title/fontSize', resolvedModes) as number | null) ?? 14
158
+ const titleLineHeight =
159
+ (getVariableByName('accordion/title/lineHeight', resolvedModes) as number | null) ?? 20
160
+ const titleFontFamily =
161
+ (getVariableByName('accordion/title/fontFamily', resolvedModes) as string | null) ?? 'System'
162
+ const titleFontWeight = toFontWeight(
163
+ getVariableByName('accordion/title/fontWeight', resolvedModes),
164
+ '700',
165
+ )
166
+
167
+ const iconColor =
168
+ (getVariableByName('accordion/icon/color', resolvedModes) as string | null) ?? '#141414'
169
+ const iconSize = (getVariableByName('accordion/icon/size', resolvedModes) as number | null) ?? 24
170
+
171
+ const headerGap = (getVariableByName('accordion/header/gap', resolvedModes) as number | null) ?? 12
172
+ const headerPaddingVertical =
173
+ (getVariableByName('accordion/header/padding/vertical', resolvedModes) as number | null) ?? 8
174
+ const headerBackground =
175
+ (getVariableByName('accordion/header/background', resolvedModes) as string | null) ??
176
+ 'transparent'
177
+
178
+ const contentGap = (getVariableByName('accordion/content/gap', resolvedModes) as number | null) ?? 12
179
+ const contentPaddingTop =
180
+ (getVariableByName('accordion/content/padding/top', resolvedModes) as number | null) ?? 8
181
+ const contentPaddingBottom =
182
+ (getVariableByName('accordion/content/padding/bottom', resolvedModes) as number | null) ?? 8
183
+
184
+ const borderColor =
185
+ (getVariableByName('accordion/border/color', resolvedModes) as string | null) ?? '#e6e6e6'
186
+
143
187
  const containerStyle: ViewStyle = {
144
188
  borderBottomWidth: 1,
145
189
  borderBottomColor: borderColor,
146
190
  }
147
-
191
+
148
192
  const headerStyle: ViewStyle = {
149
193
  flexDirection: 'row',
150
194
  alignItems: 'center',
@@ -154,16 +198,16 @@ function Accordion({
154
198
  backgroundColor: headerBackground,
155
199
  overflow: 'hidden',
156
200
  }
157
-
201
+
158
202
  const titleStyle: TextStyle = {
159
203
  flex: 1,
160
204
  color: titleColor,
161
205
  fontSize: titleFontSize,
162
206
  lineHeight: titleLineHeight,
163
207
  fontFamily: titleFontFamily,
164
- fontWeight: '700',
208
+ fontWeight: titleFontWeight,
165
209
  }
166
-
210
+
167
211
  const contentStyle: ViewStyle = {
168
212
  backgroundColor: 'transparent',
169
213
  gap: contentGap,
@@ -172,11 +216,9 @@ function Accordion({
172
216
  paddingHorizontal: 0,
173
217
  overflow: 'hidden',
174
218
  }
175
-
176
- // Generate default accessibility label
219
+
177
220
  const defaultAccessibilityLabel = accessibilityLabel || title
178
-
179
- // Web platform support
221
+
180
222
  const webProps = usePressableWebSupport({
181
223
  restProps: {},
182
224
  onPress: handleToggle,
@@ -184,12 +226,11 @@ function Accordion({
184
226
  accessibilityLabel: defaultAccessibilityLabel,
185
227
  webAccessibilityProps,
186
228
  })
187
-
188
- // Process children to pass modes
229
+
189
230
  const processedChildren = children
190
- ? cloneChildrenWithModes(React.Children.toArray(children), modes)
231
+ ? cloneChildrenWithModes(React.Children.toArray(children), resolvedModes)
191
232
  : null
192
-
233
+
193
234
  return (
194
235
  <View style={[containerStyle, style]} {...rest}>
195
236
  <Pressable
@@ -217,12 +258,12 @@ function Accordion({
217
258
  <Icon
218
259
  name={isExpanded ? 'ic_minus' : 'ic_add'}
219
260
  size={iconSize}
220
- color={disabled ? '#999999' : iconColor}
261
+ color={iconColor}
221
262
  accessibilityElementsHidden={true}
222
263
  importantForAccessibility="no"
223
264
  />
224
265
  </Pressable>
225
-
266
+
226
267
  {isExpanded && processedChildren && (
227
268
  <View style={contentStyle}>
228
269
  {processedChildren}
@@ -233,4 +274,3 @@ function Accordion({
233
274
  }
234
275
 
235
276
  export default Accordion
236
-
@@ -1,9 +1,12 @@
1
- import React from 'react'
1
+ import React, { useEffect, useMemo, useRef } from 'react'
2
2
  import {
3
+ Animated,
4
+ Keyboard,
3
5
  View,
6
+ Platform,
7
+ type KeyboardEvent,
4
8
  type ViewStyle,
5
9
  type StyleProp,
6
- Platform,
7
10
  } from 'react-native'
8
11
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
9
12
  import { EMPTY_MODES, cloneChildrenWithModes, flattenChildren } from '../../utils/react-utils'
@@ -12,48 +15,118 @@ import IconButton from '../IconButton/IconButton'
12
15
  export type ActionFooterProps = {
13
16
  /**
14
17
  * Content to render inside the action footer slot.
15
- * Typically includes IconButton and Button components.
18
+ * Typically includes `IconButton` and `Button` components.
19
+ * `IconButton` children keep their intrinsic square size; everything else
20
+ * is auto-stretched to share the remaining horizontal space equally.
16
21
  */
17
22
  children?: React.ReactNode
18
23
  /**
19
24
  * Mode configuration passed to the token resolver.
20
- * Pass the same modes to children components for consistent theming.
25
+ * Automatically merged into every slot child via {@link cloneChildrenWithModes}
26
+ * so callers don't have to thread modes down by hand.
21
27
  */
22
28
  modes?: Record<string, any>
23
29
  /**
24
- * Optional style overrides for the container
30
+ * Optional style overrides for the outer container.
25
31
  */
26
32
  style?: StyleProp<ViewStyle>
27
33
  /**
28
- * Accessibility label for the footer region
34
+ * Accessibility label for the footer region (announced for the toolbar).
29
35
  */
30
36
  accessibilityLabel?: string
31
37
  }
32
38
 
39
+ const IS_WEB = Platform.OS === 'web'
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Yoga-safe stretch
43
+ // ---------------------------------------------------------------------------
44
+ //
45
+ // React Native (Yoga) interprets the `flex: 1` shorthand as
46
+ // { flexGrow: 1, flexShrink: 1, flexBasis: 0 }
47
+ // which is the *equal-share* variant. That is the correct math for what we
48
+ // want here (equal-width action buttons), BUT Yoga has a well-known foot-gun
49
+ // when this child sits inside a parent whose main-axis size hasn't been
50
+ // resolved yet on the first layout pass: the child collapses to 0 and the
51
+ // inner text gets clipped to "" before the parent ever measures.
52
+ //
53
+ // The defensive incantation used elsewhere in this codebase (see
54
+ // `CardCTA.leftWrap` and the `MediaCard.Header` fix in CHANGELOG.md) is to
55
+ // keep the equal-share math but explicitly clamp `minWidth` to 0 so Yoga
56
+ // always allows the child to participate in the shrink algorithm, even when
57
+ // the parent itself is in an undetermined state. Combined with explicit
58
+ // `flexGrow`/`flexShrink`/`flexBasis` (NOT the `flex` shorthand) this
59
+ // renders correctly on iOS, Android, and Web — and crucially never produces
60
+ // the "buttons render as empty pills" failure mode the previous version had
61
+ // on iOS dev clients.
62
+ const STRETCH_STYLE: ViewStyle = {
63
+ flexGrow: 1,
64
+ flexShrink: 1,
65
+ flexBasis: 0,
66
+ minWidth: 0,
67
+ }
68
+
69
+ // Platform-specific drop shadow. Web boxShadow can't go through
70
+ // Platform.select (RN's typed surface doesn't include it) so we keep it as a
71
+ // separate constant and append it below.
72
+ const NATIVE_SHADOW = Platform.select<ViewStyle>({
73
+ ios: {
74
+ shadowColor: '#0c0d10',
75
+ shadowOffset: { width: 0, height: -12 },
76
+ shadowOpacity: 0.16,
77
+ shadowRadius: 24,
78
+ },
79
+ android: {
80
+ elevation: 16,
81
+ },
82
+ default: {},
83
+ }) as ViewStyle
84
+
85
+ const WEB_SHADOW = IS_WEB
86
+ ? ({
87
+ boxShadow:
88
+ '0px -12px 24px 0px rgba(12, 13, 16, 0.12), 0px -16px 48px 0px rgba(12, 13, 16, 0.16)',
89
+ } as ViewStyle)
90
+ : null
91
+
92
+ // The runtime token a slot child must equal (by reference) to be treated as
93
+ // an IconButton. `IconButton` is exported wrapped in `React.memo`, so the
94
+ // element.type identity comparison works for both `<IconButton />` from the
95
+ // same module and any `React.memo`-wrapped re-export. The fallback check
96
+ // (`type.type === IconButton`) catches one extra layer of `forwardRef` /
97
+ // `memo` wrapping which can happen when consumers re-export the component.
98
+ function isIconButtonElement(element: React.ReactElement<any>): boolean {
99
+ const t: any = element.type
100
+ if (t === IconButton) return true
101
+ if (t && typeof t === 'object' && t.type === IconButton) return true
102
+ return false
103
+ }
104
+
33
105
  /**
34
- * ActionFooter component that provides a fixed footer container for action buttons.
35
- *
36
- * This component is designed to hold action items like IconButton and Button components
37
- * at the bottom of a screen. It includes a shadow for visual separation from content above.
38
- *
39
- * The `modes` prop is automatically passed to all slot children. If a child has its own
40
- * `modes` prop, it will be merged with the parent's modes (child modes take precedence).
41
- *
42
- * @component
43
- * @param {Object} props - Component props
44
- * @param {React.ReactNode} [props.children] - Action elements to display (e.g., IconButton, Button)
45
- * @param {Object} [props.modes={}] - Mode configuration for design tokens (automatically passed to children)
46
- * @param {Object} [props.style] - Optional style overrides
47
- * @param {string} [props.accessibilityLabel] - Accessibility label for the footer region
48
- *
106
+ * ActionFooter a sticky bottom container for primary screen actions.
107
+ *
108
+ * Layout contract:
109
+ * - The outer container stretches horizontally (`alignSelf: 'stretch'`) so
110
+ * it fills the parent regardless of whether the parent is a flex column,
111
+ * a ScrollView contentContainer, or a plain View.
112
+ * - The inner slot is a single row sized by its tallest child. It does NOT
113
+ * use `flex: 1` — that previously caused the row to collapse to zero on
114
+ * the first Yoga pass on native, taking the button labels with it.
115
+ * - `IconButton` children keep their intrinsic square size.
116
+ * - Every other child is auto-stretched with the Yoga-safe stretch style
117
+ * above so two `<Button>` siblings render at equal width on iOS, Android,
118
+ * and Web.
119
+ *
120
+ * The `modes` prop is automatically pushed down to every slot child via
121
+ * {@link cloneChildrenWithModes}; explicit child-level modes win over the
122
+ * parent's modes.
123
+ *
49
124
  * @example
50
125
  * ```tsx
51
- * // Basic usage - modes are automatically passed to all children.
52
- * // Non-IconButton children (e.g., Button) are auto-stretched to fill.
53
126
  * <ActionFooter modes={modes}>
54
127
  * <IconButton iconName="ic_split" />
55
- * <Button label="Request" />
56
- * <Button label="Pay" />
128
+ * <Button label="Request" modes={{ AppearanceBrand: 'Secondary' }} />
129
+ * <Button label="Pay" modes={{ AppearanceBrand: 'Primary' }} />
57
130
  * </ActionFooter>
58
131
  * ```
59
132
  */
@@ -61,79 +134,124 @@ function ActionFooter({
61
134
  children,
62
135
  modes = EMPTY_MODES,
63
136
  style,
64
- accessibilityLabel = undefined,
137
+ accessibilityLabel,
65
138
  }: ActionFooterProps) {
66
- // Resolve design tokens
67
- const backgroundColor = getVariableByName('actionFooter/background', modes) ?? '#ffffff'
68
- const gap = getVariableByName('actionFooter/gap', modes) ?? 8
69
- const paddingHorizontal = getVariableByName('actionFooter/padding/horizontal', modes) ?? 16
70
- const paddingTop = getVariableByName('actionFooter/padding/top', modes) ?? 10
71
- const paddingBottom = getVariableByName('actionFooter/padding/bottom', modes) ?? 41
72
-
73
- // Shadow styles - cross-platform
74
- const shadowStyle: ViewStyle = Platform.select({
75
- ios: {
76
- shadowColor: 'rgba(12, 13, 16, 1)',
77
- shadowOffset: { width: 0, height: -12 },
78
- shadowOpacity: 0.16,
79
- shadowRadius: 24,
80
- },
81
- android: {
82
- elevation: 16,
83
- },
84
- default: {
85
- // Web shadow using boxShadow (RNW supports this)
86
- },
87
- }) as ViewStyle
88
-
89
- const containerStyle: ViewStyle = {
90
- backgroundColor,
91
- paddingLeft: paddingHorizontal,
92
- paddingRight: paddingHorizontal,
93
- paddingTop,
94
- paddingBottom,
95
- ...shadowStyle,
96
- }
97
-
98
- // Slot container style for horizontal layout of action items
99
- const slotStyle: ViewStyle = {
100
- flexDirection: 'row',
101
- alignItems: 'flex-start',
102
- gap,
103
- flex: 1,
104
- }
105
-
106
- // Web-specific box-shadow
107
- const webShadow = Platform.OS === 'web'
108
- ? { boxShadow: '0px -12px 24px 0px rgba(12, 13, 16, 0.12), 0px -16px 48px 0px rgba(12, 13, 16, 0.16)' } as any
109
- : {}
110
-
111
- const flatChildren = flattenChildren(children)
112
- const processedChildren = cloneChildrenWithModes(flatChildren, modes)
113
-
114
- const enhancedChildren = (processedChildren as React.ReactNode[]).map((child, index) => {
115
- if (!React.isValidElement(child)) return child
116
- const element = child as React.ReactElement<any>
117
- const isIconButton = element.type === IconButton
118
- const stretchStyle = isIconButton ? undefined : { flex: 1 }
119
- return React.cloneElement(element, {
120
- key: element.key ?? index,
121
- style: [stretchStyle, element.props.style],
139
+ // -------------------------------------------------------------------------
140
+ // Keep the footer locked in place behind the software keyboard (Android).
141
+ // -------------------------------------------------------------------------
142
+ //
143
+ // The Android activity is configured with `windowSoftInputMode="adjustResize"`,
144
+ // which shrinks the app window by the keyboard height when the keyboard
145
+ // opens. A bottom-anchored footer therefore gets lifted UP by the keyboard
146
+ // height exactly the jump the design does not want.
147
+ //
148
+ // To counteract that, we translate the footer back DOWN by the same keyboard
149
+ // height so it visually stays exactly where it was (now sitting behind the
150
+ // keyboard). iOS does not resize the window for the keyboard, so the footer
151
+ // already stays put there; we only run this on Android to avoid pushing the
152
+ // footer off-screen on platforms that don't lift it in the first place.
153
+ const keyboardOffset = useRef(new Animated.Value(0)).current
154
+ useEffect(() => {
155
+ if (Platform.OS !== 'android') return undefined
156
+
157
+ const animateTo = (toValue: number, duration?: number) => {
158
+ Animated.timing(keyboardOffset, {
159
+ toValue,
160
+ // Match the OS keyboard animation so the resize and our counter-shift
161
+ // cancel out smoothly with no visible footer movement.
162
+ duration: typeof duration === 'number' && duration > 0 ? duration : 150,
163
+ useNativeDriver: true,
164
+ }).start()
165
+ }
166
+
167
+ const showSub = Keyboard.addListener('keyboardDidShow', (e: KeyboardEvent) => {
168
+ animateTo(e?.endCoordinates?.height ?? 0, e?.duration)
169
+ })
170
+ const hideSub = Keyboard.addListener('keyboardDidHide', (e: KeyboardEvent) => {
171
+ animateTo(0, e?.duration)
172
+ })
173
+
174
+ return () => {
175
+ showSub.remove()
176
+ hideSub.remove()
177
+ }
178
+ }, [keyboardOffset])
179
+
180
+ // All token reads collapsed into a single useMemo keyed on `modes`. With
181
+ // the shared `EMPTY_MODES` default this resolves once for the common path
182
+ // and never re-allocates the container/slot style objects between renders.
183
+ const { containerStyle, slotStyle } = useMemo(() => {
184
+ const backgroundColor =
185
+ (getVariableByName('actionFooter/background', modes) ?? '#ffffff') as string
186
+ const gap = (getVariableByName('actionFooter/gap', modes) ?? 8) as number
187
+ const paddingHorizontal =
188
+ (getVariableByName('actionFooter/padding/horizontal', modes) ?? 16) as number
189
+ const paddingTop = (getVariableByName('actionFooter/padding/top', modes) ?? 10) as number
190
+ const paddingBottom = (getVariableByName('actionFooter/padding/bottom', modes) ?? 41) as number
191
+
192
+ const container: ViewStyle = {
193
+ // `alignSelf: 'stretch'` is the cross-platform way to ask "fill the
194
+ // parent's cross axis" — in the common case (column parent) this gives
195
+ // us full-width without the caller needing to pass `width: '100%'`.
196
+ alignSelf: 'stretch',
197
+ backgroundColor,
198
+ paddingLeft: paddingHorizontal,
199
+ paddingRight: paddingHorizontal,
200
+ paddingTop,
201
+ paddingBottom,
202
+ ...NATIVE_SHADOW,
203
+ }
204
+
205
+ const slot: ViewStyle = {
206
+ flexDirection: 'row',
207
+ // Vertically center the IconButton against the slightly taller Buttons
208
+ // so the row reads as a single optical baseline.
209
+ alignItems: 'center',
210
+ gap,
211
+ }
212
+
213
+ return { containerStyle: container, slotStyle: slot }
214
+ }, [modes])
215
+
216
+ // Process children once per (children, modes) tuple:
217
+ // 1. Flatten Fragments so each action is its own keyed sibling.
218
+ // 2. Push `modes` down so callers don't have to thread it manually.
219
+ // 3. Auto-stretch every non-IconButton with the Yoga-safe stretch style.
220
+ //
221
+ // The result identity is stable across re-renders when the inputs don't
222
+ // change, which keeps the `React.memo`-wrapped Button/IconButton children
223
+ // from re-rendering for no reason.
224
+ const enhancedChildren = useMemo(() => {
225
+ const flat = flattenChildren(children)
226
+ const withModes = cloneChildrenWithModes(flat, modes) as React.ReactNode[]
227
+ return withModes.map((child, index) => {
228
+ if (!React.isValidElement(child)) return child
229
+ const element = child as React.ReactElement<any>
230
+ if (isIconButtonElement(element)) return element
231
+ return React.cloneElement(element, {
232
+ key: element.key ?? `action-footer-item-${index}`,
233
+ style: [STRETCH_STYLE, element.props.style],
234
+ })
122
235
  })
123
- })
236
+ }, [children, modes])
124
237
 
125
238
  return (
126
- <View
127
- style={[containerStyle, webShadow, style]}
239
+ <Animated.View
240
+ style={[
241
+ containerStyle,
242
+ WEB_SHADOW,
243
+ style,
244
+ // Counter-translate by the keyboard height on Android so `adjustResize`
245
+ // can't lift the footer above the keyboard (no-op on iOS/web where the
246
+ // value stays at 0).
247
+ { transform: [{ translateY: keyboardOffset }] },
248
+ ]}
128
249
  accessibilityRole="toolbar"
129
- accessibilityLabel={undefined}
250
+ accessibilityLabel={accessibilityLabel}
130
251
  >
131
- <View style={slotStyle}>
132
- {enhancedChildren}
133
- </View>
134
- </View>
252
+ <View style={slotStyle}>{enhancedChildren}</View>
253
+ </Animated.View>
135
254
  )
136
255
  }
137
256
 
138
- export default ActionFooter
139
-
257
+ export default React.memo(ActionFooter)