feat : Composant Bouton

This commit is contained in:
Yaël Perret
2026-09-01 14:16:23 +02:00
parent 6a060418b4
commit 4c74a27f57
9 changed files with 927 additions and 68 deletions

261
src/components/Button.tsx Normal file
View File

@@ -0,0 +1,261 @@
import { LinearGradient } from 'expo-linear-gradient';
import type { LucideIcon } from 'lucide-react-native';
import { useCallback, useMemo, useState } from 'react';
import {
ActivityIndicator,
type GestureResponderEvent,
Pressable,
type StyleProp,
StyleSheet,
Text,
type TextStyle,
View,
type ViewStyle,
} from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import { useTheme } from '@/theme/useTheme';
const BUTTON_HEIGHT = 52;
const PRESSED_SCALE = 0.97;
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive';
type IconOnlyShape = 'round' | 'default';
type ButtonProps = {
label?: string;
variant?: ButtonVariant;
disabled?: boolean;
loading?: boolean;
loadingLabel?: string;
autoLoading?: boolean;
iconOnly?: boolean;
iconOnlyShape?: IconOnlyShape;
icon?: LucideIcon;
leftIcon?: LucideIcon;
rightIcon?: LucideIcon;
iconSize?: number;
iconStrokeWidth?: number;
fullWidth?: boolean;
onPress?: (event: GestureResponderEvent) => void | Promise<void>;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
accessibilityLabel?: string;
testID?: string;
};
export function Button({
label,
variant = 'primary',
disabled = false,
loading = false,
loadingLabel,
autoLoading = false,
iconOnly = false,
iconOnlyShape = 'round',
icon,
leftIcon,
rightIcon,
iconSize = 20,
iconStrokeWidth = 2,
fullWidth = true,
onPress,
style,
textStyle,
accessibilityLabel,
testID,
}: ButtonProps) {
const { colors, borderRadius, typography, gradients, gradientDirection } = useTheme();
const [internalLoading, setInternalLoading] = useState(false);
const [isPressed, setIsPressed] = useState(false);
const isLoading = loading || internalLoading;
const isDisabled = disabled || isLoading;
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const handlePressIn = useCallback(() => {
if (isDisabled) {
return;
}
setIsPressed(true);
scale.value = withTiming(PRESSED_SCALE, { duration: 120 });
}, [isDisabled, scale]);
const handlePressOut = useCallback(() => {
setIsPressed(false);
scale.value = withTiming(1, { duration: 140 });
}, [scale]);
const handlePress = useCallback(
async (event: GestureResponderEvent) => {
if (!onPress || isDisabled) {
return;
}
const result = onPress(event);
if (!autoLoading || !(result instanceof Promise)) {
return;
}
setInternalLoading(true);
try {
await result;
} finally {
setInternalLoading(false);
}
},
[autoLoading, isDisabled, onPress],
);
const labelColor = useMemo(() => {
if (isDisabled) {
return colors.textDisabled;
}
if (variant === 'primary') {
return colors.textPrimary;
}
if (variant === 'destructive') {
return colors.danger;
}
if (variant === 'ghost') {
return colors.tertiary;
}
return colors.textPrimary;
}, [colors, isDisabled, variant]);
const loadingColor = variant === 'primary' && !isDisabled ? colors.textPrimary : labelColor;
const iconOnlyRadius = iconOnlyShape === 'round' ? BUTTON_HEIGHT / 2 : borderRadius.button;
const sideIconColor = !isDisabled && variant === 'secondary' ? colors.tertiary : labelColor;
const iconOnlyColor = labelColor;
const IconOnly = icon;
const LeftIcon = leftIcon;
const RightIcon = rightIcon;
const containerStyle: ViewStyle = {
width: fullWidth && !iconOnly ? '100%' : BUTTON_HEIGHT,
height: BUTTON_HEIGHT,
borderRadius: iconOnly ? iconOnlyRadius : borderRadius.button,
overflow: 'hidden',
};
const baseFillStyle: ViewStyle = {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 8,
borderRadius: iconOnly ? iconOnlyRadius : borderRadius.button,
};
const variantFillStyle: ViewStyle = (() => {
if (isDisabled) {
return {
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
};
}
if (variant === 'secondary') {
return {
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
};
}
if (variant === 'ghost') {
return {
backgroundColor: 'transparent',
};
}
if (variant === 'destructive') {
return {
backgroundColor: 'rgba(255, 77, 109, 0.08)',
borderWidth: 1,
borderColor: colors.danger,
};
}
return {
backgroundColor: colors.surface,
};
})();
const contentPaddingStyle: ViewStyle = {
paddingHorizontal: iconOnly ? 0 : 24,
};
const textStyles: TextStyle = {
...typography.button,
color: labelColor,
};
const content = (
<>
{isLoading ? <ActivityIndicator color={loadingColor} size="small" /> : null}
{!isLoading && iconOnly && IconOnly ? (
<IconOnly color={iconOnlyColor} size={iconSize} strokeWidth={iconStrokeWidth} />
) : null}
{!iconOnly && !isLoading && LeftIcon ? (
<LeftIcon color={sideIconColor} size={iconSize} strokeWidth={iconStrokeWidth} />
) : null}
{!iconOnly && label ? <Text style={[textStyles, textStyle]}>{isLoading && loadingLabel ? loadingLabel : label}</Text> : null}
{!iconOnly && !isLoading && RightIcon ? (
<RightIcon color={sideIconColor} size={iconSize} strokeWidth={iconStrokeWidth} />
) : null}
</>
);
const fillNode =
variant === 'primary' && !isDisabled ? (
<LinearGradient
colors={isPressed ? gradients.signaturePressed : gradients.signature}
start={gradientDirection.start}
end={gradientDirection.end}
style={[baseFillStyle, contentPaddingStyle]}
>
{content}
</LinearGradient>
) : (
<View style={[baseFillStyle, contentPaddingStyle, variantFillStyle]}>{content}</View>
);
return (
<AnimatedPressable
testID={testID}
accessibilityRole="button"
accessibilityState={{ disabled: isDisabled, busy: isLoading }}
accessibilityLabel={accessibilityLabel ?? label}
disabled={isDisabled}
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={[styles.root, containerStyle, animatedStyle, style]}
>
{fillNode}
</AnimatedPressable>
);
}
export default Button;
const styles = StyleSheet.create({
root: {
alignSelf: 'stretch',
},
});