Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.

Data Table

The list screen of every Comitor app: a sticky-header table with sorting, row selection, pinned columns, column visibility, skeleton loading and an empty state. Every one of those is controlled — the component holds no data and calls no API, so the same table serves an in-memory array and a server-paginated endpoint without forking. The bar underneath it is Table Pagination. For a working list with many fields — filtered and sorted by several conditions at once, or corrected in place, like tasks, customers or orders — the sibling is Data Grid.

Basic usage

Three required props carry the whole thing: columns (which is also the column order), rows, and getRowId, whose return value is both the React key and the selection key. Everything else is opt-in.

InvoiceClientOwnerIssuedAmountStatus
INV-2041Aurora LogisticsMai TranAug 2, 2026$4,820Paid
INV-2042Brightline MediaDuc PhamAug 4, 2026$1,290Pending
INV-2043Cormorant FoodsLinh VoJul 28, 2026$7,640Overdue
INV-2044Delta ShipworksMai TranAug 9, 2026$980Pending
INV-2045Everest AnalyticsHuy NguyenAug 11, 2026$15,400Paid
INV-2046Foundry LabsLinh VoAug 14, 2026$2,310Pending

Sorting

A sortable header cycles through ascending → descending → unsorted. The third stop is deliberate: it lets a user return to the order the server sent without reloading the page, which is why onSortChange is typed to receive null. The header button only appears when a column is sortable and the table has an onSortChange — a shared column definition can stay inert on a read-only screen.

Sort state: { columnId: 'client', direction: 'asc' }

InvoiceOwnerStatus
INV-2041Aurora LogisticsMai TranAug 2, 2026$4,820Paid
INV-2042Brightline MediaDuc PhamAug 4, 2026$1,290Pending
INV-2043Cormorant FoodsLinh VoJul 28, 2026$7,640Overdue
INV-2044Delta ShipworksMai TranAug 9, 2026$980Pending
INV-2045Everest AnalyticsHuy NguyenAug 11, 2026$15,400Paid
INV-2046Foundry LabsLinh VoAug 14, 2026$2,310Pending

The unsorted icon is not dimmed. It sits inside a button that is still operable, so opacity-50 would not be exempt from the 3:1 threshold of WCAG 1.4.11 — measured against the header row it comes out at 1.98:1 light and 2.23:1 dark. Left at text-muted-foreground it is 4.89:1 and 4.86:1. The sorted state stays distinguishable anyway: the button switches to text-foreground and the icon changes shape.

Row selection

selectable adds a 44px checkbox column pinned to the left. The selection itself is an array of getRowId values that you own. The header checkbox reports indeterminate when only some rows are selected, and select-all only ever touches rows that isRowSelectable allows — locked rows are never swept in and never swept out.

1 selectedPaid invoices are locked.
InvoiceClientOwnerIssuedAmountStatus
INV-2041Aurora LogisticsMai TranAug 2, 2026$4,820Paid
INV-2042Brightline MediaDuc PhamAug 4, 2026$1,290Pending
INV-2043Cormorant FoodsLinh VoJul 28, 2026$7,640Overdue
INV-2044Delta ShipworksMai TranAug 9, 2026$980Pending
INV-2045Everest AnalyticsHuy NguyenAug 11, 2026$15,400Paid
INV-2046Foundry LabsLinh VoAug 14, 2026$2,310Pending

Clicking a checkbox never triggers onRowClick: the cell wraps the checkbox in a span that stops propagation, so ticking a row does not also open it.

Column visibility

DataTableColumnToggle is a separate component so it can live on the page toolbar rather than inside the table. Both halves read the same visibleColumnIds state. Columns marked hideable: false — the identifying column, here Invoice — never appear in the menu, and the menu refuses to hide the last remaining column.

['id', 'client', 'owner', 'issued', 'amount', 'status']
InvoiceClientOwnerIssuedAmountStatus
INV-2041Aurora LogisticsMai TranAug 2, 2026$4,820Paid
INV-2042Brightline MediaDuc PhamAug 4, 2026$1,290Pending
INV-2043Cormorant FoodsLinh VoJul 28, 2026$7,640Overdue
INV-2044Delta ShipworksMai TranAug 9, 2026$980Pending
INV-2045Everest AnalyticsHuy NguyenAug 11, 2026$15,400Paid
INV-2046Foundry LabsLinh VoAug 14, 2026$2,310Pending

