Module 2 · Fondations du web

HTML sémantique & accessibilité

En React Native, presque tout est une View : le sens d'un écran vit dans ta tête et dans le nom de tes composants. Le web te donne un vocabulaire riche — header, nav, main, button, label — et il récompense ceux qui l'emploient : lecteurs d'écran qui comprennent la page, clavier qui marche tout seul, moteurs de recherche qui s'y retrouvent, évaluateurs de tests techniques qui hochent la tête. Ce module t'apprend à choisir la bonne balise, et à voir ta page comme la « voit » une personne qui ne la voit pas.

💡 L'idée en une phrase

Le HTML n'est pas un langage de dessin, c'est un langage de description. Une balise ne dit pas « dessine une boîte grise » (ça, c'est le CSS) ; elle dit ce que c'est : « ceci est la navigation », « ceci est un bouton », « ceci est l'étiquette de ce champ ». Quand tu décris bien, le navigateur, les technologies d'assistance et les moteurs de recherche font le reste gratuitement. Quand tu décris mal, tu dois recoder ce travail à la main — et tu l'oublies presque toujours.

Le HTML porte du sens, pas juste des boîtes

Depuis le module sur le navigateur, tu sais que ton HTML devient un arbre — le DOM — que le navigateur peint. Mais une fois le CSS appliqué, toutes les balises peuvent se ressembler : un <div> stylé en bouton a l'air d'un bouton, un <span> en gros caractères a l'air d'un titre. Alors pourquoi choisir la « bonne » balise, si le pixel final est identique ? Parce que le pixel n'est pas le seul consommateur de ta page. Ton HTML est lu par quatre publics, dont trois ne voient rien :

  • Les technologies d'assistance — lecteurs d'écran (VoiceOver, TalkBack, NVDA), plages braille, commandes vocales. Elles ne lisent pas l'écran, elles lisent la structure : une balise sémantique leur dit quoi annoncer.
  • Les moteurs de recherche — Google lit ton HTML pour comprendre le sujet de la page ; un <h1> pèse plus lourd qu'un <span> en gras. C'est un pan entier du SEO (Search Engine Optimization).
  • Les autres développeurs, toi dans six mois inclus — un fichier fait de <nav>, <main> et <article> se lit comme un plan ; quarante <div> imbriqués se lisent comme un labyrinthe.
  • Le navigateur lui-même — il attache des comportements gratuits aux bonnes balises : un <a> s'ouvre dans un nouvel onglet au clic-molette, un <form> se soumet avec Entrée.

Deux familles de balises cohabitent. Les neutres<div> (boîte de bloc) et <span> (boîte en ligne) — n'ont aucun sens et servent uniquement à regrouper pour la mise en page ; c'est très bien comme ça. Les sémantiques déclarent un rôle : <header>, <nav>, <main> (le contenu principal, unique), <section> (une section thématique), <article> (un contenu autonome, qui aurait du sens seul : un billet, une carte de service), <aside> (périphérique), <footer>. La règle de décision : s'il existe une balise qui décrit ce que tu fais, utilise-la ; sinon seulement, prends une div.

🔗 Pont — View/Text/Pressable ↔ div/p/button

Dans le guide Halterofit (React Native : les briques), tu as appris la table divView, texte→Text, buttonPressable. Ici tu fais le chemin inverse, avec une subtilité : en RN, View est la seule boîte, donc personne ne te reproche de l'utiliser partout. Sur le web, div n'est que la boîte par défaut parmi une dizaine de boîtes qui ont un nom. Le mobile tolère la généricité ; le web récompense la précision.

La page bien structurée : titres, listes, landmarks

Trois piliers. Le premier : la hiérarchie de titres. Les balises <h1> à <h6> ne sont pas « six tailles de texte » : ce sont les niveaux d'un plan. Le <h1> est le titre de la page (unique), les <h2> ses grandes parties, les <h3> les sous-parties. Deux règles : un seul h1, et pas de saut de niveau (on ne passe pas de h1 à h4 parce que « la taille du h4 était jolie » — la taille, c'est le CSS). C'est capital parce que les utilisateurs de lecteurs d'écran naviguent par titres : une commande leur liste tous les titres comme une table des matières, et ils sautent à la section voulue. Une hiérarchie cassée, c'est une table des matières mensongère.

