264 lines
6.2 KiB
TypeScript
264 lines
6.2 KiB
TypeScript
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 { colors, gradientDirection, gradients } from '@/theme/colors';
|
|
import { borderRadius } from '@/theme/spacing';
|
|
import { typography } from '@/theme/typography';
|
|
|
|
|
|
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 [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',
|
|
},
|
|
});
|