Zum Hauptinhalt springen
EngineeringReactJavaScript
Das Mausrad verändert heimlich Ihre Zahlenfelder

Das Mausrad verändert heimlich Ihre Zahlenfelder

Die meisten UI-Bugs sind kosmetisch. Einige wenige verändern Zahlen, die der Nutzer gar nicht ändern wollte, und wenn diese Zahlen Hebel, Ordergröße oder ein Preis sind, ist der Bug nicht mehr kosmetisch.

Dies sind zwei, auf die wir gestoßen sind, beide verursacht durch Browser- und Bibliotheksverhalten, das technisch dokumentiert und praktisch unsichtbar ist. Die Fixes sind klein. Sie zu finden war es nicht.

Das Wichtigste

  • Ein fokussiertes <input type="number"> zählt hoch oder runter, wenn Sie mit dem Rad darüber scrollen. Bei einem Geldfeld überschreibt das den Wert stillschweigend.
  • Beheben Sie es mit blur(), nicht mit preventDefault(): Der Fokusverlust bricht das Hochzählen ab und lässt das Scrollen von Seite und Modal vollständig intakt.
  • Ein Radix-Popover, das per Portal an document.body ausgelagert wird, kann den Fokus innerhalb eines Radix-Dialogs nicht halten, weil der Focus-Scope des Dialogs den Fokus wieder herauszieht. Das Feld wirkt lebendig und schluckt jeden Tastendruck.
  • Übergeben Sie das eigene Element des Dialogs als Portal-Container des Popovers, damit es Teil des eingefangenen Teilbaums wird.
  • Dieser Fix macht selbstgebaute Click-outside-Handler ungültig: Sobald Inhalt per Portal ausgelagert ist, ist er kein DOM-Nachfahre mehr des Wrappers, gegen den Sie gemessen haben.

1. Das Rad ist eine Bearbeitungsgeste, und niemand hat es dem Nutzer gesagt

Native Zahlenfelder haben ein Verhalten, das bis in die Spezifikation zurückreicht: Wenn das Feld fokussiert ist, verändert das Scrollen mit dem Mausrad darüber den Wert schrittweise nach oben oder unten, und zwar um den Wert seines step-Attributs.

Bei einem Mengenwähler in einem Warenkorb ist das ein leichtes Ärgernis. In unseren Formularen ist es etwas völlig anderes, denn die betreffenden Felder sind Hebel, Handelsbetrag, Gewinnbeteiligungs-Prozentsatz und Listungspreis. Der Nutzer klickt in ein Feld, scrollt die Seite, um den Rest des Formulars zu lesen, und die Zahl, die er bereits gesetzt hatte, hat sich verändert. Nichts weist darauf hin. Das Formular validiert weiterhin. Der Bot startet mit einem Wert, den der Nutzer nie gewählt hat.

Der naive Fix besteht darin, an jedes Feld einen wheel-Handler zu hängen und preventDefault() aufzurufen. Das funktioniert, und es macht zugleich das Scrollen kaputt: Der Nutzer kann die Seite nicht mehr scrollen, während der Cursor zufällig über einem Zahlenfeld liegt, was sich auf eine völlig andere Weise kaputt anfühlt.

Der bessere Fix nutzt die Vorbedingung aus der Spezifikation. Das Hochzählen passiert nur, wenn das Feld fokussiert ist, also genügt es, den Fokus zu entfernen:

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 });

Drei bewusste Entscheidungen in diesem Snippet:

  • blur() statt preventDefault(). Wir brechen nicht das Scrollen ab, wir entfernen die Bedingung, die es zu einer Bearbeitung macht. Die Seite scrollt weiterhin normal.
  • capture: true. Der Listener beobachtet das Ereignis auf dem Weg nach unten, sodass er auch dann auslöst, wenn eine innere Komponente die Weitergabe stoppt.
  • passive: true. Sicher gerade weil wir nie preventDefault() aufrufen, und es hält den Listener aus dem kritischen Pfad des Scrollens heraus.

Ein einziger globaler Listener, einmal eingehängt, deckt jedes Zahlenfeld in der App ab, auch solche, die es noch gar nicht gibt. Die Alternative, jedes Feld einzeln zu flicken, versagt in dem Moment, in dem jemand ein neues Formular hinzufügt und es vergisst.

2. Ein Popover in einem Dialog, in das man nicht tippen kann

Der zweite trat als unmöglicher Bugreport auf: ein Suchfeld in einem Dropdown, in einem Modal, das sichtbar fokussierte und dann alles ignorierte, was man hineingetippt hat.

Die Ursache sind zwei korrekte Verhaltensweisen, die aufeinanderprallen.

Radix-Popovers lagern ihren Inhalt standardmäßig per Portal an document.body aus, was auf einer normalen Seite richtig ist, weil es Overflow- und Stacking-Kontexte umgeht. Radix-Dialoge fangen den Fokus in ihrem eigenen Teilbaum ein, was ebenfalls richtig ist und ein Modal überhaupt erst zu einem Modal macht.

