Composant · Données et contenus riches

Window splitter accessible : role separator focalisable

Séparateur de fenêtres (window splitter) conforme RGAA : role separator focalisable, aria-valuenow, alternative en un point unique au glissement (critère 13.10) et annulation.

Un séparateur de fenêtres (window splitter) est une poignée déplaçable entre deux panneaux qui permet d'ajuster leur taille relative, en continu ou entre deux positions fixes. Il n'existe pas d'élément natif : le motif repose sur un role="separator" focalisable (tabindex="0") porteur d'une valeur. À ne pas confondre avec un <hr> ou un trait décoratif, qui portent le même rôle mais ne sont ni focalisables ni valués : le tabindex="0" est précisément ce qui transforme le séparateur en widget.

Quand ce motif est le mauvais choix

Demandez-vous d'abord si un panneau redimensionnable est nécessaire : des largeurs fixes, ou une préférence utilisateur persistée, suffisent souvent. Autres cas où un autre choix est correct :

  • Replier et déplier un panneau latéral, sans réglage fin : un simple bouton avec aria-expanded est plus robuste qu'un séparateur valué.
  • Séparation purement visuelle entre deux blocs : un <hr> ou une bordure CSS suffit, sans tabindex ni valeur, comme le séparateur décoratif d'une barre d'outils.
  • Régler une valeur qui n'est pas la taille de deux panneaux (volume, prix…) : c'est un curseur, pas un séparateur.

Critères RGAA applicables

Interaction clavier attendue

ToucheAction
Flèche gauche / Flèche droiteDéplace un séparateur vertical vers la gauche / la droite.
Flèche haut / Flèche basDéplace un séparateur horizontal vers le haut / le bas.
EntréeReplie le panneau principal, ou le restaure à sa taille précédente.
Début / FinEn option : taille minimale / maximale du panneau principal.
F6En option : circule entre les panneaux.

Rôles et attributs ARIA

role="separator" avec tabindex="0", un nom accessible (aria-label ou aria-labelledby) qui désigne le panneau principal, aria-valuenow (taille du panneau principal, en pourcentage), aria-valuemin et aria-valuemax (ses bornes), et aria-controls vers le panneau principal, celui que la valeur décrit. aria-valuetext rend l'annonce compréhensible : « Panneau latéral, 30 % de la largeur » vaut mieux qu'un « 30 » nu. aria-orientation="vertical" pour un séparateur qui partage l'écran verticalement.

<div class="vue-scindee">
  <div id="panneau-lateral" style="inline-size: 30%">
    <h2>Fichiers</h2>
    …
  </div>

  <!-- tabindex="0" fait du séparateur un widget et non un trait décoratif.
       aria-controls désigne le panneau que aria-valuenow décrit. -->
  <div
    role="separator"
    id="separateur"
    tabindex="0"
    aria-label="Redimensionner le panneau latéral"
    aria-controls="panneau-lateral"
    aria-orientation="vertical"
    aria-valuenow="30" aria-valuemin="15" aria-valuemax="60"
    aria-valuetext="Panneau latéral, 30 % de la largeur"
  ></div>

  <div class="vue-scindee__principal">
    <h2>Éditeur</h2>
    …
  </div>
</div>

<!-- Test 13.10.2 : l'alternative en un point unique. Sans elle (ou sans le
     double clic ci-dessous), la seule voie au pointeur est un geste de
     trajectoire, et le motif échoue. -->
<button type="button" id="separateur-replier">Replier le panneau latéral</button>
<button type="button" id="separateur-reinitialiser">Rétablir la largeur par défaut</button>
/* Rendu 1 px, ciblable sur 24 px : sans cette zone étendue, la poignée est
   inattrapable pour une personne au geste imprécis (bonne pratique WCAG 2.2,
   ce n'est pas une exigence RGAA). */
#separateur {
  position: relative;
  inline-size: 1px;
  background: #767676; /* au moins 3:1 avec le fond contigu (critère 3.3) */
  cursor: col-resize;
}
#separateur::before {
  content: "";
  position: absolute;
  inset-block: 0;
  inset-inline: -12px;
  cursor: col-resize;
}

