DataTable
Lightweight data table with sorting and selection
DataTable
Basic Usage
Row Selection
Sorting Interaction
Sortable headers expose aria-sort and support pointer click, Enter, and Space to cycle through ascending, descending, and unsorted states. When sortOnClient=false, the table still emits sortChange without reordering local rows.
Dashboard Data Operations
In dashboard data regions, TxDataTable owns the primary list, TxPagination owns page navigation, and TxSkeleton / TxLayoutSkeleton keep loading previews from collapsing into blank space.
Data operations panel
A screenshot-verified table, pagination, and skeleton-loading composition on the local Nexus page.
Records Composition
The wide CRM layout: a pinned header, a pinned footer, a frozen first column, a three-state select-all, and composed cell primitives. Every one of these is off by default and changes nothing until it is switched on.
What makes the layout work:
maxHeightturns the table into its own scroll container, which is what the header and footer stick against;scrollXhandles the horizontal axis. Do not put it insideTxScrollin its default mode — that scrolls by transform, and a transformed ancestor kills everyposition: stickyinside it.- With
stickyHeader/stickyFooteron, the table switches toborder-collapse: separateinside.is-sticky-shell. This is not a style preference: collapsed borders are painted by the table rather than the cell, so a pinned<th>loses its rules the moment it detaches. The switch is scoped to that class, soborderedandstripedlook unchanged everywhere else. - The summary row comes from the
footerorfooter-<key>slots; with no footer slot, no<tfoot>is rendered at all. - The select-all box goes
indeterminateon a partial selection and reportsaria-checked="mixed". - Sort on timestamp fields, never with
localeCompareover readable text like "9 days ago" — that places "over 1 year ago" between "3 weeks ago" and "9 days ago" and calls it a chronology. sortCycle="bi"keeps the table from ever returning to unsorted, which suits record lists that always need a definite order.
Records table
Pinned header and footer, a frozen first column, selection highlighting, a three-state select-all, and the tag / dot / link cell primitives.
The row hover and selection fills are exposed as CSS variables, so a paper-like table can go neutral grey without a prop or an !important:
.records-shell {
--tx-data-table-row-hover-bg: var(--tx-bui-hover);
--tx-data-table-row-selected-bg: color-mix(in srgb, var(--tx-bui-accent) 7%, var(--tx-bui-surface));
}
API
TxDataTable Props
| Name | Type | Default | Description |
|---|---|---|---|
columns | DataTableColumn[] | [] | Column config |
data | any[] | [] | Data source |
rowKey | keyof T | (row: T, index: number) => string | number | index | Unique row key |
loading | boolean | false | Loading state |
emptyText | string | 'No data' | Empty text |
striped | boolean | false | Zebra rows |
bordered | boolean | false | Show borders |
hover | boolean | true | Hover highlight |
interactiveRows | boolean | false | Makes rows focusable (tabindex="0") so Enter/Space can trigger rowClick; automatically enabled when a rowClick listener is attached |
selectable | boolean | false | Selectable rows |
selectedKeys | Array<string | number> | [] | Selected keys |
defaultSort | { key: string; order: 'asc' | 'desc' | null } | null | Initial sort for the uncontrolled mode, after which the component owns it. Mutually exclusive with sort — pass one or the other |
sort | { key: string; order: 'asc' | 'desc' | null } | null | - | Controlled sort. Supply it (including as null for "unsorted") and the component stops holding its own state: it reports the user's intent through update:sort and renders whatever the parent sends back. Leave it out entirely for the uncontrolled mode driven by defaultSort |
sortOnClient | boolean | true | Client-side sort |
sortCycle | 'tri' | 'bi' | 'tri' | Header click cycle: tri is ascending → descending → unsorted, bi is ascending → descending → ascending and never unsorts |
tableLayout | 'auto' | 'fixed' | 'auto' | Native table layout mode. Use fixed when column widths must stay stable. |
nowrap | boolean | false | Prevent wrapping for all header and cell content. |
maxHeight | string | number | - | Caps the height and makes the component its own vertical scroll container. stickyHeader / stickyFooter need this to have anything to stick to, unless an ancestor already scrolls |
scrollX | boolean | false | Lets the table scroll horizontally inside the component; needed by wide tables with fixed columns |
stickyHeader | boolean | false | Pins the header row while the body scrolls |
stickyFooter | boolean | false | Pins the footer row while the body scrolls |
rowClass | (row, index) => string | string[] | Record<string, boolean> | - | Extra classes per row, e.g. to tint a row by its state |
highlightSelected | boolean | false | Tints selected rows. Off by default, so existing tables keep expressing selection through the checkbox alone |
DataTableColumn
| Field | Type | Description |
|---|---|---|
key | string | Column key |
title | string | Header title |
dataIndex | string | Data field |
width | string | number | Column width |
minWidth | string | number | Minimum column width. |
maxWidth | string | number | Maximum column width. |
auto | boolean | Force column width to auto. |
fixed | boolean | 'left' | 'right' | Sticky column side; true equals 'left'. Without scrollX / maxHeight, a fixed column switches the root from overflow: hidden to overflow: visible, so horizontal scrolling must come from an outer container or the sticky offsets have nothing to stick against; with scrollX the component becomes that scroll container itself. |
nowrap | boolean | Prevent wrapping for this column. |
align | 'left' | 'center' | 'right' | Alignment |
sortable | boolean | Sortable |
sorter | (a, b) => number | Custom sorter |
format | (value, row, index) => string | Cell formatter |
headerClass | string | Header class |
cellClass | string | Cell class |
Events
| Event | Payload | Description |
|---|---|---|
update:selectedKeys | (keys) | Selection update |
selectionChange | (keys) | Selection change |
sortChange | (sort) | Sort change |
update:sort | (sort) | Sort change; emitted in both controlled and uncontrolled modes, and paired with the sort prop |
rowClick | ({ row, index }) | Row click |
Slots
| Name | Description |
|---|---|
header-<columnKey> | Custom header; receives { column, sorted, order, toggle }. sorted says whether this column is the active sort, order is the direction (null when it is not), and toggle advances the column through the configured cycle. |
cell-<columnKey> | Custom cell; receives { row, column, value, index }. |
footer | The whole summary row: you supply the <td>s yourself, so cells can span columns. A <tfoot> is rendered only when some footer slot is present. |
footer-<columnKey> | Fills one summary cell per column; receives { column, data }. When footer is also present, footer wins. |
empty | Empty slot rendered when there are no display rows and loading=false. |
Best Practices
- Pass a stable
rowKeyfor business lists; when selection persists across pages, driveselectedKeyswith business ids instead of default indexes. - Sortable columns support click, Enter, and Space. Use
sortOnClient=falsewithsortChangefor remote sorting. tableLayout="fixed"plus explicitwidth/minWidthkeeps dense operations tables from shifting during loading or sorting.- Fixed columns should use numeric px
widthorminWidth; sticky offsets are calculated from those values. - Pick one horizontal scroller: either turn on
scrollXand let the component be its own, or leave the default and wrap the table in an outeroverflow-x: autocontainer. Both give sticky columns something to stick against — just do not nest two scrollers. - A pinned header or footer needs
maxHeight(or an ancestor that already scrolls), otherwise there is nothing to stick to; and that scroll container must not scroll by transform, asTxScrolldoes in its default mode, which disables sticky entirely. - Sort on comparable fields such as timestamps or ranks, never on formatted relative-time text.
- Custom cells should preserve readable text or status-badge labels because sortable headers expose state through
aria-sort. - The shell clips to a rounded corner, so the component drops the bottom separator on the last row of whichever section ends the table; a summary
tfootkeeps the rule that divides it from the body. Reproduce that if you restyle cell borders, otherwise a stray line sits under the table.
Review Notes
- Accessibility note:
TxDataTablerenders a native table and gives sortable header cellsscope="col",aria-sort, keyboard focus, and Enter/Space handlers. - Types:
rowKeyis declared askeyof Tor a callback that returns a string or number;sortChangemay emit aDataTableSortStateornull. - Verified coverage: The component test renders headers and rows, checks pointer sorting plus keyboard
aria-sorttransitions, selection emission, and layout/nowrap/auto/fixed-column styles. The last-row separator reset is asserted against the SFC source, because vitest never evaluates a<style>block.
Source
- Component source:
packages/tuffex/packages/components/src/data-table/src/TxDataTable.vue. - Type contracts:
packages/tuffex/packages/components/src/data-table/src/types.tsexportsDataTableProps,DataTableColumn, sort state, row-key, and emit types. - Coverage:
packages/tuffex/packages/components/src/data-table/__tests__/data-table.test.tsverifies header and row rendering, pointer and keyboard sorting (includingaria-sort), selection emission, layout/nowrap/auto/fixed-column styles, and the last-row separator reset.