Klickbare Tabellenzeilen: Fünf Edge Cases, die ein Full-Row-onClick überleben muss
Eine ganze Tabellenzeile ihre Detailseite öffnen zu lassen ist eine Einzeiler-Änderung — bis ein Nutzer Cmd-klickt, zum Kopieren einer Zelle zieht oder eine Sekunde lang über die Liste hovert. Das ist die Aufzeichnung der fünf Edge Cases, die ein Full-Row-Click-Handler überstehen muss — Neue-Tab-Modifier, Middle-Click, verschachtelte Links, Textauswahl und der Hover-Prefetch, der plötzlich jede Zeile vorlädt, über die der Zeiger kreuzt — und die Form des React-Handlers, der sie in einem Commit alle richtig löst.
Klickbare Tabellenzeilen: Fünf Edge Cases, die ein Full-Row-onClick überleben muss
Eine Listenseite ist eine Tabelle. Jede Zeile hat einen Firmennamen, etwas Metainformation und einen winzigen Pfeil-Button rechts, der die Zeile öffnet. Die ganze Zeile hebt sich beim Hovern hervor. Nutzer klicken die Zeile, und nichts passiert. Sie beschweren sich, weil die Zeile wie ein Button aussieht. Du nimmst dir zehn Sekunden Zeit und fügst onClick={() => navigate(href)} auf das <tr>, deployst, und innerhalb derselben Woche meldet ein Sales Engineer, dass Cmd-Click keinen neuen Tab mehr öffnet, eine Kandidatin beschwert sich, dass die Zeile sie am Kopieren eines Firmennamens hindert, und im Network-Tab sieht man, wie die Liste jede Zeile vorlädt, über die der Zeiger auf dem Weg zum eigentlichen Ziel streift.
Das ist die Aufzeichnung der Full-Row-klickbaren Tabellenzeile, die wir letzte Woche im AC-Center von Wield ausgeliefert haben — unserem Schweizer Recruiting-Intelligence-Produkt — und der fünf Edge Cases, die ein Einzeiler-onClick auf einem <tr> lösen muss, bevor die Abkürzung als "funktioniert" durchgeht. Der ganze Patch ist einundzwanzig Zeilen Produktionscode. Vierzehn dieser Zeilen stehen dort, weil die naheliegende Version falsch ist.
Warum "einfach onClick hinzufügen" nicht reicht
Die Auftragsliste — unsere Liste von Rekrutierungs-Mandaten, an denen ein Assessor gerade arbeitet — hatte die Standardform: ein Firmenname als <Link>, ein Mode- und Status-Badge, eine Zahl, ein Updated-Datum, ein Pfeil-Button rechts, der den Auftrag öffnete. Hover hob die ganze Zeile hervor. Klick auf die Anzahl Kandidaten tat nichts. Das ist die Discoverability-Falle, vor der Fitts' Law warnt: Der Hover-Bereich war ein breites Rechteck, die Hit-Fläche war ein dünner Link. Die Zeile sah aus wie eine Hit-Box, war aber fünf.
Die naive Lösung ist ein JSX-Attribut:
<tr onClick={() => navigate(href)}>
An einem Freitagnachmittag ausliefern — und zwischen Montagmorgen und Montagskaffee gehen fünf Dinge kaputt:
- Cmd-, Ctrl- und Shift-Click öffnen keinen neuen Tab mehr. Sie navigieren an Ort und Stelle, weil
navigate()Modifier-Tasten ignoriert: Der Handler ruft es auf, egal ob Cmd, Ctrl oder Shift gedrückt ist. - Middle-Click öffnet keinen neuen Tab mehr.
onClickfeuert nur für Primary-Button-Clicks; das native "Middle-Click öffnet in neuem Tab"-Verhalten des Browsers ist an Anchors mithrefgekoppelt, nicht an ein<tr>. - Rechtsklick öffnet den Auftrag — sobald du Middle-Click reparierst.
onClickfeuert bei einem Rechtsklick nicht, der Einzeiler ist hier also sicher. Das Risiko kommt mit dem Fix für Punkt 2: Hängst duonMouseDownoderonAuxClickan, um Middle-Click abzufangen, und filterst Button2nicht heraus, öffnet auch ein Rechtsklick den Auftrag. - Ein Zieh-Vorgang, der Text markiert, navigiert jetzt weg. Die Nutzerin versucht, "Seeberg AG" aus der Zelle zu kopieren, lässt die Maustaste los und landet auf der Auftragsseite — die Markierung ist weg. In einer Liste mit zwanzig Firmen fühlt sich die Tabelle so kaputt an.
- Der eigene
<Link>der Zeile feuert doppelt. Der Anchor navigiert; der Zeilen-Handler navigiert nochmal. Auf dem Happy Path ist die zweite Navigation ein No-op, aber bei Cmd-Click öffnet der Link einen neuen Tab und die Zeile öffnet die Seite im aktuellen Tab — schlimmer als beide Verhalten einzeln.
Dazu kommt ein sechster, produktspezifischer Fall: Die Zeile prefetchte beim Hovern ihren Workspace. Das ist das richtige Muster auf einem dünnen Link, wo der Zeiger den Link nur berührt, wenn die Nutzerin es wirklich meint. Macht man die ganze Zeile zur Hit-Fläche, ist Hover plötzlich ein starkes Signal für gar nichts Bestimmtes: Der Zeiger kreuzt Zeilen auf dem Weg woanders hin, scrollt durch die Liste, überfliegt einen Header. Bei einer Liste mit dreissig Mandaten sind das dreissig Prefetches von dreissig Workspaces, nur weil jemand scrollt, um einen bestimmten zu finden.
Der Handler, der alle fünf überlebt
Der ganze Handler sind zwölf Zeilen, und jede Zeile beantwortet eine der Kanten oben:
function openRow(event: MouseEvent<HTMLTableRowElement>) {
if (
event.button > 1 ||
(event.target as HTMLElement).closest("a, button") ||
window.getSelection()?.toString()
) return
if (event.button === 1 || event.metaKey || event.ctrlKey || event.shiftKey) {
window.open(href, "_blank", "noopener")
} else {
navigate(href)
}
}
Und er hängt an der Zeile mit zwei Listenern, nicht einem:
<tr onClick={openRow} onAuxClick={openRow} onMouseDown={warm} className="cursor-pointer ...">
Das ist das ganze Ding. Der Rest dieses Textes ist, warum jede Zeile dort steht, denn die Form ist die Sorte, die im Nachhinein offensichtlich wirkt und unverzeihlich ist, wenn man ein einzelnes Stück entfernt.
event.button > 1 — der Rechtsklick-Schutz
onAuxClick feuert für jeden Nicht-Primary-Button, Rechtsklick inklusive. MDNs MouseEvent.button-Referenz nennt 2 als rechte Taste und 0 als Primary; Middle ist 1. Das return bei > 1 lässt Rechtsklick, Zurück- und Vorwärts-Seitentasten durch zum Kontextmenü bzw. zur Verlaufsnavigation des Browsers — genau das, was jede Nutzerin bei einem Rechtsklick auf eine Tabellenzeile erwartet.
closest("a, button") — den verschachtelten interaktiven Inhalt in Ruhe lassen
Die Zeile enthält zwei Dinge, die die Nutzerin tatsächlich klicken könnte: den Firmennamen-<Link> und den Pfeil-<Link> rechts. Beide funktionieren weiterhin für Tastatur- und Screenreader-Nutzende — deshalb bleiben sie im DOM, statt durch den Zeilen-Handler ersetzt zu werden. event.target.closest("a, button") liefert truthy, wenn der echte Klick in einem der beiden entstand; dann tritt der Zeilen-Handler zur Seite und der Link erledigt seinen Job. Das ist der eine Teil des Handlers, der aus Barrierefreiheits-Sicht nicht verhandelbar ist: Ein <tr> ist kein Link, kann kein href tragen, ist nicht fokussierbar und wird von Hilfstechnik nicht als Link vorgelesen. Der sichtbare Link darin leistet die eigentliche semantische Arbeit; der Zeilenklick ist nur ein Komfort für Zeiger-Nutzende.
window.getSelection()?.toString() — nicht wegnavigieren beim Drag-to-Copy
Das ist der Fall, den die meisten übersehen. Zieht eine Nutzerin über die Zeile, um Text zu markieren, und lässt auf der Zeile los, feuert click mit der Markierung noch intakt. Das Prüfen von window.getSelection()?.toString() und das return bei nicht-leerer Markierung lässt Drag-to-Copy sich so verhalten wie in jeder anderen Tabelle. Es gibt eine subtile Variante dieses Bugs: Text in einer Zeile markieren und auf einer anderen loslassen feuert ebenfalls click; derselbe Guard fängt das ab. Es kostet einen DOM-Read pro Klick — vernachlässigbar.
event.button === 1 || metaKey || ctrlKey || shiftKey — der Neue-Tab-Vertrag
Die Standard-Modifier des Browsers für Anchors sind: Cmd auf macOS, Ctrl auf Windows und Linux, Shift für ein neues Fenster, und Middle-Click. Auf einem <tr> gehen alle vier verloren, wenn man sie nicht selbst reproduziert. Die Reihenfolge in der Bedingung ist Absicht: button === 1 muss hier geprüft werden, weil Rechtsklick weiter oben bereits herausgefiltert ist — aber Middle-Click muss in einen neuen Tab öffnen, nicht an Ort und Stelle navigieren.
onAuxClick für Middle-Click
onClick feuert nie für die mittlere Maustaste. Browser leiten Nicht-Primary-Klicks über auxclick. Denselben Handler an onClick und onAuxClick zu hängen, ist das, was Middle-Click überhaupt erst zum Handler bringt. Das ist eine Einzeiler-Konsequenz davon, dass die Geschichte des HTML-click-Events vor der Zeit liegt, in der Browser sich um Middle-Click-Neue-Tab-Semantik gekümmert haben; auxclick ist das moderne Event, das das repariert.
Der Hover-Prefetch, der zum Prefetch jeder Zeile wurde
Der Patch hat noch ein Stück, das speziell für Listen dieser Grösse gilt und sich zu heben lohnt, weil es verallgemeinerbar ist. Die Zeile rief prefetchWorkspace und prefetchView via TanStack Query auf onMouseEnter / onFocus des Links auf, über unseren useIntent-Helper. Das ist das richtige Muster auf einem dünnen Link: Hover ist ein starkes Intent-Signal, weil der Zeiger den Link nur berührt, wenn die Nutzerin es meint. Macht man die ganze Zeile zur Hit-Fläche, ist Hover plötzlich ein starkes Signal für gar nichts Bestimmtes: Der Zeiger kreuzt Zeilen auf dem Weg woanders hin, scrollt durch die Liste, überfliegt einen Header.
Die Lösung: den Warmup-Aufruf von Hover auf mousedown verschieben:
<tr onMouseDown={warm} ...>
mousedown feuert, wenn die Taste runtergeht, click erst, wenn sie wieder oben ist. Diese Lücke reicht, um einen Prefetch anzustossen, während der Browser noch entscheidet, ob der Klick ein Klick oder ein Drag wird. Es feuert ausserdem nur, wenn die Nutzerin sich tatsächlich auf eine Aktion festlegt — die Dreissig-Prefetches-beim-Scrollen-Variante verschwindet damit. Das Muster ist dasselbe, das wir im Serverless-Cold-Start-Warmup-Ping-Writeup beschrieben haben: Wärme das, was du gleich brauchst, aber nur, wenn es echte Hinweise gibt, dass die Nutzerin es gleich braucht. Im Backend ist dieser Hinweis das Feuern eines Claim-Arms. Im Frontend ist er mousedown, nicht mouseenter.
Ein Nebeneffekt, den man benennen sollte: Der Tastatur- und Screenreader-Pfad läuft weiter über den inneren <Link>, der weiterhin den fokus-basierten Prefetch von useIntent trägt. Tastaturnutzende bekommen das Warm; Zeigernutzende bekommen das Warm; das Einzige, was kein Warm mehr bekommt, ist die Zeiger-Trajektorie — und die war ohnehin nie ein Signal.
Drei Regeln, die sich verallgemeinern lassen
Drei Regeln überleben diesen Patch und gelten für jede "mach den ganzen Container klickbar"-Änderung — egal ob der Container eine Listenzeile, eine Card, ein Dashboard-Tile oder ein Item in einer Kanban-Spalte ist:
Ein <tr> ist kein Link, und darf gegenüber der Tastatur nicht so tun, als wäre es einer. Full-Row-Click ist eine reine Zeiger-Affordance. Der echte semantische Link bleibt in der Zeile und trägt weiterhin Fokus, Tastaturaktivierung, href und aria-current. Den inneren Link zu entfernen, um das Markup zu "vereinfachen", ist, wie man eine unzugängliche Liste ausliefert, die eine manuelle QA besteht, weil die QA-Mitarbeiterin eine Maus benutzt. Das ist dieselbe Disziplin, die den URL-Token-Portal-Auth-Rewrite sicher gemacht hat: Die zeigerfreundliche Affordance sitzt auf einem korrekten darunterliegenden Vertrag, nicht an seiner Stelle.
Modifier-Tasten und Middle-Click sind ein Vertrag, kein Feature. Nutzende erwarten, dass Cmd-Click überall einen neuen Tab öffnet, nicht nur auf <a>. Fängt deine Komponente Klicks ab, besitzt sie den ganzen Vertrag: Modifier-Handling, Middle-Click-Handling, Rechtsklick-Durchlass, Textauswahl-Durchlass. Die Hälfte auszuliefern ist schlimmer als nichts auszuliefern, weil die ausgelieferte Hälfte stillschweigend ein Muskelgedächtnis-Shortcut bricht, über den Nutzende keinen Bug-Report schreiben — sie hören einfach auf, die Liste zu benutzen.
Hover ist nur dann Intent, wenn die Hit-Box klein ist. Ein Warm bei mouseenter ist ein billiger Trick auf einem schmalen Link und ein teurer Fehler auf einer vollbreiten Zeile. Die Breite der Hit-Box ist die Heuristik: Je breiter die Hit-Box, desto schwächer ist Hover als Intent-Signal, und desto stärker spricht alles dafür, den Warm auf mousedown oder focus zu verschieben. Dieselbe Überlegung trägt das Dashboard-Prefetch-Muster, das wir im useQuery-vs-useEffect-Rewrite auseinandergenommen haben: Intent ist ein Signal mit Kosten, und die Kosten trägt der, der dem Signal am nächsten steht.
Die Komposition ist, was den Patch klein macht. Jeder der fünf Edge Cases einzeln ist eine Zwei-Minuten-Reparatur. Zusammen in einem Handler ausgeliefert, sind sie der Unterschied zwischen einer Zeile, die ein Button ist, und einer Zeile, die wie ein Button aussieht. Die Einzeiler-Version ist die Form, die die meisten Entwicklerinnen zuerst schreiben. Die Zwölf-Zeilen-Version ist die Form, die den Kontakt mit echten Nutzenden überlebt, die Cmd-klicken, zum Kopieren ziehen, vorbeiscrollen und auf Windows Ctrl drücken.
Wenn du eine Liste in eine Menge klickbarer Zeilen verwandelst — oder merkst, dass Nutzende deiner Tabelle immer noch den Pfeil statt die Zeile klicken — buche einen kostenlosen AI-Potenzial-Check, oder lies das Debounced-Autosave-Race-Condition-Writeup für dasselbe "offensichtliche Einzeiler-Änderung legt vier unsichtbare Kanten frei"-Thema, nur auf dem Save-Pfad statt dem Navigations-Pfad.
Quellen: MDN — MouseEvent.button · TanStack Query — Prefetching
wield · Die Recruiting‑Pipeline, die mit deinem Volumen mitskaliert.
CV‑Pipeline mit KI‑gestützter Dossier‑Generierung und Bewertung. Für Recruiter, die hundert Bewerbungen in einer Stunde sortieren — ohne Qualität zu verlieren.