/* Un trait fin exige un indicateur de focus délibéré (critère 10.7). */
#separateur:focus-visible {
  outline: 2px solid #0056b3;
  outline-offset: 2px;
  background: #0056b3;
}
const separateur = document.getElementById('separateur');
const panneau = document.getElementById('panneau-lateral');
const MIN = 15, MAX = 60, DEFAUT = 30;
let derniereLargeur = DEFAUT;

function appliquer(pourcent, { replie = false } = {}) {
  const valeur = replie ? 0 : Math.min(MAX, Math.max(MIN, Math.round(pourcent)));
  panneau.style.inlineSize = `${valeur}%`;
  separateur.setAttribute('aria-valuenow', String(valeur));
  // « 30 » seul ne veut rien dire ; c'est ce texte qui est réellement annoncé.
  separateur.setAttribute('aria-valuetext',
    replie ? 'Panneau latéral replié' : `Panneau latéral, ${valeur} % de la largeur`);
  return valeur;
}

// Clavier. Attention : ceci satisfait le critère 7.3, PAS le critère 13.10,
// qui porte sur l'usage du pointeur.
separateur.addEventListener('keydown', (evenement) => {
  const courant = Number(separateur.getAttribute('aria-valuenow'));
  let prochain = null;
  if (evenement.key === 'ArrowRight') prochain = courant + 1;
  if (evenement.key === 'ArrowLeft') prochain = courant - 1;
  if (evenement.key === 'Home') prochain = MIN;
  if (evenement.key === 'End') prochain = MAX;

  // Entrée replie en mémorisant la largeur, ou restaure.
  if (evenement.key === 'Enter') {
    evenement.preventDefault();
    if (courant === 0) appliquer(derniereLargeur);
    else { derniereLargeur = courant; appliquer(0, { replie: true }); }
    return;
  }
  if (prochain === null) return; // Tab passe sans interception
  evenement.preventDefault();
  appliquer(prochain);
});

// Test 13.10.2 : chaque route ci-dessous redimensionne d'UN contact, sans
// trajectoire. Le double clic de réinitialisation en est une aussi.
document.getElementById('separateur-reinitialiser')
  .addEventListener('click', () => appliquer(DEFAUT));
separateur.addEventListener('dblclick', () => appliquer(DEFAUT));

// Le glissement reste possible, comme AMÉLIORATION, jamais comme seule voie.
separateur.addEventListener('pointerdown', (evenement) => {
  separateur.setPointerCapture(evenement.pointerId);
  const valeurInitiale = Number(separateur.getAttribute('aria-valuenow'));

  const bouger = (e) => {
    const rect = separateur.parentElement.getBoundingClientRect();
    appliquer(((e.clientX - rect.left) / rect.width) * 100);
  };
  const relacher = () => {
    separateur.removeEventListener('pointermove', bouger);
    // Validation au relâchement seulement (critère 13.11).
    persisterPreference(Number(separateur.getAttribute('aria-valuenow')));
  };
  // Échap abandonne le glissement et restaure la taille initiale (critère 13.11).
  const annuler = (e) => {
    if (e.key !== 'Escape') return;
    separateur.removeEventListener('pointermove', bouger);
    appliquer(valeurInitiale);
  };

  separateur.addEventListener('pointermove', bouger);
  separateur.addEventListener('pointerup', relacher, { once: true });
  document.addEventListener('keydown', annuler, { once: true });
});

À ne pas faire :

<!-- Un séparateur utilisable uniquement par glisser-déposer : l'échec type
     du test 13.10.2. Ajouter des flèches clavier ne le corrige PAS, car le
     critère 13.10 porte sur l'usage du pointeur. -->
<div class="separateur" onmousedown="demarrerGlissement()"></div>

<!-- role="separator" sans tabindex : un trait décoratif, pas un widget.
     Inatteignable au clavier, attributs de valeur ignorés. -->
<div role="separator" aria-valuenow="30"></div>

<!-- Un <hr> déguisé en splitter : <hr> porte role="separator" mais n'est pas
     un widget ; les attributs de valeur sont ignorés. -->
