@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
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
declare const REGISTRY_URL = "https://raw.githubusercontent.com/yourusername/lunar-kit/main/packages/core/src/registry";
|
|
2
|
+
declare const LOCAL_REGISTRY_PATH: string;
|
|
3
|
+
declare const LOCAL_COMPONENTS_PATH: string;
|
|
4
|
+
|
|
5
|
+
export { LOCAL_COMPONENTS_PATH, LOCAL_REGISTRY_PATH, REGISTRY_URL };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { dirname } from "path";
|
|
5
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
var __dirname = dirname(__filename);
|
|
7
|
+
var REGISTRY_URL = "https://raw.githubusercontent.com/yourusername/lunar-kit/main/packages/core/src/registry";
|
|
8
|
+
var LOCAL_REGISTRY_PATH = path.join(__dirname, "..", "src", "registry");
|
|
9
|
+
var LOCAL_COMPONENTS_PATH = path.join(__dirname, "..", "src", "components");
|
|
10
|
+
export {
|
|
11
|
+
LOCAL_COMPONENTS_PATH,
|
|
12
|
+
LOCAL_REGISTRY_PATH,
|
|
13
|
+
REGISTRY_URL
|
|
14
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lunar-kit/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared registry and components for Lunar Kit",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"src/registry",
|
|
13
|
+
"src/components"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"react-native",
|
|
17
|
+
"lunar-kit",
|
|
18
|
+
"components"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsup",
|
|
22
|
+
"dev": "tsup --watch",
|
|
23
|
+
"prepublishOnly": "pnpm build"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^22.10.5",
|
|
28
|
+
"tsup": "^8.5.1",
|
|
29
|
+
"typescript": "~5.9.3"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// components/ui/accordion.tsx
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { View, Pressable } from 'react-native';
|
|
4
|
+
import Animated, {
|
|
5
|
+
useAnimatedStyle,
|
|
6
|
+
useSharedValue,
|
|
7
|
+
withTiming,
|
|
8
|
+
Easing,
|
|
9
|
+
} from 'react-native-reanimated';
|
|
10
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
11
|
+
import { cn } from '@/lib/utils';
|
|
12
|
+
import { ChevronDown } from 'lucide-react-native';
|
|
13
|
+
import { Text } from './text';
|
|
14
|
+
import { useThemeColors } from '@/hooks/useThemeColors';
|
|
15
|
+
|
|
16
|
+
type AccordionType = 'single' | 'multiple';
|
|
17
|
+
|
|
18
|
+
// Accordion Variants
|
|
19
|
+
const accordionVariants = cva(
|
|
20
|
+
'rounded-lg overflow-hidden',
|
|
21
|
+
{
|
|
22
|
+
variants: {
|
|
23
|
+
variant: {
|
|
24
|
+
default: 'border border-border',
|
|
25
|
+
bordered: 'border-2 border-border',
|
|
26
|
+
separated: 'gap-2',
|
|
27
|
+
filled: 'bg-muted',
|
|
28
|
+
ghost: '',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
defaultVariants: {
|
|
32
|
+
variant: 'default',
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// Accordion Item Variants
|
|
38
|
+
const accordionItemVariants = cva(
|
|
39
|
+
'',
|
|
40
|
+
{
|
|
41
|
+
variants: {
|
|
42
|
+
variant: {
|
|
43
|
+
default: 'border-b border-border last:border-b-0',
|
|
44
|
+
bordered: 'border-b-2 border-border last:border-b-0',
|
|
45
|
+
separated: 'border border-border rounded-lg mb-2 last:mb-0',
|
|
46
|
+
filled: 'border-b border-border last:border-b-0',
|
|
47
|
+
ghost: 'mb-1',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
defaultVariants: {
|
|
51
|
+
variant: 'default',
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Accordion Trigger Variants
|
|
57
|
+
const accordionTriggerVariants = cva(
|
|
58
|
+
'flex-row items-center justify-between px-4 py-4',
|
|
59
|
+
{
|
|
60
|
+
variants: {
|
|
61
|
+
variant: {
|
|
62
|
+
default: 'bg-card',
|
|
63
|
+
bordered: 'bg-card',
|
|
64
|
+
separated: 'bg-card',
|
|
65
|
+
filled: 'bg-muted',
|
|
66
|
+
ghost: 'bg-transparent',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
defaultVariants: {
|
|
70
|
+
variant: 'default',
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Accordion Content Variants
|
|
76
|
+
const accordionContentVariants = cva(
|
|
77
|
+
'px-4 pb-4',
|
|
78
|
+
{
|
|
79
|
+
variants: {
|
|
80
|
+
variant: {
|
|
81
|
+
default: 'bg-card',
|
|
82
|
+
bordered: 'bg-card',
|
|
83
|
+
separated: 'bg-card',
|
|
84
|
+
filled: 'bg-muted',
|
|
85
|
+
ghost: 'bg-transparent',
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
defaultVariants: {
|
|
89
|
+
variant: 'default',
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
interface AccordionProps extends VariantProps<typeof accordionVariants> {
|
|
95
|
+
type?: AccordionType;
|
|
96
|
+
value?: string | string[];
|
|
97
|
+
onValueChange?: (value: string | string[]) => void;
|
|
98
|
+
children: React.ReactNode;
|
|
99
|
+
className?: string;
|
|
100
|
+
collapsible?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface AccordionItemProps {
|
|
104
|
+
value: string;
|
|
105
|
+
children: React.ReactNode;
|
|
106
|
+
className?: string;
|
|
107
|
+
disabled?: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface AccordionTriggerProps {
|
|
111
|
+
children: React.ReactNode;
|
|
112
|
+
className?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface AccordionContentProps {
|
|
116
|
+
children: React.ReactNode;
|
|
117
|
+
className?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const AccordionContext = React.createContext<{
|
|
121
|
+
type: AccordionType;
|
|
122
|
+
value?: string | string[];
|
|
123
|
+
onValueChange?: (value: string | string[]) => void;
|
|
124
|
+
collapsible: boolean;
|
|
125
|
+
variant: 'default' | 'bordered' | 'separated' | 'filled' | 'ghost';
|
|
126
|
+
} | null>(null);
|
|
127
|
+
|
|
128
|
+
const AccordionItemContext = React.createContext<{
|
|
129
|
+
value: string;
|
|
130
|
+
isOpen: boolean;
|
|
131
|
+
toggle: () => void;
|
|
132
|
+
disabled: boolean;
|
|
133
|
+
} | null>(null);
|
|
134
|
+
|
|
135
|
+
function useAccordion() {
|
|
136
|
+
const context = React.useContext(AccordionContext);
|
|
137
|
+
if (!context) {
|
|
138
|
+
throw new Error('Accordion components must be used within Accordion');
|
|
139
|
+
}
|
|
140
|
+
return context;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function useAccordionItem() {
|
|
144
|
+
const context = React.useContext(AccordionItemContext);
|
|
145
|
+
if (!context) {
|
|
146
|
+
throw new Error('AccordionTrigger and AccordionContent must be used within AccordionItem');
|
|
147
|
+
}
|
|
148
|
+
return context;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function Accordion({
|
|
152
|
+
type = 'single',
|
|
153
|
+
value,
|
|
154
|
+
onValueChange,
|
|
155
|
+
children,
|
|
156
|
+
className,
|
|
157
|
+
collapsible = false,
|
|
158
|
+
variant = 'default',
|
|
159
|
+
}: AccordionProps) {
|
|
160
|
+
return (
|
|
161
|
+
<AccordionContext.Provider value={{
|
|
162
|
+
type,
|
|
163
|
+
value,
|
|
164
|
+
onValueChange,
|
|
165
|
+
collapsible,
|
|
166
|
+
variant: variant ?? 'default'
|
|
167
|
+
}}>
|
|
168
|
+
<View className={cn(accordionVariants({ variant }), className)}>
|
|
169
|
+
{children}
|
|
170
|
+
</View>
|
|
171
|
+
</AccordionContext.Provider>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function AccordionItem({ value, children, className, disabled = false }: AccordionItemProps) {
|
|
176
|
+
const { type, value: accordionValue, onValueChange, collapsible, variant } = useAccordion();
|
|
177
|
+
|
|
178
|
+
const isOpen = React.useMemo(() => {
|
|
179
|
+
if (type === 'single') {
|
|
180
|
+
return accordionValue === value;
|
|
181
|
+
} else {
|
|
182
|
+
return Array.isArray(accordionValue) && accordionValue.includes(value);
|
|
183
|
+
}
|
|
184
|
+
}, [type, accordionValue, value]);
|
|
185
|
+
|
|
186
|
+
const toggle = () => {
|
|
187
|
+
if (disabled) return;
|
|
188
|
+
|
|
189
|
+
if (type === 'single') {
|
|
190
|
+
if (isOpen && collapsible) {
|
|
191
|
+
onValueChange?.('');
|
|
192
|
+
} else {
|
|
193
|
+
onValueChange?.(value);
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
const currentValues = (accordionValue as string[]) || [];
|
|
197
|
+
if (isOpen) {
|
|
198
|
+
onValueChange?.(currentValues.filter((v) => v !== value));
|
|
199
|
+
} else {
|
|
200
|
+
onValueChange?.([...currentValues, value]);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<AccordionItemContext.Provider value={{ value, isOpen, toggle, disabled }}>
|
|
207
|
+
<View className={cn(accordionItemVariants({ variant }), className)}>
|
|
208
|
+
{children}
|
|
209
|
+
</View>
|
|
210
|
+
</AccordionItemContext.Provider>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function AccordionTrigger({ children, className }: AccordionTriggerProps) {
|
|
215
|
+
const { isOpen, toggle, disabled } = useAccordionItem();
|
|
216
|
+
const { variant } = useAccordion();
|
|
217
|
+
const rotation = useSharedValue(0);
|
|
218
|
+
const { colors } = useThemeColors();
|
|
219
|
+
|
|
220
|
+
React.useEffect(() => {
|
|
221
|
+
rotation.value = withTiming(isOpen ? 180 : 0, {
|
|
222
|
+
duration: 200,
|
|
223
|
+
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
|
|
224
|
+
});
|
|
225
|
+
}, [isOpen]);
|
|
226
|
+
|
|
227
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
228
|
+
transform: [{ rotate: `${rotation.value}deg` }],
|
|
229
|
+
}));
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<Pressable
|
|
233
|
+
onPress={toggle}
|
|
234
|
+
disabled={disabled}
|
|
235
|
+
className={cn(
|
|
236
|
+
accordionTriggerVariants({ variant }),
|
|
237
|
+
disabled && 'opacity-50',
|
|
238
|
+
className
|
|
239
|
+
)}
|
|
240
|
+
>
|
|
241
|
+
<View className="flex-1 pr-2">{children}</View>
|
|
242
|
+
|
|
243
|
+
<Animated.View style={animatedStyle}>
|
|
244
|
+
<ChevronDown size={20} className="text-muted-foreground" color={colors.mutedForeground} />
|
|
245
|
+
</Animated.View>
|
|
246
|
+
</Pressable>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function AccordionContent({ children, className }: AccordionContentProps) {
|
|
251
|
+
const { isOpen } = useAccordionItem();
|
|
252
|
+
const { variant } = useAccordion();
|
|
253
|
+
const height = useSharedValue(0);
|
|
254
|
+
const opacity = useSharedValue(0);
|
|
255
|
+
const [measured, setMeasured] = React.useState(false);
|
|
256
|
+
const [contentHeight, setContentHeight] = React.useState(0);
|
|
257
|
+
|
|
258
|
+
React.useEffect(() => {
|
|
259
|
+
if (measured) {
|
|
260
|
+
height.value = withTiming(isOpen ? contentHeight : 0, {
|
|
261
|
+
duration: 250,
|
|
262
|
+
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
|
|
263
|
+
});
|
|
264
|
+
opacity.value = withTiming(isOpen ? 1 : 0, {
|
|
265
|
+
duration: 200,
|
|
266
|
+
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}, [isOpen, measured, contentHeight]);
|
|
270
|
+
|
|
271
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
272
|
+
height: measured ? height.value : undefined,
|
|
273
|
+
opacity: measured ? opacity.value : 1,
|
|
274
|
+
overflow: 'hidden',
|
|
275
|
+
}));
|
|
276
|
+
|
|
277
|
+
const contentWrapperStyle = useAnimatedStyle(() => ({
|
|
278
|
+
position: measured && height.value > 0 && height.value < contentHeight ? 'absolute' : 'relative',
|
|
279
|
+
top: 0,
|
|
280
|
+
left: 0,
|
|
281
|
+
right: 0,
|
|
282
|
+
}));
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<Animated.View style={animatedStyle}>
|
|
286
|
+
<Animated.View
|
|
287
|
+
style={contentWrapperStyle}
|
|
288
|
+
onLayout={(event) => {
|
|
289
|
+
const h = event.nativeEvent.layout.height;
|
|
290
|
+
if (h > 0 && !measured) {
|
|
291
|
+
setContentHeight(h);
|
|
292
|
+
setMeasured(true);
|
|
293
|
+
height.value = isOpen ? h : 0;
|
|
294
|
+
opacity.value = isOpen ? 1 : 0;
|
|
295
|
+
}
|
|
296
|
+
}}
|
|
297
|
+
>
|
|
298
|
+
<View className={cn(accordionContentVariants({ variant }), className)}>
|
|
299
|
+
{children}
|
|
300
|
+
</View>
|
|
301
|
+
</Animated.View>
|
|
302
|
+
</Animated.View>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Preset Text Components with theme colors
|
|
307
|
+
export function AccordionTriggerText({
|
|
308
|
+
children,
|
|
309
|
+
className,
|
|
310
|
+
}: {
|
|
311
|
+
children: React.ReactNode;
|
|
312
|
+
className?: string;
|
|
313
|
+
}) {
|
|
314
|
+
return (
|
|
315
|
+
<Text variant="title" size="sm" className={className}>
|
|
316
|
+
{children}
|
|
317
|
+
</Text>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function AccordionContentText({
|
|
322
|
+
children,
|
|
323
|
+
className,
|
|
324
|
+
}: {
|
|
325
|
+
children: React.ReactNode;
|
|
326
|
+
className?: string;
|
|
327
|
+
}) {
|
|
328
|
+
return (
|
|
329
|
+
<Text variant="body" size="sm" className={cn('leading-6', className)}>
|
|
330
|
+
{children}
|
|
331
|
+
</Text>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// components/ui/avatar.tsx
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { View, Image, ImageSourcePropType } from 'react-native';
|
|
4
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
6
|
+
import { Text } from './text';
|
|
7
|
+
|
|
8
|
+
// Avatar Variants
|
|
9
|
+
const avatarVariants = cva(
|
|
10
|
+
'rounded-full overflow-hidden items-center justify-center',
|
|
11
|
+
{
|
|
12
|
+
variants: {
|
|
13
|
+
variant: {
|
|
14
|
+
default: 'bg-muted',
|
|
15
|
+
primary: 'bg-primary',
|
|
16
|
+
secondary: 'bg-secondary',
|
|
17
|
+
outline: 'bg-background border-2 border-border',
|
|
18
|
+
},
|
|
19
|
+
size: {
|
|
20
|
+
xs: 'h-6 w-6',
|
|
21
|
+
sm: 'h-8 w-8',
|
|
22
|
+
md: 'h-10 w-10',
|
|
23
|
+
lg: 'h-12 w-12',
|
|
24
|
+
xl: 'h-16 w-16',
|
|
25
|
+
'2xl': 'h-20 w-20',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
defaultVariants: {
|
|
29
|
+
variant: 'default',
|
|
30
|
+
size: 'md',
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// Text size variants
|
|
36
|
+
const avatarTextVariants = cva(
|
|
37
|
+
'font-semibold',
|
|
38
|
+
{
|
|
39
|
+
variants: {
|
|
40
|
+
variant: {
|
|
41
|
+
default: 'text-muted-foreground',
|
|
42
|
+
primary: 'text-primary-foreground',
|
|
43
|
+
secondary: 'text-secondary-foreground',
|
|
44
|
+
outline: 'text-foreground',
|
|
45
|
+
},
|
|
46
|
+
size: {
|
|
47
|
+
xs: 'text-[10px]',
|
|
48
|
+
sm: 'text-xs',
|
|
49
|
+
md: 'text-sm',
|
|
50
|
+
lg: 'text-base',
|
|
51
|
+
xl: 'text-xl',
|
|
52
|
+
'2xl': 'text-2xl',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
defaultVariants: {
|
|
56
|
+
variant: 'default',
|
|
57
|
+
size: 'md',
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Status indicator variants
|
|
63
|
+
const statusVariants = cva(
|
|
64
|
+
'absolute bottom-0 right-0 rounded-full border-2 border-background',
|
|
65
|
+
{
|
|
66
|
+
variants: {
|
|
67
|
+
status: {
|
|
68
|
+
online: 'bg-green-500',
|
|
69
|
+
offline: 'bg-muted-foreground',
|
|
70
|
+
away: 'bg-yellow-500',
|
|
71
|
+
busy: 'bg-red-500',
|
|
72
|
+
},
|
|
73
|
+
size: {
|
|
74
|
+
xs: 'h-1.5 w-1.5',
|
|
75
|
+
sm: 'h-2 w-2',
|
|
76
|
+
md: 'h-2.5 w-2.5',
|
|
77
|
+
lg: 'h-3 w-3',
|
|
78
|
+
xl: 'h-4 w-4',
|
|
79
|
+
'2xl': 'h-5 w-5',
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
interface AvatarProps extends VariantProps<typeof avatarVariants> {
|
|
86
|
+
source?: ImageSourcePropType;
|
|
87
|
+
alt?: string;
|
|
88
|
+
fallback?: string;
|
|
89
|
+
status?: 'online' | 'offline' | 'away' | 'busy';
|
|
90
|
+
className?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface AvatarGroupProps {
|
|
94
|
+
children: React.ReactNode;
|
|
95
|
+
max?: number;
|
|
96
|
+
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
|
97
|
+
variant?: 'default' | 'primary' | 'secondary' | 'outline';
|
|
98
|
+
className?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface AvatarImageProps {
|
|
102
|
+
source: ImageSourcePropType;
|
|
103
|
+
alt?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface AvatarFallbackProps {
|
|
107
|
+
children: React.ReactNode;
|
|
108
|
+
className?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const AvatarContext = React.createContext<{
|
|
112
|
+
size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
|
113
|
+
variant: 'default' | 'primary' | 'secondary' | 'outline';
|
|
114
|
+
hasImage: boolean;
|
|
115
|
+
setHasImage: (value: boolean) => void;
|
|
116
|
+
} | null>(null);
|
|
117
|
+
|
|
118
|
+
function useAvatar() {
|
|
119
|
+
const context = React.useContext(AvatarContext);
|
|
120
|
+
if (!context) {
|
|
121
|
+
throw new Error('Avatar components must be used within Avatar');
|
|
122
|
+
}
|
|
123
|
+
return context;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Get initials helper
|
|
127
|
+
const getInitials = (text?: string) => {
|
|
128
|
+
if (!text) return '?';
|
|
129
|
+
const words = text.trim().split(' ');
|
|
130
|
+
if (words.length >= 2) {
|
|
131
|
+
return (words[0][0] + words[1][0]).toUpperCase();
|
|
132
|
+
}
|
|
133
|
+
return text.substring(0, 2).toUpperCase();
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Simple Avatar (All-in-one)
|
|
137
|
+
export function Avatar({
|
|
138
|
+
source,
|
|
139
|
+
alt,
|
|
140
|
+
fallback,
|
|
141
|
+
size = 'md',
|
|
142
|
+
variant = 'default',
|
|
143
|
+
status,
|
|
144
|
+
className
|
|
145
|
+
}: AvatarProps) {
|
|
146
|
+
const [hasImage, setHasImage] = React.useState(!!source);
|
|
147
|
+
const [imageError, setImageError] = React.useState(false);
|
|
148
|
+
|
|
149
|
+
React.useEffect(() => {
|
|
150
|
+
setHasImage(!!source && !imageError);
|
|
151
|
+
}, [source, imageError]);
|
|
152
|
+
|
|
153
|
+
const avatarSize = size ?? 'md';
|
|
154
|
+
const avatarVariant = variant ?? 'default';
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<AvatarContext.Provider value={{
|
|
158
|
+
size: avatarSize,
|
|
159
|
+
variant: avatarVariant,
|
|
160
|
+
hasImage,
|
|
161
|
+
setHasImage
|
|
162
|
+
}}>
|
|
163
|
+
<View className={cn('relative', className)}>
|
|
164
|
+
<View className={cn(avatarVariants({ variant: avatarVariant, size: avatarSize }))}>
|
|
165
|
+
{source && !imageError ? (
|
|
166
|
+
<Image
|
|
167
|
+
source={source}
|
|
168
|
+
alt={alt}
|
|
169
|
+
className="w-full h-full"
|
|
170
|
+
onError={() => setImageError(true)}
|
|
171
|
+
resizeMode="cover"
|
|
172
|
+
/>
|
|
173
|
+
) : (
|
|
174
|
+
<Text className={cn(avatarTextVariants({ variant: avatarVariant, size: avatarSize }))}>
|
|
175
|
+
{getInitials(fallback || alt)}
|
|
176
|
+
</Text>
|
|
177
|
+
)}
|
|
178
|
+
</View>
|
|
179
|
+
|
|
180
|
+
{/* Status Indicator */}
|
|
181
|
+
{status && (
|
|
182
|
+
<View className={cn(statusVariants({ status, size: avatarSize }))} />
|
|
183
|
+
)}
|
|
184
|
+
</View>
|
|
185
|
+
</AvatarContext.Provider>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Composable Avatar Root
|
|
190
|
+
export function AvatarRoot({
|
|
191
|
+
size = 'md',
|
|
192
|
+
variant = 'default',
|
|
193
|
+
className,
|
|
194
|
+
children
|
|
195
|
+
}: {
|
|
196
|
+
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
|
197
|
+
variant?: 'default' | 'primary' | 'secondary' | 'outline';
|
|
198
|
+
className?: string;
|
|
199
|
+
children: React.ReactNode;
|
|
200
|
+
}) {
|
|
201
|
+
const [hasImage, setHasImage] = React.useState(false);
|
|
202
|
+
|
|
203
|
+
const avatarSize = size ?? 'md';
|
|
204
|
+
const avatarVariant = variant ?? 'default';
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<AvatarContext.Provider value={{
|
|
208
|
+
size: avatarSize,
|
|
209
|
+
variant: avatarVariant,
|
|
210
|
+
hasImage,
|
|
211
|
+
setHasImage
|
|
212
|
+
}}>
|
|
213
|
+
<View className={cn(avatarVariants({ variant: avatarVariant, size: avatarSize }), className)}>
|
|
214
|
+
{children}
|
|
215
|
+
</View>
|
|
216
|
+
</AvatarContext.Provider>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Avatar Image
|
|
221
|
+
export function AvatarImage({ source, alt }: AvatarImageProps) {
|
|
222
|
+
const { setHasImage } = useAvatar();
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<Image
|
|
226
|
+
source={source}
|
|
227
|
+
alt={alt}
|
|
228
|
+
className="w-full h-full"
|
|
229
|
+
onLoad={() => setHasImage(true)}
|
|
230
|
+
onError={() => setHasImage(false)}
|
|
231
|
+
resizeMode="cover"
|
|
232
|
+
/>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Avatar Fallback
|
|
237
|
+
export function AvatarFallback({ children, className }: AvatarFallbackProps) {
|
|
238
|
+
const { size, variant, hasImage } = useAvatar();
|
|
239
|
+
|
|
240
|
+
if (hasImage) return null;
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<Text className={cn(avatarTextVariants({ variant, size }), className)}>
|
|
244
|
+
{children}
|
|
245
|
+
</Text>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Avatar Status
|
|
250
|
+
export function AvatarStatus({
|
|
251
|
+
status
|
|
252
|
+
}: {
|
|
253
|
+
status: 'online' | 'offline' | 'away' | 'busy'
|
|
254
|
+
}) {
|
|
255
|
+
const { size } = useAvatar();
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<View className={cn(statusVariants({ status, size }))} />
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Avatar Group
|
|
263
|
+
export function AvatarGroup({
|
|
264
|
+
children,
|
|
265
|
+
max = 3,
|
|
266
|
+
size = 'md',
|
|
267
|
+
variant = 'default',
|
|
268
|
+
className
|
|
269
|
+
}: AvatarGroupProps) {
|
|
270
|
+
const avatars = React.Children.toArray(children);
|
|
271
|
+
const visibleAvatars = avatars.slice(0, max);
|
|
272
|
+
const remainingCount = avatars.length - max;
|
|
273
|
+
|
|
274
|
+
const avatarSize = size ?? 'md';
|
|
275
|
+
const avatarVariant = variant ?? 'default';
|
|
276
|
+
|
|
277
|
+
// Overlap offset based on size
|
|
278
|
+
const overlapOffset = {
|
|
279
|
+
xs: -6,
|
|
280
|
+
sm: -8,
|
|
281
|
+
md: -10,
|
|
282
|
+
lg: -12,
|
|
283
|
+
xl: -16,
|
|
284
|
+
'2xl': -20,
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
<View className={cn('flex-row items-center', className)}>
|
|
289
|
+
{visibleAvatars.map((avatar, index) => (
|
|
290
|
+
<View
|
|
291
|
+
key={index}
|
|
292
|
+
style={{
|
|
293
|
+
marginLeft: index > 0 ? overlapOffset[avatarSize] : 0,
|
|
294
|
+
zIndex: visibleAvatars.length - index,
|
|
295
|
+
}}
|
|
296
|
+
className="border-2 border-background rounded-full"
|
|
297
|
+
>
|
|
298
|
+
{React.isValidElement(avatar) && avatar.type === Avatar
|
|
299
|
+
? React.cloneElement(avatar as React.ReactElement<AvatarProps>, {
|
|
300
|
+
size: avatarSize,
|
|
301
|
+
variant: avatarVariant
|
|
302
|
+
})
|
|
303
|
+
: avatar}
|
|
304
|
+
</View>
|
|
305
|
+
))}
|
|
306
|
+
|
|
307
|
+
{/* Remaining count badge */}
|
|
308
|
+
{remainingCount > 0 && (
|
|
309
|
+
<View
|
|
310
|
+
style={{
|
|
311
|
+
marginLeft: overlapOffset[avatarSize],
|
|
312
|
+
zIndex: 0,
|
|
313
|
+
}}
|
|
314
|
+
className={cn(
|
|
315
|
+
avatarVariants({ variant: 'default', size: avatarSize }),
|
|
316
|
+
'border-2 border-background'
|
|
317
|
+
)}
|
|
318
|
+
>
|
|
319
|
+
<Text className={cn(avatarTextVariants({ variant: 'default', size: avatarSize }))}>
|
|
320
|
+
+{remainingCount}
|
|
321
|
+
</Text>
|
|
322
|
+
</View>
|
|
323
|
+
)}
|
|
324
|
+
</View>
|
|
325
|
+
);
|
|
326
|
+
}
|