@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,199 @@
|
|
|
1
|
+
|
|
2
|
+
TODO:
|
|
3
|
+
|
|
4
|
+
// components/ui/accordion.tsx
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
import { View, Text, Pressable, Animated, LayoutAnimation, Platform, UIManager } from 'react-native';
|
|
7
|
+
import { cn } from '@/lib/utils';
|
|
8
|
+
|
|
9
|
+
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
|
10
|
+
UIManager.setLayoutAnimationEnabledExperimental(true);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type AccordionType = 'single' | 'multiple';
|
|
14
|
+
|
|
15
|
+
interface AccordionProps {
|
|
16
|
+
type?: AccordionType;
|
|
17
|
+
value?: string | string[];
|
|
18
|
+
onValueChange?: (value: string | string[]) => void;
|
|
19
|
+
children: React.ReactNode;
|
|
20
|
+
className?: string;
|
|
21
|
+
collapsible?: boolean; // Allow closing open item in single mode
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface AccordionItemProps {
|
|
25
|
+
value: string;
|
|
26
|
+
children: React.ReactNode;
|
|
27
|
+
className?: string;
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface AccordionTriggerProps {
|
|
32
|
+
children: React.ReactNode;
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface AccordionContentProps {
|
|
37
|
+
children: React.ReactNode;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const AccordionContext = React.createContext<{
|
|
42
|
+
type: AccordionType;
|
|
43
|
+
value?: string | string[];
|
|
44
|
+
onValueChange?: (value: string | string[]) => void;
|
|
45
|
+
collapsible: boolean;
|
|
46
|
+
} | null>(null);
|
|
47
|
+
|
|
48
|
+
const AccordionItemContext = React.createContext<{
|
|
49
|
+
value: string;
|
|
50
|
+
isOpen: boolean;
|
|
51
|
+
toggle: () => void;
|
|
52
|
+
disabled: boolean;
|
|
53
|
+
} | null>(null);
|
|
54
|
+
|
|
55
|
+
function useAccordion() {
|
|
56
|
+
const context = React.useContext(AccordionContext);
|
|
57
|
+
if (!context) {
|
|
58
|
+
throw new Error('Accordion components must be used within Accordion');
|
|
59
|
+
}
|
|
60
|
+
return context;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function useAccordionItem() {
|
|
64
|
+
const context = React.useContext(AccordionItemContext);
|
|
65
|
+
if (!context) {
|
|
66
|
+
throw new Error('AccordionTrigger and AccordionContent must be used within AccordionItem');
|
|
67
|
+
}
|
|
68
|
+
return context;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function Accordion({
|
|
72
|
+
type = 'single',
|
|
73
|
+
value,
|
|
74
|
+
onValueChange,
|
|
75
|
+
children,
|
|
76
|
+
className,
|
|
77
|
+
collapsible = false,
|
|
78
|
+
}: AccordionProps) {
|
|
79
|
+
return (
|
|
80
|
+
<AccordionContext.Provider value={{ type, value, onValueChange, collapsible }}>
|
|
81
|
+
<View className={cn('border border-slate-200 rounded-lg overflow-hidden', className)}>
|
|
82
|
+
{children}
|
|
83
|
+
</View>
|
|
84
|
+
</AccordionContext.Provider>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function AccordionItem({ value, children, className, disabled = false }: AccordionItemProps) {
|
|
89
|
+
const { type, value: accordionValue, onValueChange, collapsible } = useAccordion();
|
|
90
|
+
|
|
91
|
+
const isOpen = React.useMemo(() => {
|
|
92
|
+
if (type === 'single') {
|
|
93
|
+
return accordionValue === value;
|
|
94
|
+
} else {
|
|
95
|
+
return Array.isArray(accordionValue) && accordionValue.includes(value);
|
|
96
|
+
}
|
|
97
|
+
}, [type, accordionValue, value]);
|
|
98
|
+
|
|
99
|
+
const toggle = () => {
|
|
100
|
+
if (disabled) return;
|
|
101
|
+
|
|
102
|
+
LayoutAnimation.configureNext(
|
|
103
|
+
LayoutAnimation.create(250, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
if (type === 'single') {
|
|
107
|
+
// Single mode: only one item can be open
|
|
108
|
+
if (isOpen && collapsible) {
|
|
109
|
+
onValueChange?.('');
|
|
110
|
+
} else {
|
|
111
|
+
onValueChange?.(value);
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
// Multiple mode: multiple items can be open
|
|
115
|
+
const currentValues = (accordionValue as string[]) || [];
|
|
116
|
+
if (isOpen) {
|
|
117
|
+
onValueChange?.(currentValues.filter((v) => v !== value));
|
|
118
|
+
} else {
|
|
119
|
+
onValueChange?.([...currentValues, value]);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<AccordionItemContext.Provider value={{ value, isOpen, toggle, disabled }}>
|
|
126
|
+
<View className={cn('border-b border-slate-200 last:border-b-0', className)}>
|
|
127
|
+
{children}
|
|
128
|
+
</View>
|
|
129
|
+
</AccordionItemContext.Provider>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function AccordionTrigger({ children, className }: AccordionTriggerProps) {
|
|
134
|
+
const { isOpen, toggle, disabled } = useAccordionItem();
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<Pressable
|
|
138
|
+
onPress={toggle}
|
|
139
|
+
disabled={disabled}
|
|
140
|
+
className={cn(
|
|
141
|
+
'flex-row items-center justify-between px-4 py-4 bg-white',
|
|
142
|
+
disabled && 'opacity-50',
|
|
143
|
+
className
|
|
144
|
+
)}
|
|
145
|
+
>
|
|
146
|
+
<View className="flex-1 pr-2">{children}</View>
|
|
147
|
+
|
|
148
|
+
{/* Chevron Icon */}
|
|
149
|
+
<Animated.View
|
|
150
|
+
style={{
|
|
151
|
+
transform: [{ rotate: isOpen ? '180deg' : '0deg' }],
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
<Text className="text-slate-500 text-sm">▼</Text>
|
|
155
|
+
</Animated.View>
|
|
156
|
+
</Pressable>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function AccordionContent({ children, className }: AccordionContentProps) {
|
|
161
|
+
const { isOpen } = useAccordionItem();
|
|
162
|
+
|
|
163
|
+
if (!isOpen) return null;
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<View className={cn('px-4 pb-4 bg-slate-50', className)}>
|
|
167
|
+
{children}
|
|
168
|
+
</View>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Helper components for common patterns
|
|
173
|
+
export function AccordionTriggerText({
|
|
174
|
+
children,
|
|
175
|
+
className
|
|
176
|
+
}: {
|
|
177
|
+
children: React.ReactNode;
|
|
178
|
+
className?: string;
|
|
179
|
+
}) {
|
|
180
|
+
return (
|
|
181
|
+
<Text className={cn('text-base font-medium text-slate-900', className)}>
|
|
182
|
+
{children}
|
|
183
|
+
</Text>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function AccordionContentText({
|
|
188
|
+
children,
|
|
189
|
+
className
|
|
190
|
+
}: {
|
|
191
|
+
children: React.ReactNode;
|
|
192
|
+
className?: string;
|
|
193
|
+
}) {
|
|
194
|
+
return (
|
|
195
|
+
<Text className={cn('text-sm text-slate-600 leading-6', className)}>
|
|
196
|
+
{children}
|
|
197
|
+
</Text>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// components/ui/accordion.tsx
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { View, Text, Pressable } from 'react-native';
|
|
4
|
+
import Animated, {
|
|
5
|
+
useAnimatedStyle,
|
|
6
|
+
useSharedValue,
|
|
7
|
+
withTiming,
|
|
8
|
+
Easing,
|
|
9
|
+
} from 'react-native-reanimated';
|
|
10
|
+
import { cn } from '@/lib/utils';
|
|
11
|
+
|
|
12
|
+
type AccordionType = 'single' | 'multiple';
|
|
13
|
+
|
|
14
|
+
interface AccordionProps {
|
|
15
|
+
type?: AccordionType;
|
|
16
|
+
value?: string | string[];
|
|
17
|
+
onValueChange?: (value: string | string[]) => void;
|
|
18
|
+
children: React.ReactNode;
|
|
19
|
+
className?: string;
|
|
20
|
+
collapsible?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface AccordionItemProps {
|
|
24
|
+
value: string;
|
|
25
|
+
children: React.ReactNode;
|
|
26
|
+
className?: string;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface AccordionTriggerProps {
|
|
31
|
+
children: React.ReactNode;
|
|
32
|
+
className?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface AccordionContentProps {
|
|
36
|
+
children: React.ReactNode;
|
|
37
|
+
className?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const AccordionContext = React.createContext<{
|
|
41
|
+
type: AccordionType;
|
|
42
|
+
value?: string | string[];
|
|
43
|
+
onValueChange?: (value: string | string[]) => void;
|
|
44
|
+
collapsible: boolean;
|
|
45
|
+
} | null>(null);
|
|
46
|
+
|
|
47
|
+
const AccordionItemContext = React.createContext<{
|
|
48
|
+
value: string;
|
|
49
|
+
isOpen: boolean;
|
|
50
|
+
toggle: () => void;
|
|
51
|
+
disabled: boolean;
|
|
52
|
+
} | null>(null);
|
|
53
|
+
|
|
54
|
+
function useAccordion() {
|
|
55
|
+
const context = React.useContext(AccordionContext);
|
|
56
|
+
if (!context) {
|
|
57
|
+
throw new Error('Accordion components must be used within Accordion');
|
|
58
|
+
}
|
|
59
|
+
return context;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function useAccordionItem() {
|
|
63
|
+
const context = React.useContext(AccordionItemContext);
|
|
64
|
+
if (!context) {
|
|
65
|
+
throw new Error('AccordionTrigger and AccordionContent must be used within AccordionItem');
|
|
66
|
+
}
|
|
67
|
+
return context;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function Accordion({
|
|
71
|
+
type = 'single',
|
|
72
|
+
value,
|
|
73
|
+
onValueChange,
|
|
74
|
+
children,
|
|
75
|
+
className,
|
|
76
|
+
collapsible = false,
|
|
77
|
+
}: AccordionProps) {
|
|
78
|
+
return (
|
|
79
|
+
<AccordionContext.Provider value={{ type, value, onValueChange, collapsible }}>
|
|
80
|
+
<View className={cn('border border-slate-200 rounded-lg overflow-hidden', className)}>
|
|
81
|
+
{children}
|
|
82
|
+
</View>
|
|
83
|
+
</AccordionContext.Provider>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function AccordionItem({ value, children, className, disabled = false }: AccordionItemProps) {
|
|
88
|
+
const { type, value: accordionValue, onValueChange, collapsible } = useAccordion();
|
|
89
|
+
|
|
90
|
+
const isOpen = React.useMemo(() => {
|
|
91
|
+
if (type === 'single') {
|
|
92
|
+
return accordionValue === value;
|
|
93
|
+
} else {
|
|
94
|
+
return Array.isArray(accordionValue) && accordionValue.includes(value);
|
|
95
|
+
}
|
|
96
|
+
}, [type, accordionValue, value]);
|
|
97
|
+
|
|
98
|
+
const toggle = () => {
|
|
99
|
+
if (disabled) return;
|
|
100
|
+
|
|
101
|
+
if (type === 'single') {
|
|
102
|
+
if (isOpen && collapsible) {
|
|
103
|
+
onValueChange?.('');
|
|
104
|
+
} else {
|
|
105
|
+
onValueChange?.(value);
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
const currentValues = (accordionValue as string[]) || [];
|
|
109
|
+
if (isOpen) {
|
|
110
|
+
onValueChange?.(currentValues.filter((v) => v !== value));
|
|
111
|
+
} else {
|
|
112
|
+
onValueChange?.([...currentValues, value]);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<AccordionItemContext.Provider value={{ value, isOpen, toggle, disabled }}>
|
|
119
|
+
<View className={cn('border-b border-slate-200 last:border-b-0', className)}>
|
|
120
|
+
{children}
|
|
121
|
+
</View>
|
|
122
|
+
</AccordionItemContext.Provider>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function AccordionTrigger({ children, className }: AccordionTriggerProps) {
|
|
127
|
+
const { isOpen, toggle, disabled } = useAccordionItem();
|
|
128
|
+
const rotation = useSharedValue(0);
|
|
129
|
+
|
|
130
|
+
React.useEffect(() => {
|
|
131
|
+
rotation.value = withTiming(isOpen ? 180 : 0, {
|
|
132
|
+
duration: 200,
|
|
133
|
+
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
|
|
134
|
+
});
|
|
135
|
+
}, [isOpen]);
|
|
136
|
+
|
|
137
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
138
|
+
transform: [{ rotate: `${rotation.value}deg` }],
|
|
139
|
+
}));
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<Pressable
|
|
143
|
+
onPress={toggle}
|
|
144
|
+
disabled={disabled}
|
|
145
|
+
className={cn(
|
|
146
|
+
'flex-row items-center justify-between px-4 py-4 bg-white',
|
|
147
|
+
disabled && 'opacity-50',
|
|
148
|
+
className
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
<View className="flex-1 pr-2">{children}</View>
|
|
152
|
+
|
|
153
|
+
<Animated.View style={animatedStyle}>
|
|
154
|
+
<Text className="text-slate-500 text-sm">▼</Text>
|
|
155
|
+
</Animated.View>
|
|
156
|
+
</Pressable>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function AccordionContent({ children, className }: AccordionContentProps) {
|
|
161
|
+
const { isOpen } = useAccordionItem();
|
|
162
|
+
const height = useSharedValue(0);
|
|
163
|
+
const opacity = useSharedValue(0);
|
|
164
|
+
const [measured, setMeasured] = React.useState(false);
|
|
165
|
+
const [contentHeight, setContentHeight] = React.useState(0);
|
|
166
|
+
|
|
167
|
+
React.useEffect(() => {
|
|
168
|
+
if (measured) {
|
|
169
|
+
height.value = withTiming(isOpen ? contentHeight : 0, {
|
|
170
|
+
duration: 250,
|
|
171
|
+
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
|
|
172
|
+
});
|
|
173
|
+
opacity.value = withTiming(isOpen ? 1 : 0, {
|
|
174
|
+
duration: 200,
|
|
175
|
+
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}, [isOpen, measured, contentHeight]);
|
|
179
|
+
|
|
180
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
181
|
+
height: measured ? height.value : undefined,
|
|
182
|
+
opacity: measured ? opacity.value : 1,
|
|
183
|
+
overflow: 'hidden',
|
|
184
|
+
}));
|
|
185
|
+
|
|
186
|
+
const contentWrapperStyle = useAnimatedStyle(() => ({
|
|
187
|
+
// Fix text rewrap: use absolute positioning during animation
|
|
188
|
+
position: measured && height.value > 0 && height.value < contentHeight ? 'absolute' : 'relative',
|
|
189
|
+
top: 0,
|
|
190
|
+
left: 0,
|
|
191
|
+
right: 0,
|
|
192
|
+
}));
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<Animated.View style={animatedStyle}>
|
|
196
|
+
<Animated.View
|
|
197
|
+
style={contentWrapperStyle}
|
|
198
|
+
onLayout={(event) => {
|
|
199
|
+
const h = event.nativeEvent.layout.height;
|
|
200
|
+
if (h > 0 && !measured) {
|
|
201
|
+
setContentHeight(h);
|
|
202
|
+
setMeasured(true);
|
|
203
|
+
height.value = isOpen ? h : 0;
|
|
204
|
+
opacity.value = isOpen ? 1 : 0;
|
|
205
|
+
}
|
|
206
|
+
}}
|
|
207
|
+
className={cn('px-4 pb-4 bg-slate-50', className)}
|
|
208
|
+
>
|
|
209
|
+
{children}
|
|
210
|
+
</Animated.View>
|
|
211
|
+
</Animated.View>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function AccordionTriggerText({
|
|
216
|
+
children,
|
|
217
|
+
className,
|
|
218
|
+
}: {
|
|
219
|
+
children: React.ReactNode;
|
|
220
|
+
className?: string;
|
|
221
|
+
}) {
|
|
222
|
+
return <Text className={cn('text-base font-medium text-slate-900', className)}>{children}</Text>;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function AccordionContentText({
|
|
226
|
+
children,
|
|
227
|
+
className,
|
|
228
|
+
}: {
|
|
229
|
+
children: React.ReactNode;
|
|
230
|
+
className?: string;
|
|
231
|
+
}) {
|
|
232
|
+
return <Text className={cn('text-sm text-slate-600 leading-6', className)}>{children}</Text>;
|
|
233
|
+
}
|
|
234
|
+
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// components/ui/avatar.tsx
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { View, Text, Image, ImageSourcePropType } from 'react-native';
|
|
4
|
+
import { cn } from '../../lib/utils';
|
|
5
|
+
|
|
6
|
+
interface AvatarProps {
|
|
7
|
+
source?: ImageSourcePropType;
|
|
8
|
+
alt?: string;
|
|
9
|
+
fallback?: string;
|
|
10
|
+
size?: 'sm' | 'md' | 'lg' | 'xl';
|
|
11
|
+
status?: 'online' | 'offline' | 'away' | 'busy';
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface AvatarGroupProps {
|
|
16
|
+
children: React.ReactNode;
|
|
17
|
+
max?: number;
|
|
18
|
+
size?: 'sm' | 'md' | 'lg' | 'xl';
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface AvatarImageProps {
|
|
23
|
+
source: ImageSourcePropType;
|
|
24
|
+
alt?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface AvatarFallbackProps {
|
|
28
|
+
children: React.ReactNode;
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const AvatarContext = React.createContext<{
|
|
33
|
+
size: 'sm' | 'md' | 'lg' | 'xl';
|
|
34
|
+
hasImage: boolean;
|
|
35
|
+
setHasImage: (value: boolean) => void;
|
|
36
|
+
} | null>(null);
|
|
37
|
+
|
|
38
|
+
function useAvatar() {
|
|
39
|
+
const context = React.useContext(AvatarContext);
|
|
40
|
+
if (!context) {
|
|
41
|
+
throw new Error('Avatar components must be used within Avatar');
|
|
42
|
+
}
|
|
43
|
+
return context;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Size mappings
|
|
47
|
+
const sizeClasses = {
|
|
48
|
+
sm: 'h-8 w-8',
|
|
49
|
+
md: 'h-10 w-10',
|
|
50
|
+
lg: 'h-12 w-12',
|
|
51
|
+
xl: 'h-16 w-16',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const textSizeClasses = {
|
|
55
|
+
sm: 'text-xs',
|
|
56
|
+
md: 'text-sm',
|
|
57
|
+
lg: 'text-base',
|
|
58
|
+
xl: 'text-xl',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const statusSizeClasses = {
|
|
62
|
+
sm: 'h-2 w-2',
|
|
63
|
+
md: 'h-2.5 w-2.5',
|
|
64
|
+
lg: 'h-3 w-3',
|
|
65
|
+
xl: 'h-4 w-4',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const statusColors = {
|
|
69
|
+
online: 'bg-green-500',
|
|
70
|
+
offline: 'bg-slate-400',
|
|
71
|
+
away: 'bg-yellow-500',
|
|
72
|
+
busy: 'bg-red-500',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export function Avatar({
|
|
76
|
+
source,
|
|
77
|
+
alt,
|
|
78
|
+
fallback,
|
|
79
|
+
size = 'md',
|
|
80
|
+
status,
|
|
81
|
+
className
|
|
82
|
+
}: AvatarProps) {
|
|
83
|
+
const [hasImage, setHasImage] = React.useState(!!source);
|
|
84
|
+
const [imageError, setImageError] = React.useState(false);
|
|
85
|
+
|
|
86
|
+
React.useEffect(() => {
|
|
87
|
+
setHasImage(!!source && !imageError);
|
|
88
|
+
}, [source, imageError]);
|
|
89
|
+
|
|
90
|
+
// Get initials from fallback text
|
|
91
|
+
const getInitials = (text?: string) => {
|
|
92
|
+
if (!text) return '?';
|
|
93
|
+
const words = text.trim().split(' ');
|
|
94
|
+
if (words.length >= 2) {
|
|
95
|
+
return (words[0][0] + words[1][0]).toUpperCase();
|
|
96
|
+
}
|
|
97
|
+
return text.substring(0, 2).toUpperCase();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<AvatarContext.Provider value={{ size, hasImage, setHasImage }}>
|
|
102
|
+
<View className={cn('relative', className)}>
|
|
103
|
+
<View
|
|
104
|
+
className={cn(
|
|
105
|
+
'rounded-full overflow-hidden items-center justify-center bg-slate-200',
|
|
106
|
+
sizeClasses[size]
|
|
107
|
+
)}
|
|
108
|
+
>
|
|
109
|
+
{source && !imageError ? (
|
|
110
|
+
<Image
|
|
111
|
+
source={source}
|
|
112
|
+
alt={alt}
|
|
113
|
+
className="w-full h-full"
|
|
114
|
+
onError={() => setImageError(true)}
|
|
115
|
+
resizeMode="cover"
|
|
116
|
+
/>
|
|
117
|
+
) : (
|
|
118
|
+
<Text
|
|
119
|
+
className={cn(
|
|
120
|
+
'font-semibold text-slate-600',
|
|
121
|
+
textSizeClasses[size]
|
|
122
|
+
)}
|
|
123
|
+
>
|
|
124
|
+
{getInitials(fallback || alt)}
|
|
125
|
+
</Text>
|
|
126
|
+
)}
|
|
127
|
+
</View>
|
|
128
|
+
|
|
129
|
+
{/* Status Indicator */}
|
|
130
|
+
{status && (
|
|
131
|
+
<View
|
|
132
|
+
className={cn(
|
|
133
|
+
'absolute bottom-0 right-0 rounded-full border-2 border-white',
|
|
134
|
+
statusSizeClasses[size],
|
|
135
|
+
statusColors[status]
|
|
136
|
+
)}
|
|
137
|
+
/>
|
|
138
|
+
)}
|
|
139
|
+
</View>
|
|
140
|
+
</AvatarContext.Provider>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Composable Avatar components
|
|
145
|
+
export function AvatarRoot({
|
|
146
|
+
size = 'md',
|
|
147
|
+
className,
|
|
148
|
+
children
|
|
149
|
+
}: {
|
|
150
|
+
size?: 'sm' | 'md' | 'lg' | 'xl';
|
|
151
|
+
className?: string;
|
|
152
|
+
children: React.ReactNode;
|
|
153
|
+
}) {
|
|
154
|
+
const [hasImage, setHasImage] = React.useState(false);
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<AvatarContext.Provider value={{ size, hasImage, setHasImage }}>
|
|
158
|
+
<View
|
|
159
|
+
className={cn(
|
|
160
|
+
'rounded-full overflow-hidden items-center justify-center bg-slate-200',
|
|
161
|
+
sizeClasses[size],
|
|
162
|
+
className
|
|
163
|
+
)}
|
|
164
|
+
>
|
|
165
|
+
{children}
|
|
166
|
+
</View>
|
|
167
|
+
</AvatarContext.Provider>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function AvatarImage({ source, alt }: AvatarImageProps) {
|
|
172
|
+
const { setHasImage } = useAvatar();
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<Image
|
|
176
|
+
source={source}
|
|
177
|
+
alt={alt}
|
|
178
|
+
className="w-full h-full"
|
|
179
|
+
onLoad={() => setHasImage(true)}
|
|
180
|
+
onError={() => setHasImage(false)}
|
|
181
|
+
resizeMode="cover"
|
|
182
|
+
/>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function AvatarFallback({ children, className }: AvatarFallbackProps) {
|
|
187
|
+
const { size, hasImage } = useAvatar();
|
|
188
|
+
|
|
189
|
+
if (hasImage) return null;
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<Text
|
|
193
|
+
className={cn(
|
|
194
|
+
'font-semibold text-slate-600',
|
|
195
|
+
textSizeClasses[size],
|
|
196
|
+
className
|
|
197
|
+
)}
|
|
198
|
+
>
|
|
199
|
+
{children}
|
|
200
|
+
</Text>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Avatar Group
|
|
205
|
+
export function AvatarGroup({ children, max = 3, size = 'md', className }: AvatarGroupProps) {
|
|
206
|
+
const avatars = React.Children.toArray(children);
|
|
207
|
+
const visibleAvatars = avatars.slice(0, max);
|
|
208
|
+
const remainingCount = avatars.length - max;
|
|
209
|
+
|
|
210
|
+
// Overlap offset based on size
|
|
211
|
+
const overlapOffset = {
|
|
212
|
+
sm: -8,
|
|
213
|
+
md: -10,
|
|
214
|
+
lg: -12,
|
|
215
|
+
xl: -16,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<View className={cn('flex-row items-center', className)}>
|
|
220
|
+
{visibleAvatars.map((avatar, index) => (
|
|
221
|
+
<View
|
|
222
|
+
key={index}
|
|
223
|
+
style={{
|
|
224
|
+
marginLeft: index > 0 ? overlapOffset[size] : 0,
|
|
225
|
+
zIndex: visibleAvatars.length - index,
|
|
226
|
+
}}
|
|
227
|
+
className="border-2 border-white rounded-full"
|
|
228
|
+
>
|
|
229
|
+
{React.isValidElement(avatar) && avatar.type === Avatar
|
|
230
|
+
? React.cloneElement(avatar as React.ReactElement<AvatarProps>, { size })
|
|
231
|
+
: avatar}
|
|
232
|
+
</View>
|
|
233
|
+
))}
|
|
234
|
+
|
|
235
|
+
{/* Remaining count badge */}
|
|
236
|
+
{remainingCount > 0 && (
|
|
237
|
+
<View
|
|
238
|
+
style={{
|
|
239
|
+
marginLeft: overlapOffset[size],
|
|
240
|
+
zIndex: 0,
|
|
241
|
+
}}
|
|
242
|
+
className={cn(
|
|
243
|
+
'rounded-full bg-slate-300 items-center justify-center border-2 border-white',
|
|
244
|
+
sizeClasses[size]
|
|
245
|
+
)}
|
|
246
|
+
>
|
|
247
|
+
<Text
|
|
248
|
+
className={cn(
|
|
249
|
+
'font-semibold text-slate-700',
|
|
250
|
+
textSizeClasses[size]
|
|
251
|
+
)}
|
|
252
|
+
>
|
|
253
|
+
+{remainingCount}
|
|
254
|
+
</Text>
|
|
255
|
+
</View>
|
|
256
|
+
)}
|
|
257
|
+
</View>
|
|
258
|
+
);
|
|
259
|
+
}
|