jfs-components 0.0.63 → 0.0.65

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 (44) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/lib/commonjs/components/Carousel/Carousel.js +12 -9
  3. package/lib/commonjs/components/Drawer/Drawer.js +116 -50
  4. package/lib/commonjs/components/IconButton/IconButton.js +42 -6
  5. package/lib/commonjs/components/IconCapsule/IconCapsule.js +5 -0
  6. package/lib/commonjs/components/Popup/Popup.js +2 -2
  7. package/lib/commonjs/components/Section/Section.js +280 -58
  8. package/lib/commonjs/components/UpiHandle/UpiHandle.js +19 -7
  9. package/lib/commonjs/icons/Icon.js +72 -75
  10. package/lib/commonjs/icons/registry.js +1 -1
  11. package/lib/commonjs/utils/MediaSource.js +181 -0
  12. package/lib/commonjs/utils/index.js +9 -1
  13. package/lib/module/components/Carousel/Carousel.js +12 -9
  14. package/lib/module/components/Drawer/Drawer.js +116 -50
  15. package/lib/module/components/IconButton/IconButton.js +42 -6
  16. package/lib/module/components/IconCapsule/IconCapsule.js +5 -0
  17. package/lib/module/components/Popup/Popup.js +2 -2
  18. package/lib/module/components/Section/Section.js +280 -58
  19. package/lib/module/components/UpiHandle/UpiHandle.js +20 -8
  20. package/lib/module/icons/Icon.js +72 -75
  21. package/lib/module/icons/registry.js +1 -1
  22. package/lib/module/utils/MediaSource.js +176 -0
  23. package/lib/module/utils/index.js +2 -1
  24. package/lib/typescript/src/components/Drawer/Drawer.d.ts +6 -1
  25. package/lib/typescript/src/components/IconButton/IconButton.d.ts +25 -14
  26. package/lib/typescript/src/components/IconCapsule/IconCapsule.d.ts +12 -1
  27. package/lib/typescript/src/components/Section/Section.d.ts +42 -1
  28. package/lib/typescript/src/components/UpiHandle/UpiHandle.d.ts +17 -3
  29. package/lib/typescript/src/icons/Icon.d.ts +35 -16
  30. package/lib/typescript/src/icons/registry.d.ts +1 -1
  31. package/lib/typescript/src/utils/MediaSource.d.ts +63 -0
  32. package/lib/typescript/src/utils/index.d.ts +2 -0
  33. package/package.json +1 -1
  34. package/src/components/Carousel/Carousel.tsx +16 -17
  35. package/src/components/Drawer/Drawer.tsx +136 -60
  36. package/src/components/IconButton/IconButton.tsx +70 -11
  37. package/src/components/IconCapsule/IconCapsule.tsx +13 -0
  38. package/src/components/Popup/Popup.tsx +2 -2
  39. package/src/components/Section/Section.tsx +411 -71
  40. package/src/components/UpiHandle/UpiHandle.tsx +37 -11
  41. package/src/icons/Icon.tsx +91 -76
  42. package/src/icons/registry.ts +1 -1
  43. package/src/utils/MediaSource.tsx +220 -0
  44. package/src/utils/index.ts +2 -0
@@ -1,7 +1,17 @@
1
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
16
  import { EMPTY_MODES, cloneChildrenWithModes, flattenChildren } from '../../utils/react-utils'
7
17
 
@@ -22,39 +32,80 @@ const headerPressedStyle: ViewStyle = { opacity: 0.85 }
22
32
  const headerFocusStyle: ViewStyle = { borderColor: '#222', borderWidth: 1 }
23
33
 
24
34
  // ---------------------------------------------------------------------------
25
- // Shared grid layout: measures the widest child once per item-count, then
26
- // renders uniform-width cells laid out with `justify-content: space-between`.
27
- // This preserves three visual invariants regardless of viewport width:
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:
28
41
  // 1. The first cell hugs the container's left edge.
29
42
  // 2. The last cell hugs the container's right edge.
30
43
  // 3. Cells in row N column K align with cells in row N+1 column K
31
44
  // (uniform cell width across the whole grid).
32
- // Pure flex sizing (e.g. `flexBasis: 0; flexGrow: 1`) cannot satisfy (1) and
33
- // (2) on wide viewports — it distributes extra space inside each cell, which
34
- // makes the icon+label content drift toward each cell's center and produces
35
- // visible "dead" margins on the left and right of the grid.
36
45
  //
