tuix-table

Eine Tabelle mit Slot-Zellen, Kartenlayout auf Mobile und Sortierung und Pagination, die die Anwendung steuert.

tuix-table zeichnet die Zeilen, die es bekommt. Es sortiert, filtert und schneidet sie nie von selbst.

Sortierung und Pagination sind gesteuert. Die Tabelle meldet, was der Nutzer angeklickt hat, und die Anwendung entscheidet, was danach passiert:

Der Nutzer klickt. Eine sortierbare Kopfzelle, einen Seitenpfeil oder die Auswahl der Seitengrösse.

Die Tabelle feuert ein Event. sort, next-page, previous-page oder page-size-change. Die Tabelle selbst ändert nichts.

Der eigene Code antwortet. Er sortiert oder holt die Zeilen und schreibt data und den passenden Zustand zurück auf das Element.

In der Tabelle bewegt sich nichts, bis der letzte Schritt läuft. Jedes Beispiel unten zeigt ihn in Angular, Vue und React.

Eine erste Tabelle

Zwei Properties genügen: headers beschreibt die Spalten, data hält die Zeilen.

Komponenten werden geladen…
const table = document.querySelector('tuix-table')
 
table.headers = [
    { name: 'ID', dataKey: 'id', width: 10 },
    { name: 'Rechnung', dataKey: 'name', width: 30 },
    {
        name: 'Status',
        dataKey: 'status',
        width: 20,
        cellColor: (row) => (row.status === 'Error' ? TextVariant.ERROR : TextVariant.SUCCESS),
    },
]
 
table.data = [{ id: 1, name: 'INV-2024-114', status: 'Success' }]

Jede Zeile braucht eine eindeutige id. Sie ist der Schlüssel beim Rendern und benennt die Slots der Zellen.

Spalten

Ein Objekt pro Spalte. Nötig sind nur dataKey und width.

FeldTypWofür
dataKeykeyof TRowDataWelches Feld der Zeile gedruckt wird. Nötig
widthnumberDie relative Breite der Spalte. Nötig
namestringDer Titel in der Kopfzeile
sortKeykeyof TRowDataMacht die Spalte sortierbar, siehe Sortieren
isSlotbooleanStatt des Werts einen Slot drucken
slotNamestringDer Basisname dieses Slots. Nötig mit isSlot
cellColor(row) => string | undefinedEine TextVariant für den Text der Zelle, Zeile für Zeile
cellBackgroundColor(row) => string | undefinedEine CSS-Farbe für die Zelle, Zeile für Zeile
interface IDataTableHeader<TRowData = Record<string, unknown>> {
    name?: string
    dataKey: keyof TRowData
    width: number
    isSlot?: boolean
    slotName?: string
    sortKey?: keyof TRowData
    cellColor?: (rowData: TRowData) => string | undefined
    cellBackgroundColor?: (rowData: TRowData) => string | undefined
}

Spaltenbreiten

width ist ein Gewicht, keine Pixelbreite. Die Breiten aller Spalten werden summiert und jede Spalte bekommt ihren Anteil, 10 / 30 / 20 und 1 / 3 / 2 ergeben also dasselbe Layout. Sind alle Breiten 0, teilen sich die Spalten den Platz gleichmässig.

Slot-Zellen

Mit isSlot: true druckt die Zelle <slot name="{slotName}-{row.id}"> statt eines Werts. Die id der Zeile steckt im Namen des Slots, jede Zelle hat also ihren eigenen:

Komponenten werden geladen…

Ein Slot nimmt so viele Elemente auf, wie die Zeile braucht. Das Beispiel legt ein Archivieren- und ein Benachrichtigen-Icon in jede Zelle. Die Framework-Tabs zeigen dasselbe Paar als @for-Block, als v-for und als map.

Sortieren

Die Tabelle zeichnet die Bedienelemente. Sortiert wird in der Anwendung.

Eine Spalte mit sortKey bekommt das Pfeilpaar, wird fokussierbar und reagiert auf einen Klick, auf Enter und auf die Leertaste. Unten ist jede Spalte sortierbar:

Komponenten werden geladen…

