Cours JavaScript et React · Module 15: Patterns et architecture
Headless Components - Séparer la logique de la présentation
Dans cette leçon7
Le cockpit et le panneau d'ingénierie ont besoin de la même liste déroulante : ouverture, fermeture, choix d'une option, gestion du clavier et attributs d'accessibilité. Mais le cockpit veut un simple bouton, alors que les ingénieurs veulent un menu spatial avec des icônes. Deux composants, ce sont deux copies d'une logique délicate et deux endroits où des bugs peuvent se glisser. Mieux vaut un module de pilotage universel que tu branches sur n'importe quel panneau. C'est exactement l'idée des Headless Components : des composants qui contiennent toute la logique, mais zéro UI.
Que sont les Headless Components ?
Un Headless Component est un composant ou un hook qui :
- Gère l'état et la logique (ouverture/fermeture, sélection, navigation)
- N'affiche AUCUN JSX, c'est-à-dire qu'il n'a pas d'apparence propre
- Confie le contrôle de l'UI au consommateur via des props, des render props ou des hooks
Le plus souvent, c'est tout simplement un custom hook, comme useCrew de la leçon précédente, sauf qu'au lieu de données d'API il gère le comportement de l'interface. Construisons un dropdown headless étape par étape. D'abord, l'état de la liste et trois actions simples :
1// Headless Dropdown : toute la logique, zéro UI
2function useDropdown(options) {
3 const [isOpen, setIsOpen] = useState(false);
4 const [selectedIndex, setSelectedIndex] = useState(-1);
5 const [selectedOption, setSelectedOption] = useState(null);
6
7 const toggle = () => setIsOpen(prev => !prev);
8 const close = () => setIsOpen(false);
9
10 const select = (index) => {
11 setSelectedIndex(index);
12 setSelectedOption(options[index]);
13 setIsOpen(false);
14 };Rien de tout cela n'apparaît encore à l'écran : c'est de la logique pure, qui fonctionnera avec n'importe quelle apparence. Passons à la gestion du clavier, la partie la plus souvent oubliée quand on écrit un dropdown à partir de zéro :
1 // Navigation au clavier
2 const handleKeyDown = (e) => {
3 if (e.key === 'ArrowDown') {
4 e.preventDefault();
5 setSelectedIndex(prev => Math.min(prev + 1, options.length - 1));
6 } else if (e.key === 'ArrowUp') {
7 e.preventDefault();
8 setSelectedIndex(prev => Math.max(prev - 1, 0));
9 } else if (e.key === 'Enter' && isOpen && selectedIndex >= 0) {
10 e.preventDefault();
11 select(selectedIndex);
12 } else if (e.key === 'Escape') {
13 close();
14 }
15 };Les flèches déplacent l'index dans les limites de la liste, Entrée valide le choix et Échap ferme la liste. e.preventDefault() désactive les réactions par défaut du navigateur : les flèches ne font pas défiler la page, et Entrée n'ajoute pas un clic normal sur le bouton, qui rouvrirait aussitôt la liste. C'est justement ce clic qui ouvre une liste fermée, car sur un bouton, Entrée et Espace agissent comme un clic de souris. Dans cette version simplifiée, un seul index sert à la fois à la mise en surbrillance et à la sélection, alors que les bibliothèques séparent ces deux notions. Enfin, le hook renvoie tout ce dont la vue a besoin, dont deux fonctions qui renvoient des ensembles de props tout prêts :
1 return {
2 isOpen, selectedIndex, selectedOption,
3 toggle, close, select, handleKeyDown,
4 getToggleProps: () => ({
5 onClick: toggle,
6 onKeyDown: handleKeyDown,
7 'aria-expanded': isOpen,
8 'aria-haspopup': 'listbox',
9 }),
10 getOptionProps: (index) => ({
11 onClick: () => select(index),
12 'aria-selected': selectedIndex === index,
13 role: 'option',
14 }),
15 };
16}getToggleProps() fournit onClick, onKeyDown, aria-expanded et aria-haspopup, tandis que getOptionProps(index) ajoute à chaque option un role et aria-selected. Le consommateur étale ces objets avec l'opérateur spread et n'a pas à penser à l'accessibilité. On appelle ces fonctions des prop getters, et tu les retrouveras par exemple dans la bibliothèque Downshift.
Utiliser le dropdown headless avec n'importe quelle UI
La clé des headless components : le même hook, des apparences différentes. La première version est un simple bouton avec une liste, sans aucune décoration :
1// Version 1 : Dropdown simple
2function SimpleDropdown({ options, label }) {
3 const dropdown = useDropdown(options);
4
5 return (
6 <div className="simple-dropdown">
7 <button {...dropdown.getToggleProps()}>
8 {dropdown.selectedOption || label}
9 </button>
10 {dropdown.isOpen && (
11 <ul role="listbox">
12 {options.map((opt, i) => (
13 <li key={i} {...dropdown.getOptionProps(i)}>
14 {opt}
15 </li>
16 ))}
17 </ul>
18 )}
19 </div>
20 );
21}La seconde version utilise exactement le même hook, mais construit un menu spatial avec des icônes et une flèche qui indique l'état de la liste :
1// Version 2 : Dropdown spatial avec icônes
2function SpaceDropdown({ options, icons }) {
3 const dropdown = useDropdown(options);
4
5 return (
6 <div className="space-dropdown">
7 <button type="button" {...dropdown.getToggleProps()} className="space-trigger">
8 <span>{dropdown.selectedOption || 'Choisis un système'}</span>
9 <span>{dropdown.isOpen ? '▲' : '▼'}</span>
10 </button>
11 {dropdown.isOpen && (
12 <div className="space-menu">
13 {options.map((opt, i) => (
14 <div key={i} {...dropdown.getOptionProps(i)} className="space-option">
15 <span>{icons[i]}</span>
16 <span>{opt}</span>
17 </div>
18 ))}
19 </div>
20 )}
21 </div>
22 );
23}Les deux composants ne diffèrent que par leur JSX, et le hook n'a pas changé d'une seule ligne. Tu peux aussi déstructurer le résultat directement : const { isOpen, selectedOption, getToggleProps, getOptionProps } = useDropdown(options). Remarque que le déclencheur est de nouveau un <button>, même s'il ne ressemble pas du tout à celui de la première version. Le hook fournit les attributs ARIA, mais c'est toi qui choisis l'élément : une <div> ne recevrait pas le focus au clavier, et même avec tabIndex={0} et role="button", tu devrais gérer Entrée et Espace toi-même.
Headless Toggle / Disclosure
Autre exemple : un toggle headless pour construire des accordéons, des sections dépliables et des spoilers. Ce hook est court, car il ne surveille qu'une seule valeur booléenne :
1function useToggle(initialState = false) {
2 const [isOpen, setIsOpen] = useState(initialState);
3
4 return {
5 isOpen,
6 toggle: () => setIsOpen(p => !p),
7 open: () => setIsOpen(true),
8 close: () => setIsOpen(false),
9 getToggleProps: () => ({
10 onClick: () => setIsOpen(p => !p),
11 'aria-expanded': isOpen,
12 }),
13 getPanelProps: () => ({
14 role: 'region',
15 hidden: !isOpen,
16 }),
17 };
18}On va construire deux composants complètement différents sur le même interrupteur. Le premier est un accordéon qui déplie la description d'un système du vaisseau :
1// Utilisation 1 : Accordéon
2function SystemDetails({ title, children }) {
3 const { isOpen, getToggleProps, getPanelProps } = useToggle();
4
5 return (
6 <div className="accordion-item">
7 <button {...getToggleProps()}>
8 {title} {isOpen ? '−' : '+'}
9 </button>
10 <div {...getPanelProps()}>
11 {isOpen && children}
12 </div>
13 </div>
14 );
15}L'infobulle a une autre apparence, mais utilise exactement la même API du hook, donc la logique d'ouverture et de fermeture n'est pas dupliquée :
1// Utilisation 2 : infobulle
2function InfoTooltip({ content, children }) {
3 const { isOpen, getToggleProps, getPanelProps } = useToggle();
4
5 return (
6 <span className="tooltip-wrapper">
7 <button type="button" {...getToggleProps()} className="tooltip-trigger">
8 {children}
9 </button>
10 <span {...getPanelProps()} className="tooltip-content">
11 {isOpen && content}
12 </span>
13 </span>
14 );
15}getPanelProps() définit hidden et role="region" : grâce à hidden, un panneau replié disparaît aussi pour les lecteurs d'écran, et pas seulement visuellement. Le déclencheur est de nouveau un <button>, car aria-expanded n'est autorisé que sur des éléments dotés d'un rôle, comme un bouton, et un <span> ne recevrait pas le focus. Tu retrouveras la même forme d'API dans les bibliothèques headless sous le nom de Disclosure.
Headless Tabs
Les onglets sont le troisième classique. Le hook retient l'onglet actif et attribue les rôles tab et tabpanel, que les lecteurs d'écran comprennent sans description supplémentaire :
1function useTabs(tabCount, defaultTab = 0) {
2 const [activeTab, setActiveTab] = useState(defaultTab);
3
4 return {
5 activeTab,
6 setActiveTab,
7 getTabProps: (index) => ({
8 onClick: () => setActiveTab(index),
9 role: 'tab',
10 'aria-selected': activeTab === index,
11 tabIndex: activeTab === index ? 0 : -1,
12 }),
13 getPanelProps: (index) => ({
14 role: 'tabpanel',
15 hidden: activeTab !== index,
16 }),
17 isActive: (index) => activeTab === index,
18 };
19}Un tabIndex à 0 uniquement pour l'onglet actif, c'est la technique du roving tabindex : la touche Tab arrive directement sur l'onglet actif. Le pattern d'onglets de WAI-ARIA ajoute aussi la navigation avec les flèches, et c'est là que servira le paramètre tabCount, encore inutilisé pour l'instant.
Pourquoi headless ?
| Caractéristique | Composant classique | Headless Component |
|---|---|---|
| UI | Styles/JSX intégrés | Zéro, TOI tu décides |
| Réutilisabilité | Limitée à une seule apparence | Illimitée |
| Accessibilité | À implémenter à la main | Attributs aria intégrés |
| Testabilité | Nécessite un rendu | Teste le hook seul |
Headless UI dans l'écosystème React
La bibliothèque @headlessui/react (des créateurs de Tailwind CSS) fournit des composants headless prêts à l'emploi :
Listbox: dropdown avec prise en charge complète du clavierCombobox: autocomplete/rechercheDialog: modale avec focus trapDisclosure: accordéon/section dépliableMenu: menu déroulantTabs: onglets
Chacun fournit la logique, les attributs aria et la gestion du clavier, mais zéro style. C'est toi qui fournis toute l'apparence. Radix UI Primitives et React Aria d'Adobe fonctionnent de façon similaire.
Quand utiliser des composants headless ?
- Tu construis un design system : différentes équipes ont besoin des mêmes interactions, mais de styles différents
- Tu as besoin d'une accessibilité complète (a11y) : les composants headless ont des attributs aria intégrés
- Tu crées des composants utilisés dans plusieurs projets : même logique, styles différents
- Tu veux séparer les responsabilités : hook = logique, composant = apparence
Les Headless Components fonctionnent comme le système de navigation universel d'un vaisseau : la logique est la même, mais chaque panneau a une apparence différente, car chaque station a des besoins différents. Mon conseil : écris ton propre hook headless pour des interrupteurs simples comme useToggle. Un dropdown, un combobox ou une boîte de dialogue avec une gestion complète du clavier et du focus demandent beaucoup de travail, alors utilise plutôt une bibliothèque éprouvée. Dans la prochaine leçon, on donnera encore plus de contrôle à l'utilisateur du composant grâce à l'Inversion of Control, et tu verras en quoi les prop getters diffèrent d'une props collection.
Retiens : un hook headless, c'est un moteur sans coque, et la coque, c'est toi qui la dessines.
Code de cette leçon : App.jsx
1import React, { useState } from 'react';
2
3// === HOOK HEADLESS : useDropdown ===
4function useDropdown(options) {
5 const [isOpen, setIsOpen] = useState(false);
6 const [selectedIndex, setSelectedIndex] = useState(-1);
7 const [selectedOption, setSelectedOption] = useState(null);
8
9 const toggle = () => setIsOpen(p => !p);
10 const close = () => setIsOpen(false);
11
12 const select = (index) => {
13 setSelectedIndex(index);
14 setSelectedOption(options[index]);
15 setIsOpen(false);
16 };
17
18 // preventDefault : les flèches ne font pas défiler la page, et Entrée n’ajoute pas de clic qui rouvrirait la liste
19 const handleKeyDown = (e) => {
20 if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(p => Math.min(p + 1, options.length - 1)); }
21 else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(p => Math.max(p - 1, 0)); }
22 else if (e.key === 'Enter' && isOpen && selectedIndex >= 0) { e.preventDefault(); select(selectedIndex); }
23 else if (e.key === 'Escape') close();
24 };
25
26 return {
27 isOpen, selectedIndex, selectedOption, toggle, close, select, handleKeyDown,
28 getToggleProps: () => ({ onClick: toggle, onKeyDown: handleKeyDown, 'aria-expanded': isOpen, 'aria-haspopup': 'listbox' }),
29 getOptionProps: (i) => ({ onClick: () => select(i), 'aria-selected': selectedIndex === i, role: 'option' }),
30 };
31}
32
33// === HOOK HEADLESS : useToggle ===
34function useToggle(initial = false) {
35 const [isOpen, setIsOpen] = useState(initial);
36 return {
37 isOpen,
38 toggle: () => setIsOpen(p => !p),
39 getToggleProps: () => ({ onClick: () => setIsOpen(p => !p), 'aria-expanded': isOpen }),
40 getPanelProps: () => ({ role: 'region', hidden: !isOpen }),
41 };
42}
43
44// === COMPOSANT VISUEL 1 : Simple Dropdown ===
45function SimpleDropdown({ options, label }) {
46 const dd = useDropdown(options);
47 return (
48 <div className="dropdown">
49 <button className="dd-trigger" {...dd.getToggleProps()}>
50 {dd.selectedOption || label} <span>{dd.isOpen ? '\u25B2' : '\u25BC'}</span>
51 </button>
52 {dd.isOpen && (
53 <ul className="dd-menu" role="listbox">
54 {options.map((opt, i) => (
55 <li key={i} className={"dd-option " + (dd.selectedIndex === i ? "highlighted" : "")} {...dd.getOptionProps(i)}>
56 {opt}
57 </li>
58 ))}
59 </ul>
60 )}
61 </div>
62 );
63}
64
65// === COMPOSANT VISUEL 2 : Space Dropdown (apparence différente, même logique) ===
66function SpaceDropdown({ options, icons, label }) {
67 const dd = useDropdown(options);
68 return (
69 <div className="space-dd">
70 <button type="button" className="space-trigger" {...dd.getToggleProps()}>
71 <span>{dd.selectedOption || label}</span>
72 <span className="arrow">{dd.isOpen ? '\u25B2' : '\u25BC'}</span>
73 </button>
74 {dd.isOpen && (
75 <div className="space-menu">
76 {options.map((opt, i) => (
77 <div key={i} className={"space-option " + (dd.selectedIndex === i ? "highlighted" : "")} {...dd.getOptionProps(i)}>
78 <span className="opt-icon">{icons[i]}</span>
79 <span>{opt}</span>
80 </div>
81 ))}
82 </div>
83 )}
84 </div>
85 );
86}
87
88// === Accordéon utilisant useToggle ===
89function AccordionItem({ title, children }) {
90 const { isOpen, getToggleProps, getPanelProps } = useToggle();
91 return (
92 <div className="accordion-item">
93 <button className="accordion-header" {...getToggleProps()}>
94 {title} <span>{isOpen ? '\u2212' : '+'}</span>
95 </button>
96 <div className="accordion-body" {...getPanelProps()}>
97 {isOpen && <div className="accordion-content">{children}</div>}
98 </div>
99 </div>
100 );
101}
102
103// === COMPOSANT PRINCIPAL ===
104export default function App() {
105 return (
106 <div className="app">
107 <h1>Démo des composants headless</h1>
108 <p className="subtitle">Les mêmes hooks de logique, des apparences différentes</p>
109
110 <div className="section">
111 <h3>Dropdown simple (useDropdown)</h3>
112 <SimpleDropdown options={['Mars', 'Jupiter', 'Saturne', 'Europe']} label="Choisis une planète..." />
113 </div>
114
115 <div className="section">
116 <h3>Dropdown spatial (même useDropdown, autre apparence)</h3>
117 <SpaceDropdown
118 options={['Moteurs', 'Boucliers', 'Navigation', 'Armement']}
119 icons={['MOT', 'BOU', 'NAV', 'ARM']}
120 label="Choisis un système..."
121 />
122 </div>
123
124 <div className="section">
125 <h3>Accordéon (useToggle)</h3>
126 <AccordionItem title="État du moteur">
127 <p>Propulsion plasma à 94 % de rendement.</p>
128 </AccordionItem>
129 <AccordionItem title="Rapport des boucliers">
130 <p>Boucliers avant : 78 %. Boucliers arrière : 100 %.</p>
131 </AccordionItem>
132 <AccordionItem title="Navigation">
133 <p>Cap sur Alpha du Centaure. Arrivée prévue dans 4,2 ans.</p>
134 </AccordionItem>
135 </div>
136 </div>
137 );
138}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Que sont les Headless Components dans le contexte de React ?
2. À quoi servent les méthodes getToggleProps() et getOptionProps() dans les hooks headless ?
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Termine deux hooks headless et les deux apparences construites dessus. Dans useDropdown ___BLANK1___ : après le choix d'une option, la liste se ferme (utilise l'action close déjà prête). ___BLANK2___ : getToggleProps règle aria-expanded selon que la liste est ouverte ou non. ___BLANK3___ : getOptionProps(index) met aria-selected à true seulement pour l'option choisie (selectedIndex) et à false pour les autres. Dans useToggle ___BLANK4___ : getPanelProps cache le panneau avec l'attribut hidden quand l'interrupteur est fermé. ___BLANK5___ : SimpleDropdown étale sur son bouton les props renvoyées par getToggleProps du hook useDropdown (le résultat de l'appel, pas la fonction elle-même).
- Tri horizontal
Range la syntaxe de destructuration du résultat du hook headless useDropdown :
- Clique dans l'ordre
Clique dans l'ordre les étapes de création d'un composant headless :