major
#29448
React: Verbesserungen der Tabellen im React-Layout
Sammelticket für Verbesserungen der Tabellen in der React-View-Schicht. Die einzelnen Punkte werden hier gesammelt und einzeln umgesetzt.
Punkte
1. Spaltenfilter passend zum Attributtyp
Ein Boolean-Attribut (tl.core:Boolean) bekam in der Tabelle den Text-Filter angeboten statt der Ja/Nein-Auswahl — zu sehen an der Spalte active im „Attribute"-Beispiel der React-Demo.
Ursache: ColumnProviderService leitet den Spaltenfilter aus dem Application-Type des Storage-Mappings ab. BooleanMapping meldet als Application-Type den primitiven Typ boolean, und Boolean.class.isAssignableFrom(boolean.class) ist false. Damit fiel die Spalte auf die Label-basierte Text-Filterung zurück. tl.core:Tristate war nicht betroffen, weil dessen DirectMapping java.lang.Boolean meldet.
Lösung: Der Application-Type wird auf seinen Wrapper-Typ normalisiert. Zusätzlich sind BOOLEAN und TRISTATE unterschieden: Ein zweiwertiges Boolean hat keine leeren Zellen (BooleanMapping bildet null auf false ab), sein Filter bietet daher nur die beiden Wert-Optionen an; die Option „kein Wert" gibt es weiterhin beim Tristate. Dazu trägt FilterInput.Bool ein Flag, ob die Spalte leere Zellen hat.
2. Spalten auswählen und ihre Reihenfolge ändern
Welche Spalten eine Tabelle anzeigt und in welcher Reihenfolge, war für den Benutzer nicht wählbar: Das Modell (TableView) konnte Spalten ein- und ausblenden und verschieben, aber es gab keine Bedienung dafür — nur das Ziehen eines Spaltenkopfes ändert die Reihenfolge, und die ausgeblendeten Spalten waren dem Client überhaupt nicht bekannt.
Lösung: Ein Zahnrad-Knopf am rechten Rand der Tabellen-Kopfzeile öffnet die Spaltenauswahl. Er liegt über der Kopfzeile, nicht daneben: Eine um seine Breite verkürzte Kopfzeile wäre schmaler als der Tabellenkörper und würde die letzte Überschrift dauerhaft abschneiden, auch durch Scrollen nicht erreichbar. Der Dialog zeigt alle Spalten der Tabelle als Liste — die angezeigten in ihrer Reihenfolge zuerst, danach die ausgeblendeten —, jede mit einem Ankreuzfeld und per Ziehen umsortierbar. Er arbeitet auf einer Arbeitskopie: „Abbrechen" verwirft, „OK" setzt Auswahl und Reihenfolge in einem Schritt, „Standard wiederherstellen" stellt den definierten Zustand wieder her. Die letzte angezeigte Spalte lässt sich nicht abwählen.
Damit der Knopf nichts verdeckt, halten Kopfzeile und Körper an ihrem Ende seine Breite frei. Ohne diese Reserve war der Filter der letzten Spalte nicht bedienbar, sobald die Spalten den verfügbaren Platz ausfüllten: Ein Klick auf das Filtersymbol landete auf dem Knopf, und es gab keinen Scrollbereich, um das Symbol darunter hervorzuholen. Die Reserve ist Innenabstand und nicht Breite — als Breite hätte die letzte Körperzelle sie mitgenommen (sie wächst in den freien Raum) und wäre damit breiter als ihre Kopfzelle geworden.
Dabei kam heraus, dass die Personalisierung die Auswahl gar nicht speichern konnte: columnOrder kodiert Menge und Reihenfolge, so dass beim Wiederherstellen nicht unterscheidbar war, ob eine fehlende Spalte ausgeblendet wurde oder erst später zur Tabelle hinzukam — jede ausgeblendete Spalte kam als „neue" Spalte zurück. Der TableViewState führt deshalb zusätzlich die bewusst ausgeblendeten Spalten.
Neu im Modell: TableView.columnOptions() (alle Spalten mit Sichtbarkeitskennzeichen), TableView.setColumnOrder(List) (Auswahl und Reihenfolge in einem Schritt, damit nicht jeder Zwischenstand persistiert und gemeldet wird) und TableView.defaultColumnOrder().
3. Spalten zu allen Attributen des konfigurierten Typs anbieten
Eine Tabelle mit explizit konfigurierten `<column>`s konnte nur diese Spalten zeigen. Wer eine weitere Eigenschaft des Zeilentyps sehen wollte, musste die Konfiguration ändern.
Lösung: Ist zusätzlich types angegeben, richtet die Tabelle Spalten für alle weiteren Attribute dieser Typen ein — dieselbe Menge, die auch ein Formular anzeigt (also ohne die als versteckt annotierten), in Deklarationsreihenfolge und aus dem Typ abgeleitet. Diese Spalten sind zunächst nicht angezeigt: Sie erscheinen in der Spaltenauswahl (Punkt 2) als abgewählte Einträge und lassen sich dort einschalten. Ohne types passiert nichts, weil ein aus der ersten Zeile geratener Zeilentyp je nach Datenlage andere Spalten anbieten würde. Das gilt für die Lese- und die editierbare Tabelle (row-edit); eine angebotene Spalte ist dort editierbar, wenn ihr Attribut es laut Modell ist — es gibt kein <column>, das ein Read-only-Flag setzen könnte.
Damit eine bereits gespeicherte Personalisierung nicht plötzlich alle Attribute einblendet, kennt die Wiederherstellung den Unterschied zwischen „neu hinzugekommen" (wird angezeigt) und „wird nur angeboten" (bleibt aus): DefaultTableView nimmt die standardmäßig ausgeblendeten Spalten entgegen. Ebenso stellt „Standard wiederherstellen" nur die konfigurierten Spalten wieder her, nicht jedes angebotene Attribut.
Dabei fiel auf, dass die Aktionsspalten der editierbaren Tabelle (Detail öffnen, Zeile löschen) als Eintrag ohne Beschriftung in der Auswahl standen — und abwählbar waren, womit die Aktion unwiederbringlich verschwunden wäre: Eine abgewählte Spalte ohne Beschriftung hätte auch keinen Eintrag zum Wiedereinschalten. Schlimmer noch lieferte der Dialog sie nie zurück, so dass jedes „OK" sie entfernt hätte. Neu ist deshalb Column.selectable() (Gegenstück zum vorhandenen frozenEligible()): Solche Spalten werden nicht als Option angeboten und behalten ihren Platz relativ zu den übrigen Spalten — eine führende bleibt vorn, eine abschließende hinten, unabhängig davon, wie viele Spalten die neue Anordnung hält.
4. Suche im Spaltendialog
Eine Tabelle über einen großen Typ bietet nach Punkt 3 eine Spalte je Attribut an (im Attribute-Beispiel 41 Einträge). Der Dialog hat daher ab elf Einträgen ein Suchfeld, das die Liste auf die passenden Beschriftungen einschränkt. Die vollständige Liste bleibt Bezug für die Einfügeposition beim Ziehen, so dass eine Zeile auch neben eine von der Suche ausgeblendete Zeile fallen kann. Die Liste scrollt dabei in einer festen Höhe: Würde sie mit der Trefferzahl wachsen und schrumpfen, würde sich der Dialog — und mit ihm die Schaltflächen unter dem Zeiger — bei jedem Tastendruck verschieben.
5. Ankreuzfelder im Filterdialog mit Beschriftung daneben
Im Filterdialog stand die Beschriftung eines Ankreuzfeldes über der Box statt daneben; „Ja" und „Nein" wirkten dadurch wie zwei unverbundene Zeilen.
Ursache war eine Lücke in der Formularschicht: LabelPosition.AFTER existierte im Java-Enum und im Client-Kontrakt, aber im Stylesheet fehlte die Regel .tlFormField--after — die Position blieb daher überall ohne Wirkung, nicht nur hier. Die Regel ist ergänzt, und der Filterdialog fordert sie für Ankreuzfelder an. Ein gemischtes Filterformular behält die Beschriftung des Muster-Eingabefeldes oben.
6. Fehler: Tabelle scrollt nach links, wenn ein Auswahlfeld in einer Zelle geöffnet wird
In einer waagerecht gescrollten, editierbaren Tabelle sprang die Anzeige an den linken Rand, sobald man die Optionen eines Auswahlfeldes in einer Zelle öffnete — zu sehen am Feld „Beobachter" in der letzten Spalte.
Ursache: Ein Klick in eine solche Zelle galt für die Tabelle als nicht-interaktiv (siehe Punkt 9). Sie merkte sich daher, den Cursor in diese Spalte zu setzen, sobald die Zeile editierbar gerendert ist. Beim folgenden Server-Update fand sie dort kein Eingabefeld — Auswahlfelder haben keines — und wich auf das erste Eingabefeld der Zeile aus, mit preventScroll: false. Der Browser scrollte zu dieser weit links liegenden Zelle. Anschließend holte sich das Auswahlfeld den Fokus zurück, so dass nur der verlorene Scroll-Zustand übrig blieb.
Der Fehler ist unabhängig von den Punkten 1-5 und betraf jede waagerecht gescrollte Tabelle mit einem Auswahlfeld in einer Zelle; auffallen konnte er erst, seit Punkt 3 die Spalte „Beobachter" überhaupt verfügbar macht.
Lösung in TLTableView: Ist eine bestimmte Spalte gemeint, wird nicht mehr auf eine andere ausgewichen; liegt der Fokus inzwischen bewusst außerhalb der Tabelle (im Auswahl-Popup), holt sie ihn nicht zurück; und der Cursor wird mit preventScroll: true gesetzt, weil die geklickte Zelle bereits sichtbar ist.
7. Tristate-Attribut als Ankreuzfeld mit drei Zuständen
Ein Attribut vom Typ tl.core:Tristate bot in Formular und Tabelle ein Texteingabefeld an, in das man „true" oder „false" tippen musste. Ursache: In der Feldauswahl (FieldControlService) stand TRISTATE im selben case-Block wie STRING und landete damit beim Textfeld.
Lösung: BOOLEAN und TRISTATE bekommen beide das Ankreuzfeld, letzteres mit einem dritten Zustand für „kein Wert": Dieser wird als indeterminate (bzw. aria-checked="mixed") dargestellt, und ein Klick schaltet weiter in der Reihenfolge, die die klassische UI dokumentiert — angekreuzt, nicht angekreuzt, leer. Dazu trägt ReactCheckboxControl ein Flag, und CheckboxValueArguments liefert den umhüllten Boolean, weil „kein Wert" über die Leitung null ist.
8. Fehler: Bedienen eines Feldes in einer Zelle verwirft die Eingabe
Beim Bearbeiten in der Tabelle ging der gerade gesetzte Wert verloren: Das Ankreuzfeld des Tristate-Attributs sprang nach dem Klick auf den vorherigen Zustand zurück, so dass sich der dritte Zustand nicht erreichen ließ.
Ursache: Ein Klick auf ein Bedienelement in einer Zelle sendete zusätzlich eine Zeilen-Selektion. Der Server antwortete darauf mit der komplett neu gerenderten Zeile (inklusive aller Zell-Zustände) — und diese Antwort überschrieb im Client genau den Wert, den das Bedienelement im selben Moment sendete. Serverseitig kam der Wert korrekt an und lag auch im Overlay; nur die Anzeige fiel auf den Stand davor zurück. Betroffen war jedes Feld in einer editierbaren Zelle; bei Texteingaben fiel es seltener auf, weil der Rebuild dort meist vor dem Tippen abgeschlossen war.
Lösung in TLTableView: Ein Klick auf ein interaktives Element in einer '''bereits selektierten''' Zeile ist keine Selektionsgeste und sendet kein select mehr. Strg- und Umschalt-Klicks (Mehrfachauswahl) bleiben unberührt.
9. Gemeinsame Ursache von Punkt 6 und 8: Bedienelemente mit ARIA-Rolle
Die Tabelle erkannte ein Bedienelement in einer Zelle nur an seinem Element-Namen (input, textarea, select, button, a`). Ein Auswahlfeld ist aber ein `div mit role="combobox" — ein Klick darauf sah für die Tabelle daher wie ein Klick auf reinen Zellinhalt aus. Daraus entstanden Punkt 6 (Cursor wird in eine fremde Spalte gesetzt, Anzeige scrollt) und Punkt 8 (Zeilen-Selektion verwirft die Eingabe); die Symptome sind dort behoben, die Wurzel liegt hier.
Lösung: Die Erkennung berücksichtigt zusätzlich die ARIA-Rollen, mit denen ein Bedienelement seine Funktion trägt (combobox, listbox, option, button, link, checkbox, radio, switch, textbox, spinbutton, slider, menu, menuitem). Ein Klick auf reinen Zellinhalt wählt weiterhin die Zeile aus.
10. Feste Spalten
Beim waagerechten Scrollen liefen alle Spalten mit — die Namensspalte, an der man eine Zeile überhaupt erkennt, verschwand als erste. Das Modell konnte einen festen Spaltenvorspann (frozenCount) zwar führen, bedienbar war er nur über das Kontextmenü eines Spaltenkopfes, und konfigurieren ließ er sich nicht.
Lösung: <table fixed-columns="N"> hält die ersten N Spalten beim waagerechten Scrollen an ihrem Platz; die übrigen Spalten verschwinden hinter ihnen. Der Wert ist der Ausgangszustand — eine gespeicherte Personalisierung gewinnt, wie bei Spaltenbreiten und -reihenfolge auch. Für die editierbare Tabelle zählt er die Datenspalten: Eine führende Aktionsspalte (Detail öffnen) wird mitgehalten, sonst hielte fixed-columns="2" den Knopf und eine Datenspalte fest.
Die Grenze des festen Bereichs ist ein Griff in der Kopfzeile und per Ziehen auf eine andere Spaltengrenze verschiebbar. Sie rastet auf die Spaltengrenzen ein, die gerade zu sehen sind — eine herausgescrollte Grenze ist kein Ziel, sonst würde der feste Bereich breiter als die Tabelle. Beim Ziehen zeigt eine Linie über Kopfzeile und Zeilen, wo die Grenze landet. Ganz nach links gezogen bleibt keine Spalte fest; der Griff bleibt am linken Rand liegen, so dass sich von dort wieder ein fester Bereich herausziehen lässt.
Neu im Modell ist DefaultTableView.initialState(...), damit ein Aufrufer den Startzustand anreichern kann, und setFrozenColumnCount prüft die Grenze gegen Column.frozenEligible(): Der feste Bereich darf nicht hinter einer Aktionsspalte enden.
Fehler: feste Spalten wirkten nur in der Kopfzeile
Der feste Vorspann hielt die Überschriften an ihrem Platz, die Zellen darunter scrollten aber mit. Ursache: .tlTableView__row hatte overflow: hidden. Ein Vorfahre mit abschneidendem overflow zwischen einer position: sticky-Zelle und dem Scrollcontainer macht die Zeile zum Scroll-Kontext der Zelle — die Zelle klebt damit an der Zeile, die selbst mitscrollt. Das Abschneiden ist entbehrlich, weil jede Zelle ihren Inhalt selbst abschneidet. Der Fehler bestand, seit es feste Spalten gibt; auffallen konnte er kaum, weil sie nur über das Kontextmenü erreichbar waren.
11. Fehler: Kopfzeile und Körper laufen am rechten Ende auseinander
Ganz nach rechts gescrollt scrollte der Körper weiter als die Kopfzeile, so dass die Überschriften neben den falschen Spalten standen — um die Breite einer Scrollleiste versetzt.
Ursache: Der Körper hat eine senkrechte Scrollleiste, die Kopfzeile nicht. Ihr Sichtfenster ist damit um deren Breite breiter und ihr Scrollweg um dieselbe Breite kürzer; die Gleichsetzung header.scrollLeft = body.scrollLeft wird am Ende auf das Maximum der Kopfzeile geklemmt. Gemessen an der Demo (Leiste 15 px): Scrollweg der Kopfzeile 589 gegen 604 des Körpers, die letzte Spalte stand mit Kopf bei x=718 und Zelle bei x=703. Der Fehler ist unabhängig von den anderen Punkten und bestand, seit Kopfzeile und Körper getrennte Scrollcontainer sind.
Lösung: Die Kopfzeile endet mit einer Reserve in Breite der Scrollleiste. Deren Breite wird gemessen (offsetWidth - clientWidth des Körpers) und per ResizeObserver nachgeführt, denn die Leiste kommt und geht mit der Zeilenzahl und ist je nach Plattform 0 breit (Overlay). Die Reserve ist wieder Innenabstand und kein verkleinertes Sichtfenster — sonst wäre die letzte Überschrift dauerhaft angeschnitten (siehe Punkt 2).
12. Fehler: Uhrzeit-Attribut bekommt einen Datumsselektor
Ein Attribut vom Typ tl.core:Time bot in Formular und Tabelle einen Datumsselektor an — ein Feld, in dem sich der eigentliche Wert gar nicht eingeben lässt. tl.core:DateTime war ebenso betroffen: Der Datumsselektor kennt keine Uhrzeit und hat sie beim Bearbeiten stillschweigend verworfen.
Ursache: tl.core:Date, tl.core:Day, tl.core:Time und tl.core:DateTime haben alle die Modell-Art Kind.DATE und werden als Date gespeichert; welchen Teil eines Zeitpunktes ein Attribut meint, sagt erst dessen ConfigType-Annotation (TIME, DATE_TIME). Die Feldauswahl sah nur die Modell-Art.
Lösung: ReactDatePickerControl kennt die Art des Wertes (Kind.DATE, TIME, DATE_TIME) und bestimmt daraus alle drei Darstellungen: das HTML-Eingabefeld (date, time, datetime-local), die damit ausgetauschte ISO-Form und das lokalisierte Anzeigeformat. Die ISO-Umrechnung läuft in der Zeitzone des Benutzers, damit Eingabefeld und Anzeige denselben Wert zeigen; beim Lesen wird auch die Form mit Sekunden akzeptiert, die ein Browser bei Sekunden ungleich null sendet. Die Art leitet DatePickerControlProvider aus ConfigType ab — die Annotation am Attribut vor der an seinem Typ.
Dieselbe Ableitung nutzt die Tabellenschicht: Eine Filtergrenze wird in den Formaten der Spalte gelesen, also im Anzeigeformat und dessen kurzer Variante („14:30:00" wie auch „14:30"). Vorher erwartete der Filter einer Uhrzeit-Spalte eine Datumseingabe, konnte sie nicht deuten und blieb wirkungslos.
13. Fehler: Klick neben ein Feld im Dialog springt zum ersten Feld
In einem gescrollten Formular in einem Dialog sprang die Anzeige an den Anfang zurück, sobald man nach einer Eingabe neben das Feld klickte: Der Cursor landete im ersten Feld des Dialogs und der Dialog scrollte dorthin. Im Hauptfenster passierte das nicht.
Ursache: Der Dialog-Hintergrund (.tlDialog__backdrop) trägt tabindex="-1". Ein Klick auf eine Stelle im Dialog, die keinen Fokus annehmen kann (eine Beschriftung, der Freiraum neben dem Feld), schickt den Fokus deshalb auf den nächsten fokussierbaren Vorfahren — eben diesen Hintergrund. Der liegt außerhalb des gefangenen Dialogelements, weshalb der Focus-Trap das für ein Entkommen hielt und den Fokus in das erste Feld zurückholte; der Browser scrollte dorthin. Im Hauptfenster gibt es keinen Trap, dort fällt der Fokus einfach auf body.
Lösung: Der Trap unterscheidet „Fokus fallen gelassen" von „Fokus entwendet": Ist das Ziel ein Vorfahre des gefangenen Elements (Hintergrund, body), hat nichts die Oberfläche verlassen und der Fokus bleibt liegen. Ein echtes Entkommen — ein Bedienelement im Hintergrund zieht den Fokus, oder Tab läuft über den Rand hinaus — wird weiterhin zurückgeholt.
14. Boolean als Radio-Buttons oder Auswahlfeld
Die Annotation <boolean-display presentation="radio"/> (bzw. "select") blieb in der React-Schicht wirkungslos: Jedes Boolean-Attribut bekam ein Ankreuzfeld, auch die Attribute activeRadio und activeSelect des Attribute-Beispiels.
Lösung: Das neue ReactBooleanChoiceControl (Client TLBooleanChoice) bietet die Werte als Auswahl an — Radio-Buttons oder ein Ja/Nein-Auswahlfeld. Die Optionen sind so beschriftet, wie ein Boolean überall sonst dargestellt wird, ein Tristate bekommt „Kein Wert" als dritte Option, und der Wert geht als Boolean/`null` über dieselbe typisierte Wert-Nachricht wie beim Ankreuzfeld. Der frühere CheckboxControlProvider heißt jetzt BooleanControlProvider und wählt anhand von BooleanDisplay — Annotation am Attribut vor der am Typ — zwischen Ankreuzfeld, Radio-Buttons und Auswahlfeld; ohne Annotation bleibt es beim Ankreuzfeld.
Die Optionsreihe nennt in ihrem Stylesheet-Selektor beide Klassen. Sonst gewinnt in einer Tabellenzelle die Regel, die ein Bedienelement zum Block macht: Die Reihe wäre dort kein Flex-Container, ihr Abstand bliebe wirkungslos und die Optionen stünden ohne Zwischenraum aneinander (mit Rand statt Abstand rutschte die zweite Option gar in eine zweite, in der Zeilenhöhe nicht sichtbare Zeile).