polish(mobile): audit design DSFR multi-agents — tokens, a11y, états, finitions
- tokens centralisés (Tint, IconSize, LineHeight, LetterSpacing), zéro hex en dur - accessibilité (rôles, labels, live regions, afficher/masquer mot de passe) - états vides/erreur complétés (ex. saisie introuvable), cibles tactiles >=44px - suppression du composant mort RepubliqueHeader Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,14 @@ import { Spacing, Radius, FontSize, FontWeight, MIN_TOUCH, HIT_SLOP } from '@/li
|
||||
import { Loader } from '@/components/ui';
|
||||
import type { Emotion } from '@/types';
|
||||
|
||||
/** Dimensions locales (multiples de l'échelle 4px du DSFR). */
|
||||
const TILE_MIN_HEIGHT = 92;
|
||||
const TILE_EMOJI = 30;
|
||||
const BACK_ICON = 16;
|
||||
const CHEVRON_ICON = 12;
|
||||
const BORDER_DEFAULT = 1.5;
|
||||
const BORDER_SELECTED = 2.5;
|
||||
|
||||
interface EmotionPickerProps {
|
||||
value?: number;
|
||||
onChange: (emotionId: number) => void;
|
||||
@@ -23,8 +31,14 @@ export default function EmotionPicker({ value, onChange }: EmotionPickerProps) {
|
||||
if (selectedParent?.enfants && selectedParent.enfants.length > 0) {
|
||||
return (
|
||||
<View>
|
||||
<TouchableOpacity style={styles.backBtn} hitSlop={HIT_SLOP} onPress={() => setSelectedParent(null)}>
|
||||
<Ionicons name="arrow-back" size={16} color={Colors.primary} />
|
||||
<TouchableOpacity
|
||||
style={styles.backBtn}
|
||||
hitSlop={HIT_SLOP}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Retour aux émotions"
|
||||
onPress={() => setSelectedParent(null)}
|
||||
>
|
||||
<Ionicons name="arrow-back" size={BACK_ICON} color={Colors.primary} />
|
||||
<Text style={styles.backText}>Retour aux émotions</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.subTitle}>
|
||||
@@ -85,16 +99,18 @@ function EmotionTile({
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tile,
|
||||
selected && { borderColor: emotion.couleur, borderWidth: 2.5, backgroundColor: `${emotion.couleur}12` },
|
||||
selected && { borderColor: emotion.couleur, borderWidth: BORDER_SELECTED, backgroundColor: `${emotion.couleur}12` },
|
||||
]}
|
||||
onPress={onPress}
|
||||
accessibilityLabel={emotion.nom}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected }}
|
||||
accessibilityLabel={hasChildren ? `${emotion.nom}, ouvrir les sous-émotions` : emotion.nom}
|
||||
>
|
||||
<Text style={styles.tileEmoji}>{emotion.icone || '🔵'}</Text>
|
||||
<Text style={styles.tileName} numberOfLines={2}>
|
||||
{emotion.nom}
|
||||
</Text>
|
||||
{hasChildren && <Ionicons name="chevron-down" size={12} color={Colors.gray[400]} style={styles.chevron} />}
|
||||
{hasChildren && <Ionicons name="chevron-down" size={CHEVRON_ICON} color={Colors.gray[400]} style={styles.chevron} />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
@@ -107,7 +123,7 @@ const styles = StyleSheet.create({
|
||||
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.md, justifyContent: 'space-between' },
|
||||
tile: {
|
||||
width: '30%',
|
||||
minHeight: 92,
|
||||
minHeight: TILE_MIN_HEIGHT,
|
||||
backgroundColor: Colors.white,
|
||||
borderRadius: Radius.sm,
|
||||
paddingVertical: Spacing.md,
|
||||
@@ -115,10 +131,10 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: Spacing.xs,
|
||||
borderWidth: 1.5,
|
||||
borderWidth: BORDER_DEFAULT,
|
||||
borderColor: Colors.gray[200],
|
||||
},
|
||||
tileEmoji: { fontSize: 30 },
|
||||
tileEmoji: { fontSize: TILE_EMOJI },
|
||||
tileName: { fontSize: FontSize.xs, fontWeight: FontWeight.semibold, color: Colors.black, textAlign: 'center' },
|
||||
chevron: { marginTop: 2 },
|
||||
chevron: { marginTop: Spacing.xs / 2 },
|
||||
});
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import Svg, { Circle, G, Text as SvgText } from 'react-native-svg';
|
||||
import { Colors } from '@/lib/colors';
|
||||
import { Spacing, Radius, FontSize, FontWeight } from '@/lib/theme';
|
||||
|
||||
/** Dimensions du donut et de sa légende. */
|
||||
const CENTER_PCT_SIZE = 30;
|
||||
const CENTER_LABEL_SIZE = FontSize.tiny;
|
||||
const CENTER_PCT_DY = 6;
|
||||
const CENTER_LABEL_DY = 16;
|
||||
const LEGEND_DOT = 12;
|
||||
|
||||
export interface EmotionPieSlice {
|
||||
nom: string;
|
||||
@@ -84,9 +92,9 @@ export default function EmotionPieChart({
|
||||
{/* Centre du donut : part dominante */}
|
||||
<SvgText
|
||||
x={center}
|
||||
y={center - 6}
|
||||
fontSize={30}
|
||||
fontWeight="800"
|
||||
y={center - CENTER_PCT_DY}
|
||||
fontSize={CENTER_PCT_SIZE}
|
||||
fontWeight={FontWeight.heavy}
|
||||
fill={Colors.black}
|
||||
textAnchor="middle"
|
||||
>
|
||||
@@ -94,8 +102,8 @@ export default function EmotionPieChart({
|
||||
</SvgText>
|
||||
<SvgText
|
||||
x={center}
|
||||
y={center + 16}
|
||||
fontSize={12}
|
||||
y={center + CENTER_LABEL_DY}
|
||||
fontSize={CENTER_LABEL_SIZE}
|
||||
fill={Colors.gray[500]}
|
||||
textAnchor="middle"
|
||||
>
|
||||
@@ -111,7 +119,9 @@ export default function EmotionPieChart({
|
||||
<Text style={styles.legendName} numberOfLines={1}>
|
||||
{item.nom}
|
||||
</Text>
|
||||
<Text style={styles.legendPct}>{item.pourcentage}%</Text>
|
||||
<Text style={styles.legendPct}>
|
||||
{item.pourcentage}% · {item.count}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
@@ -120,10 +130,10 @@ export default function EmotionPieChart({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { alignItems: 'center', marginBottom: 20 },
|
||||
legend: { alignSelf: 'stretch', marginTop: 20, gap: 8 },
|
||||
container: { alignItems: 'center', marginBottom: Spacing.lg },
|
||||
legend: { alignSelf: 'stretch', marginTop: Spacing.lg, gap: Spacing.sm },
|
||||
legendRow: { flexDirection: 'row', alignItems: 'center' },
|
||||
legendDot: { width: 12, height: 12, borderRadius: 6, marginRight: 10 },
|
||||
legendName: { flex: 1, fontSize: 14, color: Colors.black },
|
||||
legendPct: { fontSize: 14, fontWeight: '700', color: Colors.gray[600] },
|
||||
legendDot: { width: LEGEND_DOT, height: LEGEND_DOT, borderRadius: Radius.pill, marginRight: Spacing.sm },
|
||||
legendName: { flex: 1, fontSize: FontSize.sm, color: Colors.black },
|
||||
legendPct: { fontSize: FontSize.sm, fontWeight: FontWeight.bold, color: Colors.gray[600] },
|
||||
});
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { Colors } from '@/lib/colors';
|
||||
|
||||
interface RepubliqueHeaderProps {
|
||||
/** Affiche le nom du service (CESIZen) à côté du bloc Marianne. */
|
||||
showService?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* En-tête institutionnel reprenant l'identité visuelle de l'État (DSFR) :
|
||||
* bloc « RÉPUBLIQUE / FRANÇAISE » accompagné du nom du service.
|
||||
*/
|
||||
export default function RepubliqueHeader({ showService = true }: RepubliqueHeaderProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.marianneBlock}>
|
||||
<Text style={styles.republique}>RÉPUBLIQUE</Text>
|
||||
<Text style={styles.francaise}>FRANÇAISE</Text>
|
||||
<Text style={styles.devise}>Liberté{'\n'}Égalité{'\n'}Fraternité</Text>
|
||||
</View>
|
||||
{showService && (
|
||||
<View style={styles.serviceBlock}>
|
||||
<Text style={styles.serviceName}>CESIZen</Text>
|
||||
<Text style={styles.serviceTagline}>Santé mentale</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: Colors.white,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: Colors.gray[200],
|
||||
},
|
||||
marianneBlock: {
|
||||
paddingRight: 16,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: Colors.gray[200],
|
||||
},
|
||||
republique: { fontSize: 12, fontWeight: '700', color: Colors.black, letterSpacing: 0.5, lineHeight: 14 },
|
||||
francaise: { fontSize: 12, fontWeight: '700', color: Colors.black, letterSpacing: 0.5, lineHeight: 14 },
|
||||
devise: { fontSize: 8, color: Colors.gray[500], marginTop: 4, lineHeight: 10, fontStyle: 'italic' },
|
||||
serviceBlock: { paddingLeft: 16 },
|
||||
serviceName: { fontSize: 18, fontWeight: '800', color: Colors.primary },
|
||||
serviceTagline: { fontSize: 11, color: Colors.gray[500], marginTop: 1 },
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { Colors } from '@/lib/colors';
|
||||
import { FontSize, FontWeight, Spacing } from '@/lib/theme';
|
||||
import { FontSize, FontWeight, LetterSpacing, LineHeight, Spacing } from '@/lib/theme';
|
||||
|
||||
interface AppBarProps {
|
||||
/** Titre de la page, affiché sous le bandeau institutionnel. */
|
||||
@@ -17,7 +17,7 @@ interface AppBarProps {
|
||||
*/
|
||||
export default function AppBar({ title, subtitle, right }: AppBarProps) {
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<View style={styles.wrapper} accessibilityRole="header">
|
||||
<View style={styles.brandRow}>
|
||||
<View style={styles.marianne}>
|
||||
<Text style={styles.republique}>RÉPUBLIQUE</Text>
|
||||
@@ -61,12 +61,12 @@ const styles = StyleSheet.create({
|
||||
fontSize: FontSize.tiny,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black,
|
||||
letterSpacing: 0.5,
|
||||
lineHeight: 13,
|
||||
letterSpacing: LetterSpacing.sm,
|
||||
lineHeight: LineHeight.tight,
|
||||
},
|
||||
serviceBlock: { flex: 1 },
|
||||
serviceName: { fontSize: FontSize.lg, fontWeight: FontWeight.heavy, color: Colors.primary },
|
||||
serviceTagline: { fontSize: FontSize.tiny, color: Colors.gray[500], marginTop: 1 },
|
||||
serviceTagline: { fontSize: FontSize.tiny, color: Colors.gray[500], marginTop: Spacing.xs / 4 },
|
||||
right: { marginLeft: Spacing.sm },
|
||||
titleRow: {
|
||||
paddingHorizontal: Spacing.lg,
|
||||
@@ -74,5 +74,5 @@ const styles = StyleSheet.create({
|
||||
paddingTop: Spacing.xs,
|
||||
},
|
||||
title: { fontSize: FontSize.xxl, fontWeight: FontWeight.bold, color: Colors.black },
|
||||
subtitle: { fontSize: FontSize.sm, color: Colors.gray[500], marginTop: 2 },
|
||||
subtitle: { fontSize: FontSize.sm, color: Colors.gray[500], marginTop: Spacing.xs / 2 },
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { Colors } from '@/lib/colors';
|
||||
import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme';
|
||||
import { FontSize, FontWeight, LetterSpacing, Radius, Spacing, Tint } from '@/lib/theme';
|
||||
|
||||
type Tone = 'neutral' | 'success' | 'error' | 'info' | 'warning' | 'primary';
|
||||
|
||||
@@ -14,7 +14,7 @@ interface BadgeProps {
|
||||
|
||||
/** 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];
|
||||
const palette = color ? { bg: hexWithAlpha(color, 0.14), text: color } : TONES[tone];
|
||||
return (
|
||||
<View style={[styles.badge, { backgroundColor: palette.bg }]}>
|
||||
<Text style={[styles.text, { color: palette.text }, uppercase && styles.uppercase]} numberOfLines={1}>
|
||||
@@ -28,9 +28,9 @@ 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 },
|
||||
info: { bg: Tint.info, text: Colors.info },
|
||||
warning: { bg: Tint.warning, text: Colors.warning },
|
||||
primary: { bg: Tint.primary, text: Colors.primary },
|
||||
};
|
||||
|
||||
/** Ajoute un canal alpha (0–1) à une couleur hex #RRGGBB. */
|
||||
@@ -39,18 +39,13 @@ function hexWithAlpha(hex: string, alpha: number): string {
|
||||
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,
|
||||
paddingVertical: Spacing.xs - 1,
|
||||
borderRadius: Radius.sm,
|
||||
},
|
||||
text: { fontSize: FontSize.tiny, fontWeight: FontWeight.bold },
|
||||
uppercase: { textTransform: 'uppercase', letterSpacing: 0.5 },
|
||||
uppercase: { textTransform: 'uppercase', letterSpacing: LetterSpacing.sm },
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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';
|
||||
import { FontSize, FontWeight, MIN_TOUCH, MIN_TOUCH_SM, Radius, Spacing } from '@/lib/theme';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'danger' | 'tertiary';
|
||||
type Size = 'md' | 'sm';
|
||||
@@ -36,8 +36,9 @@ export default function Button({
|
||||
}: ButtonProps) {
|
||||
const isDisabled = disabled || loading;
|
||||
const palette = VARIANTS[variant];
|
||||
const minHeight = size === 'sm' ? 40 : MIN_TOUCH;
|
||||
const minHeight = size === 'sm' ? MIN_TOUCH_SM : MIN_TOUCH;
|
||||
const fontSize = size === 'sm' ? FontSize.sm : FontSize.md;
|
||||
const iconSize = fontSize + Spacing.xs - 1;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -64,7 +65,7 @@ export default function Button({
|
||||
<ActivityIndicator color={palette.text} size="small" />
|
||||
) : (
|
||||
<View style={styles.content}>
|
||||
{icon && <Ionicons name={icon} size={fontSize + 3} color={palette.text} />}
|
||||
{icon && <Ionicons name={icon} size={iconSize} color={palette.text} />}
|
||||
<Text style={[styles.label, { color: palette.text, fontSize }]}>{title}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
View,
|
||||
type AccessibilityRole,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from 'react-native';
|
||||
import { Colors } from '@/lib/colors';
|
||||
import { Radius, Shadow, Spacing } from '@/lib/theme';
|
||||
|
||||
@@ -9,18 +16,31 @@ interface CardProps {
|
||||
padding?: number;
|
||||
/** Légère ombre portée. */
|
||||
elevated?: boolean;
|
||||
/** Libellé d'accessibilité (lecteurs d'écran), utile sur une carte cliquable. */
|
||||
accessibilityLabel?: string;
|
||||
/** Rôle d'accessibilité (défaut « button » si cliquable). */
|
||||
accessibilityRole?: AccessibilityRole;
|
||||
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) {
|
||||
export default function Card({
|
||||
children,
|
||||
onPress,
|
||||
padding = Spacing.lg,
|
||||
elevated = false,
|
||||
accessibilityLabel,
|
||||
accessibilityRole,
|
||||
style,
|
||||
}: CardProps) {
|
||||
const content = [styles.card, { padding }, elevated && Shadow.card, style];
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityRole={accessibilityRole ?? 'button'}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
style={({ pressed }) => [content, pressed && styles.pressed]}
|
||||
>
|
||||
{children}
|
||||
@@ -28,7 +48,11 @@ export default function Card({ children, onPress, padding = Spacing.lg, elevated
|
||||
);
|
||||
}
|
||||
|
||||
return <View style={content}>{children}</View>;
|
||||
return (
|
||||
<View style={content} accessibilityRole={accessibilityRole} accessibilityLabel={accessibilityLabel}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { FontSize, FontWeight, ICON_CIRCLE, IconSize, LineHeight, Radius, Spacing, Tint } from '@/lib/theme';
|
||||
import Button from './Button';
|
||||
|
||||
interface EmptyStateProps {
|
||||
@@ -17,7 +17,7 @@ export default function EmptyState({ icon = 'document-text-outline', title, mess
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.iconCircle}>
|
||||
<Ionicons name={icon} size={32} color={Colors.primary} />
|
||||
<Ionicons name={icon} size={IconSize.xl} color={Colors.primary} />
|
||||
</View>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{message ? <Text style={styles.message}>{message}</Text> : null}
|
||||
@@ -33,15 +33,15 @@ export default function EmptyState({ icon = 'document-text-outline', title, mess
|
||||
const styles = StyleSheet.create({
|
||||
container: { alignItems: 'center', justifyContent: 'center', paddingVertical: Spacing.xxxl, paddingHorizontal: Spacing.xl },
|
||||
iconCircle: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
width: ICON_CIRCLE,
|
||||
height: ICON_CIRCLE,
|
||||
borderRadius: Radius.pill,
|
||||
backgroundColor: '#E3E3FD',
|
||||
backgroundColor: Tint.primary,
|
||||
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 },
|
||||
message: { fontSize: FontSize.sm, color: Colors.gray[500], textAlign: 'center', marginTop: Spacing.sm, lineHeight: LineHeight.normal },
|
||||
action: { marginTop: Spacing.xl },
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ 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}>
|
||||
<View style={styles.container} accessibilityRole="progressbar" accessibilityLabel={label ?? 'Chargement en cours'}>
|
||||
<ActivityIndicator size="large" color={Colors.primary} />
|
||||
{label ? <Text style={styles.label}>{label}</Text> : null}
|
||||
</View>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { StyleSheet, Text } from 'react-native';
|
||||
import { Colors } from '@/lib/colors';
|
||||
import { FontSize, FontWeight, Spacing } from '@/lib/theme';
|
||||
import { FontSize, FontWeight, LetterSpacing, 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>;
|
||||
return (
|
||||
<Text style={styles.title} accessibilityRole="header">
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
@@ -13,7 +17,7 @@ const styles = StyleSheet.create({
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.gray[500],
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
letterSpacing: LetterSpacing.md,
|
||||
marginBottom: Spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Colors } from '@/lib/colors';
|
||||
import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme';
|
||||
import { FontSize, FontWeight, HIT_SLOP, IconSize, MIN_TOUCH, Radius, Spacing } from '@/lib/theme';
|
||||
|
||||
interface TextFieldProps {
|
||||
label: string;
|
||||
@@ -53,7 +54,9 @@ export default function TextField({
|
||||
style,
|
||||
}: TextFieldProps) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const borderColor = error ? Colors.error : focused ? Colors.primary : Colors.gray[600];
|
||||
const isSecure = secureTextEntry && !revealed;
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
@@ -61,31 +64,53 @@ export default function TextField({
|
||||
{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,
|
||||
]}
|
||||
/>
|
||||
<View style={styles.inputRow}>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={Colors.gray[400]}
|
||||
secureTextEntry={isSecure}
|
||||
keyboardType={keyboardType}
|
||||
autoCapitalize={autoCapitalize}
|
||||
autoComplete={autoComplete}
|
||||
multiline={multiline}
|
||||
numberOfLines={numberOfLines}
|
||||
editable={editable}
|
||||
accessibilityLabel={label}
|
||||
accessibilityState={{ disabled: !editable }}
|
||||
style={[
|
||||
styles.input,
|
||||
{ borderBottomColor: borderColor },
|
||||
secureTextEntry && styles.inputWithIcon,
|
||||
multiline && styles.multiline,
|
||||
!editable && styles.readonly,
|
||||
]}
|
||||
/>
|
||||
{secureTextEntry ? (
|
||||
<Pressable
|
||||
onPress={() => setRevealed((r) => !r)}
|
||||
hitSlop={HIT_SLOP}
|
||||
style={styles.toggle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={revealed ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
|
||||
>
|
||||
<Ionicons
|
||||
name={revealed ? 'eye-off-outline' : 'eye-outline'}
|
||||
size={IconSize.lg}
|
||||
color={Colors.gray[500]}
|
||||
/>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
{error ? (
|
||||
<View style={styles.messageRow}>
|
||||
<Ionicons name="alert-circle" size={14} color={Colors.error} />
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<Ionicons name="alert-circle" size={IconSize.sm} color={Colors.error} />
|
||||
<Text style={styles.errorText} accessibilityLiveRegion="polite">
|
||||
{error}
|
||||
</Text>
|
||||
</View>
|
||||
) : helper ? (
|
||||
<Text style={styles.helperText}>{helper}</Text>
|
||||
@@ -103,6 +128,7 @@ const styles = StyleSheet.create({
|
||||
marginBottom: Spacing.xs,
|
||||
},
|
||||
required: { color: Colors.error },
|
||||
inputRow: { position: 'relative', justifyContent: 'center' },
|
||||
input: {
|
||||
backgroundColor: Colors.gray[50],
|
||||
borderTopLeftRadius: Radius.sm,
|
||||
@@ -112,9 +138,16 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: Spacing.md,
|
||||
fontSize: FontSize.body,
|
||||
color: Colors.black,
|
||||
minHeight: 44,
|
||||
minHeight: MIN_TOUCH,
|
||||
},
|
||||
multiline: { minHeight: 110, textAlignVertical: 'top' },
|
||||
inputWithIcon: { paddingRight: Spacing.xxl + Spacing.md },
|
||||
toggle: {
|
||||
position: 'absolute',
|
||||
right: Spacing.md,
|
||||
height: MIN_TOUCH,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
multiline: { minHeight: MIN_TOUCH * 2.5, 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 },
|
||||
|
||||
Reference in New Issue
Block a user