managarten/manadeck/apps/mobile/components/ui/Button.tsx
Till-JS e7f5f942f3 chore: initial commit - consolidate 4 projects into monorepo
Projects included:
- maerchenzauber (NestJS backend + Expo mobile + SvelteKit web + Astro landing)
- manacore (Expo mobile + SvelteKit web + Astro landing)
- manadeck (NestJS backend + Expo mobile + SvelteKit web)
- memoro (Expo mobile + SvelteKit web + Astro landing)

This commit preserves the current state before monorepo restructuring.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 23:38:24 +01:00

223 lines
5.5 KiB
TypeScript

import React from 'react';
import { Pressable, ActivityIndicator, View, PressableProps, Platform, Animated, ViewStyle, TextStyle } from 'react-native';
import { Text } from './Text';
import { cva, type VariantProps } from 'class-variance-authority';
import { useThemeColors } from '~/utils/themeUtils';
const buttonVariants = cva('flex-row items-center justify-center rounded-lg transition-all', {
variants: {
variant: {
primary: '',
secondary: '',
outline: 'border-2',
ghost: '',
danger: '',
},
size: {
sm: 'px-3 py-4',
md: 'px-4 py-5',
lg: 'px-6 py-6',
xl: 'px-8 py-7',
},
fullWidth: {
true: 'w-full',
false: '',
},
disabled: {
true: 'opacity-50',
false: '',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
fullWidth: false,
disabled: false,
},
});
const textVariants = cva('font-semibold text-center', {
variants: {
variant: {
primary: '',
secondary: '',
outline: '',
ghost: '',
danger: '',
},
size: {
sm: 'text-base',
md: 'text-lg',
lg: 'text-xl',
xl: 'text-2xl',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
});
export interface ButtonProps
extends VariantProps<typeof buttonVariants>,
Omit<PressableProps, 'style' | 'children'> {
children: React.ReactNode;
loading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
className?: string;
}
export const Button = React.forwardRef<View, ButtonProps>(
(
{
className,
variant,
size,
fullWidth,
disabled,
loading,
leftIcon,
rightIcon,
children,
onPress,
...props
},
ref
) => {
const isDisabled = disabled || loading;
const scaleAnim = React.useRef(new Animated.Value(1)).current;
const colors = useThemeColors();
const handlePressIn = () => {
if (Platform.OS === 'ios') {
Animated.spring(scaleAnim, {
toValue: 0.95,
useNativeDriver: true,
speed: 50,
bounciness: 4,
}).start();
}
};
const handlePressOut = () => {
if (Platform.OS === 'ios') {
Animated.spring(scaleAnim, {
toValue: 1,
useNativeDriver: true,
speed: 50,
bounciness: 4,
}).start();
}
};
// Get variant-specific colors
const getVariantColors = (): { backgroundColor: string; borderColor: string; textColor: string } => {
switch (variant) {
case 'primary':
return {
backgroundColor: colors.primary,
borderColor: colors.primary,
textColor: colors.primaryForeground,
};
case 'secondary':
return {
backgroundColor: colors.secondary,
borderColor: colors.secondary,
textColor: colors.secondaryForeground,
};
case 'outline':
return {
backgroundColor: 'transparent',
borderColor: colors.border,
textColor: colors.foreground,
};
case 'ghost':
return {
backgroundColor: 'transparent',
borderColor: 'transparent',
textColor: colors.foreground,
};
case 'danger':
return {
backgroundColor: colors.destructive,
borderColor: colors.destructive,
textColor: colors.destructiveForeground,
};
default:
return {
backgroundColor: colors.primary,
borderColor: colors.primary,
textColor: colors.primaryForeground,
};
}
};
const variantColors = getVariantColors();
// Android ripple color based on variant
const getRippleColor = () => {
switch (variant) {
case 'primary':
case 'danger':
return 'rgba(255, 255, 255, 0.3)';
case 'secondary':
case 'outline':
case 'ghost':
default:
return 'rgba(0, 0, 0, 0.1)';
}
};
return (
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
<Pressable
ref={ref}
className={buttonVariants({
variant,
size,
fullWidth,
disabled: isDisabled,
className,
})}
disabled={isDisabled}
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
android_ripple={{
color: getRippleColor(),
borderless: false,
}}
style={({ pressed }) => ({
backgroundColor: variantColors.backgroundColor,
borderColor: variantColors.borderColor,
borderWidth: variant === 'outline' ? 2 : 1,
...(Platform.OS === 'ios' ? { opacity: pressed ? 0.85 : 1 } : {}),
})}
{...props}>
{loading ? (
<ActivityIndicator
size="small"
color={variantColors.textColor}
/>
) : (
<>
{leftIcon && <View className="mr-2">{leftIcon}</View>}
<Text
className={textVariants({
variant,
size,
})}
style={{ color: variantColors.textColor }}>
{children}
</Text>
{rightIcon && <View className="ml-2">{rightIcon}</View>}
</>
)}
</Pressable>
</Animated.View>
);
}
);
Button.displayName = 'Button';