Semaine 6 — Projet de synthèse
Construire une bibliothèque testée, accessible et documentée
1. Design system thinking
Tokens, variantes, composition — une API cohérente pour les développeurs
2. Accessibilité web (a11y)
ARIA, navigation clavier, contraste, screen readers
3. Production-ready
Savoir quand un composant est prêt : props typées, tests, a11y, documentation
4. Combiner les patterns
Assembler testing + performance + patterns avancés dans un projet concret
1. Recap semaine 6
Testing, performance, patterns avancés
2. Design system thinking
Cohérence, tokens, variantes, slots
3. Accessibilité (a11y)
ARIA, navigation clavier, contraste, screen readers
4. Checklist de qualité
Un composant est prêt quand…
5. Mini-projet : bibliothèque de composants
Démo complète d'une lib testée, accessible et documentée
Recap semaine 6
Testing · Performance · Patterns avancés
Vitest
Runner rapide, compatible Jest API, hot module reload
React Testing Library
Tester le comportement, pas l'implémentation — queries par rôle
MSW
Mock Service Worker — intercepter les requêtes réseau sans modifier le code
Principe clé : tester comme un utilisateur interagit — pas comme un développeur lit le code
React.memo
Éviter les re-renders si les props n'ont pas changé
useMemo
Mémoriser un calcul coûteux entre les renders
useCallback
Stabiliser une référence de fonction pour les enfants memoized
React.lazy + Suspense
Code-splitting : charger les composants à la demande
Règle d'or : ne jamais optimiser prématurément — mesurer d'abord avec React DevTools Profiler
Custom hooks
Extraire la logique réutilisable dans une fonction use…
Compound components
API déclarative avec Context interne (Radix, shadcn/ui)
Composants headless
Logique + a11y sans imposer de style
Aujourd'hui : on combine TOUT pour construire une bibliothèque de composants robuste
Design System Thinking
Cohérence · Tokens · Variantes · Composition
Un design system n'est PAS juste du CSS
C'est une API cohérente pour les développeurs — un contrat partagé entre design et code
Ce que c'est
Ce que ce n'est pas
Les tokens définissent les valeurs de base — couleurs, spacing, typographie, border-radius
Exemples de tokens
Pourquoi des tokens ?
Définir les tokens comme un objet typé — autocomplétion garantie
// tokens.ts — source de vérité
export const tokens = {
colors: {
primary: '#6366f1', // indigo-500
danger: '#ef4444', // red-500
success: '#10b981', // emerald-500
neutral: '#64748b', // slate-500
},
spacing: {
xs: '4px', sm: '8px', md: '16px', lg: '24px', xl: '32px',
},
radius: {
sm: '4px', md: '8px', lg: '12px', full: '9999px',
},
fontSize: {
sm: '0.875rem', base: '1rem', lg: '1.125rem', xl: '1.25rem',
},
} as const
as const rend toutes les valeurs littérales — TypeScript les considère comme des types exacts, pas des string
Chaque composant expose des variantes qui contrôlent son apparence et son comportement
// Button.tsx — props avec variantes
interface ButtonProps {
variant: 'primary' | 'secondary' | 'danger' | 'ghost'
size: 'sm' | 'md' | 'lg'
disabled?: boolean
loading?: boolean
children: React.ReactNode
}
Les variantes forment un contrat explicite — l'utilisateur du composant sait exactement quelles combinaisons sont possibles
// Map de styles par variante
const variantStyles: Record<ButtonProps['variant'], string> = {
primary: 'bg-indigo-600 hover:bg-indigo-700 text-white',
secondary: 'bg-slate-700 hover:bg-slate-600 text-white',
danger: 'bg-red-600 hover:bg-red-700 text-white',
ghost: 'border border-slate-600 hover:bg-slate-800 text-slate-300',
}
const sizeStyles: Record<ButtonProps['size'], string> = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
}
export function Button({ variant, size, disabled, loading, children }: ButtonProps) {
return (
<button
className={`rounded-lg font-semibold transition ${variantStyles[variant]} ${sizeStyles[size]}`}
disabled={disabled || loading}
aria-busy={loading}
>
{loading ? 'Chargement…' : children}
</button>
)
}
Préférer les composants composables aux composants ultra-configurables
✗ ERREUR — sur-configuration
<Card
title="Mon titre"
subtitle="Sous-titre"
icon="star"
footer={<Button>OK</Button>}
headerAction={<IconButton />}
borderColor="indigo"
showDivider={true}
/>
// 15 props → impossible à maintenir
✓ CORRECT — composition
<Card>
<Card.Header>
<Card.Title>Mon titre</Card.Title>
<IconButton />
</Card.Header>
<Card.Body>Contenu</Card.Body>
<Card.Footer>
<Button>OK</Button>
</Card.Footer>
</Card>
// Flexible, lisible, extensible
✗ ERREUR — trop spécifique
// Un composant pour UN seul cas d'usage
function UserProfileCard() {
return (
<div className="bg-white p-4">
<img src={user.avatar} />
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
)
}
// Inutilisable ailleurs
✓ CORRECT — composable
// Composants génériques réutilisables
<Card>
<Avatar src={user.avatar} size="md" />
<Text variant="heading">
{user.name}
</Text>
<Text variant="body" color="muted">
{user.email}
</Text>
</Card>
// Chaque pièce est réutilisable
Accessibilité (a11y)
ARIA · Navigation clavier · Contraste · Screen readers
15%
de la population mondiale vit avec un handicap
100%
des utilisateurs bénéficient d'une bonne a11y (clavier, mobile, etc.)
Loi
RGAA en France, ADA aux USA — obligation légale
L'a11y n'est pas optionnelle — c'est un critère de qualité
ARIA = Accessible Rich Internet Applications — des attributs pour enrichir la sémantique HTML
Attributs de rôle
role="button" — élément cliquable
role="dialog" — fenêtre modale
role="alert" — message important
role="tab" / "tabpanel" — onglets
Attributs d'état
aria-expanded="true" — ouvert/fermé
aria-hidden="true" — masqué au SR
aria-label="Fermer" — label invisible
aria-disabled="true" — désactivé
Règle d'or : préférer le HTML sémantique natif (<button>, <nav>, <dialog>) — ARIA est un correctif quand le HTML ne suffit pas
Chaque composant interactif DOIT être utilisable au clavier
Touches attendues
Erreurs fréquentes
Focus trap + Escape + aria-modal + retour du focus
function Modal({ isOpen, onClose, title, children }: ModalProps) {
const previousFocus = useRef<HTMLElement | null>(null)
useEffect(() => {
if (isOpen) {
previousFocus.current = document.activeElement as HTMLElement
// Focus le premier élément focusable dans le modal
}
return () => previousFocus.current?.focus() // Retour du focus
}, [isOpen])
if (!isOpen) return null
return (
<div role="dialog" aria-modal="true" aria-labelledby="modal-title"
onKeyDown={e => e.key === 'Escape' && onClose()}>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose} aria-label="Fermer">✕</button>
</div>
)
}
Le ratio de contraste minimum entre le texte et le fond
WCAG AA (minimum)
WCAG AAA (idéal)
Blanc sur indigo
Ratio ≈ 4.6:1 ✓
Blanc sur jaune
Ratio ≈ 1.1:1 ✗
Gris clair sur dark
Ratio ≈ 11:1 ✓
Un lecteur d'écran parcourt le DOM sémantique — pas le visuel
Ce que le SR annonce :
"Bouton, Supprimer l'élément" — rôle + label accessible
Ce que l'utilisateur voit :
Un bouton rouge avec une icône poubelle
HTML sémantique = gratuit
Divs = silence
Les queries getByRole vérifient implicitement les rôles ARIA
// Si getByRole trouve l'élément, c'est qu'il est accessible !
import { render, screen } from '@testing-library/react'
it('le bouton est accessible', () => {
render(<Button variant="primary" size="md">Sauvegarder</Button>)
// ✓ Vérifie le rôle "button" ET le texte accessible
const btn = screen.getByRole('button', { name: 'Sauvegarder' })
expect(btn).toBeInTheDocument()
})
it('le modal a le bon rôle', () => {
render(<Modal isOpen title="Confirmer" onClose={vi.fn()}>...</Modal>)
// ✓ Vérifie role="dialog" ET aria-labelledby
const dialog = screen.getByRole('dialog', { name: 'Confirmer' })
expect(dialog).toBeInTheDocument()
})
Détecter les violations d'accessibilité automatiquement dans vos tests
// setup : npm install -D vitest-axe
import { axe, toHaveNoViolations } from 'vitest-axe'
expect.extend(toHaveNoViolations)
it('le composant n\'a aucune violation a11y', async () => {
const { container } = render(<Alert variant="warning">Attention !</Alert>)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
axe-core teste : contraste, labels manquants, rôles incorrects, structure des titres, focus order…
✗ ERREUR — div avec onClick
<div
className="cursor-pointer bg-blue-500"
onClick={handleDelete}
>
Supprimer
</div>
✗ Pas focusable au clavier
✗ Pas de rôle "button"
✗ Enter/Space ne marche pas
✗ Screen reader l'ignore
✓ CORRECT — button natif
<button
className="bg-blue-500 rounded px-4 py-2"
onClick={handleDelete}
aria-label="Supprimer l'élément"
>
Supprimer
</button>
✓ Focusable nativement
✓ Rôle "button" automatique
✓ Enter + Space activent le click
✓ Screen reader annonce le bouton
Checklist de qualité
Un composant est prêt quand…
1. Props typées
Interface TypeScript, valeurs par défaut, documentation JSDoc
2. Tests
Comportement, variantes, cas limites, a11y (axe-core)
3. Accessible
Navigable au clavier, rôles ARIA, contraste suffisant
4. Tous les états
hover, focus, disabled, loading, error, empty
5. Documenté
Page démo style Storybook avec exemples de chaque variante
6. Performant
Pas de re-render inutile, memo si nécessaire
Si UN seul critère manque, le composant n'est pas prêt pour la production
// Alert.tsx — typage strict avec documentation
interface AlertProps {
/** Variante visuelle de l'alerte */
variant: 'info' | 'success' | 'warning' | 'error'
/** Titre optionnel affiché en gras */
title?: string
/** Contenu principal */
children: React.ReactNode
/** Si true, affiche un bouton pour fermer */
dismissible?: boolean
/** Callback quand l'utilisateur ferme l'alerte */
onDismiss?: () => void
}
Bonnes pratiques : JSDoc sur chaque prop, union types pour les variantes, optional pour les props facultatives, valeurs par défaut via destructuring
À tester
À NE PAS tester
Tester le COMPORTEMENT visible par l'utilisateur — pas l'implémentation
Un composant sans ses états est un composant incomplet
Default
Hover / Focus
Disabled
Loading
Error
Success
Piège : oublier les états loading et error — l'utilisateur ne sait pas ce qui se passe
Mini-projet : Bibliothèque de composants
Démo complète d'une lib testée, accessible et documentée
// Architecture recommandée
src/
components/
Button/
Button.tsx ← composant
Button.test.tsx ← tests
Button.stories.tsx ← démo / docs
index.ts ← export
Alert/
Alert.tsx
Alert.test.tsx
Alert.stories.tsx
index.ts
Modal/
...
tokens/
tokens.ts ← design tokens
index.ts ← barrel export
Tokens + variantes + accessibilité + tous les états
// Alert.tsx
const variantConfig = {
info: { bg: 'bg-blue-900/40', border: 'border-blue-500', icon: 'ℹ️', role: 'status' },
success: { bg: 'bg-emerald-900/40', border: 'border-emerald-500', icon: '✓', role: 'status' },
warning: { bg: 'bg-yellow-900/40', border: 'border-yellow-500', icon: '⚠', role: 'alert' },
error: { bg: 'bg-red-900/40', border: 'border-red-500', icon: '✗', role: 'alert' },
} as const
export function Alert({ variant, title, children, dismissible, onDismiss }: AlertProps) {
const config = variantConfig[variant]
return (
<div
role={config.role}
className={`p-4 rounded-lg border-l-4 ${config.bg} ${config.border}`}
>
<span aria-hidden="true">{config.icon}</span>
{title && <strong>{title}</strong>}
<p>{children}</p>
{dismissible && (
<button onClick={onDismiss} aria-label="Fermer l'alerte">✕</button>
)}
</div>
)
}
// Alert.test.tsx
import { render, screen } from '@testing-library/react'
import { axe, toHaveNoViolations } from 'vitest-axe'
expect.extend(toHaveNoViolations)
describe('Alert', () => {
it('affiche le contenu et le titre', () => {
render(<Alert variant="info" title="Info">Message</Alert>)
expect(screen.getByText('Info')).toBeInTheDocument()
expect(screen.getByText('Message')).toBeInTheDocument()
})
it('error/warning ont role="alert"', () => {
render(<Alert variant="error">Erreur !</Alert>)
expect(screen.getByRole('alert')).toBeInTheDocument()
})
it('le bouton dismiss appelle onDismiss', async () => {
const onDismiss = vi.fn()
render(<Alert variant="info" dismissible onDismiss={onDismiss}>X</Alert>)
await userEvent.click(screen.getByRole('button', { name: "Fermer l'alerte" }))
expect(onDismiss).toHaveBeenCalledOnce()
})
it('n\'a aucune violation a11y', async () => {
const { container } = render(<Alert variant="warning">Attention</Alert>)
expect(await axe(container)).toHaveNoViolations()
})
})
Chaque composant a une page démo avec toutes ses variantes et états
// Alert.stories.tsx — démo interactive
export default { title: 'Components/Alert', component: Alert }
export const Info = () => (
<Alert variant="info" title="Information">
Votre profil a été mis à jour.
</Alert>
)
export const Error = () => (
<Alert variant="error" title="Erreur" dismissible>
Impossible de sauvegarder. Veuillez réessayer.
</Alert>
)
export const AllVariants = () => (
<div className="space-y-4">
{['info', 'success', 'warning', 'error'].map(v => (
<Alert key={v} variant={v} title={v}>Exemple {v}</Alert>
))}
</div>
)
1. Style sans comportement
Se concentrer sur l'apparence et oublier le clavier, les screen readers, et les états loading/error
2. Composants trop spécifiques
Créer UserProfileCard au lieu de Card + Avatar + Text composables
3. Pas de cas limites
Texte très long, liste vide, props manquantes, contenu dynamique — jamais testés
4. États manquants
hover, focus, disabled, loading, error, empty — au moins un oublié dans chaque composant non vérifié
✗ ERREUR — beau mais inaccessible
function Dropdown({ items }) {
const [open, setOpen] = useState(false)
return (
<div onClick={() => setOpen(!open)}>
Choisir ▼
{open && items.map(...)}
</div>
)
}
// Pas de focus, pas de clavier, pas d'ARIA
✓ CORRECT — comportement complet
function Dropdown({ items, label }) {
return (
<button
aria-expanded={open}
aria-haspopup="listbox"
onKeyDown={handleKeyDown}
>
{label} ▼
</button>
<ul role="listbox">...</ul>
)
}
// Clavier + ARIA + focus management
✗ ERREUR — tests uniquement "happy path"
it('affiche la liste', () => {
render(<List items={['a', 'b']} />)
expect(screen.getByText('a')).toBeInTheDocument()
})
// Et si items est vide ? null ? très long ?
✓ CORRECT — cas limites couverts
it('affiche "Aucun élément" si vide', () => {
render(<List items={[]} />)
expect(screen.getByText('Aucun élément'))
})
it('tronque les textes très longs', () => {
render(<List items={['a'.repeat(500)]} />)
// Vérifier que le layout ne casse pas
})
Construire une bibliothèque de 3-5 composants :
Composants suggérés
Critères d'évaluation
Définir les tokens
Couleurs, spacing, radius, typography — tokens.ts
Définir l'interface (props)
Quelles variantes ? Quels états ? Quelle API ?
Écrire les tests en premier (TDD)
Tester le comportement attendu AVANT d'implémenter
Implémenter le composant
Tokens + variantes + ARIA + clavier — les tests guident
Documenter avec une page démo
Toutes les variantes et états visibles sur une seule page
Design system ≠ CSS
C'est une API cohérente : tokens + variantes + composition = contrat partagé
Chaque composant doit avoir
Props typées, tests, accessibilité clavier, documentation — sinon pas prêt
Tokens = cohérence
Couleurs, spacing, border-radius définis à UN seul endroit — plus de valeurs magiques
getByRole = test d'a11y gratuit
Si la query trouve l'élément, c'est que son rôle ARIA est correct — vérifié implicitement
La semaine en un mot : QUALITÉ
Un composant production-ready combine : TypeScript strict + tests complets + a11y native + tous les états gérés + documentation claire