Deuxième pilier : les listes, <ul> (non ordonnée), <ol> (ordonnée, quand l'ordre compte) et leurs <li>. Un lecteur d'écran annonce « liste, 4 éléments » avant de la lire : l'utilisateur sait combien d'items l'attendent et peut sauter le bloc entier. Trois <div> empilés ne disent rien de tout ça.

Troisième pilier : les landmarks (« points de repère »), le nom que les technologies d'assistance donnent aux grandes régions déclarées par <header>, <nav>, <main>, <aside>, <footer>. Comme pour les titres, une commande « liste-moi les régions » permet de sauter au contenu principal sans se retaper le menu à chaque page — l'équivalent, pour l'oreille, de ce que tes yeux font en une demi-seconde quand ils scannent une page.

📅 Dans RendezVous

Voici le squelette de la page de prise de rendez-vous de notre clinique. Lis-le comme un plan : chaque balise annonce sa région, chaque titre son niveau.

<header>
  <img src="/logo.svg" alt="Clinique RendezVous" />
  <nav aria-label="Navigation principale">
    <ul>
      <li><a href="/">Accueil</a></li>
      <li><a href="/services">Services</a></li>
      <li><a href="/rendez-vous">Prendre rendez-vous</a></li>
    </ul>
  </nav>
</header>

<main>
  <h1>Prendre rendez-vous</h1>

  <section>
    <h2>Choisissez un service</h2>
    <ul>  <!-- une carte par service : liste, pas des div -->
      <li><article><h3>Consultation générale</h3> ...</article></li>
      <li><article><h3>Physiothérapie</h3> ...</article></li>
    </ul>
  </section>

  <section>
    <h2>Vos coordonnées</h2>
    <form> ... </form>  <!-- détaillé plus bas, et aux modules Formulaires -->
  </section>
</main>

<footer>
  <p>© Clinique RendezVous — 514 555-0199</p>
</footer>

Un seul h1, des h2 pour les deux étapes, des h3 pour chaque service — aucun saut. Chaque carte de service est un article (elle a du sens seule) dans un li (elles forment une liste).

🧠 Quiz éclair

Un utilisateur de lecteur d'écran demande la liste des titres pour se déplacer dans cette page. Qu'entend-il exactement, et pourquoi le plan qu'il se construit dans la tête est-il faux ?

<main>
  <h1>Prendre rendez-vous</h1>

  <h3>Choisissez un service</h3>
  <div class="titre-service">Physiothérapie</div>
  <p>Séance de 45 minutes.</p>

  <h2>Vos coordonnées</h2>
</main>

Il entend trois entrées : « Prendre rendez-vous, niveau 1 », « Choisissez un service, niveau 3 », « Vos coordonnées, niveau 2 ». Trois dégâts.

Le saut 1 → 3 lui fait chercher un niveau 2 absent : il croit avoir raté une section, ou que sa commande de navigation a sauté quelque chose. La descente puis la remontée (3 puis 2) lui décrit un plan incohérent, où « Vos coordonnées » paraît reprendre au-dessus d'une section dont elle n'est pas la suite. Et surtout, « Physiothérapie » n'apparaît pas du tout dans la liste : c'est un div, et une classe CSS ne porte aucun sens — cette section-là est purement invisible pour la navigation par titres, alors qu'elle se voit très bien à l'écran.

Le correctif ne touche que deux balises : h2 pour « Choisissez un service », h3 pour « Physiothérapie ». La taille du texte, elle, est une affaire de CSS et n'entre jamais en ligne de compte dans ce choix.

Les éléments interactifs natifs : le navigateur travaille pour toi

C'est ici que la sémantique cesse d'être une affaire de bon goût pour devenir une affaire de fonctionnalités gratuites. Compare un vrai <button> à un <div onClick=...> stylé pareil : visuellement identiques, mais le vrai bouton reçoit du navigateur, sans une ligne de code :

  • Le focus clavier : Tab s'arrête dessus. Le div est invisible pour le clavier — un utilisateur sans souris ne peut jamais l'atteindre.
  • L'activation clavier : Entrée et Espace déclenchent le clic. Sur le div, ces touches ne font rien.
  • Le rôle : le lecteur d'écran annonce « Confirmer, bouton ». Le div passe pour du texte inerte — l'utilisateur ignore qu'on peut agir dessus.
  • L'état désactivé : disabled retire le bouton du parcours clavier et l'annonce comme grisé — encore un comportement à recoder à la main sur un div.

Deuxième distinction, très demandée en entrevue : <a> vs <button>. La règle tient en quatre mots : naviguer ou agir ? Un lien (<a href="...">) emmène quelque part ; un bouton fait quelque chose ici (soumettre, ouvrir une fenêtre). Ce n'est pas cosmétique : un lien s'ouvre dans un nouvel onglet, se copie, se met en favori, et le lecteur d'écran annonce « lien » ou « bouton », ce dont l'utilisateur déduit ce qui va se passer. Un <a> sans href qui sert de bouton trahit cette attente — et l'inverse aussi.

Enfin, les éléments de formulaire natifs : <input> et ses types (type="email", "tel", "date", "number"…), <select>, <textarea>. Le bon type change le clavier affiché sur mobile (le @ pour un email, le pavé numérique pour un téléphone), active une validation native et déclenche l'autocomplétion. Les modules « Formulaires 1 — contrôlé ou non » et « Formulaires 2 — validation & erreurs » y consacrent deux chapitres entiers.

✍️ Exercice de lecture

Voici deux versions du même bloc « choisir la physiothérapie » de RendezVous. Compare-les, puis cite trois différences concrètes pour un utilisateur de lecteur d'écran (ou de clavier seul).

<!-- Version A : la « soupe de div » -->
<div class="card">
  <div class="card-title">Physiothérapie</div>
  <div class="card-text">Séance de 45 minutes.</div>
  <div class="btn" onclick="choisir('physio')">Choisir</div>
</div>

<!-- Version B : sémantique -->
<article class="card">
  <h3>Physiothérapie</h3>
  <p>Séance de 45 minutes.</p>
  <button type="button" onclick="choisir('physio')">Choisir</button>
</article>
Voir le corrigé

1. Le titre. En B, « Physiothérapie » est un h3 : il apparaît dans la navigation par titres du lecteur d'écran — l'utilisateur peut sauter de service en service. En A, c'est un div avec une classe : aucune navigation possible, la classe CSS n'apporte aucun sens.

2. Le bouton au clavier. En B, Tab atteint le button, et Entrée ou Espace le déclenchent. En A, le div.btn n'est pas focusable : un utilisateur au clavier seul ne peut pas choisir ce service du tout. Ce n'est pas une gêne, c'est un blocage complet.

3. L'annonce. En B, le lecteur d'écran dit « Choisir, bouton » : l'utilisateur sait qu'une action est possible. En A, il entend « Choisir » comme du texte ordinaire — rien n'indique qu'on peut interagir. Bonus : le p de B est annoncé comme un paragraphe, et l'article délimite proprement la carte.

🧠 Quiz éclair

Ces deux éléments ont été intervertis. Pour chacun, nomme une chose concrète que l'utilisateur y perd.

{/* Ouvre un panneau de créneaux, sans quitter la page */}
<a onClick={() => setPanneauOuvert(true)}>Voir les créneaux</a>

{/* Emmène sur la page /services */}
<button onClick={() => router.push('/services')}>Nos services</button>

Le faux lien. Un <a> sans href n'est plus un lien aux yeux du navigateur : Tab ne s'arrête pas dessus, Entrée ne l'active pas, et le lecteur d'écran n'annonce aucun rôle — il lit « Voir les créneaux » comme du texte ordinaire. Un utilisateur au clavier ne peut donc pas du tout ouvrir le panneau. C'était le travail d'un <button type="button">, qui apporte le focus et l'activation gratuitement.

Le faux bouton. Il navigue, mais il n'a pas d'adresse : impossible de l'ouvrir dans un nouvel onglet (Ctrl+clic, clic-molette), d'en copier le lien, de le mettre en favori — et un moteur de recherche ne suivra jamais ce chemin. Le lecteur d'écran annonce « bouton », donc l'utilisateur ne s'attend pas à changer de page : la surprise est en soi un défaut. C'était le travail d'un <a href="/services">. Le test tient toujours en quatre mots : naviguer ou agir ?

L'arbre d'accessibilité : ce que la machine « voit »

Tu connais le DOM, l'arbre que le navigateur construit à partir de ton HTML. Il en construit un deuxième, dérivé du premier : l'arbre d'accessibilité (accessibility tree), la version « résumée pour les technologies d'assistance » de ta page. Le décoratif en est élagué, et chaque élément restant y est décrit par trois informations :

  • Le rôle : ce que c'est — bouton, lien, titre de niveau 2, champ de saisie, région de navigation. Il découle de la balise : voilà pourquoi la balise compte.
  • Le nom accessible : comment on l'appelle — le texte du bouton, le <label> du champ, l'alt d'une image, ou un aria-label. Un élément sans nom accessible est annoncé « bouton »… et c'est tout.
  • L'état : coché ou non, déplié ou replié, désactivé, invalide…

Le lecteur d'écran ne lit ni tes pixels ni ton CSS : il parcourt cet arbre et le met en voix. Sur la page RendezVous, VoiceOver dira à peu près : « Clinique RendezVous, image. Navigation principale, région, liste, 3 éléments. Accueil, lien. […] Contenu principal, région. Prendre rendez-vous, titre de niveau 1. Choisissez un service, titre de niveau 2. Liste, 2 éléments… ». Chaque mot vient d'une balise que tu as choisie — sans le alt="Clinique RendezVous", l'utilisateur entendrait « logo.svg, image ». Tu peux voir cet arbre toi-même : l'onglet Accessibility des DevTools montre le rôle, le nom et les états de chaque élément, sans installer de lecteur d'écran.

Deux silhouettes : le DOM, et ce qu'il en reste
le DOM — ce que tu as écrit
<header>
<img alt="Clinique RendezVous">
<img alt="">décorative, et déclarée telle
<main>
<h1>Prendre rendez-vous</h1>
l'arbre d'accessibilité — ce que le lecteur d'écran parcourt
bannière
image
contenu principal
titre, niveau 1
L'image décorative manque dans l'arbre d'accessibilité, et elle manque parce que tu l'as déclarée telle : un alt vide dit « celle-ci ne porte aucun sens », et c'est cette déclaration — pas le fait d'être décorative — qui la retire de l'arbre. Sans elle, le nœud resterait, sans nom. Tout le reste ne bouge pas : il change de nature, la balise devenant un rôle. Un <div> de mise en page, lui, n'est jamais retiré : il n'a simplement aucun rôle à annoncer, et ses enfants remontent d'un cran.
🔗 Pont — accessibilityLabel (RN) ↔ aria-label (web)

En React Native, tu croises accessibilityLabel="Fermer" sur un Pressable qui n'affiche qu'une icône ; sur le web, le même besoin s'écrit aria-label="Fermer" sur un <button>. Deux dialectes, un principe : tout élément interactif doit avoir un nom prononçable. iOS, Android et le web ont chacun leur arbre d'accessibilité ; RN et le HTML sont deux façons de l'alimenter — ce que tu apprends ici te resservira dans Halterofit, et inversement.

label + input : le duo à connaître par cœur

S'il ne fallait retenir qu'un patron de ce module, ce serait celui-ci. Un champ nu — <input type="email" /> avec un texte posé à côté — est anonyme : visuellement tout va bien, mais dans l'arbre d'accessibilité il n'a pas de nom, et le lecteur d'écran annonce « champ de saisie, modifiable » sans dire quoi saisir. La solution est le couple label + input reliés par l'identifiant : le <label> porte un attribut for (en JSX htmlFor, car for est un mot réservé de JavaScript) dont la valeur est exactement l'id du champ.

Ce lien déclenche trois effets gratuits. Un : le texte du label devient le nom accessible du champ (« Courriel, champ de saisie »). Deux : cliquer le label focus le champ — anecdotique en apparence, énorme sur mobile où la cible tactile s'agrandit d'autant, et pour les cases à cocher bien plus faciles à atteindre par leur étiquette. Trois : les outils d'audit (Lighthouse, axe) comme les évaluateurs humains vérifient ce lien en premier — c'est le test décisif du « formulaire fait proprement ».

📖 La formule

Le champ étiqueté

<label htmlFor="email">Courriel</label>
<input
  id="email"                      // le même identifiant des deux côtés : c'est LE lien
  type="email"                    // bon clavier mobile + validation native
  aria-describedby="email-err"    // « ma description est là-bas » (l'erreur)
  aria-invalid={hasError}         // état invalide annoncé au lecteur d'écran
/>
<p id="email-err" role="alert">Adresse email invalide.</p>

On l'emploie pour chaque champ de formulaire, sans exception ; on la reconnaît au trio htmlFor/id identiques (en HTML pur : for="email" — React impose htmlFor). Le role="alert" de la dernière ligne, lui, veut dire « lis-moi à voix haute dès que j'apparais » — on le détaille à la section sur ARIA. Tu reverras cette formule telle quelle au module « Formulaires 2 — validation & erreurs », où l'on branchera hasError sur une vraie validation.

✍️ Exercice de lecture

Un collègue a écrit ce champ pour le formulaire de RendezVous, et se plaint : « quand je clique sur l'étiquette Téléphone, le champ ne prend pas le focus ». Trouve pourquoi — il y a en fait deux problèmes.

<label for="telephone">Téléphone</label>
<input id="phone" type="text" placeholder="514 555-0199" />
Voir le corrigé

Problème 1 — le lien est cassé. Le label pointe vers for="telephone" mais le champ s'appelle id="phone". Les deux valeurs doivent être identiques ; comme aucun élément n'a l'id telephone, le label ne pointe vers rien : cliquer dessus ne fait rien, et le champ reste sans nom accessible (le placeholder ne compte pas comme un nom fiable — il disparaît dès qu'on tape et les lecteurs d'écran le traitent inégalement).

Problème 2 — le mauvais type. type="text" affiche le clavier alphabétique complet sur mobile. type="tel" afficherait le pavé numérique téléphonique — exactement ce qu'on veut pour saisir un numéro dans une app de clinique. Corrigé : <label for="phone">Téléphone</label> + <input id="phone" type="tel" />.

ARIA : puissant, et dangereux si on en abuse

ARIA (Accessible Rich Internet Applications) est un jeu d'attributs — role, aria-* — qui enrichissent à la main l'arbre d'accessibilité quand le HTML natif ne suffit pas. Sa première règle est un paradoxe écrit noir sur blanc dans la spécification : la première règle d'ARIA est de ne pas utiliser ARIA quand un élément natif fait l'affaire. Raison : ARIA ne fait que déclarer, jamais implémenter. Un élément qui promet un comportement sans le tenir est pire qu'un élément muet — l'utilisateur essaie, échoue, et ne comprend pas pourquoi.

Cela dit, ARIA a quatre usages parfaitement légitimes, et tu n'as pas besoin d'en connaître plus pour l'instant :

  • aria-label : donner un nom accessible quand aucun texte visible ne peut le faire. Cas d'école, le bouton-icône : <button aria-label="Fermer">✕</button> — sans lui, le lecteur d'écran annonce « multiplication, bouton », ou pire.
  • aria-describedby : relier un élément à sa description (le nom dit « quoi », la description dit « comment » ou « pourquoi c'est en erreur »). C'est le pont entre un champ et son message d'aide ou d'erreur : l'id du message dans l'attribut, et le lecteur d'écran le lit à la suite du champ.
  • aria-invalid : marquer un champ comme invalide. Le rouge du CSS est invisible pour un lecteur d'écran ; aria-invalid="true" rend l'erreur audible (« Courriel, champ de saisie, données invalides »).
  • aria-live : déclarer une zone dont les changements doivent être annoncés spontanément. Par défaut, un lecteur d'écran ne lit que là où l'utilisateur navigue ; un message apparu ailleurs (« Rendez-vous confirmé ! ») passe inaperçu. Une région aria-live="polite" est annoncée dès qu'elle change ; role="alert" (vu dans la formule) en est le raccourci assertif, adapté aux erreurs.
📅 Dans RendezVous

Le champ email du formulaire, en situation d'erreur, met tout ce vocabulaire ensemble :

<label htmlFor="email">Courriel</label>
<input
  id="email"
  type="email"
  aria-invalid={true}               // l'état « invalide » devient audible
  aria-describedby="email-err"      // « mon message d'erreur, c'est lui »
/>
{/* role="alert" : le message est annoncé DÈS son apparition */}
<p id="email-err" role="alert">
  Entre une adresse valide, ex. nom@exemple.com
</p>

L'utilisateur voyant voit le rouge ; l'utilisateur non voyant entend « Courriel, champ de saisie, données invalides, Entre une adresse valide… ». Même information, deux canaux.

⚠️ Piège fréquent : saupoudrer de l'ARIA partout

Le réflexe du débutant qui découvre l'accessibilité : ajouter des role et des aria-label partout, « pour être sûr ». C'est contre-productif. aria-label sur un bouton qui a déjà un texte visible remplace ce texte et crée un décalage (le vocal dit « Envoyer », l'écran dit « Confirmer » — la commande vocale « clique sur Confirmer » échoue) ; role="navigation" sur un <nav> est redondant. La discipline : HTML natif d'abord, ARIA seulement pour ce que le HTML ne sait pas dire (nom d'un bouton-icône, lien champ↔erreur, zone live). Un mauvais ARIA est pire que pas d'ARIA du tout.

🧠 Quiz éclair

Pourquoi role="button" sur un div est-il pire que rien ?

Parce qu'ARIA déclare sans implémenter : le lecteur d'écran annonce « bouton », donc l'utilisateur s'attend à pouvoir le focuser à Tab et l'activer à Entrée/Espace… et rien de tout ça ne marche, puisque le div n'a pas ces comportements. C'est une promesse non tenue. La première règle d'ARIA : préférer l'élément natif (<button>) qui apporte le rôle et le comportement.

Le clavier et le focus : l'accessibilité qui se teste en 30 secondes

Beaucoup de gens naviguent sans souris ni écran tactile : utilisateurs de lecteurs d'écran, personnes à mobilité réduite, utilisateurs avancés pressés… et toi-même, le jour où tu remplis un long formulaire. Le contrat du web au clavier est simple : Tab passe d'un élément interactif au suivant, Maj+Tab recule, Entrée active liens et boutons, Espace active boutons et cases à cocher, les flèches circulent dans un <select> ou un groupe de radios. Tout cela vient des éléments natifs ; l'élément actuellement visé est dit focusé, et il n'y en a qu'un à la fois.

L'ordre du focus, d'abord : Tab suit l'ordre du DOM — celui de ton HTML, pas l'ordre visuel du CSS. Si ton CSS remonte un bouton en haut de page alors qu'il est en bas du HTML, le clavier y arrivera en dernier : déroutant. Écris donc le HTML dans l'ordre logique de lecture et laisse Tab suivre. (L'attribut tabindex permet d'intervenir ; retiens surtout que forcer un ordre avec des tabindex positifs est une mauvaise idée célèbre. Et tabindex="0" est ce qu'il faudrait ajouter — avec un role, un onKeyDown pour Entrée et Espace, un style de focus et l'état désactivé — pour rattraper un div cliquable : cinq choses à coder, cinq occasions de se tromper.)

La visibilité du focus, ensuite : l'anneau (souvent bleu) autour de l'élément focusé n'est pas une verrue esthétique, c'est le curseur de l'utilisateur clavier. Le supprimer en CSS (outline: none, classique des années 2010) rend la navigation clavier littéralement aveugle. Le CSS moderne offre la nuance : :focus-visible, une pseudo-classe qui ne matche que quand le navigateur juge l'indicateur utile — au clavier, mais pas après un clic de souris. Tu styles :focus-visible et tout le monde est content.

D'où le test express, sur ta page comme sur celle d'un test technique : pose la souris et fais tout au clavier. Si tu bloques quelque part, tu as trouvé un bug d'accessibilité.

✍️ Exercice de lecture

Ce composant vient d'un vrai projet (à peine caricaturé). Liste tout ce qui ne marchera pas au clavier ou au lecteur d'écran — il y a au moins trois problèmes.

function BookButton({ onReserver }) {
  return (
    <div
      className="btn-primaire"
      onClick={onReserver}
      style={{ outline: 'none' }}
    >
      <img src="/calendrier.svg" />
      Réserver
    </div>
  );
}
Voir le corrigé

1. C'est un div : pas focusable (Tab l'ignore), pas activable à Entrée/Espace, annoncé sans rôle — l'utilisateur clavier ou lecteur d'écran ne peut pas réserver. 2. outline: 'none' supprime l'anneau de focus ; même si on rendait l'élément focusable, on ne verrait pas qu'il l'est. 3. L'image n'a pas d'attribut alt : le lecteur d'écran peut annoncer le nom du fichier. Ici l'icône est décorative (le texte « Réserver » suffit), donc la bonne valeur est alt="" — un alt vide, qui dit explicitement « ignore-moi ». La correction tient en une ligne d'esprit : <button type="button" className="btn-primaire" onClick={onReserver}> avec l'image en alt="" — et tous les problèmes disparaissent d'un coup.

🧠 Quiz éclair

Ton collègue tient à son div et jure l'avoir rattrapé. De fait, Tab s'arrête bien dessus et le lecteur d'écran annonce « Réserver, bouton ». Un utilisateur au clavier reste pourtant incapable de réserver. Qu'est-ce qui manque encore ?

<div
  className="btn-primaire"
  role="button"
  tabIndex={0}
  onClick={onReserver}
>
  Réserver
</div>

L'activation au clavier. Sur un vrai <button>, Entrée et Espace produisent un clic : le navigateur le fait pour toi, et onClick part. Sur un div, aucune touche ne produit de clic — onClick n'y répond qu'à la souris et au toucher. L'utilisateur atteint l'élément, appuie sur Entrée, et il ne se passe rien. Il faudrait un onKeyDown qui intercepte Entrée et Espace, appelle onReserver, et empêche Espace de faire défiler la page (son comportement par défaut).

Deux manques restent ensuite. Le focus visible : la classe btn-primaire ne prévoit sans doute aucun style :focus-visible, donc l'utilisateur ne voit pas où il est. Et l'état désactivé : disabled n'a aucun effet sur un div — il resterait focusable et cliquable, et il faudrait le retirer du parcours à la main. Quatre choses recodées, une par une, pour rattraper une balise de six lettres.

L'accessibilité en entrevue et en test technique

Parlons stratégie. L'accessibilité (abrégée a11y — un « a », onze lettres, un « y ») est l'un des critères les plus discriminants pour un candidat junior, précisément parce que presque aucun junior ne la maîtrise. Les correcteurs ont des grilles, et « accessibilité » y figure noir sur blanc : un vrai test de stage, examiné pour préparer ce guide, listait « a11y » parmi les manques types des candidatures refusées, aux côtés de la gestion d'erreurs et des états de chargement. Or un formulaire dont chaque champ a son label relié et une hiérarchie de titres propre, ça se voit en trente secondes de lecture — et c'est du HTML gratuit : aucune bibliothèque, aucune heure de plus, juste les bonnes balises dès le départ.

Les cinq réflexes à appliquer dans tout test technique (et à citer en entrevue si on te demande comment tu abordes l'accessibilité) :

  1. La bonne balise pour le bon rôle : button pour agir, a pour naviguer, les landmarks pour la structure, un seul h1, pas de saut de titre.
  2. Chaque champ a son label relié par htmlFor/id, et le bon type. Jamais un placeholder en guise d'étiquette.
  3. Chaque image a un alt : descriptif si elle informe, vide (alt="") si elle décore.
  4. Tout marche au clavier : parcours Tab logique, focus visible (:focus-visible), jamais d'outline: none sans remplacement.
  5. Les erreurs sont audibles : aria-describedby + aria-invalid + role="alert" — la formule de ce module, exactement.
🎤 En entrevue

« Pourquoi utiliser un button plutôt qu'un div avec un onClick ? »

« Parce que le button natif apporte gratuitement tout ce que le div n'a pas : il est focusable à Tab, il s'active à Entrée et à Espace, il est annoncé comme " bouton " par les lecteurs d'écran, et il gère l'état disabled. Avec un div, il faudrait recoder tout ça à la main — tabindex, gestion du clavier, role — et on en oublie toujours une partie. Visuellement les deux peuvent être identiques, puisque le style est l'affaire du CSS ; la différence est fonctionnelle et invisible… jusqu'à ce qu'un utilisateur au clavier ou au lecteur d'écran essaie de s'en servir. »

« C'est quoi, l'accessibilité web, concrètement ? »

« C'est faire en sorte que la page soit utilisable par tout le monde, y compris sans souris, sans écran, ou sans une vision parfaite. Concrètement, le navigateur construit à partir du HTML un arbre d'accessibilité — rôles, noms, états — que les lecteurs d'écran mettent en voix ; mon travail est d'alimenter correctement cet arbre. Au quotidien, ça tient à des gestes simples : HTML sémantique plutôt que des div partout, un label relié à chaque champ, des alt sur les images, une navigation clavier complète avec un focus visible, et des erreurs annonçables, pas seulement colorées en rouge. Et j'ai un test express : lâcher la souris et parcourir la page à Tab. »

« Quand est-il légitime d'utiliser ARIA ? »

« La règle d'or vient de la spec elle-même : ne pas utiliser ARIA quand un élément natif suffit, parce qu'ARIA déclare un rôle sans fournir le comportement. Les cas légitimes, c'est ce que le HTML ne sait pas exprimer : aria-label pour nommer un bouton-icône, aria-describedby pour relier un champ à son message d'erreur, aria-invalid pour rendre l'état d'erreur audible, aria-live ou role="alert" pour qu'un message qui apparaît dynamiquement soit annoncé. Donc : HTML d'abord, ARIA en complément ciblé. »

À retenir

La formule du module : label htmlForinput id, complétée en cas d'erreur par aria-describedby + aria-invalid + role="alert". Et le test des 30 secondes : pose la souris, fais tout au clavier, focus visible compris. Si ces deux réflexes sont acquis, le reste du module suit.

Et ailleurs : L'idée profonde dépasse le HTML : déclare ton intention à la machine, et la machine travaille pour toi. Même philosophie que le typage TypeScript (déclarer les types → le compilateur attrape tes erreurs) ou que les testID et accessibilityLabel de React Native. Chaque plateforme a son arbre d'accessibilité, et le métier appris ici (rôle, nom, état, focus) se transpose tel quel dans Halterofit. Bonus concret : Testing Library (module « Tester le web : Vitest & Testing Library ») sélectionne les éléments par rôle et par nom accessibles — un HTML sémantique est un HTML testable. L'accessibilité n'est pas une couche qu'on ajoute à la fin ; c'est la qualité de ta description du monde.

🗂️ L'aide-mémoire
Ce que porte un nœud
un rôle (il vient de la balise), un nom (le texte, l'alt, le <label>), un état
for / htmlFor
la même valeur des deux côtés, à la lettre près — sinon le champ n'a aucun nom accessible
aria-label sur un texte visible
le remplace, il ne s'y ajoute pas : le texte affiché n'est plus le nom accessible
role="navigation" sur un <nav>
redondant : le rôle vient déjà de la balise
L'ordre de tabulation
suit l'ordre du DOM, pas celui du CSS. Un tabindex positif pour le forcer est une mauvaise idée célèbre
<main>
unique dans la page — c'est ce qui en fait une cible de saut fiable