jfs-components 0.0.62 → 0.0.64

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 (255) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/lib/commonjs/components/Accordion/Accordion.js +1 -1
  3. package/lib/commonjs/components/ActionFooter/ActionFooter.js +1 -1
  4. package/lib/commonjs/components/ActionTile/ActionTile.js +2 -1
  5. package/lib/commonjs/components/AmountInput/AmountInput.js +2 -1
  6. package/lib/commonjs/components/AppBar/AppBar.js +1 -1
  7. package/lib/commonjs/components/Avatar/Avatar.js +184 -162
  8. package/lib/commonjs/components/AvatarGroup/AvatarGroup.js +1 -1
  9. package/lib/commonjs/components/Badge/Badge.js +2 -1
  10. package/lib/commonjs/components/Balance/Balance.js +2 -1
  11. package/lib/commonjs/components/BottomNav/BottomNav.js +2 -1
  12. package/lib/commonjs/components/BottomNavItem/BottomNavItem.js +106 -86
  13. package/lib/commonjs/components/Button/Button.js +190 -93
  14. package/lib/commonjs/components/ButtonGroup/ButtonGroup.js +1 -1
  15. package/lib/commonjs/components/Card/Card.js +2 -1
  16. package/lib/commonjs/components/CardCTA/CardCTA.js +1 -1
  17. package/lib/commonjs/components/CardProviderInfo/CardProviderInfo.js +1 -1
  18. package/lib/commonjs/components/Carousel/Carousel.js +3 -2
  19. package/lib/commonjs/components/Checkbox/Checkbox.js +2 -1
  20. package/lib/commonjs/components/ChipGroup/ChipGroup.js +1 -1
  21. package/lib/commonjs/components/ChipSelect/ChipSelect.js +2 -1
  22. package/lib/commonjs/components/DebitCard/DebitCard.js +1 -1
  23. package/lib/commonjs/components/Disclaimer/Disclaimer.js +2 -1
  24. package/lib/commonjs/components/Divider/Divider.js +2 -1
  25. package/lib/commonjs/components/Drawer/Drawer.js +109 -48
  26. package/lib/commonjs/components/EmptyState/EmptyState.js +2 -1
  27. package/lib/commonjs/components/FilterBar/FilterBar.js +1 -1
  28. package/lib/commonjs/components/Form/Form.js +2 -1
  29. package/lib/commonjs/components/FormField/FormField.js +3 -2
  30. package/lib/commonjs/components/HStack/HStack.js +1 -1
  31. package/lib/commonjs/components/HoldingsCard/HoldingsCard.js +2 -1
  32. package/lib/commonjs/components/IconButton/IconButton.js +118 -128
  33. package/lib/commonjs/components/IconCapsule/IconCapsule.js +61 -57
  34. package/lib/commonjs/components/InputSearch/InputSearch.js +7 -3
  35. package/lib/commonjs/components/LazyList/LazyList.js +1 -1
  36. package/lib/commonjs/components/LinearMeter/LinearMeter.js +3 -2
  37. package/lib/commonjs/components/ListGroup/ListGroup.js +1 -1
  38. package/lib/commonjs/components/ListItem/ListItem.js +190 -142
  39. package/lib/commonjs/components/MediaCard/MediaCard.js +3 -3
  40. package/lib/commonjs/components/MerchantProfile/MerchantProfile.js +2 -1
  41. package/lib/commonjs/components/MoneyValue/MoneyValue.js +2 -1
  42. package/lib/commonjs/components/NavArrow/NavArrow.js +82 -59
  43. package/lib/commonjs/components/NoteInput/NoteInput.js +2 -1
  44. package/lib/commonjs/components/Nudge/Nudge.js +1 -1
  45. package/lib/commonjs/components/Numpad/Numpad.js +2 -1
  46. package/lib/commonjs/components/OTP/OTP.js +1 -1
  47. package/lib/commonjs/components/PaymentFeedback/PaymentFeedback.js +2 -1
  48. package/lib/commonjs/components/Popup/Popup.js +2 -1
  49. package/lib/commonjs/components/ProductLabel/ProductLabel.js +2 -1
  50. package/lib/commonjs/components/ProgressBadge/ProgressBadge.js +2 -1
  51. package/lib/commonjs/components/RadioButton/RadioButton.js +2 -1
  52. package/lib/commonjs/components/RechargeCard/RechargeCard.js +2 -1
  53. package/lib/commonjs/components/Screen/Screen.js +1 -1
  54. package/lib/commonjs/components/Section/Section.js +500 -166
  55. package/lib/commonjs/components/SegmentedControl/SegmentedControl.js +3 -2
  56. package/lib/commonjs/components/StatItem/StatItem.js +2 -1
  57. package/lib/commonjs/components/StatusHero/StatusHero.js +2 -1
  58. package/lib/commonjs/components/Stepper/Step.js +2 -1
  59. package/lib/commonjs/components/Stepper/StepLabel.js +2 -1
  60. package/lib/commonjs/components/Stepper/Stepper.js +2 -1
  61. package/lib/commonjs/components/SupportText/SupportText.js +2 -1
  62. package/lib/commonjs/components/SupportText/SupportTextIcon.js +2 -1
  63. package/lib/commonjs/components/SwappableAmount/SwappableAmount.js +2 -1
  64. package/lib/commonjs/components/Tabs/TabItem.js +2 -1
  65. package/lib/commonjs/components/Tabs/Tabs.js +2 -1
  66. package/lib/commonjs/components/Text/Text.js +2 -1
  67. package/lib/commonjs/components/TextInput/TextInput.js +2 -2
  68. package/lib/commonjs/components/ThreadHero/ThreadHero.js +2 -1
  69. package/lib/commonjs/components/Title/Title.js +2 -1
  70. package/lib/commonjs/components/Toast/Toast.js +2 -1
  71. package/lib/commonjs/components/Toggle/Toggle.js +2 -1
  72. package/lib/commonjs/components/Tooltip/Tooltip.js +2 -1
  73. package/lib/commonjs/components/TransactionBubble/TransactionBubble.js +1 -1
  74. package/lib/commonjs/components/TransactionDetails/TransactionDetails.js +2 -2
  75. package/lib/commonjs/components/TransactionStatus/TransactionStatus.js +3 -2
  76. package/lib/commonjs/components/UpiHandle/UpiHandle.js +144 -110
  77. package/lib/commonjs/components/VStack/VStack.js +1 -1
  78. package/lib/commonjs/design-tokens/figma-variables-resolver.js +21 -3
  79. package/lib/commonjs/icons/registry.js +1 -1
  80. package/lib/commonjs/utils/react-utils.js +17 -0
  81. package/lib/module/components/Accordion/Accordion.js +2 -2
  82. package/lib/module/components/ActionFooter/ActionFooter.js +2 -2
  83. package/lib/module/components/ActionTile/ActionTile.js +2 -1
  84. package/lib/module/components/AmountInput/AmountInput.js +2 -1
  85. package/lib/module/components/AppBar/AppBar.js +2 -2
  86. package/lib/module/components/Avatar/Avatar.js +184 -162
  87. package/lib/module/components/AvatarGroup/AvatarGroup.js +2 -2
  88. package/lib/module/components/Badge/Badge.js +2 -1
  89. package/lib/module/components/Balance/Balance.js +2 -1
  90. package/lib/module/components/BottomNav/BottomNav.js +2 -1
  91. package/lib/module/components/BottomNavItem/BottomNavItem.js +108 -88
  92. package/lib/module/components/Button/Button.js +192 -95
  93. package/lib/module/components/ButtonGroup/ButtonGroup.js +2 -2
  94. package/lib/module/components/Card/Card.js +2 -1
  95. package/lib/module/components/CardCTA/CardCTA.js +2 -2
  96. package/lib/module/components/CardProviderInfo/CardProviderInfo.js +2 -2
  97. package/lib/module/components/Carousel/Carousel.js +3 -2
  98. package/lib/module/components/Checkbox/Checkbox.js +2 -1
  99. package/lib/module/components/ChipGroup/ChipGroup.js +2 -2
  100. package/lib/module/components/ChipSelect/ChipSelect.js +2 -1
  101. package/lib/module/components/DebitCard/DebitCard.js +2 -2
  102. package/lib/module/components/Disclaimer/Disclaimer.js +2 -1
  103. package/lib/module/components/Divider/Divider.js +2 -1
  104. package/lib/module/components/Drawer/Drawer.js +109 -48
  105. package/lib/module/components/EmptyState/EmptyState.js +2 -1
  106. package/lib/module/components/FilterBar/FilterBar.js +2 -2
  107. package/lib/module/components/Form/Form.js +2 -1
  108. package/lib/module/components/FormField/FormField.js +3 -2
  109. package/lib/module/components/HStack/HStack.js +2 -2
  110. package/lib/module/components/HoldingsCard/HoldingsCard.js +2 -1
  111. package/lib/module/components/IconButton/IconButton.js +120 -130
  112. package/lib/module/components/IconCapsule/IconCapsule.js +60 -57
  113. package/lib/module/components/InputSearch/InputSearch.js +7 -3
  114. package/lib/module/components/LazyList/LazyList.js +2 -2
  115. package/lib/module/components/LinearMeter/LinearMeter.js +3 -2
  116. package/lib/module/components/ListGroup/ListGroup.js +2 -2
  117. package/lib/module/components/ListItem/ListItem.js +194 -146
  118. package/lib/module/components/MediaCard/MediaCard.js +4 -2
  119. package/lib/module/components/MerchantProfile/MerchantProfile.js +2 -1
  120. package/lib/module/components/MoneyValue/MoneyValue.js +2 -1
  121. package/lib/module/components/NavArrow/NavArrow.js +82 -58
  122. package/lib/module/components/NoteInput/NoteInput.js +2 -1
  123. package/lib/module/components/Nudge/Nudge.js +2 -2
  124. package/lib/module/components/Numpad/Numpad.js +2 -1
  125. package/lib/module/components/OTP/OTP.js +2 -2
  126. package/lib/module/components/PaymentFeedback/PaymentFeedback.js +2 -1
  127. package/lib/module/components/Popup/Popup.js +2 -1
  128. package/lib/module/components/ProductLabel/ProductLabel.js +2 -1
  129. package/lib/module/components/ProgressBadge/ProgressBadge.js +2 -1
  130. package/lib/module/components/RadioButton/RadioButton.js +2 -1
  131. package/lib/module/components/RechargeCard/RechargeCard.js +2 -1
  132. package/lib/module/components/Screen/Screen.js +2 -2
  133. package/lib/module/components/Section/Section.js +503 -169
  134. package/lib/module/components/SegmentedControl/SegmentedControl.js +3 -2
  135. package/lib/module/components/StatItem/StatItem.js +2 -1
  136. package/lib/module/components/StatusHero/StatusHero.js +2 -1
  137. package/lib/module/components/Stepper/Step.js +2 -1
  138. package/lib/module/components/Stepper/StepLabel.js +2 -1
  139. package/lib/module/components/Stepper/Stepper.js +2 -1
  140. package/lib/module/components/SupportText/SupportText.js +2 -1
  141. package/lib/module/components/SupportText/SupportTextIcon.js +2 -1
  142. package/lib/module/components/SwappableAmount/SwappableAmount.js +2 -1
  143. package/lib/module/components/Tabs/TabItem.js +2 -1
  144. package/lib/module/components/Tabs/Tabs.js +2 -1
  145. package/lib/module/components/Text/Text.js +2 -1
  146. package/lib/module/components/TextInput/TextInput.js +3 -3
  147. package/lib/module/components/ThreadHero/ThreadHero.js +2 -1
  148. package/lib/module/components/Title/Title.js +2 -1
  149. package/lib/module/components/Toast/Toast.js +2 -1
  150. package/lib/module/components/Toggle/Toggle.js +2 -1
  151. package/lib/module/components/Tooltip/Tooltip.js +2 -1
  152. package/lib/module/components/TransactionBubble/TransactionBubble.js +2 -2
  153. package/lib/module/components/TransactionDetails/TransactionDetails.js +3 -3
  154. package/lib/module/components/TransactionStatus/TransactionStatus.js +3 -2
  155. package/lib/module/components/UpiHandle/UpiHandle.js +147 -113
  156. package/lib/module/components/VStack/VStack.js +2 -2
  157. package/lib/module/design-tokens/figma-variables-resolver.js +21 -3
  158. package/lib/module/icons/registry.js +1 -1
  159. package/lib/module/utils/react-utils.js +16 -0
  160. package/lib/typescript/src/components/Avatar/Avatar.d.ts +11 -17
  161. package/lib/typescript/src/components/BottomNavItem/BottomNavItem.d.ts +12 -8
  162. package/lib/typescript/src/components/Button/Button.d.ts +18 -1
  163. package/lib/typescript/src/components/IconButton/IconButton.d.ts +12 -29
  164. package/lib/typescript/src/components/IconCapsule/IconCapsule.d.ts +10 -18
  165. package/lib/typescript/src/components/InputSearch/InputSearch.d.ts +8 -3
  166. package/lib/typescript/src/components/ListItem/ListItem.d.ts +14 -1
  167. package/lib/typescript/src/components/NavArrow/NavArrow.d.ts +12 -11
  168. package/lib/typescript/src/components/Section/Section.d.ts +43 -48
  169. package/lib/typescript/src/components/UpiHandle/UpiHandle.d.ts +13 -12
  170. package/lib/typescript/src/icons/registry.d.ts +1 -1
  171. package/lib/typescript/src/utils/react-utils.d.ts +15 -0
  172. package/package.json +4 -6
  173. package/src/components/Accordion/Accordion.tsx +2 -2
  174. package/src/components/ActionFooter/ActionFooter.tsx +2 -2
  175. package/src/components/ActionTile/ActionTile.tsx +2 -1
  176. package/src/components/AmountInput/AmountInput.tsx +2 -1
  177. package/src/components/AppBar/AppBar.tsx +2 -2
  178. package/src/components/Avatar/Avatar.tsx +229 -158
  179. package/src/components/AvatarGroup/AvatarGroup.tsx +2 -2
  180. package/src/components/Badge/Badge.tsx +2 -1
  181. package/src/components/Balance/Balance.tsx +2 -1
  182. package/src/components/BottomNav/BottomNav.tsx +2 -1
  183. package/src/components/BottomNavItem/BottomNavItem.tsx +159 -88
  184. package/src/components/Button/Button.tsx +228 -101
  185. package/src/components/ButtonGroup/ButtonGroup.tsx +2 -2
  186. package/src/components/Card/Card.tsx +2 -1
  187. package/src/components/CardCTA/CardCTA.tsx +2 -2
  188. package/src/components/CardProviderInfo/CardProviderInfo.tsx +2 -2
  189. package/src/components/Carousel/Carousel.tsx +3 -2
  190. package/src/components/Checkbox/Checkbox.tsx +2 -1
  191. package/src/components/ChipGroup/ChipGroup.tsx +2 -2
  192. package/src/components/ChipSelect/ChipSelect.tsx +2 -1
  193. package/src/components/DebitCard/DebitCard.tsx +2 -2
  194. package/src/components/Disclaimer/Disclaimer.tsx +2 -1
  195. package/src/components/Divider/Divider.tsx +2 -1
  196. package/src/components/Drawer/Drawer.tsx +124 -58
  197. package/src/components/EmptyState/EmptyState.tsx +2 -1
  198. package/src/components/FilterBar/FilterBar.tsx +2 -2
  199. package/src/components/Form/Form.tsx +2 -1
  200. package/src/components/FormField/FormField.tsx +3 -2
  201. package/src/components/HStack/HStack.tsx +2 -2
  202. package/src/components/HoldingsCard/HoldingsCard.tsx +2 -1
  203. package/src/components/IconButton/IconButton.tsx +154 -126
  204. package/src/components/IconCapsule/IconCapsule.tsx +73 -54
  205. package/src/components/InputSearch/InputSearch.tsx +19 -5
  206. package/src/components/LazyList/LazyList.tsx +2 -2
  207. package/src/components/LinearMeter/LinearMeter.tsx +3 -2
  208. package/src/components/ListGroup/ListGroup.tsx +2 -2
  209. package/src/components/ListItem/ListItem.tsx +257 -187
  210. package/src/components/MediaCard/MediaCard.tsx +2 -1
  211. package/src/components/MerchantProfile/MerchantProfile.tsx +2 -1
  212. package/src/components/MoneyValue/MoneyValue.tsx +2 -1
  213. package/src/components/NavArrow/NavArrow.tsx +91 -58
  214. package/src/components/NoteInput/NoteInput.tsx +2 -1
  215. package/src/components/Nudge/Nudge.tsx +2 -2
  216. package/src/components/Numpad/Numpad.tsx +2 -1
  217. package/src/components/OTP/OTP.tsx +2 -2
  218. package/src/components/PaymentFeedback/PaymentFeedback.tsx +2 -1
  219. package/src/components/Popup/Popup.tsx +2 -1
  220. package/src/components/ProductLabel/ProductLabel.tsx +2 -1
  221. package/src/components/ProgressBadge/ProgressBadge.tsx +2 -2
  222. package/src/components/RadioButton/RadioButton.tsx +2 -1
  223. package/src/components/RechargeCard/RechargeCard.tsx +2 -1
  224. package/src/components/Screen/Screen.tsx +2 -2
  225. package/src/components/Section/Section.tsx +672 -176
  226. package/src/components/SegmentedControl/SegmentedControl.tsx +3 -2
  227. package/src/components/StatItem/StatItem.tsx +2 -1
  228. package/src/components/StatusHero/StatusHero.tsx +2 -1
  229. package/src/components/Stepper/Step.tsx +2 -1
  230. package/src/components/Stepper/StepLabel.tsx +2 -1
  231. package/src/components/Stepper/Stepper.tsx +2 -1
  232. package/src/components/SupportText/SupportText.tsx +2 -1
  233. package/src/components/SupportText/SupportTextIcon.tsx +2 -1
  234. package/src/components/SwappableAmount/SwappableAmount.tsx +2 -1
  235. package/src/components/Tabs/TabItem.tsx +2 -1
  236. package/src/components/Tabs/Tabs.tsx +2 -1
  237. package/src/components/Text/Text.tsx +2 -1
  238. package/src/components/TextInput/TextInput.tsx +3 -3
  239. package/src/components/ThreadHero/ThreadHero.tsx +2 -1
  240. package/src/components/Title/Title.tsx +2 -1
  241. package/src/components/Toast/Toast.tsx +2 -1
  242. package/src/components/Toggle/Toggle.tsx +2 -1
  243. package/src/components/Tooltip/Tooltip.tsx +2 -1
  244. package/src/components/TransactionBubble/TransactionBubble.tsx +2 -2
  245. package/src/components/TransactionDetails/TransactionDetails.tsx +3 -3
  246. package/src/components/TransactionStatus/TransactionStatus.tsx +3 -2
  247. package/src/components/UpiHandle/UpiHandle.tsx +193 -125
  248. package/src/components/VStack/VStack.tsx +2 -2
  249. package/src/design-tokens/figma-variables-resolver.ts +21 -3
  250. package/src/icons/registry.ts +1 -1
  251. package/src/utils/react-utils.ts +16 -0
  252. package/lib/typescript/App.d.ts +0 -2
  253. package/lib/typescript/index.d.ts +0 -2
  254. package/lib/typescript/metro.config.d.ts +0 -78
  255. package/lib/typescript/react-native.config.d.ts +0 -4
