@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,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
+ }