feat : Composant Bouton
This commit is contained in:
261
src/components/Button.tsx
Normal file
261
src/components/Button.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user