@@ -1,93 +1,335 @@
1
- import React, { useState, useRef, useCallback, useEffect } from 'react'
1
+ import React, { useState, useMemo, useRef, useCallback } from 'react'
2
2
  import { View, Text, Pressable, Platform, type StyleProp, type ViewStyle, type PressableStateCallbackType } from 'react-native'
3
+ import Animated, {
4
+ FadeInUp,
5
+ FadeOutUp,
6
+ ReduceMotion,
7
+ useAnimatedStyle,
8
+ useSharedValue,
9
+ withSpring,
10
+ } from 'react-native-reanimated'
3
11
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
4
12
  import NavArrow from '../NavArrow/NavArrow'
13
+ import IconCapsule from '../IconCapsule/IconCapsule'
14
+ import ListItem from '../ListItem/ListItem'
5
15
  import { usePressableWebSupport, type WebAccessibilityProps } from '../../utils/web-platform-utils'
6
- import { cloneChildrenWithModes, flattenChildren } from '../../utils/react-utils'
16
+ import { EMPTY_MODES, cloneChildrenWithModes, flattenChildren } from '../../utils/react-utils'
17
+
18
+ // Match Button: delay the press visual on iOS so a scroll-cancelled touch
19
+ // never applies the "pressed" style. See Button.tsx for the rationale.
20
+ const IS_WEB = Platform.OS === 'web'
21
+ const IS_IOS = Platform.OS === 'ios'
22
+ const HEADER_PRESS_DELAY = IS_IOS ? 130 : 0
23
+
24
+ // Module-scope style constants — never re-allocated per render.
25
+ const headerWrapStyle: ViewStyle = {
26
+ flexDirection: 'row',
27
+ alignItems: 'center',
28
+ justifyContent: 'space-between',
29
+ }
30
+ const headerHoverStyle: ViewStyle = { opacity: 0.95 }
31
+ const headerPressedStyle: ViewStyle = { opacity: 0.85 }
32
+ const headerFocusStyle: ViewStyle = { borderColor: '#222', borderWidth: 1 }
7
33
 
