@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,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Swipeable Row
|
|
3
|
+
*
|
|
4
|
+
* A list row with swipe-to-reveal actions.
|
|
5
|
+
* Supports left and right swipe actions with haptic feedback.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* <SwipeableRow
|
|
10
|
+
* rightActions={[
|
|
11
|
+
* {
|
|
12
|
+
* label: 'Archive',
|
|
13
|
+
* color: '#3b82f6',
|
|
14
|
+
* onPress: () => handleArchive(),
|
|
15
|
+
* },
|
|
16
|
+
* {
|
|
17
|
+
* label: 'Delete',
|
|
18
|
+
* color: '#ef4444',
|
|
19
|
+
* onPress: () => handleDelete(),
|
|
20
|
+
* },
|
|
21
|
+
* ]}
|
|
22
|
+
* >
|
|
23
|
+
* <ListItem title="Swipe me" />
|
|
24
|
+
* </SwipeableRow>
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import React, { useCallback, useRef } from 'react';
|
|
29
|
+
import {
|
|
30
|
+
View,
|
|
31
|
+
Text,
|
|
32
|
+
Pressable,
|
|
33
|
+
StyleSheet,
|
|
34
|
+
ViewStyle,
|
|
35
|
+
TextStyle,
|
|
36
|
+
Dimensions,
|
|
37
|
+
} from 'react-native';
|
|
38
|
+
import Animated, {
|
|
39
|
+
useSharedValue,
|
|
40
|
+
useAnimatedStyle,
|
|
41
|
+
withSpring,
|
|
42
|
+
withTiming,
|
|
43
|
+
runOnJS,
|
|
44
|
+
interpolate,
|
|
45
|
+
Extrapolation,
|
|
46
|
+
} from 'react-native-reanimated';
|
|
47
|
+
import {
|
|
48
|
+
Gesture,
|
|
49
|
+
GestureDetector,
|
|
50
|
+
} from 'react-native-gesture-handler';
|
|
51
|
+
import { useTheme } from '@nativeui/core';
|
|
52
|
+
import { haptic } from '@nativeui/core';
|
|
53
|
+
|
|
54
|
+
const SCREEN_WIDTH = Dimensions.get('window').width;
|
|
55
|
+
const ACTION_WIDTH = 80;
|
|
56
|
+
const SPRING_CONFIG = { damping: 20, stiffness: 200 };
|
|
57
|
+
|
|
58
|
+
export interface SwipeAction {
|
|
59
|
+
/** Action label */
|
|
60
|
+
label: string;
|
|
61
|
+
/** Icon component (optional) */
|
|
62
|
+
icon?: React.ReactNode;
|
|
63
|
+
/** Background color */
|
|
64
|
+
color: string;
|
|
65
|
+
/** Text color */
|
|
66
|
+
textColor?: string;
|
|
67
|
+
/** Action callback */
|
|
68
|
+
onPress: () => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface SwipeableRowProps {
|
|
72
|
+
/** Content to display */
|
|
73
|
+
children: React.ReactNode;
|
|
74
|
+
/** Actions revealed on left swipe (right side) */
|
|
75
|
+
rightActions?: SwipeAction[];
|
|
76
|
+
/** Actions revealed on right swipe (left side) */
|
|
77
|
+
leftActions?: SwipeAction[];
|
|
78
|
+
/** Width of each action button */
|
|
79
|
+
actionWidth?: number;
|
|
80
|
+
/** Enable full swipe to trigger first action */
|
|
81
|
+
fullSwipeEnabled?: boolean;
|
|
82
|
+
/** Callback when row is swiped open */
|
|
83
|
+
onSwipeOpen?: (direction: 'left' | 'right') => void;
|
|
84
|
+
/** Callback when row is closed */
|
|
85
|
+
onSwipeClose?: () => void;
|
|
86
|
+
/** Container style */
|
|
87
|
+
style?: ViewStyle;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function SwipeableRow({
|
|
91
|
+
children,
|
|
92
|
+
rightActions = [],
|
|
93
|
+
leftActions = [],
|
|
94
|
+
actionWidth = ACTION_WIDTH,
|
|
95
|
+
fullSwipeEnabled = true,
|
|
96
|
+
onSwipeOpen,
|
|
97
|
+
onSwipeClose,
|
|
98
|
+
style,
|
|
99
|
+
}: SwipeableRowProps) {
|
|
100
|
+
const { colors } = useTheme();
|
|
101
|
+
|
|
102
|
+
const translateX = useSharedValue(0);
|
|
103
|
+
const contextX = useSharedValue(0);
|
|
104
|
+
const isOpen = useRef<'left' | 'right' | null>(null);
|
|
105
|
+
|
|
106
|
+
const rightActionsWidth = rightActions.length * actionWidth;
|
|
107
|
+
const leftActionsWidth = leftActions.length * actionWidth;
|
|
108
|
+
const fullSwipeThreshold = SCREEN_WIDTH * 0.5;
|
|
109
|
+
|
|
110
|
+
const close = useCallback(() => {
|
|
111
|
+
translateX.value = withSpring(0, SPRING_CONFIG);
|
|
112
|
+
if (isOpen.current) {
|
|
113
|
+
isOpen.current = null;
|
|
114
|
+
onSwipeClose?.();
|
|
115
|
+
}
|
|
116
|
+
}, [onSwipeClose]);
|
|
117
|
+
|
|
118
|
+
const openRight = useCallback(() => {
|
|
119
|
+
translateX.value = withSpring(-rightActionsWidth, SPRING_CONFIG);
|
|
120
|
+
if (isOpen.current !== 'right') {
|
|
121
|
+
isOpen.current = 'right';
|
|
122
|
+
onSwipeOpen?.('right');
|
|
123
|
+
}
|
|
124
|
+
}, [rightActionsWidth, onSwipeOpen]);
|
|
125
|
+
|
|
126
|
+
const openLeft = useCallback(() => {
|
|
127
|
+
translateX.value = withSpring(leftActionsWidth, SPRING_CONFIG);
|
|
128
|
+
if (isOpen.current !== 'left') {
|
|
129
|
+
isOpen.current = 'left';
|
|
130
|
+
onSwipeOpen?.('left');
|
|
131
|
+
}
|
|
132
|
+
}, [leftActionsWidth, onSwipeOpen]);
|
|
133
|
+
|
|
134
|
+
const triggerFullSwipe = useCallback(
|
|
135
|
+
(direction: 'left' | 'right') => {
|
|
136
|
+
haptic('medium');
|
|
137
|
+
const action = direction === 'right' ? rightActions[0] : leftActions[0];
|
|
138
|
+
if (action) {
|
|
139
|
+
// Animate off screen then reset
|
|
140
|
+
const target = direction === 'right' ? -SCREEN_WIDTH : SCREEN_WIDTH;
|
|
141
|
+
translateX.value = withTiming(target, { duration: 200 }, () => {
|
|
142
|
+
runOnJS(action.onPress)();
|
|
143
|
+
translateX.value = 0;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
[rightActions, leftActions]
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const panGesture = Gesture.Pan()
|
|
151
|
+
.activeOffsetX([-10, 10])
|
|
152
|
+
.onStart(() => {
|
|
153
|
+
contextX.value = translateX.value;
|
|
154
|
+
})
|
|
155
|
+
.onUpdate((event) => {
|
|
156
|
+
let newX = contextX.value + event.translationX;
|
|
157
|
+
|
|
158
|
+
// Limit swipe based on available actions
|
|
159
|
+
if (rightActions.length === 0 && newX < 0) newX = 0;
|
|
160
|
+
if (leftActions.length === 0 && newX > 0) newX = 0;
|
|
161
|
+
|
|
162
|
+
// Determine max swipe distances
|
|
163
|
+
const maxSwipeLeft = fullSwipeEnabled ? SCREEN_WIDTH * 0.75 : rightActionsWidth;
|
|
164
|
+
const maxSwipeRight = fullSwipeEnabled ? SCREEN_WIDTH * 0.75 : leftActionsWidth;
|
|
165
|
+
|
|
166
|
+
// Clamp the swipe to prevent seeing both sides at once
|
|
167
|
+
// Add rubber band resistance when going past the action buttons
|
|
168
|
+
if (newX < 0) {
|
|
169
|
+
// Swiping left (revealing right actions)
|
|
170
|
+
if (newX < -rightActionsWidth) {
|
|
171
|
+
// Add resistance past the buttons
|
|
172
|
+
const overshoot = -newX - rightActionsWidth;
|
|
173
|
+
const resistance = Math.min(overshoot * 0.3, maxSwipeLeft - rightActionsWidth);
|
|
174
|
+
newX = -rightActionsWidth - resistance;
|
|
175
|
+
}
|
|
176
|
+
} else if (newX > 0) {
|
|
177
|
+
// Swiping right (revealing left actions)
|
|
178
|
+
if (newX > leftActionsWidth) {
|
|
179
|
+
// Add resistance past the buttons
|
|
180
|
+
const overshoot = newX - leftActionsWidth;
|
|
181
|
+
const resistance = Math.min(overshoot * 0.3, maxSwipeRight - leftActionsWidth);
|
|
182
|
+
newX = leftActionsWidth + resistance;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
translateX.value = newX;
|
|
187
|
+
})
|
|
188
|
+
.onEnd((event) => {
|
|
189
|
+
'worklet';
|
|
190
|
+
const velocity = event.velocityX;
|
|
191
|
+
const x = translateX.value;
|
|
192
|
+
|
|
193
|
+
// Full swipe detection
|
|
194
|
+
if (fullSwipeEnabled) {
|
|
195
|
+
if (x < -fullSwipeThreshold && rightActions.length > 0) {
|
|
196
|
+
runOnJS(triggerFullSwipe)('right');
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (x > fullSwipeThreshold && leftActions.length > 0) {
|
|
200
|
+
runOnJS(triggerFullSwipe)('left');
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Determine final position based on position and velocity
|
|
206
|
+
if (x < -rightActionsWidth / 2 || velocity < -500) {
|
|
207
|
+
if (rightActions.length > 0) {
|
|
208
|
+
runOnJS(openRight)();
|
|
209
|
+
runOnJS(haptic)('selection');
|
|
210
|
+
} else {
|
|
211
|
+
runOnJS(close)();
|
|
212
|
+
}
|
|
213
|
+
} else if (x > leftActionsWidth / 2 || velocity > 500) {
|
|
214
|
+
if (leftActions.length > 0) {
|
|
215
|
+
runOnJS(openLeft)();
|
|
216
|
+
runOnJS(haptic)('selection');
|
|
217
|
+
} else {
|
|
218
|
+
runOnJS(close)();
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
runOnJS(close)();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const rowStyle = useAnimatedStyle(() => ({
|
|
226
|
+
transform: [{ translateX: translateX.value }],
|
|
227
|
+
}));
|
|
228
|
+
|
|
229
|
+
const rightActionsStyle = useAnimatedStyle(() => {
|
|
230
|
+
// Only show when swiping left (negative translateX)
|
|
231
|
+
const isSwipingLeft = translateX.value < 0;
|
|
232
|
+
return {
|
|
233
|
+
width: isSwipingLeft
|
|
234
|
+
? Math.min(-translateX.value, rightActionsWidth + 50) // Allow slight overshoot for full swipe
|
|
235
|
+
: 0,
|
|
236
|
+
opacity: isSwipingLeft ? 1 : 0,
|
|
237
|
+
};
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const leftActionsStyle = useAnimatedStyle(() => {
|
|
241
|
+
// Only show when swiping right (positive translateX)
|
|
242
|
+
const isSwipingRight = translateX.value > 0;
|
|
243
|
+
return {
|
|
244
|
+
width: isSwipingRight
|
|
245
|
+
? Math.min(translateX.value, leftActionsWidth + 50) // Allow slight overshoot for full swipe
|
|
246
|
+
: 0,
|
|
247
|
+
opacity: isSwipingRight ? 1 : 0,
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<View style={[styles.container, style]}>
|
|
253
|
+
{/* Left Actions (revealed on right swipe) */}
|
|
254
|
+
{leftActions.length > 0 && (
|
|
255
|
+
<Animated.View style={[styles.actionsContainer, styles.leftActions, leftActionsStyle]}>
|
|
256
|
+
{leftActions.map((action, index) => (
|
|
257
|
+
<ActionButton
|
|
258
|
+
key={index}
|
|
259
|
+
action={action}
|
|
260
|
+
width={actionWidth}
|
|
261
|
+
onPress={() => {
|
|
262
|
+
haptic('light');
|
|
263
|
+
action.onPress();
|
|
264
|
+
close();
|
|
265
|
+
}}
|
|
266
|
+
/>
|
|
267
|
+
))}
|
|
268
|
+
</Animated.View>
|
|
269
|
+
)}
|
|
270
|
+
|
|
271
|
+
{/* Right Actions (revealed on left swipe) */}
|
|
272
|
+
{rightActions.length > 0 && (
|
|
273
|
+
<Animated.View style={[styles.actionsContainer, styles.rightActions, rightActionsStyle]}>
|
|
274
|
+
{rightActions.map((action, index) => (
|
|
275
|
+
<ActionButton
|
|
276
|
+
key={index}
|
|
277
|
+
action={action}
|
|
278
|
+
width={actionWidth}
|
|
279
|
+
onPress={() => {
|
|
280
|
+
haptic('light');
|
|
281
|
+
action.onPress();
|
|
282
|
+
close();
|
|
283
|
+
}}
|
|
284
|
+
/>
|
|
285
|
+
))}
|
|
286
|
+
</Animated.View>
|
|
287
|
+
)}
|
|
288
|
+
|
|
289
|
+
{/* Main Content */}
|
|
290
|
+
<GestureDetector gesture={panGesture}>
|
|
291
|
+
<Animated.View style={[styles.row, { backgroundColor: colors.background }, rowStyle]}>
|
|
292
|
+
{children}
|
|
293
|
+
</Animated.View>
|
|
294
|
+
</GestureDetector>
|
|
295
|
+
</View>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
interface ActionButtonProps {
|
|
300
|
+
action: SwipeAction;
|
|
301
|
+
width: number;
|
|
302
|
+
onPress: () => void;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function ActionButton({ action, width, onPress }: ActionButtonProps) {
|
|
306
|
+
return (
|
|
307
|
+
<Pressable
|
|
308
|
+
style={[
|
|
309
|
+
styles.actionButton,
|
|
310
|
+
{ width, backgroundColor: action.color },
|
|
311
|
+
]}
|
|
312
|
+
onPress={onPress}
|
|
313
|
+
>
|
|
314
|
+
{action.icon && <View style={styles.actionIcon}>{action.icon}</View>}
|
|
315
|
+
<Text
|
|
316
|
+
style={[
|
|
317
|
+
styles.actionLabel,
|
|
318
|
+
{ color: action.textColor ?? '#fff' },
|
|
319
|
+
]}
|
|
320
|
+
numberOfLines={1}
|
|
321
|
+
>
|
|
322
|
+
{action.label}
|
|
323
|
+
</Text>
|
|
324
|
+
</Pressable>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const styles = StyleSheet.create({
|
|
329
|
+
container: {
|
|
330
|
+
overflow: 'hidden',
|
|
331
|
+
},
|
|
332
|
+
row: {
|
|
333
|
+
zIndex: 1,
|
|
334
|
+
},
|
|
335
|
+
actionsContainer: {
|
|
336
|
+
position: 'absolute',
|
|
337
|
+
top: 0,
|
|
338
|
+
bottom: 0,
|
|
339
|
+
flexDirection: 'row',
|
|
340
|
+
overflow: 'hidden',
|
|
341
|
+
},
|
|
342
|
+
leftActions: {
|
|
343
|
+
left: 0,
|
|
344
|
+
justifyContent: 'flex-start',
|
|
345
|
+
},
|
|
346
|
+
rightActions: {
|
|
347
|
+
right: 0,
|
|
348
|
+
justifyContent: 'flex-end',
|
|
349
|
+
},
|
|
350
|
+
actionButton: {
|
|
351
|
+
justifyContent: 'center',
|
|
352
|
+
alignItems: 'center',
|
|
353
|
+
paddingHorizontal: 8,
|
|
354
|
+
},
|
|
355
|
+
actionIcon: {
|
|
356
|
+
marginBottom: 4,
|
|
357
|
+
},
|
|
358
|
+
actionLabel: {
|
|
359
|
+
fontSize: 12,
|
|
360
|
+
fontWeight: '600',
|
|
361
|
+
},
|
|
362
|
+
});
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Switch
|
|
3
|
+
*
|
|
4
|
+
* An iOS-style toggle switch with animated thumb.
|
|
5
|
+
* Uses design tokens for consistent styling.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* const [enabled, setEnabled] = useState(false);
|
|
10
|
+
* <Switch checked={enabled} onCheckedChange={setEnabled} />
|
|
11
|
+
* <Switch checked={enabled} onCheckedChange={setEnabled} label="Notifications" />
|
|
12
|
+
* <Switch label="Dark Mode" description="Enable dark theme" />
|
|
13
|
+
* <Switch disabled />
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import React, { useEffect, useCallback } from 'react';
|
|
18
|
+
import {
|
|
19
|
+
Pressable,
|
|
20
|
+
View,
|
|
21
|
+
Text,
|
|
22
|
+
StyleSheet,
|
|
23
|
+
ViewStyle,
|
|
24
|
+
AccessibilityInfo,
|
|
25
|
+
} from 'react-native';
|
|
26
|
+
import Animated, {
|
|
27
|
+
useSharedValue,
|
|
28
|
+
useAnimatedStyle,
|
|
29
|
+
withSpring,
|
|
30
|
+
interpolate,
|
|
31
|
+
interpolateColor,
|
|
32
|
+
Extrapolation,
|
|
33
|
+
} from 'react-native-reanimated';
|
|
34
|
+
import { useTheme } from '@nativeui/core';
|
|
35
|
+
import { haptic } from '@nativeui/core';
|
|
36
|
+
|
|
37
|
+
export type SwitchSize = 'sm' | 'md' | 'lg';
|
|
38
|
+
|
|
39
|
+
export interface SwitchProps {
|
|
40
|
+
/** Whether the switch is on */
|
|
41
|
+
checked?: boolean;
|
|
42
|
+
/** Callback when switch state changes */
|
|
43
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
44
|
+
/** Disable the switch */
|
|
45
|
+
disabled?: boolean;
|
|
46
|
+
/** Label text shown next to switch */
|
|
47
|
+
label?: string;
|
|
48
|
+
/** Description text below label */
|
|
49
|
+
description?: string;
|
|
50
|
+
/** Additional accessibility label */
|
|
51
|
+
accessibilityLabel?: string;
|
|
52
|
+
/** Size variant */
|
|
53
|
+
size?: SwitchSize;
|
|
54
|
+
/** Additional container styles */
|
|
55
|
+
style?: ViewStyle;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function Switch({
|
|
59
|
+
checked = false,
|
|
60
|
+
onCheckedChange,
|
|
61
|
+
disabled = false,
|
|
62
|
+
label,
|
|
63
|
+
description,
|
|
64
|
+
accessibilityLabel,
|
|
65
|
+
size = 'md',
|
|
66
|
+
style,
|
|
67
|
+
}: SwitchProps) {
|
|
68
|
+
const { colors, components, platformShadow, springs } = useTheme();
|
|
69
|
+
const tokens = components.switch[size];
|
|
70
|
+
const progress = useSharedValue(checked ? 1 : 0);
|
|
71
|
+
const scale = useSharedValue(1);
|
|
72
|
+
const [reduceMotion, setReduceMotion] = React.useState(false);
|
|
73
|
+
|
|
74
|
+
// Calculate thumb travel distance from tokens
|
|
75
|
+
const thumbTravel = tokens.trackWidth - tokens.thumbSize - (tokens.thumbOffset * 2);
|
|
76
|
+
|
|
77
|
+
// Check for reduce motion preference
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion);
|
|
80
|
+
const subscription = AccessibilityInfo.addEventListener(
|
|
81
|
+
'reduceMotionChanged',
|
|
82
|
+
setReduceMotion
|
|
83
|
+
);
|
|
84
|
+
return () => subscription.remove();
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
// Animate on state change
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
const target = checked ? 1 : 0;
|
|
90
|
+
if (reduceMotion) {
|
|
91
|
+
progress.value = target;
|
|
92
|
+
} else {
|
|
93
|
+
progress.value = withSpring(target, springs.snappy);
|
|
94
|
+
}
|
|
95
|
+
}, [checked, reduceMotion, springs.snappy]);
|
|
96
|
+
|
|
97
|
+
const handlePressIn = useCallback(() => {
|
|
98
|
+
if (!disabled) {
|
|
99
|
+
scale.value = withSpring(0.95, springs.snappy);
|
|
100
|
+
}
|
|
101
|
+
}, [disabled, springs.snappy]);
|
|
102
|
+
|
|
103
|
+
const handlePressOut = useCallback(() => {
|
|
104
|
+
scale.value = withSpring(1, springs.snappy);
|
|
105
|
+
}, [springs.snappy]);
|
|
106
|
+
|
|
107
|
+
const handlePress = useCallback(() => {
|
|
108
|
+
if (disabled || !onCheckedChange) return;
|
|
109
|
+
haptic('medium');
|
|
110
|
+
onCheckedChange(!checked);
|
|
111
|
+
}, [disabled, onCheckedChange, checked]);
|
|
112
|
+
|
|
113
|
+
const trackAnimatedStyle = useAnimatedStyle(() => ({
|
|
114
|
+
backgroundColor: interpolateColor(
|
|
115
|
+
progress.value,
|
|
116
|
+
[0, 1],
|
|
117
|
+
[colors.backgroundMuted, colors.primary]
|
|
118
|
+
),
|
|
119
|
+
transform: [{ scale: scale.value }],
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
const thumbAnimatedStyle = useAnimatedStyle(() => ({
|
|
123
|
+
transform: [
|
|
124
|
+
{
|
|
125
|
+
translateX: interpolate(
|
|
126
|
+
progress.value,
|
|
127
|
+
[0, 1],
|
|
128
|
+
[0, thumbTravel],
|
|
129
|
+
Extrapolation.CLAMP
|
|
130
|
+
),
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
scale: interpolate(
|
|
134
|
+
progress.value,
|
|
135
|
+
[0, 0.5, 1],
|
|
136
|
+
[1, 0.9, 1],
|
|
137
|
+
Extrapolation.CLAMP
|
|
138
|
+
),
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<Pressable
|
|
145
|
+
onPressIn={handlePressIn}
|
|
146
|
+
onPressOut={handlePressOut}
|
|
147
|
+
onPress={handlePress}
|
|
148
|
+
disabled={disabled}
|
|
149
|
+
style={[
|
|
150
|
+
styles.container,
|
|
151
|
+
{ gap: tokens.gap },
|
|
152
|
+
style,
|
|
153
|
+
]}
|
|
154
|
+
accessible
|
|
155
|
+
accessibilityRole="switch"
|
|
156
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
157
|
+
accessibilityState={{
|
|
158
|
+
checked,
|
|
159
|
+
disabled,
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
{(label || description) && (
|
|
163
|
+
<View style={styles.labelContainer}>
|
|
164
|
+
{label && (
|
|
165
|
+
<Text
|
|
166
|
+
style={[
|
|
167
|
+
styles.label,
|
|
168
|
+
{
|
|
169
|
+
fontSize: tokens.labelFontSize,
|
|
170
|
+
color: disabled ? colors.foregroundMuted : colors.foreground,
|
|
171
|
+
},
|
|
172
|
+
]}
|
|
173
|
+
>
|
|
174
|
+
{label}
|
|
175
|
+
</Text>
|
|
176
|
+
)}
|
|
177
|
+
{description && (
|
|
178
|
+
<Text
|
|
179
|
+
style={[
|
|
180
|
+
styles.description,
|
|
181
|
+
{ color: colors.foregroundMuted },
|
|
182
|
+
]}
|
|
183
|
+
>
|
|
184
|
+
{description}
|
|
185
|
+
</Text>
|
|
186
|
+
)}
|
|
187
|
+
</View>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
<Animated.View
|
|
191
|
+
style={[
|
|
192
|
+
styles.track,
|
|
193
|
+
{
|
|
194
|
+
width: tokens.trackWidth,
|
|
195
|
+
height: tokens.trackHeight,
|
|
196
|
+
borderRadius: tokens.borderRadius,
|
|
197
|
+
paddingHorizontal: tokens.thumbOffset,
|
|
198
|
+
},
|
|
199
|
+
trackAnimatedStyle,
|
|
200
|
+
disabled && styles.disabled,
|
|
201
|
+
]}
|
|
202
|
+
>
|
|
203
|
+
<Animated.View
|
|
204
|
+
style={[
|
|
205
|
+
styles.thumb,
|
|
206
|
+
{
|
|
207
|
+
width: tokens.thumbSize,
|
|
208
|
+
height: tokens.thumbSize,
|
|
209
|
+
borderRadius: tokens.borderRadius,
|
|
210
|
+
backgroundColor: colors.background,
|
|
211
|
+
},
|
|
212
|
+
platformShadow('sm'),
|
|
213
|
+
thumbAnimatedStyle,
|
|
214
|
+
]}
|
|
215
|
+
/>
|
|
216
|
+
</Animated.View>
|
|
217
|
+
</Pressable>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const styles = StyleSheet.create({
|
|
222
|
+
container: {
|
|
223
|
+
flexDirection: 'row',
|
|
224
|
+
alignItems: 'center',
|
|
225
|
+
justifyContent: 'space-between',
|
|
226
|
+
},
|
|
227
|
+
labelContainer: {
|
|
228
|
+
flex: 1,
|
|
229
|
+
gap: 2,
|
|
230
|
+
},
|
|
231
|
+
label: {
|
|
232
|
+
fontWeight: '500',
|
|
233
|
+
},
|
|
234
|
+
description: {
|
|
235
|
+
fontSize: 13,
|
|
236
|
+
},
|
|
237
|
+
track: {
|
|
238
|
+
justifyContent: 'center',
|
|
239
|
+
},
|
|
240
|
+
thumb: {
|
|
241
|
+
// Shadow applied via platformShadow
|
|
242
|
+
},
|
|
243
|
+
disabled: {
|
|
244
|
+
opacity: 0.5,
|
|
245
|
+
},
|
|
246
|
+
});
|