@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,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carousel
|
|
3
|
+
*
|
|
4
|
+
* Auto-playing image/content slideshow with animated pagination indicators.
|
|
5
|
+
* Supports swipe gestures, autoplay, parallax effects, and custom indicators.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* // Basic carousel with children
|
|
10
|
+
* <Carousel>
|
|
11
|
+
* <CarouselItem>
|
|
12
|
+
* <Image source={{ uri: 'https://...' }} style={{ width: '100%', height: 200 }} />
|
|
13
|
+
* </CarouselItem>
|
|
14
|
+
* <CarouselItem>
|
|
15
|
+
* <View style={{ backgroundColor: 'blue', height: 200 }} />
|
|
16
|
+
* </CarouselItem>
|
|
17
|
+
* </Carousel>
|
|
18
|
+
*
|
|
19
|
+
* // With data + renderItem (for parallax support)
|
|
20
|
+
* <Carousel
|
|
21
|
+
* data={slides}
|
|
22
|
+
* renderItem={({ item, index, scrollX, width }) => (
|
|
23
|
+
* <OnboardingSlide item={item} index={index} scrollX={scrollX} width={width} />
|
|
24
|
+
* )}
|
|
25
|
+
* />
|
|
26
|
+
*
|
|
27
|
+
* // With autoplay
|
|
28
|
+
* <Carousel autoplay autoplayInterval={5000}>
|
|
29
|
+
* {items}
|
|
30
|
+
* </Carousel>
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import React, { useRef, useState, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react';
|
|
35
|
+
import {
|
|
36
|
+
View,
|
|
37
|
+
StyleSheet,
|
|
38
|
+
ViewStyle,
|
|
39
|
+
Dimensions,
|
|
40
|
+
NativeSyntheticEvent,
|
|
41
|
+
NativeScrollEvent,
|
|
42
|
+
LayoutChangeEvent,
|
|
43
|
+
Pressable,
|
|
44
|
+
FlatList,
|
|
45
|
+
} from 'react-native';
|
|
46
|
+
import Animated, {
|
|
47
|
+
useAnimatedStyle,
|
|
48
|
+
useSharedValue,
|
|
49
|
+
interpolate,
|
|
50
|
+
Extrapolation,
|
|
51
|
+
SharedValue,
|
|
52
|
+
} from 'react-native-reanimated';
|
|
53
|
+
import { useTheme } from '@nativeui/core';
|
|
54
|
+
|
|
55
|
+
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
|
56
|
+
|
|
57
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
58
|
+
// Types
|
|
59
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export interface CarouselRenderItemInfo<T> {
|
|
62
|
+
item: T;
|
|
63
|
+
index: number;
|
|
64
|
+
scrollX: SharedValue<number>;
|
|
65
|
+
width: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CarouselProps<T = any> {
|
|
69
|
+
/** Carousel slides (alternative to data + renderItem) */
|
|
70
|
+
children?: React.ReactNode;
|
|
71
|
+
/** Data array for renderItem pattern */
|
|
72
|
+
data?: T[];
|
|
73
|
+
/** Render function for data items - receives scrollX for parallax effects */
|
|
74
|
+
renderItem?: (info: CarouselRenderItemInfo<T>) => React.ReactElement;
|
|
75
|
+
/** Enable autoplay */
|
|
76
|
+
autoplay?: boolean;
|
|
77
|
+
/** Autoplay interval in ms */
|
|
78
|
+
autoplayInterval?: number;
|
|
79
|
+
/** Show pagination indicators */
|
|
80
|
+
showIndicators?: boolean;
|
|
81
|
+
/** Indicator position */
|
|
82
|
+
indicatorPosition?: 'bottom' | 'top';
|
|
83
|
+
/** Called when active slide changes */
|
|
84
|
+
onSlideChange?: (index: number) => void;
|
|
85
|
+
/** Initial slide index */
|
|
86
|
+
initialIndex?: number;
|
|
87
|
+
/** Container style */
|
|
88
|
+
style?: ViewStyle;
|
|
89
|
+
/** Content height (defaults to auto) */
|
|
90
|
+
height?: number;
|
|
91
|
+
/** Indicator style variant */
|
|
92
|
+
indicatorStyle?: 'dot' | 'line';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface CarouselItemProps {
|
|
96
|
+
/** Slide content */
|
|
97
|
+
children: React.ReactNode;
|
|
98
|
+
/** Item style */
|
|
99
|
+
style?: ViewStyle;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface CarouselRef {
|
|
103
|
+
/** Scroll to a specific slide index */
|
|
104
|
+
scrollToIndex: (index: number, animated?: boolean) => void;
|
|
105
|
+
/** Get the current active index */
|
|
106
|
+
getActiveIndex: () => number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
110
|
+
// CarouselItem Component
|
|
111
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
export function CarouselItem({ children, style }: CarouselItemProps) {
|
|
114
|
+
return (
|
|
115
|
+
<View style={[styles.item, style]}>
|
|
116
|
+
{children}
|
|
117
|
+
</View>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
122
|
+
// Animated Pagination Dot Component
|
|
123
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
interface AnimatedDotProps {
|
|
126
|
+
index: number;
|
|
127
|
+
scrollX: SharedValue<number>;
|
|
128
|
+
width: number;
|
|
129
|
+
activeColor: string;
|
|
130
|
+
inactiveColor: string;
|
|
131
|
+
onPress: () => void;
|
|
132
|
+
variant: 'dot' | 'line';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function AnimatedDot({
|
|
136
|
+
index,
|
|
137
|
+
scrollX,
|
|
138
|
+
width,
|
|
139
|
+
activeColor,
|
|
140
|
+
inactiveColor,
|
|
141
|
+
onPress,
|
|
142
|
+
variant,
|
|
143
|
+
}: AnimatedDotProps) {
|
|
144
|
+
const dotStyle = useAnimatedStyle(() => {
|
|
145
|
+
const inputRange = [
|
|
146
|
+
(index - 1) * width,
|
|
147
|
+
index * width,
|
|
148
|
+
(index + 1) * width,
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
const dotWidth = interpolate(
|
|
152
|
+
scrollX.value,
|
|
153
|
+
inputRange,
|
|
154
|
+
variant === 'line' ? [8, 24, 8] : [8, 12, 8],
|
|
155
|
+
Extrapolation.CLAMP
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const opacity = interpolate(
|
|
159
|
+
scrollX.value,
|
|
160
|
+
inputRange,
|
|
161
|
+
[0.4, 1, 0.4],
|
|
162
|
+
Extrapolation.CLAMP
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const scale = interpolate(
|
|
166
|
+
scrollX.value,
|
|
167
|
+
inputRange,
|
|
168
|
+
[0.8, 1, 0.8],
|
|
169
|
+
Extrapolation.CLAMP
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
width: dotWidth,
|
|
174
|
+
opacity,
|
|
175
|
+
transform: [{ scale }],
|
|
176
|
+
backgroundColor: activeColor,
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<Pressable onPress={onPress}>
|
|
182
|
+
<Animated.View style={[styles.dot, dotStyle]} />
|
|
183
|
+
</Pressable>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
188
|
+
// Carousel Component
|
|
189
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
function CarouselInner<T = any>(
|
|
192
|
+
{
|
|
193
|
+
children,
|
|
194
|
+
data,
|
|
195
|
+
renderItem,
|
|
196
|
+
autoplay = false,
|
|
197
|
+
autoplayInterval = 4000,
|
|
198
|
+
showIndicators = true,
|
|
199
|
+
indicatorPosition = 'bottom',
|
|
200
|
+
onSlideChange,
|
|
201
|
+
initialIndex = 0,
|
|
202
|
+
style,
|
|
203
|
+
height,
|
|
204
|
+
indicatorStyle = 'line',
|
|
205
|
+
}: CarouselProps<T>,
|
|
206
|
+
ref: React.ForwardedRef<CarouselRef>
|
|
207
|
+
) {
|
|
208
|
+
const { colors, spacing } = useTheme();
|
|
209
|
+
const flatListRef = useRef<FlatList>(null);
|
|
210
|
+
const [activeIndex, setActiveIndex] = useState(initialIndex);
|
|
211
|
+
const [containerWidth, setContainerWidth] = useState(SCREEN_WIDTH);
|
|
212
|
+
const autoplayRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
213
|
+
const scrollX = useSharedValue(0);
|
|
214
|
+
|
|
215
|
+
// Determine slide count from children or data
|
|
216
|
+
const childArray = children ? React.Children.toArray(children) : [];
|
|
217
|
+
const slideCount = data ? data.length : childArray.length;
|
|
218
|
+
|
|
219
|
+
// Handle container layout to get accurate width
|
|
220
|
+
const handleLayout = useCallback((event: LayoutChangeEvent) => {
|
|
221
|
+
const { width } = event.nativeEvent.layout;
|
|
222
|
+
setContainerWidth(width);
|
|
223
|
+
}, []);
|
|
224
|
+
|
|
225
|
+
// Scroll to specific slide
|
|
226
|
+
const scrollToSlide = useCallback((index: number, animated = true) => {
|
|
227
|
+
const clampedIndex = Math.max(0, Math.min(index, slideCount - 1));
|
|
228
|
+
flatListRef.current?.scrollToIndex({ index: clampedIndex, animated });
|
|
229
|
+
}, [slideCount]);
|
|
230
|
+
|
|
231
|
+
// Expose imperative methods via ref
|
|
232
|
+
useImperativeHandle(ref, () => ({
|
|
233
|
+
scrollToIndex: (index: number, animated = true) => {
|
|
234
|
+
scrollToSlide(index, animated);
|
|
235
|
+
setActiveIndex(index);
|
|
236
|
+
onSlideChange?.(index);
|
|
237
|
+
},
|
|
238
|
+
getActiveIndex: () => activeIndex,
|
|
239
|
+
}), [scrollToSlide, activeIndex, onSlideChange]);
|
|
240
|
+
|
|
241
|
+
// Handle scroll to update scrollX shared value
|
|
242
|
+
const handleScroll = useCallback(
|
|
243
|
+
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
244
|
+
scrollX.value = event.nativeEvent.contentOffset.x;
|
|
245
|
+
},
|
|
246
|
+
[]
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
// Handle scroll end to determine active slide
|
|
250
|
+
const handleMomentumScrollEnd = useCallback(
|
|
251
|
+
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
252
|
+
const offsetX = event.nativeEvent.contentOffset.x;
|
|
253
|
+
const newIndex = Math.round(offsetX / containerWidth);
|
|
254
|
+
const clampedIndex = Math.max(0, Math.min(newIndex, slideCount - 1));
|
|
255
|
+
|
|
256
|
+
if (clampedIndex !== activeIndex) {
|
|
257
|
+
setActiveIndex(clampedIndex);
|
|
258
|
+
onSlideChange?.(clampedIndex);
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
[containerWidth, slideCount, activeIndex, onSlideChange]
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// Autoplay effect
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
if (!autoplay || slideCount <= 1) return;
|
|
267
|
+
|
|
268
|
+
const startAutoplay = () => {
|
|
269
|
+
autoplayRef.current = setInterval(() => {
|
|
270
|
+
setActiveIndex((current) => {
|
|
271
|
+
const nextIndex = (current + 1) % slideCount;
|
|
272
|
+
scrollToSlide(nextIndex);
|
|
273
|
+
return nextIndex;
|
|
274
|
+
});
|
|
275
|
+
}, autoplayInterval);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
startAutoplay();
|
|
279
|
+
|
|
280
|
+
return () => {
|
|
281
|
+
if (autoplayRef.current) {
|
|
282
|
+
clearInterval(autoplayRef.current);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
}, [autoplay, autoplayInterval, slideCount, scrollToSlide]);
|
|
286
|
+
|
|
287
|
+
// Stop autoplay on user interaction
|
|
288
|
+
const handleScrollBeginDrag = useCallback(() => {
|
|
289
|
+
if (autoplayRef.current) {
|
|
290
|
+
clearInterval(autoplayRef.current);
|
|
291
|
+
autoplayRef.current = null;
|
|
292
|
+
}
|
|
293
|
+
}, []);
|
|
294
|
+
|
|
295
|
+
// Restart autoplay after user interaction
|
|
296
|
+
const handleScrollEndDrag = useCallback(() => {
|
|
297
|
+
if (autoplay && slideCount > 1 && !autoplayRef.current) {
|
|
298
|
+
autoplayRef.current = setInterval(() => {
|
|
299
|
+
setActiveIndex((current) => {
|
|
300
|
+
const nextIndex = (current + 1) % slideCount;
|
|
301
|
+
scrollToSlide(nextIndex);
|
|
302
|
+
return nextIndex;
|
|
303
|
+
});
|
|
304
|
+
}, autoplayInterval);
|
|
305
|
+
}
|
|
306
|
+
}, [autoplay, autoplayInterval, slideCount, scrollToSlide]);
|
|
307
|
+
|
|
308
|
+
const handleDotPress = (index: number) => {
|
|
309
|
+
scrollToSlide(index);
|
|
310
|
+
setActiveIndex(index);
|
|
311
|
+
onSlideChange?.(index);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Render indicators
|
|
315
|
+
const indicators = showIndicators && slideCount > 1 && (
|
|
316
|
+
<View
|
|
317
|
+
style={[
|
|
318
|
+
styles.indicators,
|
|
319
|
+
indicatorPosition === 'top' ? styles.indicatorsTop : styles.indicatorsBottom,
|
|
320
|
+
]}
|
|
321
|
+
>
|
|
322
|
+
{Array.from({ length: slideCount }).map((_, index) => (
|
|
323
|
+
<AnimatedDot
|
|
324
|
+
key={index}
|
|
325
|
+
index={index}
|
|
326
|
+
scrollX={scrollX}
|
|
327
|
+
width={containerWidth}
|
|
328
|
+
activeColor={colors.primary}
|
|
329
|
+
inactiveColor={colors.border}
|
|
330
|
+
onPress={() => handleDotPress(index)}
|
|
331
|
+
variant={indicatorStyle}
|
|
332
|
+
/>
|
|
333
|
+
))}
|
|
334
|
+
</View>
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
// Render slide content
|
|
338
|
+
const renderSlide = useCallback(
|
|
339
|
+
({ item, index }: { item: T | React.ReactNode; index: number }) => {
|
|
340
|
+
if (data && renderItem) {
|
|
341
|
+
return (
|
|
342
|
+
<View style={{ width: containerWidth, flex: 1 }}>
|
|
343
|
+
{renderItem({ item: item as T, index, scrollX, width: containerWidth })}
|
|
344
|
+
</View>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
return (
|
|
348
|
+
<View style={{ width: containerWidth, flex: 1 }}>
|
|
349
|
+
{item as React.ReactNode}
|
|
350
|
+
</View>
|
|
351
|
+
);
|
|
352
|
+
},
|
|
353
|
+
[containerWidth, data, renderItem, scrollX]
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
const listData = data || childArray;
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
<View
|
|
360
|
+
style={[styles.container, { height }, style]}
|
|
361
|
+
onLayout={handleLayout}
|
|
362
|
+
>
|
|
363
|
+
{indicatorPosition === 'top' && indicators}
|
|
364
|
+
|
|
365
|
+
<FlatList
|
|
366
|
+
ref={flatListRef}
|
|
367
|
+
data={listData as any[]}
|
|
368
|
+
renderItem={renderSlide}
|
|
369
|
+
keyExtractor={(_, index) => `carousel-${index}`}
|
|
370
|
+
horizontal
|
|
371
|
+
pagingEnabled
|
|
372
|
+
showsHorizontalScrollIndicator={false}
|
|
373
|
+
onScroll={handleScroll}
|
|
374
|
+
scrollEventThrottle={16}
|
|
375
|
+
onMomentumScrollEnd={handleMomentumScrollEnd}
|
|
376
|
+
onScrollBeginDrag={handleScrollBeginDrag}
|
|
377
|
+
onScrollEndDrag={handleScrollEndDrag}
|
|
378
|
+
bounces={false}
|
|
379
|
+
decelerationRate="fast"
|
|
380
|
+
initialScrollIndex={initialIndex}
|
|
381
|
+
getItemLayout={(_, index) => ({
|
|
382
|
+
length: containerWidth,
|
|
383
|
+
offset: containerWidth * index,
|
|
384
|
+
index,
|
|
385
|
+
})}
|
|
386
|
+
style={{ flex: 1 }}
|
|
387
|
+
contentContainerStyle={{ flexGrow: 1 }}
|
|
388
|
+
accessibilityRole="adjustable"
|
|
389
|
+
accessibilityValue={{
|
|
390
|
+
min: 0,
|
|
391
|
+
max: slideCount - 1,
|
|
392
|
+
now: activeIndex,
|
|
393
|
+
text: `Slide ${activeIndex + 1} of ${slideCount}`,
|
|
394
|
+
}}
|
|
395
|
+
/>
|
|
396
|
+
|
|
397
|
+
{indicatorPosition === 'bottom' && indicators}
|
|
398
|
+
</View>
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Export with forwardRef while preserving generic type
|
|
403
|
+
export const Carousel = forwardRef(CarouselInner) as <T = any>(
|
|
404
|
+
props: CarouselProps<T> & { ref?: React.ForwardedRef<CarouselRef> }
|
|
405
|
+
) => React.ReactElement;
|
|
406
|
+
|
|
407
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
408
|
+
// Styles
|
|
409
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
const styles = StyleSheet.create({
|
|
412
|
+
container: {
|
|
413
|
+
overflow: 'hidden',
|
|
414
|
+
},
|
|
415
|
+
item: {
|
|
416
|
+
flex: 1,
|
|
417
|
+
},
|
|
418
|
+
indicators: {
|
|
419
|
+
flexDirection: 'row',
|
|
420
|
+
justifyContent: 'center',
|
|
421
|
+
alignItems: 'center',
|
|
422
|
+
gap: 6,
|
|
423
|
+
paddingVertical: 8,
|
|
424
|
+
},
|
|
425
|
+
indicatorsTop: {},
|
|
426
|
+
indicatorsBottom: {},
|
|
427
|
+
dot: {
|
|
428
|
+
height: 8,
|
|
429
|
+
borderRadius: 4,
|
|
430
|
+
},
|
|
431
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkbox
|
|
3
|
+
*
|
|
4
|
+
* An accessible checkbox component with animated checkmark.
|
|
5
|
+
* Uses design tokens for consistent styling.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* const [checked, setChecked] = useState(false);
|
|
10
|
+
* <Checkbox checked={checked} onCheckedChange={setChecked} />
|
|
11
|
+
* <Checkbox checked={checked} onCheckedChange={setChecked} label="Accept terms" />
|
|
12
|
+
* <Checkbox indeterminate />
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React, { useEffect, useCallback } from 'react';
|
|
17
|
+
import {
|
|
18
|
+
Pressable,
|
|
19
|
+
View,
|
|
20
|
+
Text,
|
|
21
|
+
StyleSheet,
|
|
22
|
+
ViewStyle,
|
|
23
|
+
AccessibilityInfo,
|
|
24
|
+
} from 'react-native';
|
|
25
|
+
import Animated, {
|
|
26
|
+
useSharedValue,
|
|
27
|
+
useAnimatedStyle,
|
|
28
|
+
withSpring,
|
|
29
|
+
interpolate,
|
|
30
|
+
interpolateColor,
|
|
31
|
+
Extrapolation,
|
|
32
|
+
} from 'react-native-reanimated';
|
|
33
|
+
import Svg, { Path } from 'react-native-svg';
|
|
34
|
+
import { useTheme } from '@nativeui/core';
|
|
35
|
+
import { haptic } from '@nativeui/core';
|
|
36
|
+
|
|
37
|
+
const AnimatedSvg = Animated.createAnimatedComponent(Svg);
|
|
38
|
+
|
|
39
|
+
export type CheckboxSize = 'sm' | 'md' | 'lg';
|
|
40
|
+
|
|
41
|
+
export interface CheckboxProps {
|
|
42
|
+
/** Whether the checkbox is checked */
|
|
43
|
+
checked?: boolean;
|
|
44
|
+
/** Callback when checked state changes */
|
|
45
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
46
|
+
/** Show indeterminate state (overrides checked) */
|
|
47
|
+
indeterminate?: boolean;
|
|
48
|
+
/** Disable the checkbox */
|
|
49
|
+
disabled?: boolean;
|
|
50
|
+
/** Label text shown next to checkbox */
|
|
51
|
+
label?: string;
|
|
52
|
+
/** Description text below label */
|
|
53
|
+
description?: string;
|
|
54
|
+
/** Additional accessibility label */
|
|
55
|
+
accessibilityLabel?: string;
|
|
56
|
+
/** Size variant */
|
|
57
|
+
size?: CheckboxSize;
|
|
58
|
+
/** Additional container styles */
|
|
59
|
+
style?: ViewStyle;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function Checkbox({
|
|
63
|
+
checked = false,
|
|
64
|
+
onCheckedChange,
|
|
65
|
+
indeterminate = false,
|
|
66
|
+
disabled = false,
|
|
67
|
+
label,
|
|
68
|
+
description,
|
|
69
|
+
accessibilityLabel,
|
|
70
|
+
size = 'md',
|
|
71
|
+
style,
|
|
72
|
+
}: CheckboxProps) {
|
|
73
|
+
const { colors, components, platformShadow, springs } = useTheme();
|
|
74
|
+
const tokens = components.checkbox[size];
|
|
75
|
+
const progress = useSharedValue(checked || indeterminate ? 1 : 0);
|
|
76
|
+
const scale = useSharedValue(1);
|
|
77
|
+
const [reduceMotion, setReduceMotion] = React.useState(false);
|
|
78
|
+
|
|
79
|
+
// Check for reduce motion preference
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion);
|
|
82
|
+
const subscription = AccessibilityInfo.addEventListener(
|
|
83
|
+
'reduceMotionChanged',
|
|
84
|
+
setReduceMotion
|
|
85
|
+
);
|
|
86
|
+
return () => subscription.remove();
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
// Animate on state change
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
const target = checked || indeterminate ? 1 : 0;
|
|
92
|
+
if (reduceMotion) {
|
|
93
|
+
progress.value = target;
|
|
94
|
+
} else {
|
|
95
|
+
progress.value = withSpring(target, springs.snappy);
|
|
96
|
+
}
|
|
97
|
+
}, [checked, indeterminate, reduceMotion, springs.snappy]);
|
|
98
|
+
|
|
99
|
+
const handlePressIn = useCallback(() => {
|
|
100
|
+
if (!disabled) {
|
|
101
|
+
scale.value = withSpring(0.92, springs.snappy);
|
|
102
|
+
}
|
|
103
|
+
}, [disabled, springs.snappy]);
|
|
104
|
+
|
|
105
|
+
const handlePressOut = useCallback(() => {
|
|
106
|
+
scale.value = withSpring(1, springs.snappy);
|
|
107
|
+
}, [springs.snappy]);
|
|
108
|
+
|
|
109
|
+
const handlePress = useCallback(() => {
|
|
110
|
+
if (disabled || !onCheckedChange) return;
|
|
111
|
+
haptic('light');
|
|
112
|
+
onCheckedChange(!checked);
|
|
113
|
+
}, [disabled, onCheckedChange, checked]);
|
|
114
|
+
|
|
115
|
+
const boxAnimatedStyle = useAnimatedStyle(() => ({
|
|
116
|
+
backgroundColor: interpolateColor(
|
|
117
|
+
progress.value,
|
|
118
|
+
[0, 1],
|
|
119
|
+
['transparent', colors.primary]
|
|
120
|
+
),
|
|
121
|
+
borderColor: interpolateColor(
|
|
122
|
+
progress.value,
|
|
123
|
+
[0, 1],
|
|
124
|
+
[colors.border, colors.primary]
|
|
125
|
+
),
|
|
126
|
+
transform: [{ scale: scale.value }],
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
const iconAnimatedStyle = useAnimatedStyle(() => ({
|
|
130
|
+
opacity: progress.value,
|
|
131
|
+
transform: [
|
|
132
|
+
{
|
|
133
|
+
scale: interpolate(
|
|
134
|
+
progress.value,
|
|
135
|
+
[0, 1],
|
|
136
|
+
[0.5, 1],
|
|
137
|
+
Extrapolation.CLAMP
|
|
138
|
+
),
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
// Checkmark SVG path
|
|
144
|
+
const checkPath = "M4 12l5 5L20 6";
|
|
145
|
+
// Indeterminate line path
|
|
146
|
+
const indeterminatePath = "M6 12h12";
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<Pressable
|
|
150
|
+
onPressIn={handlePressIn}
|
|
151
|
+
onPressOut={handlePressOut}
|
|
152
|
+
onPress={handlePress}
|
|
153
|
+
disabled={disabled}
|
|
154
|
+
style={[
|
|
155
|
+
styles.container,
|
|
156
|
+
{ gap: tokens.gap },
|
|
157
|
+
style,
|
|
158
|
+
]}
|
|
159
|
+
accessible
|
|
160
|
+
accessibilityRole="checkbox"
|
|
161
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
162
|
+
accessibilityState={{
|
|
163
|
+
checked: indeterminate ? 'mixed' : checked,
|
|
164
|
+
disabled,
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
<Animated.View
|
|
168
|
+
style={[
|
|
169
|
+
styles.box,
|
|
170
|
+
{
|
|
171
|
+
width: tokens.size,
|
|
172
|
+
height: tokens.size,
|
|
173
|
+
borderRadius: tokens.borderRadius,
|
|
174
|
+
borderWidth: tokens.borderWidth,
|
|
175
|
+
},
|
|
176
|
+
boxAnimatedStyle,
|
|
177
|
+
(checked || indeterminate) && platformShadow('sm'),
|
|
178
|
+
disabled && styles.disabled,
|
|
179
|
+
]}
|
|
180
|
+
>
|
|
181
|
+
<AnimatedSvg
|
|
182
|
+
width={tokens.iconSize}
|
|
183
|
+
height={tokens.iconSize}
|
|
184
|
+
viewBox="0 0 24 24"
|
|
185
|
+
fill="none"
|
|
186
|
+
style={iconAnimatedStyle}
|
|
187
|
+
>
|
|
188
|
+
<Path
|
|
189
|
+
d={indeterminate ? indeterminatePath : checkPath}
|
|
190
|
+
stroke={colors.primaryForeground}
|
|
191
|
+
strokeWidth={3}
|
|
192
|
+
strokeLinecap="round"
|
|
193
|
+
strokeLinejoin="round"
|
|
194
|
+
/>
|
|
195
|
+
</AnimatedSvg>
|
|
196
|
+
</Animated.View>
|
|
197
|
+
|
|
198
|
+
{(label || description) && (
|
|
199
|
+
<View style={styles.labelContainer}>
|
|
200
|
+
{label && (
|
|
201
|
+
<Text
|
|
202
|
+
style={[
|
|
203
|
+
styles.label,
|
|
204
|
+
{
|
|
205
|
+
fontSize: tokens.labelFontSize,
|
|
206
|
+
color: disabled ? colors.foregroundMuted : colors.foreground,
|
|
207
|
+
},
|
|
208
|
+
]}
|
|
209
|
+
>
|
|
210
|
+
{label}
|
|
211
|
+
</Text>
|
|
212
|
+
)}
|
|
213
|
+
{description && (
|
|
214
|
+
<Text
|
|
215
|
+
style={[
|
|
216
|
+
styles.description,
|
|
217
|
+
{ color: colors.foregroundMuted },
|
|
218
|
+
]}
|
|
219
|
+
>
|
|
220
|
+
{description}
|
|
221
|
+
</Text>
|
|
222
|
+
)}
|
|
223
|
+
</View>
|
|
224
|
+
)}
|
|
225
|
+
</Pressable>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const styles = StyleSheet.create({
|
|
230
|
+
container: {
|
|
231
|
+
flexDirection: 'row',
|
|
232
|
+
alignItems: 'flex-start',
|
|
233
|
+
},
|
|
234
|
+
box: {
|
|
235
|
+
alignItems: 'center',
|
|
236
|
+
justifyContent: 'center',
|
|
237
|
+
overflow: 'hidden',
|
|
238
|
+
},
|
|
239
|
+
disabled: {
|
|
240
|
+
opacity: 0.5,
|
|
241
|
+
},
|
|
242
|
+
labelContainer: {
|
|
243
|
+
flex: 1,
|
|
244
|
+
gap: 2,
|
|
245
|
+
},
|
|
246
|
+
label: {
|
|
247
|
+
fontWeight: '500',
|
|
248
|
+
},
|
|
249
|
+
description: {
|
|
250
|
+
fontSize: 13,
|
|
251
|
+
},
|
|
252
|
+
});
|