Die drei Schritte

Die Spalte kennzeichnen. sortKey nennt das Feld, nach dem geordnet wird. Es muss nicht der dataKey sein, eine formatierte Spalte oder eine mit Slot kann also nach jedem Feld der Zeile sortieren.

table.headers = [{ name: 'Rechnung', dataKey: 'name', sortKey: 'name', width: 30 }]

Auf sort hören. Das Detail trägt die Spalte und die Richtung, die die Tabelle verlangt: asc für eine neue Spalte, und die Gegenrichtung, wenn sortBy sie bereits nennt.

table.addEventListener('sort', (event) => {
    const { sortKey, sortDirection } = event.detail
})

Sortieren und den Zustand zurückschreiben. data bekommt die neue Reihenfolge, sortBy und sortDirection sagen der Kopfzeile, wo sie steht.

table.data = sortRows(rows, sortKey, sortDirection)
table.sortBy = sortKey
table.sortDirection = sortDirection

Wird nur data umsortiert, bewegen sich die Zeilen, während die Kopfzeile die vorige Spalte hervorhebt. Pfeil und aria-sort richten sich nach sortBy und sortDirection, nicht nach dem Klick.

Die Zeilen sortieren

Array.prototype.sort sortiert an Ort und Stelle, also eine Kopie sortieren. Das neue Array ist auch das, was React, Vue und Angular zum Neuzeichnen brauchen:

const compare = (a: Invoice, b: Invoice, key: keyof Invoice) => {
    const left = a[key]
    const right = b[key]
    return typeof left === 'number' && typeof right === 'number'
        ? left - right
        : String(left).localeCompare(String(right))
}
 
const sortRows = (rows: Invoice[], sortKey: keyof Invoice, sortDirection: SortDirection) =>
    [...rows].sort((a, b) => (sortDirection === 'asc' ? 1 : -1) * compare(a, b, sortKey))

Ein paar Punkte dazu:

  • Nach Typ vergleichen. Der Standardvergleich läuft über Text und stellt damit 10 vor 2. Zahlen subtrahieren, Text durch localeCompare schicken und Daten über ihre Zeitstempel vergleichen.
  • Festlegen, wohin leere Werte fallen. Ein Feld, das null sein kann, braucht eine eigene Regel, damit diese Zeilen immer auf derselben Seite landen.
  • Eine Spalte auf einmal. Das Detail trägt nie einen zweiten Key. Eine Ordnung über mehrere Spalten gehört dem eigenen Code, hinter demselben Event.
  • Paginierte Tabellen sortieren den ganzen Satz, nicht die sichtbare Seite. Auf dem Server sortieren, die erste Seite der neuen Ordnung holen und currentPage zurück auf 1 setzen. Die Tabelle setzt sie nicht selbst zurück.
  • sortBy erst setzen, wenn eine Spalte angeklickt wurde. Vorher ist kein Pfeil hervorgehoben und die Zeilen stehen so, wie sie übergeben wurden.

Pagination

Derselbe Vertrag wie beim Sortieren: die Fusszeile fragt, der eigene Code antwortet. Die Demo blättert durch 23 Zeilen.

Komponenten werden geladen…

Die Fusszeile einschalten. paginated zeigt sie, totalItems ist die Zahl über alle Seiten, und pageSizeOptions füllt die Auswahl der Seitengrösse.

Auf die drei Events hören. next-page, previous-page und page-size-change tragen alle { page, pageSize }, also die Seite, die die Tabelle verlangt.

table.addEventListener('next-page', (event) => {
    load(event.detail.page, event.detail.pageSize)
})

Die Seite zurückschreiben. data bekommt die Zeilen dieser Seite, currentPage und currentSize den Zustand.

Die Pfeile stoppen bei der ersten und bei der letzten Seite, die die Tabelle aus totalItems und der Seitengrösse berechnet. Eine andere Seitengrösse verlangt immer Seite 1.

Laden

isLoadingData ersetzt die Zeilen durch einen Loader. Kopfzeile und Pagination-Fusszeile bleiben stehen, und die Auswahl der Seitengrösse ist deaktiviert:

