tuix-table

A data table with slotted cells, a card layout on mobile, and sorting and pagination the application controls.

tuix-table draws the rows you give it. It never sorts, filters or slices them by itself.

Sorting and pagination are controlled. The table reports what the user clicked and your application decides what happens next:

The user clicks. A sortable column header, a page chevron or the page-size select.

The table fires an event. sort, next-page, previous-page or page-size-change. The table itself changes nothing.

Your code answers. It sorts or fetches the rows, then writes data and the matching state back on the element.

Nothing moves in the table until that last step runs. Every example below shows it in Angular, Vue and React.

A first table

Two properties are enough: headers describes the columns, data holds the rows.

Loading the components…
const table = document.querySelector('tuix-table')
 
table.headers = [
    { name: 'ID', dataKey: 'id', width: 10 },
    { name: 'Invoice', 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' }]
Every row needs a unique id. It keys the rendering and it names the cell slots.

Headers

One object per column. Only dataKey and width are required.

FieldTypeWhat it does
dataKeykeyof TRowDataWhich field of the row to print. Required
widthnumberThe relative width of the column. Required
namestringThe title in the header row
sortKeykeyof TRowDataMakes the column sortable. See Sorting
isSlotbooleanPrint a slot instead of the value
slotNamestringThe base name of that slot. Required with isSlot
cellColor(row) => string | undefinedA TextVariant for the text of the cell, row by row
cellBackgroundColor(row) => string | undefinedA CSS colour for the cell, row by row
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
}

Column widths

width is a weight, not a pixel value. The widths of all columns are added up and each column takes its share, so 10 / 30 / 20 and 1 / 3 / 2 give the same layout. When every width is 0, the columns share the space evenly.

Slotted cells

With isSlot: true the cell prints <slot name="{slotName}-{row.id}"> instead of a value. The row id is part of the slot name, so every cell has a slot of its own:

Loading the components…

A slot takes as many elements as the row needs. The example puts an archive icon and a notify icon in each cell. The framework tabs show that same pair as an @for block, a v-for and a map.

Sorting

The table draws the sorting controls. Your application does the sorting.

A column with a sortKey gets the chevron pair, becomes focusable and reacts to a click, to Enter and to Space. Every column below is sortable:

Loading the components…

The three steps

Mark the column. sortKey names the field to order by. It does not have to be the dataKey, so a formatted column or a slotted one can sort by any field of the row.

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

Listen for sort. The detail carries the column and the direction the table is asking for: asc for a new column, and the opposite of the current one when sortBy already names it.

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

Sort, then write the state back. data takes the new order, and sortBy and sortDirection tell the header where it stands.

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

Reorder data alone and the rows move while the header keeps the previous column highlighted. The chevron and aria-sort follow sortBy and sortDirection, not the click.

Sorting the rows

Array.prototype.sort sorts in place, so sort a copy. The new array is also what React, Vue and Angular need in order to redraw:

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

A few things to keep in mind:

  • Compare by type. The default comparison is by text, which puts 10 before 2. Subtract numbers, send text through localeCompare, and compare dates by their timestamps.
  • Decide where empty values go. A field that can be null needs a rule of its own, so those rows always land on the same side.
  • One column at a time. The detail never carries a second key. Ordering by more than one column is your code's job, behind the same event.
  • Paginated tables sort the whole set, not the page on screen. Sort on the server, ask for the first page of the new order and set currentPage back to 1. The table does not reset it for you.
  • Leave sortBy unset until a column is clicked. No chevron is highlighted then, and the rows print in the order you gave them.

Pagination

Same contract as sorting: the footer asks, your code answers. The demo pages through 23 rows.

Loading the components…

Turn the footer on. paginated shows it, totalItems is the count across all pages, and pageSizeOptions fills the page-size select.

Listen for the three events. next-page, previous-page and page-size-change all carry { page, pageSize }, the page the table is asking for.

table.addEventListener('next-page', (event) => {
    load(event.detail.page, event.detail.pageSize)
})
Write the page back. data takes the rows of that page, currentPage and currentSize the state.

The chevrons stop at the first and at the last page, which the table works out from totalItems and the page size. Choosing another page size always asks for page 1.

Loading

isLoadingData replaces the rows with a loader. The header row and the pagination footer stay, and the page-size select is disabled:

Loading the components…

loader shapes it, and every field is optional:

FieldTypeDefaultWhat it does
sizestring'14px'The diameter of a dot
colorstringPalette.MAINThe colour of the dots
visiblebooleantruefalse hides the loader completely
ariaLabelstring'Loading'What a screen reader announces
wrapperStyleRecord<string, string | number>NoneOverrides the centring wrapper

Responsive behaviour

ViewportLayout
Above 1280pxTable
640px to 1280pxTable, or cards with showMobileTableInTablet
Below 640pxCards: one block per row, each field on its own line

disableMobileTable keeps the table layout at every width. The layout is fixed: a cell that overflows scrolls on its own instead of wrapping, and the table scrolls sideways when the columns no longer fit its 650px minimum.

API

Properties

PropertyAttributeTypeDefaultDescription
datadataRecord<string, any>[][]The rows of the current page. Property, or JSON in the attribute
headersheadersIDataTableHeader[][]The columns. Property, or JSON in the attribute
paginatedpaginatedbooleanfalseShow the pagination footer
totalItemstotalitemsnumberdata.lengthTotal across all pages, for the counter
currentPagecurrentpagenumber1The page being shown, 1-based
currentSizecurrentsizenumberfirst of pageSizeOptionsRows per page
pageSizeOptionspagesizeoptionsnumber[][5, 10, 20]Choices in the page-size select. Property, or JSON
sortBysortbystringNoneThe sortKey the rows are currently sorted by
sortDirectionsortdirection'asc' | 'desc''asc'The direction of the current sort
isLoadingDataisloadingdatabooleanfalseReplace the rows with the loader
loaderloaderThreeDotsLoaderConfigNoneLoader appearance. Property, or JSON in the attribute
disableMobileTabledisablemobiletablebooleanfalseKeep the table layout on small screens
showMobileTableInTabletshowmobiletableintabletbooleanfalseUse the card layout up to the tablet breakpoint too
darkdarkbooleanfalseRender the dark palette

data, headers, pageSizeOptions and loader are array and object properties, so their attributes read JSON (data='[{"id":1}]'). Only the property can carry the functions a header may hold, such as cellColor.

dark switches the whole element, including the tuix-text and tuix-icon elements it draws. Content you slot into a cell is yours, so pass dark down to it as well.

Events

All four bubble and cross the shadow boundary, and all four carry the state the table is asking for:

EventDetailWhen
sort{ sortKey: string, sortDirection: SortDirection }A sortable column header is clicked, or activated with Enter or Space
next-page{ page: number, pageSize: number }The next-page chevron is clicked
previous-page{ page: number, pageSize: number }The previous-page chevron is clicked
page-size-change{ page: 1, pageSize: number }Another page size is chosen

TypeScript

TuixTableElement<TRowData> types a reference to the element with all its properties, which is what a useRef or a querySelector needs:

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 }]

On this page