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
|
@@ -126,7 +126,17 @@ function Drawer({
|
|
|
126
126
|
const updateMode = useCallback(newMode => {
|
|
127
127
|
setMode(newMode);
|
|
128
128
|
}, []);
|
|
129
|
-
|
|
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__*/
|
|
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
|
-
|
|
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:
|
|
275
|
-
style: [styles.
|
|
276
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
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
|
-
|
|
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
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
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
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
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 >=
|
|
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
|
-
|
|
99
|
-
setMeasuredForCount(totalItems);
|
|
123
|
+
setFirstRowMaxWidth(prev => prev !== null && Math.abs(prev - newMax) < 0.5 ? prev : newMax);
|
|
100
124
|
}
|
|
101
|
-
}, [
|
|
102
|
-
const
|
|
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
|
|
134
|
+
const cellStyle = useMemo(() => cellWidth !== null ? {
|
|
113
135
|
width: cellWidth
|
|
114
136
|
} : undefined, [cellWidth]);
|
|
115
|
-
|
|
116
|
-
|
|
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:
|
|
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
|
-
|
|
125
|
-
|
|
221
|
+
...(onLayoutHandler ? {
|
|
222
|
+
onLayout: onLayoutHandler
|
|
223
|
+
} : null),
|
|
224
|
+
style: cellStyle,
|
|
126
225
|
children: child
|
|
127
226
|
}, itemIndex);
|
|
128
|
-
}),
|
|
227
|
+
}), cellWidth !== null && spacersNeeded > 0 && Array.from({
|
|
129
228
|
length: spacersNeeded
|
|
130
229
|
}, (_, i) => /*#__PURE__*/_jsx(View, {
|
|
131
|
-
style:
|
|
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: [
|
|
452
|
-
items:
|
|
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;
|