feat : Switch

This commit is contained in:
Yaël Perret
2026-09-10 11:40:54 +02:00
parent 845a9509b7
commit cf59438c25
5 changed files with 279 additions and 0 deletions

View File

@@ -0,0 +1,151 @@
import { LinearGradient } from 'expo-linear-gradient';
import { useEffect, useRef } from 'react';
import {
Animated,
Easing,
Pressable,
StyleSheet,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { colors } from '@/theme/colors';
export type SwitchSize = 'md' | 'lg';
export type SwitchProps = {
value: boolean;
onValueChange?: (value: boolean) => void;
disabled?: boolean;
testID?: string;
accessibilityLabel?: string;
size?: SwitchSize;
containerStyle?: StyleProp<ViewStyle>;
style?: StyleProp<ViewStyle>;
};
const SIZE_BY_VARIANT: Record<
SwitchSize,
{ width: number; height: number; thumb: number; padding: number }
> = {
md: { width: 56, height: 34, thumb: 26, padding: 4 },
lg: { width: 64, height: 38, thumb: 30, padding: 4 },
};
function Switch({
size = 'md',
value,
disabled,
onValueChange,
testID,
accessibilityLabel,
containerStyle,
style,
}: SwitchProps) {
const metrics = SIZE_BY_VARIANT[size];
const travel = metrics.width - metrics.padding * 2 - metrics.thumb;
const progress = useRef(new Animated.Value(value ? 1 : 0)).current;
useEffect(() => {
Animated.timing(progress, {
toValue: value ? 1 : 0,
duration: 180,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
}, [progress, value]);
const thumbTranslateX = progress.interpolate({
inputRange: [0, 1],
outputRange: [0, travel],
});
return (
<Pressable
testID={testID}
accessibilityRole="switch"
accessibilityState={{ checked: value, disabled }}
accessibilityLabel={accessibilityLabel}
disabled={disabled}
onPress={() => onValueChange?.(!value)}
style={[
styles.container,
{
width: metrics.width,
height: metrics.height,
borderRadius: metrics.height / 2,
opacity: disabled ? 0.5 : 1,
},
containerStyle,
]}
>
{value ? (
<LinearGradient
colors={[colors.secondary, colors.primary]}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
style={[
styles.trackFill,
{ borderRadius: metrics.height / 2, borderColor: 'rgba(255,255,255,0.18)' },
]}
pointerEvents="none"
/>
) : (
<View
style={[
styles.trackFill,
styles.trackOff,
{ borderRadius: metrics.height / 2 },
]}
pointerEvents="none"
/>
)}
<Animated.View
style={[
styles.thumb,
{
width: metrics.thumb,
height: metrics.thumb,
borderRadius: metrics.thumb / 2,
top: metrics.padding,
left: metrics.padding,
transform: [{ translateX: thumbTranslateX }],
backgroundColor: value ? '#FFFFFF' : colors.textStrong,
},
style,
]}
/>
</Pressable>
);
}
const styles = StyleSheet.create({
container: {
position: 'relative',
overflow: 'hidden',
},
trackFill: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
borderWidth: 1,
},
trackOff: {
backgroundColor: colors.surfaceElevated,
borderColor: colors.inputBorder,
},
thumb: {
position: 'absolute',
shadowColor: '#000000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 3,
},
});
export default Switch;

View File

@@ -2,11 +2,13 @@ export { default as FormField } from './FormField';
export { default as Input } from './Input';
export { default as PasswordInput } from './PasswordInput';
export { default as SearchInput } from './SearchInput';
export { default as Switch } from './Switch';
export { default as TextArea } from './TextArea';
export type { FieldCounter, FormFieldProps } from './FormField';
export type { InputProps, InputSize, InputState } from './Input';
export type { PasswordInputProps, PasswordStrength } from './PasswordInput';
export type { SearchInputProps } from './SearchInput';
export type { SwitchProps, SwitchSize } from './Switch';
export type { TextAreaProps } from './TextArea';