Skip to content

Commit

Permalink
added a customizable ui/button component with tailwind typesafety in …
Browse files Browse the repository at this point in the history
…variatn definitions
  • Loading branch information
vucinatim committed Apr 13, 2024
1 parent d07e4fc commit e90f655
Show file tree
Hide file tree
Showing 5 changed files with 293 additions and 11 deletions.
7 changes: 7 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"tailwindCSS.experimental.classRegex": [
["variants \\=([^;]*);", "'([^']*)'"],
["variants \\=([^;]*);", "\"([^\"]*)\""],
["variants \\=([^;]*);", "\\`([^\\`]*)\\`"]
]
}
30 changes: 20 additions & 10 deletions app/(guest)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import Button from '@components/ui/button';
import theme from '@utils/theme';
import { router } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { Button, Text, View } from 'react-native';
import { Text, View } from 'react-native';

const GuestHomeScreen = () => {
const { t, i18n } = useTranslation('common');
Expand All @@ -11,7 +12,7 @@ const GuestHomeScreen = () => {
};

return (
<View className="flex flex-1 items-center justify-center bg-red-300">
<View className="flex flex-1 items-center justify-center gap-y-4 bg-gray-100">
<Text
style={{
color: theme.primary[400],
Expand All @@ -20,15 +21,24 @@ const GuestHomeScreen = () => {
</Text>
<Text>{t('helloWorld')}</Text>
<Button
title="Login"
onPress={() => router.navigate('(authenticated)')}
/>
variant="default"
onPress={() => router.navigate('(authenticated)')}>
Login
</Button>

<Button variant="filled" onPress={() => handleLanguageChange('en')}>
Change Language 🇬🇧
</Button>

<Button variant="filled" onPress={() => handleLanguageChange('sl')}>
Change Language 🇸🇮
</Button>

<Button
title="Change Language"
onPress={() =>
handleLanguageChange(i18n.language === 'en' ? 'sl' : 'en')
}
/>
variant="danger"
onPress={() => alert('You just performed a dangerous action!')}>
Dangerous Button
</Button>
</View>
);
};
Expand Down
168 changes: 168 additions & 0 deletions components/ui/button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { cn } from '@utils/cn';
import theme from '@utils/theme';
import clsx from 'clsx';
import type { LucideIcon } from 'lucide-react-native';
import React from 'react';
import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSequence,
withTiming,
} from 'react-native-reanimated';

interface VariantProps {
container: string;
text: string;
icon?: string;
}

// You should edit these variants to match your design spirit
// Adding using cn('') provides auto-sorting of tailwind classes on save
// Tailwind typesafety is also supported in variants objects (with .vscode/settings.json)
const variants = {
default: {
container: cn('rounded-2xl border border-gray-400 bg-slate-300 px-4 py-4'),
text: cn('font-bold text-gray-800'),
icon: cn('text-gray-800'),
},
filled: {
container: cn('rounded-2xl bg-sky-500 px-4 py-4'),
text: cn('font-bold text-white'),
icon: cn('text-white'),
},
danger: {
container: cn('rounded-2xl bg-red-400 px-4 py-4'),
text: cn('font-bold text-white'),
icon: cn('text-white'),
},
} satisfies Record<string, VariantProps>;

const getVariant = (variant: keyof typeof variants) => variants[variant];

export interface ButtonProps {
children?: React.ReactNode;
className?: string;
onPress?: () => void;
variant?: keyof typeof variants;
loading?: boolean;
disabled?: boolean;
fitted?: boolean;
iconLeft?: LucideIcon;
iconRight?: LucideIcon;
feedbackText?: string;

// Don't wrap with text component
asChild?: boolean;
}

const Button = ({
children,
className,
onPress,
variant = 'default',
loading = false,
disabled = false,
fitted = false,
iconLeft,
iconRight,
feedbackText,
asChild,
...rest
}: ButtonProps) => {
const { container, text, icon } = getVariant(variant);

// Shared value for animation
const feedbackOpacity = useSharedValue(0);
const feedbackTranslateY = useSharedValue(30);

const handlePress = () => {
feedbackOpacity.value = withSequence(
withTiming(1, { duration: 300 }),
withTiming(0, { duration: 1000 }, () => {
feedbackTranslateY.value = 30;
}),
);
feedbackTranslateY.value = withTiming(0, { duration: 300 });

onPress?.();
};

const feedbackStyle = useAnimatedStyle(() => ({
opacity: feedbackOpacity.value,
transform: [{ translateY: feedbackTranslateY.value }],
}));

const childStyle = useAnimatedStyle(() => ({
opacity: 1 - feedbackOpacity.value,
transform: [{ translateY: feedbackTranslateY.value - 30 }],
}));

return (
<TouchableOpacity
onPress={handlePress}
disabled={loading || disabled}
activeOpacity={0.5}
className={clsx(
container,
'flex justify-center overflow-hidden',
fitted && 'self-baseline',
disabled && 'opacity-50',
className,
)}
{...rest}>
{/* You could add a gradient background to the button by uncommenting the code below */}
{/* {variant === 'filled' && (
<LinearGradient
className="absolute bottom-0 left-0 right-0 top-0 opacity-70"
start={[0, 0]}
end={[1, 0]}
colors={[theme.primary[600], theme.primary[500]]}
/>
)} */}

{loading && (
<View className="absolute bottom-0 left-0 right-0 top-0 z-10 flex items-center justify-center">
<ActivityIndicator
size="small"
color={variant === 'danger' ? theme.red[500] : theme.primary[500]}
/>
</View>
)}

<View
className={clsx(
'flex-row items-center justify-center',
fitted && 'self-baseline',
className,
)}>
{iconLeft &&
React.createElement(iconLeft, {
size: 18,
className: clsx(icon, 'mr-2'),
})}
<Animated.View style={feedbackText ? childStyle : undefined}>
{asChild ? (
children
) : (
<Text className={clsx(text, 'text-center')}>{children}</Text>
)}
</Animated.View>
{feedbackText && (
<Animated.View
className="absolute bottom-0 left-0 right-0 top-0 flex items-center justify-center"
style={[feedbackStyle, { position: 'absolute', bottom: 0 }]}>
<Text className={clsx(text, 'text-center')}>{feedbackText}</Text>
</Animated.View>
)}
{iconRight &&
React.createElement(iconRight, {
size: 18,
className: clsx(icon, 'ml-2'),
})}
</View>
</TouchableOpacity>
);
};

export default Button;
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@
"expo": "~50.0.14",
"expo-constants": "~15.4.5",
"expo-font": "~11.10.3",
"expo-linear-gradient": "~12.7.2",
"expo-linking": "~6.2.2",
"expo-localization": "~14.8.3",
"expo-network": "~5.8.0",
"expo-router": "~3.4.8",
"expo-status-bar": "~1.11.1",
"i18next": "^23.10.1",
"intl-pluralrules": "^2.0.1",
"lucide-react-native": "^0.368.0",
"nativewind": "^4.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
Expand All @@ -36,6 +38,7 @@
"react-native-reanimated": "~3.6.2",
"react-native-safe-area-context": "4.8.2",
"react-native-screens": "~3.29.0",
"react-native-svg": "^15.1.0",
"tailwind-merge": "^2.2.2",
"tailwindcss": "^3.4.3",
"zod": "^3.22.4",
Expand Down
Loading

0 comments on commit e90f655

Please sign in to comment.