feat(mobile): fondation design system DSFR (theme + composants UI) + hooks admin + route admin

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Azmog
2026-06-19 15:14:55 +02:00
parent 549a2fdb03
commit 2e96e07c7e
12 changed files with 674 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
import { StyleSheet, Text, View } from 'react-native';
import { Colors } from '@/lib/colors';
import { FontSize, FontWeight, Spacing } from '@/lib/theme';
interface AppBarProps {
/** Titre de la page, affiché sous le bandeau institutionnel. */
title?: string;
subtitle?: string;
/** Élément optionnel aligné à droite du bandeau (ex. bouton). */
right?: React.ReactNode;
}
/**
* Bandeau institutionnel République Française réutilisable, à placer en haut
* de chaque écran principal. Reprend le bloc-marque de l'État (DSFR) + le nom
* du service, avec un titre/sous-titre de page optionnel.
*/
export default function AppBar({ title, subtitle, right }: AppBarProps) {
return (
<View style={styles.wrapper}>
<View style={styles.brandRow}>
<View style={styles.marianne}>
<Text style={styles.republique}>RÉPUBLIQUE</Text>
<Text style={styles.republique}>FRANÇAISE</Text>
</View>
<View style={styles.serviceBlock}>
<Text style={styles.serviceName}>CESIZen</Text>
<Text style={styles.serviceTagline}>Santé mentale</Text>
</View>
{right ? <View style={styles.right}>{right}</View> : null}
</View>
{title ? (
<View style={styles.titleRow}>
<Text style={styles.title}>{title}</Text>
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
backgroundColor: Colors.white,
borderBottomWidth: 1,
borderBottomColor: Colors.gray[200],
},
brandRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.lg,
paddingVertical: Spacing.md,
},
marianne: {
paddingRight: Spacing.md,
marginRight: Spacing.md,
borderRightWidth: 1,
borderRightColor: Colors.gray[200],
},
republique: {
fontSize: FontSize.tiny,
fontWeight: FontWeight.bold,
color: Colors.black,
letterSpacing: 0.5,
lineHeight: 13,
},
serviceBlock: { flex: 1 },
serviceName: { fontSize: FontSize.lg, fontWeight: FontWeight.heavy, color: Colors.primary },
serviceTagline: { fontSize: FontSize.tiny, color: Colors.gray[500], marginTop: 1 },
right: { marginLeft: Spacing.sm },
titleRow: {
paddingHorizontal: Spacing.lg,
paddingBottom: Spacing.md,
paddingTop: Spacing.xs,
},
title: { fontSize: FontSize.xxl, fontWeight: FontWeight.bold, color: Colors.black },
subtitle: { fontSize: FontSize.sm, color: Colors.gray[500], marginTop: 2 },
});
+56
View File
@@ -0,0 +1,56 @@
import { StyleSheet, Text, View } from 'react-native';
import { Colors } from '@/lib/colors';
import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme';
type Tone = 'neutral' | 'success' | 'error' | 'info' | 'warning' | 'primary';
interface BadgeProps {
label: string;
tone?: Tone;
/** Couleur personnalisée (ex. couleur d'une émotion) — prioritaire sur `tone`. */
color?: string;
uppercase?: boolean;
}
/** Pastille d'état DSFR, taille homogène. */
export default function Badge({ label, tone = 'neutral', color, uppercase = false }: BadgeProps) {
const palette = color ? { bg: hexWithAlpha(color, 0.14), text: darken(color) } : TONES[tone];
return (
<View style={[styles.badge, { backgroundColor: palette.bg }]}>
<Text style={[styles.text, { color: palette.text }, uppercase && styles.uppercase]} numberOfLines={1}>
{label}
</Text>
</View>
);
}
const TONES: Record<Tone, { bg: string; text: string }> = {
neutral: { bg: Colors.gray[100], text: Colors.gray[600] },
success: { bg: Colors.successBg, text: Colors.success },
error: { bg: Colors.errorBg, text: Colors.error },
info: { bg: '#E8EDFF', text: Colors.info },
warning: { bg: '#FFE9C7', text: Colors.warning },
primary: { bg: '#E3E3FD', text: Colors.primary },
};
/** Ajoute un canal alpha (0–1) à une couleur hex #RRGGBB. */
function hexWithAlpha(hex: string, alpha: number): string {
const a = Math.round(alpha * 255).toString(16).padStart(2, '0');
return `${hex}${a}`;
}
/** Pour le texte sur fond teinté, on garde la couleur d'origine (lisible sur 14% d'alpha). */
function darken(hex: string): string {
return hex;
}
const styles = StyleSheet.create({
badge: {
alignSelf: 'flex-start',
paddingHorizontal: Spacing.sm,
paddingVertical: 3,
borderRadius: Radius.sm,
},
text: { fontSize: FontSize.tiny, fontWeight: FontWeight.bold },
uppercase: { textTransform: 'uppercase', letterSpacing: 0.5 },
});
+93
View File
@@ -0,0 +1,93 @@
import { ActivityIndicator, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Colors } from '@/lib/colors';
import { FontSize, FontWeight, MIN_TOUCH, Radius, Spacing } from '@/lib/theme';
type Variant = 'primary' | 'secondary' | 'danger' | 'tertiary';
type Size = 'md' | 'sm';
interface ButtonProps {
title: string;
onPress?: () => void;
variant?: Variant;
size?: Size;
loading?: boolean;
disabled?: boolean;
/** Nom d'icône Ionicons affichée à gauche du libellé. */
icon?: keyof typeof Ionicons.glyphMap;
fullWidth?: boolean;
style?: StyleProp<ViewStyle>;
}
/**
* Bouton DSFR. 4 variantes : primary (bleu plein), secondary (contour),
* danger (rouge plein), tertiary (texte seul). Hauteur ≥ 44px (accessibilité).
*/
export default function Button({
title,
onPress,
variant = 'primary',
size = 'md',
loading = false,
disabled = false,
icon,
fullWidth = false,
style,
}: ButtonProps) {
const isDisabled = disabled || loading;
const palette = VARIANTS[variant];
const minHeight = size === 'sm' ? 40 : MIN_TOUCH;
const fontSize = size === 'sm' ? FontSize.sm : FontSize.md;
return (
<Pressable
onPress={onPress}
disabled={isDisabled}
accessibilityRole="button"
accessibilityState={{ disabled: isDisabled, busy: loading }}
style={({ pressed }) => [
styles.base,
{
minHeight,
paddingVertical: size === 'sm' ? Spacing.sm : Spacing.md,
backgroundColor: palette.bg,
borderColor: palette.border,
borderWidth: palette.border === 'transparent' ? 0 : 1,
},
fullWidth && styles.fullWidth,
pressed && !isDisabled && { opacity: 0.85 },
isDisabled && styles.disabled,
style,
]}
>
{loading ? (
<ActivityIndicator color={palette.text} size="small" />
) : (
<View style={styles.content}>
{icon && <Ionicons name={icon} size={fontSize + 3} color={palette.text} />}
<Text style={[styles.label, { color: palette.text, fontSize }]}>{title}</Text>
</View>
)}
</Pressable>
);
}
const VARIANTS: Record<Variant, { bg: string; text: string; border: string }> = {
primary: { bg: Colors.primary, text: Colors.white, border: 'transparent' },
secondary: { bg: Colors.white, text: Colors.primary, border: Colors.primary },
danger: { bg: Colors.error, text: Colors.white, border: 'transparent' },
tertiary: { bg: 'transparent', text: Colors.primary, border: 'transparent' },
};
const styles = StyleSheet.create({
base: {
borderRadius: Radius.sm,
paddingHorizontal: Spacing.xl,
alignItems: 'center',
justifyContent: 'center',
},
fullWidth: { alignSelf: 'stretch', width: '100%' },
disabled: { opacity: 0.45 },
content: { flexDirection: 'row', alignItems: 'center', gap: Spacing.sm },
label: { fontWeight: FontWeight.semibold },
});
+42
View File
@@ -0,0 +1,42 @@
import { Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { Colors } from '@/lib/colors';
import { Radius, Shadow, Spacing } from '@/lib/theme';
interface CardProps {
children: React.ReactNode;
onPress?: () => void;
/** Padding interne (défaut lg = 16). Mettre 0 pour gérer soi-même. */
padding?: number;
/** Légère ombre portée. */
elevated?: boolean;
style?: StyleProp<ViewStyle>;
}
/** Conteneur DSFR : fond blanc, bordure grise fine, coins peu arrondis. */
export default function Card({ children, onPress, padding = Spacing.lg, elevated = false, style }: CardProps) {
const content = [styles.card, { padding }, elevated && Shadow.card, style];
if (onPress) {
return (
<Pressable
onPress={onPress}
accessibilityRole="button"
style={({ pressed }) => [content, pressed && styles.pressed]}
>
{children}
</Pressable>
);
}
return <View style={content}>{children}</View>;
}
const styles = StyleSheet.create({
card: {
backgroundColor: Colors.white,
borderWidth: 1,
borderColor: Colors.gray[200],
borderRadius: Radius.sm,
},
pressed: { backgroundColor: Colors.gray[50] },
});
+47
View File
@@ -0,0 +1,47 @@
import { StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Colors } from '@/lib/colors';
import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme';
import Button from './Button';
interface EmptyStateProps {
icon?: keyof typeof Ionicons.glyphMap;
title: string;
message?: string;
actionLabel?: string;
onAction?: () => void;
}
/** État vide homogène : icône, titre, message et action optionnelle. */
export default function EmptyState({ icon = 'document-text-outline', title, message, actionLabel, onAction }: EmptyStateProps) {
return (
<View style={styles.container}>
<View style={styles.iconCircle}>
<Ionicons name={icon} size={32} color={Colors.primary} />
</View>
<Text style={styles.title}>{title}</Text>
{message ? <Text style={styles.message}>{message}</Text> : null}
{actionLabel && onAction ? (
<View style={styles.action}>
<Button title={actionLabel} onPress={onAction} icon="add" />
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create({
container: { alignItems: 'center', justifyContent: 'center', paddingVertical: Spacing.xxxl, paddingHorizontal: Spacing.xl },
iconCircle: {
width: 72,
height: 72,
borderRadius: Radius.pill,
backgroundColor: '#E3E3FD',
alignItems: 'center',
justifyContent: 'center',
marginBottom: Spacing.lg,
},
title: { fontSize: FontSize.lg, fontWeight: FontWeight.bold, color: Colors.black, textAlign: 'center' },
message: { fontSize: FontSize.sm, color: Colors.gray[500], textAlign: 'center', marginTop: Spacing.sm, lineHeight: 20 },
action: { marginTop: Spacing.xl },
});
+18
View File
@@ -0,0 +1,18 @@
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { Colors } from '@/lib/colors';
import { FontSize, Spacing } from '@/lib/theme';
/** Indicateur de chargement centré et homogène, avec libellé optionnel. */
export default function Loader({ label }: { label?: string }) {
return (
<View style={styles.container}>
<ActivityIndicator size="large" color={Colors.primary} />
{label ? <Text style={styles.label}>{label}</Text> : null}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: Spacing.xl, backgroundColor: Colors.gray[50] },
label: { marginTop: Spacing.md, fontSize: FontSize.sm, color: Colors.gray[500] },
});
+19
View File
@@ -0,0 +1,19 @@
import { StyleSheet, Text } from 'react-native';
import { Colors } from '@/lib/colors';
import { FontSize, FontWeight, Spacing } from '@/lib/theme';
/** Intitulé de section (petites capitales DSFR) au-dessus d'une liste/groupe. */
export default function SectionTitle({ children }: { children: string }) {
return <Text style={styles.title}>{children}</Text>;
}
const styles = StyleSheet.create({
title: {
fontSize: FontSize.xs,
fontWeight: FontWeight.bold,
color: Colors.gray[500],
textTransform: 'uppercase',
letterSpacing: 1,
marginBottom: Spacing.sm,
},
});
+122
View File
@@ -0,0 +1,122 @@
import { useState } from 'react';
import {
StyleSheet,
Text,
TextInput,
View,
type KeyboardTypeOptions,
type StyleProp,
type TextInputProps,
type ViewStyle,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Colors } from '@/lib/colors';
import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme';
interface TextFieldProps {
label: string;
value: string;
onChangeText: (text: string) => void;
placeholder?: string;
error?: string;
helper?: string;
required?: boolean;
secureTextEntry?: boolean;
keyboardType?: KeyboardTypeOptions;
autoCapitalize?: TextInputProps['autoCapitalize'];
autoComplete?: TextInputProps['autoComplete'];
multiline?: boolean;
numberOfLines?: number;
editable?: boolean;
style?: StyleProp<ViewStyle>;
}
/**
* Champ de saisie DSFR : libellé au-dessus, fond gris clair, bordure basse
* accentuée (bleu au focus, rouge en erreur), message d'erreur sous le champ.
*/
export default function TextField({
label,
value,
onChangeText,
placeholder,
error,
helper,
required = false,
secureTextEntry = false,
keyboardType,
autoCapitalize = 'none',
autoComplete,
multiline = false,
numberOfLines,
editable = true,
style,
}: TextFieldProps) {
const [focused, setFocused] = useState(false);
const borderColor = error ? Colors.error : focused ? Colors.primary : Colors.gray[600];
return (
<View style={[styles.container, style]}>
<Text style={styles.label}>
{label}
{required && <Text style={styles.required}> *</Text>}
</Text>
<TextInput
value={value}
onChangeText={onChangeText}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder={placeholder}
placeholderTextColor={Colors.gray[400]}
secureTextEntry={secureTextEntry}
keyboardType={keyboardType}
autoCapitalize={autoCapitalize}
autoComplete={autoComplete}
multiline={multiline}
numberOfLines={numberOfLines}
editable={editable}
style={[
styles.input,
{ borderBottomColor: borderColor },
multiline && styles.multiline,
!editable && styles.readonly,
]}
/>
{error ? (
<View style={styles.messageRow}>
<Ionicons name="alert-circle" size={14} color={Colors.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
) : helper ? (
<Text style={styles.helperText}>{helper}</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create({
container: { marginBottom: Spacing.lg },
label: {
fontSize: FontSize.sm,
fontWeight: FontWeight.medium,
color: Colors.black,
marginBottom: Spacing.xs,
},
required: { color: Colors.error },
input: {
backgroundColor: Colors.gray[50],
borderTopLeftRadius: Radius.sm,
borderTopRightRadius: Radius.sm,
borderBottomWidth: 2,
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.md,
fontSize: FontSize.body,
color: Colors.black,
minHeight: 44,
},
multiline: { minHeight: 110, textAlignVertical: 'top' },
readonly: { color: Colors.gray[500], backgroundColor: Colors.gray[100] },
messageRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.xs, marginTop: Spacing.xs },
errorText: { fontSize: FontSize.xs, color: Colors.error },
helperText: { fontSize: FontSize.xs, color: Colors.gray[500], marginTop: Spacing.xs },
});
+9
View File
@@ -0,0 +1,9 @@
// Barrel des composants UI DSFR réutilisables.
export { default as AppBar } from './AppBar';
export { default as Badge } from './Badge';
export { default as Button } from './Button';
export { default as Card } from './Card';
export { default as EmptyState } from './EmptyState';
export { default as Loader } from './Loader';
export { default as SectionTitle } from './SectionTitle';
export { default as TextField } from './TextField';