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