@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,204 @@
|
|
|
1
|
+
// components/ui/dialog.tsx
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { Modal, View, Pressable, Animated } from 'react-native';
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
import { Text } from './text';
|
|
6
|
+
|
|
7
|
+
interface DialogProps {
|
|
8
|
+
open?: boolean;
|
|
9
|
+
onOpenChange?: (open: boolean) => void;
|
|
10
|
+
children: React.ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface DialogContentProps {
|
|
14
|
+
children: React.ReactNode;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface DialogHeaderProps {
|
|
19
|
+
children: React.ReactNode;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface DialogTitleProps {
|
|
24
|
+
children: React.ReactNode;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface DialogDescriptionProps {
|
|
29
|
+
children: React.ReactNode;
|
|
30
|
+
className?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface DialogFooterProps {
|
|
34
|
+
children: React.ReactNode;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DialogContext = React.createContext<{
|
|
39
|
+
open: boolean;
|
|
40
|
+
onOpenChange: (open: boolean) => void;
|
|
41
|
+
} | null>(null);
|
|
42
|
+
|
|
43
|
+
function useDialog() {
|
|
44
|
+
const context = React.useContext(DialogContext);
|
|
45
|
+
if (!context) {
|
|
46
|
+
throw new Error('Dialog components must be used within Dialog');
|
|
47
|
+
}
|
|
48
|
+
return context;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function Dialog({ open: controlledOpen, onOpenChange: controlledOnOpenChange, children }: DialogProps) {
|
|
52
|
+
const [internalOpen, setInternalOpen] = React.useState(false);
|
|
53
|
+
|
|
54
|
+
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
|
55
|
+
const onOpenChange = controlledOnOpenChange || setInternalOpen;
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<DialogContext.Provider value={{ open, onOpenChange }}>
|
|
59
|
+
{children}
|
|
60
|
+
</DialogContext.Provider>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function DialogTrigger({ children }: { children: React.ReactNode }) {
|
|
65
|
+
const { onOpenChange } = useDialog();
|
|
66
|
+
|
|
67
|
+
if (React.isValidElement(children)) {
|
|
68
|
+
return React.cloneElement(children as React.ReactElement<any>, {
|
|
69
|
+
onPress: () => onOpenChange(true),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Pressable onPress={() => onOpenChange(true)}>
|
|
75
|
+
{children}
|
|
76
|
+
</Pressable>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function DialogContent({ children, className }: DialogContentProps) {
|
|
81
|
+
const { open, onOpenChange } = useDialog();
|
|
82
|
+
|
|
83
|
+
const [visible, setVisible] = React.useState(false);
|
|
84
|
+
|
|
85
|
+
const scaleAnim = React.useRef(new Animated.Value(0.9)).current;
|
|
86
|
+
const opacityAnim = React.useRef(new Animated.Value(0)).current;
|
|
87
|
+
|
|
88
|
+
React.useEffect(() => {
|
|
89
|
+
if (open) {
|
|
90
|
+
setVisible(true);
|
|
91
|
+
|
|
92
|
+
Animated.parallel([
|
|
93
|
+
Animated.spring(scaleAnim, {
|
|
94
|
+
toValue: 1,
|
|
95
|
+
useNativeDriver: true,
|
|
96
|
+
tension: 80,
|
|
97
|
+
friction: 10,
|
|
98
|
+
}),
|
|
99
|
+
Animated.timing(opacityAnim, {
|
|
100
|
+
toValue: 1,
|
|
101
|
+
duration: 200,
|
|
102
|
+
useNativeDriver: true,
|
|
103
|
+
}),
|
|
104
|
+
]).start();
|
|
105
|
+
} else {
|
|
106
|
+
Animated.parallel([
|
|
107
|
+
Animated.timing(scaleAnim, {
|
|
108
|
+
toValue: 0.9,
|
|
109
|
+
duration: 150,
|
|
110
|
+
useNativeDriver: true,
|
|
111
|
+
}),
|
|
112
|
+
Animated.timing(opacityAnim, {
|
|
113
|
+
toValue: 0,
|
|
114
|
+
duration: 150,
|
|
115
|
+
useNativeDriver: true,
|
|
116
|
+
}),
|
|
117
|
+
]).start(() => {
|
|
118
|
+
setVisible(false);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}, [open]);
|
|
122
|
+
|
|
123
|
+
if (!visible) return null;
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<Modal
|
|
127
|
+
visible={visible}
|
|
128
|
+
transparent
|
|
129
|
+
animationType="none"
|
|
130
|
+
onRequestClose={() => onOpenChange(false)}
|
|
131
|
+
>
|
|
132
|
+
<Pressable
|
|
133
|
+
onPress={() => onOpenChange(false)}
|
|
134
|
+
className="flex-1 bg-black/50 dark:bg-black/70 items-center justify-center p-4"
|
|
135
|
+
>
|
|
136
|
+
<Animated.View
|
|
137
|
+
style={{
|
|
138
|
+
transform: [{ scale: scaleAnim }],
|
|
139
|
+
opacity: opacityAnim,
|
|
140
|
+
}}
|
|
141
|
+
className="w-full max-w-md"
|
|
142
|
+
>
|
|
143
|
+
<Pressable
|
|
144
|
+
onPress={(e) => e.stopPropagation()}
|
|
145
|
+
className={cn(
|
|
146
|
+
'bg-background rounded-lg p-6 shadow-lg web:min-w-[400px] border border-border',
|
|
147
|
+
className
|
|
148
|
+
)}
|
|
149
|
+
>
|
|
150
|
+
{children}
|
|
151
|
+
</Pressable>
|
|
152
|
+
</Animated.View>
|
|
153
|
+
</Pressable>
|
|
154
|
+
</Modal>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function DialogHeader({ children, className }: DialogHeaderProps) {
|
|
159
|
+
return (
|
|
160
|
+
<View className={cn('mb-4', className)}>
|
|
161
|
+
{children}
|
|
162
|
+
</View>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function DialogTitle({ children, className }: DialogTitleProps) {
|
|
167
|
+
return (
|
|
168
|
+
<Text size="xl" variant="title" className={cn(className)}>
|
|
169
|
+
{children}
|
|
170
|
+
</Text>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function DialogDescription({ children, className }: DialogDescriptionProps) {
|
|
175
|
+
return (
|
|
176
|
+
<Text size="sm" className={cn('text-muted-foreground mt-2', className)}>
|
|
177
|
+
{children}
|
|
178
|
+
</Text>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function DialogFooter({ children, className }: DialogFooterProps) {
|
|
183
|
+
return (
|
|
184
|
+
<View className={cn('flex-row justify-end gap-2 mt-6', className)}>
|
|
185
|
+
{children}
|
|
186
|
+
</View>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function DialogClose({ children }: { children: React.ReactNode }) {
|
|
191
|
+
const { onOpenChange } = useDialog();
|
|
192
|
+
|
|
193
|
+
if (React.isValidElement(children)) {
|
|
194
|
+
return React.cloneElement(children as React.ReactElement<any>, {
|
|
195
|
+
onPress: () => onOpenChange(false),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<Pressable onPress={() => onOpenChange(false)}>
|
|
201
|
+
{children}
|
|
202
|
+
</Pressable>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// components/ui/form.tsx
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { View } from 'react-native';
|
|
4
|
+
import {
|
|
5
|
+
Controller,
|
|
6
|
+
ControllerProps,
|
|
7
|
+
FieldPath,
|
|
8
|
+
FieldValues,
|
|
9
|
+
FormProvider,
|
|
10
|
+
useFormContext,
|
|
11
|
+
} from 'react-hook-form';
|
|
12
|
+
import { cn } from '@/lib/utils';
|
|
13
|
+
import { Text } from './text';
|
|
14
|
+
|
|
15
|
+
export const Form = FormProvider;
|
|
16
|
+
|
|
17
|
+
type FormFieldContextValue<
|
|
18
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
19
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
|
20
|
+
> = {
|
|
21
|
+
name: TName;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
|
25
|
+
{} as FormFieldContextValue
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export const FormField = <
|
|
29
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
30
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
|
31
|
+
>({
|
|
32
|
+
...props
|
|
33
|
+
}: ControllerProps<TFieldValues, TName>) => {
|
|
34
|
+
return (
|
|
35
|
+
<FormFieldContext.Provider value={{ name: props.name }}>
|
|
36
|
+
<Controller {...props} />
|
|
37
|
+
</FormFieldContext.Provider>
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const useFormField = () => {
|
|
42
|
+
const fieldContext = React.useContext(FormFieldContext);
|
|
43
|
+
const itemContext = React.useContext(FormItemContext);
|
|
44
|
+
const { getFieldState, formState } = useFormContext();
|
|
45
|
+
|
|
46
|
+
const fieldState = getFieldState(fieldContext.name, formState);
|
|
47
|
+
|
|
48
|
+
if (!fieldContext) {
|
|
49
|
+
throw new Error('useFormField should be used within <FormField>');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { id } = itemContext;
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
id,
|
|
56
|
+
name: fieldContext.name,
|
|
57
|
+
formItemId: `${id}-form-item`,
|
|
58
|
+
formDescriptionId: `${id}-form-item-description`,
|
|
59
|
+
formMessageId: `${id}-form-item-message`,
|
|
60
|
+
...fieldState,
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type FormItemContextValue = {
|
|
65
|
+
id: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const FormItemContext = React.createContext<FormItemContextValue>(
|
|
69
|
+
{} as FormItemContextValue
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
export const FormItem = React.forwardRef<
|
|
73
|
+
View,
|
|
74
|
+
React.ComponentPropsWithoutRef<typeof View>
|
|
75
|
+
>(({ className, ...props }, ref) => {
|
|
76
|
+
const id = React.useId();
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<FormItemContext.Provider value={{ id }}>
|
|
80
|
+
<View ref={ref} className={cn('mb-4', className)} {...props} />
|
|
81
|
+
</FormItemContext.Provider>
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
FormItem.displayName = 'FormItem';
|
|
85
|
+
|
|
86
|
+
export const FormLabel = ({ className, ...props }: React.ComponentPropsWithoutRef<typeof Text>) => {
|
|
87
|
+
const { error, formItemId } = useFormField();
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<Text
|
|
91
|
+
size="sm"
|
|
92
|
+
variant="label"
|
|
93
|
+
className={cn(
|
|
94
|
+
'mb-2',
|
|
95
|
+
error && 'text-destructive',
|
|
96
|
+
className
|
|
97
|
+
)}
|
|
98
|
+
nativeID={formItemId}
|
|
99
|
+
{...props}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
};
|
|
103
|
+
FormLabel.displayName = 'FormLabel';
|
|
104
|
+
|
|
105
|
+
export const FormDescription = ({ className, ...props }: React.ComponentPropsWithoutRef<typeof Text>) => {
|
|
106
|
+
const { formDescriptionId } = useFormField();
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<Text
|
|
110
|
+
size="sm"
|
|
111
|
+
nativeID={formDescriptionId}
|
|
112
|
+
className={cn('text-muted-foreground mt-2', className)}
|
|
113
|
+
{...props}
|
|
114
|
+
/>
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
FormDescription.displayName = 'FormDescription';
|
|
118
|
+
|
|
119
|
+
export const FormMessage = ({ className, children, ...props }: React.ComponentPropsWithoutRef<typeof Text>) => {
|
|
120
|
+
const { error, formMessageId } = useFormField();
|
|
121
|
+
|
|
122
|
+
const body = error ? String(error?.message) : children;
|
|
123
|
+
|
|
124
|
+
if (!body) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<Text
|
|
130
|
+
size="sm"
|
|
131
|
+
nativeID={formMessageId}
|
|
132
|
+
className={cn('text-destructive mt-1', className)}
|
|
133
|
+
{...props}
|
|
134
|
+
>
|
|
135
|
+
{body}
|
|
136
|
+
</Text>
|
|
137
|
+
);
|
|
138
|
+
};
|
|
139
|
+
FormMessage.displayName = 'FormMessage';
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// components/ui/input.tsx
|
|
2
|
+
import React, { forwardRef, useState } from 'react';
|
|
3
|
+
import { View, TextInput, Platform, type TextInputProps } from 'react-native';
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
import { useThemeColors } from '@/hooks/useThemeColors';
|
|
6
|
+
|
|
7
|
+
export interface InputProps extends TextInputProps {
|
|
8
|
+
variant?: 'outline' | 'underline';
|
|
9
|
+
size?: 'sm' | 'md' | 'lg';
|
|
10
|
+
prefix?: React.ReactNode;
|
|
11
|
+
suffix?: React.ReactNode;
|
|
12
|
+
error?: boolean;
|
|
13
|
+
containerClassName?: string;
|
|
14
|
+
inputClassName?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const Input = forwardRef<TextInput, InputProps>(
|
|
18
|
+
(
|
|
19
|
+
{
|
|
20
|
+
variant = 'outline',
|
|
21
|
+
size = 'md',
|
|
22
|
+
prefix,
|
|
23
|
+
suffix,
|
|
24
|
+
error = false,
|
|
25
|
+
containerClassName,
|
|
26
|
+
inputClassName,
|
|
27
|
+
style,
|
|
28
|
+
...props
|
|
29
|
+
},
|
|
30
|
+
ref
|
|
31
|
+
) => {
|
|
32
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
33
|
+
const { colors } = useThemeColors();
|
|
34
|
+
const isOutline = variant === 'outline';
|
|
35
|
+
|
|
36
|
+
const sizeStyles = {
|
|
37
|
+
sm: 'web:h-9 h-10',
|
|
38
|
+
md: 'web:h-10 h-11',
|
|
39
|
+
lg: 'web:h-12 h-13',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const handleFocus = (e: any) => {
|
|
43
|
+
setIsFocused(true);
|
|
44
|
+
props.onFocus?.(e);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const handleBlur = (e: any) => {
|
|
48
|
+
setIsFocused(false);
|
|
49
|
+
props.onBlur?.(e);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<View
|
|
54
|
+
className={cn(
|
|
55
|
+
'flex-row items-center bg-background',
|
|
56
|
+
sizeStyles[size],
|
|
57
|
+
isOutline && 'border rounded-lg px-3',
|
|
58
|
+
!isOutline && 'border-b',
|
|
59
|
+
isOutline && !error && !isFocused && 'border-input',
|
|
60
|
+
isOutline && !error && isFocused && 'border-ring',
|
|
61
|
+
isOutline && error && 'border-destructive',
|
|
62
|
+
!isOutline && !error && !isFocused && 'border-input',
|
|
63
|
+
!isOutline && !error && isFocused && 'border-ring',
|
|
64
|
+
!isOutline && error && 'border-destructive',
|
|
65
|
+
containerClassName
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
{prefix && <View className="mr-3">{prefix}</View>}
|
|
69
|
+
|
|
70
|
+
<TextInput
|
|
71
|
+
ref={ref}
|
|
72
|
+
{...props}
|
|
73
|
+
style={[
|
|
74
|
+
{
|
|
75
|
+
flex: 1,
|
|
76
|
+
fontSize: 16,
|
|
77
|
+
color: colors.foreground,
|
|
78
|
+
padding: 0,
|
|
79
|
+
paddingVertical: 0,
|
|
80
|
+
paddingHorizontal: 0,
|
|
81
|
+
margin: 0,
|
|
82
|
+
height: '100%',
|
|
83
|
+
textAlignVertical: 'center',
|
|
84
|
+
outlineWidth: 0,
|
|
85
|
+
...(Platform.OS === 'ios' && {
|
|
86
|
+
lineHeight: 20,
|
|
87
|
+
}),
|
|
88
|
+
...(Platform.OS === 'android' && {
|
|
89
|
+
textAlignVertical: 'center',
|
|
90
|
+
}),
|
|
91
|
+
},
|
|
92
|
+
style,
|
|
93
|
+
]}
|
|
94
|
+
className={cn('text-foreground outline-none', inputClassName)}
|
|
95
|
+
placeholderTextColor={colors.mutedForeground}
|
|
96
|
+
onFocus={handleFocus}
|
|
97
|
+
onBlur={handleBlur}
|
|
98
|
+
underlineColorAndroid="transparent"
|
|
99
|
+
/>
|
|
100
|
+
|
|
101
|
+
{suffix && <View className="ml-3">{suffix}</View>}
|
|
102
|
+
</View>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
Input.displayName = 'Input';
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// components/ui/radio-group.tsx
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { View } from 'react-native';
|
|
4
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
6
|
+
import { Text } from './text';
|
|
7
|
+
import { Radio } from './radio';
|
|
8
|
+
|
|
9
|
+
// Radio Group Variants
|
|
10
|
+
const radioGroupVariants = cva(
|
|
11
|
+
'',
|
|
12
|
+
{
|
|
13
|
+
variants: {
|
|
14
|
+
orientation: {
|
|
15
|
+
vertical: 'gap-3',
|
|
16
|
+
horizontal: 'flex-row gap-4',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
defaultVariants: {
|
|
20
|
+
orientation: 'vertical',
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
interface RadioGroupProps extends VariantProps<typeof radioGroupVariants> {
|
|
26
|
+
value?: string;
|
|
27
|
+
onValueChange?: (value: string) => void;
|
|
28
|
+
children: React.ReactNode;
|
|
29
|
+
className?: string;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
size?: 'sm' | 'md' | 'lg';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface RadioGroupItemProps {
|
|
35
|
+
value: string;
|
|
36
|
+
id?: string;
|
|
37
|
+
children?: React.ReactNode;
|
|
38
|
+
className?: string;
|
|
39
|
+
disabled?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface RadioGroupLabelProps {
|
|
43
|
+
children: React.ReactNode;
|
|
44
|
+
className?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const RadioGroupContext = React.createContext<{
|
|
48
|
+
value?: string;
|
|
49
|
+
onValueChange?: (value: string) => void;
|
|
50
|
+
disabled?: boolean;
|
|
51
|
+
size: 'sm' | 'md' | 'lg';
|
|
52
|
+
} | null>(null);
|
|
53
|
+
|
|
54
|
+
function useRadioGroup() {
|
|
55
|
+
const context = React.useContext(RadioGroupContext);
|
|
56
|
+
if (!context) {
|
|
57
|
+
throw new Error('RadioGroupItem must be used within RadioGroup');
|
|
58
|
+
}
|
|
59
|
+
return context;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function RadioGroup({
|
|
63
|
+
value,
|
|
64
|
+
onValueChange,
|
|
65
|
+
children,
|
|
66
|
+
className,
|
|
67
|
+
orientation,
|
|
68
|
+
disabled = false,
|
|
69
|
+
size = 'md',
|
|
70
|
+
}: RadioGroupProps) {
|
|
71
|
+
return (
|
|
72
|
+
<RadioGroupContext.Provider value={{ value, onValueChange, disabled, size }}>
|
|
73
|
+
<View className={cn(radioGroupVariants({ orientation }), className)}>
|
|
74
|
+
{children}
|
|
75
|
+
</View>
|
|
76
|
+
</RadioGroupContext.Provider>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function RadioGroupItem({
|
|
81
|
+
value,
|
|
82
|
+
id,
|
|
83
|
+
children,
|
|
84
|
+
className,
|
|
85
|
+
disabled: itemDisabled,
|
|
86
|
+
}: RadioGroupItemProps) {
|
|
87
|
+
const { value: selectedValue, onValueChange, disabled: groupDisabled, size } = useRadioGroup();
|
|
88
|
+
const isSelected = selectedValue === value;
|
|
89
|
+
const isDisabled = groupDisabled || itemDisabled;
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<Radio
|
|
93
|
+
checked={isSelected}
|
|
94
|
+
onCheckedChange={() => !isDisabled && onValueChange?.(value)}
|
|
95
|
+
disabled={isDisabled}
|
|
96
|
+
size={size}
|
|
97
|
+
value={value}
|
|
98
|
+
className={className}
|
|
99
|
+
>
|
|
100
|
+
{children}
|
|
101
|
+
</Radio>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function RadioGroupLabel({ children, className }: RadioGroupLabelProps) {
|
|
106
|
+
const { size } = useRadioGroup();
|
|
107
|
+
|
|
108
|
+
const textSize = size === 'sm' ? 'sm' : size === 'lg' ? 'md' : 'sm';
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<Text variant="body" size={textSize} className={className}>
|
|
112
|
+
{children}
|
|
113
|
+
</Text>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function RadioGroupDescription({ children, className }: RadioGroupLabelProps) {
|
|
118
|
+
return (
|
|
119
|
+
<Text variant="muted" size="sm" className={cn('mt-0.5', className)}>
|
|
120
|
+
{children}
|
|
121
|
+
</Text>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// components/ui/radio.tsx
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { View, Pressable } from 'react-native';
|
|
4
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
6
|
+
|
|
7
|
+
// Radio Circle Variants
|
|
8
|
+
const radioCircleVariants = cva(
|
|
9
|
+
'rounded-full border-2 items-center justify-center',
|
|
10
|
+
{
|
|
11
|
+
variants: {
|
|
12
|
+
size: {
|
|
13
|
+
sm: 'h-4 w-4 border',
|
|
14
|
+
md: 'h-5 w-5 border-2',
|
|
15
|
+
lg: 'h-6 w-6 border-2',
|
|
16
|
+
},
|
|
17
|
+
checked: {
|
|
18
|
+
true: 'border-primary',
|
|
19
|
+
false: 'border-input',
|
|
20
|
+
},
|
|
21
|
+
disabled: {
|
|
22
|
+
true: 'opacity-50',
|
|
23
|
+
false: '',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
defaultVariants: {
|
|
27
|
+
size: 'md',
|
|
28
|
+
checked: false,
|
|
29
|
+
disabled: false,
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Radio Dot Variants
|
|
35
|
+
const radioDotVariants = cva(
|
|
36
|
+
'rounded-full bg-primary',
|
|
37
|
+
{
|
|
38
|
+
variants: {
|
|
39
|
+
size: {
|
|
40
|
+
sm: 'h-1.5 w-1.5',
|
|
41
|
+
md: 'h-2.5 w-2.5',
|
|
42
|
+
lg: 'h-3 w-3',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
defaultVariants: {
|
|
46
|
+
size: 'md',
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Radio Container Variants
|
|
52
|
+
const radioContainerVariants = cva(
|
|
53
|
+
'flex-row items-center',
|
|
54
|
+
{
|
|
55
|
+
variants: {
|
|
56
|
+
size: {
|
|
57
|
+
sm: 'gap-2',
|
|
58
|
+
md: 'gap-3',
|
|
59
|
+
lg: 'gap-4',
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
defaultVariants: {
|
|
63
|
+
size: 'md',
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
interface RadioProps extends VariantProps<typeof radioCircleVariants> {
|
|
69
|
+
checked?: boolean;
|
|
70
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
71
|
+
children?: React.ReactNode;
|
|
72
|
+
className?: string;
|
|
73
|
+
disabled?: boolean;
|
|
74
|
+
value?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function Radio({
|
|
78
|
+
checked = false,
|
|
79
|
+
onCheckedChange,
|
|
80
|
+
children,
|
|
81
|
+
className,
|
|
82
|
+
size = 'md',
|
|
83
|
+
disabled = false,
|
|
84
|
+
value,
|
|
85
|
+
}: RadioProps) {
|
|
86
|
+
return (
|
|
87
|
+
<Pressable
|
|
88
|
+
onPress={() => !disabled && onCheckedChange?.(!checked)}
|
|
89
|
+
disabled={disabled}
|
|
90
|
+
className={cn(radioContainerVariants({ size }), className)}
|
|
91
|
+
>
|
|
92
|
+
{/* Radio Circle */}
|
|
93
|
+
<View
|
|
94
|
+
className={cn(
|
|
95
|
+
radioCircleVariants({
|
|
96
|
+
size,
|
|
97
|
+
checked,
|
|
98
|
+
disabled,
|
|
99
|
+
})
|
|
100
|
+
)}
|
|
101
|
+
>
|
|
102
|
+
{checked && <View className={cn(radioDotVariants({ size }))} />}
|
|
103
|
+
</View>
|
|
104
|
+
|
|
105
|
+
{/* Label */}
|
|
106
|
+
{children}
|
|
107
|
+
</Pressable>
|
|
108
|
+
);
|
|
109
|
+
}
|