feat : Badge et Chip

This commit is contained in:
Yaël Perret
2026-09-01 14:43:51 +02:00
parent 4c74a27f57
commit 5a865d5635
3 changed files with 457 additions and 1 deletions

View File

@@ -1,14 +1,22 @@
import { ArrowUp, ArrowUpRight, Plus } from 'lucide-react-native'; import { ArrowUp, ArrowUpRight, Plus } from 'lucide-react-native';
import { useCallback } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native'; import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { Badge } from '@/components/Badge';
import { Button } from '@/components/Button'; import { Button } from '@/components/Button';
import { ThemeChip } from '@/components/ThemeChip';
import { useTheme } from '@/theme/useTheme'; import { useTheme } from '@/theme/useTheme';
import '../global.css'; import '../global.css';
export default function Index() { export default function Index() {
const { colors, spacing, typography } = useTheme(); const { colors, spacing, typography } = useTheme();
const [selectedThemes, setSelectedThemes] = useState<string[]>(['Soirée', 'Techno', 'Festival']);
const themeOptions = useMemo(
() => ['Soirée', 'Techno', 'Festival', 'Fête de jeunesse', 'Concert', 'Bal', 'Afterwork', 'Gratuit'],
[],
);
const simulateSubmit = useCallback(async () => { const simulateSubmit = useCallback(async () => {
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
@@ -16,6 +24,20 @@ export default function Index() {
}); });
}, []); }, []);
const handleThemeSelect = useCallback((value: string, nextSelected: boolean) => {
setSelectedThemes((prev) => {
if (nextSelected) {
if (prev.includes(value)) {
return prev;
}
return [...prev, value];
}
return prev.filter((theme) => theme !== value);
});
}, []);
return ( return (
<View style={[styles.screen, { backgroundColor: colors.deepBackground }]}> <View style={[styles.screen, { backgroundColor: colors.deepBackground }]}>
<ScrollView <ScrollView
@@ -102,6 +124,53 @@ export default function Index() {
<Text style={[styles.caption, typography.digits, { color: colors.textSecondary }]}>Icône / FAB</Text> <Text style={[styles.caption, typography.digits, { color: colors.textSecondary }]}>Icône / FAB</Text>
</View> </View>
</View> </View>
<View style={{ gap: spacing[12] }}>
<Text style={[typography.sectionLabel, { color: colors.textSecondary, letterSpacing: 1.1 }]}>BADGES</Text>
<View style={styles.badgeWrap}>
<Badge variant="verified" />
<Badge variant="free" />
<Badge variant="complete" />
<Badge variant="tonight" />
<Badge variant="pro" />
<Badge variant="draft" />
<Badge date={{ day: '04', month: 'sept' }} />
</View>
</View>
<View style={{ gap: spacing[12] }}>
<Text style={[typography.sectionLabel, { color: colors.textSecondary, letterSpacing: 1.1 }]}>CHIPS DE THÈME · CAPSULE 34 PX</Text>
<View style={styles.chipWrap}>
<ThemeChip label="Soirée" selected selectedVariant="gradient" showCross={false} />
<ThemeChip label="Festival" selected selectedVariant="accent" />
<ThemeChip label="Fête de jeunesse" />
<ThemeChip label="Concert" />
<ThemeChip label="Bal" />
<ThemeChip label="Techno" />
<ThemeChip label="Afterwork" />
<ThemeChip label="Gratuit" selected selectedVariant="accent" showRemoveIcon={false} />
</View>
</View>
<View style={{ gap: spacing[12] }}>
<Text style={[typography.sectionLabel, { color: colors.textSecondary, letterSpacing: 1.1 }]}>THÈMES · MULTI-SÉLECTION</Text>
<View style={styles.chipWrap}>
{themeOptions.map((theme) => {
const selected = selectedThemes.includes(theme);
return (
<ThemeChip
key={theme}
label={theme}
value={theme}
selected={selected}
selectedVariant={theme === 'Soirée' ? 'gradient' : 'accent'}
onSelect={handleThemeSelect}
/>
);
})}
</View>
</View>
</ScrollView> </ScrollView>
</View> </View>
); );
@@ -134,4 +203,16 @@ const styles = StyleSheet.create({
alignSelf: 'center', alignSelf: 'center',
width: 140, width: 140,
}, },
badgeWrap: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
alignItems: 'center',
},
chipWrap: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
alignItems: 'center',
},
}); });

244
src/components/Badge.tsx Normal file
View 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,
},
});

View 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',
},
});