jfs-components 0.0.63 → 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.
@@ -126,7 +126,17 @@ function Drawer({
126
126
  const updateMode = useCallback(newMode => {
127
127
  setMode(newMode);
128
128
  }, []);
129
- const gesture = Gesture.Pan().simultaneousWithExternalGesture(scrollRef).activeOffsetY([-5, 5]).activeOffsetX([-5, 5]).onStart(() => {
129
+
130
+ // Gesture policy:
131
+ // • activeOffsetY: require a clear *vertical* drag (10px) before this
132
+ // pan claims the gesture. Matches typical iOS scroll activation feel.
133
+ // • failOffsetX: if the finger crosses ~16px horizontally *before* we
134
+ // activate, surrender the gesture entirely so any horizontal child
135
+ // (FlatList horizontal, swiper, slider, etc.) can scroll cleanly
136
+ // without the drawer also translating on Y.
137
+ // • simultaneousWithExternalGesture(scrollRef): cooperate with the
138
+ // drawer's own internal vertical ScrollView for nested scrolling.
139
+ const gesture = Gesture.Pan().simultaneousWithExternalGesture(scrollRef).activeOffsetY([-10, 10]).failOffsetX([-16, 16]).onStart(() => {
130
140
  context.value = {
131
141
  y: translateY.value
132
142
  };
@@ -135,6 +145,16 @@ function Drawer({
135
145
  prevAtTop.value = scrollY.value <= 1;
136
146
  scrollTopTranslationOffset.value = 0;
137
147
  }).onUpdate(event => {
148
+ // Defense-in-depth: even after vertical activation, if the *current*
149
+ // motion is dominantly horizontal (e.g., the user activated with a
150
+ // small Y nudge and then curved into a horizontal swipe on a child
151
+ // carousel), don't translate the drawer this frame. failOffsetX
152
+ // already prevents activation in pure-horizontal swipes; this guards
153
+ // the diagonal-then-horizontal case.
154
+ if (Math.abs(event.translationX) > Math.abs(event.translationY) * 1.5) {
155
+ return;
156
+ }
157
+
138
158
  // Logic for nested scrolling:
139
159
  // If we are at the expanded position (minTranslateY) AND content is
140
160
  // scrolled down (scrollY > 0), let the ScrollView handle the gesture.
@@ -246,71 +266,108 @@ function Drawer({
246
266
  const titleWeight = getVariableByName('drawer/title/fontWeight', modes) || '700';
247
267
  const titleLineHeight = getVariableByName('drawer/title/lineHeight', modes) || 17;
248
268
  const titlePaddingBottom = getVariableByName('drawer/titleWrap/padding/bottom', modes) || 8;
269
+
270
+ // Drop shadow — Figma layers two shadows (primary + secondary) sharing
271
+ // the same offsetY/blur but with their own offsetX and color.
272
+ const shadowPrimaryOffsetX = getVariableByName('drawer/shadow/primary/offsetX', modes) ?? 0;
273
+ const shadowPrimaryOffsetY = getVariableByName('drawer/shadow/primary/offsetY', modes) ?? 16;
274
+ const shadowPrimaryBlur = getVariableByName('drawer/shadow/primary/blur', modes) ?? 48;
275
+ const shadowPrimaryColor = getVariableByName('drawer/shadow/primary/color', modes) ?? 'rgba(12, 13, 16, 0.16)';
276
+ const shadowSecondaryOffsetX = getVariableByName('drawer/shadow/secondary/offsetX', modes) ?? 0;
277
+ const shadowSecondaryColor = getVariableByName('drawer/shadow/secondary/color', modes) ?? 'rgba(12, 13, 16, 0.12)';
278
+
279
+ // Cross-platform shadow style. Web supports stacking two shadows via
280
+ // boxShadow. iOS only supports a single native shadow per view, so we
281
+ // apply the more prominent (primary) one. Android uses elevation.
282
+ const shadowStyle = Platform.select({
283
+ web: {
284
+ boxShadow: `${shadowSecondaryOffsetX}px ${shadowPrimaryOffsetY}px ${shadowPrimaryBlur}px 0px ${shadowSecondaryColor}, ` + `${shadowPrimaryOffsetX}px ${shadowPrimaryOffsetY}px ${shadowPrimaryBlur}px 0px ${shadowPrimaryColor}`
285
+ },
286
+ ios: {
287
+ shadowColor: shadowPrimaryColor,
288
+ shadowOffset: {
289
+ width: shadowPrimaryOffsetX,
290
+ height: shadowPrimaryOffsetY
291
+ },
292
+ shadowOpacity: 1,
293
+ shadowRadius: shadowPrimaryBlur / 2
294
+ },
295
+ android: {
296
+ elevation: 16
297
+ },
298
+ default: {}
299
+ });
249
300
  const defaultAccessibilityLabel = accessibilityLabel || title || 'Drawer';
250
301
  return /*#__PURE__*/_jsx(GestureHandlerRootView, {
251
302
  style: [styles.host, style],
252
303
  pointerEvents: "box-none",
253
304
  children: /*#__PURE__*/_jsx(GestureDetector, {
254
305
  gesture: gesture,
255
- children: /*#__PURE__*/_jsxs(Animated.View, {
306
+ children: /*#__PURE__*/_jsx(Animated.View, {
256
307
  style: [styles.sheet, {
257
308
  // Constraint the height strictly to the expanded height
258
309
  // This ensures the ScrollView has a finite frame to scroll within
259
310
  height: expandedHeight,
260
311
  backgroundColor,
261
312
  borderTopLeftRadius: radius,
262
- borderTopRightRadius: radius,
263
- paddingLeft,
264
- paddingRight,
265
- paddingBottom,
266
- rowGap: drawerGap
267
- }, sheetStyle, animatedStyle],
313
+ borderTopRightRadius: radius
314
+ }, shadowStyle, sheetStyle, animatedStyle],
268
315
  accessible: true,
269
316
  ...(Platform.OS === 'web' ? {
270
317
  accessibilityRole: 'dialog'
271
318
  } : undefined),
272
319
  accessibilityLabel: undefined,
273
320
  accessibilityHint: accessibilityHint || 'Swipe up to expand, swipe down to collapse',
274
- children: [/*#__PURE__*/_jsx(View, {
275
- style: [styles.handleArea, !title && !header && {
276
- paddingBottom: 0
321
+ children: /*#__PURE__*/_jsxs(View, {
322
+ style: [styles.sheetInner, {
323
+ borderTopLeftRadius: radius,
324
+ borderTopRightRadius: radius,
325
+ paddingLeft,
326
+ paddingRight,
327
+ paddingBottom,
328
+ rowGap: drawerGap
277
329
  }],
278
- children: /*#__PURE__*/_jsx(View, {
330
+ children: [/*#__PURE__*/_jsx(View, {
331
+ style: [styles.handleArea, !title && !header && {
332
+ paddingBottom: 0
333
+ }],
334
+ children: /*#__PURE__*/_jsx(View, {
335
+ style: [{
336
+ backgroundColor: handleColor,
337
+ width: handleWidth,
338
+ height: handleHeight,
339
+ borderRadius: handleRadius
340
+ }]
341
+ })
342
+ }), header, title && /*#__PURE__*/_jsx(Text, {
279
343
  style: [{
280
- backgroundColor: handleColor,
281
- width: handleWidth,
282
- height: handleHeight,
283
- borderRadius: handleRadius
284
- }]
285
- })
286
- }), header, title && /*#__PURE__*/_jsx(Text, {
287
- style: [{
288
- color: titleColor,
289
- fontSize: titleSize,
290
- fontWeight: titleWeight,
291
- lineHeight: titleLineHeight,
292
- marginBottom: titlePaddingBottom
293
- }],
294
- children: title
295
- }), /*#__PURE__*/_jsx(AnimatedScrollView, {
296
- ref: scrollRef,
297
- style: [styles.content, contentStyle],
298
- contentContainerStyle: [{
299
- paddingBottom: paddingBottom + bottomInset,
300
- gap: drawerGap,
301
- flexDirection: 'column',
302
- alignItems: 'stretch'
303
- }, contentContainerStyle],
304
- showsVerticalScrollIndicator: showsVerticalScrollIndicator,
305
- animatedProps: animatedScrollProps,
306
- alwaysBounceVertical: false,
307
- overScrollMode: "always",
308
- onScroll: useAnimatedScrollHandler(event => {
309
- scrollY.value = event.contentOffset.y;
310
- }),
311
- scrollEventThrottle: 16,
312
- children: children
313
- })]
344
+ color: titleColor,
345
+ fontSize: titleSize,
346
+ fontWeight: titleWeight,
347
+ lineHeight: titleLineHeight,
348
+ marginBottom: titlePaddingBottom
349
+ }],
350
+ children: title
351
+ }), /*#__PURE__*/_jsx(AnimatedScrollView, {
352
+ ref: scrollRef,
353
+ style: [styles.content, contentStyle],
354
+ contentContainerStyle: [{
355
+ paddingBottom: paddingBottom + bottomInset,
356
+ gap: drawerGap,
357
+ flexDirection: 'column',
358
+ alignItems: 'stretch'
359
+ }, contentContainerStyle],
360
+ showsVerticalScrollIndicator: showsVerticalScrollIndicator,
361
+ animatedProps: animatedScrollProps,
362
+ alwaysBounceVertical: false,
363
+ overScrollMode: "always",
364
+ onScroll: useAnimatedScrollHandler(event => {
365
+ scrollY.value = event.contentOffset.y;
366
+ }),
367
+ scrollEventThrottle: 16,
368
+ children: children
369
+ })]
370
+ })
314
371
  })
315
372
  })
316
373
  });
@@ -328,7 +385,10 @@ const styles = StyleSheet.create({
328
385
  sheet: {
329
386
  width: '100%',
330
387
  position: 'absolute',
331
- top: 0,
388
+ top: 0
389
+ },
390
+ sheetInner: {
391
+ flex: 1,
332
392
  overflow: 'hidden'
333
393
  },
334
394
  handleArea: {
@@ -2,8 +2,11 @@
2
2
 
3
3
  import React, { useState, useMemo, useRef, useCallback } from 'react';
4
4
  import { View, Text, Pressable, Platform } from 'react-native';
5
+ import Animated, { FadeInUp, FadeOutUp, ReduceMotion, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
5
6
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
6
7
  import NavArrow from '../NavArrow/NavArrow';
8
+ import IconCapsule from '../IconCapsule/IconCapsule';
9
+ import ListItem from '../ListItem/ListItem';
7
10
  import { usePressableWebSupport } from '../../utils/web-platform-utils';
8
11
  import { EMPTY_MODES, cloneChildrenWithModes, flattenChildren } from '../../utils/react-utils';
9
12
 
@@ -32,76 +35,95 @@ const headerFocusStyle = {
32
35
  };
33
36
 
34
37
  // ---------------------------------------------------------------------------
35
- // Shared grid layout: measures the widest child once per item-count, then
36
- // renders uniform-width cells laid out with `justify-content: space-between`.
37
- // This preserves three visual invariants regardless of viewport width:
38
+ // Shared grid layout first-row-anchored sizing.
39
+ //
40
+ // We measure each cell of the *first row* via `onLayout`, take their max as
41
+ // the canonical cellWidth, then apply that explicit width to every cell in
42
+ // every row. Combined with `justify-content: space-between`, this preserves
43
+ // three visual invariants regardless of viewport width:
38
44
  // 1. The first cell hugs the container's left edge.
39
45
  // 2. The last cell hugs the container's right edge.
40
46
  // 3. Cells in row N column K align with cells in row N+1 column K
41
47
  // (uniform cell width across the whole grid).
42
- // Pure flex sizing (e.g. `flexBasis: 0; flexGrow: 1`) cannot satisfy (1) and
43
- // (2) on wide viewports — it distributes extra space inside each cell, which
44
- // makes the icon+label content drift toward each cell's center and produces
45
- // visible "dead" margins on the left and right of the grid.
46
48
  //
47
- // To avoid the blank-flash that the previous implementation suffered from
48
- // (it hid the grid with `opacity: 0` until measurement completed, and reset
49
- // every measurement when the item count changed):
50
- // * Cells render at their *natural* widths during measurement instead of
51
- // being hidden. With `space-between`, the first/last cells already hug
52
- // the edges; only the column alignment can be off by a few pixels for
53
- // the single frame between mount and the onLayout callback.
54
- // * On item-count change (e.g. expand / collapse), we clear the per-cell
55
- // samples but keep the rendered layout visible — we never blank out.
56
- // * No 500ms safety timeout is needed because the grid is visible from the
57
- // first frame.
49
+ // Why first-row-anchored?
50
+ //
51
+ // The first row is *always present* (collapsed and expanded both render it),
52
+ // so the measurement happens exactly once on first mount and stays valid for
53
+ // the lifetime of the SlotGrid the cellWidth is *stable across toggles*.
54
+ // New cells in row 2+ mount when the user expands, and by that time
55
+ // `cellWidth` is already cached, so their `Animated.View` `entering` cascade
56
+ // is never interrupted by a re-measurement-driven re-render.
57
+ //
58
+ // Why not container-width math (e.g. `(containerWidth - gaps) / columns`)?
59
+ // That makes every cell wide enough to fill its share of the row. On wide
60
+ // viewports each cell becomes much wider than its content (icon + label),
61
+ // the content centers inside its oversized cell, and the visible result is
62
+ // a "dead margin" of empty space on the left and right of the grid. Sizing
63
+ // cells to natural content + `space-between` instead pushes the first cell
64
+ // flush left and the last cell flush right, distributing leftover space as
65
+ // the inter-cell gap.
66
+ //
67
+ // Why not measure every cell?
68
+ // The original implementation did, and `max()` could change when the item
69
+ // count changed (collapsed picks one max, expanded another), producing a
70
+ // visible width jump on toggle. The per-cell remeasurement also forced a
71
+ // re-render in the same React batch as the `entering` animation mounting,
72
+ // which Reanimated treats as a cancellation signal — cells visibly didn't
73
+ // animate. Anchoring to the first row eliminates both costs.
74
+ //
75
+ // First-frame behavior is preserved (no blank-flash): until the first-row
76
+ // `onLayout` fires, cells render at their natural width with `space-between`
77
+ // already laying them out correctly; the only thing that changes after
78
+ // measurement is that subsequent rows snap to the same cellWidth so columns
79
+ // align.
58
80
  // ---------------------------------------------------------------------------
59
81
  const SLOT_GRID_MAX_COLUMNS = 4;
60
- const slotGridRowStyle = {
82
+
83
+ // Beyond this many "extra" cells, additional cells reuse the cap delay so very
84
+ // large grids (16, 32, …) still feel snappy instead of cascading for >1s.
85
+ const SLOT_GRID_STAGGER_CAP = 8;
86
+ const SLOT_GRID_ENTER_STAGGER_MS = 35;
87
+ const SLOT_GRID_EXIT_STAGGER_MS = 20;
88
+ const SLOT_GRID_EXIT_DURATION_MS = 160;
89
+ const slotGridRowFlowStyle = {
61
90
  flexDirection: 'row',
62
91
  justifyContent: 'space-between'
63
92
  };
64
93
  const SlotGrid = /*#__PURE__*/React.memo(function SlotGrid({
65
94
  items,
66
95
  gap,
67
- maxColumns = SLOT_GRID_MAX_COLUMNS
96
+ maxColumns = SLOT_GRID_MAX_COLUMNS,
97
+ animateExtrasFromIndex,
98
+ animateContainerLayout
68
99
  }) {
69
100
  const totalItems = items.length;
70
- const [maxItemWidth, setMaxItemWidth] = useState(null);
71
- // Tracks the item-count that `maxItemWidth` corresponds to. When the
72
- // current `totalItems` differs, the existing measurement is considered
73
- // stale and cells fall back to natural widths until remeasurement.
74
- const [measuredForCount, setMeasuredForCount] = useState(0);
75
- const itemWidthsRef = useRef(new Map());
101
+ const columns = Math.min(maxColumns, totalItems || 1);
102
+ // Number of cells in the first row. Capped by `columns` (a fully-filled row)
103
+ // and by `totalItems` (e.g. a 1-item grid has a 1-cell first row).
104
+ const firstRowSize = Math.min(columns, totalItems);
76
105
 
77
- // Synchronously invalidate per-cell samples when the item count changes
78
- // (e.g. show more / less). We deliberately do NOT touch `maxItemWidth` or
79
- // `measuredForCount` state here flipping them would force an extra render
80
- // pass; instead we let the count mismatch (`measuredForCount !== totalItems`)
81
- // gate the use of the stale value, and the next onLayout cycle will publish
82
- // a fresh `maxItemWidth` for the new count.
83
- const prevTotalRef = useRef(totalItems);
84
- if (prevTotalRef.current !== totalItems) {
85
- prevTotalRef.current = totalItems;
86
- itemWidthsRef.current = new Map();
87
- }
88
- const handleItemLayout = useCallback((index, width) => {
89
- const widths = itemWidthsRef.current;
106
+ // First-row width measurement. Only cells whose `itemIndex < firstRowSize`
107
+ // get an `onLayout` callback. Once we have a width sample for each first-row
108
+ // cell, we publish the max and the callbacks become inert — no further
109
+ // measurement happens for the rest of the SlotGrid's lifetime, so toggles
110
+ // never trigger a re-measurement-driven re-render.
111
+ const [firstRowMaxWidth, setFirstRowMaxWidth] = useState(null);
112
+ const firstRowWidthsRef = useRef(new Map());
113
+ const handleFirstRowItemLayout = useCallback((index, width) => {
114
+ const widths = firstRowWidthsRef.current;
90
115
  const previous = widths.get(index);
91
116
  if (previous !== undefined && Math.abs(previous - width) < 0.5) return;
92
117
  widths.set(index, width);
93
- if (widths.size >= totalItems && totalItems > 0) {
118
+ if (widths.size >= firstRowSize && firstRowSize > 0) {
94
119
  let newMax = 0;
95
120
  for (const w of widths.values()) {
96
121
  if (w > newMax) newMax = w;
97
122
  }
98
- setMaxItemWidth(prev => prev !== null && Math.abs(prev - newMax) < 0.5 ? prev : newMax);
99
- setMeasuredForCount(totalItems);
123
+ setFirstRowMaxWidth(prev => prev !== null && Math.abs(prev - newMax) < 0.5 ? prev : newMax);
100
124
  }
101
- }, [totalItems]);
102
- const hasFreshMeasurement = maxItemWidth !== null && measuredForCount === totalItems;
103
- const cellWidth = hasFreshMeasurement ? maxItemWidth : undefined;
104
- const columns = Math.min(maxColumns, totalItems || 1);
125
+ }, [firstRowSize]);
126
+ const cellWidth = firstRowMaxWidth;
105
127
  const rows = [];
106
128
  for (let i = 0; i < items.length; i += columns) {
107
129
  rows.push(items.slice(i, i + columns));
@@ -109,34 +131,131 @@ const SlotGrid = /*#__PURE__*/React.memo(function SlotGrid({
109
131
  const containerStyle = useMemo(() => ({
110
132
  gap
111
133
  }), [gap]);
112
- const measuredCellStyle = useMemo(() => cellWidth !== undefined ? {
134
+ const cellStyle = useMemo(() => cellWidth !== null ? {
113
135
  width: cellWidth
114
136
  } : undefined, [cellWidth]);
115
- return /*#__PURE__*/_jsx(View, {
116
- style: containerStyle,
137
+
138
+ // `space-between` distributes any leftover row space as inter-cell gap, so
139
+ // the first cell is flush-left and the last cell is flush-right regardless
140
+ // of whether cellWidth has been measured yet. Identical layout strategy
141
+ // before and after measurement — no first-frame layout shift.
142
+ const rowStyle = slotGridRowFlowStyle;
143
+ const animationsEnabled = animateExtrasFromIndex !== undefined;
144
+ // Resolve the threshold once. When undefined we treat it as
145
+ // Number.POSITIVE_INFINITY so the per-cell branch always picks the plain
146
+ // `<View>` path.
147
+ const extrasThreshold = animationsEnabled ? animateExtrasFromIndex : Number.POSITIVE_INFINITY;
148
+ // Total count of "extra" cells currently rendered. Used to compute the
149
+ // reverse-stagger delay for the exiting animation so that the last cell
150
+ // leaves first.
151
+ const extrasCount = animationsEnabled ? Math.max(0, totalItems - extrasThreshold) : 0;
152
+ const useAnimatedContainer = animateContainerLayout === true;
153
+
154
+ // Explicit-height clip animation:
155
+ //
156
+ // Reanimated's `LinearTransition` interpolates the container's bounds, and
157
+ // in practice that interpolation drags on the cells inside (they momentarily
158
+ // appear squashed because the parent is shorter than its natural content
159
+ // for the duration of the animation). To keep cells at their *natural size
160
+ // throughout*, we instead:
161
+ // 1. Render the rows inside an inner `<View>` that sizes to its content
162
+ // naturally — cells are never squeezed, never resized.
163
+ // 2. Wrap that inner view in an `Animated.View` with `overflow: 'hidden'`
164
+ // and an explicit `height` driven by a shared value.
165
+ // 3. The inner view reports its natural height via `onLayout`. The first
166
+ // measurement snaps the shared value (no first-mount animation). Every
167
+ // subsequent change (e.g. expand/collapse adds or removes rows) springs
168
+ // the shared value to the new natural height.
169
+ //
170
+ // Visually: the container reveals/conceals content like a curtain, and the
171
+ // cells never deform.
172
+ const animatedHeight = useSharedValue(-1);
173
+ const isFirstHeightLayoutRef = useRef(true);
174
+ const handleContentLayout = useCallback(e => {
175
+ const h = e.nativeEvent.layout.height;
176
+ if (h <= 0) return;
177
+ if (isFirstHeightLayoutRef.current) {
178
+ isFirstHeightLayoutRef.current = false;
179
+ animatedHeight.value = h;
180
+ return;
181
+ }
182
+ animatedHeight.value = withSpring(h, {
183
+ damping: 22,
184
+ stiffness: 180,
185
+ reduceMotion: ReduceMotion.System
186
+ });
187
+ }, [animatedHeight]);
188
+ const animatedHeightStyle = useAnimatedStyle(() => animatedHeight.value < 0 ? {} : {
189
+ height: animatedHeight.value,
190
+ overflow: 'hidden'
191
+ });
192
+ const rowsChildren = /*#__PURE__*/_jsx(_Fragment, {
117
193
  children: rows.map((row, rowIndex) => {
118
194
  const spacersNeeded = row.length < columns ? columns - row.length : 0;
119
195
  return /*#__PURE__*/_jsxs(View, {
120
- style: slotGridRowStyle,
196
+ style: rowStyle,
121
197
  children: [row.map((child, colIndex) => {
122
198
  const itemIndex = rowIndex * columns + colIndex;
199
+ // Only first-row cells participate in measurement, and only
200
+ // until firstRowMaxWidth has been published. After that the
201
+ // onLayout becomes a no-op (we elide it).
202
+ const onLayoutHandler = firstRowMaxWidth === null && itemIndex < firstRowSize ? e => handleFirstRowItemLayout(itemIndex, e.nativeEvent.layout.width) : undefined;
203
+ if (itemIndex >= extrasThreshold) {
204
+ const extraOrdinal = itemIndex - extrasThreshold;
205
+ const enterStaggerSteps = Math.min(extraOrdinal, SLOT_GRID_STAGGER_CAP);
206
+ const reverseOrdinal = Math.max(0, extrasCount - 1 - extraOrdinal);
207
+ const exitStaggerSteps = Math.min(reverseOrdinal, SLOT_GRID_STAGGER_CAP);
208
+ const entering = FadeInUp.springify().damping(18).delay(enterStaggerSteps * SLOT_GRID_ENTER_STAGGER_MS).reduceMotion(ReduceMotion.System);
209
+ const exiting = FadeOutUp.duration(SLOT_GRID_EXIT_DURATION_MS).delay(exitStaggerSteps * SLOT_GRID_EXIT_STAGGER_MS).reduceMotion(ReduceMotion.System);
210
+ return /*#__PURE__*/_jsx(Animated.View, {
211
+ entering: entering,
212
+ exiting: exiting,
213
+ ...(onLayoutHandler ? {
214
+ onLayout: onLayoutHandler
215
+ } : null),
216
+ style: cellStyle,
217
+ children: child
218
+ }, itemIndex);
219
+ }
123
220
  return /*#__PURE__*/_jsx(View, {
124
- onLayout: hasFreshMeasurement ? undefined : e => handleItemLayout(itemIndex, e.nativeEvent.layout.width),
125
- style: measuredCellStyle,
221
+ ...(onLayoutHandler ? {
222
+ onLayout: onLayoutHandler
223
+ } : null),
224
+ style: cellStyle,
126
225
  children: child
127
226
  }, itemIndex);
128
- }), hasFreshMeasurement && spacersNeeded > 0 && Array.from({
227
+ }), cellWidth !== null && spacersNeeded > 0 && Array.from({
129
228
  length: spacersNeeded
130
229
  }, (_, i) => /*#__PURE__*/_jsx(View, {
131
- style: measuredCellStyle
230
+ style: cellStyle
132
231
  }, `spacer-${i}`))]
133
232
  }, rowIndex);
134
233
  })
135
234
  });
235
+ if (useAnimatedContainer) {
236
+ // Outer Animated.View clips and animates height. Inner View holds the
237
+ // rows at natural size and reports its natural height for the spring
238
+ // target. Cell-width measurement happens on the cells themselves
239
+ // (first-row only) — no container-level onLayout is needed.
240
+ return /*#__PURE__*/_jsx(Animated.View, {
241
+ style: animatedHeightStyle,
242
+ children: /*#__PURE__*/_jsx(View, {
243
+ style: containerStyle,
244
+ onLayout: handleContentLayout,
245
+ children: rowsChildren
246
+ })
247
+ });
248
+ }
249
+ return /*#__PURE__*/_jsx(View, {
250
+ style: containerStyle,
251
+ children: rowsChildren
252
+ });
136
253
  }, slotGridPropsAreEqual);
137
254
  function slotGridPropsAreEqual(prev, next) {
138
255
  if (prev.gap !== next.gap) return false;
139
256
  if ((prev.maxColumns ?? SLOT_GRID_MAX_COLUMNS) !== (next.maxColumns ?? SLOT_GRID_MAX_COLUMNS)) return false;
257
+ if (prev.animateExtrasFromIndex !== next.animateExtrasFromIndex) return false;
258
+ if (prev.animateContainerLayout !== next.animateContainerLayout) return false;
140
259
  if (prev.items === next.items) return true;
141
260
  if (prev.items.length !== next.items.length) return false;
142
261
  for (let i = 0; i < prev.items.length; i++) {
@@ -423,9 +542,17 @@ function SectionBento({
423
542
  // Same rationale as Section: accepted on the type but unused internally.
424
543
  accessibilityLabel: _accessibilityLabel,
425
544
  accessibilityHint,
545
+ collapsedCount = SLOT_GRID_MAX_COLUMNS,
546
+ defaultExpanded = false,
547
+ expanded: controlledExpanded,
548
+ onExpandedChange,
549
+ toggleMoreLabel = 'More',
550
+ toggleLessLabel = 'Less',
551
+ toggleMoreIcon = 'ic_chevron_down',
552
+ toggleLessIcon = 'ic_chevron_up',
553
+ renderToggle,
426
554
  ...rest
427
555
  }) {
428
- // Resolve section container tokens
429
556
  const backgroundColor = getVariableByName('section/background/color', modes) || '#ffffff';
430
557
  const gap = getVariableByName('section/gap', modes) || 12;
431
558
  const paddingHorizontal = getVariableByName('section/padding/horizontal', modes) || 12;
@@ -440,6 +567,51 @@ function SectionBento({
440
567
  }), [backgroundColor, paddingHorizontal, paddingVertical, radius, gap]);
441
568
  const processedNavSlot = useMemo(() => navSlot ? cloneChildrenWithModes(flattenChildren(navSlot), modes) : null, [navSlot, modes]);
442
569
  const processedUpiSlot = useMemo(() => upiSlot ? cloneChildrenWithModes(flattenChildren(upiSlot), modes) : null, [upiSlot, modes]);
570
+
571
+ // `canExpand` is true iff there are strictly more real items than fit into
572
+ // the collapsed grid. When `allRealItems.length === collapsedCount` we just
573
+ // render them all with no toggle — identical to the pre-feature behavior.
574
+ const allRealItems = useMemo(() => processedNavSlot ?? [], [processedNavSlot]);
575
+ const canExpand = allRealItems.length > collapsedCount;
576
+ const isControlled = controlledExpanded !== undefined;
577
+ const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
578
+ const expanded = isControlled ? controlledExpanded : internalExpanded;
579
+
580
+ // Mirror onExpandedChange in a ref so `toggle` can stay referentially stable.
581
+ const onExpandedChangeRef = useRef(onExpandedChange);
582
+ onExpandedChangeRef.current = onExpandedChange;
583
+ const isControlledRef = useRef(isControlled);
584
+ isControlledRef.current = isControlled;
585
+ const expandedRef = useRef(expanded);
586
+ expandedRef.current = expanded;
587
+ const toggle = useCallback(() => {
588
+ const next = !expandedRef.current;
589
+ if (!isControlledRef.current) {
590
+ setInternalExpanded(next);
591
+ }
592
+ onExpandedChangeRef.current?.(next);
593
+ }, []);
594
+ const navGridItems = useMemo(() => {
595
+ if (!canExpand) {
596
+ return allRealItems;
597
+ }
598
+ // Leave the last collapsed slot for the toggle cell.
599
+ const visibleRealItems = expanded ? allRealItems : allRealItems.slice(0, collapsedCount - 1);
600
+ const toggleNode = renderToggle ? renderToggle({
601
+ expanded,
602
+ toggle
603
+ }) : /*#__PURE__*/_jsx(DefaultBentoToggle, {
604
+ expanded: expanded,
605
+ onPress: toggle,
606
+ modes: modes,
607
+ moreLabel: toggleMoreLabel,
608
+ lessLabel: toggleLessLabel,
609
+ moreIcon: toggleMoreIcon,
610
+ lessIcon: toggleLessIcon,
611
+ extraCount: allRealItems.length - (collapsedCount - 1)
612
+ });
613
+ return [...visibleRealItems, toggleNode];
614
+ }, [canExpand, allRealItems, expanded, collapsedCount, renderToggle, toggle, modes, toggleMoreLabel, toggleLessLabel, toggleMoreIcon, toggleLessIcon]);
443
615
  return /*#__PURE__*/_jsxs(View, {
444
616
  style: [containerStyle, style],
445
617
  ...(Platform.OS === 'web' ? {
@@ -448,9 +620,13 @@ function SectionBento({
448
620
  accessibilityLabel: undefined,
449
621
  accessibilityHint: accessibilityHint,
450
622
  ...rest,
451
- children: [processedNavSlot && /*#__PURE__*/_jsx(SlotGrid, {
452
- items: processedNavSlot,
453
- gap: gap
623
+ children: [navGridItems.length > 0 && /*#__PURE__*/_jsx(SlotGrid, {
624
+ items: navGridItems,
625
+ gap: gap,
626
+ ...(canExpand ? {
627
+ animateExtrasFromIndex: collapsedCount,
628
+ animateContainerLayout: true
629
+ } : null)
454
630
  }), processedUpiSlot && /*#__PURE__*/_jsx(View, {
455
631
  style: sectionBentoUpiRowStyle,
456
632
  children: processedUpiSlot
@@ -458,6 +634,52 @@ function SectionBento({
458
634
  });
459
635
  }
460
636
 
637
+ // ---------------------------------------------------------------------------
638
+ // DefaultBentoToggle — internal toggle cell rendered by `SectionBento` when no
639
+ // `renderToggle` prop is provided. Uses a vertical `ListItem` so the cell
640
+ // matches the visual rhythm of the surrounding nav items in every mode.
641
+ //
642
+ // Two icons (`ic_chevron_down` / `ic_chevron_up`) are used instead of a
643
+ // rotating single icon, because the toggle cell is reconciled by position
644
+ // (collapsed: end of row 1; expanded: end of last row), so any persistent
645
+ // shared-value-driven rotation would lose its anchor across toggles.
646
+ // ---------------------------------------------------------------------------
647
+
648
+ function DefaultBentoToggle({
649
+ expanded,
650
+ onPress,
651
+ modes,
652
+ moreLabel,
653
+ lessLabel,
654
+ moreIcon,
655
+ lessIcon,
656
+ extraCount
657
+ }) {
658
+ const label = expanded ? lessLabel : moreLabel;
659
+ const iconName = expanded ? lessIcon : moreIcon;
660
+ const accessibilityState = useMemo(() => ({
661
+ expanded
662
+ }), [expanded]);
663
+ const webAccessibilityProps = useMemo(() => ({
664
+ ariaExpanded: expanded
665
+ }), [expanded]);
666
+ const accessibilityHint = expanded ? `Hides ${extraCount} additional ${extraCount === 1 ? 'action' : 'actions'}` : `Shows ${extraCount} additional ${extraCount === 1 ? 'action' : 'actions'}`;
667
+ return /*#__PURE__*/_jsx(ListItem, {
668
+ layout: "Vertical",
669
+ supportText: label,
670
+ leading: /*#__PURE__*/_jsx(IconCapsule, {
671
+ iconName: iconName,
672
+ modes: modes
673
+ }),
674
+ modes: modes,
675
+ onPress: onPress,
676
+ accessibilityLabel: label,
677
+ accessibilityHint: accessibilityHint,
678
+ accessibilityState: accessibilityState,
679
+ webAccessibilityProps: webAccessibilityProps
680
+ });
681
+ }
682
+
461
683
  // Attach Bento as a property of Section using namespace pattern
462
684
  Section.Bento = SectionBento;
463
685
  export default Section;