Why string[] and not Record<string, boolean>

  • It is a set, not an order. Column order always comes from columns; reordering this array changes nothing. The Ids suffix exists to say exactly that — the 0.1.0 name visibleColumns read like an array of column objects.
  • A record has a third state: key absent. Ship a new column and every saved layout is missing its key, so each call site guesses show or hide and the bug is silent. An array of ids is just JSON.stringify-able.
  • The package deliberately does not write to localStorage. In a multi-workspace SaaS a table layout belongs to the pair (user × workspace) and usually has to sync across machines — that is the application's call, not the component's.

Pinned columns and sticky header

Scroll the table below sideways and downwards. The identifier stays on the left, the amount on the right, and the header stays on top. width is required on every pinned column: the sticky left/right offsets are accumulated from those numbers, so a missing width stacks pinned columns on top of one another. The vertical scroll needs a height, which is what containerClassName="max-h-72" is doing here.

InvoiceClientOwnerContactIssuedTermsStatusAmount
INV-2041Aurora LogisticsMai Tran[email protected]Aug 2, 2026Net 30Paid$4,820
INV-2042Brightline MediaDuc Pham[email protected]Aug 4, 2026Net 30Pending$1,290
INV-2043Cormorant FoodsLinh Vo[email protected]Jul 28, 2026Net 30Overdue$7,640
INV-2044Delta ShipworksMai Tran[email protected]Aug 9, 2026Net 30Pending$980
INV-2045Everest AnalyticsHuy Nguyen[email protected]Aug 11, 2026Net 30Paid$15,400
INV-2046Foundry LabsLinh Vo[email protected]Aug 14, 2026Net 30Pending$2,310

Pinned cells take their fill from the row with bg-inherit, and CSS inherits background-color from the direct parent — which is why the header fill sits on the <tr>, not the <thead>. The same rule is the reason no state in this table is signalled with a translucent background: any alpha on the row is inherited by the pinned cell and the columns underneath show straight through it while you scroll.

Opening a row

Passing onRowClick puts the row into the tab order and wires Enter and Space, because opening a record must not be a mouse-only affordance. Tab into the table below and press Enter. Use isRowActive to mark whatever is currently open next to it.

InvoiceClientIssuedAmountStatus
INV-2041Aurora LogisticsAug 2, 2026$4,820Paid
INV-2042Brightline MediaAug 4, 2026$1,290Pending
INV-2043Cormorant FoodsJul 28, 2026$7,640Overdue
INV-2044Delta ShipworksAug 9, 2026$980Pending
INV-2045Everest AnalyticsAug 11, 2026$15,400Paid
INV-2046Foundry LabsAug 14, 2026$2,310Pending
  • The row keeps its implicit role="row"; no role="button" is added. That role would override the row role and a screen reader would lose the table structure — row and column counts, column headers. The row stays a row and merely becomes activatable.
  • Key presses are only honoured when the <tr> itself has focus. Enter or Space on a checkbox, button or link inside a cell already means something else, and bubbling would open the record by accident.
  • The focus ring is drawn with outline and a negative offset, not ring: box-shadow is not painted on a <tr> in a border-collapse table, and a positive offset would be clipped by the scroll container. Its colour is foreground, not the brand ring — gold on card is about 1.9:1, under the 3:1 that WCAG 1.4.11 asks of a non-text indicator.
  • isRowActive tints each cell with --accent, the same fill a selected row gets and the one DataGrid uses, so every table in the package marks a row the same way. The tint goes on the cells, not the row: an opaque fill on each <td> keeps pinned cells solid while you scroll and wins over the even-row stripe, where a class on the <tr> would lose. No border either, so opening a row does not make the table jump.

Density and stripes

density is a local override of the app-wide density axis, not a second class table. It rewrites --row-py and --table-head-h on the <table> element, which is what keeps the header shrinking together with the body — and lets one table be compact while the rest of the app stays comfortable.

