feat : Switch
This commit is contained in:
@@ -20,6 +20,12 @@ export default function HomeTabScreen() {
|
||||
onPress={() => router.push('/index-old')}
|
||||
accessibilityLabel="Ouvrir la page d'exemple des champs"
|
||||
/>
|
||||
<Button
|
||||
label="Voir la démo du switch"
|
||||
variant="secondary"
|
||||
onPress={() => router.push('/switch-demo')}
|
||||
accessibilityLabel="Ouvrir la page de démonstration du switch"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -43,5 +49,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
actions: {
|
||||
marginTop: spacing[16],
|
||||
gap: spacing[8],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -47,6 +47,7 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="(auth)" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="(org)" />
|
||||
<Stack.Screen name="switch-demo" />
|
||||
<Stack.Screen name="event/[id]" />
|
||||
<Stack.Screen name="create/event" options={{ presentation: 'modal' }} />
|
||||
<Stack.Screen name="create/post" options={{ presentation: 'modal' }} />
|
||||
|
||||
118
src/app/switch-demo.tsx
Normal file
118
src/app/switch-demo.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { FormField, Switch } from '@/components/form';
|
||||
import { Screen } from '@/components/ui/Screen';
|
||||
import { colors } from '@/theme/colors';
|
||||
import { spacing } from '@/theme/spacing';
|
||||
import { typography } from '@/theme/typography';
|
||||
|
||||
export default function SwitchDemoScreen() {
|
||||
const [pushEnabled, setPushEnabled] = useState(true);
|
||||
const [weeklyDigestEnabled, setWeeklyDigestEnabled] = useState(false);
|
||||
|
||||
return (
|
||||
<Screen contentContainerStyle={styles.content}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.kicker}>DESIGN SYSTEM · SWITCH</Text>
|
||||
<Text style={styles.title}>Demos du composant Switch</Text>
|
||||
</View>
|
||||
|
||||
<FormField
|
||||
label="Notifications push"
|
||||
helper="Switch custom basé sur Expo/React Native"
|
||||
>
|
||||
<View style={styles.switchRow}>
|
||||
<View style={styles.switchLabelWrap}>
|
||||
<Text style={styles.switchLabel}>Activer les alertes de nouveaux événements</Text>
|
||||
<Text style={styles.switchHint}>
|
||||
{pushEnabled ? 'Activé' : 'Désactivé'}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
accessibilityLabel="Activer les notifications push"
|
||||
value={pushEnabled}
|
||||
onValueChange={setPushEnabled}
|
||||
/>
|
||||
</View>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Digest hebdo"
|
||||
helper="Exemple de taille large"
|
||||
>
|
||||
<View style={styles.switchRow}>
|
||||
<View style={styles.switchLabelWrap}>
|
||||
<Text style={styles.switchLabel}>Recevoir un recap chaque lundi</Text>
|
||||
<Text style={styles.switchHint}>
|
||||
{weeklyDigestEnabled ? 'Activé' : 'Désactivé'}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
accessibilityLabel="Activer le digest hebdomadaire"
|
||||
value={weeklyDigestEnabled}
|
||||
onValueChange={setWeeklyDigestEnabled}
|
||||
size="lg"
|
||||
/>
|
||||
</View>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Etat desactive" helper="Le switch est non interactif">
|
||||
<View style={styles.switchRow}>
|
||||
<View style={styles.switchLabelWrap}>
|
||||
<Text style={styles.switchLabel}>Mode maintenance</Text>
|
||||
<Text style={styles.switchHint}>Indisponible</Text>
|
||||
</View>
|
||||
<Switch
|
||||
accessibilityLabel="Switch desactive"
|
||||
value
|
||||
disabled
|
||||
/>
|
||||
</View>
|
||||
</FormField>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: {
|
||||
gap: spacing[16],
|
||||
paddingTop: spacing[24],
|
||||
},
|
||||
header: {
|
||||
gap: spacing[8],
|
||||
marginBottom: spacing[8],
|
||||
},
|
||||
kicker: {
|
||||
...typography.sectionLabel,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
title: {
|
||||
...typography.body,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
switchRow: {
|
||||
minHeight: 56,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.inputBorder,
|
||||
borderRadius: 14,
|
||||
backgroundColor: colors.inputBackground,
|
||||
paddingHorizontal: 14,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing[12],
|
||||
},
|
||||
switchLabelWrap: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
switchLabel: {
|
||||
...typography.body,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
switchHint: {
|
||||
...typography.caption,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
});
|
||||
151
src/components/form/Switch.tsx
Normal file
151
src/components/form/Switch.tsx
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user