feat : Composant Bouton

This commit is contained in:
Yaël Perret
2026-09-01 14:16:23 +02:00
parent 6a060418b4
commit 4c74a27f57
9 changed files with 927 additions and 68 deletions

178
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"@expo-google-fonts/inter": "^0.4.2",
"@expo-google-fonts/jetbrains-mono": "^0.4.1",
"@expo-google-fonts/space-grotesk": "^0.4.1",
"@expo/ui": "~56.0.14",
"@react-navigation/bottom-tabs": "^7.18.18",
@@ -33,15 +34,17 @@
"expo-system-ui": "~56.0.5",
"expo-web-browser": "~56.0.5",
"lucide-react": "^1.38.0",
"lucide-react-native": "^1.38.0",
"nativewind": "^4.2.6",
"prettier-plugin-tailwindcss": "^0.5.14",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.85.3",
"react-native-gesture-handler": "~2.31.1",
"react-native-reanimated": "^4.3.1",
"react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.8.3",
"tailwindcss": "^3.4.19"
@@ -1288,6 +1291,12 @@
"integrity": "sha512-syfiImMaDmq7cFi0of+waE2M4uSCyd16zgyWxdPOY7fN2VBmSLKEzkfbZgeOjJq61kSqPBNNtXjggiQiSD6gMQ==",
"license": "MIT AND OFL-1.1"
},
"node_modules/@expo-google-fonts/jetbrains-mono": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@expo-google-fonts/jetbrains-mono/-/jetbrains-mono-0.4.1.tgz",
"integrity": "sha512-CslACrtMOcRwoWXCO7OMEI+9w3fukWSoBtvNz46OqPoogEuuoY0tkDY1O8sFumk8t0pC6Cx0Xr95O0TOQhpkug==",
"license": "MIT AND OFL-1.1"
},
"node_modules/@expo-google-fonts/material-symbols": {
"version": "0.4.38",
"resolved": "https://registry.npmjs.org/@expo-google-fonts/material-symbols/-/material-symbols-0.4.38.tgz",
@@ -3656,6 +3665,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/bplist-creator": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
@@ -4139,6 +4154,56 @@
"hyphenate-style-name": "^1.0.3"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.14",
"source-map": "^0.6.1"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/css-tree/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
@@ -4285,6 +4350,61 @@
"license": "MIT",
"peer": true
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -4312,6 +4432,18 @@
"node": ">= 0.8"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error-stack-parser": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
@@ -6655,6 +6787,17 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lucide-react-native": {
"version": "1.38.0",
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-1.38.0.tgz",
"integrity": "sha512-wOgCXC8VZxIkrDK1160JA+7cBQ9njTFnFLQHLvrxLI5Cdhgz/dNN0j9+MS9aUJnYdBi0y3mNj73ZLnuzV6pYPg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-native": "*",
"react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -6680,6 +6823,12 @@
"integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
"license": "Apache-2.0"
},
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -7315,6 +7464,18 @@
"node": ">=10"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/nullthrows": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
@@ -8585,6 +8746,21 @@
"react-native": ">=0.82.0"
}
},
"node_modules/react-native-svg": {
"version": "15.15.4",
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.4.tgz",
"integrity": "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==",
"license": "MIT",
"dependencies": {
"css-select": "^5.1.0",
"css-tree": "^1.1.3",
"warn-once": "0.1.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-web": {
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",

View File

@@ -4,6 +4,7 @@
"version": "1.0.0",
"dependencies": {
"@expo-google-fonts/inter": "^0.4.2",
"@expo-google-fonts/jetbrains-mono": "^0.4.1",
"@expo-google-fonts/space-grotesk": "^0.4.1",
"@expo/ui": "~56.0.14",
"@react-navigation/bottom-tabs": "^7.18.18",
@@ -28,15 +29,17 @@
"expo-system-ui": "~56.0.5",
"expo-web-browser": "~56.0.5",
"lucide-react": "^1.38.0",
"lucide-react-native": "^1.38.0",
"nativewind": "^4.2.6",
"prettier-plugin-tailwindcss": "^0.5.14",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.85.3",
"react-native-gesture-handler": "~2.31.1",
"react-native-reanimated": "^4.3.1",
"react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.8.3",
"tailwindcss": "^3.4.19"

View File

@@ -1,8 +1,15 @@
import { Stack } from 'expo-router';
import { ThemeProvider } from '../theme';
import { useAppFonts } from '../theme/typography';
export default function RootLayout() {
const [fontsLoaded] = useAppFonts();
if (!fontsLoaded) {
return null;
}
return (
<ThemeProvider>
<Stack />

View File

@@ -1,48 +1,137 @@
import { StyleSheet, Text, View } from 'react-native';
import { ArrowUp, ArrowUpRight, Plus } from 'lucide-react-native';
import { useCallback } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { Button } from '@/components/Button';
import { useTheme } from '@/theme/useTheme';
import '../global.css';
import { useTheme } from '../theme';
export default function Index() {
const { colors, spacing, typography } = useTheme();
const simulateSubmit = useCallback(async () => {
await new Promise<void>((resolve) => {
setTimeout(resolve, 1300);
});
}, []);
return (
<View style={styles.container}>
<View style={[styles.screen, { backgroundColor: colors.deepBackground }]}>
<ScrollView
contentContainerStyle={[
styles.content,
{
padding: spacing[24],
gap: spacing[24],
},
]}
showsVerticalScrollIndicator={false}
>
<View style={{ gap: spacing[8] }}>
<Text
style={[
styles.title,
typography.sectionLabel,
{
color: colors.textSecondary,
fontFamily: typography.sectionLabel.fontFamily,
fontSize: typography.sectionLabel.fontSize,
lineHeight: typography.sectionLabel.lineHeight,
letterSpacing: typography.sectionLabel.letterSpacing,
letterSpacing: 1.1,
},
]}>
GradientButton variants
]}
>
BOUTONS · HAUTEUR 52 PX · RAYON 14 PX
</Text>
</View>
<View style={styles.row}>
<View style={styles.cell}>
<Button label="Publier" variant="primary" />
<Text style={[styles.caption, typography.digits, { color: colors.textSecondary }]}>Primaire</Text>
</View>
<View style={styles.cell}>
<Button label="Publier" variant="primary" />
<Text style={[styles.caption, typography.digits, { color: colors.textSecondary }]}>Pressé (tap)</Text>
</View>
</View>
<View style={styles.row}>
<View style={styles.cell}>
<Button label="Intéressé" variant="secondary" rightIcon={ArrowUpRight} />
<Text style={[styles.caption, typography.digits, { color: colors.textSecondary }]}>Secondaire</Text>
</View>
<View style={styles.cell}>
<Button label="Publier" variant="primary" disabled />
<Text style={[styles.caption, typography.digits, { color: colors.textSecondary }]}>Désactivé</Text>
</View>
</View>
<View style={styles.row}>
<View style={styles.cell}>
<Button label="Tout voir" variant="ghost" fullWidth={false} style={styles.autoWidthButton} />
<Text style={[styles.caption, typography.digits, { color: colors.textSecondary }]}>Texte</Text>
</View>
<View style={styles.cell}>
<Button label="Envoyer" variant="primary" autoLoading loadingLabel="Envoi..." onPress={simulateSubmit} />
<Text style={[styles.caption, typography.digits, { color: colors.textSecondary }]}>Chargement</Text>
</View>
</View>
<View style={styles.row}>
<View style={styles.cell}>
<Button label="Supprimer" variant="destructive" />
<Text style={[styles.caption, typography.digits, { color: colors.textSecondary }]}>Destructif</Text>
</View>
<View style={styles.cell}>
<View style={styles.iconRow}>
<Button
iconOnly
variant="secondary"
fullWidth={false}
icon={ArrowUp}
iconOnlyShape="default"
accessibilityLabel="Partager"
/>
<Button
iconOnly
variant="primary"
fullWidth={false}
icon={Plus}
iconOnlyShape="round"
accessibilityLabel="Ajouter"
/>
</View>
<Text style={[styles.caption, typography.digits, { color: colors.textSecondary }]}>Icône / FAB</Text>
</View>
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
safeArea: {
screen: {
flex: 1,
},
container: {
content: {
paddingBottom: 40,
},
row: {
flexDirection: 'row',
gap: 14,
},
cell: {
flex: 1,
paddingHorizontal: 20,
paddingTop: 24,
gap: 10,
},
caption: {
textAlign: 'center',
},
iconRow: {
flexDirection: 'row',
justifyContent: 'center',
gap: 12,
},
title: {
textTransform: 'uppercase',
marginBottom: 20,
},
stack: {
width: '100%',
},
disabledWrap: {
marginTop: 24,
autoWidthButton: {
alignSelf: 'center',
width: 140,
},
});

261
src/components/Button.tsx Normal file
View File

@@ -0,0 +1,261 @@
import { LinearGradient } from 'expo-linear-gradient';
import type { LucideIcon } from 'lucide-react-native';
import { useCallback, useMemo, useState } from 'react';
import {
ActivityIndicator,
type GestureResponderEvent,
Pressable,
type StyleProp,
StyleSheet,
Text,
type TextStyle,
View,
type ViewStyle,
} from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import { useTheme } from '@/theme/useTheme';
const BUTTON_HEIGHT = 52;
const PRESSED_SCALE = 0.97;
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive';
type IconOnlyShape = 'round' | 'default';
type ButtonProps = {
label?: string;
variant?: ButtonVariant;
disabled?: boolean;
loading?: boolean;
loadingLabel?: string;
autoLoading?: boolean;
iconOnly?: boolean;
iconOnlyShape?: IconOnlyShape;
icon?: LucideIcon;
leftIcon?: LucideIcon;
rightIcon?: LucideIcon;
iconSize?: number;
iconStrokeWidth?: number;
fullWidth?: boolean;
onPress?: (event: GestureResponderEvent) => void | Promise<void>;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
accessibilityLabel?: string;
testID?: string;
};
export function Button({
label,
variant = 'primary',
disabled = false,
loading = false,
loadingLabel,
autoLoading = false,
iconOnly = false,
iconOnlyShape = 'round',
icon,
leftIcon,
rightIcon,
iconSize = 20,
iconStrokeWidth = 2,
fullWidth = true,
onPress,
style,
textStyle,
accessibilityLabel,
testID,
}: ButtonProps) {
const { colors, borderRadius, typography, gradients, gradientDirection } = useTheme();
const [internalLoading, setInternalLoading] = useState(false);
const [isPressed, setIsPressed] = useState(false);
const isLoading = loading || internalLoading;
const isDisabled = disabled || isLoading;
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const handlePressIn = useCallback(() => {
if (isDisabled) {
return;
}
setIsPressed(true);
scale.value = withTiming(PRESSED_SCALE, { duration: 120 });
}, [isDisabled, scale]);
const handlePressOut = useCallback(() => {
setIsPressed(false);
scale.value = withTiming(1, { duration: 140 });
}, [scale]);
const handlePress = useCallback(
async (event: GestureResponderEvent) => {
if (!onPress || isDisabled) {
return;
}
const result = onPress(event);
if (!autoLoading || !(result instanceof Promise)) {
return;
}
setInternalLoading(true);
try {
await result;
} finally {
setInternalLoading(false);
}
},
[autoLoading, isDisabled, onPress],
);
const labelColor = useMemo(() => {
if (isDisabled) {
return colors.textDisabled;
}
if (variant === 'primary') {
return colors.textPrimary;
}
if (variant === 'destructive') {
return colors.danger;
}
if (variant === 'ghost') {
return colors.tertiary;
}
return colors.textPrimary;
}, [colors, isDisabled, variant]);
const loadingColor = variant === 'primary' && !isDisabled ? colors.textPrimary : labelColor;
const iconOnlyRadius = iconOnlyShape === 'round' ? BUTTON_HEIGHT / 2 : borderRadius.button;
const sideIconColor = !isDisabled && variant === 'secondary' ? colors.tertiary : labelColor;
const iconOnlyColor = labelColor;
const IconOnly = icon;
const LeftIcon = leftIcon;
const RightIcon = rightIcon;
const containerStyle: ViewStyle = {
width: fullWidth && !iconOnly ? '100%' : BUTTON_HEIGHT,
height: BUTTON_HEIGHT,
borderRadius: iconOnly ? iconOnlyRadius : borderRadius.button,
overflow: 'hidden',
};
const baseFillStyle: ViewStyle = {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 8,
borderRadius: iconOnly ? iconOnlyRadius : borderRadius.button,
};
const variantFillStyle: ViewStyle = (() => {
if (isDisabled) {
return {
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
};
}
if (variant === 'secondary') {
return {
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
};
}
if (variant === 'ghost') {
return {
backgroundColor: 'transparent',
};
}
if (variant === 'destructive') {
return {
backgroundColor: 'rgba(255, 77, 109, 0.08)',
borderWidth: 1,
borderColor: colors.danger,
};
}
return {
backgroundColor: colors.surface,
};
})();
const contentPaddingStyle: ViewStyle = {
paddingHorizontal: iconOnly ? 0 : 24,
};
const textStyles: TextStyle = {
...typography.button,
color: labelColor,
};
const content = (
<>
{isLoading ? <ActivityIndicator color={loadingColor} size="small" /> : null}
{!isLoading && iconOnly && IconOnly ? (
<IconOnly color={iconOnlyColor} size={iconSize} strokeWidth={iconStrokeWidth} />
) : null}
{!iconOnly && !isLoading && LeftIcon ? (
<LeftIcon color={sideIconColor} size={iconSize} strokeWidth={iconStrokeWidth} />
) : null}
{!iconOnly && label ? <Text style={[textStyles, textStyle]}>{isLoading && loadingLabel ? loadingLabel : label}</Text> : null}
{!iconOnly && !isLoading && RightIcon ? (
<RightIcon color={sideIconColor} size={iconSize} strokeWidth={iconStrokeWidth} />
) : null}
</>
);
const fillNode =
variant === 'primary' && !isDisabled ? (
<LinearGradient
colors={isPressed ? gradients.signaturePressed : gradients.signature}
start={gradientDirection.start}
end={gradientDirection.end}
style={[baseFillStyle, contentPaddingStyle]}
>
{content}
</LinearGradient>
) : (
<View style={[baseFillStyle, contentPaddingStyle, variantFillStyle]}>{content}</View>
);
return (
<AnimatedPressable
testID={testID}
accessibilityRole="button"
accessibilityState={{ disabled: isDisabled, busy: isLoading }}
accessibilityLabel={accessibilityLabel ?? label}
disabled={isDisabled}
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={[styles.root, containerStyle, animatedStyle, style]}
>
{fillNode}
</AnimatedPressable>
);
}
export default Button;
const styles = StyleSheet.create({
root: {
alignSelf: 'stretch',
},
});

View File

@@ -1,6 +1,6 @@
import { type PropsWithChildren, useMemo } from 'react';
import colors from './colors';
import { colors, glow, gradientDirection, gradients, palette } from './colors';
import spacing, { borderRadius } from './spacing';
import typography from './typography';
import { ThemeContext } from './useTheme';
@@ -9,6 +9,10 @@ export function ThemeProvider({ children }: PropsWithChildren) {
const value = useMemo(
() => ({
colors,
gradients,
glow,
gradientDirection,
palette,
typography,
spacing,
borderRadius,

View File

@@ -1,17 +1,173 @@
export const colors = {
deepBackground: '#0A0A12',
surface: '#14141F',
surfaceElevated: '#1E1E2D',
border: '#2A2A3C',
primary: '#FF2E88',
secondary: '#7B3DFF',
tertiary: '#28E0F0',
success: '#3DDC97',
textPrimary: '#F4F4FA',
textSecondary: '#9A9AB4',
signatureGradient: ['#7B3DFF', '#FF2E88'] as const,
import type { ColorValue, ViewStyle } from 'react-native';
/* ------------------------------------------------------------------ *
* Palette brute — 1:1 avec les tokens :root de la maquette
* ------------------------------------------------------------------ */
export const palette = {
/** Fond profond de l'app */
ink900: '#0A0A12',
/** Fond du canvas hors écran (planche de style / desktop) */
ink950: '#06060C',
/** Surface / carte */
ink800: '#14141F',
/** Surface élevée (champ, bouton secondaire, sheet) */
ink700: '#1E1E2D',
/** Bordure subtile */
ink600: '#2A2A3C',
magenta: '#FF2E88',
/** Magenta pressé — extrémité du dégradé assombri */
magentaPressed: '#E5216F',
/** Teinte magenta claire (texte sur fond magenta translucide) */
magentaTint: '#FFB4D4',
violet: '#7B3DFF',
violetPressed: '#6A2FE6',
violetTint: '#C9B6FF',
cyan: '#28E0F0',
green: '#3DDC97',
/** Ambre — seule teinte chaude du système (placeholders, alertes) */
amber: '#FFC478',
white: '#F4F4FA',
grey100: '#D6D6E6',
grey200: '#C9C9DC',
grey400: '#9A9AB4',
} as const;
export const signatureGradient = colors.signatureGradient;
/* ------------------------------------------------------------------ *
* Tokens sémantiques
* ------------------------------------------------------------------ */
export const colors = {
/* --- Fonds & surfaces ------------------------------------------- */
deepBackground: palette.ink900,
/** Fond hors-écran / derrière les sheets */
canvas: palette.ink950,
surface: palette.ink800,
surfaceElevated: palette.ink700,
border: palette.ink600,
/** Séparateur intra-carte, plus léger que `border` */
divider: 'rgba(255,255,255,0.12)',
/** Bloc squelette de chargement */
skeleton: 'rgba(244,244,250,0.07)',
/** Piste de barre de progression / slider */
track: 'rgba(244,244,250,0.28)',
/* --- Accents ---------------------------------------------------- */
primary: palette.magenta,
primaryPressed: palette.magentaPressed,
/** Fond de badge / chip actif teinté magenta */
primarySoft: 'rgba(255,46,136,0.14)',
/** Bordure de bouton destructif */
primaryBorder: 'rgba(255,46,136,0.55)',
onPrimary: '#FFFFFF',
secondary: palette.violet,
secondaryPressed: palette.violetPressed,
secondarySoft: 'rgba(123,61,255,0.14)',
tertiary: palette.cyan,
/** Fond de badge teinté cyan (line-up, « en cours ») */
tertiarySoft: 'rgba(40,224,240,0.12)',
/* --- États ------------------------------------------------------ */
success: palette.green,
successSoft: 'rgba(61,220,151,0.14)',
successBorder: 'rgba(61,220,151,0.5)',
/** Pas de rouge dans le système : le destructif emprunte le magenta */
danger: palette.magenta,
dangerSoft: 'rgba(255,46,136,0.14)',
warning: palette.amber,
warningSoft: 'rgba(255,196,120,0.14)',
/* --- Texte ------------------------------------------------------ */
textPrimary: palette.white,
/** Titres et libellés secondaires (le plus utilisé après textPrimary) */
textStrong: palette.grey100,
/** Corps de texte long — jamais du blanc pur sur fond sombre */
textBody: palette.grey200,
textSecondary: palette.grey400,
textOnAccent: '#FFFFFF',
textOnPrimarySoft: palette.magentaTint,
textOnSecondarySoft: palette.violetTint,
/** Texte désactivé (bouton off) — appliquer aussi opacity 0.45 */
textDisabled: palette.grey400,
/* --- Voiles sur média ------------------------------------------- */
/** Header / tab bar en blur */
scrimHeader: 'rgba(10,10,18,0.94)',
/** Bas d'image de couverture (B4, C3) */
scrimHeavy: 'rgba(10,10,18,0.86)',
scrimMedium: 'rgba(10,10,18,0.6)',
scrimLight: 'rgba(10,10,18,0.15)',
/** Bouton flottant sur photo */
glassFill: 'rgba(255,255,255,0.14)',
/* --- Carte (B7) ------------------------------------------------- */
mapBackground: '#0C1424',
mapWater: '#101A2E',
mapLand: '#0A0E1A',
} as const;
/* ------------------------------------------------------------------ *
* Dégradés — tuples pour expo-linear-gradient.
* La maquette utilise 135deg (haut-gauche → bas-droite) : d'où
* start {0,0} / end {1,1}.
* ------------------------------------------------------------------ */
export const gradientDirection = {
start: { x: 0.2, y: 0 },
end: { x: 0.8, y: 1 },
} as const;
export const gradients = {
/** Dégradé signature — bouton primaire, FAB, logo */
signature: [palette.violet, palette.magenta],
/** Même dégradé à l'état pressé */
signaturePressed: [palette.violetPressed, palette.magentaPressed],
/** Voile vertical sur image de couverture (start {0,0} end {0,1}) */
coverScrim: [
colors.scrimMedium,
colors.scrimLight,
'rgba(10,10,18,0.98)',
],
} as const satisfies Record<string, readonly ColorValue[]>;
/* ------------------------------------------------------------------ *
* Glow — les box-shadow CSS n'existent pas en RN.
* iOS lit shadow*, Android ne lit que elevation (et ignore la couleur
* avant API 28) : le glow rose y sera un simple relief.
* ------------------------------------------------------------------ */
export const glow = {
/** --gl · sous un bouton primaire / une carte magenta */
primary: {
shadowColor: palette.magenta,
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.55,
shadowRadius: 25,
elevation: 12,
},
/** --glv · variante violette */
violet: {
shadowColor: palette.violet,
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.6,
shadowRadius: 25,
elevation: 12,
},
/** Ombre neutre de carte élevée */
card: {
shadowColor: '#000000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.5,
shadowRadius: 18,
elevation: 6,
},
} as const satisfies Record<string, ViewStyle>;
export type ColorToken = keyof typeof colors;
/** @deprecated utiliser `gradients.signature` */
export const signatureGradient = gradients.signature;
export default colors;

View File

@@ -1,67 +1,226 @@
import { Inter_400Regular, Inter_500Medium } from '@expo-google-fonts/inter';
import { SpaceGrotesk_600SemiBold, SpaceGrotesk_700Bold } from '@expo-google-fonts/space-grotesk';
import {
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
} from '@expo-google-fonts/inter';
import {
JetBrainsMono_400Regular,
JetBrainsMono_500Medium,
JetBrainsMono_700Bold,
} from '@expo-google-fonts/jetbrains-mono';
import {
SpaceGrotesk_600SemiBold,
SpaceGrotesk_700Bold,
} from '@expo-google-fonts/space-grotesk';
import { useFonts } from 'expo-font';
import type { TextStyle } from 'react-native';
/* ------------------------------------------------------------------ *
* Familles
* Maquette Bahla : Space Grotesk (display) / Inter (UI) / JetBrains Mono
* (labels + chiffres). Le mono n'est PAS la mono système : c'est un
* choix de marque, il doit être chargé.
* ------------------------------------------------------------------ */
export const fontFamily = {
display: 'SpaceGrotesk_600SemiBold',
displayBold: 'SpaceGrotesk_700Bold',
body: 'Inter_400Regular',
bodyMedium: 'Inter_500Medium',
mono: 'monospace',
bodySemi: 'Inter_600SemiBold',
bodyBold: 'Inter_700Bold',
mono: 'JetBrainsMono_400Regular',
monoMedium: 'JetBrainsMono_500Medium',
monoBold: 'JetBrainsMono_700Bold',
} as const;
/* ------------------------------------------------------------------ *
* Tokens
* letterSpacing est en px sur RN (pas en em) : chaque valeur ci-dessous
* est la conversion em × fontSize de la maquette.
* ------------------------------------------------------------------ */
export const typography = {
display: {
fontFamily: fontFamily.display,
fontSize: 40,
/* --- Display · Space Grotesk ------------------------------------ */
hero: {
fontFamily: fontFamily.displayBold,
fontSize: 48,
lineHeight: 48,
letterSpacing: -0.8,
letterSpacing: -1.44,
},
display: {
fontFamily: fontFamily.displayBold,
fontSize: 40,
lineHeight: 42,
letterSpacing: -1.2,
},
/** Grand titre d'écran (« Mes events », dashboard) */
screenTitleLg: {
fontFamily: fontFamily.displayBold,
fontSize: 28,
lineHeight: 32,
letterSpacing: -0.56,
},
screenTitle: {
fontFamily: fontFamily.displayBold,
fontSize: 26,
lineHeight: 32,
letterSpacing: -0.6,
lineHeight: 30,
letterSpacing: -0.52,
},
/** Titre de bloc / nom d'artiste en tête de line-up */
sectionTitle: {
fontFamily: fontFamily.displayBold,
fontSize: 20,
lineHeight: 24,
letterSpacing: -0.2,
},
cardTitle: {
fontFamily: fontFamily.display,
fontSize: 18,
lineHeight: 24,
letterSpacing: -0.2,
lineHeight: 22,
letterSpacing: 0,
},
sectionLabel: {
fontFamily: fontFamily.mono,
fontSize: 11,
lineHeight: 16,
letterSpacing: 0.8,
textTransform: 'uppercase' as const,
/** Titre de carte compacte (grille, slot de line-up) */
cardTitleSm: {
fontFamily: fontFamily.display,
fontSize: 16,
lineHeight: 20,
letterSpacing: -0.16,
},
/* --- Corps & UI · Inter ---------------------------------------- */
body: {
fontFamily: fontFamily.body,
fontSize: 15,
lineHeight: 24,
letterSpacing: 0,
},
bodySm: {
fontFamily: fontFamily.body,
fontSize: 14,
lineHeight: 22,
letterSpacing: 0,
},
/** Libellé de bouton — 52 px de haut, rayon 14 px */
button: {
fontFamily: fontFamily.bodySemi,
fontSize: 15,
lineHeight: 20,
letterSpacing: 0,
},
/** Nom d'organisateur, en-tête de ligne, valeur de champ */
label: {
fontFamily: fontFamily.bodySemi,
fontSize: 13,
lineHeight: 18,
letterSpacing: 0,
},
metadata: {
fontFamily: fontFamily.bodyMedium,
fontSize: 13,
lineHeight: 18,
letterSpacing: 0,
},
/** Texte de chip / filtre */
chip: {
fontFamily: fontFamily.bodySemi,
fontSize: 12,
lineHeight: 16,
letterSpacing: 0,
},
metadataSm: {
fontFamily: fontFamily.bodyMedium,
fontSize: 12,
lineHeight: 16,
letterSpacing: 0,
},
caption: {
fontFamily: fontFamily.body,
fontSize: 11,
lineHeight: 15,
letterSpacing: 0,
},
/** Libellé de la tab bar (4 onglets) */
tabLabel: {
fontFamily: fontFamily.bodyMedium,
fontSize: 10,
lineHeight: 13,
letterSpacing: 0,
},
/* --- Labels & chiffres · JetBrains Mono ------------------------- */
/** Overline de rangée / d'écran sur la planche de style */
overlineLg: {
fontFamily: fontFamily.monoMedium,
fontSize: 13,
lineHeight: 17,
letterSpacing: 1.82,
textTransform: 'uppercase',
},
/** Libellé de section dans l'app */
sectionLabel: {
fontFamily: fontFamily.monoMedium,
fontSize: 11,
lineHeight: 14,
letterSpacing: 1.76,
textTransform: 'uppercase',
},
/** Overline resserré (cartouches, en-têtes de colonne) */
overlineSm: {
fontFamily: fontFamily.monoMedium,
fontSize: 10,
lineHeight: 13,
letterSpacing: 1.8,
textTransform: 'uppercase',
},
/** Badge / pastille (« COMPLET », « CE SOIR ») */
badge: {
fontFamily: fontFamily.monoMedium,
fontSize: 9,
lineHeight: 12,
letterSpacing: 1.62,
textTransform: 'uppercase',
},
/** Chiffres, horaires, prix, distances — 22:30 · 25 CHF · 18 km */
digits: {
fontFamily: fontFamily.mono,
fontSize: 11,
lineHeight: 16,
letterSpacing: 0.4,
lineHeight: 14,
letterSpacing: 0,
},
} as const;
digitsSm: {
fontFamily: fontFamily.mono,
fontSize: 10,
lineHeight: 13,
letterSpacing: 0,
},
/** Grand chiffre de statistique (dashboard organisateur) */
statValue: {
fontFamily: fontFamily.displayBold,
fontSize: 24,
lineHeight: 26,
letterSpacing: -0.48,
},
} as const satisfies Record<string, TextStyle>;
export const loadFonts = () =>
export type TypographyToken = keyof typeof typography;
/* ------------------------------------------------------------------ *
* Chargement — hook, donc préfixe `use` (règle react-hooks).
* ------------------------------------------------------------------ */
export const useAppFonts = () =>
useFonts({
SpaceGrotesk_600SemiBold,
SpaceGrotesk_700Bold,
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
JetBrainsMono_400Regular,
JetBrainsMono_500Medium,
JetBrainsMono_700Bold,
});
/** @deprecated conservé pour compat — utiliser `useAppFonts` */
export const loadFonts = useAppFonts;
export default typography;

View File

@@ -1,11 +1,15 @@
import { createContext, useContext } from 'react';
import colors from './colors';
import { colors, glow, gradientDirection, gradients, palette } from './colors';
import spacing, { borderRadius } from './spacing';
import typography from './typography';
export type ThemeContextValue = {
colors: typeof colors;
gradients: typeof gradients;
glow: typeof glow;
gradientDirection: typeof gradientDirection;
palette: typeof palette;
typography: typeof typography;
spacing: typeof spacing;
borderRadius: typeof borderRadius;