Komponenten werden geladen…

loader bestimmt sein Aussehen, jedes Feld ist optional:

FeldTypStandardWofür
sizestring'14px'Der Durchmesser eines Punkts
colorstringPalette.MAINDie Farbe der Punkte
visiblebooleantruefalse blendet den Loader ganz aus
ariaLabelstring'Loading'Was ein Screenreader ansagt
wrapperStyleRecord<string, string | number>KeinerÜberschreibt den zentrierenden Wrapper

Verhalten auf kleinen Bildschirmen

ViewportLayout
Über 1280pxTabelle
640px bis 1280pxTabelle, oder Karten mit showMobileTableInTablet
Unter 640pxKarten: ein Block pro Zeile, jedes Feld auf eigener Zeile

disableMobileTable behält das Tabellenlayout in jeder Breite. Das Layout ist fest: eine Zelle, die überläuft, scrollt für sich statt umzubrechen, und die Tabelle scrollt seitwärts, wenn die Spalten ihr Minimum von 650px nicht mehr einhalten.

API

Properties

PropertyAttributTypStandardBeschreibung
datadataRecord<string, any>[][]Die Zeilen der aktuellen Seite. Property oder JSON im Attribut
headersheadersIDataTableHeader[][]Die Spalten. Property oder JSON im Attribut
paginatedpaginatedbooleanfalseDie Pagination-Fusszeile zeigen
totalItemstotalitemsnumberdata.lengthSumme über alle Seiten, für den Zähler
currentPagecurrentpagenumber1Die gezeigte Seite, 1-basiert
currentSizecurrentsizenumbererstes pageSizeOptionsZeilen pro Seite
pageSizeOptionspagesizeoptionsnumber[][5, 10, 20]Auswahl der Seitengrösse. Property oder JSON
sortBysortbystringKeinerDer sortKey, nach dem gerade sortiert ist
sortDirectionsortdirection'asc' | 'desc''asc'Die Richtung der aktuellen Sortierung
isLoadingDataisloadingdatabooleanfalseDie Zeilen durch den Loader ersetzen
loaderloaderThreeDotsLoaderConfigKeinerAussehen des Loaders. Property oder JSON im Attribut
disableMobileTabledisablemobiletablebooleanfalseDas Tabellenlayout auf kleinen Bildschirmen behalten
showMobileTableInTabletshowmobiletableintabletbooleanfalseDas Kartenlayout auch bis zum Tablet-Breakpoint nutzen
darkdarkbooleanfalseDie dunkle Palette rendern

data, headers, pageSizeOptions und loader sind Array- und Objekt-Properties, ihre Attribute lesen also JSON (data='[{"id":1}]'). Nur die Property kann die Funktionen tragen, die eine Spalte haben kann, etwa cellColor.

dark schaltet das ganze Element um, auch die tuix-text- und tuix-icon-Elemente, die es zeichnet. Inhalt, den man in eine Zelle slottet, gehört einem selbst, dark also dorthin weiterreichen.

Events

Alle vier steigen auf und überqueren die Shadow-Grenze, und alle vier tragen den Zustand, den die Tabelle verlangt:

EventDetailWann
sort{ sortKey: string, sortDirection: SortDirection }Eine sortierbare Kopfzelle wird geklickt oder mit Enter/Leertaste ausgelöst
next-page{ page: number, pageSize: number }Der Pfeil zur nächsten Seite wird geklickt
previous-page{ page: number, pageSize: number }Der Pfeil zur vorigen Seite wird geklickt
page-size-change{ page: 1, pageSize: number }Eine andere Seitengrösse wird gewählt

TypeScript

TuixTableElement<TRowData> typisiert eine Referenz auf das Element mit all seinen Properties, genau das, was ein useRef oder ein querySelector braucht:

import type { TuixTableElement, IDataTableHeader } from 'tuix-webcomponents'
 
const table = document.querySelector('tuix-table') as TuixTableElement<Invoice>
const headers: IDataTableHeader<Invoice>[] = [{ name: 'ID', dataKey: 'id', width: 10 }]

Auf dieser Seite