8
34
  // ---------------------------------------------------------------------------
9
- // Shared grid layout: measures widest child, enforces uniform width,
10
- // chunks into fixed rows of up to maxColumns, space-between per row.
35
+ // Shared grid layout first-row-anchored sizing.
36
+ //
37
+ // We measure each cell of the *first row* via `onLayout`, take their max as
38
+ // the canonical cellWidth, then apply that explicit width to every cell in
39
+ // every row. Combined with `justify-content: space-between`, this preserves
40
+ // three visual invariants regardless of viewport width:
41
+ // 1. The first cell hugs the container's left edge.
42
+ // 2. The last cell hugs the container's right edge.
43
+ // 3. Cells in row N column K align with cells in row N+1 column K
44
+ // (uniform cell width across the whole grid).
45
+ //
46
+ // Why first-row-anchored?
47
+ //
48
+ // The first row is *always present* (collapsed and expanded both render it),
49
+ // so the measurement happens exactly once on first mount and stays valid for
50
+ // the lifetime of the SlotGrid — the cellWidth is *stable across toggles*.
51
+ // New cells in row 2+ mount when the user expands, and by that time
52
+ // `cellWidth` is already cached, so their `Animated.View` `entering` cascade
53
+ // is never interrupted by a re-measurement-driven re-render.
54
+ //
55
+ // Why not container-width math (e.g. `(containerWidth - gaps) / columns`)?
56
+ // That makes every cell wide enough to fill its share of the row. On wide
57
+ // viewports each cell becomes much wider than its content (icon + label),
58
+ // the content centers inside its oversized cell, and the visible result is
59
+ // a "dead margin" of empty space on the left and right of the grid. Sizing
60
+ // cells to natural content + `space-between` instead pushes the first cell
61
+ // flush left and the last cell flush right, distributing leftover space as
62
+ // the inter-cell gap.
63
+ //
64
+ // Why not measure every cell?
65
+ // The original implementation did, and `max()` could change when the item
66
+ // count changed (collapsed picks one max, expanded another), producing a
67
+ // visible width jump on toggle. The per-cell remeasurement also forced a
68
+ // re-render in the same React batch as the `entering` animation mounting,
69
+ // which Reanimated treats as a cancellation signal — cells visibly didn't
70
+ // animate. Anchoring to the first row eliminates both costs.
71
+ //
72
+ // First-frame behavior is preserved (no blank-flash): until the first-row
73
+ // `onLayout` fires, cells render at their natural width with `space-between`
74
+ // already laying them out correctly; the only thing that changes after
75
+ // measurement is that subsequent rows snap to the same cellWidth so columns
76
+ // align.
11
77
  // ---------------------------------------------------------------------------
12
78
  const SLOT_GRID_MAX_COLUMNS = 4
13
79
 
