feat : Badge et Chip
This commit is contained in:
@@ -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
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