The override only runs one way. Only density="compact" writes those two variables; the default "comfortable" writes nothing at all and simply inherits whatever <html data-density="compact"> says. A table can therefore be denser than the app around it, never looser.

InvoiceClientOwnerIssuedAmountStatus
INV-2041Aurora LogisticsMai TranAug 2, 2026$4,820Paid
INV-2042Brightline MediaDuc PhamAug 4, 2026$1,290Pending
INV-2043Cormorant FoodsLinh VoJul 28, 2026$7,640Overdue
INV-2044Delta ShipworksMai TranAug 9, 2026$980Pending
INV-2045Everest AnalyticsHuy NguyenAug 11, 2026$15,400Paid
INV-2046Foundry LabsLinh VoAug 14, 2026$2,310Pending

striped defaults to false: stripes help you track a row across a long, wide table and are pure noise on a short one, so it is a per-screen decision rather than a package default. The stripe uses the opaque --table-row-stripe token. It is declared at table level, so the generated selector is tbody tr:nth-child(even) at specificity (0,2,2) — it beats and replaces the row's own bg-card (0,1,0) rather than layering over it. A translucent stripe would therefore leave even rows with nothing opaque beneath them, and pinned cells inheriting that alpha would go see-through. The same specificity maths is why you cannot mark a row with a plain background class on a striped table: it loses on every even row.

Loading and empty states

loading replaces the body with loadingRowCount skeleton rows shaped like the real columns, and sets aria-busy on the table. When the request comes back empty, the built-in EmptyState uses labels.emptyTitle and labels.emptyDescription; pass empty when it needs an action.

InvoiceClientOwnerIssuedAmountStatus

EmptyState takes the icon component: icon={Inbox}, never icon={<Inbox />}. A Lucide icon is a forwardRef object rather than a function, so component and element cannot be told apart at runtime; the shared IconComponent type makes the compiler catch it instead of failing quietly. If you need an arbitrary node — an illustration — use the media slot.

The labels contract

Every string — including the four aria-label strings that only a screen reader ever hears — comes from the dataTable namespace of the nearest ComitorLocaleProvider: English without one, Vietnamese under locale="vi". Language is set once, at the root; labels is for wording on top of it — no fork, no wrapper. Below, one empty table three ways: untouched, under the Vietnamese provider, and under it with a single labels key, which wins.

No provider

InvoiceClient
No data yet

locale="vi"

Hoá đơnKhách hàng
Chưa có dữ liệu

locale="vi" + one labels key

Hoá đơnKhách hàng
Chưa có hoá đơn

Three rules govern the bag, and they are the same across TablePaginationLabels, SearchFilterBarLabels and ShellLabels:

  • The prop is Partial<>. The component merges it over the provider's table, so overriding one key keeps the rest — in the provider's language, as the third table shows.
  • Keys that interpolate a number or a unit are functions, not strings with placeholders. DataTableLabels happens to have none — all seven keys are plain strings — but its sibling TablePaginationLabels does, and the reason is on that page.
  • A standalone prop beats labels. DataTableColumnToggle keeps a label prop because that string varies by content ("Fields", "Columns") while labels.columnToggle varies by language.
tsx
import {
  ComitorLocaleProvider,
  DataTable,
  DataTableColumnToggle,
  DEFAULT_DATA_TABLE_LABELS,
  comitorMessagesVi,
} from '@comitor/ui'

// 1. The prop is Partial<DataTableLabels>, merged over the provider's dataTable table,
//    so one key is a legal override and the other six keep the provider's language.
<DataTable {...tableProps} labels={{ emptyTitle: 'No invoices yet' }} />

// 2. A standalone prop beats `labels`. `label` is the per-table string, `labels.columnToggle`
//    is the per-language string — the first wins where both are present.
<DataTableColumnToggle {...toggleProps} label="Fields" labels={{ columnToggle: 'Columns' }} />
// renders "Fields"

// 3. Rewording a key for EVERY table is the provider's job, not each call site's. Keep the
//    object at module level — a new object on every render re-renders every consumer.
const MESSAGES = { dataTable: { emptyTitle: 'Nothing here yet' } }
<ComitorLocaleProvider messages={MESSAGES}>{children}</ComitorLocaleProvider>

