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