@lunar-kit/core 0.1.0
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/dist/index.d.ts +5 -0
- package/dist/index.js +14 -0
- package/package.json +31 -0
- package/src/components/ui/accordion.tsx +334 -0
- package/src/components/ui/avatar.tsx +326 -0
- package/src/components/ui/badge.tsx +84 -0
- package/src/components/ui/banner.tsx +151 -0
- package/src/components/ui/bottom-sheet.tsx +579 -0
- package/src/components/ui/button.tsx +142 -0
- package/src/components/ui/calendar.tsx +502 -0
- package/src/components/ui/card.tsx +163 -0
- package/src/components/ui/checkbox.tsx +129 -0
- package/src/components/ui/date-picker.tsx +190 -0
- package/src/components/ui/date-range-picker.tsx +262 -0
- package/src/components/ui/dialog.tsx +204 -0
- package/src/components/ui/form.tsx +139 -0
- package/src/components/ui/input.tsx +107 -0
- package/src/components/ui/radio-group.tsx +123 -0
- package/src/components/ui/radio.tsx +109 -0
- package/src/components/ui/select-sheet.tsx +814 -0
- package/src/components/ui/select.tsx +547 -0
- package/src/components/ui/tabs.tsx +254 -0
- package/src/components/ui/text.tsx +229 -0
- package/src/components/ui/textarea.tsx +77 -0
- package/src/components/v0/accordion.tsx +199 -0
- package/src/components/v1/accordion.tsx +234 -0
- package/src/components/v1/avatar.tsx +259 -0
- package/src/components/v1/bottom-sheet.tsx +1090 -0
- package/src/components/v1/button.tsx +61 -0
- package/src/components/v1/calendar.tsx +498 -0
- package/src/components/v1/card.tsx +86 -0
- package/src/components/v1/checkbox.tsx +46 -0
- package/src/components/v1/date-picker.tsx +135 -0
- package/src/components/v1/date-range-picker.tsx +218 -0
- package/src/components/v1/dialog.tsx +211 -0
- package/src/components/v1/radio-group.tsx +76 -0
- package/src/components/v1/select.tsx +217 -0
- package/src/components/v1/tabs.tsx +253 -0
- package/src/registry/ui/accordion.json +30 -0
- package/src/registry/ui/avatar.json +41 -0
- package/src/registry/ui/badge.json +26 -0
- package/src/registry/ui/banner.json +27 -0
- package/src/registry/ui/bottom-sheet.json +29 -0
- package/src/registry/ui/button.json +24 -0
- package/src/registry/ui/calendar.json +29 -0
- package/src/registry/ui/card.json +25 -0
- package/src/registry/ui/checkbox.json +25 -0
- package/src/registry/ui/date-picker.json +30 -0
- package/src/registry/ui/date-range-picker.json +33 -0
- package/src/registry/ui/dialog.json +25 -0
- package/src/registry/ui/form.json +27 -0
- package/src/registry/ui/input.json +22 -0
- package/src/registry/ui/radio-group.json +26 -0
- package/src/registry/ui/radio.json +23 -0
- package/src/registry/ui/select-sheet.json +29 -0
- package/src/registry/ui/select.json +26 -0
- package/src/registry/ui/tabs.json +29 -0
- package/src/registry/ui/text.json +22 -0
- package/src/registry/ui/textarea.json +24 -0
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
// components/ui/bottom-sheet.tsx
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import {
|
|
4
|
+
Modal,
|
|
5
|
+
View,
|
|
6
|
+
Text,
|
|
7
|
+
Pressable,
|
|
8
|
+
Dimensions,
|
|
9
|
+
FlatList,
|
|
10
|
+
ListRenderItem,
|
|
11
|
+
ScrollView,
|
|
12
|
+
} from 'react-native';
|
|
13
|
+
import Animated, {
|
|
14
|
+
useAnimatedStyle,
|
|
15
|
+
useSharedValue,
|
|
16
|
+
withSpring,
|
|
17
|
+
runOnJS,
|
|
18
|
+
clamp,
|
|
19
|
+
interpolate,
|
|
20
|
+
Extrapolation,
|
|
21
|
+
} from 'react-native-reanimated';
|
|
22
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
23
|
+
import { cn } from '@/lib/utils';
|
|
24
|
+
|
|
25
|
+
const SCREEN_HEIGHT = Dimensions.get('window').height;
|
|
26
|
+
const VELOCITY_THRESHOLD = 300;
|
|
27
|
+
const SPRING_CONFIG = {
|
|
28
|
+
damping: 50,
|
|
29
|
+
stiffness: 400,
|
|
30
|
+
mass: 0.5,
|
|
31
|
+
overshootClamping: false,
|
|
32
|
+
restSpeedThreshold: 0.01,
|
|
33
|
+
restDisplacementThreshold: 0.01,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
interface BottomSheetProps {
|
|
37
|
+
open?: boolean;
|
|
38
|
+
onOpenChange?: (open: boolean) => void;
|
|
39
|
+
children: React.ReactNode;
|
|
40
|
+
snapPoints?: string[];
|
|
41
|
+
defaultSnapPoint?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface BottomSheetContentProps {
|
|
45
|
+
children: React.ReactNode;
|
|
46
|
+
className?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface BottomSheetHeaderProps {
|
|
50
|
+
children: React.ReactNode;
|
|
51
|
+
className?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface BottomSheetTitleProps {
|
|
55
|
+
children: React.ReactNode;
|
|
56
|
+
className?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface BottomSheetDescriptionProps {
|
|
60
|
+
children: React.ReactNode;
|
|
61
|
+
className?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface BottomSheetFooterProps {
|
|
65
|
+
children: React.ReactNode;
|
|
66
|
+
className?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface BottomSheetBodyProps {
|
|
70
|
+
children: React.ReactNode;
|
|
71
|
+
className?: string;
|
|
72
|
+
scrollable?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface BottomSheetListProps<T> {
|
|
76
|
+
data: T[];
|
|
77
|
+
renderItem?: ListRenderItem<T>;
|
|
78
|
+
keyExtractor?: (item: T, index: number) => string;
|
|
79
|
+
variant?: 'list' | 'select' | 'multiple';
|
|
80
|
+
onSelect?: (selected: T | T[] | null) => void;
|
|
81
|
+
selectedValue?: any;
|
|
82
|
+
selectedValues?: any[];
|
|
83
|
+
getItemValue?: (item: T) => any;
|
|
84
|
+
className?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface BottomSheetListItemProps {
|
|
88
|
+
children: React.ReactNode;
|
|
89
|
+
className?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const BottomSheetContext = React.createContext<{
|
|
93
|
+
open: boolean;
|
|
94
|
+
onOpenChange: (open: boolean) => void;
|
|
95
|
+
snapPoints: string[];
|
|
96
|
+
currentSnapPoint: number;
|
|
97
|
+
} | null>(null);
|
|
98
|
+
|
|
99
|
+
function useBottomSheet() {
|
|
100
|
+
const context = React.useContext(BottomSheetContext);
|
|
101
|
+
if (!context) {
|
|
102
|
+
throw new Error('BottomSheet components must be used within BottomSheet');
|
|
103
|
+
}
|
|
104
|
+
return context;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function BottomSheet({
|
|
108
|
+
open: controlledOpen,
|
|
109
|
+
onOpenChange: controlledOnOpenChange,
|
|
110
|
+
children,
|
|
111
|
+
snapPoints = ['50%'],
|
|
112
|
+
defaultSnapPoint = 0,
|
|
113
|
+
}: BottomSheetProps) {
|
|
114
|
+
const [internalOpen, setInternalOpen] = React.useState(false);
|
|
115
|
+
|
|
116
|
+
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
|
117
|
+
const onOpenChange = controlledOnOpenChange || setInternalOpen;
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<BottomSheetContext.Provider
|
|
121
|
+
value={{ open, onOpenChange, snapPoints, currentSnapPoint: defaultSnapPoint }}
|
|
122
|
+
>
|
|
123
|
+
{children}
|
|
124
|
+
</BottomSheetContext.Provider>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function BottomSheetTrigger({ children }: { children: React.ReactNode }) {
|
|
129
|
+
const { onOpenChange } = useBottomSheet();
|
|
130
|
+
|
|
131
|
+
if (React.isValidElement(children)) {
|
|
132
|
+
return React.cloneElement(children as React.ReactElement<any>, {
|
|
133
|
+
onPress: () => onOpenChange(true),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return <Pressable onPress={() => onOpenChange(true)}>{children}</Pressable>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function BottomSheetContent({ children, className }: BottomSheetContentProps) {
|
|
141
|
+
const { open, onOpenChange, snapPoints, currentSnapPoint: defaultSnapPoint } = useBottomSheet();
|
|
142
|
+
|
|
143
|
+
const [visible, setVisible] = React.useState(false);
|
|
144
|
+
const [currentSnapIndex, setCurrentSnapIndex] = React.useState(defaultSnapPoint);
|
|
145
|
+
|
|
146
|
+
const snapHeights = React.useMemo(() => {
|
|
147
|
+
return snapPoints.map((point) => {
|
|
148
|
+
const percentage = parseInt(point) / 100;
|
|
149
|
+
return SCREEN_HEIGHT * percentage;
|
|
150
|
+
});
|
|
151
|
+
}, [snapPoints]);
|
|
152
|
+
|
|
153
|
+
const animatedPosition = useSharedValue(0);
|
|
154
|
+
const backdropOpacity = useSharedValue(0);
|
|
155
|
+
|
|
156
|
+
const handleClose = React.useCallback(() => {
|
|
157
|
+
onOpenChange(false);
|
|
158
|
+
}, [onOpenChange]);
|
|
159
|
+
|
|
160
|
+
const handleSnapChange = React.useCallback((newIndex: number) => {
|
|
161
|
+
setCurrentSnapIndex(newIndex);
|
|
162
|
+
}, []);
|
|
163
|
+
|
|
164
|
+
// Find closest snap point
|
|
165
|
+
const findClosestSnapPoint = (position: number, velocity: number) => {
|
|
166
|
+
'worklet';
|
|
167
|
+
|
|
168
|
+
const currentHeight = snapHeights[currentSnapIndex];
|
|
169
|
+
|
|
170
|
+
// Velocity-based prediction
|
|
171
|
+
if (Math.abs(velocity) > VELOCITY_THRESHOLD) {
|
|
172
|
+
if (velocity < 0 && currentSnapIndex < snapHeights.length - 1) {
|
|
173
|
+
return currentSnapIndex + 1;
|
|
174
|
+
} else if (velocity > 0 && currentSnapIndex > 0) {
|
|
175
|
+
return currentSnapIndex - 1;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Position-based snapping
|
|
180
|
+
const threshold = currentHeight * 0.3; // 30% threshold
|
|
181
|
+
|
|
182
|
+
if (position < -threshold && currentSnapIndex < snapHeights.length - 1) {
|
|
183
|
+
return currentSnapIndex + 1;
|
|
184
|
+
} else if (position > threshold && currentSnapIndex > 0) {
|
|
185
|
+
return currentSnapIndex - 1;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return currentSnapIndex;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const panGesture = Gesture.Pan()
|
|
192
|
+
.onUpdate((event) => {
|
|
193
|
+
const dy = event.translationY;
|
|
194
|
+
|
|
195
|
+
// Clamp with rubberband effect
|
|
196
|
+
const currentHeight = snapHeights[currentSnapIndex];
|
|
197
|
+
const maxHeight = snapHeights[snapHeights.length - 1];
|
|
198
|
+
|
|
199
|
+
if (dy < 0) {
|
|
200
|
+
// Dragging up
|
|
201
|
+
if (currentSnapIndex < snapHeights.length - 1) {
|
|
202
|
+
// Allow smooth transition to next snap point
|
|
203
|
+
const maxDrag = -(maxHeight - currentHeight);
|
|
204
|
+
animatedPosition.value = clamp(dy, maxDrag, 0);
|
|
205
|
+
} else {
|
|
206
|
+
// Rubberband at top
|
|
207
|
+
animatedPosition.value = dy * 0.15;
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
// Dragging down
|
|
211
|
+
if (currentSnapIndex > 0) {
|
|
212
|
+
// Allow smooth transition
|
|
213
|
+
animatedPosition.value = dy;
|
|
214
|
+
} else {
|
|
215
|
+
// Rubberband at bottom with progressive resistance
|
|
216
|
+
const rubberband = Math.min(dy / (currentHeight * 0.5), 1);
|
|
217
|
+
animatedPosition.value = dy * (1 - rubberband * 0.7);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
.onEnd((event) => {
|
|
222
|
+
const dy = event.translationY;
|
|
223
|
+
const vy = event.velocityY;
|
|
224
|
+
const currentHeight = snapHeights[currentSnapIndex];
|
|
225
|
+
|
|
226
|
+
// Check if should close (only from lowest snap point)
|
|
227
|
+
if (currentSnapIndex === 0 && (dy > currentHeight * 0.4 || vy > 1000)) {
|
|
228
|
+
animatedPosition.value = withSpring(SCREEN_HEIGHT, SPRING_CONFIG);
|
|
229
|
+
backdropOpacity.value = withSpring(0, SPRING_CONFIG);
|
|
230
|
+
runOnJS(handleClose)();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Find target snap point
|
|
235
|
+
const targetIndex = findClosestSnapPoint(dy, vy);
|
|
236
|
+
|
|
237
|
+
if (targetIndex !== currentSnapIndex) {
|
|
238
|
+
runOnJS(handleSnapChange)(targetIndex);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Spring back to position
|
|
242
|
+
animatedPosition.value = withSpring(0, SPRING_CONFIG);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
React.useEffect(() => {
|
|
246
|
+
if (open) {
|
|
247
|
+
setVisible(true);
|
|
248
|
+
setCurrentSnapIndex(defaultSnapPoint);
|
|
249
|
+
|
|
250
|
+
animatedPosition.value = withSpring(0, SPRING_CONFIG);
|
|
251
|
+
backdropOpacity.value = withSpring(1, SPRING_CONFIG);
|
|
252
|
+
} else {
|
|
253
|
+
animatedPosition.value = withSpring(SCREEN_HEIGHT, SPRING_CONFIG);
|
|
254
|
+
backdropOpacity.value = withSpring(0, SPRING_CONFIG);
|
|
255
|
+
|
|
256
|
+
const timer = setTimeout(() => {
|
|
257
|
+
setVisible(false);
|
|
258
|
+
}, 300);
|
|
259
|
+
|
|
260
|
+
return () => clearTimeout(timer);
|
|
261
|
+
}
|
|
262
|
+
}, [open, defaultSnapPoint]);
|
|
263
|
+
|
|
264
|
+
const backdropAnimatedStyle = useAnimatedStyle(() => ({
|
|
265
|
+
opacity: backdropOpacity.value,
|
|
266
|
+
}));
|
|
267
|
+
|
|
268
|
+
const sheetAnimatedStyle = useAnimatedStyle(() => {
|
|
269
|
+
const currentHeight = snapHeights[currentSnapIndex];
|
|
270
|
+
const position = animatedPosition.value;
|
|
271
|
+
|
|
272
|
+
let height = currentHeight;
|
|
273
|
+
|
|
274
|
+
// Interpolate height smoothly when dragging between snap points
|
|
275
|
+
if (position < 0 && currentSnapIndex < snapHeights.length - 1) {
|
|
276
|
+
const nextHeight = snapHeights[currentSnapIndex + 1];
|
|
277
|
+
const maxDrag = nextHeight - currentHeight;
|
|
278
|
+
|
|
279
|
+
// Smooth interpolation
|
|
280
|
+
const progress = clamp(Math.abs(position) / maxDrag, 0, 1);
|
|
281
|
+
height = interpolate(
|
|
282
|
+
progress,
|
|
283
|
+
[0, 1],
|
|
284
|
+
[currentHeight, nextHeight],
|
|
285
|
+
Extrapolation.CLAMP
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
height,
|
|
291
|
+
transform: [
|
|
292
|
+
{
|
|
293
|
+
translateY: position > 0 ? position : position * 0.1, // Minimal movement when dragging up
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
};
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
if (!visible) return null;
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<Modal visible={visible} transparent animationType="none" onRequestClose={handleClose}>
|
|
303
|
+
<View style={{ flex: 1 }}>
|
|
304
|
+
{/* Backdrop */}
|
|
305
|
+
<Animated.View
|
|
306
|
+
style={[
|
|
307
|
+
{
|
|
308
|
+
flex: 1,
|
|
309
|
+
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
310
|
+
},
|
|
311
|
+
backdropAnimatedStyle,
|
|
312
|
+
]}
|
|
313
|
+
>
|
|
314
|
+
<Pressable onPress={handleClose} style={{ flex: 1 }} />
|
|
315
|
+
</Animated.View>
|
|
316
|
+
|
|
317
|
+
{/* Bottom Sheet */}
|
|
318
|
+
<Animated.View
|
|
319
|
+
style={[
|
|
320
|
+
{
|
|
321
|
+
position: 'absolute',
|
|
322
|
+
bottom: 0,
|
|
323
|
+
left: 0,
|
|
324
|
+
right: 0,
|
|
325
|
+
},
|
|
326
|
+
sheetAnimatedStyle,
|
|
327
|
+
]}
|
|
328
|
+
>
|
|
329
|
+
<View className={cn('bg-white rounded-t-3xl shadow-2xl h-full', className)}>
|
|
330
|
+
{/* Drag Handle */}
|
|
331
|
+
<GestureDetector gesture={panGesture}>
|
|
332
|
+
<Animated.View className="items-center py-3">
|
|
333
|
+
<View className="w-12 h-1.5 bg-slate-300 rounded-full" />
|
|
334
|
+
</Animated.View>
|
|
335
|
+
</GestureDetector>
|
|
336
|
+
|
|
337
|
+
{/* Content with padding for footer */}
|
|
338
|
+
<View className="flex-1 pb-20">
|
|
339
|
+
{children}
|
|
340
|
+
</View>
|
|
341
|
+
</View>
|
|
342
|
+
</Animated.View>
|
|
343
|
+
</View>
|
|
344
|
+
</Modal>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function BottomSheetHeader({ children, className }: BottomSheetHeaderProps) {
|
|
349
|
+
return <View className={cn('px-4 pb-2', className)}>{children}</View>;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function BottomSheetTitle({ children, className }: BottomSheetTitleProps) {
|
|
353
|
+
return (
|
|
354
|
+
<Text className={cn('text-2xl font-semibold text-slate-900', className)}>{children}</Text>
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function BottomSheetDescription({ children, className }: BottomSheetDescriptionProps) {
|
|
359
|
+
return <Text className={cn('text-sm text-slate-500 mt-2', className)}>{children}</Text>;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function BottomSheetBody({ children, className, scrollable }: BottomSheetBodyProps) {
|
|
363
|
+
if (!scrollable) {
|
|
364
|
+
return <View className={cn('flex-1 px-4', className)}>{children}</View>;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return (
|
|
368
|
+
<ScrollView
|
|
369
|
+
className={cn('flex-1 px-4', className)}
|
|
370
|
+
showsVerticalScrollIndicator={false}
|
|
371
|
+
bounces={false}
|
|
372
|
+
>
|
|
373
|
+
{children}
|
|
374
|
+
</ScrollView>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function BottomSheetList<T>({
|
|
379
|
+
data,
|
|
380
|
+
renderItem,
|
|
381
|
+
keyExtractor,
|
|
382
|
+
variant = 'list',
|
|
383
|
+
onSelect,
|
|
384
|
+
selectedValue,
|
|
385
|
+
selectedValues = [],
|
|
386
|
+
getItemValue,
|
|
387
|
+
className,
|
|
388
|
+
}: BottomSheetListProps<T>) {
|
|
389
|
+
const [internalSelectedValues, setInternalSelectedValues] =
|
|
390
|
+
React.useState<any[]>(selectedValues);
|
|
391
|
+
|
|
392
|
+
const defaultKeyExtractor = (item: T, index: number) => {
|
|
393
|
+
if (typeof item === 'object' && item !== null && 'id' in item) {
|
|
394
|
+
return String((item as any).id);
|
|
395
|
+
}
|
|
396
|
+
return String(index);
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const defaultGetItemValue = (item: T) => {
|
|
400
|
+
if (typeof item === 'object' && item !== null && 'value' in item) {
|
|
401
|
+
return (item as any).value;
|
|
402
|
+
}
|
|
403
|
+
return item;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const finalKeyExtractor = keyExtractor || defaultKeyExtractor;
|
|
407
|
+
const finalGetItemValue = getItemValue || defaultGetItemValue;
|
|
408
|
+
|
|
409
|
+
const handleSelect = (item: T) => {
|
|
410
|
+
const itemValue = finalGetItemValue(item);
|
|
411
|
+
|
|
412
|
+
if (variant === 'select') {
|
|
413
|
+
onSelect?.(item);
|
|
414
|
+
} else if (variant === 'multiple') {
|
|
415
|
+
const isSelected = internalSelectedValues.includes(itemValue);
|
|
416
|
+
let newSelected: any[];
|
|
417
|
+
|
|
418
|
+
if (isSelected) {
|
|
419
|
+
newSelected = internalSelectedValues.filter((v) => v !== itemValue);
|
|
420
|
+
} else {
|
|
421
|
+
newSelected = [...internalSelectedValues, itemValue];
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
setInternalSelectedValues(newSelected);
|
|
425
|
+
|
|
426
|
+
const selectedItems = data.filter((d) => newSelected.includes(finalGetItemValue(d)));
|
|
427
|
+
onSelect?.(selectedItems as T[]);
|
|
428
|
+
} else {
|
|
429
|
+
onSelect?.(item);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const isItemSelected = (item: T) => {
|
|
434
|
+
const itemValue = finalGetItemValue(item);
|
|
435
|
+
|
|
436
|
+
if (variant === 'select') {
|
|
437
|
+
return selectedValue === itemValue;
|
|
438
|
+
} else if (variant === 'multiple') {
|
|
439
|
+
return internalSelectedValues.includes(itemValue);
|
|
440
|
+
}
|
|
441
|
+
return false;
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const defaultRenderItem: ListRenderItem<T> = ({ item }) => {
|
|
445
|
+
const isSelected = isItemSelected(item);
|
|
446
|
+
const itemLabel =
|
|
447
|
+
typeof item === 'object' && item !== null && 'label' in item
|
|
448
|
+
? (item as any).label
|
|
449
|
+
: String(item);
|
|
450
|
+
|
|
451
|
+
return (
|
|
452
|
+
<Pressable
|
|
453
|
+
onPress={() => handleSelect(item)}
|
|
454
|
+
className={cn(
|
|
455
|
+
'flex-row items-center justify-between px-4 py-3 border-b border-slate-100',
|
|
456
|
+
isSelected && 'bg-blue-50'
|
|
457
|
+
)}
|
|
458
|
+
>
|
|
459
|
+
<View className="flex-row items-center gap-3 flex-1">
|
|
460
|
+
{variant === 'select' && (
|
|
461
|
+
<View
|
|
462
|
+
className={cn(
|
|
463
|
+
'h-5 w-5 rounded-full border-2 items-center justify-center',
|
|
464
|
+
isSelected ? 'border-blue-600' : 'border-slate-300'
|
|
465
|
+
)}
|
|
466
|
+
>
|
|
467
|
+
{isSelected && <View className="h-2.5 w-2.5 rounded-full bg-blue-600" />}
|
|
468
|
+
</View>
|
|
469
|
+
)}
|
|
470
|
+
|
|
471
|
+
{variant === 'multiple' && (
|
|
472
|
+
<View
|
|
473
|
+
className={cn(
|
|
474
|
+
'h-5 w-5 rounded border-2 items-center justify-center',
|
|
475
|
+
isSelected ? 'bg-blue-600 border-blue-600' : 'border-slate-300'
|
|
476
|
+
)}
|
|
477
|
+
>
|
|
478
|
+
{isSelected && <Text className="text-white text-xs font-bold">✓</Text>}
|
|
479
|
+
</View>
|
|
480
|
+
)}
|
|
481
|
+
|
|
482
|
+
<Text
|
|
483
|
+
className={cn(
|
|
484
|
+
'text-base flex-1',
|
|
485
|
+
isSelected ? 'text-blue-600 font-semibold' : 'text-slate-900'
|
|
486
|
+
)}
|
|
487
|
+
>
|
|
488
|
+
{itemLabel}
|
|
489
|
+
</Text>
|
|
490
|
+
</View>
|
|
491
|
+
|
|
492
|
+
{variant === 'select' && isSelected && (
|
|
493
|
+
<Text className="text-blue-600 font-bold">✓</Text>
|
|
494
|
+
)}
|
|
495
|
+
</Pressable>
|
|
496
|
+
);
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
const finalRenderItem = renderItem || defaultRenderItem;
|
|
500
|
+
|
|
501
|
+
return (
|
|
502
|
+
<FlatList
|
|
503
|
+
data={data}
|
|
504
|
+
renderItem={finalRenderItem}
|
|
505
|
+
keyExtractor={finalKeyExtractor}
|
|
506
|
+
className={cn('flex-1 px-0', className)}
|
|
507
|
+
showsVerticalScrollIndicator={false}
|
|
508
|
+
bounces={false}
|
|
509
|
+
/>
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function BottomSheetListItem({ children, className }: BottomSheetListItemProps) {
|
|
514
|
+
return <View className={cn('px-4 py-3 border-b border-slate-100', className)}>{children}</View>;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function BottomSheetFooter({ children, className }: BottomSheetFooterProps) {
|
|
518
|
+
return (
|
|
519
|
+
<View
|
|
520
|
+
className={cn(
|
|
521
|
+
'absolute bottom-0 left-0 right-0 bg-white px-4 py-4 border-t border-slate-200',
|
|
522
|
+
className
|
|
523
|
+
)}
|
|
524
|
+
>
|
|
525
|
+
{children}
|
|
526
|
+
</View>
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function BottomSheetClose({ children }: { children: React.ReactNode }) {
|
|
531
|
+
const { onOpenChange } = useBottomSheet();
|
|
532
|
+
|
|
533
|
+
if (React.isValidElement(children)) {
|
|
534
|
+
return React.cloneElement(children as React.ReactElement<any>, {
|
|
535
|
+
onPress: () => onOpenChange(false),
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return <Pressable onPress={() => onOpenChange(false)}>{children}</Pressable>;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
// /**
|
|
544
|
+
// * FIXME: BottomSheet component with snap points and drag-to-resize functionality.
|
|
545
|
+
// */
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
// /**
|
|
549
|
+
// * BottomSheet component with snap points and drag-to-resize functionality.
|
|
550
|
+
// * Features:
|
|
551
|
+
// * - Multiple snap points support
|
|
552
|
+
// * - Smooth drag gestures
|
|
553
|
+
// * - Scrollable content area only (header & footer fixed)
|
|
554
|
+
// * - Footer stays at bottom when content is short
|
|
555
|
+
// */
|
|
556
|
+
|
|
557
|
+
// import * as React from 'react';
|
|
558
|
+
// import { Modal, View, Text, Pressable, Animated, Dimensions, PanResponder, LayoutAnimation, Platform, UIManager, ScrollView, ListRenderItem, FlatList } from 'react-native';
|
|
559
|
+
// import { cn } from '../../lib/utils';
|
|
560
|
+
|
|
561
|
+
// const SCREEN_HEIGHT = Dimensions.get('window').height;
|
|
562
|
+
|
|
563
|
+
// const SNAP_THRESHOLD = 80;
|
|
564
|
+
// const VELOCITY_THRESHOLD = 0.5;
|
|
565
|
+
// const CLOSE_THRESHOLD = 0.4;
|
|
566
|
+
|
|
567
|
+
// if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
|
568
|
+
// UIManager.setLayoutAnimationEnabledExperimental(true);
|
|
569
|
+
// }
|
|
570
|
+
|
|
571
|
+
// interface BottomSheetProps {
|
|
572
|
+
// open?: boolean;
|
|
573
|
+
// onOpenChange?: (open: boolean) => void;
|
|
574
|
+
// children: React.ReactNode;
|
|
575
|
+
// snapPoints?: string[];
|
|
576
|
+
// defaultSnapPoint?: number;
|
|
577
|
+
// }
|
|
578
|
+
|
|
579
|
+
// interface BottomSheetContentProps {
|
|
580
|
+
// children: React.ReactNode;
|
|
581
|
+
// className?: string;
|
|
582
|
+
// }
|
|
583
|
+
|
|
584
|
+
// interface BottomSheetHeaderProps {
|
|
585
|
+
// children: React.ReactNode;
|
|
586
|
+
// className?: string;
|
|
587
|
+
// }
|
|
588
|
+
|
|
589
|
+
// interface BottomSheetTitleProps {
|
|
590
|
+
// children: React.ReactNode;
|
|
591
|
+
// className?: string;
|
|
592
|
+
// }
|
|
593
|
+
|
|
594
|
+
// interface BottomSheetDescriptionProps {
|
|
595
|
+
// children: React.ReactNode;
|
|
596
|
+
// className?: string;
|
|
597
|
+
// }
|
|
598
|
+
|
|
599
|
+
// interface BottomSheetFooterProps {
|
|
600
|
+
// children: React.ReactNode;
|
|
601
|
+
// className?: string;
|
|
602
|
+
// }
|
|
603
|
+
|
|
604
|
+
// // DONE: Add BottomSheetBody for scrollable content
|
|
605
|
+
// interface BottomSheetBodyProps {
|
|
606
|
+
// children: React.ReactNode;
|
|
607
|
+
// className?: string;
|
|
608
|
+
// scrollable?: boolean;
|
|
609
|
+
// }
|
|
610
|
+
|
|
611
|
+
// interface BottomSheetListProps<T> {
|
|
612
|
+
// data: T[];
|
|
613
|
+
// renderItem?: ListRenderItem<T>;
|
|
614
|
+
// keyExtractor?: (item: T, index: number) => string;
|
|
615
|
+
// variant?: 'list' | 'select' | 'multiple';
|
|
616
|
+
// onSelect?: (selected: T | T[] | null) => void;
|
|
617
|
+
// selectedValue?: any; // for single select
|
|
618
|
+
// selectedValues?: any[]; // for multiple select
|
|
619
|
+
// getItemValue?: (item: T) => any; // to extract value from item
|
|
620
|
+
// className?: string;
|
|
621
|
+
// }
|
|
622
|
+
|
|
623
|
+
// interface BottomSheetListItemProps {
|
|
624
|
+
// children: React.ReactNode;
|
|
625
|
+
// className?: string;
|
|
626
|
+
// }
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
// const BottomSheetContext = React.createContext<{
|
|
630
|
+
// open: boolean;
|
|
631
|
+
// onOpenChange: (open: boolean) => void;
|
|
632
|
+
// snapPoints: string[];
|
|
633
|
+
// currentSnapPoint: number;
|
|
634
|
+
// } | null>(null);
|
|
635
|
+
|
|
636
|
+
// function useBottomSheet() {
|
|
637
|
+
// const context = React.useContext(BottomSheetContext);
|
|
638
|
+
// if (!context) {
|
|
639
|
+
// throw new Error('BottomSheet components must be used within BottomSheet');
|
|
640
|
+
// }
|
|
641
|
+
// return context;
|
|
642
|
+
// }
|
|
643
|
+
|
|
644
|
+
// export function BottomSheet({
|
|
645
|
+
// open: controlledOpen,
|
|
646
|
+
// onOpenChange: controlledOnOpenChange,
|
|
647
|
+
// children,
|
|
648
|
+
// snapPoints = ['50%'],
|
|
649
|
+
// defaultSnapPoint = 0,
|
|
650
|
+
// }: BottomSheetProps) {
|
|
651
|
+
// const [internalOpen, setInternalOpen] = React.useState(false);
|
|
652
|
+
|
|
653
|
+
// const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
|
654
|
+
// const onOpenChange = controlledOnOpenChange || setInternalOpen;
|
|
655
|
+
|
|
656
|
+
// return (
|
|
657
|
+
// <BottomSheetContext.Provider value={{ open, onOpenChange, snapPoints, currentSnapPoint: defaultSnapPoint }}>
|
|
658
|
+
// {children}
|
|
659
|
+
// </BottomSheetContext.Provider>
|
|
660
|
+
// );
|
|
661
|
+
// }
|
|
662
|
+
|
|
663
|
+
// export function BottomSheetTrigger({ children }: { children: React.ReactNode }) {
|
|
664
|
+
// const { onOpenChange } = useBottomSheet();
|
|
665
|
+
|
|
666
|
+
// if (React.isValidElement(children)) {
|
|
667
|
+
// return React.cloneElement(children as React.ReactElement<any>, {
|
|
668
|
+
// onPress: () => onOpenChange(true),
|
|
669
|
+
// });
|
|
670
|
+
// }
|
|
671
|
+
|
|
672
|
+
// return (
|
|
673
|
+
// <Pressable onPress={() => onOpenChange(true)}>
|
|
674
|
+
// {children}
|
|
675
|
+
// </Pressable>
|
|
676
|
+
// );
|
|
677
|
+
// }
|
|
678
|
+
|
|
679
|
+
// export function BottomSheetContent({ children, className }: BottomSheetContentProps) {
|
|
680
|
+
// const { open, onOpenChange, snapPoints, currentSnapPoint: defaultSnapPoint } = useBottomSheet();
|
|
681
|
+
|
|
682
|
+
// const [visible, setVisible] = React.useState(false);
|
|
683
|
+
// const [currentSnapIndex, setCurrentSnapIndex] = React.useState(defaultSnapPoint);
|
|
684
|
+
|
|
685
|
+
// const snapHeights = React.useMemo(() => {
|
|
686
|
+
// return snapPoints.map(point => {
|
|
687
|
+
// const percentage = parseInt(point) / 100;
|
|
688
|
+
// return SCREEN_HEIGHT * percentage;
|
|
689
|
+
// });
|
|
690
|
+
// }, [snapPoints]);
|
|
691
|
+
|
|
692
|
+
// const currentHeight = snapHeights[currentSnapIndex];
|
|
693
|
+
|
|
694
|
+
// const translateY = React.useRef(new Animated.Value(SCREEN_HEIGHT)).current;
|
|
695
|
+
// const backdropOpacity = React.useRef(new Animated.Value(0)).current;
|
|
696
|
+
|
|
697
|
+
// const panResponder = React.useRef(
|
|
698
|
+
// PanResponder.create({
|
|
699
|
+
// onStartShouldSetPanResponder: () => true,
|
|
700
|
+
// onMoveShouldSetPanResponder: (_, gestureState) => {
|
|
701
|
+
// return Math.abs(gestureState.dy) > 5;
|
|
702
|
+
// },
|
|
703
|
+
// onPanResponderMove: (_, gestureState) => {
|
|
704
|
+
// const { dy } = gestureState;
|
|
705
|
+
|
|
706
|
+
// if (dy > 0) {
|
|
707
|
+
// translateY.setValue(dy);
|
|
708
|
+
// } else {
|
|
709
|
+
// const clampedDy = Math.max(dy, -50);
|
|
710
|
+
// translateY.setValue(clampedDy);
|
|
711
|
+
// }
|
|
712
|
+
// },
|
|
713
|
+
// onPanResponderRelease: (_, gestureState) => {
|
|
714
|
+
// const { dy, vy } = gestureState;
|
|
715
|
+
|
|
716
|
+
// if (currentSnapIndex === 0 && (dy > currentHeight * CLOSE_THRESHOLD || vy > VELOCITY_THRESHOLD)) {
|
|
717
|
+
// onOpenChange(false);
|
|
718
|
+
// return;
|
|
719
|
+
// }
|
|
720
|
+
|
|
721
|
+
// if (dy < -30 || vy < -VELOCITY_THRESHOLD) {
|
|
722
|
+
// if (currentSnapIndex < snapHeights.length - 1) {
|
|
723
|
+
// LayoutAnimation.configureNext(
|
|
724
|
+
// LayoutAnimation.create(
|
|
725
|
+
// 300,
|
|
726
|
+
// LayoutAnimation.Types.easeInEaseOut,
|
|
727
|
+
// LayoutAnimation.Properties.scaleXY
|
|
728
|
+
// )
|
|
729
|
+
// );
|
|
730
|
+
// setCurrentSnapIndex(currentSnapIndex + 1);
|
|
731
|
+
// }
|
|
732
|
+
|
|
733
|
+
// Animated.spring(translateY, {
|
|
734
|
+
// toValue: 0,
|
|
735
|
+
// useNativeDriver: true,
|
|
736
|
+
// tension: 80,
|
|
737
|
+
// friction: 10,
|
|
738
|
+
// }).start();
|
|
739
|
+
// }
|
|
740
|
+
// else if (dy > SNAP_THRESHOLD || vy > VELOCITY_THRESHOLD) {
|
|
741
|
+
// if (currentSnapIndex > 0) {
|
|
742
|
+
// LayoutAnimation.configureNext(
|
|
743
|
+
// LayoutAnimation.create(
|
|
744
|
+
// 300,
|
|
745
|
+
// LayoutAnimation.Types.easeInEaseOut,
|
|
746
|
+
// LayoutAnimation.Properties.scaleXY
|
|
747
|
+
// )
|
|
748
|
+
// );
|
|
749
|
+
// setCurrentSnapIndex(currentSnapIndex - 1);
|
|
750
|
+
|
|
751
|
+
// Animated.spring(translateY, {
|
|
752
|
+
// toValue: 0,
|
|
753
|
+
// useNativeDriver: true,
|
|
754
|
+
// tension: 80,
|
|
755
|
+
// friction: 10,
|
|
756
|
+
// }).start();
|
|
757
|
+
// } else {
|
|
758
|
+
// onOpenChange(false);
|
|
759
|
+
// }
|
|
760
|
+
// }
|
|
761
|
+
// else {
|
|
762
|
+
// Animated.spring(translateY, {
|
|
763
|
+
// toValue: 0,
|
|
764
|
+
// useNativeDriver: true,
|
|
765
|
+
// tension: 80,
|
|
766
|
+
// friction: 10,
|
|
767
|
+
// }).start();
|
|
768
|
+
// }
|
|
769
|
+
// },
|
|
770
|
+
// })
|
|
771
|
+
// ).current;
|
|
772
|
+
|
|
773
|
+
// React.useEffect(() => {
|
|
774
|
+
// if (open) {
|
|
775
|
+
// setVisible(true);
|
|
776
|
+
// setCurrentSnapIndex(defaultSnapPoint);
|
|
777
|
+
|
|
778
|
+
// Animated.parallel([
|
|
779
|
+
// Animated.spring(translateY, {
|
|
780
|
+
// toValue: 0,
|
|
781
|
+
// useNativeDriver: true,
|
|
782
|
+
// tension: 80,
|
|
783
|
+
// friction: 10,
|
|
784
|
+
// }),
|
|
785
|
+
// Animated.timing(backdropOpacity, {
|
|
786
|
+
// toValue: 1,
|
|
787
|
+
// duration: 250,
|
|
788
|
+
// useNativeDriver: true,
|
|
789
|
+
// }),
|
|
790
|
+
// ]).start();
|
|
791
|
+
// } else {
|
|
792
|
+
// Animated.parallel([
|
|
793
|
+
// Animated.timing(translateY, {
|
|
794
|
+
// toValue: SCREEN_HEIGHT,
|
|
795
|
+
// duration: 250,
|
|
796
|
+
// useNativeDriver: true,
|
|
797
|
+
// }),
|
|
798
|
+
// Animated.timing(backdropOpacity, {
|
|
799
|
+
// toValue: 0,
|
|
800
|
+
// duration: 250,
|
|
801
|
+
// useNativeDriver: true,
|
|
802
|
+
// }),
|
|
803
|
+
// ]).start(() => {
|
|
804
|
+
// setVisible(false);
|
|
805
|
+
// });
|
|
806
|
+
// }
|
|
807
|
+
// }, [open]);
|
|
808
|
+
|
|
809
|
+
// if (!visible) return null;
|
|
810
|
+
|
|
811
|
+
// return (
|
|
812
|
+
// <Modal
|
|
813
|
+
// visible={visible}
|
|
814
|
+
// transparent
|
|
815
|
+
// animationType="none"
|
|
816
|
+
// onRequestClose={() => onOpenChange(false)}
|
|
817
|
+
// >
|
|
818
|
+
// <View style={{ flex: 1 }}>
|
|
819
|
+
// <Animated.View
|
|
820
|
+
// style={{
|
|
821
|
+
// flex: 1,
|
|
822
|
+
// backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
823
|
+
// opacity: backdropOpacity,
|
|
824
|
+
// }}
|
|
825
|
+
// >
|
|
826
|
+
// <Pressable
|
|
827
|
+
// onPress={() => onOpenChange(false)}
|
|
828
|
+
// style={{ flex: 1 }}
|
|
829
|
+
// />
|
|
830
|
+
// </Animated.View>
|
|
831
|
+
|
|
832
|
+
// <Animated.View
|
|
833
|
+
// style={{
|
|
834
|
+
// position: 'absolute',
|
|
835
|
+
// bottom: 0,
|
|
836
|
+
// left: 0,
|
|
837
|
+
// right: 0,
|
|
838
|
+
// height: currentHeight,
|
|
839
|
+
// transform: [{ translateY }],
|
|
840
|
+
// }}
|
|
841
|
+
// >
|
|
842
|
+
// {/* DONE: Flexbox layout untuk fixed header/footer */}
|
|
843
|
+
// <View
|
|
844
|
+
// className={cn(
|
|
845
|
+
// 'bg-white rounded-t-3xl shadow-2xl h-full flex-col',
|
|
846
|
+
// className
|
|
847
|
+
// )}
|
|
848
|
+
// >
|
|
849
|
+
// {/* Drag Handle - Fixed */}
|
|
850
|
+
// <View className="items-center py-3" {...panResponder.panHandlers}>
|
|
851
|
+
// <View className="w-12 h-1 bg-slate-300 rounded-full" />
|
|
852
|
+
// </View>
|
|
853
|
+
|
|
854
|
+
// {/* DONE: Children akan di-render langsung (header, body, footer) */}
|
|
855
|
+
// {children}
|
|
856
|
+
// </View>
|
|
857
|
+
// </Animated.View>
|
|
858
|
+
// </View>
|
|
859
|
+
// </Modal>
|
|
860
|
+
// );
|
|
861
|
+
// }
|
|
862
|
+
|
|
863
|
+
// export function BottomSheetHeader({ children, className }: BottomSheetHeaderProps) {
|
|
864
|
+
// return (
|
|
865
|
+
// <View className={cn('px-4 pb-2', className)}>
|
|
866
|
+
// {children}
|
|
867
|
+
// </View>
|
|
868
|
+
// );
|
|
869
|
+
// }
|
|
870
|
+
|
|
871
|
+
// export function BottomSheetTitle({ children, className }: BottomSheetTitleProps) {
|
|
872
|
+
// return (
|
|
873
|
+
// <Text className={cn('text-2xl font-semibold text-slate-900', className)}>
|
|
874
|
+
// {children}
|
|
875
|
+
// </Text>
|
|
876
|
+
// );
|
|
877
|
+
// }
|
|
878
|
+
|
|
879
|
+
// export function BottomSheetDescription({ children, className }: BottomSheetDescriptionProps) {
|
|
880
|
+
// return (
|
|
881
|
+
// <Text className={cn('text-sm text-slate-500 mt-2', className)}>
|
|
882
|
+
// {children}
|
|
883
|
+
// </Text>
|
|
884
|
+
// );
|
|
885
|
+
// }
|
|
886
|
+
|
|
887
|
+
// // DONE: New BottomSheetBody component for scrollable content
|
|
888
|
+
// export function BottomSheetBody({ children, className, scrollable }: BottomSheetBodyProps) {
|
|
889
|
+
|
|
890
|
+
// if (!scrollable) {
|
|
891
|
+
// return (
|
|
892
|
+
// <View className={cn('flex-1 px-4', className)}>
|
|
893
|
+
// {children}
|
|
894
|
+
// </View>
|
|
895
|
+
// );
|
|
896
|
+
// }
|
|
897
|
+
|
|
898
|
+
// return (
|
|
899
|
+
// <ScrollView
|
|
900
|
+
// className={cn('flex-1 px-4', className)}
|
|
901
|
+
// showsVerticalScrollIndicator={false}
|
|
902
|
+
// bounces={false}
|
|
903
|
+
// >
|
|
904
|
+
// {children}
|
|
905
|
+
// </ScrollView>
|
|
906
|
+
// );
|
|
907
|
+
// }
|
|
908
|
+
|
|
909
|
+
// export function BottomSheetList<T>({
|
|
910
|
+
// data,
|
|
911
|
+
// renderItem,
|
|
912
|
+
// keyExtractor,
|
|
913
|
+
// variant = 'list',
|
|
914
|
+
// onSelect,
|
|
915
|
+
// selectedValue,
|
|
916
|
+
// selectedValues = [],
|
|
917
|
+
// getItemValue,
|
|
918
|
+
// className,
|
|
919
|
+
// }: BottomSheetListProps<T>) {
|
|
920
|
+
// const [internalSelectedValues, setInternalSelectedValues] = React.useState<any[]>(selectedValues);
|
|
921
|
+
|
|
922
|
+
// // Default keyExtractor
|
|
923
|
+
// const defaultKeyExtractor = (item: T, index: number) => {
|
|
924
|
+
// if (typeof item === 'object' && item !== null && 'id' in item) {
|
|
925
|
+
// return String((item as any).id);
|
|
926
|
+
// }
|
|
927
|
+
// return String(index);
|
|
928
|
+
// };
|
|
929
|
+
|
|
930
|
+
// // Default getItemValue
|
|
931
|
+
// const defaultGetItemValue = (item: T) => {
|
|
932
|
+
// if (typeof item === 'object' && item !== null && 'value' in item) {
|
|
933
|
+
// return (item as any).value;
|
|
934
|
+
// }
|
|
935
|
+
// return item;
|
|
936
|
+
// };
|
|
937
|
+
|
|
938
|
+
// const finalKeyExtractor = keyExtractor || defaultKeyExtractor;
|
|
939
|
+
// const finalGetItemValue = getItemValue || defaultGetItemValue;
|
|
940
|
+
|
|
941
|
+
// // Handle selection
|
|
942
|
+
// const handleSelect = (item: T) => {
|
|
943
|
+
// const itemValue = finalGetItemValue(item);
|
|
944
|
+
|
|
945
|
+
// if (variant === 'select') {
|
|
946
|
+
// // Single select
|
|
947
|
+
// onSelect?.(item);
|
|
948
|
+
// } else if (variant === 'multiple') {
|
|
949
|
+
// // Multiple select
|
|
950
|
+
// const isSelected = internalSelectedValues.includes(itemValue);
|
|
951
|
+
// let newSelected: any[];
|
|
952
|
+
|
|
953
|
+
// if (isSelected) {
|
|
954
|
+
// newSelected = internalSelectedValues.filter(v => v !== itemValue);
|
|
955
|
+
// } else {
|
|
956
|
+
// newSelected = [...internalSelectedValues, itemValue];
|
|
957
|
+
// }
|
|
958
|
+
|
|
959
|
+
// setInternalSelectedValues(newSelected);
|
|
960
|
+
|
|
961
|
+
// // Return selected items
|
|
962
|
+
// const selectedItems = data.filter(d => newSelected.includes(finalGetItemValue(d)));
|
|
963
|
+
// onSelect?.(selectedItems as T[]);
|
|
964
|
+
// } else {
|
|
965
|
+
// // Plain list - just callback
|
|
966
|
+
// onSelect?.(item);
|
|
967
|
+
// }
|
|
968
|
+
// };
|
|
969
|
+
|
|
970
|
+
// // Check if item is selected
|
|
971
|
+
// const isItemSelected = (item: T) => {
|
|
972
|
+
// const itemValue = finalGetItemValue(item);
|
|
973
|
+
|
|
974
|
+
// if (variant === 'select') {
|
|
975
|
+
// return selectedValue === itemValue;
|
|
976
|
+
// } else if (variant === 'multiple') {
|
|
977
|
+
// return internalSelectedValues.includes(itemValue);
|
|
978
|
+
// }
|
|
979
|
+
// return false;
|
|
980
|
+
// };
|
|
981
|
+
|
|
982
|
+
// // Default render item
|
|
983
|
+
// const defaultRenderItem: ListRenderItem<T> = ({ item }) => {
|
|
984
|
+
// const isSelected = isItemSelected(item);
|
|
985
|
+
// const itemValue = finalGetItemValue(item);
|
|
986
|
+
// const itemLabel = typeof item === 'object' && item !== null && 'label' in item
|
|
987
|
+
// ? (item as any).label
|
|
988
|
+
// : String(item);
|
|
989
|
+
|
|
990
|
+
// return (
|
|
991
|
+
// <Pressable
|
|
992
|
+
// onPress={() => handleSelect(item)}
|
|
993
|
+
// className={cn(
|
|
994
|
+
// 'flex-row items-center justify-between px-4 py-3 border-b border-slate-100',
|
|
995
|
+
// isSelected && 'bg-blue-50'
|
|
996
|
+
// )}
|
|
997
|
+
// >
|
|
998
|
+
// <View className="flex-row items-center gap-3 flex-1">
|
|
999
|
+
// {/* Radio or Checkbox based on variant */}
|
|
1000
|
+
// {variant === 'select' && (
|
|
1001
|
+
// <View
|
|
1002
|
+
// className={cn(
|
|
1003
|
+
// 'h-5 w-5 rounded-full border-2 items-center justify-center',
|
|
1004
|
+
// isSelected ? 'border-blue-600' : 'border-slate-300'
|
|
1005
|
+
// )}
|
|
1006
|
+
// >
|
|
1007
|
+
// {isSelected && (
|
|
1008
|
+
// <View className="h-2.5 w-2.5 rounded-full bg-blue-600" />
|
|
1009
|
+
// )}
|
|
1010
|
+
// </View>
|
|
1011
|
+
// )}
|
|
1012
|
+
|
|
1013
|
+
// {variant === 'multiple' && (
|
|
1014
|
+
// <View
|
|
1015
|
+
// className={cn(
|
|
1016
|
+
// 'h-5 w-5 rounded border-2 items-center justify-center',
|
|
1017
|
+
// isSelected ? 'bg-blue-600 border-blue-600' : 'border-slate-300'
|
|
1018
|
+
// )}
|
|
1019
|
+
// >
|
|
1020
|
+
// {isSelected && (
|
|
1021
|
+
// <Text className="text-white text-xs font-bold">✓</Text>
|
|
1022
|
+
// )}
|
|
1023
|
+
// </View>
|
|
1024
|
+
// )}
|
|
1025
|
+
|
|
1026
|
+
// {/* Label */}
|
|
1027
|
+
// <Text
|
|
1028
|
+
// className={cn(
|
|
1029
|
+
// 'text-base flex-1',
|
|
1030
|
+
// isSelected ? 'text-blue-600 font-semibold' : 'text-slate-900'
|
|
1031
|
+
// )}
|
|
1032
|
+
// >
|
|
1033
|
+
// {itemLabel}
|
|
1034
|
+
// </Text>
|
|
1035
|
+
// </View>
|
|
1036
|
+
|
|
1037
|
+
// {/* Checkmark for selected (except multiple, already has checkbox) */}
|
|
1038
|
+
// {variant === 'select' && isSelected && (
|
|
1039
|
+
// <Text className="text-blue-600 font-bold">✓</Text>
|
|
1040
|
+
// )}
|
|
1041
|
+
// </Pressable>
|
|
1042
|
+
// );
|
|
1043
|
+
// };
|
|
1044
|
+
|
|
1045
|
+
// const finalRenderItem = renderItem || defaultRenderItem;
|
|
1046
|
+
|
|
1047
|
+
// return (
|
|
1048
|
+
// <FlatList
|
|
1049
|
+
// data={data}
|
|
1050
|
+
// renderItem={finalRenderItem}
|
|
1051
|
+
// keyExtractor={finalKeyExtractor}
|
|
1052
|
+
// className={cn('flex-1 px-0', className)}
|
|
1053
|
+
// showsVerticalScrollIndicator={false}
|
|
1054
|
+
// bounces={false}
|
|
1055
|
+
// />
|
|
1056
|
+
// );
|
|
1057
|
+
// }
|
|
1058
|
+
|
|
1059
|
+
// // DONE: BottomSheetListItem for custom rendering
|
|
1060
|
+
// export function BottomSheetListItem({ children, className }: BottomSheetListItemProps) {
|
|
1061
|
+
// return (
|
|
1062
|
+
// <View className={cn('px-4 py-3 border-b border-slate-100', className)}>
|
|
1063
|
+
// {children}
|
|
1064
|
+
// </View>
|
|
1065
|
+
// );
|
|
1066
|
+
// }
|
|
1067
|
+
|
|
1068
|
+
// export function BottomSheetFooter({ children, className }: BottomSheetFooterProps) {
|
|
1069
|
+
// return (
|
|
1070
|
+
// <View className={cn('px-4 pt-4 pb-4', className)}>
|
|
1071
|
+
// {children}
|
|
1072
|
+
// </View>
|
|
1073
|
+
// );
|
|
1074
|
+
// }
|
|
1075
|
+
|
|
1076
|
+
// export function BottomSheetClose({ children }: { children: React.ReactNode }) {
|
|
1077
|
+
// const { onOpenChange } = useBottomSheet();
|
|
1078
|
+
|
|
1079
|
+
// if (React.isValidElement(children)) {
|
|
1080
|
+
// return React.cloneElement(children as React.ReactElement<any>, {
|
|
1081
|
+
// onPress: () => onOpenChange(false),
|
|
1082
|
+
// });
|
|
1083
|
+
// }
|
|
1084
|
+
|
|
1085
|
+
// return (
|
|
1086
|
+
// <Pressable onPress={() => onOpenChange(false)}>
|
|
1087
|
+
// {children}
|
|
1088
|
+
// </Pressable>
|
|
1089
|
+
// );
|
|
1090
|
+
// }
|