Chapitre 4.5 — Le design system de Drill
Où on en est : NativeWind installé, tokens en place. Dernier chapitre de style : on fige le kit UI de Drill — les composants qu’on réutilisera dans TOUTES les parties (et toutes vos apps perso).
⏱️ TL;DR — Six composants suffisent à Drill : AppText (typo thémée), Screen (page + safe areas), Card, Button (3 variantes), Badge (difficulté/streak), ProgressBar. Chacun : petite API, className extensible, accessibilité incluse. C’est votre premier design system RN — le squelette de chaque app que vous ferez ensuite.
🎯 Objectifs
- Construire les 6 composants de base avec une API cohérente.
- Systématiser le pattern « variantes par mapping de classes ».
- Rendre chaque composant extensible (className) sans le rendre flou.
- Constituer un kit copiable pour vos futures apps (P19 s’en servira).
Les règles du kit
- Petites APIs : 2-4 props par composant, des variantes nommées plutôt que des booléens accumulés.
- Classes littérales en mappings (contrainte NativeWind — chapitre 4.4).
classNamepass-through en dernier : l’appelant peut ajuster les marges sans forker.- Accessibilité par défaut : roles et labels inclus DANS le kit, pas laissés aux écrans.
AppText — la typo une fois pour toutes
Pas d’héritage de police en RN (chapitre 3.1) → un composant texte à soi est la parade standard :
// components/ui/AppText.tsx
import { Text, TextProps } from 'react-native'
import { cn } from '../../lib/cn'
type Variant = 'title' | 'heading' | 'body' | 'caption' | 'mono'
const variants: Record<Variant, string> = {
title: 'text-3xl font-extrabold text-ink-100',
heading: 'text-xl font-bold text-ink-100',
body: 'text-base text-ink-100',
caption: 'text-[13px] text-ink-400',
mono: 'font-mono text-base text-brand-light', // extraits de code dans les questions
}
type Props = TextProps & { variant?: Variant; className?: string }
export function AppText({ variant = 'body', className, ...rest }: Props) {
return <Text className={cn(variants[variant], className)} {...rest} />
}// lib/cn.ts — le mini-helper (logique pure → lib/)
export function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}Désormais, la règle du cours : plus jamais <Text> nu dans un écran — toujours AppText.
Un changement de police (Partie 12, quand on soignera l’identité) = une ligne ici.
Screen — la page standard
// components/ui/Screen.tsx
import { ScrollView, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { cn } from '../../lib/cn'
type Props = {
children: React.ReactNode
scroll?: boolean // contenu borné → true ; l'écran gère un FlatList → false
className?: string
}
export function Screen({ children, scroll = false, className }: Props) {
const insets = useSafeAreaInsets()
const Wrapper = scroll ? ScrollView : View
return (
<View className="flex-1 bg-ink-950">
<Wrapper
className={cn('flex-1', className)}
style={{ paddingTop: insets.top }}
{...(scroll
? { contentContainerStyle: { padding: 16, paddingBottom: insets.bottom + 16 } }
: {})}
>
{children}
</Wrapper>
</View>
)
}Tout le savoir des chapitres 3.6 et 4.2 encapsulé : fond qui déborde sous les barres, contenu
protégé, et le choix ScrollView/View explicite. Les écrans deviennent : <Screen>…</Screen>.
Button — les 3 variantes
// components/ui/Button.tsx
import { Pressable, PressableProps } from 'react-native'
import { AppText } from './AppText'
import { cn } from '../../lib/cn'
type Variant = 'primary' | 'secondary' | 'ghost'
const base = 'min-h-[52px] flex-row items-center justify-center gap-2 rounded-xl px-5'
const variants: Record<Variant, { box: string; label: string }> = {
primary: { box: 'bg-brand active:bg-brand/85', label: 'text-white font-bold text-base' },
secondary: { box: 'border border-ink-700 bg-ink-900 active:opacity-85', label: 'text-ink-100 font-semibold text-base' },
ghost: { box: 'active:opacity-70', label: 'text-brand-light font-semibold text-base' },
}
type Props = PressableProps & {
label: string
variant?: Variant
className?: string
}
export function Button({ label, variant = 'primary', className, disabled, ...rest }: Props) {
const v = variants[variant]
return (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: !!disabled }}
disabled={disabled}
className={cn(base, v.box, disabled && 'opacity-40', className)}
{...rest}
>
<AppText className={v.label}>{label}</AppText>
</Pressable>
)
}Usage : <Button label="Lancer le quiz" onPress={start} />,
<Button label="Annuler" variant="ghost" />. L’AnswerButton du chapitre 3.3 reste à part —
c’est un composant métier (états correct/incorrect), pas un bouton générique. La frontière
kit générique / composants métier est une vraie décision d’architecture : le kit ne connaît
pas le quiz.
Card, Badge, ProgressBar
// components/ui/Card.tsx
export function Card({ className, ...rest }: ViewProps & { className?: string }) {
return <View className={cn('rounded-2xl border border-ink-700 bg-ink-900 p-4', className)} {...rest} />
}// components/ui/Badge.tsx — difficulté des questions, streak…
type Tone = 'neutral' | 'success' | 'danger' | 'streak' | 'brand'
const tones: Record<Tone, string> = {
neutral: 'bg-ink-700/50 text-ink-100',
success: 'bg-green-500/15 text-green-500',
danger: 'bg-red-500/15 text-red-500',
streak: 'bg-streak/15 text-streak',
brand: 'bg-brand/15 text-brand-light',
}
export function Badge({ label, tone = 'neutral' }: { label: string; tone?: Tone }) {
return (
<View className={cn('self-start rounded-full px-2.5 py-1', tones[tone].split(' ')[0])}>
<AppText className={cn('text-xs font-bold', tones[tone].split(' ')[1])}>{label}</AppText>
</View>
)
}// components/ui/ProgressBar.tsx — progression dans la session (question 3/10)
export function ProgressBar({ ratio }: { ratio: number }) {
return (
<View
accessibilityRole="progressbar"
accessibilityValue={{ min: 0, max: 100, now: Math.round(ratio * 100) }}
className="h-2 overflow-hidden rounded-full bg-ink-700/60"
>
<View className="h-full rounded-full bg-brand" style={{ width: `${ratio * 100}%` }} />
</View>
)
}ProgressBar illustre la cohabitation className/style : structure en classes, largeur
calculée en style (elle sera animée en Partie 10 sans changer l’API).
📱 Essaie sur ton téléphone — Montez un écran vitrine
app/kitchen-sink.tsx: chaque composant du kit dans tous ses états. Les design systems du monde entier ont cet écran — c’est votre page de test visuel permanente (et votre première vraie satisfaction esthétique du cours).
💡 Pour un dev Next.js — Ce kit, c’est votre
components/ui/de shadcn/ui, en RN. Il n’existe pas (encore) de shadcn RN dominant — les apps sérieuses écrivent ces 200 lignes. Bonne nouvelle : vous venez de le faire une fois pour toutes ; les projets guidés (P19) le copieront tel quel.
✏️ Exercices
1. Ajoutez au kit un composant Divider (filet horizontal thémé) et un Row
(flex-row items-center gap paramétrable) — les deux plus petits composants les plus rentables.
✅ Solution
export function Divider({ className }: { className?: string }) {
return <View className={cn('h-px bg-ink-700/60', className)} />
}
export function Row({ gap = 3, className, ...rest }: ViewProps & { gap?: 2 | 3 | 4; className?: string }) {
const gaps = { 2: 'gap-2', 3: 'gap-3', 4: 'gap-4' } as const
return <View className={cn('flex-row items-center', gaps[gap], className)} {...rest} />
}Noter le gap en union bornée mappée sur des classes littérales — la contrainte NativeWind
transformée en API propre.
2. Button doit accepter un état loading : spinner à la place du label, interactions
coupées, accessibilité correcte.
✅ Solution
import { ActivityIndicator } from 'react-native'
type Props = PressableProps & { label: string; variant?: Variant; loading?: boolean; className?: string }
export function Button({ loading, disabled, label, … }: Props) {
const off = disabled || loading
return (
<Pressable
disabled={off}
accessibilityState={{ disabled: !!off, busy: !!loading }}
className={cn(base, v.box, off && 'opacity-40', className)}
{...rest}
>
{loading ? <ActivityIndicator color="#fff" /> : <AppText className={v.label}>{label}</AppText>}
</Pressable>
)
}ActivityIndicator = le spinner natif. accessibilityState.busy annonce le chargement à
TalkBack.
3. Un écran a besoin d’une Card cliquable. Deux options : ajouter onPress à Card, ou
composer Pressable + Card. Laquelle choisit un design system sain, et pourquoi ?
✅ Solution
Composer : <Pressable className="active:opacity-85"><Card>…</Card></Pressable> (ou un
PressableCard métier local). Ajouter onPress à Card la transformerait en « peut-être bouton » :
double responsabilité, accessibilité ambiguë (role ?), et l’API enfle pour tous les usages non
cliquables. Un composant = un rôle ; la composition fait le reste.
🧠 Quiz
1. Pourquoi un composant AppText est-il quasi obligatoire en RN ?
Réponse
Aucun héritage de police/couleur entre composants : sans point central, chaque Text redéfinit tout (et diverge). AppText porte les variantes typographiques et le thème — un changement de police = une ligne.
2. Que encapsule le composant Screen ?
Réponse
Le fond plein écran sous les barres système, les safe areas (insets) sur le contenu, et le choix ScrollView (contenu borné) vs View (l’écran contient une FlatList) — les acquis des chapitres 3.6/4.2.
3. Pourquoi les variantes sont-elles des mappings d’objets et pas des template strings ?
Réponse
Contrainte NativeWind : les classes doivent être littérales dans le source pour être
compilées. Record<Variant, string> garde chaque classe écrite en toutes lettres, avec le
bonus du typage exhaustif.
4. Où passe la frontière entre le kit ui/ et les composants métier ?
Réponse
Le kit (Button, Card, Badge…) ne connaît rien au domaine quiz ; les composants métier (AnswerButton, ThemeCard, StreakLabel) encodent les règles du jeu et composent le kit. On peut copier ui/ dans une autre app sans emporter Drill.
5. Comment ProgressBar concilie-t-elle NativeWind et valeur dynamique ?
Réponse
Structure en className (piste, radius, couleurs) ; la largeur calculée (width: ratio%)
en prop style — le partage canonique classes déclaratives / style calculé.
👉 Partie suivante : Partie 5 — Navigation avec Expo Router — plusieurs écrans, des tabs, des paramètres : Drill devient une vraie app.