80
+ // Beyond this many "extra" cells, additional cells reuse the cap delay so very
81
+ // large grids (16, 32, …) still feel snappy instead of cascading for >1s.
82
+ const SLOT_GRID_STAGGER_CAP = 8
83
+ const SLOT_GRID_ENTER_STAGGER_MS = 35
84
+ const SLOT_GRID_EXIT_STAGGER_MS = 20
85
+ const SLOT_GRID_EXIT_DURATION_MS = 160
86
+
14
87
  type SlotGridProps = {
15
88
  items: React.ReactNode[];
16
89
  gap: number;
17
90
  maxColumns?: number;
91
+ /**
92
+ * If set, cells whose index is `>= animateExtrasFromIndex` are wrapped in
93
+ * `Animated.View` with staggered FadeInUp/FadeOutUp builders so that they
94
+ * fade in (and reverse-fade out) when they mount or unmount. Cells below
95
+ * this threshold render as plain `<View>` and are unaffected.
96
+ */
97
+ animateExtrasFromIndex?: number;
98
+ /**
99
+ * If true, the rows container animates its height via an explicit
100
+ * `useSharedValue` + `withSpring` driven by `onLayout` measurements of the
101
+ * inner content (with `overflow: 'hidden'` to clip mid-animation). Cells
102
+ * inside always render at their natural size — they are *never* resized
103
+ * during the transition. Default false.
104
+ */
105
+ animateContainerLayout?: boolean;
18
106
  }
19
107
 
