@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.
Files changed (59) hide show
  1. package/dist/index.d.ts +5 -0
  2. package/dist/index.js +14 -0
  3. package/package.json +31 -0
  4. package/src/components/ui/accordion.tsx +334 -0
  5. package/src/components/ui/avatar.tsx +326 -0
  6. package/src/components/ui/badge.tsx +84 -0
  7. package/src/components/ui/banner.tsx +151 -0
  8. package/src/components/ui/bottom-sheet.tsx +579 -0
  9. package/src/components/ui/button.tsx +142 -0
  10. package/src/components/ui/calendar.tsx +502 -0
  11. package/src/components/ui/card.tsx +163 -0
  12. package/src/components/ui/checkbox.tsx +129 -0
  13. package/src/components/ui/date-picker.tsx +190 -0
  14. package/src/components/ui/date-range-picker.tsx +262 -0
  15. package/src/components/ui/dialog.tsx +204 -0
  16. package/src/components/ui/form.tsx +139 -0
  17. package/src/components/ui/input.tsx +107 -0
  18. package/src/components/ui/radio-group.tsx +123 -0
  19. package/src/components/ui/radio.tsx +109 -0
  20. package/src/components/ui/select-sheet.tsx +814 -0
  21. package/src/components/ui/select.tsx +547 -0
  22. package/src/components/ui/tabs.tsx +254 -0
  23. package/src/components/ui/text.tsx +229 -0
  24. package/src/components/ui/textarea.tsx +77 -0
  25. package/src/components/v0/accordion.tsx +199 -0
  26. package/src/components/v1/accordion.tsx +234 -0
  27. package/src/components/v1/avatar.tsx +259 -0
  28. package/src/components/v1/bottom-sheet.tsx +1090 -0
  29. package/src/components/v1/button.tsx +61 -0
  30. package/src/components/v1/calendar.tsx +498 -0
  31. package/src/components/v1/card.tsx +86 -0
  32. package/src/components/v1/checkbox.tsx +46 -0
  33. package/src/components/v1/date-picker.tsx +135 -0
  34. package/src/components/v1/date-range-picker.tsx +218 -0
  35. package/src/components/v1/dialog.tsx +211 -0
  36. package/src/components/v1/radio-group.tsx +76 -0
  37. package/src/components/v1/select.tsx +217 -0
  38. package/src/components/v1/tabs.tsx +253 -0
  39. package/src/registry/ui/accordion.json +30 -0
  40. package/src/registry/ui/avatar.json +41 -0
  41. package/src/registry/ui/badge.json +26 -0
  42. package/src/registry/ui/banner.json +27 -0
  43. package/src/registry/ui/bottom-sheet.json +29 -0
  44. package/src/registry/ui/button.json +24 -0
  45. package/src/registry/ui/calendar.json +29 -0
  46. package/src/registry/ui/card.json +25 -0
  47. package/src/registry/ui/checkbox.json +25 -0
  48. package/src/registry/ui/date-picker.json +30 -0
  49. package/src/registry/ui/date-range-picker.json +33 -0
  50. package/src/registry/ui/dialog.json +25 -0
  51. package/src/registry/ui/form.json +27 -0
  52. package/src/registry/ui/input.json +22 -0
  53. package/src/registry/ui/radio-group.json +26 -0
  54. package/src/registry/ui/radio.json +23 -0
  55. package/src/registry/ui/select-sheet.json +29 -0
  56. package/src/registry/ui/select.json +26 -0
  57. package/src/registry/ui/tabs.json +29 -0
  58. package/src/registry/ui/text.json +22 -0
  59. 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
+ // }