<hr role="separator" tabindex="0" aria-valuenow="30">

<!-- Sans nom accessible : « séparateur, 30 ». Redimensionner quoi ? -->
<div role="separator" tabindex="0" aria-valuenow="30" aria-controls="panneau-lateral"></div>
// À ne pas faire : persister à chaque pointermove. Une requête par pixel de
// glissement, et plus aucun moyen d'abandonner (critère 13.11).
const bouger = (e) => { appliquer(calcul(e)); persisterPreference(calcul(e)); };

// À ne pas faire : replier sans mémoriser la largeur précédente. Replier puis
// déplier renvoie le panneau à une taille par défaut que l'utilisateur n'a
// jamais choisie.
appliquer(0);

Défauts fréquents et impact utilisateur

  • Séparateur utilisable uniquement par glisser-déposer : l'échec type du test 13.10.2 ; une personne qui peut toucher l'écran mais pas suivre une trajectoire (tremblement, pointage à la tête) ne peut pas redimensionner. Ajouter des flèches clavier ne corrige pas ce défaut, qui concerne le pointeur.
  • role="separator" sans tabindex : ce n'est qu'un filet décoratif ; l'utilisateur clavier ne peut jamais l'atteindre ni le manipuler, et les attributs de valeur sont ignorés.
  • Pas de nom accessible : le lecteur d'écran annonce « séparateur, 30 » ; redimensionner quoi ?
  • Pas d'aria-valuetext : un « 30 » nu est annoncé ; trente quoi, pour cent de quoi ?
  • aria-controls pointant le mauvais panneau : aria-valuenow décrit le panneau principal ; s'il pointe l'éditeur alors que la valeur décrit le panneau latéral, l'annonce est inversée.
  • Zone de saisie de 1 px et repli sans mémoire : un trait de 1 px est impossible à attraper pour une personne au geste imprécis (environ 24 px de zone cliquable est une bonne pratique WCAG 2.2, pas une exigence RGAA) ; et un panneau qui se rouvre à une taille par défaut au lieu de la taille précédente fait perdre son réglage à l'utilisateur.

Ce que les outils automatiques ne détectent pas

Un scanner automatique, le nôtre compris, repère un role="separator" focalisable auquel manque aria-valuenow. Mais le défaut central du motif lui échappe entièrement :

  • l'absence d'alternative en un point unique au glissement (test 13.10.2) ne se vérifie qu'en essayant de redimensionner sans glisser ;
  • la taille de la zone de saisie autour du trait et la visibilité réelle de l'indicateur de focus sur un élément de 1 px relèvent du test manuel ;
  • la mémoire du repli (rouvrir à la taille précédente) et la validation au relâchement (critère 13.11) exigent une manipulation réelle au pointeur.

Vérifier ce composant

Protocole manuel, dans cet ordre :

  1. Le test du critère 13.10, à lire attentivement : redimensionnez le panneau avec des contacts en un point unique seulement, double clic de réinitialisation ou boutons replier et rétablir. Si la seule voie au pointeur est un glissement, le test 13.10.2 échoue. Les flèches clavier ne sauvent pas ce test.
  2. Le test du widget : pouvez-vous atteindre le séparateur à Tab ? Sinon, c'est un trait décoratif qui se prend pour un widget : tabindex="0" manque.
  3. Le test de la zone de saisie : essayez d'attraper la poignée au tactile, ou avec un pointeur imprécis. Une cible de 1 px n'est pas utilisable.
  4. Au lecteur d'écran : attendez-vous à « Redimensionner le panneau latéral, séparateur, Panneau latéral, 30 % de la largeur ». Un « 30 » nu signale l'absence d'aria-valuetext.
  5. Le test de la mémoire : repliez, puis dépliez. Le panneau doit revenir à sa taille précédente, pas à une valeur par défaut.

Deux outils gratuits du site pour appuyer la vérification : le simulateur de handicap moteur fait expérimenter ce qu'un glissement de poignée exige avec un pointeur altéré, et pourquoi l'alternative en un point unique n'est pas une option ; le testeur de focus visible valide vos styles :focus, indispensables sur un élément de 1 px (critère 10.7).

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