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.
- package/CHANGELOG.md +23 -0
- package/lib/commonjs/components/Drawer/Drawer.js +107 -47
- package/lib/commonjs/components/Section/Section.js +280 -58
- package/lib/commonjs/icons/registry.js +1 -1
- package/lib/module/components/Drawer/Drawer.js +107 -47
- package/lib/module/components/Section/Section.js +280 -58
- package/lib/module/icons/registry.js +1 -1
- package/lib/typescript/src/components/Section/Section.d.ts +42 -1
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/Drawer/Drawer.tsx +122 -57
- package/src/components/Section/Section.tsx +411 -71
- package/src/icons/registry.ts +1 -1
|
@@ -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
|
|
26
|
-
//
|
|
27
|
-
//
|
|
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
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
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
|
|
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
|
-
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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 =
|
|
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 >=
|
|
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
|
-
|
|
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
|
-
[
|
|
149
|
+
[firstRowSize]
|
|
106
150
|
)
|
|
107
151
|
|
|
108
|
-
const
|
|
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
|
|
121
|
-
() => (cellWidth !==
|
|
160
|
+
const cellStyle = useMemo<ViewStyle | undefined>(
|
|
161
|
+
() => (cellWidth !== null ? { width: cellWidth } : undefined),
|
|
122
162
|
[cellWidth]
|
|
123
163
|
)
|
|
124
164
|
|
|
125
|
-
|
|
126
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
-
{
|
|
294
|
+
{cellWidth !== null &&
|
|
152
295
|
spacersNeeded > 0 &&
|
|
153
296
|
Array.from({ length: spacersNeeded }, (_, i) => (
|
|
154
|
-
<View key={`spacer-${i}`} style={
|
|
297
|
+
<View key={`spacer-${i}`} style={cellStyle} />
|
|
155
298
|
))}
|
|
156
299
|
</View>
|
|
157
300
|
)
|
|
158
301
|
})}
|
|
159
|
-
|
|
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
|
-
{
|
|
843
|
+
{navGridItems.length > 0 && (
|
|
568
844
|
<SlotGrid
|
|
569
|
-
items={
|
|
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
|
|
package/src/icons/registry.ts
CHANGED