@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,579 @@
|
|
|
1
|
+
// components/ui/bottom-sheet.tsx
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import {
|
|
4
|
+
Modal,
|
|
5
|
+
View,
|
|
6
|
+
Pressable,
|
|
7
|
+
Dimensions,
|
|
8
|
+
FlatList,
|
|
9
|
+
ListRenderItem,
|
|
10
|
+
ScrollView,
|
|
11
|
+
} from 'react-native';
|
|
12
|
+
import Animated, {
|
|
13
|
+
useAnimatedStyle,
|
|
14
|
+
useSharedValue,
|
|
15
|
+
withSpring,
|
|
16
|
+
runOnJS,
|
|
17
|
+
clamp,
|
|
18
|
+
interpolate,
|
|
19
|
+
Extrapolation,
|
|
20
|
+
} from 'react-native-reanimated';
|
|
21
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
22
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
23
|
+
import { cn } from '@/lib/utils';
|
|
24
|
+
import { Text } from './text';
|
|
25
|
+
import { Checkbox } from './checkbox';
|
|
26
|
+
import { Radio } from './radio';
|
|
27
|
+
|
|
28
|
+
const SCREEN_HEIGHT = Dimensions.get('window').height;
|
|
29
|
+
const VELOCITY_THRESHOLD = 300;
|
|
30
|
+
const SPRING_CONFIG = {
|
|
31
|
+
damping: 50,
|
|
32
|
+
stiffness: 400,
|
|
33
|
+
mass: 0.5,
|
|
34
|
+
overshootClamping: false,
|
|
35
|
+
restSpeedThreshold: 0.01,
|
|
36
|
+
restDisplacementThreshold: 0.01,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Bottom Sheet Variants
|
|
40
|
+
const bottomSheetVariants = cva(
|
|
41
|
+
'rounded-t-3xl shadow-2xl h-full',
|
|
42
|
+
{
|
|
43
|
+
variants: {
|
|
44
|
+
variant: {
|
|
45
|
+
default: 'bg-card',
|
|
46
|
+
filled: 'bg-muted',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
defaultVariants: {
|
|
50
|
+
variant: 'default',
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// Drag Handle Variants
|
|
56
|
+
const dragHandleVariants = cva(
|
|
57
|
+
'w-12 h-1.5 rounded-full',
|
|
58
|
+
{
|
|
59
|
+
variants: {
|
|
60
|
+
variant: {
|
|
61
|
+
default: 'bg-muted-foreground/30',
|
|
62
|
+
filled: 'bg-muted-foreground/50',
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
defaultVariants: {
|
|
66
|
+
variant: 'default',
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// List Item Variants
|
|
72
|
+
const listItemVariants = cva(
|
|
73
|
+
'flex-row items-center justify-between px-4 py-3 border-b border-border',
|
|
74
|
+
{
|
|
75
|
+
variants: {
|
|
76
|
+
selected: {
|
|
77
|
+
true: 'bg-accent',
|
|
78
|
+
false: 'bg-transparent',
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
defaultVariants: {
|
|
82
|
+
selected: false,
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
interface BottomSheetProps extends VariantProps<typeof bottomSheetVariants> {
|
|
88
|
+
open?: boolean;
|
|
89
|
+
onOpenChange?: (open: boolean) => void;
|
|
90
|
+
children: React.ReactNode;
|
|
91
|
+
snapPoints?: string[];
|
|
92
|
+
defaultSnapPoint?: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface BottomSheetContentProps {
|
|
96
|
+
children: React.ReactNode;
|
|
97
|
+
className?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface BottomSheetHeaderProps {
|
|
101
|
+
children: React.ReactNode;
|
|
102
|
+
className?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface BottomSheetTitleProps {
|
|
106
|
+
children: React.ReactNode;
|
|
107
|
+
className?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface BottomSheetDescriptionProps {
|
|
111
|
+
children: React.ReactNode;
|
|
112
|
+
className?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface BottomSheetFooterProps {
|
|
116
|
+
children: React.ReactNode;
|
|
117
|
+
className?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface BottomSheetBodyProps {
|
|
121
|
+
children: React.ReactNode;
|
|
122
|
+
className?: string;
|
|
123
|
+
scrollable?: boolean;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface BottomSheetListProps<T> {
|
|
127
|
+
data: T[];
|
|
128
|
+
renderItem?: ListRenderItem<T>;
|
|
129
|
+
keyExtractor?: (item: T, index: number) => string;
|
|
130
|
+
variant?: 'list' | 'select' | 'multiple';
|
|
131
|
+
onSelect?: (selected: T | T[] | null) => void;
|
|
132
|
+
selectedValue?: any;
|
|
133
|
+
selectedValues?: any[];
|
|
134
|
+
getItemValue?: (item: T) => any;
|
|
135
|
+
className?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface BottomSheetListItemProps {
|
|
139
|
+
children: React.ReactNode;
|
|
140
|
+
className?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const BottomSheetContext = React.createContext<{
|
|
144
|
+
open: boolean;
|
|
145
|
+
onOpenChange: (open: boolean) => void;
|
|
146
|
+
snapPoints: string[];
|
|
147
|
+
currentSnapPoint: number;
|
|
148
|
+
variant: 'default' | 'filled';
|
|
149
|
+
} | null>(null);
|
|
150
|
+
|
|
151
|
+
function useBottomSheet() {
|
|
152
|
+
const context = React.useContext(BottomSheetContext);
|
|
153
|
+
if (!context) {
|
|
154
|
+
throw new Error('BottomSheet components must be used within BottomSheet');
|
|
155
|
+
}
|
|
156
|
+
return context;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function BottomSheet({
|
|
160
|
+
open: controlledOpen,
|
|
161
|
+
onOpenChange: controlledOnOpenChange,
|
|
162
|
+
children,
|
|
163
|
+
snapPoints = ['50%'],
|
|
164
|
+
defaultSnapPoint = 0,
|
|
165
|
+
variant = 'default',
|
|
166
|
+
}: BottomSheetProps) {
|
|
167
|
+
const [internalOpen, setInternalOpen] = React.useState(false);
|
|
168
|
+
|
|
169
|
+
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
|
170
|
+
const onOpenChange = controlledOnOpenChange || setInternalOpen;
|
|
171
|
+
const sheetVariant = variant ?? 'default';
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<BottomSheetContext.Provider
|
|
175
|
+
value={{
|
|
176
|
+
open,
|
|
177
|
+
onOpenChange,
|
|
178
|
+
snapPoints,
|
|
179
|
+
currentSnapPoint: defaultSnapPoint,
|
|
180
|
+
variant: sheetVariant
|
|
181
|
+
}}
|
|
182
|
+
>
|
|
183
|
+
{children}
|
|
184
|
+
</BottomSheetContext.Provider>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function BottomSheetTrigger({ children }: { children: React.ReactNode }) {
|
|
189
|
+
const { onOpenChange } = useBottomSheet();
|
|
190
|
+
|
|
191
|
+
if (React.isValidElement(children)) {
|
|
192
|
+
return React.cloneElement(children as React.ReactElement<any>, {
|
|
193
|
+
onPress: () => onOpenChange(true),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return <Pressable onPress={() => onOpenChange(true)}>{children}</Pressable>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function BottomSheetContent({ children, className }: BottomSheetContentProps) {
|
|
201
|
+
const { open, onOpenChange, snapPoints, currentSnapPoint: defaultSnapPoint, variant } = useBottomSheet();
|
|
202
|
+
|
|
203
|
+
const [visible, setVisible] = React.useState(false);
|
|
204
|
+
const [currentSnapIndex, setCurrentSnapIndex] = React.useState(defaultSnapPoint);
|
|
205
|
+
|
|
206
|
+
const snapHeights = React.useMemo(() => {
|
|
207
|
+
return snapPoints.map((point) => {
|
|
208
|
+
const percentage = parseInt(point) / 100;
|
|
209
|
+
return SCREEN_HEIGHT * percentage;
|
|
210
|
+
});
|
|
211
|
+
}, [snapPoints]);
|
|
212
|
+
|
|
213
|
+
const animatedPosition = useSharedValue(0);
|
|
214
|
+
const backdropOpacity = useSharedValue(0);
|
|
215
|
+
|
|
216
|
+
const handleClose = React.useCallback(() => {
|
|
217
|
+
onOpenChange(false);
|
|
218
|
+
}, [onOpenChange]);
|
|
219
|
+
|
|
220
|
+
const handleSnapChange = React.useCallback((newIndex: number) => {
|
|
221
|
+
setCurrentSnapIndex(newIndex);
|
|
222
|
+
}, []);
|
|
223
|
+
|
|
224
|
+
const findClosestSnapPoint = (position: number, velocity: number) => {
|
|
225
|
+
'worklet';
|
|
226
|
+
|
|
227
|
+
const currentHeight = snapHeights[currentSnapIndex];
|
|
228
|
+
|
|
229
|
+
if (Math.abs(velocity) > VELOCITY_THRESHOLD) {
|
|
230
|
+
if (velocity < 0 && currentSnapIndex < snapHeights.length - 1) {
|
|
231
|
+
return currentSnapIndex + 1;
|
|
232
|
+
} else if (velocity > 0 && currentSnapIndex > 0) {
|
|
233
|
+
return currentSnapIndex - 1;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const threshold = currentHeight * 0.3;
|
|
238
|
+
|
|
239
|
+
if (position < -threshold && currentSnapIndex < snapHeights.length - 1) {
|
|
240
|
+
return currentSnapIndex + 1;
|
|
241
|
+
} else if (position > threshold && currentSnapIndex > 0) {
|
|
242
|
+
return currentSnapIndex - 1;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return currentSnapIndex;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const panGesture = Gesture.Pan()
|
|
249
|
+
.onUpdate((event) => {
|
|
250
|
+
const dy = event.translationY;
|
|
251
|
+
const currentHeight = snapHeights[currentSnapIndex];
|
|
252
|
+
const maxHeight = snapHeights[snapHeights.length - 1];
|
|
253
|
+
|
|
254
|
+
if (dy < 0) {
|
|
255
|
+
if (currentSnapIndex < snapHeights.length - 1) {
|
|
256
|
+
const maxDrag = -(maxHeight - currentHeight);
|
|
257
|
+
animatedPosition.value = clamp(dy, maxDrag, 0);
|
|
258
|
+
} else {
|
|
259
|
+
animatedPosition.value = dy * 0.15;
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
if (currentSnapIndex > 0) {
|
|
263
|
+
animatedPosition.value = dy;
|
|
264
|
+
} else {
|
|
265
|
+
const rubberband = Math.min(dy / (currentHeight * 0.5), 1);
|
|
266
|
+
animatedPosition.value = dy * (1 - rubberband * 0.7);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
.onEnd((event) => {
|
|
271
|
+
const dy = event.translationY;
|
|
272
|
+
const vy = event.velocityY;
|
|
273
|
+
const currentHeight = snapHeights[currentSnapIndex];
|
|
274
|
+
|
|
275
|
+
if (currentSnapIndex === 0 && (dy > currentHeight * 0.4 || vy > 1000)) {
|
|
276
|
+
animatedPosition.value = withSpring(SCREEN_HEIGHT, SPRING_CONFIG);
|
|
277
|
+
backdropOpacity.value = withSpring(0, SPRING_CONFIG);
|
|
278
|
+
runOnJS(handleClose)();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const targetIndex = findClosestSnapPoint(dy, vy);
|
|
283
|
+
|
|
284
|
+
if (targetIndex !== currentSnapIndex) {
|
|
285
|
+
runOnJS(handleSnapChange)(targetIndex);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
animatedPosition.value = withSpring(0, SPRING_CONFIG);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
React.useEffect(() => {
|
|
292
|
+
if (open) {
|
|
293
|
+
setVisible(true);
|
|
294
|
+
setCurrentSnapIndex(defaultSnapPoint);
|
|
295
|
+
|
|
296
|
+
animatedPosition.value = withSpring(0, SPRING_CONFIG);
|
|
297
|
+
backdropOpacity.value = withSpring(1, SPRING_CONFIG);
|
|
298
|
+
} else {
|
|
299
|
+
animatedPosition.value = withSpring(SCREEN_HEIGHT, SPRING_CONFIG);
|
|
300
|
+
backdropOpacity.value = withSpring(0, SPRING_CONFIG);
|
|
301
|
+
|
|
302
|
+
const timer = setTimeout(() => {
|
|
303
|
+
setVisible(false);
|
|
304
|
+
}, 300);
|
|
305
|
+
|
|
306
|
+
return () => clearTimeout(timer);
|
|
307
|
+
}
|
|
308
|
+
}, [open, defaultSnapPoint]);
|
|
309
|
+
|
|
310
|
+
const backdropAnimatedStyle = useAnimatedStyle(() => ({
|
|
311
|
+
opacity: backdropOpacity.value,
|
|
312
|
+
}));
|
|
313
|
+
|
|
314
|
+
const sheetAnimatedStyle = useAnimatedStyle(() => {
|
|
315
|
+
const currentHeight = snapHeights[currentSnapIndex];
|
|
316
|
+
const position = animatedPosition.value;
|
|
317
|
+
|
|
318
|
+
let height = currentHeight;
|
|
319
|
+
|
|
320
|
+
if (position < 0 && currentSnapIndex < snapHeights.length - 1) {
|
|
321
|
+
const nextHeight = snapHeights[currentSnapIndex + 1];
|
|
322
|
+
const maxDrag = nextHeight - currentHeight;
|
|
323
|
+
|
|
324
|
+
const progress = clamp(Math.abs(position) / maxDrag, 0, 1);
|
|
325
|
+
height = interpolate(
|
|
326
|
+
progress,
|
|
327
|
+
[0, 1],
|
|
328
|
+
[currentHeight, nextHeight],
|
|
329
|
+
Extrapolation.CLAMP
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
height,
|
|
335
|
+
transform: [
|
|
336
|
+
{
|
|
337
|
+
translateY: position > 0 ? position : position * 0.1,
|
|
338
|
+
},
|
|
339
|
+
],
|
|
340
|
+
};
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
if (!visible) return null;
|
|
344
|
+
|
|
345
|
+
return (
|
|
346
|
+
<Modal visible={visible} transparent animationType="none" onRequestClose={handleClose}>
|
|
347
|
+
<View style={{ flex: 1 }}>
|
|
348
|
+
{/* Backdrop */}
|
|
349
|
+
<Animated.View
|
|
350
|
+
style={[
|
|
351
|
+
{
|
|
352
|
+
flex: 1,
|
|
353
|
+
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
354
|
+
},
|
|
355
|
+
backdropAnimatedStyle,
|
|
356
|
+
]}
|
|
357
|
+
>
|
|
358
|
+
<Pressable onPress={handleClose} style={{ flex: 1 }} />
|
|
359
|
+
</Animated.View>
|
|
360
|
+
|
|
361
|
+
{/* Bottom Sheet */}
|
|
362
|
+
<Animated.View
|
|
363
|
+
style={[
|
|
364
|
+
{
|
|
365
|
+
position: 'absolute',
|
|
366
|
+
bottom: 0,
|
|
367
|
+
left: 0,
|
|
368
|
+
right: 0,
|
|
369
|
+
},
|
|
370
|
+
sheetAnimatedStyle,
|
|
371
|
+
]}
|
|
372
|
+
>
|
|
373
|
+
<View className={cn(bottomSheetVariants({ variant }), className)}>
|
|
374
|
+
{/* Drag Handle */}
|
|
375
|
+
<GestureDetector gesture={panGesture}>
|
|
376
|
+
<Animated.View className="items-center py-3">
|
|
377
|
+
<View className={cn(dragHandleVariants({ variant }))} />
|
|
378
|
+
</Animated.View>
|
|
379
|
+
</GestureDetector>
|
|
380
|
+
|
|
381
|
+
{/* Content */}
|
|
382
|
+
<View className="flex-1 pb-20">
|
|
383
|
+
{children}
|
|
384
|
+
</View>
|
|
385
|
+
</View>
|
|
386
|
+
</Animated.View>
|
|
387
|
+
</View>
|
|
388
|
+
</Modal>
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function BottomSheetHeader({ children, className }: BottomSheetHeaderProps) {
|
|
393
|
+
return <View className={cn('px-4 pb-2', className)}>{children}</View>;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function BottomSheetTitle({ children, className }: BottomSheetTitleProps) {
|
|
397
|
+
return (
|
|
398
|
+
<Text variant="header" size="md" className={className}>
|
|
399
|
+
{children}
|
|
400
|
+
</Text>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function BottomSheetDescription({ children, className }: BottomSheetDescriptionProps) {
|
|
405
|
+
return (
|
|
406
|
+
<Text variant="muted" size="sm" className={cn('mt-2', className)}>
|
|
407
|
+
{children}
|
|
408
|
+
</Text>
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function BottomSheetBody({ children, className, scrollable }: BottomSheetBodyProps) {
|
|
413
|
+
if (!scrollable) {
|
|
414
|
+
return <View className={cn('flex-1 px-4', className)}>{children}</View>;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return (
|
|
418
|
+
<ScrollView
|
|
419
|
+
className={cn('flex-1 px-4', className)}
|
|
420
|
+
showsVerticalScrollIndicator={false}
|
|
421
|
+
bounces={false}
|
|
422
|
+
>
|
|
423
|
+
{children}
|
|
424
|
+
</ScrollView>
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function BottomSheetList<T>({
|
|
429
|
+
data,
|
|
430
|
+
renderItem,
|
|
431
|
+
keyExtractor,
|
|
432
|
+
variant = 'list',
|
|
433
|
+
onSelect,
|
|
434
|
+
selectedValue,
|
|
435
|
+
selectedValues = [],
|
|
436
|
+
getItemValue,
|
|
437
|
+
className,
|
|
438
|
+
}: BottomSheetListProps<T>) {
|
|
439
|
+
const [internalSelectedValues, setInternalSelectedValues] =
|
|
440
|
+
React.useState<any[]>(selectedValues);
|
|
441
|
+
|
|
442
|
+
const defaultKeyExtractor = (item: T, index: number) => {
|
|
443
|
+
if (typeof item === 'object' && item !== null && 'id' in item) {
|
|
444
|
+
return String((item as any).id);
|
|
445
|
+
}
|
|
446
|
+
return String(index);
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const defaultGetItemValue = (item: T) => {
|
|
450
|
+
if (typeof item === 'object' && item !== null && 'value' in item) {
|
|
451
|
+
return (item as any).value;
|
|
452
|
+
}
|
|
453
|
+
return item;
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const finalKeyExtractor = keyExtractor || defaultKeyExtractor;
|
|
457
|
+
const finalGetItemValue = getItemValue || defaultGetItemValue;
|
|
458
|
+
|
|
459
|
+
const handleSelect = (item: T) => {
|
|
460
|
+
const itemValue = finalGetItemValue(item);
|
|
461
|
+
|
|
462
|
+
if (variant === 'select') {
|
|
463
|
+
onSelect?.(item);
|
|
464
|
+
} else if (variant === 'multiple') {
|
|
465
|
+
const isSelected = internalSelectedValues.includes(itemValue);
|
|
466
|
+
let newSelected: any[];
|
|
467
|
+
|
|
468
|
+
if (isSelected) {
|
|
469
|
+
newSelected = internalSelectedValues.filter((v) => v !== itemValue);
|
|
470
|
+
} else {
|
|
471
|
+
newSelected = [...internalSelectedValues, itemValue];
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
setInternalSelectedValues(newSelected);
|
|
475
|
+
|
|
476
|
+
const selectedItems = data.filter((d) => newSelected.includes(finalGetItemValue(d)));
|
|
477
|
+
onSelect?.(selectedItems as T[]);
|
|
478
|
+
} else {
|
|
479
|
+
onSelect?.(item);
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const isItemSelected = (item: T) => {
|
|
484
|
+
const itemValue = finalGetItemValue(item);
|
|
485
|
+
|
|
486
|
+
if (variant === 'select') {
|
|
487
|
+
return selectedValue === itemValue;
|
|
488
|
+
} else if (variant === 'multiple') {
|
|
489
|
+
return internalSelectedValues.includes(itemValue);
|
|
490
|
+
}
|
|
491
|
+
return false;
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const defaultRenderItem: ListRenderItem<T> = ({ item }) => {
|
|
495
|
+
const isSelected = isItemSelected(item);
|
|
496
|
+
const itemLabel =
|
|
497
|
+
typeof item === 'object' && item !== null && 'label' in item
|
|
498
|
+
? (item as any).label
|
|
499
|
+
: String(item);
|
|
500
|
+
|
|
501
|
+
return (
|
|
502
|
+
<Pressable
|
|
503
|
+
onPress={() => handleSelect(item)}
|
|
504
|
+
className={cn(listItemVariants({ selected: isSelected }))}
|
|
505
|
+
>
|
|
506
|
+
<View className="flex-row items-center gap-3 flex-1">
|
|
507
|
+
{variant === 'select' && (
|
|
508
|
+
<Radio checked={isSelected} />
|
|
509
|
+
)}
|
|
510
|
+
|
|
511
|
+
{variant === 'multiple' && (
|
|
512
|
+
<Checkbox checked={isSelected} />
|
|
513
|
+
)}
|
|
514
|
+
|
|
515
|
+
<Text
|
|
516
|
+
variant={isSelected ? 'default' : 'default'}
|
|
517
|
+
size="md"
|
|
518
|
+
className={cn(
|
|
519
|
+
'flex-1',
|
|
520
|
+
isSelected && 'text-primary font-semibold'
|
|
521
|
+
)}
|
|
522
|
+
>
|
|
523
|
+
{itemLabel}
|
|
524
|
+
</Text>
|
|
525
|
+
</View>
|
|
526
|
+
|
|
527
|
+
{variant === 'select' && isSelected && (
|
|
528
|
+
<Text className="text-primary font-bold">✓</Text>
|
|
529
|
+
)}
|
|
530
|
+
</Pressable>
|
|
531
|
+
);
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const finalRenderItem = renderItem || defaultRenderItem;
|
|
535
|
+
|
|
536
|
+
return (
|
|
537
|
+
<FlatList
|
|
538
|
+
data={data}
|
|
539
|
+
renderItem={finalRenderItem}
|
|
540
|
+
keyExtractor={finalKeyExtractor}
|
|
541
|
+
className={cn('flex-1 px-0', className)}
|
|
542
|
+
showsVerticalScrollIndicator={false}
|
|
543
|
+
bounces={false}
|
|
544
|
+
/>
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export function BottomSheetListItem({ children, className }: BottomSheetListItemProps) {
|
|
549
|
+
return (
|
|
550
|
+
<View className={cn('px-4 py-3 border-b border-border', className)}>
|
|
551
|
+
{children}
|
|
552
|
+
</View>
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function BottomSheetFooter({ children, className }: BottomSheetFooterProps) {
|
|
557
|
+
return (
|
|
558
|
+
<View
|
|
559
|
+
className={cn(
|
|
560
|
+
'absolute bottom-0 left-0 right-0 bg-card px-4 py-4 border-t border-border',
|
|
561
|
+
className
|
|
562
|
+
)}
|
|
563
|
+
>
|
|
564
|
+
{children}
|
|
565
|
+
</View>
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export function BottomSheetClose({ children }: { children: React.ReactNode }) {
|
|
570
|
+
const { onOpenChange } = useBottomSheet();
|
|
571
|
+
|
|
572
|
+
if (React.isValidElement(children)) {
|
|
573
|
+
return React.cloneElement(children as React.ReactElement<any>, {
|
|
574
|
+
onPress: () => onOpenChange(false),
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return <Pressable onPress={() => onOpenChange(false)}>{children}</Pressable>;
|
|
579
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// components/ui/button.tsx
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { Pressable, Text as RNText, ActivityIndicator } from 'react-native';
|
|
4
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
6
|
+
|
|
7
|
+
const buttonVariants = cva(
|
|
8
|
+
'items-center justify-center rounded-md flex-row gap-2',
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: 'bg-primary',
|
|
13
|
+
destructive: 'bg-destructive',
|
|
14
|
+
outline: 'border border-input bg-background',
|
|
15
|
+
secondary: 'bg-secondary',
|
|
16
|
+
ghost: 'bg-transparent',
|
|
17
|
+
link: 'bg-transparent',
|
|
18
|
+
},
|
|
19
|
+
size: {
|
|
20
|
+
default: 'h-10 px-4 py-2',
|
|
21
|
+
sm: 'h-9 px-3 py-1.5',
|
|
22
|
+
lg: 'h-11 px-8 py-3',
|
|
23
|
+
icon: 'h-10 w-10',
|
|
24
|
+
},
|
|
25
|
+
disabled: {
|
|
26
|
+
true: 'opacity-50',
|
|
27
|
+
false: '',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
defaultVariants: {
|
|
31
|
+
variant: 'default',
|
|
32
|
+
size: 'default',
|
|
33
|
+
disabled: false,
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const buttonTextVariants = cva(
|
|
39
|
+
'font-semibold text-center',
|
|
40
|
+
{
|
|
41
|
+
variants: {
|
|
42
|
+
variant: {
|
|
43
|
+
default: 'text-primary-foreground',
|
|
44
|
+
destructive: 'text-destructive-foreground',
|
|
45
|
+
outline: 'text-foreground',
|
|
46
|
+
secondary: 'text-secondary-foreground',
|
|
47
|
+
ghost: 'text-foreground',
|
|
48
|
+
link: 'text-primary underline',
|
|
49
|
+
},
|
|
50
|
+
size: {
|
|
51
|
+
default: 'text-sm',
|
|
52
|
+
sm: 'text-xs',
|
|
53
|
+
lg: 'text-base',
|
|
54
|
+
icon: 'text-sm',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
defaultVariants: {
|
|
58
|
+
variant: 'default',
|
|
59
|
+
size: 'default',
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
export interface ButtonProps
|
|
65
|
+
extends React.ComponentPropsWithoutRef<typeof Pressable>,
|
|
66
|
+
VariantProps<typeof buttonVariants> {
|
|
67
|
+
children: React.ReactNode;
|
|
68
|
+
className?: string;
|
|
69
|
+
textClassName?: string;
|
|
70
|
+
loading?: boolean;
|
|
71
|
+
leftIcon?: React.ReactNode;
|
|
72
|
+
rightIcon?: React.ReactNode;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function Button({
|
|
76
|
+
children,
|
|
77
|
+
variant = 'default',
|
|
78
|
+
size = 'default',
|
|
79
|
+
disabled = false,
|
|
80
|
+
loading = false,
|
|
81
|
+
leftIcon,
|
|
82
|
+
rightIcon,
|
|
83
|
+
className,
|
|
84
|
+
textClassName,
|
|
85
|
+
...props
|
|
86
|
+
}: ButtonProps) {
|
|
87
|
+
const isDisabled = disabled || loading;
|
|
88
|
+
|
|
89
|
+
// Check if children is only icon (for icon button)
|
|
90
|
+
const isIconOnly = size === 'icon' && typeof children !== 'string';
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<Pressable
|
|
94
|
+
disabled={isDisabled}
|
|
95
|
+
className={cn(
|
|
96
|
+
buttonVariants({
|
|
97
|
+
variant,
|
|
98
|
+
size,
|
|
99
|
+
disabled: isDisabled
|
|
100
|
+
}),
|
|
101
|
+
className
|
|
102
|
+
)}
|
|
103
|
+
{...props}
|
|
104
|
+
>
|
|
105
|
+
{/* Loading Indicator */}
|
|
106
|
+
{loading && (
|
|
107
|
+
<ActivityIndicator
|
|
108
|
+
size="small"
|
|
109
|
+
color={
|
|
110
|
+
variant === 'default' || variant === 'destructive'
|
|
111
|
+
? '#ffffff'
|
|
112
|
+
: variant === 'outline' || variant === 'ghost'
|
|
113
|
+
? '#0f172a'
|
|
114
|
+
: '#0f172a'
|
|
115
|
+
}
|
|
116
|
+
/>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{/* Left Icon */}
|
|
120
|
+
{!loading && leftIcon && leftIcon}
|
|
121
|
+
|
|
122
|
+
{/* Text or Icon Content */}
|
|
123
|
+
{!loading && (
|
|
124
|
+
isIconOnly ? (
|
|
125
|
+
children
|
|
126
|
+
) : (
|
|
127
|
+
<RNText
|
|
128
|
+
className={cn(
|
|
129
|
+
buttonTextVariants({ variant, size }),
|
|
130
|
+
textClassName
|
|
131
|
+
)}
|
|
132
|
+
>
|
|
133
|
+
{children}
|
|
134
|
+
</RNText>
|
|
135
|
+
)
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
{/* Right Icon */}
|
|
139
|
+
{!loading && rightIcon && rightIcon}
|
|
140
|
+
</Pressable>
|
|
141
|
+
);
|
|
142
|
+
}
|