@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,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slider
|
|
3
|
+
*
|
|
4
|
+
* A value slider component with gesture support.
|
|
5
|
+
* Supports range selection and step values.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* const [value, setValue] = useState(50);
|
|
10
|
+
*
|
|
11
|
+
* <Slider value={value} onValueChange={setValue} />
|
|
12
|
+
* <Slider value={value} onValueChange={setValue} min={0} max={100} step={10} />
|
|
13
|
+
* <Slider value={value} onValueChange={setValue} showValue />
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import React, { useCallback } from 'react';
|
|
18
|
+
import {
|
|
19
|
+
View,
|
|
20
|
+
Text,
|
|
21
|
+
StyleSheet,
|
|
22
|
+
ViewStyle,
|
|
23
|
+
TextStyle,
|
|
24
|
+
LayoutChangeEvent,
|
|
25
|
+
} from 'react-native';
|
|
26
|
+
import Animated, {
|
|
27
|
+
useSharedValue,
|
|
28
|
+
useAnimatedStyle,
|
|
29
|
+
withSpring,
|
|
30
|
+
runOnJS,
|
|
31
|
+
} from 'react-native-reanimated';
|
|
32
|
+
import {
|
|
33
|
+
Gesture,
|
|
34
|
+
GestureDetector,
|
|
35
|
+
} from 'react-native-gesture-handler';
|
|
36
|
+
import { useTheme } from '@nativeui/core';
|
|
37
|
+
import { haptic } from '@nativeui/core';
|
|
38
|
+
|
|
39
|
+
export type SliderSize = 'sm' | 'md' | 'lg';
|
|
40
|
+
|
|
41
|
+
export interface SliderProps {
|
|
42
|
+
/** Current value */
|
|
43
|
+
value: number;
|
|
44
|
+
/** Callback when value changes */
|
|
45
|
+
onValueChange?: (value: number) => void;
|
|
46
|
+
/** Callback when sliding starts */
|
|
47
|
+
onSlidingStart?: () => void;
|
|
48
|
+
/** Callback when sliding ends */
|
|
49
|
+
onSlidingComplete?: (value: number) => void;
|
|
50
|
+
/** Minimum value */
|
|
51
|
+
min?: number;
|
|
52
|
+
/** Maximum value */
|
|
53
|
+
max?: number;
|
|
54
|
+
/** Step increment */
|
|
55
|
+
step?: number;
|
|
56
|
+
/** Disabled state */
|
|
57
|
+
disabled?: boolean;
|
|
58
|
+
/** Size preset */
|
|
59
|
+
size?: SliderSize;
|
|
60
|
+
/** Show current value label */
|
|
61
|
+
showValue?: boolean;
|
|
62
|
+
/** Format value for display */
|
|
63
|
+
formatValue?: (value: number) => string;
|
|
64
|
+
/** Label text */
|
|
65
|
+
label?: string;
|
|
66
|
+
/** Container style */
|
|
67
|
+
style?: ViewStyle;
|
|
68
|
+
/** Label style */
|
|
69
|
+
labelStyle?: TextStyle;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const trackHeights: Record<SliderSize, number> = {
|
|
73
|
+
sm: 4,
|
|
74
|
+
md: 6,
|
|
75
|
+
lg: 8,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const thumbSizes: Record<SliderSize, number> = {
|
|
79
|
+
sm: 16,
|
|
80
|
+
md: 20,
|
|
81
|
+
lg: 24,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export function Slider({
|
|
85
|
+
value,
|
|
86
|
+
onValueChange,
|
|
87
|
+
onSlidingStart,
|
|
88
|
+
onSlidingComplete,
|
|
89
|
+
min = 0,
|
|
90
|
+
max = 100,
|
|
91
|
+
step = 1,
|
|
92
|
+
disabled = false,
|
|
93
|
+
size = 'md',
|
|
94
|
+
showValue = false,
|
|
95
|
+
formatValue,
|
|
96
|
+
label,
|
|
97
|
+
style,
|
|
98
|
+
labelStyle,
|
|
99
|
+
}: SliderProps) {
|
|
100
|
+
const { colors, spacing } = useTheme();
|
|
101
|
+
|
|
102
|
+
const trackWidth = useSharedValue(0);
|
|
103
|
+
const thumbScale = useSharedValue(1);
|
|
104
|
+
|
|
105
|
+
const trackHeight = trackHeights[size];
|
|
106
|
+
const thumbSize = thumbSizes[size];
|
|
107
|
+
|
|
108
|
+
// Calculate value from position (JS thread version)
|
|
109
|
+
const calculateValue = useCallback(
|
|
110
|
+
(posX: number, width: number): number => {
|
|
111
|
+
if (width === 0) return min;
|
|
112
|
+
const percentage = Math.max(0, Math.min(1, posX / width));
|
|
113
|
+
let newValue = min + percentage * (max - min);
|
|
114
|
+
|
|
115
|
+
// Apply step
|
|
116
|
+
if (step > 0) {
|
|
117
|
+
newValue = Math.round(newValue / step) * step;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return Math.max(min, Math.min(max, newValue));
|
|
121
|
+
},
|
|
122
|
+
[min, max, step]
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const handleUpdate = useCallback(
|
|
126
|
+
(posX: number, width: number) => {
|
|
127
|
+
const newValue = calculateValue(posX, width);
|
|
128
|
+
onValueChange?.(newValue);
|
|
129
|
+
},
|
|
130
|
+
[calculateValue, onValueChange]
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const handleSlidingStart = useCallback(() => {
|
|
134
|
+
haptic('selection');
|
|
135
|
+
onSlidingStart?.();
|
|
136
|
+
}, [onSlidingStart]);
|
|
137
|
+
|
|
138
|
+
const handleSlidingComplete = useCallback(
|
|
139
|
+
(posX: number, width: number) => {
|
|
140
|
+
const finalValue = calculateValue(posX, width);
|
|
141
|
+
haptic('light');
|
|
142
|
+
onSlidingComplete?.(finalValue);
|
|
143
|
+
},
|
|
144
|
+
[calculateValue, onSlidingComplete]
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const panGesture = Gesture.Pan()
|
|
148
|
+
.enabled(!disabled)
|
|
149
|
+
.onStart(() => {
|
|
150
|
+
'worklet';
|
|
151
|
+
thumbScale.value = withSpring(1.2, { damping: 20, stiffness: 300 });
|
|
152
|
+
runOnJS(handleSlidingStart)();
|
|
153
|
+
})
|
|
154
|
+
.onUpdate((event) => {
|
|
155
|
+
'worklet';
|
|
156
|
+
runOnJS(handleUpdate)(event.x, trackWidth.value);
|
|
157
|
+
})
|
|
158
|
+
.onEnd((event) => {
|
|
159
|
+
'worklet';
|
|
160
|
+
thumbScale.value = withSpring(1, { damping: 20, stiffness: 300 });
|
|
161
|
+
runOnJS(handleSlidingComplete)(event.x, trackWidth.value);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const tapGesture = Gesture.Tap()
|
|
165
|
+
.enabled(!disabled)
|
|
166
|
+
.onEnd((event) => {
|
|
167
|
+
'worklet';
|
|
168
|
+
runOnJS(handleUpdate)(event.x, trackWidth.value);
|
|
169
|
+
runOnJS(handleSlidingComplete)(event.x, trackWidth.value);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const gesture = Gesture.Race(panGesture, tapGesture);
|
|
173
|
+
|
|
174
|
+
const handleLayout = (event: LayoutChangeEvent) => {
|
|
175
|
+
trackWidth.value = event.nativeEvent.layout.width;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const percentage = ((value - min) / (max - min)) * 100;
|
|
179
|
+
|
|
180
|
+
const thumbAnimatedStyle = useAnimatedStyle(() => ({
|
|
181
|
+
transform: [{ scale: thumbScale.value }],
|
|
182
|
+
}));
|
|
183
|
+
|
|
184
|
+
const displayValue = formatValue ? formatValue(value) : value.toString();
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<View style={[styles.container, style]}>
|
|
188
|
+
{(label || showValue) && (
|
|
189
|
+
<View style={[styles.header, { marginBottom: spacing[2] }]}>
|
|
190
|
+
{label && (
|
|
191
|
+
<Text
|
|
192
|
+
style={[
|
|
193
|
+
styles.label,
|
|
194
|
+
{ color: disabled ? colors.foregroundMuted : colors.foreground },
|
|
195
|
+
labelStyle,
|
|
196
|
+
]}
|
|
197
|
+
>
|
|
198
|
+
{label}
|
|
199
|
+
</Text>
|
|
200
|
+
)}
|
|
201
|
+
{showValue && (
|
|
202
|
+
<Text
|
|
203
|
+
style={[
|
|
204
|
+
styles.value,
|
|
205
|
+
{ color: disabled ? colors.foregroundMuted : colors.primary },
|
|
206
|
+
]}
|
|
207
|
+
>
|
|
208
|
+
{displayValue}
|
|
209
|
+
</Text>
|
|
210
|
+
)}
|
|
211
|
+
</View>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
<GestureDetector gesture={gesture}>
|
|
215
|
+
<View
|
|
216
|
+
style={[
|
|
217
|
+
styles.trackContainer,
|
|
218
|
+
{ height: thumbSize, opacity: disabled ? 0.5 : 1 },
|
|
219
|
+
]}
|
|
220
|
+
onLayout={handleLayout}
|
|
221
|
+
>
|
|
222
|
+
{/* Track background */}
|
|
223
|
+
<View
|
|
224
|
+
style={[
|
|
225
|
+
styles.track,
|
|
226
|
+
{
|
|
227
|
+
height: trackHeight,
|
|
228
|
+
borderRadius: trackHeight / 2,
|
|
229
|
+
backgroundColor: colors.backgroundMuted,
|
|
230
|
+
},
|
|
231
|
+
]}
|
|
232
|
+
/>
|
|
233
|
+
|
|
234
|
+
{/* Track fill */}
|
|
235
|
+
<View
|
|
236
|
+
style={[
|
|
237
|
+
styles.trackFill,
|
|
238
|
+
{
|
|
239
|
+
height: trackHeight,
|
|
240
|
+
borderRadius: trackHeight / 2,
|
|
241
|
+
backgroundColor: colors.primary,
|
|
242
|
+
width: `${percentage}%`,
|
|
243
|
+
},
|
|
244
|
+
]}
|
|
245
|
+
/>
|
|
246
|
+
|
|
247
|
+
{/* Thumb */}
|
|
248
|
+
<Animated.View
|
|
249
|
+
style={[
|
|
250
|
+
styles.thumb,
|
|
251
|
+
{
|
|
252
|
+
width: thumbSize,
|
|
253
|
+
height: thumbSize,
|
|
254
|
+
borderRadius: thumbSize / 2,
|
|
255
|
+
backgroundColor: colors.background,
|
|
256
|
+
borderWidth: 2,
|
|
257
|
+
borderColor: colors.primary,
|
|
258
|
+
left: `${percentage}%`,
|
|
259
|
+
marginLeft: -thumbSize / 2,
|
|
260
|
+
},
|
|
261
|
+
thumbAnimatedStyle,
|
|
262
|
+
]}
|
|
263
|
+
/>
|
|
264
|
+
</View>
|
|
265
|
+
</GestureDetector>
|
|
266
|
+
</View>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const styles = StyleSheet.create({
|
|
271
|
+
container: {
|
|
272
|
+
width: '100%',
|
|
273
|
+
},
|
|
274
|
+
header: {
|
|
275
|
+
flexDirection: 'row',
|
|
276
|
+
justifyContent: 'space-between',
|
|
277
|
+
alignItems: 'center',
|
|
278
|
+
},
|
|
279
|
+
label: {
|
|
280
|
+
fontSize: 14,
|
|
281
|
+
fontWeight: '500',
|
|
282
|
+
},
|
|
283
|
+
value: {
|
|
284
|
+
fontSize: 14,
|
|
285
|
+
fontWeight: '600',
|
|
286
|
+
},
|
|
287
|
+
trackContainer: {
|
|
288
|
+
justifyContent: 'center',
|
|
289
|
+
},
|
|
290
|
+
track: {
|
|
291
|
+
position: 'absolute',
|
|
292
|
+
left: 0,
|
|
293
|
+
right: 0,
|
|
294
|
+
},
|
|
295
|
+
trackFill: {
|
|
296
|
+
position: 'absolute',
|
|
297
|
+
left: 0,
|
|
298
|
+
},
|
|
299
|
+
thumb: {
|
|
300
|
+
position: 'absolute',
|
|
301
|
+
shadowColor: '#000',
|
|
302
|
+
shadowOffset: { width: 0, height: 2 },
|
|
303
|
+
shadowOpacity: 0.1,
|
|
304
|
+
shadowRadius: 4,
|
|
305
|
+
elevation: 3,
|
|
306
|
+
},
|
|
307
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spinner
|
|
3
|
+
*
|
|
4
|
+
* A loading indicator component with consistent styling.
|
|
5
|
+
* Wraps ActivityIndicator with theme-aware colors and size presets.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* <Spinner />
|
|
10
|
+
* <Spinner size="lg" />
|
|
11
|
+
* <Spinner color="primary" />
|
|
12
|
+
* <Spinner size="sm" color="secondary" />
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React from 'react';
|
|
17
|
+
import {
|
|
18
|
+
ActivityIndicator,
|
|
19
|
+
View,
|
|
20
|
+
StyleSheet,
|
|
21
|
+
ViewStyle,
|
|
22
|
+
ActivityIndicatorProps,
|
|
23
|
+
} from 'react-native';
|
|
24
|
+
import { useTheme } from '@nativeui/core';
|
|
25
|
+
|
|
26
|
+
export type SpinnerSize = 'sm' | 'md' | 'lg';
|
|
27
|
+
export type SpinnerColor = 'default' | 'primary' | 'secondary' | 'muted';
|
|
28
|
+
|
|
29
|
+
export interface SpinnerProps extends Omit<ActivityIndicatorProps, 'size' | 'color'> {
|
|
30
|
+
/** Size preset */
|
|
31
|
+
size?: SpinnerSize;
|
|
32
|
+
/** Color variant */
|
|
33
|
+
color?: SpinnerColor;
|
|
34
|
+
/** Container style */
|
|
35
|
+
style?: ViewStyle;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const sizeMap: Record<SpinnerSize, 'small' | 'large'> = {
|
|
39
|
+
sm: 'small',
|
|
40
|
+
md: 'small',
|
|
41
|
+
lg: 'large',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const containerSizes: Record<SpinnerSize, number> = {
|
|
45
|
+
sm: 16,
|
|
46
|
+
md: 24,
|
|
47
|
+
lg: 36,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function Spinner({
|
|
51
|
+
size = 'md',
|
|
52
|
+
color = 'default',
|
|
53
|
+
style,
|
|
54
|
+
...props
|
|
55
|
+
}: SpinnerProps) {
|
|
56
|
+
const { colors } = useTheme();
|
|
57
|
+
|
|
58
|
+
const getColor = (): string => {
|
|
59
|
+
switch (color) {
|
|
60
|
+
case 'primary':
|
|
61
|
+
return colors.primary;
|
|
62
|
+
case 'secondary':
|
|
63
|
+
return colors.secondary;
|
|
64
|
+
case 'muted':
|
|
65
|
+
return colors.foregroundMuted;
|
|
66
|
+
case 'default':
|
|
67
|
+
default:
|
|
68
|
+
return colors.foreground;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<View
|
|
74
|
+
style={[
|
|
75
|
+
styles.container,
|
|
76
|
+
{
|
|
77
|
+
width: containerSizes[size],
|
|
78
|
+
height: containerSizes[size],
|
|
79
|
+
},
|
|
80
|
+
style,
|
|
81
|
+
]}
|
|
82
|
+
accessibilityRole="progressbar"
|
|
83
|
+
accessibilityLabel="Loading"
|
|
84
|
+
accessibilityState={{ busy: true }}
|
|
85
|
+
>
|
|
86
|
+
<ActivityIndicator
|
|
87
|
+
size={sizeMap[size]}
|
|
88
|
+
color={getColor()}
|
|
89
|
+
{...props}
|
|
90
|
+
/>
|
|
91
|
+
</View>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const styles = StyleSheet.create({
|
|
96
|
+
container: {
|
|
97
|
+
justifyContent: 'center',
|
|
98
|
+
alignItems: 'center',
|
|
99
|
+
},
|
|
100
|
+
});
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stepper
|
|
3
|
+
*
|
|
4
|
+
* A quantity input component with increment/decrement buttons.
|
|
5
|
+
* Perfect for cart quantities, counters, and numeric inputs.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* const [value, setValue] = useState(1);
|
|
10
|
+
*
|
|
11
|
+
* <Stepper value={value} onValueChange={setValue} />
|
|
12
|
+
* <Stepper value={value} onValueChange={setValue} min={0} max={10} />
|
|
13
|
+
* <Stepper value={value} onValueChange={setValue} size="lg" />
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import React, { useCallback } from 'react';
|
|
18
|
+
import {
|
|
19
|
+
View,
|
|
20
|
+
Text,
|
|
21
|
+
Pressable,
|
|
22
|
+
StyleSheet,
|
|
23
|
+
ViewStyle,
|
|
24
|
+
TextStyle,
|
|
25
|
+
} from 'react-native';
|
|
26
|
+
import Animated, {
|
|
27
|
+
useSharedValue,
|
|
28
|
+
useAnimatedStyle,
|
|
29
|
+
withSpring,
|
|
30
|
+
} from 'react-native-reanimated';
|
|
31
|
+
import { useTheme } from '@nativeui/core';
|
|
32
|
+
import { haptic } from '@nativeui/core';
|
|
33
|
+
|
|
34
|
+
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
|
35
|
+
|
|
36
|
+
export type StepperSize = 'sm' | 'md' | 'lg';
|
|
37
|
+
export type StepperVariant = 'default' | 'outline' | 'ghost';
|
|
38
|
+
|
|
39
|
+
export interface StepperProps {
|
|
40
|
+
/** Current value */
|
|
41
|
+
value: number;
|
|
42
|
+
/** Callback when value changes */
|
|
43
|
+
onValueChange?: (value: number) => void;
|
|
44
|
+
/** Minimum value */
|
|
45
|
+
min?: number;
|
|
46
|
+
/** Maximum value */
|
|
47
|
+
max?: number;
|
|
48
|
+
/** Step increment */
|
|
49
|
+
step?: number;
|
|
50
|
+
/** Disabled state */
|
|
51
|
+
disabled?: boolean;
|
|
52
|
+
/** Size preset */
|
|
53
|
+
size?: StepperSize;
|
|
54
|
+
/** Visual variant */
|
|
55
|
+
variant?: StepperVariant;
|
|
56
|
+
/** Label text */
|
|
57
|
+
label?: string;
|
|
58
|
+
/** Format value for display */
|
|
59
|
+
formatValue?: (value: number) => string;
|
|
60
|
+
/** Container style */
|
|
61
|
+
style?: ViewStyle;
|
|
62
|
+
/** Label style */
|
|
63
|
+
labelStyle?: TextStyle;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const sizeTokens: Record<StepperSize, {
|
|
67
|
+
height: number;
|
|
68
|
+
buttonWidth: number;
|
|
69
|
+
fontSize: number;
|
|
70
|
+
iconSize: number;
|
|
71
|
+
valueWidth: number;
|
|
72
|
+
}> = {
|
|
73
|
+
sm: { height: 32, buttonWidth: 32, fontSize: 14, iconSize: 16, valueWidth: 40 },
|
|
74
|
+
md: { height: 40, buttonWidth: 40, fontSize: 16, iconSize: 20, valueWidth: 48 },
|
|
75
|
+
lg: { height: 48, buttonWidth: 48, fontSize: 18, iconSize: 24, valueWidth: 56 },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export function Stepper({
|
|
79
|
+
value,
|
|
80
|
+
onValueChange,
|
|
81
|
+
min = 0,
|
|
82
|
+
max = Infinity,
|
|
83
|
+
step = 1,
|
|
84
|
+
disabled = false,
|
|
85
|
+
size = 'md',
|
|
86
|
+
variant = 'default',
|
|
87
|
+
label,
|
|
88
|
+
formatValue,
|
|
89
|
+
style,
|
|
90
|
+
labelStyle,
|
|
91
|
+
}: StepperProps) {
|
|
92
|
+
const { colors, radius, spacing } = useTheme();
|
|
93
|
+
const tokens = sizeTokens[size];
|
|
94
|
+
|
|
95
|
+
const decrementScale = useSharedValue(1);
|
|
96
|
+
const incrementScale = useSharedValue(1);
|
|
97
|
+
|
|
98
|
+
const canDecrement = value > min;
|
|
99
|
+
const canIncrement = value < max;
|
|
100
|
+
|
|
101
|
+
const handleDecrement = useCallback(() => {
|
|
102
|
+
if (!canDecrement || disabled) return;
|
|
103
|
+
haptic('light');
|
|
104
|
+
const newValue = Math.max(min, value - step);
|
|
105
|
+
onValueChange?.(newValue);
|
|
106
|
+
}, [canDecrement, disabled, min, value, step, onValueChange]);
|
|
107
|
+
|
|
108
|
+
const handleIncrement = useCallback(() => {
|
|
109
|
+
if (!canIncrement || disabled) return;
|
|
110
|
+
haptic('light');
|
|
111
|
+
const newValue = Math.min(max, value + step);
|
|
112
|
+
onValueChange?.(newValue);
|
|
113
|
+
}, [canIncrement, disabled, max, value, step, onValueChange]);
|
|
114
|
+
|
|
115
|
+
const handleDecrementPressIn = () => {
|
|
116
|
+
decrementScale.value = withSpring(0.9, { damping: 15, stiffness: 400 });
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const handleDecrementPressOut = () => {
|
|
120
|
+
decrementScale.value = withSpring(1, { damping: 15, stiffness: 400 });
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const handleIncrementPressIn = () => {
|
|
124
|
+
incrementScale.value = withSpring(0.9, { damping: 15, stiffness: 400 });
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const handleIncrementPressOut = () => {
|
|
128
|
+
incrementScale.value = withSpring(1, { damping: 15, stiffness: 400 });
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const decrementAnimatedStyle = useAnimatedStyle(() => ({
|
|
132
|
+
transform: [{ scale: decrementScale.value }],
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
const incrementAnimatedStyle = useAnimatedStyle(() => ({
|
|
136
|
+
transform: [{ scale: incrementScale.value }],
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
const getButtonStyle = (isDisabled: boolean): ViewStyle => {
|
|
140
|
+
const base: ViewStyle = {
|
|
141
|
+
width: tokens.buttonWidth,
|
|
142
|
+
height: tokens.height,
|
|
143
|
+
alignItems: 'center',
|
|
144
|
+
justifyContent: 'center',
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
switch (variant) {
|
|
148
|
+
case 'outline':
|
|
149
|
+
return {
|
|
150
|
+
...base,
|
|
151
|
+
backgroundColor: 'transparent',
|
|
152
|
+
borderWidth: 1,
|
|
153
|
+
borderColor: isDisabled ? colors.border : colors.border,
|
|
154
|
+
};
|
|
155
|
+
case 'ghost':
|
|
156
|
+
return {
|
|
157
|
+
...base,
|
|
158
|
+
backgroundColor: 'transparent',
|
|
159
|
+
};
|
|
160
|
+
default:
|
|
161
|
+
return {
|
|
162
|
+
...base,
|
|
163
|
+
backgroundColor: isDisabled ? colors.backgroundMuted : colors.secondary,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const displayValue = formatValue ? formatValue(value) : value.toString();
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<View style={[styles.wrapper, style]}>
|
|
172
|
+
{label && (
|
|
173
|
+
<Text
|
|
174
|
+
style={[
|
|
175
|
+
styles.label,
|
|
176
|
+
{
|
|
177
|
+
fontSize: 14,
|
|
178
|
+
marginBottom: spacing[2],
|
|
179
|
+
color: disabled ? colors.foregroundMuted : colors.foreground,
|
|
180
|
+
},
|
|
181
|
+
labelStyle,
|
|
182
|
+
]}
|
|
183
|
+
>
|
|
184
|
+
{label}
|
|
185
|
+
</Text>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
<View
|
|
189
|
+
style={[
|
|
190
|
+
styles.container,
|
|
191
|
+
{
|
|
192
|
+
borderRadius: radius.md,
|
|
193
|
+
overflow: 'hidden',
|
|
194
|
+
opacity: disabled ? 0.5 : 1,
|
|
195
|
+
},
|
|
196
|
+
variant === 'outline' && {
|
|
197
|
+
borderWidth: 1,
|
|
198
|
+
borderColor: colors.border,
|
|
199
|
+
},
|
|
200
|
+
]}
|
|
201
|
+
>
|
|
202
|
+
{/* Decrement Button */}
|
|
203
|
+
<AnimatedPressable
|
|
204
|
+
style={[
|
|
205
|
+
getButtonStyle(!canDecrement),
|
|
206
|
+
{ borderTopLeftRadius: radius.md, borderBottomLeftRadius: radius.md },
|
|
207
|
+
decrementAnimatedStyle,
|
|
208
|
+
]}
|
|
209
|
+
onPress={handleDecrement}
|
|
210
|
+
onPressIn={handleDecrementPressIn}
|
|
211
|
+
onPressOut={handleDecrementPressOut}
|
|
212
|
+
disabled={disabled || !canDecrement}
|
|
213
|
+
accessibilityRole="button"
|
|
214
|
+
accessibilityLabel="Decrease value"
|
|
215
|
+
accessibilityState={{ disabled: disabled || !canDecrement }}
|
|
216
|
+
>
|
|
217
|
+
<Text
|
|
218
|
+
style={[
|
|
219
|
+
styles.icon,
|
|
220
|
+
{
|
|
221
|
+
fontSize: tokens.iconSize,
|
|
222
|
+
color: !canDecrement || disabled
|
|
223
|
+
? colors.foregroundMuted
|
|
224
|
+
: colors.foreground,
|
|
225
|
+
},
|
|
226
|
+
]}
|
|
227
|
+
>
|
|
228
|
+
−
|
|
229
|
+
</Text>
|
|
230
|
+
</AnimatedPressable>
|
|
231
|
+
|
|
232
|
+
{/* Value Display */}
|
|
233
|
+
<View
|
|
234
|
+
style={[
|
|
235
|
+
styles.valueContainer,
|
|
236
|
+
{
|
|
237
|
+
width: tokens.valueWidth,
|
|
238
|
+
height: tokens.height,
|
|
239
|
+
backgroundColor: variant === 'default' ? colors.background : 'transparent',
|
|
240
|
+
borderLeftWidth: variant !== 'ghost' ? 1 : 0,
|
|
241
|
+
borderRightWidth: variant !== 'ghost' ? 1 : 0,
|
|
242
|
+
borderColor: colors.border,
|
|
243
|
+
},
|
|
244
|
+
]}
|
|
245
|
+
>
|
|
246
|
+
<Text
|
|
247
|
+
style={[
|
|
248
|
+
styles.value,
|
|
249
|
+
{
|
|
250
|
+
fontSize: tokens.fontSize,
|
|
251
|
+
color: disabled ? colors.foregroundMuted : colors.foreground,
|
|
252
|
+
},
|
|
253
|
+
]}
|
|
254
|
+
>
|
|
255
|
+
{displayValue}
|
|
256
|
+
</Text>
|
|
257
|
+
</View>
|
|
258
|
+
|
|
259
|
+
{/* Increment Button */}
|
|
260
|
+
<AnimatedPressable
|
|
261
|
+
style={[
|
|
262
|
+
getButtonStyle(!canIncrement),
|
|
263
|
+
{ borderTopRightRadius: radius.md, borderBottomRightRadius: radius.md },
|
|
264
|
+
incrementAnimatedStyle,
|
|
265
|
+
]}
|
|
266
|
+
onPress={handleIncrement}
|
|
267
|
+
onPressIn={handleIncrementPressIn}
|
|
268
|
+
onPressOut={handleIncrementPressOut}
|
|
269
|
+
disabled={disabled || !canIncrement}
|
|
270
|
+
accessibilityRole="button"
|
|
271
|
+
accessibilityLabel="Increase value"
|
|
272
|
+
accessibilityState={{ disabled: disabled || !canIncrement }}
|
|
273
|
+
>
|
|
274
|
+
<Text
|
|
275
|
+
style={[
|
|
276
|
+
styles.icon,
|
|
277
|
+
{
|
|
278
|
+
fontSize: tokens.iconSize,
|
|
279
|
+
color: !canIncrement || disabled
|
|
280
|
+
? colors.foregroundMuted
|
|
281
|
+
: colors.foreground,
|
|
282
|
+
},
|
|
283
|
+
]}
|
|
284
|
+
>
|
|
285
|
+
+
|
|
286
|
+
</Text>
|
|
287
|
+
</AnimatedPressable>
|
|
288
|
+
</View>
|
|
289
|
+
</View>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const styles = StyleSheet.create({
|
|
294
|
+
wrapper: {},
|
|
295
|
+
label: {
|
|
296
|
+
fontWeight: '500',
|
|
297
|
+
},
|
|
298
|
+
container: {
|
|
299
|
+
flexDirection: 'row',
|
|
300
|
+
alignItems: 'center',
|
|
301
|
+
alignSelf: 'flex-start',
|
|
302
|
+
},
|
|
303
|
+
valueContainer: {
|
|
304
|
+
alignItems: 'center',
|
|
305
|
+
justifyContent: 'center',
|
|
306
|
+
},
|
|
307
|
+
value: {
|
|
308
|
+
fontWeight: '600',
|
|
309
|
+
fontVariant: ['tabular-nums'],
|
|
310
|
+
},
|
|
311
|
+
icon: {
|
|
312
|
+
fontWeight: '400',
|
|
313
|
+
},
|
|
314
|
+
});
|