// Both built-in tables are plain data in modules with NO 'use client', so a Server Component
// can read them — spread them, diff them, build a list from them.
DEFAULT_DATA_TABLE_LABELS    // English — the component itself reads the provider, not this
comitorMessagesVi.dataTable  // Vietnamese
const withOneChange = { ...comitorMessagesVi.dataTable, emptyTitle: 'Chưa có hoá đơn' }

DEFAULT_DATA_TABLE_LABELS and the DataTableLabels type live in a module that carries no 'use client' directive, on purpose. Everything exported from a client module reaches a Server Component as a client-reference proxy that reads back as undefined without throwing or warning — and plain data constants are exactly what a server page wants to spread, compare and list. The same goes for comitorMessagesVi. The full set of keys, with the English defaults and the Vietnamese table's wording:

PropKiểuMặc địnhMô tả
selectAllRowsstring'Select all visible rows'aria-label of the header checkbox while not everything is selected. Vietnamese: “Chọn tất cả dòng đang hiển thị”
deselectAllRowsstring'Deselect all'aria-label of the header checkbox once every selectable row is selected. Vietnamese: “Bỏ chọn tất cả”
selectRowstring'Select row'aria-label of an unselected row checkbox. Vietnamese: “Chọn dòng”
deselectRowstring'Deselect row'aria-label of a selected row checkbox. Vietnamese: “Bỏ chọn dòng”
emptyTitlestring'No data yet'Title of the built-in empty state. Ignored when you pass empty. Vietnamese: “Chưa có dữ liệu”
emptyDescriptionstring'This list is empty.'Description of the built-in empty state. Vietnamese: “Danh sách hiện đang trống.”
columnTogglestring'Columns'Button text and menu heading of DataTableColumnToggle. Vietnamese: “Cột hiển thị”

Types

All of these are exported from @comitor/ui alongside the components.

ts
type SortDirection = 'asc' | 'desc'

interface DataTableSort {
  columnId: string
  direction: SortDirection
}

type DataTableAlign = 'left' | 'center' | 'right'

interface DataTableColumn<TRow> {
  id: string
  header: ReactNode
  label?: string
  cell: (row: TRow, rowIndex: number) => ReactNode
  sortable?: boolean
  align?: DataTableAlign
  width?: number
  minWidth?: number
  pinned?: 'left' | 'right'
  hideable?: boolean
  headerClassName?: string
  cellClassName?: string
}

DataTable props

PropKiểuMặc địnhMô tả
columnsbắt buộcDataTableColumn<TRow>[]—Column definitions. This array is also the column ORDER — nothing else reorders it.
rowsbắt buộcTRow[]—The rows to render, already sorted, filtered and paginated by you.
getRowIdbắt buộc(row: TRow, index: number) => string—Stable identity of a row. Used for the React key and as the selection key.
loadingbooleanfalseRenders loadingRowCount skeleton rows instead of the body and sets aria-busy on the table.
loadingRowCountnumber5How many skeleton rows to draw while loading.
emptyReactNode—Replaces the built-in empty state. Rendered in a full-width cell when rows is empty and loading is false.
sortDataTableSort | null—Controlled sort state. null means unsorted. The component never sorts your data.
onSortChange(sort: DataTableSort | null) => void—Called with the next state of the asc → desc → unsorted cycle. Required before any sortable column renders a button.
selectablebooleanfalseAdds the leading checkbox column, pinned left at 44px.
selectedIdsstring[][]Controlled selection, holding the values returned by getRowId.
onSelectedIdsChange(ids: string[]) => void—Called with the next selection. Without it both the header and the row checkboxes are inert.
isRowSelectable(row: TRow, index: number) => boolean() => trueLocks the checkbox of individual rows. Locked rows are also excluded from select-all.
visibleColumnIdsstring[]—Controlled set of visible column ids. Omit to show everything. Order inside the array is ignored.
onRowClick(row: TRow, index: number) => void—Makes rows activatable. Adds tabIndex=0 and Enter/Space handling on the <tr>.
rowClassName(row: TRow, index: number) => string | undefined—Extra classes per row. Avoid alpha backgrounds here — see the note on pinned columns.
isRowActive(row: TRow, index: number) => boolean() => falseHighlights the row currently open elsewhere with the --accent tint on each cell — the same fill as a selected row and as DataGrid.
stickyHeaderbooleantruePins the header row to the top of the scroll container.
stripedbooleanfalseOpaque zebra striping on even rows. Off by default — worth it on long, dense tables only.
minWidthnumber | string—Minimum table width. Set it to the sum of the column widths to enable horizontal scrolling.
density'comfortable' | 'compact''comfortable'Local override of the app-wide density axis. Only 'compact' writes --row-py and --table-head-h onto the <table>; 'comfortable' writes nothing and inherits <html data-density>.
labelsPartial<DataTableLabels>from providerDisplay and screen-reader strings, merged over the dataTable table of ComitorLocaleProvider — English without one.
aria-labelstring—Accessible name of the table. Required when a page carries more than one table.
aria-labelledbystring—Id of a visible heading to use as the accessible name instead.
classNamestring—Classes on the <table> element.
containerClassNamestring—Classes on the scroll container — this is where a max-height belongs.