20
- function SlotGrid({ items, gap, maxColumns = SLOT_GRID_MAX_COLUMNS }: SlotGridProps) {
21
- const [maxItemWidth, setMaxItemWidth] = useState<number | null>(null)
22
- const [measureTimedOut, setMeasureTimedOut] = useState(false)
23
- const itemWidthsRef = useRef<Map<number, number>>(new Map())
24
- const totalItems = items.length
25
-
26
- useEffect(() => {
27
- itemWidthsRef.current.clear()
28
- setMaxItemWidth(null)
29
- setMeasureTimedOut(false)
30
- }, [totalItems])
31
-
32
- useEffect(() => {
33
- if (maxItemWidth !== null) return
34
- const timer = setTimeout(() => setMeasureTimedOut(true), 500)
35
- return () => clearTimeout(timer)
36
- }, [maxItemWidth, totalItems])
37
-
38
- const handleItemLayout = useCallback((index: number, width: number) => {
39
- itemWidthsRef.current.set(index, width)
40
- if (itemWidthsRef.current.size >= totalItems && totalItems > 0) {
41
- setMaxItemWidth(Math.max(...itemWidthsRef.current.values()))
42
- }
43
- }, [totalItems])
108
+ const slotGridRowFlowStyle: ViewStyle = {
109
+ flexDirection: 'row',
110
+ justifyContent: 'space-between',
111
+ }
44
112
 
45
- const isMeasured = maxItemWidth !== null
113
+ const SlotGrid = React.memo(function SlotGrid({
114
+ items,
115
+ gap,
116
+ maxColumns = SLOT_GRID_MAX_COLUMNS,
117
+ animateExtrasFromIndex,
118
+ animateContainerLayout,
119
+ }: SlotGridProps) {
120
+ const totalItems = items.length
46
121
  const columns = Math.min(maxColumns, totalItems || 1)
122
+ // Number of cells in the first row. Capped by `columns` (a fully-filled row)
123
+ // and by `totalItems` (e.g. a 1-item grid has a 1-cell first row).
124
+ const firstRowSize = Math.min(columns, totalItems)
125
+
126
+ // First-row width measurement. Only cells whose `itemIndex < firstRowSize`
127
+ // get an `onLayout` callback. Once we have a width sample for each first-row
128
+ // cell, we publish the max and the callbacks become inert — no further
129
+ // measurement happens for the rest of the SlotGrid's lifetime, so toggles
130
+ // never trigger a re-measurement-driven re-render.
131
+ const [firstRowMaxWidth, setFirstRowMaxWidth] = useState<number | null>(null)
132
+ const firstRowWidthsRef = useRef<Map<number, number>>(new Map())
133
+ const handleFirstRowItemLayout = useCallback(
134
+ (index: number, width: number) => {
135
+ const widths = firstRowWidthsRef.current
136
+ const previous = widths.get(index)
137
+ if (previous !== undefined && Math.abs(previous - width) < 0.5) return
138
+ widths.set(index, width)
139
+ if (widths.size >= firstRowSize && firstRowSize > 0) {
140
+ let newMax = 0
141
+ for (const w of widths.values()) {
142
+ if (w > newMax) newMax = w
143
+ }
144
+ setFirstRowMaxWidth((prev) =>
145
+ prev !== null && Math.abs(prev - newMax) < 0.5 ? prev : newMax
146
+ )
147
+ }
148
+ },
149
+ [firstRowSize]
150
+ )
151
+
152
+ const cellWidth = firstRowMaxWidth
47
153
 
48
154
  const rows: React.ReactNode[][] = []
49
155
  for (let i = 0; i < items.length; i += columns) {
50
156
  rows.push(items.slice(i, i + columns))
51
157
  }
52
158
 
53
- return (
54
- <View style={{ gap, ...(isMeasured || measureTimedOut ? {} : { opacity: 0 }) }}>
159
+ const containerStyle = useMemo<ViewStyle>(() => ({ gap }), [gap])
160
+ const cellStyle = useMemo<ViewStyle | undefined>(
161
+ () => (cellWidth !== null ? { width: cellWidth } : undefined),
162
+ [cellWidth]
163
+ )
164
+
165
+ // `space-between` distributes any leftover row space as inter-cell gap, so
166
+ // the first cell is flush-left and the last cell is flush-right regardless
167
+ // of whether cellWidth has been measured yet. Identical layout strategy
168
+ // before and after measurement — no first-frame layout shift.
169
+ const rowStyle = slotGridRowFlowStyle
170
+
171
+ const animationsEnabled = animateExtrasFromIndex !== undefined
172
+ // Resolve the threshold once. When undefined we treat it as
173
+ // Number.POSITIVE_INFINITY so the per-cell branch always picks the plain
174
+ // `<View>` path.
175
+ const extrasThreshold = animationsEnabled
176
+ ? (animateExtrasFromIndex as number)
177
+ : Number.POSITIVE_INFINITY
178
+ // Total count of "extra" cells currently rendered. Used to compute the
179
+ // reverse-stagger delay for the exiting animation so that the last cell
180
+ // leaves first.
181
+ const extrasCount = animationsEnabled
182
+ ? Math.max(0, totalItems - extrasThreshold)
183
+ : 0
184
+
185
+ const useAnimatedContainer = animateContainerLayout === true
186
+
187
+ // Explicit-height clip animation:
188
+ //
189
+ // Reanimated's `LinearTransition` interpolates the container's bounds, and
190
+ // in practice that interpolation drags on the cells inside (they momentarily
191
+ // appear squashed because the parent is shorter than its natural content
192
+ // for the duration of the animation). To keep cells at their *natural size
193
+ // throughout*, we instead:
194
+ // 1. Render the rows inside an inner `<View>` that sizes to its content
195
+ // naturally — cells are never squeezed, never resized.
196
+ // 2. Wrap that inner view in an `Animated.View` with `overflow: 'hidden'`
197
+ // and an explicit `height` driven by a shared value.
198
+ // 3. The inner view reports its natural height via `onLayout`. The first
199
+ // measurement snaps the shared value (no first-mount animation). Every
200
+ // subsequent change (e.g. expand/collapse adds or removes rows) springs
201
+ // the shared value to the new natural height.
202
+ //
203
+ // Visually: the container reveals/conceals content like a curtain, and the
204
+ // cells never deform.
205
+ const animatedHeight = useSharedValue<number>(-1)
206
+ const isFirstHeightLayoutRef = useRef(true)
207
+ const handleContentLayout = useCallback(
208
+ (e: { nativeEvent: { layout: { height: number } } }) => {
209
+ const h = e.nativeEvent.layout.height
210
+ if (h <= 0) return
211
+ if (isFirstHeightLayoutRef.current) {
212
+ isFirstHeightLayoutRef.current = false
213
+ animatedHeight.value = h
214
+ return
215
+ }
216
+ animatedHeight.value = withSpring(h, {
217
+ damping: 22,
218
+ stiffness: 180,
219
+ reduceMotion: ReduceMotion.System,
220
+ })
221
+ },
222
+ [animatedHeight]
223
+ )
224
+ const animatedHeightStyle = useAnimatedStyle(() =>
225
+ animatedHeight.value < 0
226
+ ? {}
227
+ : { height: animatedHeight.value, overflow: 'hidden' as const }
228
+ )
229
+
230
+ const rowsChildren = (
231
+ <>
55
232
  {rows.map((row, rowIndex) => {
56
233
  const spacersNeeded = row.length < columns ? columns - row.length : 0
57
234
  return (
58
- <View
59
- key={rowIndex}
60
- style={{
61
- flexDirection: 'row' as const,
62
- justifyContent: 'space-between' as const,
63
- }}
64
- >
235
+ <View key={rowIndex} style={rowStyle}>
65
236
  {row.map((child, colIndex) => {
66
237
  const itemIndex = rowIndex * columns + colIndex
238
+ // Only first-row cells participate in measurement, and only
239
+ // until firstRowMaxWidth has been published. After that the
240
+ // onLayout becomes a no-op (we elide it).
241
+ const onLayoutHandler =
242
+ firstRowMaxWidth === null && itemIndex < firstRowSize
243
+ ? (e: { nativeEvent: { layout: { width: number } } }) =>
244
+ handleFirstRowItemLayout(
245
+ itemIndex,
246
+ e.nativeEvent.layout.width
247
+ )
248
+ : undefined
249
+
250
+ if (itemIndex >= extrasThreshold) {
251
+ const extraOrdinal = itemIndex - extrasThreshold
252
+ const enterStaggerSteps = Math.min(
253
+ extraOrdinal,
254
+ SLOT_GRID_STAGGER_CAP
255
+ )
256
+ const reverseOrdinal = Math.max(
257
+ 0,
258
+ extrasCount - 1 - extraOrdinal
259
+ )
260
+ const exitStaggerSteps = Math.min(
261
+ reverseOrdinal,
262
+ SLOT_GRID_STAGGER_CAP
263
+ )
264
+ const entering = FadeInUp.springify()
265
+ .damping(18)
266
+ .delay(enterStaggerSteps * SLOT_GRID_ENTER_STAGGER_MS)
267
+ .reduceMotion(ReduceMotion.System)
268
+ const exiting = FadeOutUp.duration(SLOT_GRID_EXIT_DURATION_MS)
269
+ .delay(exitStaggerSteps * SLOT_GRID_EXIT_STAGGER_MS)
270
+ .reduceMotion(ReduceMotion.System)
271
+ return (
272
+ <Animated.View
273
+ key={itemIndex}
274
+ entering={entering}
275
+ exiting={exiting}
276
+ {...(onLayoutHandler ? { onLayout: onLayoutHandler } : null)}
277
+ style={cellStyle}
278
+ >
279
+ {child}
280
+ </Animated.View>
281
+ )
282
+ }
283
+
67
284
  return (
68
285
  <View
69
286
  key={itemIndex}
70
- onLayout={
71
- !isMeasured
72
- ? (e) => handleItemLayout(itemIndex, e.nativeEvent.layout.width)
73
- : undefined
74
- }
75
- style={isMeasured ? { width: maxItemWidth } : undefined}
287
+ {...(onLayoutHandler ? { onLayout: onLayoutHandler } : null)}
288
+ style={cellStyle}
76
289
  >
77
290
  {child}
78
291
  </View>
79
292
  )
80
293
  })}
81
- {isMeasured &&
294
+ {cellWidth !== null &&
82
295
  spacersNeeded > 0 &&
83
296
  Array.from({ length: spacersNeeded }, (_, i) => (
84
- <View key={`spacer-${i}`} style={{ width: maxItemWidth }} />
297
+ <View key={`spacer-${i}`} style={cellStyle} />
85
298
  ))}
86
299
  </View>
87
300
  )
88
301
  })}
89
- </View>
302
+ </>
90
303
  )
304
+
305
+ if (useAnimatedContainer) {
306
+ // Outer Animated.View clips and animates height. Inner View holds the
307
+ // rows at natural size and reports its natural height for the spring
308
+ // target. Cell-width measurement happens on the cells themselves
309
+ // (first-row only) — no container-level onLayout is needed.
310
+ return (
311
+ <Animated.View style={animatedHeightStyle}>
312
+ <View style={containerStyle} onLayout={handleContentLayout}>
313
+ {rowsChildren}
314
+ </View>
315
+ </Animated.View>
316
+ )
317
+ }
318
+
319
+ return <View style={containerStyle}>{rowsChildren}</View>
320
+ }, slotGridPropsAreEqual)
321
+
322
+ function slotGridPropsAreEqual(prev: SlotGridProps, next: SlotGridProps) {
323
+ if (prev.gap !== next.gap) return false
324
+ if ((prev.maxColumns ?? SLOT_GRID_MAX_COLUMNS) !== (next.maxColumns ?? SLOT_GRID_MAX_COLUMNS)) return false
325
+ if (prev.animateExtrasFromIndex !== next.animateExtrasFromIndex) return false
326
+ if (prev.animateContainerLayout !== next.animateContainerLayout) return false
327
+ if (prev.items === next.items) return true
328
+ if (prev.items.length !== next.items.length) return false
329
+ for (let i = 0; i < prev.items.length; i++) {
330
+ if (prev.items[i] !== next.items[i]) return false
331
+ }
332
+ return true
91
333
  }
92
334
 
93
335
  type SectionProps = {
@@ -132,40 +374,27 @@ type SectionProps = {
132
374
  * @param {string} [props.accessibilityLabel] - Accessibility label for the section. If not provided, uses title
133
375
  * @param {string} [props.accessibilityHint] - Additional accessibility hint for screen readers
134
376
  */
135
- function Section({
136
- title = 'Section title',
137
- supportText = 'Section support text',
138
- showSupportText = true,
139
- slot,
140
- slotDirection = 'row',
141
- modes = {},
142
- onPress,
143
- style,
144
- accessibilityLabel,
145
- accessibilityHint,
146
- webAccessibilityProps,
147
- ...rest
148
- }: SectionProps) {
149
- const [isHeaderFocused, setIsHeaderFocused] = useState(false)
150
- const [isHeaderHovered, setIsHeaderHovered] = useState(false)
151
- const [isHeaderPressed, setIsHeaderPressed] = useState(false)
152
- const headerFocusStyle: ViewStyle = isHeaderFocused ? { borderColor: '#222', borderWidth: 1 } : {}
153
- const headerHoverStyle: ViewStyle = isHeaderHovered ? { opacity: 0.95 } : {}
154
- const headerPressedStyle: ViewStyle = isHeaderPressed ? { opacity: 0.85 } : {}
155
- // Resolve section container tokens
377
+ interface SectionTokens {
378
+ containerStyle: ViewStyle;
379
+ headerStyle: ViewStyle;
380
+ titleStyle: ViewStyle;
381
+ supportTextStyle: ViewStyle;
382
+ sectionGap: number;
383
+ slotGap: number;
384
+ }
385
+
386
+ function resolveSectionTokens(modes: Record<string, any>): SectionTokens {
156
387
  const backgroundColor = getVariableByName('section/background/color', modes) || '#ffffff'
157
- const sectionGap = getVariableByName('section/gap', modes) || 12
158
- const slotGap = getVariableByName('slot/gap', modes) || 12
388
+ const sectionGap = (getVariableByName('section/gap', modes) || 12) as number
389
+ const slotGap = (getVariableByName('slot/gap', modes) || 12) as number
159
390
  const paddingHorizontal = getVariableByName('section/padding/horizontal', modes) || 12
160
391
  const paddingVertical = getVariableByName('section/padding/vertical', modes) || 16
161
392
  const radius = getVariableByName('section/radius', modes) || 12
162
393
 
163
- // Resolve section header tokens
164
394
  const headerGap = getVariableByName('section/header/gap', modes) || 8
165
395
  const headerPaddingHorizontal = getVariableByName('section/header/padding/horizontal', modes) || 0
166
396
  const headerPaddingVertical = getVariableByName('section/header/padding/vertical', modes) || 0
167
397
 
168
- // Resolve section title tokens
169
398
  const titleColor = getVariableByName('section/title/color', modes) || '#0f0d0a'
170
399
  const titleFontSize = getVariableByName('section/title/fontSize', modes) || 18
171
400
  const titleLineHeight = getVariableByName('section/title/lineHeight', modes) || 20
@@ -176,7 +405,6 @@ function Section({
176
405
  ? titleFontWeightRaw.toString()
177
406
  : titleFontWeightRaw
178
407
 
179
- // Resolve section support text tokens
180
408
  const supportTextColor = getVariableByName('section/supportText/color', modes) || '#1f1a14'
181
409
  const supportTextFontSize = getVariableByName('section/supportText/fontSize', modes) || 14
182
410
  const supportTextLineHeight = getVariableByName('section/supportText/lineHeight', modes) || 18
@@ -187,46 +415,85 @@ function Section({
187
415
  ? supportTextFontWeightRaw.toString()
188
416
  : supportTextFontWeightRaw
189
417
 
190
- const containerStyle = {
191
- backgroundColor,
192
- paddingHorizontal,
193
- paddingVertical,
194
- borderRadius: radius,
195
- gap: sectionGap,
196
- }
197
-
198
- const headerStyle = {
199
- paddingHorizontal: headerPaddingHorizontal,
200
- paddingVertical: headerPaddingVertical,
201
- gap: headerGap,
202
- }
203
-
204
- const headerWrapStyle: ViewStyle = {
205
- flexDirection: 'row' as const,
206
- alignItems: 'center' as const,
207
- justifyContent: 'space-between' as const,
208
- }
209
-
210
- const titleStyle = {
211
- flex: 1,
212
- color: titleColor,
213
- fontSize: titleFontSize,
214
- lineHeight: titleLineHeight,
215
- fontFamily: titleFontFamily,
216
- fontWeight: titleFontWeight,
418
+ return {
419
+ containerStyle: {
420
+ backgroundColor,
421
+ paddingHorizontal,
422
+ paddingVertical,
423
+ borderRadius: radius,
424
+ gap: sectionGap,
425
+ },
426
+ headerStyle: {
427
+ paddingHorizontal: headerPaddingHorizontal,
428
+ paddingVertical: headerPaddingVertical,
429
+ gap: headerGap,
430
+ },
431
+ titleStyle: {
432
+ flex: 1,
433
+ color: titleColor,
434
+ fontSize: titleFontSize,
435
+ lineHeight: titleLineHeight,
436
+ fontFamily: titleFontFamily,
437
+ fontWeight: titleFontWeight,
438
+ } as ViewStyle,
439
+ supportTextStyle: {
440
+ color: supportTextColor,
441
+ fontSize: supportTextFontSize,
442
+ lineHeight: supportTextLineHeight,
443
+ fontFamily: supportTextFontFamily,
444
+ fontWeight: supportTextFontWeight,
445
+ } as ViewStyle,
446
+ sectionGap,
447
+ slotGap,
217
448
  }
449
+ }
218
450
 
219
- const supportTextStyle = {
220
- color: supportTextColor,
221
- fontSize: supportTextFontSize,
222
- lineHeight: supportTextLineHeight,
223
- fontFamily: supportTextFontFamily,
224
- fontWeight: supportTextFontWeight,
225
- }
451
+ function Section({
452
+ title = 'Section title',
453
+ supportText = 'Section support text',
454
+ showSupportText = true,
455
+ slot,
456
+ slotDirection = 'row',
457
+ modes = EMPTY_MODES,
458
+ onPress,
459
+ style,
460
+ // accessibilityLabel is intentionally accepted on the type for API
461
+ // back-compat, but the inner Pressable/View deliberately pass
462
+ // `accessibilityLabel={undefined}` (the title Text carries the label
463
+ // instead). Prefix to satisfy the unused-var lint while keeping the prop
464
+ // shape unchanged.
465
+ accessibilityLabel: _accessibilityLabel,
466
+ accessibilityHint,
467
+ webAccessibilityProps,
468
+ ...rest
469
+ }: SectionProps) {
470
+ // Focus and hover are still mirrored in React because they are visible,
471
+ // sustained states (web-only in practice). The setters are gated so they
472
+ // never fire on native — keeping the component render-free during touch.
473
+ // Press is handled imperatively via the `Pressable` style callback so a
474
+ // scroll-cancelled touch never schedules a React render.
475
+ const [isHeaderFocused, setIsHeaderFocused] = useState(false)
476
+ const [isHeaderHovered, setIsHeaderHovered] = useState(false)
226
477
 
227
- // Generate default accessibility label from title and supportText
228
- const defaultAccessibilityLabel = accessibilityLabel ||
229
- (showSupportText ? `${title}. ${supportText}` : title)
478
+ // Mirror user handlers in a ref so our wrappers can stay referentially
479
+ // stable. Without this, every parent re-render would hand Pressable fresh
480
+ // function identities and re-bind every event.
481
+ const userHandlersRef = useRef<{
482
+ onPressIn?: (e: any) => void
483
+ onPressOut?: (e: any) => void
484
+ onHoverIn?: (e: any) => void
485
+ onHoverOut?: (e: any) => void
486
+ onFocus?: (e: any) => void
487
+ onBlur?: (e: any) => void
488
+ }>({})
489
+ userHandlersRef.current.onPressIn = (rest as any)?.onPressIn
490
+ userHandlersRef.current.onPressOut = (rest as any)?.onPressOut
491
+ userHandlersRef.current.onHoverIn = (rest as any)?.onHoverIn
492
+ userHandlersRef.current.onHoverOut = (rest as any)?.onHoverOut
493
+ userHandlersRef.current.onFocus = (rest as any)?.onFocus
494
+ userHandlersRef.current.onBlur = (rest as any)?.onBlur
495
+
496
+ const tokens = useMemo(() => resolveSectionTokens(modes), [modes])
230
497
 
231
498
  // Get web platform support props (only used when onPress is defined)
232
499
  const webProps = usePressableWebSupport({
@@ -241,7 +508,7 @@ function Section({
241
508
  <>
242
509
  <View style={headerWrapStyle}>
243
510
  <Text
244
- style={titleStyle}
511
+ style={tokens.titleStyle}
245
512
  numberOfLines={1}
246
513
  accessibilityElementsHidden={true}
247
514
  importantForAccessibility="no"
@@ -254,7 +521,7 @@ function Section({
254
521
  </View>
255
522
  {showSupportText && (
256
523
  <Text
257
- style={supportTextStyle}
524
+ style={tokens.supportTextStyle}
258
525
  numberOfLines={2}
259
526
  accessibilityElementsHidden={true}
260
527
  importantForAccessibility="no"
@@ -265,10 +532,53 @@ function Section({
265
532
  </>
266
533
  )
267
534
 
535
+ // Stable handler identities. User handlers are read through the ref so
536
+ // these wrappers don't need new identities each render.
537
+ const handlePressIn = useCallback((e: any) => {
538
+ userHandlersRef.current.onPressIn?.(e)
539
+ }, [])
540
+ const handlePressOut = useCallback((e: any) => {
541
+ userHandlersRef.current.onPressOut?.(e)
542
+ }, [])
543
+ const handleHoverIn = useCallback((e: any) => {
544
+ if (IS_WEB) setIsHeaderHovered(true)
545
+ userHandlersRef.current.onHoverIn?.(e)
546
+ }, [])
547
+ const handleHoverOut = useCallback((e: any) => {
548
+ if (IS_WEB) setIsHeaderHovered(false)
549
+ userHandlersRef.current.onHoverOut?.(e)
550
+ }, [])
551
+ const handleFocus = useCallback((e: any) => {
552
+ if (IS_WEB) setIsHeaderFocused(true)
553
+ userHandlersRef.current.onFocus?.(e)
554
+ }, [])
555
+ const handleBlur = useCallback((e: any) => {
556
+ if (IS_WEB) setIsHeaderFocused(false)
557
+ userHandlersRef.current.onBlur?.(e)
558
+ }, [])
559
+
560
+ // The pressed visual is applied by the host view directly through the
561
+ // Pressable style callback — no React render is scheduled. We still want
562
+ // the (constant) hover style on web so we keep it in the array.
563
+ const headerStyleCallback = useCallback(
564
+ ({ pressed }: PressableStateCallbackType): StyleProp<ViewStyle> => [
565
+ tokens.headerStyle,
566
+ pressed ? headerPressedStyle : null,
567
+ isHeaderHovered ? headerHoverStyle : null,
568
+ isHeaderFocused ? headerFocusStyle : null,
569
+ ],
570
+ [tokens.headerStyle, isHeaderHovered, isHeaderFocused]
571
+ )
572
+
573
+ const containerStyleArray = useMemo(
574
+ () => [tokens.containerStyle, style],
575
+ [tokens.containerStyle, style]
576
+ )
577
+
268
578
  return (
269
579
  <View
270
- style={[containerStyle, style]}
271
- {...(Platform.OS === 'web' ? { accessibilityRole: 'region' as any } : undefined)}
580
+ style={containerStyleArray}
581
+ {...(IS_WEB ? { accessibilityRole: 'region' as any } : undefined)}
272
582
  accessibilityLabel={undefined}
273
583
  accessibilityHint={accessibilityHint}
274
584
  {...rest}
@@ -277,62 +587,66 @@ function Section({
277
587
  <Pressable
278
588
  accessibilityRole="button"
279
589
  accessibilityLabel={undefined}
280
- accessibilityHint={accessibilityHint || "Opens section details"}
590
+ accessibilityHint={accessibilityHint || 'Opens section details'}
281
591
  onPress={onPress}
282
- onPressIn={(e: any) => {
283
- setIsHeaderPressed(true)
284
- ; (rest as any)?.onPressIn?.(e)
285
- }}
286
- onPressOut={(e: any) => {
287
- setIsHeaderPressed(false)
288
- ; (rest as any)?.onPressOut?.(e)
289
- }}
290
- onFocus={(e: any) => {
291
- setIsHeaderFocused(true)
292
- ; (rest as any)?.onFocus?.(e)
293
- }}
294
- onBlur={(e: any) => {
295
- setIsHeaderFocused(false)
296
- ; (rest as any)?.onBlur?.(e)
297
- }}
298
- onHoverIn={(e: any) => {
299
- setIsHeaderHovered(true)
300
- ; (rest as any)?.onHoverIn?.(e)
301
- }}
302
- onHoverOut={(e: any) => {
303
- setIsHeaderHovered(false)
304
- ; (rest as any)?.onHoverOut?.(e)
305
- }}
306
- style={({ pressed }: PressableStateCallbackType) => [
307
- headerStyle,
308
- pressed ? headerPressedStyle : null,
309
- headerHoverStyle,
310
- headerFocusStyle,
311
- ]}
592
+ onPressIn={handlePressIn}
593
+ onPressOut={handlePressOut}
594
+ onFocus={handleFocus}
595
+ onBlur={handleBlur}
596
+ onHoverIn={handleHoverIn}
597
+ onHoverOut={handleHoverOut}
598
+ unstable_pressDelay={HEADER_PRESS_DELAY}
599
+ style={headerStyleCallback}
312
600
  {...webProps}
313
601
  >
314
602
  {headerContent}
315
603
  </Pressable>
316
604
  ) : (
317
- <View style={headerStyle}>
605
+ <View style={tokens.headerStyle}>
318
606
  {headerContent}
319
607
  </View>
320
608
  )}
321
- {slot && slotDirection === 'row' && (
322
- <SlotGrid
323
- items={cloneChildrenWithModes(flattenChildren(slot), modes)}
324
- gap={sectionGap}
325
- />
326
- )}
327
- {slot && slotDirection === 'column' && (
328
- <View style={{ flexDirection: 'column' as const, gap: slotGap }}>
329
- {cloneChildrenWithModes(flattenChildren(slot), modes)}
330
- </View>
331
- )}
609
+ {slot && <SectionSlot slot={slot} modes={modes} direction={slotDirection} rowGap={tokens.sectionGap} columnGap={tokens.slotGap} />}
332
610
  </View>
333
611
  )
334
612
  }
335
613
 
614
+ type SectionSlotProps = {
615
+ slot: React.ReactNode;
616
+ modes: Record<string, any>;
617
+ direction: 'row' | 'column';
618
+ rowGap: number;
619
+ columnGap: number;
620
+ }
621
+
622
+ /**
623
+ * Internal helper that processes the slot children once per (slot, modes) pair
624
+ * and dispatches to the row (SlotGrid) or column layout. Splitting this out of
625
+ * `Section` lets the parent re-render (e.g. for header press/hover state)
626
+ * without re-walking the slot tree via `cloneChildrenWithModes`.
627
+ */
628
+ function SectionSlot({ slot, modes, direction, rowGap, columnGap }: SectionSlotProps) {
629
+ const processed = useMemo(
630
+ () => cloneChildrenWithModes(flattenChildren(slot), modes),
631
+ [slot, modes]
632
+ )
633
+
634
+ const columnContainerStyle = useMemo<ViewStyle>(
635
+ () => ({ flexDirection: 'column', gap: columnGap }),
636
+ [columnGap]
637
+ )
638
+
639
+ if (direction === 'row') {
640
+ return <SlotGrid items={processed} gap={rowGap} />
641
+ }
642
+ return <View style={columnContainerStyle}>{processed}</View>
643
+ }
644
+
645
+ type BentoToggleRenderState = {
646
+ expanded: boolean;
647
+ toggle: () => void;
648
+ }
649
+
336
650
  type SectionBentoProps = {
337
651
  navSlot?: React.ReactNode;
338
652
  upiSlot?: React.ReactNode;
@@ -344,6 +658,43 @@ type SectionBentoProps = {
344
658
  * Web-specific accessibility props (only used on web platform)
345
659
  */
346
660
  webAccessibilityProps?: WebAccessibilityProps;
661
+ /**
662
+ * Total cell count visible when collapsed (real items + the toggle cell).
663
+ * Defaults to {@link SLOT_GRID_MAX_COLUMNS} (4) so the collapsed state fills
664
+ * exactly one row. When `navSlot.length <= collapsedCount`, expansion is
665
+ * disabled (no toggle injected, no animation wrappers — identical to the
666
+ * legacy behavior for back-compat).
667
+ */
668
+ collapsedCount?: number;
669
+ /**
670
+ * Uncontrolled initial expanded state. Ignored when `expanded` is provided.
671
+ * Defaults to `false`.
672
+ */
673
+ defaultExpanded?: boolean;
674
+ /**
675
+ * Controlled expanded state. When provided, `onExpandedChange` should also
676
+ * be provided so the component can request changes.
677
+ */
678
+ expanded?: boolean;
679
+ /**
680
+ * Called when the user taps the toggle. Required in controlled mode; ignored
681
+ * in uncontrolled mode unless you want to observe the change.
682
+ */
683
+ onExpandedChange?: (next: boolean) => void;
684
+ /** Label shown on the toggle cell when collapsed. Default `'More'`. */
685
+ toggleMoreLabel?: string;
686
+ /** Label shown on the toggle cell when expanded. Default `'Less'`. */
687
+ toggleLessLabel?: string;
688
+ /** Icon name shown on the toggle when collapsed. Default `'ic_chevron_down'`. */
689
+ toggleMoreIcon?: string;
690
+ /** Icon name shown on the toggle when expanded. Default `'ic_chevron_up'`. */
691
+ toggleLessIcon?: string;
692
+ /**
693
+ * Escape hatch: render a custom toggle cell instead of the default ListItem.
694
+ * The provided node is rendered in the toggle's grid slot. Wire `toggle()` to
695
+ * any tap interaction inside it. Height + per-cell animations still apply.
696
+ */
697
+ renderToggle?: (state: BentoToggleRenderState) => React.ReactNode;
347
698
  } & React.ComponentProps<typeof View>;
348
699
 
349
700
  /**
@@ -368,37 +719,118 @@ type SectionBentoProps = {
368
719
  * @param {string} [props.accessibilityLabel] - Accessibility label for the section
369
720
  * @param {string} [props.accessibilityHint] - Additional accessibility hint for screen readers
370
721
  */
722
+ const sectionBentoUpiRowStyle: ViewStyle = { flexDirection: 'row', gap: 8 }
723
+
371
724
  function SectionBento({
372
725
  navSlot,
373
726
  upiSlot,
374
- modes = {},
727
+ modes = EMPTY_MODES,
375
728
  style,
376
- accessibilityLabel = undefined,
729
+ // Same rationale as Section: accepted on the type but unused internally.
730
+ accessibilityLabel: _accessibilityLabel,
377
731
  accessibilityHint,
732
+ collapsedCount = SLOT_GRID_MAX_COLUMNS,
733
+ defaultExpanded = false,
734
+ expanded: controlledExpanded,
735
+ onExpandedChange,
736
+ toggleMoreLabel = 'More',
737
+ toggleLessLabel = 'Less',
738
+ toggleMoreIcon = 'ic_chevron_down',
739
+ toggleLessIcon = 'ic_chevron_up',
740
+ renderToggle,
378
741
  ...rest
379
742
  }: SectionBentoProps) {
380
- // Resolve section container tokens
381
743
  const backgroundColor = getVariableByName('section/background/color', modes) || '#ffffff'
382
744
  const gap = getVariableByName('section/gap', modes) || 12
383
745
  const paddingHorizontal = getVariableByName('section/padding/horizontal', modes) || 12
384
746
  const paddingVertical = getVariableByName('section/padding/vertical', modes) || 16
385
747
  const radius = getVariableByName('section/radius', modes) || 12
386
748
 
387
- const containerStyle = {
388
- backgroundColor,
389
- paddingHorizontal,
390
- paddingVertical,
391
- borderRadius: radius,
392
- gap,
393
- }
749
+ const containerStyle = useMemo<ViewStyle>(
750
+ () => ({
751
+ backgroundColor,
752
+ paddingHorizontal,
753
+ paddingVertical,
754
+ borderRadius: radius,
755
+ gap,
756
+ }),
757
+ [backgroundColor, paddingHorizontal, paddingVertical, radius, gap]
758
+ )
759
+
760
+ const processedNavSlot = useMemo(
761
+ () => (navSlot ? cloneChildrenWithModes(flattenChildren(navSlot), modes) : null),
762
+ [navSlot, modes]
763
+ )
764
+
765
+ const processedUpiSlot = useMemo(
766
+ () => (upiSlot ? cloneChildrenWithModes(flattenChildren(upiSlot), modes) : null),
767
+ [upiSlot, modes]
768
+ )
394
769
 
395
- const processedNavSlot = navSlot
396
- ? cloneChildrenWithModes(flattenChildren(navSlot), modes)
397
- : null
770
+ // `canExpand` is true iff there are strictly more real items than fit into
771
+ // the collapsed grid. When `allRealItems.length === collapsedCount` we just
772
+ // render them all with no toggle — identical to the pre-feature behavior.
773
+ const allRealItems = useMemo(() => processedNavSlot ?? [], [processedNavSlot])
774
+ const canExpand = allRealItems.length > collapsedCount
775
+
776
+ const isControlled = controlledExpanded !== undefined
777
+ const [internalExpanded, setInternalExpanded] = useState<boolean>(defaultExpanded)
778
+ const expanded = isControlled ? (controlledExpanded as boolean) : internalExpanded
779
+
780
+ // Mirror onExpandedChange in a ref so `toggle` can stay referentially stable.
781
+ const onExpandedChangeRef = useRef(onExpandedChange)
782
+ onExpandedChangeRef.current = onExpandedChange
783
+ const isControlledRef = useRef(isControlled)
784
+ isControlledRef.current = isControlled
785
+ const expandedRef = useRef(expanded)
786
+ expandedRef.current = expanded
787
+
788
+ const toggle = useCallback(() => {
789
+ const next = !expandedRef.current
790
+ if (!isControlledRef.current) {
791
+ setInternalExpanded(next)
792
+ }
793
+ onExpandedChangeRef.current?.(next)
794
+ }, [])
398
795
 
399
- const processedUpiSlot = upiSlot
400
- ? cloneChildrenWithModes(flattenChildren(upiSlot), modes)
401
- : null
796
+ const navGridItems = useMemo<React.ReactNode[]>(() => {
797
+ if (!canExpand) {
798
+ return allRealItems
799
+ }
800
+ // Leave the last collapsed slot for the toggle cell.
801
+ const visibleRealItems = expanded
802
+ ? allRealItems
803
+ : allRealItems.slice(0, collapsedCount - 1)
804
+
805
+ const toggleNode = renderToggle
806
+ ? renderToggle({ expanded, toggle })
807
+ : (
808
+ <DefaultBentoToggle
809
+ expanded={expanded}
810
+ onPress={toggle}
811
+ modes={modes}
812
+ moreLabel={toggleMoreLabel}
813
+ lessLabel={toggleLessLabel}
814
+ moreIcon={toggleMoreIcon}
815
+ lessIcon={toggleLessIcon}
816
+ extraCount={allRealItems.length - (collapsedCount - 1)}
817
+ />
818
+ )
819
+
820
+ return [...visibleRealItems, toggleNode]
821
+ }, [
822
+ canExpand,
823
+ allRealItems,
824
+ expanded,
825
+ collapsedCount,
826
+ renderToggle,
827
+ toggle,
828
+ modes,
829
+ toggleMoreLabel,
830
+ toggleLessLabel,
831
+ toggleMoreIcon,
832
+ toggleLessIcon,
833
+ ])
402
834
 
403
835
  return (
404
836
  <View
@@ -408,14 +840,20 @@ function SectionBento({
408
840
  accessibilityHint={accessibilityHint}
409
841
  {...rest}
410
842
  >
411
- {processedNavSlot && (
843
+ {navGridItems.length > 0 && (
412
844
  <SlotGrid
413
- items={processedNavSlot as React.ReactNode[]}
845
+ items={navGridItems}
414
846
  gap={gap}
847
+ {...(canExpand
848
+ ? {
849
+ animateExtrasFromIndex: collapsedCount,
850
+ animateContainerLayout: true,
851
+ }
852
+ : null)}
415
853
  />
416
854
  )}
417
855
  {processedUpiSlot && (
418
- <View style={{ flexDirection: 'row' as const, gap: 8 }}>
856
+ <View style={sectionBentoUpiRowStyle}>
419
857
  {processedUpiSlot}
420
858
  </View>
421
859
  )}
@@ -423,6 +861,64 @@ function SectionBento({
423
861
  )
424
862
  }
425
863
 
864
+ // ---------------------------------------------------------------------------
865
+ // DefaultBentoToggle — internal toggle cell rendered by `SectionBento` when no
866
+ // `renderToggle` prop is provided. Uses a vertical `ListItem` so the cell
867
+ // matches the visual rhythm of the surrounding nav items in every mode.
868
+ //
869
+ // Two icons (`ic_chevron_down` / `ic_chevron_up`) are used instead of a
870
+ // rotating single icon, because the toggle cell is reconciled by position
871
+ // (collapsed: end of row 1; expanded: end of last row), so any persistent
872
+ // shared-value-driven rotation would lose its anchor across toggles.
873
+ // ---------------------------------------------------------------------------
874
+ type DefaultBentoToggleProps = {
875
+ expanded: boolean;
876
+ onPress: () => void;
877
+ modes: Record<string, any>;
878
+ moreLabel: string;
879
+ lessLabel: string;
880
+ moreIcon: string;
881
+ lessIcon: string;
882
+ /** How many additional actions become visible when expanding. Used in the a11y hint. */
883
+ extraCount: number;
884
+ }
885
+
886
+ function DefaultBentoToggle({
887
+ expanded,
888
+ onPress,
889
+ modes,
890
+ moreLabel,
891
+ lessLabel,
892
+ moreIcon,
893
+ lessIcon,
894
+ extraCount,
895
+ }: DefaultBentoToggleProps) {
896
+ const label = expanded ? lessLabel : moreLabel
897
+ const iconName = expanded ? lessIcon : moreIcon
898
+ const accessibilityState = useMemo(() => ({ expanded }), [expanded])
899
+ const webAccessibilityProps = useMemo(
900
+ () => ({ ariaExpanded: expanded }),
901
+ [expanded]
902
+ )
903
+ const accessibilityHint = expanded
904
+ ? `Hides ${extraCount} additional ${extraCount === 1 ? 'action' : 'actions'}`
905
+ : `Shows ${extraCount} additional ${extraCount === 1 ? 'action' : 'actions'}`
906
+
907
+ return (
908
+ <ListItem
909
+ layout="Vertical"
910
+ supportText={label}
911
+ leading={<IconCapsule iconName={iconName} modes={modes} />}
912
+ modes={modes}
913
+ onPress={onPress}
914
+ accessibilityLabel={label}
915
+ accessibilityHint={accessibilityHint}
916
+ accessibilityState={accessibilityState}
917
+ webAccessibilityProps={webAccessibilityProps}
918
+ />
919
+ )
920
+ }
921
+
426
922
  // Attach Bento as a property of Section using namespace pattern
427
923
  Section.Bento = SectionBento
428
924