feat : Badge et Chip
This commit is contained in:
244
src/components/Badge.tsx
Normal file
244
src/components/Badge.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { Check, type LucideIcon } from 'lucide-react-native';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from 'react-native';
|
||||
|
||||
import { useTheme } from '@/theme/useTheme';
|
||||
|
||||
export type BadgeVariant = 'verified' | 'tonight' | 'free' | 'complete' | 'draft' | 'pro' | 'date';
|
||||
|
||||
export type BadgeDate = {
|
||||
day: string;
|
||||
month: string;
|
||||
};
|
||||
|
||||
type BadgeProps = {
|
||||
variant?: BadgeVariant;
|
||||
label?: string;
|
||||
icon?: LucideIcon;
|
||||
date?: BadgeDate;
|
||||
compact?: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
};
|
||||
|
||||
const DEFAULT_LABELS: Record<BadgeVariant, string> = {
|
||||
verified: 'Vérifié',
|
||||
tonight: 'Ce soir',
|
||||
free: 'Gratuit',
|
||||
complete: 'Complet',
|
||||
draft: 'Brouillon',
|
||||
pro: 'Pro',
|
||||
date: 'Date',
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
variant = 'verified',
|
||||
label,
|
||||
icon,
|
||||
date,
|
||||
compact = true,
|
||||
style,
|
||||
textStyle,
|
||||
}: BadgeProps) {
|
||||
const { colors, typography, borderRadius } = useTheme();
|
||||
const pulse = useRef(new Animated.Value(1)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (variant !== 'tonight') {
|
||||
return;
|
||||
}
|
||||
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulse, {
|
||||
toValue: 1.04,
|
||||
duration: 640,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulse, {
|
||||
toValue: 0.98,
|
||||
duration: 640,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
animation.start();
|
||||
|
||||
return () => animation.stop();
|
||||
}, [pulse, variant]);
|
||||
|
||||
const config = useMemo(() => {
|
||||
if (variant === 'verified') {
|
||||
return {
|
||||
backgroundColor: 'rgba(40, 224, 240, 0.12)',
|
||||
borderColor: 'rgba(40, 224, 240, 0.6)',
|
||||
textColor: '#A9F4FF',
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === 'tonight') {
|
||||
return {
|
||||
backgroundColor: 'rgba(255, 46, 136, 0.18)',
|
||||
borderColor: 'rgba(255, 46, 136, 0.7)',
|
||||
textColor: '#FFD0E3',
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === 'free') {
|
||||
return {
|
||||
backgroundColor: colors.successSoft,
|
||||
borderColor: colors.successBorder,
|
||||
textColor: '#A6F5CD',
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === 'complete') {
|
||||
return {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.06)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||
textColor: '#F4F5FA',
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === 'draft') {
|
||||
return {
|
||||
backgroundColor: colors.warningSoft,
|
||||
borderColor: 'rgba(255, 196, 120, 0.55)',
|
||||
textColor: '#FFE0AE',
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === 'pro') {
|
||||
return {
|
||||
backgroundColor: colors.secondarySoft,
|
||||
borderColor: 'rgba(123, 61, 255, 0.55)',
|
||||
textColor: '#DCCBFF',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.04)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||
textColor: colors.textPrimary,
|
||||
};
|
||||
}, [colors, variant]);
|
||||
|
||||
if (variant === 'date' || date) {
|
||||
const day = date?.day ?? '04';
|
||||
const month = date?.month ?? 'sept';
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.dateBadge,
|
||||
{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.02)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.dateDay,
|
||||
{
|
||||
color: '#F5F7FF',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{day}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.dateMonth,
|
||||
{
|
||||
color: '#DFE3F2',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{month}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = icon ?? (variant === 'verified' ? Check : undefined);
|
||||
const resolvedLabel = (label ?? DEFAULT_LABELS[variant] ?? '').toUpperCase();
|
||||
|
||||
const badgeStyle: ViewStyle = {
|
||||
backgroundColor: config.backgroundColor,
|
||||
borderColor: config.borderColor,
|
||||
borderWidth: 1,
|
||||
borderRadius: borderRadius.chip,
|
||||
paddingHorizontal: compact ? 12 : 16,
|
||||
paddingVertical: compact ? 8 : 10,
|
||||
minHeight: compact ? 30 : 34,
|
||||
alignSelf: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
...(variant === 'tonight'
|
||||
? { shadowColor: '#FF2E88', shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.45, shadowRadius: 12, elevation: 6 }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const textStyleResolved: TextStyle = {
|
||||
color: config.textColor,
|
||||
...typography.badge,
|
||||
letterSpacing: 1.45,
|
||||
opacity: 1,
|
||||
fontWeight: '600',
|
||||
};
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
badgeStyle,
|
||||
variant === 'tonight' ? { transform: [{ scale: pulse }] } : null,
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{Icon ? <Icon color={config.textColor} size={12} strokeWidth={2.5} /> : null}
|
||||
<Text style={[textStyleResolved, textStyle]}>{resolvedLabel}</Text>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
dateBadge: {
|
||||
width: 54,
|
||||
aspectRatio: 1,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
paddingVertical: 6,
|
||||
},
|
||||
dateDay: {
|
||||
fontFamily: 'SpaceGrotesk_700Bold',
|
||||
fontSize: 18,
|
||||
lineHeight: 20,
|
||||
letterSpacing: -0.24,
|
||||
textAlign: 'center',
|
||||
},
|
||||
dateMonth: {
|
||||
fontFamily: 'JetBrainsMono_500Medium',
|
||||
fontSize: 9,
|
||||
lineHeight: 10,
|
||||
letterSpacing: 1.2,
|
||||
textAlign: 'center',
|
||||
textTransform: 'uppercase',
|
||||
marginTop: 1,
|
||||
},
|
||||
});
|
||||
131
src/components/ThemeChip.tsx
Normal file
131
src/components/ThemeChip.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { X } from 'lucide-react-native';
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
type StyleProp,
|
||||
StyleSheet,
|
||||
Text,
|
||||
type TextStyle,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native';
|
||||
|
||||
import { useTheme } from '@/theme/useTheme';
|
||||
|
||||
export type ThemeChipSelectedVariant = 'gradient' | 'accent';
|
||||
|
||||
export type ThemeChipProps = {
|
||||
label: string;
|
||||
value?: string;
|
||||
selected?: boolean;
|
||||
selectedVariant?: ThemeChipSelectedVariant;
|
||||
disabled?: boolean;
|
||||
showCross?: boolean;
|
||||
showRemoveIcon?: boolean;
|
||||
onSelect?: (value: string, nextSelected: boolean) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
testID?: string;
|
||||
accessibilityLabel?: string;
|
||||
};
|
||||
|
||||
const CHIP_HEIGHT = 34;
|
||||
|
||||
export function ThemeChip({
|
||||
label,
|
||||
value,
|
||||
selected = false,
|
||||
selectedVariant = 'accent',
|
||||
disabled = false,
|
||||
showCross,
|
||||
showRemoveIcon = true,
|
||||
onSelect,
|
||||
style,
|
||||
textStyle,
|
||||
testID,
|
||||
accessibilityLabel,
|
||||
}: ThemeChipProps) {
|
||||
const { colors, gradients, gradientDirection, borderRadius, typography } = useTheme();
|
||||
|
||||
const resolvedValue = value ?? label;
|
||||
const isGradient = selected && selectedVariant === 'gradient';
|
||||
const showSelectedIcon = selected && (showCross ?? showRemoveIcon);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect?.(resolvedValue, !selected);
|
||||
}, [disabled, onSelect, resolvedValue, selected]);
|
||||
|
||||
const containerStyle: ViewStyle = {
|
||||
minHeight: CHIP_HEIGHT,
|
||||
borderRadius: borderRadius.chip,
|
||||
paddingHorizontal: 14,
|
||||
borderWidth: 1,
|
||||
alignSelf: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
opacity: disabled ? 0.45 : 1,
|
||||
};
|
||||
|
||||
const accentSelectedStyle: ViewStyle = {
|
||||
backgroundColor: colors.primarySoft,
|
||||
borderColor: colors.primary,
|
||||
};
|
||||
|
||||
const idleStyle: ViewStyle = {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: '#2A2A3C',
|
||||
};
|
||||
|
||||
const textColor = isGradient
|
||||
? colors.textPrimary
|
||||
: selected
|
||||
? colors.textOnPrimarySoft
|
||||
: colors.textPrimary;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
testID={testID}
|
||||
disabled={disabled}
|
||||
onPress={handlePress}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ disabled, selected }}
|
||||
accessibilityLabel={accessibilityLabel ?? label}
|
||||
style={[styles.root, style]}
|
||||
>
|
||||
{isGradient ? (
|
||||
<LinearGradient
|
||||
colors={gradients.signature}
|
||||
start={gradientDirection.start}
|
||||
end={gradientDirection.end}
|
||||
style={[containerStyle, { borderColor: 'transparent' }]}
|
||||
>
|
||||
<Text style={[styles.label, typography.chip, { color: textColor }, textStyle]}>{label}</Text>
|
||||
{showSelectedIcon ? <X color={textColor} size={12} strokeWidth={2.3} /> : null}
|
||||
</LinearGradient>
|
||||
) : (
|
||||
<View style={[containerStyle, selected ? accentSelectedStyle : idleStyle]}>
|
||||
<Text style={[styles.label, typography.chip, { color: textColor }, textStyle]}>{label}</Text>
|
||||
{showSelectedIcon ? <X color={textColor} size={12} strokeWidth={2.3} /> : null}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThemeChip;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
label: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user