DataTableColumn fields

PropKiểuMặc địnhMô tả
idbắt buộcstring—Stable column id. Referenced by DataTableSort.columnId and by visibleColumnIds.
headerbắt buộcReactNode—Header cell content.
cellbắt buộc(row: TRow, rowIndex: number) => ReactNode—Renders the body cell. The row index is passed too, so a numbering column needs no extra data.
labelstring—Plain-text name shown in the column toggle menu. Required whenever header is not a string — otherwise the menu falls back to the id.
sortablebooleanfalseTurns the header into a sort button — but only when the table also has onSortChange.
align'left' | 'center' | 'right''left'Text alignment of header and body cells. On a right-aligned column the sort icon moves to the left of the label.
widthnumber—Column width in px. REQUIRED on pinned columns — the sticky offsets are accumulated from it.
minWidthnumberwidthMinimum column width in px.
pinned'left' | 'right'—Freezes the column during horizontal scroll.
hideablebooleantrueSet false to keep the column out of the toggle menu — right for the identifying column.
headerClassNamestring—Extra classes on the header cell.
cellClassNamestring—Extra classes on every body cell of this column.

DataTableColumnToggle props

PropKiểuMặc địnhMô tả
columnsbắt buộcDataTableColumn<TRow>[]—The same array you gave DataTable. Columns with hideable: false are filtered out of the menu.
visibleColumnIdsbắt buộcstring[]—The same state you gave DataTable.visibleColumnIds.
onVisibleColumnIdsChangebắt buộc(visibleColumnIds: string[]) => void—Receives the next set, always rebuilt in the order of columns so the stored value keeps one shape.
labelstringlabels.columnToggleButton text and menu heading. Beats labels.columnToggle when both are given.
labelsPartial<DataTableLabels>from providerSame label bag as DataTable; only columnToggle is read here.
classNamestring—Classes on the trigger button.

Accessibility

  • The table is a real <table> with <th scope="col"> headers — no ARIA grid pattern is layered on top, so browser table navigation keeps working.
  • Name every table with aria-label or aria-labelledby. It is mandatory once a page has more than one.
  • The sorted column carries aria-sort="ascending" or "descending"; the attribute is absent while unsorted. The sort control is a real <button> inside the header cell, and its icon is aria-hidden.
  • While loading, the table sets aria-busy="true".
  • Selectable rows get aria-selected and data-state="selected". Each checkbox is named from labels.selectRow / deselectRow, and the header one from selectAllRows / deselectAllRows. Nothing on screen shows those four, so they are the ones to check: a Vietnamese app that forgets the provider looks right and still announces them in English.
  • Clickable rows are keyboard operable: tabIndex=0, Enter and Space, and a visible focus outline in foreground (the brand ring colour would only reach ~1.9:1 on card). Space is prevented from scrolling and Enter from submitting an enclosing form.
  • The sort state changes the icon shape as well as the colour, so it reads in greyscale (WCAG 1.4.1). Selected rows carry a ticked checkbox as well as the tint.
  • Non-text contrast (WCAG 1.4.11) is met by the unsorted sort icon at 4.89:1 / 4.86:1 against the card surface.