La plupart des bugs d'interface sont cosmétiques. Certains modifient des chiffres que l'utilisateur n'avait pas l'intention de changer, et si ces chiffres sont un levier, une taille d'ordre ou un prix, le bug n'est plus cosmétique.
En voici deux que nous avons rencontrés, tous deux causés par un comportement du navigateur et des bibliothèques, techniquement documenté mais pratiquement invisible. Les correctifs sont petits. Les trouver ne l'était pas.
Points clés
- Un champ <input type="number"> qui a le focus s'incrémente ou se décrémente quand vous faites défiler la molette au-dessus de lui. Sur un champ monétaire, cela réécrit silencieusement la valeur.
- Corrigez-le avec blur(), pas avec preventDefault() : retirer le focus annule l'incrément tout en laissant le défilement de la page et de la modale parfaitement intact.
- Un popover Radix rendu via un portail vers document.body ne peut pas conserver le focus à l'intérieur d'un dialog Radix, parce que le focus scope du dialog rappelle le focus vers l'extérieur. Le champ paraît actif et avale chaque frappe.
- Passez l'élément du dialog lui-même comme conteneur de portail du popover pour qu'il fasse partie du sous-arbre piégé.
- Ce correctif invalide les gestionnaires de clic-extérieur faits main : une fois le contenu rendu via un portail, il n'est plus un descendant DOM du wrapper auquel vous le compariez.
1. La molette est un geste d'édition, et personne n'a prévenu l'utilisateur
Les champs numériques natifs ont un comportement qui remonte à la spécification : lorsque le champ a le focus, faire défiler la molette au-dessus de lui augmente ou diminue la valeur par pas de son attribut step.
Sur un sélecteur de quantité dans un panier d'achat, c'est un désagrément mineur. Sur nos formulaires, c'est tout autre chose, parce que les champs en question sont le levier, le montant de trading, le pourcentage de partage des gains et le prix de mise en vente. L'utilisateur clique dans un champ, fait défiler la page pour lire le reste du formulaire, et le chiffre qu'il avait déjà défini a changé. Rien ne le signale. Le formulaire se valide quand même. Le bot démarre avec une valeur que l'utilisateur n'a jamais choisie.
Le correctif naïf consiste à attacher un gestionnaire wheel sur chaque champ et à appeler preventDefault(). Ça marche, et ça casse aussi le défilement : l'utilisateur ne peut plus faire défiler la page tant que le curseur se trouve au-dessus d'un champ numérique, ce qui donne une impression de dysfonctionnement d'une tout autre nature.
Le meilleur correctif s'appuie sur la précondition de la spécification. L'incrément ne se produit que lorsque le champ a le focus, donc retirer le focus suffit :
const onWheel = (e) => {
const active = document.activeElement;
if (
active instanceof HTMLInputElement &&
active.type === "number" &&
(active === e.target || active.contains(e.target))
) {
active.blur(); // kills the increment, leaves scrolling intact
}
};
document.addEventListener("wheel", onWheel, { passive: true, capture: true });
Trois choix délibérés dans cet extrait :
blur()plutôt quepreventDefault(). Nous n'annulons pas le défilement, nous supprimons la condition qui en fait une édition. La page continue de défiler normalement.capture: true. L'écouteur observe l'événement lors de sa descente, il se déclenche donc même si un composant interne stoppe la propagation.passive: true. Sûr précisément parce que nous n'appelons jamaispreventDefault(), et cela garde l'écouteur hors du chemin critique du défilement.
Un seul écouteur global, monté une fois, couvre chaque champ numérique de l'application, y compris ceux qui n'existent pas encore. L'alternative, corriger chaque champ, échoue dès que quelqu'un ajoute un nouveau formulaire et oublie.
2. Un popover dans un dialog dans lequel on ne peut rien saisir
Le second s'est présenté comme un rapport de bug impossible : une barre de recherche dans un menu déroulant, dans une modale, qui prenait visiblement le focus puis ignorait tout ce qu'on y tapait.
La cause : deux comportements corrects qui entrent en collision.
Les popovers Radix rendent leur contenu via un portail vers document.body par défaut, ce qui est correct sur une page normale, parce que cela échappe aux contextes d'overflow et d'empilement. Les dialogs Radix piègent le focus dans leur propre sous-arbre, ce qui est également correct, et c'est ce qui fait d'une modale une modale.
Mettez les deux ensemble et le contenu du popover est, en termes de DOM, à l'extérieur du dialog. Le focus scope du dialog vérifie chaque événement de focus entrant à peu près comme ceci :
container.contains(target) ? allow : focus(lastFocusedElement)
La barre de recherche n'est pas contenue par le dialog, donc chaque événement de focus qui l'atteignait était immédiatement annulé. Le champ s'affichait, montrait un curseur un instant, et perdait le focus avant qu'une frappe puisse être enregistrée. Il paraissait actif et avalait tout.
Le correctif consiste à cesser de mentir sur l'endroit où vit le popover. Le portail de Radix accepte un container, donc nous rendons un div vide, de hauteur nulle, à l'intérieur du dialog et y faisons passer le popover par le portail :
// inside the dialog
<div ref={setDropdownHost} />
...
<PopoverContent container={dropdownHost}>…</PopoverContent>
Deux détails faciles à rater :
- Un state, pas un ref. Le nœud hôte n'existe pas au premier rendu, et le portail a besoin d'un nouveau rendu une fois qu'il existe. Un
useRefretiendrait le nœud mais ne déclencherait jamais ce rendu. - L'hôte ne doit pas affecter la mise en page. Il est vide et de hauteur nulle, il peut donc se placer n'importe où dans le balisage du dialog sans rien décaler.
3. Le correctif qui casse discrètement le code d'à côté
C'est la partie que je signalerais le plus fortement, parce que c'est là qu'un correctif propre devient une régression.
Avant de passer à un popover en portail, le menu déroulant avait un gestionnaire de clic-extérieur fait main : garder un ref sur le wrapper, écouter les clics du document, fermer si le clic n'était pas à l'intérieur.
Une fois le contenu rendu via un portail, il n'est plus un descendant DOM de ce wrapper, même s'il est visuellement à l'intérieur. Le gestionnaire voit donc désormais les clics sur la propre barre de recherche du menu déroulant comme des clics à l'extérieur, et ferme la chose même que l'utilisateur essaie d'utiliser.
Supprimer ce gestionnaire n'était pas du nettoyage, cela faisait partie du correctif. La règle générale :
Le rendu via portail casse toute hypothèse fondée sur le confinement DOM. Les gestionnaires de clic-extérieur, les recherches
closest(), la délégation d'événements et les sélecteurs de descendants CSS changent tous de sens silencieusement. Quand vous placez quelque chose dans un portail, allez trouver le code qui supposait le confinement.
Ce que ces deux bugs ont en commun
Les deux bugs se situent dans l'écart entre « la bibliothèque se comporte correctement » et « l'utilisateur passe un mauvais moment ». Rien n'a levé d'exception. Rien n'a été journalisé. Dans les deux cas, le composant paraissait correct sur une capture d'écran.
Les leçons générales que je retiendrais :
- Sachez quels gestes la plateforme traite comme des éditions. La molette au-dessus d'un champ numérique avec le focus en est un ; le collage par clic du milieu sous Linux aussi. Si un champ contient de l'argent, auditez-les.
- Préférez supprimer une précondition plutôt qu'annuler un événement.
blur()l'a emporté surpreventDefault()parce qu'il visait la cause au lieu de supprimer le symptôme, et il a laissé intact tout comportement sans rapport. - Résolvez-le une fois, globalement, quand le mode de défaillance est « quelqu'un oublie ». Un correctif champ par champ est un correctif avec une date de péremption.
- Quand vous utilisez un portail, auditez les hypothèses de confinement. L'arbre DOM et l'arbre visuel cessent de concorder, et tout code qui les confondait est désormais erroné.