Kombiniert man beides, liegt der Inhalt des Popovers, in DOM-Begriffen, außerhalb des Dialogs. Der Focus-Scope des Dialogs prüft jedes eingehende Fokus-Ereignis in etwa so:

container.contains(target) ? allow : focus(lastFocusedElement)

Das Suchfeld ist nicht im Dialog enthalten, also wurde jedes Fokus-Ereignis, das darauf landete, sofort rückgängig gemacht. Das Feld wurde gerendert, zeigte für einen Augenblick einen Cursor und verlor den Fokus, bevor ein Tastendruck registriert werden konnte. Es wirkte lebendig und verschluckte alles.

Der Fix besteht darin, aufzuhören, über den Aufenthaltsort des Popovers zu lügen. Radix' Portal akzeptiert einen container, also rendern wir ein leeres div mit Höhe null im Dialog und lagern das Popover per Portal dorthin aus:

// inside the dialog
<div ref={setDropdownHost} />
...
<PopoverContent container={dropdownHost}>…</PopoverContent>

Zwei Details, die man leicht falsch macht:

  • State, nicht ein Ref. Der Host-Knoten existiert beim ersten Render nicht, und das Portal braucht ein erneutes Rendern, sobald er es tut. Ein useRef würde den Knoten halten, aber dieses Rendern nie auslösen.
  • Der Host darf das Layout nicht beeinflussen. Er ist leer und hat Höhe null, sodass er an jeder passenden Stelle im Markup des Dialogs sitzen kann, ohne irgendetwas zu verschieben.

3. Der Fix, der still den Code daneben kaputt macht

Das ist der Teil, vor dem ich am eindringlichsten warnen würde, denn hier wird ein sauberer Fix zu einer Regression.

Bevor wir zu einem per Portal ausgelagerten Popover übergingen, hatte das Dropdown einen selbstgebauten Click-outside-Handler: einen Ref auf den Wrapper halten, auf Dokument-Klicks lauschen, schließen, wenn der Klick nicht innerhalb lag.

Sobald Inhalt per Portal ausgelagert ist, ist er kein DOM-Nachfahre dieses Wrappers mehr, obwohl er visuell darin liegt. Der Handler sieht also nun Klicks auf das eigene Suchfeld des Dropdowns als Klicks außerhalb und schließt genau das, was der Nutzer benutzen will.

Diesen Handler zu entfernen war keine Aufräumarbeit, es war Teil des Fixes. Die allgemeine Regel:

Portale brechen jede Annahme, die auf DOM-Enthaltensein beruht. Click-outside-Handler, closest()-Abfragen, Event-Delegation und CSS-Nachfahren-Selektoren ändern alle still ihre Bedeutung. Wenn Sie etwas per Portal auslagern, suchen Sie den Code, der Enthaltensein angenommen hat.

Was diese beiden gemeinsam haben

Beide Bugs sitzen in der Lücke zwischen „die Bibliothek verhält sich korrekt" und „der Nutzer hat eine schlechte Zeit". Nichts hat eine Exception geworfen. Nichts wurde geloggt. In beiden Fällen sah die Komponente auf einem Screenshot richtig aus.

Die allgemeinen Lehren, die ich behalten würde:

  • Wissen Sie, welche Gesten die Plattform als Bearbeitung behandelt. Das Rad über einem fokussierten Zahlenfeld ist eine davon; ebenso das Einfügen per mittlerem Mausklick unter Linux. Wenn ein Feld Geld enthält, prüfen Sie sie.
  • Ziehen Sie das Entfernen einer Vorbedingung dem Abbrechen eines Ereignisses vor. blur() schlug preventDefault(), weil es die Ursache angegangen ist statt das Symptom zu unterdrücken, und es hat unbeteiligtes Verhalten unangetastet gelassen.
  • Lösen Sie es einmal, global, wenn der Fehlermodus „jemand vergisst es" lautet. Ein Fix pro Feld ist ein Fix mit Verfallsdatum.
  • Wenn Sie ein Portal verwenden, prüfen Sie auf Annahmen über Enthaltensein. Der DOM-Baum und der visuelle Baum stimmen nicht mehr überein, und jeder Code, der beides vermengt hat, ist nun falsch.

Weiterführende Lektüre

Bereit, Ihren Handel zu automatisieren?

Starten Sie Ihre Reise mit Freya Finance und lassen Sie KI-gestützte Bots für Sie arbeiten.

Kostenlos starten
Dieser Inhalt dient nur zu Bildungszwecken und stellt keine Finanzberatung dar. Der Handel mit Kryptowährungen birgt erhebliche Risiken. Investieren Sie nie mehr, als Sie sich leisten können zu verlieren.