37
- // To avoid the blank-flash that the previous implementation suffered from
38
- // (it hid the grid with `opacity: 0` until measurement completed, and reset
39
- // every measurement when the item count changed):
40
- // * Cells render at their *natural* widths during measurement instead of
41
- // being hidden. With `space-between`, the first/last cells already hug
42
- // the edges; only the column alignment can be off by a few pixels for
43
- // the single frame between mount and the onLayout callback.
44
- // * On item-count change (e.g. expand / collapse), we clear the per-cell
45
- // samples but keep the rendered layout visible — we never blank out.
46
- // * No 500ms safety timeout is needed because the grid is visible from the
47
- // first frame.
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.
48
77
  // ---------------------------------------------------------------------------
49
78
  const SLOT_GRID_MAX_COLUMNS = 4
50
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
+
51
87
  type SlotGridProps = {
52
88
  items: React.ReactNode[];
53
89
  gap: number;
54
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;
55
106
  }
56
107
 
57
- const slotGridRowStyle: ViewStyle = {
108
+ const slotGridRowFlowStyle: ViewStyle = {
58
109
  flexDirection: 'row',
59
110
  justifyContent: 'space-between',
60
111
  }
@@ -63,53 +114,42 @@ const SlotGrid = React.memo(function SlotGrid({
63
114
  items,
64
115
  gap,
65
116
  maxColumns = SLOT_GRID_MAX_COLUMNS,
117
+ animateExtrasFromIndex,
118
+ animateContainerLayout,
66
119
  }: SlotGridProps) {
67
120
  const totalItems = items.length
68
-
69
- const [maxItemWidth, setMaxItemWidth] = useState<number | null>(null)
70
- // Tracks the item-count that `maxItemWidth` corresponds to. When the
71
- // current `totalItems` differs, the existing measurement is considered
72
- // stale and cells fall back to natural widths until remeasurement.
73
- const [measuredForCount, setMeasuredForCount] = useState(0)
74
- const itemWidthsRef = useRef<Map<number, number>>(new Map())
75
-
76
- // Synchronously invalidate per-cell samples when the item count changes
77
- // (e.g. show more / less). We deliberately do NOT touch `maxItemWidth` or
78
- // `measuredForCount` state here flipping them would force an extra render
79
- // pass; instead we let the count mismatch (`measuredForCount !== totalItems`)
80
- // gate the use of the stale value, and the next onLayout cycle will publish
81
- // a fresh `maxItemWidth` for the new count.
82
- const prevTotalRef = useRef(totalItems)
83
- if (prevTotalRef.current !== totalItems) {
84
- prevTotalRef.current = totalItems
85
- itemWidthsRef.current = new Map()
86
- }
87
-
88
- const handleItemLayout = useCallback(
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(
89
134
  (index: number, width: number) => {
90
- const widths = itemWidthsRef.current
135
+ const widths = firstRowWidthsRef.current
91
136
  const previous = widths.get(index)
92
137
  if (previous !== undefined && Math.abs(previous - width) < 0.5) return
93
138
  widths.set(index, width)
94
- if (widths.size >= totalItems && totalItems > 0) {
139
+ if (widths.size >= firstRowSize && firstRowSize > 0) {
95
140
  let newMax = 0
96
141
  for (const w of widths.values()) {
97
142
  if (w > newMax) newMax = w
98
143
  }
99
- setMaxItemWidth((prev) =>
144
+ setFirstRowMaxWidth((prev) =>
100
145
  prev !== null && Math.abs(prev - newMax) < 0.5 ? prev : newMax
101
146
  )
102
- setMeasuredForCount(totalItems)
103
147
  }
104
148
  },
105
- [totalItems]
149
+ [firstRowSize]
106
150
  )
107
151
 
108
- const hasFreshMeasurement =
109
- maxItemWidth !== null && measuredForCount === totalItems
110
- const cellWidth = hasFreshMeasurement ? maxItemWidth : undefined
111
-
112
- const columns = Math.min(maxColumns, totalItems || 1)
152
+ const cellWidth = firstRowMaxWidth
113
153
 
114
154
  const rows: React.ReactNode[][] = []
115
155
  for (let i = 0; i < items.length; i += columns) {
@@ -117,52 +157,173 @@ const SlotGrid = React.memo(function SlotGrid({
117
157
  }
118
158
 
119
159
  const containerStyle = useMemo<ViewStyle>(() => ({ gap }), [gap])
120
- const measuredCellStyle = useMemo<ViewStyle | undefined>(
121
- () => (cellWidth !== undefined ? { width: cellWidth } : undefined),
160
+ const cellStyle = useMemo<ViewStyle | undefined>(
161
+ () => (cellWidth !== null ? { width: cellWidth } : undefined),
122
162
  [cellWidth]
123
163
  )
124
164
 
125
- return (
126
- <View style={containerStyle}>
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
+ <>
127
232
  {rows.map((row, rowIndex) => {
128
233
  const spacersNeeded = row.length < columns ? columns - row.length : 0
129
234
  return (
130
- <View key={rowIndex} style={slotGridRowStyle}>
235
+ <View key={rowIndex} style={rowStyle}>
131
236
  {row.map((child, colIndex) => {
132
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
+
133
284
  return (
134
285
  <View
135
286
  key={itemIndex}
136
- onLayout={
137
- hasFreshMeasurement
138
- ? undefined
139
- : (e) =>
140
- handleItemLayout(
141
- itemIndex,
142
- e.nativeEvent.layout.width
143
- )
144
- }
145
- style={measuredCellStyle}
287
+ {...(onLayoutHandler ? { onLayout: onLayoutHandler } : null)}
288
+ style={cellStyle}
146
289
  >
147
290
  {child}
148
291
  </View>
149
292
  )
150
293
  })}
151
- {hasFreshMeasurement &&
294
+ {cellWidth !== null &&
152
295
  spacersNeeded > 0 &&
153
296
  Array.from({ length: spacersNeeded }, (_, i) => (
154
- <View key={`spacer-${i}`} style={measuredCellStyle} />
297
+ <View key={`spacer-${i}`} style={cellStyle} />
155
298
  ))}
156
299
  </View>
157
300
  )
158
301
  })}
159
- </View>
302
+ </>
160
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>
161
320
  }, slotGridPropsAreEqual)
162
321
 
163
322
  function slotGridPropsAreEqual(prev: SlotGridProps, next: SlotGridProps) {
164
323
  if (prev.gap !== next.gap) return false
165
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
166
327
  if (prev.items === next.items) return true
167
328
  if (prev.items.length !== next.items.length) return false
168
329
  for (let i = 0; i < prev.items.length; i++) {
@@ -481,6 +642,11 @@ function SectionSlot({ slot, modes, direction, rowGap, columnGap }: SectionSlotP
481
642
  return <View style={columnContainerStyle}>{processed}</View>
482
643
  }
483
644
 
645
+ type BentoToggleRenderState = {
646
+ expanded: boolean;
647
+ toggle: () => void;
648
+ }
649
+
484
650
  type SectionBentoProps = {
485
651
  navSlot?: React.ReactNode;
486
652
  upiSlot?: React.ReactNode;
@@ -492,6 +658,43 @@ type SectionBentoProps = {
492
658
  * Web-specific accessibility props (only used on web platform)
493
659
  */
494
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;
495
698
  } & React.ComponentProps<typeof View>;
496
699
 
497
700
  /**
@@ -526,9 +729,17 @@ function SectionBento({
526
729
  // Same rationale as Section: accepted on the type but unused internally.
527
730
  accessibilityLabel: _accessibilityLabel,
528
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,
529
741
  ...rest
530
742
  }: SectionBentoProps) {
531
- // Resolve section container tokens
532
743
  const backgroundColor = getVariableByName('section/background/color', modes) || '#ffffff'
533
744
  const gap = getVariableByName('section/gap', modes) || 12
534
745
  const paddingHorizontal = getVariableByName('section/padding/horizontal', modes) || 12
@@ -556,6 +767,71 @@ function SectionBento({
556
767
  [upiSlot, modes]
557
768
  )
558
769
 
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
+ }, [])
795
+
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
+ ])
834
+
559
835
  return (
560
836
  <View
561
837
  style={[containerStyle, style]}
@@ -564,10 +840,16 @@ function SectionBento({
564
840
  accessibilityHint={accessibilityHint}
565
841
  {...rest}
566
842
  >
567
- {processedNavSlot && (
843
+ {navGridItems.length > 0 && (
568
844
  <SlotGrid
569
- items={processedNavSlot as React.ReactNode[]}
845
+ items={navGridItems}
570
846
  gap={gap}
847
+ {...(canExpand
848
+ ? {
849
+ animateExtrasFromIndex: collapsedCount,
850
+ animateContainerLayout: true,
851
+ }
852
+ : null)}
571
853
  />
572
854
  )}
573
855
  {processedUpiSlot && (
@@ -579,6 +861,64 @@ function SectionBento({
579
861
  )
580
862
  }
581
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
+
582
922
  // Attach Bento as a property of Section using namespace pattern
583
923
  Section.Bento = SectionBento
584
924