diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx
index 0ea7e14..40b97d3 100644
--- a/src/app/(tabs)/index.tsx
+++ b/src/app/(tabs)/index.tsx
@@ -1,14 +1,26 @@
+import { useRouter } from 'expo-router';
import { StyleSheet, Text, View } from 'react-native';
+import { Button } from '@/components/Button';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
export default function HomeTabScreen() {
+ const router = useRouter();
+
return (
Accueil
Base ecran accueil tab
+
+
);
}
@@ -29,4 +41,7 @@ const styles = StyleSheet.create({
...typography.body,
color: colors.textSecondary,
},
+ actions: {
+ marginTop: spacing[16],
+ },
});
diff --git a/src/app/index-old.tsx b/src/app/index-old.tsx
index ac3aad7..df5f3d7 100644
--- a/src/app/index-old.tsx
+++ b/src/app/index-old.tsx
@@ -1,226 +1,144 @@
-import { ArrowUp, ArrowUpRight, Plus } from 'lucide-react-native';
-import { useCallback, useMemo, useState } from 'react';
-import { ScrollView, StyleSheet, Text, View } from 'react-native';
+import { Mail } from 'lucide-react-native';
+import { useMemo, useState } from 'react';
+import { StyleSheet, Text, View } from 'react-native';
-import { Badge } from '@/components/Badge';
-import { Button } from '@/components/Button';
-import { ParticipationSegment } from '@/components/ParticipationSegment';
-import { ThemeChip } from '@/components/ThemeChip';
+import { FormField, Input, PasswordInput, SearchInput, TextArea } from '@/components/form';
+import { Screen } from '@/components/ui/Screen';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
-export default function Index() {
- const [selectedThemes, setSelectedThemes] = useState(['Soirée', 'Techno', 'Festival']);
+function computePasswordStrength(value: string): 0 | 1 | 2 | 3 {
+ if (!value) {
+ return 0;
+ }
- const themeOptions = useMemo(
- () => ['Soirée', 'Techno', 'Festival', 'Fête de jeunesse', 'Concert', 'Bal', 'Afterwork', 'Gratuit'],
- [],
- );
+ const hasLower = /[a-z]/.test(value);
+ const hasUpper = /[A-Z]/.test(value);
+ const hasNumber = /\d/.test(value);
+ const hasSpecial = /[^A-Za-z0-9]/.test(value);
- const simulateSubmit = useCallback(async () => {
- await new Promise((resolve) => {
- setTimeout(resolve, 1300);
- });
- }, []);
+ const criteria = [hasLower, hasUpper, hasNumber, hasSpecial].filter(Boolean)
+ .length;
- const handleThemeSelect = useCallback((value: string, nextSelected: boolean) => {
- setSelectedThemes((prev) => {
- if (nextSelected) {
- if (prev.includes(value)) {
- return prev;
- }
+ if (value.length < 6 || criteria <= 1) {
+ return 1;
+ }
- return [...prev, value];
- }
+ if (value.length >= 10 && criteria >= 3) {
+ return 3;
+ }
- return prev.filter((theme) => theme !== value);
- });
- }, []);
+ return 2;
+}
+
+export default function InputExamplesScreen() {
+ const [email, setEmail] = useState('lea.mercier@example.ch');
+ const [password, setPassword] = useState('');
+ const [search, setSearch] = useState('néon');
+ const [debouncedSearch, setDebouncedSearch] = useState('néon');
+ const [description, setDescription] = useState('');
+
+ const emailError = useMemo(() => {
+ if (email.length === 0) {
+ return 'Adresse e-mail requise';
+ }
+
+ const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+ return isValid ? undefined : 'Adresse e-mail invalide';
+ }, [email]);
+
+ const passwordStrength = useMemo(() => computePasswordStrength(password), [password]);
return (
-
-
+
+ DESIGN SYSTEM · FORM INPUTS
+ Page d'exemple des champs
+
+
+
-
-
- BOUTONS · HAUTEUR 52 PX · RAYON 14 PX
-
-
+ }
+ />
+
-
-
-
- Primaire
-
-
-
- Pressé (tap)
-
-
+
+
+
-
-
-
- Secondaire
-
-
-
- Désactivé
-
-
+
+
+
-
-
-
- Texte
-
-
-
- Chargement
-
-
+
+
+
-
-
-
- Destructif
-
-
-
-
-
-
- Icône / FAB
-
-
-
-
- PARTICIPATION
-
-
-
-
-
-
-
- BADGES
-
-
-
-
-
-
-
-
-
-
-
-
- CHIPS DE THÈME · CAPSULE 34 PX
-
-
-
-
-
-
-
-
-
-
-
-
-
- THÈMES · MULTI-SÉLECTION
-
- {themeOptions.map((theme) => {
- const selected = selectedThemes.includes(theme);
-
- return (
-
- );
- })}
-
-
-
-
+
+ undefined}
+ />
+
+
);
}
const styles = StyleSheet.create({
- screen: {
- flex: 1,
- },
content: {
- paddingBottom: 40,
+ gap: spacing[16],
+ paddingTop: spacing[24],
},
- row: {
- flexDirection: 'row',
- gap: 14,
+ header: {
+ gap: spacing[8],
+ marginBottom: spacing[8],
},
- cell: {
- flex: 1,
- gap: 10,
+ kicker: {
+ ...typography.sectionLabel,
+ color: colors.textSecondary,
},
- caption: {
- textAlign: 'center',
- },
- iconRow: {
- flexDirection: 'row',
- justifyContent: 'center',
- gap: 12,
- },
- autoWidthButton: {
- alignSelf: 'center',
- width: 140,
- },
- badgeWrap: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- gap: 12,
- alignItems: 'center',
- },
- chipWrap: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- gap: 10,
- alignItems: 'center',
+ title: {
+ ...typography.body,
+ color: colors.textPrimary,
},
});
diff --git a/src/components/form/FormField.tsx b/src/components/form/FormField.tsx
new file mode 100644
index 0000000..a4e7e37
--- /dev/null
+++ b/src/components/form/FormField.tsx
@@ -0,0 +1,110 @@
+import type { ReactNode } from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+
+import { colors } from '@/theme/colors';
+import { fontFamily, typography } from '@/theme/typography';
+
+export type FieldCounter = {
+ value: number;
+ max: number;
+};
+
+export type FormFieldProps = {
+ label?: string;
+ required?: boolean;
+ helper?: string;
+ error?: string;
+ counter?: FieldCounter;
+ children: ReactNode;
+};
+
+const FormField = ({
+ label,
+ required = false,
+ helper,
+ error,
+ counter,
+ children,
+}: FormFieldProps) => {
+ const message = error ?? helper;
+ const isCounterOverflow =
+ typeof counter?.value === 'number' && typeof counter?.max === 'number'
+ ? counter.value > counter.max
+ : false;
+
+ return (
+
+ {label ? (
+
+ {label}
+ {required ? * : null}
+
+ ) : null}
+
+ {children}
+
+ {message || counter ? (
+
+ {message ? (
+
+ {message}
+
+ ) : (
+
+ )}
+
+ {counter ? (
+
+ {counter.value} / {counter.max}
+
+ ) : null}
+
+ ) : null}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ gap: 8,
+ },
+ label: {
+ ...typography.inputLabel,
+ color: colors.textBody,
+ },
+ required: {
+ color: colors.primary,
+ },
+ bottomRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'flex-start',
+ gap: 8,
+ },
+ message: {
+ ...typography.helper,
+ color: colors.textSecondary,
+ flex: 1,
+ },
+ errorMessage: {
+ color: colors.danger,
+ },
+ messageSpacer: {
+ flex: 1,
+ },
+ counter: {
+ ...typography.metadataSm,
+ color: colors.textSecondary,
+ fontFamily: fontFamily.mono,
+ },
+ counterError: {
+ color: colors.danger,
+ },
+});
+
+export default FormField;
diff --git a/src/components/form/Input.tsx b/src/components/form/Input.tsx
new file mode 100644
index 0000000..0fdd800
--- /dev/null
+++ b/src/components/form/Input.tsx
@@ -0,0 +1,143 @@
+import type { ReactNode } from 'react';
+import {
+ forwardRef,
+ useImperativeHandle,
+ useRef,
+ useState,
+} from 'react';
+import {
+ Pressable,
+ StyleSheet,
+ TextInput,
+ View,
+ type StyleProp,
+ type TextInputProps,
+ type ViewStyle,
+} from 'react-native';
+
+import { colors } from '@/theme/colors';
+import { typography } from '@/theme/typography';
+
+export type InputSize = 'md' | 'lg';
+export type InputState = 'default' | 'error' | 'success';
+
+export type InputProps = Omit & {
+ size?: InputSize;
+ state?: InputState;
+ left?: ReactNode;
+ right?: ReactNode;
+ containerStyle?: StyleProp;
+};
+
+const HEIGHT_BY_SIZE: Record = {
+ md: 48,
+ lg: 56,
+};
+
+const Input = forwardRef(function Input(
+ {
+ size = 'md',
+ state = 'default',
+ left,
+ right,
+ containerStyle,
+ editable = true,
+ onFocus,
+ onBlur,
+ placeholderTextColor = colors.inputPlaceholder,
+ ...textInputProps
+ },
+ ref,
+) {
+ const [isFocused, setIsFocused] = useState(false);
+ const inputRef = useRef(null);
+
+ useImperativeHandle(ref, () => inputRef.current as TextInput);
+
+ const handleFocus: NonNullable = (event) => {
+ setIsFocused(true);
+ onFocus?.(event);
+ };
+
+ const handleBlur: NonNullable = (event) => {
+ setIsFocused(false);
+ onBlur?.(event);
+ };
+
+ const borderColor =
+ state === 'error'
+ ? colors.danger
+ : isFocused
+ ? colors.inputBorderFocus
+ : state === 'success'
+ ? colors.success
+ : colors.inputBorder;
+
+ return (
+ {
+ if (editable) {
+ inputRef.current?.focus();
+ }
+ }}
+ style={[
+ styles.container,
+ {
+ height: HEIGHT_BY_SIZE[size],
+ borderColor,
+ backgroundColor: editable ? colors.inputBackground : colors.surface,
+ opacity: editable ? 1 : 0.5,
+ },
+ isFocused && state !== 'error' ? styles.focusRing : null,
+ containerStyle,
+ ]}
+ >
+ {left ? {left} : null}
+
+ {right ? {right} : null}
+
+ );
+});
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ borderWidth: 1,
+ borderRadius: 14,
+ paddingHorizontal: 14,
+ gap: 10,
+ },
+ input: {
+ ...typography.input,
+ flex: 1,
+ alignSelf: 'stretch',
+ height: '100%',
+ color: colors.textPrimary,
+ paddingVertical: 0,
+ },
+ slot: {
+ minWidth: 20,
+ minHeight: 20,
+ alignItems: 'center',
+ justifyContent: 'center',
+ alignSelf: 'center',
+ },
+ focusRing: {
+ shadowColor: colors.focusRing,
+ shadowOffset: { width: 0, height: 0 },
+ shadowOpacity: 1,
+ shadowRadius: 10,
+ elevation: 2,
+ },
+});
+
+export default Input;
diff --git a/src/components/form/PasswordInput.tsx b/src/components/form/PasswordInput.tsx
new file mode 100644
index 0000000..8c2eb71
--- /dev/null
+++ b/src/components/form/PasswordInput.tsx
@@ -0,0 +1,117 @@
+import { Eye, EyeOff } from 'lucide-react-native';
+import { forwardRef, useState } from 'react';
+import {
+ Pressable,
+ StyleSheet,
+ TextInput,
+ View,
+ type StyleProp,
+ type ViewStyle,
+} from 'react-native';
+
+import { colors } from '@/theme/colors';
+
+import Input, { type InputProps } from './Input';
+
+export type PasswordStrength = 0 | 1 | 2 | 3;
+
+export type PasswordInputProps = Omit & {
+ strength?: PasswordStrength;
+ strengthContainerStyle?: StyleProp;
+};
+
+const SEGMENT_COUNT = 3;
+
+function getStrengthColor(strength: PasswordStrength): string {
+ if (strength <= 1) {
+ return colors.danger;
+ }
+
+ if (strength === 2) {
+ return colors.warning;
+ }
+
+ return colors.success;
+}
+
+const PasswordInput = forwardRef(
+ function PasswordInput({ strength, strengthContainerStyle, ...props }, ref) {
+ const [revealed, setRevealed] = useState(false);
+ const strengthColor =
+ typeof strength === 'number' ? getStrengthColor(strength) : colors.divider;
+
+ return (
+
+ setRevealed((prev) => !prev)}
+ style={styles.toggleButton}
+ hitSlop={8}
+ accessibilityRole="button"
+ accessibilityLabel={
+ revealed
+ ? 'Masquer le mot de passe'
+ : 'Afficher le mot de passe'
+ }
+ >
+ {revealed ? (
+
+ ) : (
+
+ )}
+
+ }
+ {...props}
+ />
+
+ {typeof strength === 'number' ? (
+
+ {Array.from({ length: SEGMENT_COUNT }).map((_, index) => (
+
+ ))}
+
+ ) : null}
+
+ );
+ },
+);
+
+const styles = StyleSheet.create({
+ wrapper: {
+ gap: 8,
+ },
+ toggleButton: {
+ width: 44,
+ height: 44,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ strengthRow: {
+ flexDirection: 'row',
+ gap: 4,
+ },
+ strengthSegment: {
+ flex: 1,
+ height: 3,
+ borderRadius: 999,
+ },
+});
+
+export default PasswordInput;
diff --git a/src/components/form/SearchInput.tsx b/src/components/form/SearchInput.tsx
new file mode 100644
index 0000000..5ca63b6
--- /dev/null
+++ b/src/components/form/SearchInput.tsx
@@ -0,0 +1,84 @@
+import { Search, X } from 'lucide-react-native';
+import {
+ forwardRef,
+ useEffect,
+ useImperativeHandle,
+ useRef,
+ type ReactNode,
+} from 'react';
+import { Pressable, StyleSheet, TextInput } from 'react-native';
+
+import { colors } from '@/theme/colors';
+
+import Input, { type InputProps } from './Input';
+
+export type SearchInputProps = Omit & {
+ onDebouncedChange?: (value: string) => void;
+ debounceMs?: number;
+};
+
+const SearchInput = forwardRef(function SearchInput(
+ { onDebouncedChange, debounceMs = 300, value, onChangeText, ...props },
+ ref,
+) {
+ const inputRef = useRef(null);
+ const valueAsString = typeof value === 'string' ? value : '';
+
+ useImperativeHandle(ref, () => inputRef.current as TextInput);
+
+ useEffect(() => {
+ if (!onDebouncedChange) {
+ return;
+ }
+
+ const timer = setTimeout(() => {
+ onDebouncedChange(valueAsString);
+ }, debounceMs);
+
+ return () => {
+ clearTimeout(timer);
+ };
+ }, [debounceMs, onDebouncedChange, valueAsString]);
+
+ const clearButton: ReactNode =
+ valueAsString.length > 0 ? (
+ {
+ onChangeText?.('');
+ inputRef.current?.clear();
+ inputRef.current?.focus();
+ }}
+ style={styles.clearButton}
+ hitSlop={8}
+ accessibilityRole="button"
+ accessibilityLabel="Vider la recherche"
+ >
+
+
+ ) : null;
+
+ return (
+ }
+ right={clearButton}
+ returnKeyType="search"
+ autoCorrect={false}
+ clearButtonMode="never"
+ {...props}
+ />
+ );
+});
+
+const styles = StyleSheet.create({
+ clearButton: {
+ width: 44,
+ height: 44,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+});
+
+export default SearchInput;
diff --git a/src/components/form/TextArea.tsx b/src/components/form/TextArea.tsx
new file mode 100644
index 0000000..93b81b5
--- /dev/null
+++ b/src/components/form/TextArea.tsx
@@ -0,0 +1,44 @@
+import { forwardRef } from 'react';
+import { TextInput, type StyleProp, type ViewStyle } from 'react-native';
+
+import { typography } from '@/theme/typography';
+
+import Input, { type InputProps } from './Input';
+
+export type TextAreaProps = Omit<
+ InputProps,
+ 'multiline' | 'textAlignVertical' | 'containerStyle'
+> & {
+ rows?: number;
+ containerStyle?: StyleProp;
+};
+
+const TextArea = forwardRef(function TextArea(
+ { rows = 4, containerStyle, ...props },
+ ref,
+) {
+ const lineHeight =
+ typeof typography.input.lineHeight === 'number'
+ ? typography.input.lineHeight
+ : 22;
+ const minHeight = rows * lineHeight + 24;
+
+ return (
+
+ );
+});
+
+export default TextArea;
diff --git a/src/components/form/index.ts b/src/components/form/index.ts
new file mode 100644
index 0000000..35d3f2d
--- /dev/null
+++ b/src/components/form/index.ts
@@ -0,0 +1,12 @@
+export { default as FormField } from './FormField';
+export { default as Input } from './Input';
+export { default as PasswordInput } from './PasswordInput';
+export { default as SearchInput } from './SearchInput';
+export { default as TextArea } from './TextArea';
+
+export type { FieldCounter, FormFieldProps } from './FormField';
+export type { InputProps, InputSize, InputState } from './Input';
+export type { PasswordInputProps, PasswordStrength } from './PasswordInput';
+export type { SearchInputProps } from './SearchInput';
+export type { TextAreaProps } from './TextArea';
+
diff --git a/src/theme/colors.ts b/src/theme/colors.ts
index 3c9c92d..67bfe59 100644
--- a/src/theme/colors.ts
+++ b/src/theme/colors.ts
@@ -46,6 +46,11 @@ export const colors = {
canvas: palette.ink950,
surface: palette.ink800,
surfaceElevated: palette.ink700,
+ inputBackground: palette.ink700,
+ inputBorder: palette.ink600,
+ inputBorderFocus: palette.violet,
+ inputPlaceholder: palette.grey400,
+ focusRing: 'rgba(123,61,255,0.22)',
border: palette.ink600,
/** Séparateur intra-carte, plus léger que `border` */
divider: 'rgba(255,255,255,0.12)',
diff --git a/src/theme/typography.ts b/src/theme/typography.ts
index e312b51..795904a 100644
--- a/src/theme/typography.ts
+++ b/src/theme/typography.ts
@@ -100,6 +100,24 @@ export const typography = {
lineHeight: 22,
letterSpacing: 0,
},
+ input: {
+ fontFamily: fontFamily.body,
+ fontSize: 16,
+ lineHeight: 22,
+ letterSpacing: 0,
+ },
+ inputLabel: {
+ fontFamily: fontFamily.bodySemi,
+ fontSize: 13,
+ lineHeight: 18,
+ letterSpacing: 0,
+ },
+ helper: {
+ fontFamily: fontFamily.body,
+ fontSize: 12,
+ lineHeight: 16,
+ letterSpacing: 0,
+ },
/** Libellé de bouton — 52 px de haut, rayon 14 px */
button: {
fontFamily: fontFamily.bodySemi,