Composant · Formulaires et saisie

Champ incrémental (spinbutton) : critères RGAA et ARIA

Champ incrémental accessible : input type number pour les quantités, inputmode numeric pour les chaînes de chiffres, role spinbutton et critères RGAA.

Un champ incrémental (spinbutton) restreint sa valeur à une plage de valeurs discrètes : un champ de saisie que l'on augmente et diminue aux flèches, souvent flanqué de boutons + et −. L'élément natif <input type="number"> fournit les flèches, l'annonce de la valeur et la valeur de formulaire. Mais il n'est le bon choix que pour de véritables quantités, et c'est la première question à trancher.

Quand ce motif est le mauvais choix

type="number" convient aux quantités sur lesquelles on ferait un calcul : quantité de panier, âge, prix, note. Il est incorrect pour les chaînes de chiffres, qui sont des identifiants et non des nombres :

DonnéeÉlément adapté
Quantité, âge, prix, note<input type="number">
Numéro de téléphone<input type="tel"> (« +33 » n'est pas un nombre)
Carte bancaire, code postal, PIN, code à usage unique, IBAN, référence<input type="text" inputmode="numeric">

Avec type="number" sur une chaîne de chiffres, les zéros de tête sont perdus (« 007 » devient « 7 »), la molette de la souris modifie silencieusement la valeur quand le champ a le focus, et certains navigateurs rejettent des caractères sans aucun retour. inputmode="numeric" affiche le clavier numérique sur mobile, ce que l'on cherche en réalité, sans aucun de ces défauts.

Pour choisir une valeur dans une plage de façon plus visuelle et continue, le motif curseur (slider) est parfois plus adapté que le champ incrémental.

Critères RGAA applicables

Interaction clavier attendue

ToucheAction
Flèche hautAugmente la valeur d'un pas
Flèche basDiminue la valeur d'un pas
Origine (Home)Place la valeur au minimum, si des bornes existent
Fin (End)Place la valeur au maximum, si des bornes existent
Page précédente / Page suivanteOptionnel : incrémente ou décrémente d'un pas plus grand

La saisie directe au clavier doit rester possible : taper « 7 » est plus rapide que six appuis sur le bouton +.

Rôles et attributs ARIA

Avec <input type="number">, aucun rôle ARIA n'est nécessaire. Les boutons + et − personnalisés doivent soit porter un nom accessible (« Augmenter la quantité »), soit être retirés de la restitution (aria-hidden="true" et tabindex="-1"), puisqu'ils dupliquent les flèches déjà disponibles sur le champ. L'annonce d'un ajustement de valeur passe par une zone role="status".

<div class="quantite">
  <label for="qte">Quantité</label>

  <!-- tabindex="-1" et aria-hidden : ces boutons sont une commodité
       pour le pointeur ; le champ gère déjà le clavier. -->
  <button type="button" tabindex="-1" aria-hidden="true">−</button>

  <input type="number" id="qte" name="qte"
         min="1" max="10" step="1" value="1"
         inputmode="numeric" aria-describedby="qte-aide">

  <button type="button" tabindex="-1" aria-hidden="true">+</button>

  <!-- Les bornes sont annoncées AVANT la validation (critère 11.10). -->
  <p id="qte-aide">Maximum 10 par commande.</p>

  <!-- Toujours présente dans le DOM, vide au repos : c'est ici que
       l'ajustement d'une valeur hors bornes est annoncé. -->
  <p role="status" class="sr-only"></p>
</div>
const champ = document.getElementById('qte');
const statut = document.querySelector('[role="status"]');
const MIN = 1, MAX = 10;

// Le correctif que tout le monde oublie : sans lui, faire défiler la
// page avec le champ focalisé change la quantité en silence.
champ.addEventListener('wheel', (evenement) => {
  if (document.activeElement === champ) evenement.preventDefault();
}, { passive: false });

function bornerEtAnnoncer() {
  const saisie = Number(champ.value);
  if (Number.isNaN(saisie) || champ.value === '') return;

  const bornee = Math.min(MAX, Math.max(MIN, saisie));
  if (bornee === saisie) return;

  champ.value = String(bornee);
  // Ne jamais réécrire la saisie en silence (critère 11.10).
  statut.textContent = `Quantité ramenée à ${bornee}. Maximum ${MAX} par commande.`;
}

// Borner à la perte de focus, jamais à chaque frappe : sinon taper
// « 10 » devient impossible, « 1 » étant corrigé avant le « 0 ».
champ.addEventListener('change', bornerEtAnnoncer);

Pour une chaîne de chiffres, qui n'est pas un champ incrémental, la bonne construction est la suivante :

<!-- Pas type="number" : un code postal n'est pas une quantité.
     inputmode donne le clavier numérique sur mobile, autocomplete
     sert la finalité du champ (critère 11.13). -->
<label for="code-postal">Code postal</label>
<input type="text" id="code-postal" name="code-postal"
       inputmode="numeric" pattern="[0-9]{5}"
       autocomplete="postal-code" aria-describedby="cp-aide">
<p id="cp-aide">5 chiffres, par exemple 75011.</p>

En version ARIA (si le natif est réellement impossible) : role="spinbutton", aria-valuenow obligatoire, aria-valuemin/aria-valuemax, un nom accessible, et toute la gestion clavier du tableau ci-dessus à réimplémenter.

Défauts fréquents et impact utilisateur

  • Molette non neutralisée sur type="number" : le champ a le focus, l'utilisateur fait défiler la page, et la quantité change silencieusement. C'est un bug d'intégrité des données, pas un détail : la commande part avec la mauvaise quantité.
  • type="number" pour une carte bancaire ou un code postal : zéros de tête perdus, caractères rejetés sans retour, flèches d'incrément dénuées de sens sur un identifiant.
  • Écrêtage silencieux : l'utilisateur tape 999, le champ se réécrit à 10 sans explication. Un utilisateur de lecteur d'écran n'apprend jamais que sa saisie a changé (critère 11.10).
  • Boutons + et − sans nom, exposés aux technologies d'assistance : annoncés « moins, bouton » / « plus, bouton », ils encombrent la tabulation en dupliquant les flèches du champ.
  • Champ en lecture seule pilotable uniquement aux boutons + et − : la saisie directe disparaît, le chemin le plus rapide vers une valeur est retiré sans bénéfice.
  • Écrêtage à chaque frappe (dans onChange ou oninput) : taper « 10 » devient impossible car « 1 » est corrigé avant la saisie du « 0 ». Écrêter à la perte de focus.

Ce que les outils automatiques ne détectent pas

Soyons précis sur le partage : l'absence d'étiquette ou un role="spinbutton" sans aria-valuenow sont bien détectés par les outils automatiques, notre scanner compris. Leur échappent en revanche :

  • Le mauvais choix d'élément : type="number" sur un numéro de carte est du HTML parfaitement valide. Seule l'analyse de la nature de la donnée le révèle.
  • Le bug de la molette et l'écrêtage silencieux d'une valeur hors bornes : deux comportements dynamiques qui exigent un test manuel (critère 11.10 pour le second).
  • La pertinence de l'aide à la saisie : bornes annoncées avant validation, message d'ajustement réellement restitué par la zone de statut.

Vérifier ce composant

Protocole manuel rapide :

  1. Le test de la molette, en premier : focaliser le champ, puis faire défiler la page à la souris. Si la valeur change, le bug de corruption silencieuse est présent.
  2. Le test de l'élément : la donnée est-elle une quantité ? Taper « 007 » : si le champ affiche « 7 », c'est un type="number" posé sur une chaîne de chiffres.
  3. Le test des bornes : taper 999 avec un maximum de 10. Quelque chose doit signaler l'ajustement, visuellement et via la zone de statut (critère 11.10).
  4. Au clavier seul : les flèches haut/bas modifient la valeur, la saisie d'un nombre à deux chiffres fonctionne, et Tab ne s'arrête pas sur les boutons + et −.

Deux outils gratuits du site aident à ce contrôle : l'outil Patterns ARIA fournit des régions live (role="status") prêtes à coller pour l'annonce d'ajustement, et le Simulateur Lecteur d'Écran permet de vérifier le nom, le rôle et la valeur réellement exposés par le champ.

Approfondir avec les fiches critères

Vérifier ce composant sur votre site ?

Le scan repère les défauts détectables automatiquement (rôles incohérents, champs sans étiquette, attributs ARIA orphelins) ; le reste se vérifie à la main avec les protocoles de cette fiche.

Lancer un scan gratuit

Toutes les fiches composants accessibles