@metacells/mcellui-mcp-server 0.1.1 → 0.1.2
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.js +8 -2
- package/package.json +5 -3
- package/registry/registry.json +717 -0
- package/registry/ui/accordion.tsx +416 -0
- package/registry/ui/action-sheet.tsx +396 -0
- package/registry/ui/alert-dialog.tsx +355 -0
- package/registry/ui/avatar-stack.tsx +278 -0
- package/registry/ui/avatar.tsx +116 -0
- package/registry/ui/badge.tsx +125 -0
- package/registry/ui/button.tsx +240 -0
- package/registry/ui/card.tsx +675 -0
- package/registry/ui/carousel.tsx +431 -0
- package/registry/ui/checkbox.tsx +252 -0
- package/registry/ui/chip.tsx +271 -0
- package/registry/ui/column.tsx +133 -0
- package/registry/ui/datetime-picker.tsx +578 -0
- package/registry/ui/dialog.tsx +292 -0
- package/registry/ui/fab.tsx +225 -0
- package/registry/ui/form.tsx +323 -0
- package/registry/ui/horizontal-list.tsx +200 -0
- package/registry/ui/icon-button.tsx +244 -0
- package/registry/ui/image-gallery.tsx +455 -0
- package/registry/ui/image.tsx +283 -0
- package/registry/ui/input.tsx +242 -0
- package/registry/ui/label.tsx +99 -0
- package/registry/ui/list.tsx +519 -0
- package/registry/ui/progress.tsx +168 -0
- package/registry/ui/pull-to-refresh.tsx +231 -0
- package/registry/ui/radio-group.tsx +294 -0
- package/registry/ui/rating.tsx +311 -0
- package/registry/ui/row.tsx +136 -0
- package/registry/ui/screen.tsx +153 -0
- package/registry/ui/search-input.tsx +281 -0
- package/registry/ui/section-header.tsx +258 -0
- package/registry/ui/segmented-control.tsx +229 -0
- package/registry/ui/select.tsx +311 -0
- package/registry/ui/separator.tsx +74 -0
- package/registry/ui/sheet.tsx +362 -0
- package/registry/ui/skeleton.tsx +156 -0
- package/registry/ui/slider.tsx +307 -0
- package/registry/ui/spinner.tsx +100 -0
- package/registry/ui/stepper.tsx +314 -0
- package/registry/ui/stories.tsx +463 -0
- package/registry/ui/swipeable-row.tsx +362 -0
- package/registry/ui/switch.tsx +246 -0
- package/registry/ui/tabs.tsx +348 -0
- package/registry/ui/textarea.tsx +265 -0
- package/registry/ui/toast.tsx +316 -0
- package/registry/ui/tooltip.tsx +369 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alert Dialog
|
|
3
|
+
*
|
|
4
|
+
* A confirmation dialog that requires explicit user action.
|
|
5
|
+
* Cannot be dismissed by clicking backdrop (use for destructive actions).
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* const [open, setOpen] = useState(false);
|
|
10
|
+
*
|
|
11
|
+
* <AlertDialog open={open} onOpenChange={setOpen}>
|
|
12
|
+
* <AlertDialogContent>
|
|
13
|
+
* <AlertDialogHeader>
|
|
14
|
+
* <AlertDialogTitle>Delete Account</AlertDialogTitle>
|
|
15
|
+
* <AlertDialogDescription>
|
|
16
|
+
* This will permanently delete your account. This action cannot be undone.
|
|
17
|
+
* </AlertDialogDescription>
|
|
18
|
+
* </AlertDialogHeader>
|
|
19
|
+
* <AlertDialogFooter>
|
|
20
|
+
* <AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
21
|
+
* <AlertDialogAction onPress={handleDelete}>Delete</AlertDialogAction>
|
|
22
|
+
* </AlertDialogFooter>
|
|
23
|
+
* </AlertDialogContent>
|
|
24
|
+
* </AlertDialog>
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import React, { useEffect, useCallback, createContext, useContext } from 'react';
|
|
29
|
+
import {
|
|
30
|
+
View,
|
|
31
|
+
Text,
|
|
32
|
+
Modal,
|
|
33
|
+
StyleSheet,
|
|
34
|
+
ViewStyle,
|
|
35
|
+
TextStyle,
|
|
36
|
+
Dimensions,
|
|
37
|
+
Pressable,
|
|
38
|
+
PressableProps,
|
|
39
|
+
} from 'react-native';
|
|
40
|
+
import Animated, {
|
|
41
|
+
useSharedValue,
|
|
42
|
+
useAnimatedStyle,
|
|
43
|
+
withSpring,
|
|
44
|
+
withTiming,
|
|
45
|
+
runOnJS,
|
|
46
|
+
interpolate,
|
|
47
|
+
} from 'react-native-reanimated';
|
|
48
|
+
import { useTheme } from '@nativeui/core';
|
|
49
|
+
import { haptic } from '@nativeui/core';
|
|
50
|
+
|
|
51
|
+
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
|
52
|
+
|
|
53
|
+
// Context for alert dialog state
|
|
54
|
+
const AlertDialogContext = createContext<{
|
|
55
|
+
onClose: () => void;
|
|
56
|
+
} | null>(null);
|
|
57
|
+
|
|
58
|
+
const useAlertDialog = () => {
|
|
59
|
+
const context = useContext(AlertDialogContext);
|
|
60
|
+
if (!context) {
|
|
61
|
+
throw new Error('AlertDialog components must be used within an AlertDialog');
|
|
62
|
+
}
|
|
63
|
+
return context;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export interface AlertDialogProps {
|
|
67
|
+
/** Controlled open state */
|
|
68
|
+
open: boolean;
|
|
69
|
+
/** Callback when open state changes */
|
|
70
|
+
onOpenChange: (open: boolean) => void;
|
|
71
|
+
/** Children (should be AlertDialogContent) */
|
|
72
|
+
children: React.ReactNode;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function AlertDialog({ open, onOpenChange, children }: AlertDialogProps) {
|
|
76
|
+
const handleClose = useCallback(() => {
|
|
77
|
+
onOpenChange(false);
|
|
78
|
+
}, [onOpenChange]);
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Modal
|
|
82
|
+
visible={open}
|
|
83
|
+
transparent
|
|
84
|
+
animationType="none"
|
|
85
|
+
statusBarTranslucent
|
|
86
|
+
onRequestClose={handleClose}
|
|
87
|
+
>
|
|
88
|
+
<AlertDialogContext.Provider value={{ onClose: handleClose }}>
|
|
89
|
+
{children}
|
|
90
|
+
</AlertDialogContext.Provider>
|
|
91
|
+
</Modal>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface AlertDialogContentProps {
|
|
96
|
+
/** Content children */
|
|
97
|
+
children: React.ReactNode;
|
|
98
|
+
/** Max width of dialog */
|
|
99
|
+
maxWidth?: number;
|
|
100
|
+
/** Additional styles */
|
|
101
|
+
style?: ViewStyle;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function AlertDialogContent({
|
|
105
|
+
children,
|
|
106
|
+
maxWidth = SCREEN_WIDTH - 48,
|
|
107
|
+
style,
|
|
108
|
+
}: AlertDialogContentProps) {
|
|
109
|
+
const { colors, radius, platformShadow, springs } = useTheme();
|
|
110
|
+
|
|
111
|
+
const progress = useSharedValue(0);
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
progress.value = withSpring(1, springs.snappy);
|
|
115
|
+
}, []);
|
|
116
|
+
|
|
117
|
+
const animatedBackdropStyle = useAnimatedStyle(() => ({
|
|
118
|
+
opacity: interpolate(progress.value, [0, 1], [0, 0.5]),
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
const animatedDialogStyle = useAnimatedStyle(() => ({
|
|
122
|
+
opacity: progress.value,
|
|
123
|
+
transform: [
|
|
124
|
+
{ scale: interpolate(progress.value, [0, 1], [0.95, 1]) },
|
|
125
|
+
],
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<View style={styles.container}>
|
|
130
|
+
<Animated.View
|
|
131
|
+
style={[
|
|
132
|
+
styles.backdrop,
|
|
133
|
+
{ backgroundColor: colors.foreground },
|
|
134
|
+
animatedBackdropStyle,
|
|
135
|
+
]}
|
|
136
|
+
/>
|
|
137
|
+
|
|
138
|
+
<Animated.View
|
|
139
|
+
style={[
|
|
140
|
+
styles.dialog,
|
|
141
|
+
{
|
|
142
|
+
maxWidth,
|
|
143
|
+
backgroundColor: colors.card,
|
|
144
|
+
borderRadius: radius.xl,
|
|
145
|
+
},
|
|
146
|
+
platformShadow('lg'),
|
|
147
|
+
animatedDialogStyle,
|
|
148
|
+
style,
|
|
149
|
+
]}
|
|
150
|
+
>
|
|
151
|
+
{children}
|
|
152
|
+
</Animated.View>
|
|
153
|
+
</View>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface AlertDialogHeaderProps {
|
|
158
|
+
children: React.ReactNode;
|
|
159
|
+
style?: ViewStyle;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function AlertDialogHeader({ children, style }: AlertDialogHeaderProps) {
|
|
163
|
+
const { spacing } = useTheme();
|
|
164
|
+
return (
|
|
165
|
+
<View style={[styles.header, { marginBottom: spacing[4] }, style]}>
|
|
166
|
+
{children}
|
|
167
|
+
</View>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface AlertDialogTitleProps {
|
|
172
|
+
children: React.ReactNode;
|
|
173
|
+
style?: TextStyle;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function AlertDialogTitle({ children, style }: AlertDialogTitleProps) {
|
|
177
|
+
const { colors } = useTheme();
|
|
178
|
+
return (
|
|
179
|
+
<Text style={[styles.title, { color: colors.foreground }, style]}>
|
|
180
|
+
{children}
|
|
181
|
+
</Text>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface AlertDialogDescriptionProps {
|
|
186
|
+
children: React.ReactNode;
|
|
187
|
+
style?: TextStyle;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function AlertDialogDescription({ children, style }: AlertDialogDescriptionProps) {
|
|
191
|
+
const { colors, spacing } = useTheme();
|
|
192
|
+
return (
|
|
193
|
+
<Text
|
|
194
|
+
style={[
|
|
195
|
+
styles.description,
|
|
196
|
+
{ color: colors.foregroundMuted, marginTop: spacing[2] },
|
|
197
|
+
style,
|
|
198
|
+
]}
|
|
199
|
+
>
|
|
200
|
+
{children}
|
|
201
|
+
</Text>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface AlertDialogFooterProps {
|
|
206
|
+
children: React.ReactNode;
|
|
207
|
+
style?: ViewStyle;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function AlertDialogFooter({ children, style }: AlertDialogFooterProps) {
|
|
211
|
+
const { spacing } = useTheme();
|
|
212
|
+
return (
|
|
213
|
+
<View style={[styles.footer, { marginTop: spacing[6], gap: spacing[3] }, style]}>
|
|
214
|
+
{children}
|
|
215
|
+
</View>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface AlertDialogCancelProps extends Omit<PressableProps, 'style'> {
|
|
220
|
+
children: React.ReactNode;
|
|
221
|
+
style?: ViewStyle;
|
|
222
|
+
textStyle?: TextStyle;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function AlertDialogCancel({
|
|
226
|
+
children,
|
|
227
|
+
style,
|
|
228
|
+
textStyle,
|
|
229
|
+
onPress,
|
|
230
|
+
...props
|
|
231
|
+
}: AlertDialogCancelProps) {
|
|
232
|
+
const { colors, radius, spacing } = useTheme();
|
|
233
|
+
const { onClose } = useAlertDialog();
|
|
234
|
+
|
|
235
|
+
const handlePress = useCallback(
|
|
236
|
+
(e: any) => {
|
|
237
|
+
haptic('light');
|
|
238
|
+
onPress?.(e);
|
|
239
|
+
onClose();
|
|
240
|
+
},
|
|
241
|
+
[onPress, onClose]
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<Pressable
|
|
246
|
+
style={({ pressed }) => [
|
|
247
|
+
styles.button,
|
|
248
|
+
{
|
|
249
|
+
backgroundColor: colors.secondary,
|
|
250
|
+
borderRadius: radius.md,
|
|
251
|
+
paddingHorizontal: spacing[4],
|
|
252
|
+
paddingVertical: spacing[2.5],
|
|
253
|
+
opacity: pressed ? 0.7 : 1,
|
|
254
|
+
},
|
|
255
|
+
style,
|
|
256
|
+
]}
|
|
257
|
+
onPress={handlePress}
|
|
258
|
+
{...props}
|
|
259
|
+
>
|
|
260
|
+
<Text style={[styles.buttonText, { color: colors.secondaryForeground }, textStyle]}>
|
|
261
|
+
{children}
|
|
262
|
+
</Text>
|
|
263
|
+
</Pressable>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface AlertDialogActionProps extends Omit<PressableProps, 'style'> {
|
|
268
|
+
children: React.ReactNode;
|
|
269
|
+
/** Use destructive styling */
|
|
270
|
+
destructive?: boolean;
|
|
271
|
+
style?: ViewStyle;
|
|
272
|
+
textStyle?: TextStyle;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function AlertDialogAction({
|
|
276
|
+
children,
|
|
277
|
+
destructive = false,
|
|
278
|
+
style,
|
|
279
|
+
textStyle,
|
|
280
|
+
onPress,
|
|
281
|
+
...props
|
|
282
|
+
}: AlertDialogActionProps) {
|
|
283
|
+
const { colors, radius, spacing } = useTheme();
|
|
284
|
+
const { onClose } = useAlertDialog();
|
|
285
|
+
|
|
286
|
+
const handlePress = useCallback(
|
|
287
|
+
(e: any) => {
|
|
288
|
+
haptic(destructive ? 'warning' : 'light');
|
|
289
|
+
onPress?.(e);
|
|
290
|
+
onClose();
|
|
291
|
+
},
|
|
292
|
+
[onPress, onClose, destructive]
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const backgroundColor = destructive ? colors.destructive : colors.primary;
|
|
296
|
+
const textColor = destructive ? colors.destructiveForeground : colors.primaryForeground;
|
|
297
|
+
|
|
298
|
+
return (
|
|
299
|
+
<Pressable
|
|
300
|
+
style={({ pressed }) => [
|
|
301
|
+
styles.button,
|
|
302
|
+
{
|
|
303
|
+
backgroundColor,
|
|
304
|
+
borderRadius: radius.md,
|
|
305
|
+
paddingHorizontal: spacing[4],
|
|
306
|
+
paddingVertical: spacing[2.5],
|
|
307
|
+
opacity: pressed ? 0.7 : 1,
|
|
308
|
+
},
|
|
309
|
+
style,
|
|
310
|
+
]}
|
|
311
|
+
onPress={handlePress}
|
|
312
|
+
{...props}
|
|
313
|
+
>
|
|
314
|
+
<Text style={[styles.buttonText, { color: textColor }, textStyle]}>
|
|
315
|
+
{children}
|
|
316
|
+
</Text>
|
|
317
|
+
</Pressable>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const styles = StyleSheet.create({
|
|
322
|
+
container: {
|
|
323
|
+
flex: 1,
|
|
324
|
+
justifyContent: 'center',
|
|
325
|
+
alignItems: 'center',
|
|
326
|
+
},
|
|
327
|
+
backdrop: {
|
|
328
|
+
...StyleSheet.absoluteFillObject,
|
|
329
|
+
},
|
|
330
|
+
dialog: {
|
|
331
|
+
width: '100%',
|
|
332
|
+
padding: 24,
|
|
333
|
+
},
|
|
334
|
+
header: {},
|
|
335
|
+
title: {
|
|
336
|
+
fontSize: 18,
|
|
337
|
+
fontWeight: '600',
|
|
338
|
+
},
|
|
339
|
+
description: {
|
|
340
|
+
fontSize: 14,
|
|
341
|
+
lineHeight: 20,
|
|
342
|
+
},
|
|
343
|
+
footer: {
|
|
344
|
+
flexDirection: 'row',
|
|
345
|
+
justifyContent: 'flex-end',
|
|
346
|
+
},
|
|
347
|
+
button: {
|
|
348
|
+
alignItems: 'center',
|
|
349
|
+
justifyContent: 'center',
|
|
350
|
+
},
|
|
351
|
+
buttonText: {
|
|
352
|
+
fontSize: 14,
|
|
353
|
+
fontWeight: '600',
|
|
354
|
+
},
|
|
355
|
+
});
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AvatarStack
|
|
3
|
+
*
|
|
4
|
+
* Overlapping avatar group for displaying multiple users.
|
|
5
|
+
* Shows a configurable number of avatars with an overflow indicator.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* // Basic usage
|
|
10
|
+
* <AvatarStack
|
|
11
|
+
* avatars={[
|
|
12
|
+
* { source: { uri: 'https://...' }, name: 'John' },
|
|
13
|
+
* { source: { uri: 'https://...' }, name: 'Jane' },
|
|
14
|
+
* { source: { uri: 'https://...' }, name: 'Bob' },
|
|
15
|
+
* ]}
|
|
16
|
+
* />
|
|
17
|
+
*
|
|
18
|
+
* // With max count
|
|
19
|
+
* <AvatarStack
|
|
20
|
+
* avatars={users.map(u => ({ source: { uri: u.avatar }, name: u.name }))}
|
|
21
|
+
* max={3}
|
|
22
|
+
* size="lg"
|
|
23
|
+
* />
|
|
24
|
+
*
|
|
25
|
+
* // Different sizes
|
|
26
|
+
* <AvatarStack avatars={avatars} size="sm" />
|
|
27
|
+
* <AvatarStack avatars={avatars} size="md" />
|
|
28
|
+
* <AvatarStack avatars={avatars} size="lg" />
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import React from 'react';
|
|
33
|
+
import {
|
|
34
|
+
View,
|
|
35
|
+
Text,
|
|
36
|
+
Image,
|
|
37
|
+
StyleSheet,
|
|
38
|
+
ViewStyle,
|
|
39
|
+
ImageSourcePropType,
|
|
40
|
+
} from 'react-native';
|
|
41
|
+
import { useTheme } from '@nativeui/core';
|
|
42
|
+
|
|
43
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
// Types
|
|
45
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export type AvatarStackSize = 'sm' | 'md' | 'lg' | 'xl';
|
|
48
|
+
|
|
49
|
+
export interface AvatarItem {
|
|
50
|
+
/** Image source */
|
|
51
|
+
source?: ImageSourcePropType;
|
|
52
|
+
/** User name (used for fallback initials) */
|
|
53
|
+
name?: string;
|
|
54
|
+
/** Background color for fallback */
|
|
55
|
+
fallbackColor?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface AvatarStackProps {
|
|
59
|
+
/** Array of avatar items */
|
|
60
|
+
avatars: AvatarItem[];
|
|
61
|
+
/** Maximum number of avatars to show */
|
|
62
|
+
max?: number;
|
|
63
|
+
/** Size preset */
|
|
64
|
+
size?: AvatarStackSize;
|
|
65
|
+
/** Overlap amount (0-1, default 0.3) */
|
|
66
|
+
overlap?: number;
|
|
67
|
+
/** Container style */
|
|
68
|
+
style?: ViewStyle;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
// Size configs
|
|
73
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
const SIZE_CONFIG = {
|
|
76
|
+
sm: {
|
|
77
|
+
size: 28,
|
|
78
|
+
fontSize: 10,
|
|
79
|
+
borderWidth: 2,
|
|
80
|
+
},
|
|
81
|
+
md: {
|
|
82
|
+
size: 36,
|
|
83
|
+
fontSize: 12,
|
|
84
|
+
borderWidth: 2,
|
|
85
|
+
},
|
|
86
|
+
lg: {
|
|
87
|
+
size: 44,
|
|
88
|
+
fontSize: 14,
|
|
89
|
+
borderWidth: 3,
|
|
90
|
+
},
|
|
91
|
+
xl: {
|
|
92
|
+
size: 56,
|
|
93
|
+
fontSize: 18,
|
|
94
|
+
borderWidth: 3,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
99
|
+
// Helper functions
|
|
100
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
function getInitials(name?: string): string {
|
|
103
|
+
if (!name) return '?';
|
|
104
|
+
const parts = name.trim().split(/\s+/);
|
|
105
|
+
const first = parts[0] ?? '';
|
|
106
|
+
const last = parts[parts.length - 1] ?? '';
|
|
107
|
+
if (parts.length === 1) {
|
|
108
|
+
return first.charAt(0).toUpperCase();
|
|
109
|
+
}
|
|
110
|
+
return (first.charAt(0) + last.charAt(0)).toUpperCase();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function stringToColor(str: string): string {
|
|
114
|
+
let hash = 0;
|
|
115
|
+
for (let i = 0; i < str.length; i++) {
|
|
116
|
+
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
|
117
|
+
}
|
|
118
|
+
const hue = Math.abs(hash % 360);
|
|
119
|
+
return `hsl(${hue}, 55%, 55%)`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
123
|
+
// Component
|
|
124
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
export function AvatarStack({
|
|
127
|
+
avatars,
|
|
128
|
+
max = 4,
|
|
129
|
+
size = 'md',
|
|
130
|
+
overlap = 0.3,
|
|
131
|
+
style,
|
|
132
|
+
}: AvatarStackProps) {
|
|
133
|
+
const { colors, fontWeight } = useTheme();
|
|
134
|
+
const config = SIZE_CONFIG[size];
|
|
135
|
+
|
|
136
|
+
const visibleAvatars = avatars.slice(0, max);
|
|
137
|
+
const overflowCount = avatars.length - max;
|
|
138
|
+
const hasOverflow = overflowCount > 0;
|
|
139
|
+
|
|
140
|
+
// Calculate overlap offset
|
|
141
|
+
const overlapOffset = config.size * overlap;
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<View
|
|
145
|
+
style={[
|
|
146
|
+
styles.container,
|
|
147
|
+
style,
|
|
148
|
+
]}
|
|
149
|
+
accessibilityRole="none"
|
|
150
|
+
accessibilityLabel={`${avatars.length} users`}
|
|
151
|
+
>
|
|
152
|
+
{visibleAvatars.map((avatar, index) => {
|
|
153
|
+
const initials = getInitials(avatar.name);
|
|
154
|
+
const bgColor = avatar.fallbackColor || stringToColor(avatar.name || `user-${index}`);
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<View
|
|
158
|
+
key={index}
|
|
159
|
+
style={[
|
|
160
|
+
styles.avatarWrapper,
|
|
161
|
+
{
|
|
162
|
+
width: config.size,
|
|
163
|
+
height: config.size,
|
|
164
|
+
borderRadius: config.size / 2,
|
|
165
|
+
borderWidth: config.borderWidth,
|
|
166
|
+
borderColor: colors.background,
|
|
167
|
+
marginLeft: index === 0 ? 0 : -overlapOffset,
|
|
168
|
+
zIndex: visibleAvatars.length - index,
|
|
169
|
+
},
|
|
170
|
+
]}
|
|
171
|
+
>
|
|
172
|
+
{avatar.source ? (
|
|
173
|
+
<Image
|
|
174
|
+
source={avatar.source}
|
|
175
|
+
style={[
|
|
176
|
+
styles.avatarImage,
|
|
177
|
+
{
|
|
178
|
+
width: config.size - config.borderWidth * 2,
|
|
179
|
+
height: config.size - config.borderWidth * 2,
|
|
180
|
+
borderRadius: (config.size - config.borderWidth * 2) / 2,
|
|
181
|
+
},
|
|
182
|
+
]}
|
|
183
|
+
/>
|
|
184
|
+
) : (
|
|
185
|
+
<View
|
|
186
|
+
style={[
|
|
187
|
+
styles.avatarFallback,
|
|
188
|
+
{
|
|
189
|
+
width: config.size - config.borderWidth * 2,
|
|
190
|
+
height: config.size - config.borderWidth * 2,
|
|
191
|
+
borderRadius: (config.size - config.borderWidth * 2) / 2,
|
|
192
|
+
backgroundColor: bgColor,
|
|
193
|
+
},
|
|
194
|
+
]}
|
|
195
|
+
>
|
|
196
|
+
<Text
|
|
197
|
+
style={[
|
|
198
|
+
styles.initials,
|
|
199
|
+
{
|
|
200
|
+
fontSize: config.fontSize,
|
|
201
|
+
fontWeight: fontWeight.semibold,
|
|
202
|
+
color: '#ffffff',
|
|
203
|
+
},
|
|
204
|
+
]}
|
|
205
|
+
>
|
|
206
|
+
{initials}
|
|
207
|
+
</Text>
|
|
208
|
+
</View>
|
|
209
|
+
)}
|
|
210
|
+
</View>
|
|
211
|
+
);
|
|
212
|
+
})}
|
|
213
|
+
|
|
214
|
+
{/* Overflow indicator */}
|
|
215
|
+
{hasOverflow && (
|
|
216
|
+
<View
|
|
217
|
+
style={[
|
|
218
|
+
styles.avatarWrapper,
|
|
219
|
+
styles.overflowBadge,
|
|
220
|
+
{
|
|
221
|
+
width: config.size,
|
|
222
|
+
height: config.size,
|
|
223
|
+
borderRadius: config.size / 2,
|
|
224
|
+
borderWidth: config.borderWidth,
|
|
225
|
+
borderColor: colors.background,
|
|
226
|
+
backgroundColor: colors.secondary,
|
|
227
|
+
marginLeft: -overlapOffset,
|
|
228
|
+
zIndex: 0,
|
|
229
|
+
},
|
|
230
|
+
]}
|
|
231
|
+
>
|
|
232
|
+
<Text
|
|
233
|
+
style={[
|
|
234
|
+
styles.overflowText,
|
|
235
|
+
{
|
|
236
|
+
fontSize: config.fontSize,
|
|
237
|
+
fontWeight: fontWeight.semibold,
|
|
238
|
+
color: colors.secondaryForeground,
|
|
239
|
+
},
|
|
240
|
+
]}
|
|
241
|
+
>
|
|
242
|
+
+{overflowCount > 99 ? '99' : overflowCount}
|
|
243
|
+
</Text>
|
|
244
|
+
</View>
|
|
245
|
+
)}
|
|
246
|
+
</View>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
251
|
+
// Styles
|
|
252
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
const styles = StyleSheet.create({
|
|
255
|
+
container: {
|
|
256
|
+
flexDirection: 'row',
|
|
257
|
+
alignItems: 'center',
|
|
258
|
+
},
|
|
259
|
+
avatarWrapper: {
|
|
260
|
+
justifyContent: 'center',
|
|
261
|
+
alignItems: 'center',
|
|
262
|
+
backgroundColor: '#ffffff',
|
|
263
|
+
},
|
|
264
|
+
avatarImage: {
|
|
265
|
+
resizeMode: 'cover',
|
|
266
|
+
},
|
|
267
|
+
avatarFallback: {
|
|
268
|
+
justifyContent: 'center',
|
|
269
|
+
alignItems: 'center',
|
|
270
|
+
},
|
|
271
|
+
initials: {
|
|
272
|
+
textAlign: 'center',
|
|
273
|
+
},
|
|
274
|
+
overflowBadge: {},
|
|
275
|
+
overflowText: {
|
|
276
|
+
textAlign: 'center',
|
|
277
|
+
},
|
|
278
|
+
});
|