@k2b/ui
DataTable
Basic records and professional panels with search, filters, actions, and pagination.
The basic form renders exact typed records. Sorting, selection, totals, and pagination stay application-owned.
Owner | Requests | |
|---|---|---|
Documentation | Design systems | 3274 |
Public API | Platform | 18492 |
| Total | — | 21766 |
TSX
Copy<DataTable rows={ordered()} columns={columns} getRowId={(row) => row.id} selectedRowId="api" sort={sort()} renderCell={({ col, value, render }) => col.id === "name" ? <strong>{value}</strong> : render(value)} onSortChange={setSort} footer={{ values: { name: "Total", requests: 21_766 } }}/><Pagination currentPage={2} totalPages={6} baseUrl="?page=" />The professional composition adds a labelled panel, actions, search, filters, and pagination without moving data ownership into the component.
Orders
3 of 3 rows
Customer | Status | Items | Total | Settings |
|---|---|---|---|---|
Alice Becker | Delivered | 3 | €129.90 | |
Bob Schmidt | Shipped | 1 | €42.50 | |
Cara Müller | New | 5 | €219.99 | |
| Total | 9 | €392.39 |
TSX
Copyconst [query, setQuery] = createSignal(""); <DataTable.Panel> <DataTable.Header title="Orders" subtitle={`${filteredRows().length} of ${total} rows`}> <Button size="sm" variant="subtle"><i class="ti ti-settings" />Settings</Button> </DataTable.Header> <DataTable.Controls> <TextInput aria-label="Search orders" value={query()} onValueChange={setQuery} icon="ti ti-search" placeholder="Search orders..." clearable /> <FilterChip label="Status" icon="ti ti-filter" value={statuses()} onValueChange={setStatuses} options={statusOptions} /> </DataTable.Controls> <DataTable rows={filteredRows()} columns={columns} getRowId={(row) => row.id} footer={{ values: { customer: "Total", items: totals().items, total: totals().total } }} renderCell={({ row, col, value, render }) => { if (col.id === "status") { const status = orderStatus[row.status]; return <StatusBadge label={status.label} tone={status.tone} icon={null} />; } if (col.id === "actions") return <Button size="sm" variant="subtle">Open</Button>; return col.id === "total" ? formatCurrency(value) : render(value); }} /> <DataTable.Footer> <Pagination currentPage={1} totalPages={6} baseUrl="?page=" /> </DataTable.Footer></DataTable.Panel>DataTable renders typed rows and columns. The server owns filtering, sorting, pagination, aggregation, and permission checks; the component owns table presentation and row interaction.
Use DataTable
Use it for records with consistent fields, comparable values, and column headings.
Use StructuredDataPreview for one small metadata object. Wrap the table in
DataTable.Panel when it also needs a title, count, search, filters, actions,
or pagination. The standalone DataPanel export remains available for panels
whose content is not a table.
When a page shows a single dataset, the page heading already names it. Render
the table without a DataTable.Header that repeats the page title, give it an
ariaLabel, and put the count in the page subtitle or a statistic. Keep row
actions quiet: a ghost button or a row menu, with the danger tone reserved for
the confirmation.
Import
import {
DataTable,
type DataTableColumn,
type DataTableFooter,
type DataTableProps,
type DataTableRenderCell,
type DataTableRenderHeader,
type DataTableSort,
Pagination,
type PaginationProps,
} from "@k2b/ui";Rows and columns
Each column has a stable id, a header, and usually a value key or function. Numeric values align right by default. renderCell and renderHeader customize presentation without changing the underlying row model.
The default cell wrapper keeps every value on one line and truncates it.
With cellContentClass, values can wrap, and a column's nowrap decides:
set nowrap: true for dates, times, numbers, amounts, or short codes. Body
and footer values then stay on one line and set the column's minimum width;
headers may wrap. Without nowrap, the first non-empty value decides:
numbers, including bigint, and Date values stay on one line; strings wrap.
nowrap: false allows wrapping for numeric or date values too. A
white-space set by cellContentClass itself wins over nowrap.
Pass getRowId when selection or stable row identity matters. Use selectedRowId for a selected record, not the row index.
Selected rows take the row selection fill without changing text weight or row geometry; see Colors and selection.
The default cell renderer displays missing values as an em dash, dates with the current locale, and booleans as Yes or No.
Assistant view tables may declare a ref column whose cells are Cloud
references or null. The cell shows title ?? id; sorting and Copy data use
that visible text. References contain only {type, id, title?}, with no URL,
and open after a confirmation through the owning app's reader with the viewer's
access. Original row-index keys let the host recover the reference after sorting.
Add a header and controls
The basic DataTable stays valid on its own. The compound panel adds only
presentation and accessible structure; it does not own query or pagination
state.
<DataTable.Panel>
<DataTable.Header
title="Orders"
subtitle={`${rows.length} of ${total} rows`}
>
<Button size="sm" variant="subtle">Settings</Button>
</DataTable.Header>
<DataTable.Controls>
<TextInput
aria-label="Search orders"
value={query()}
onValueChange={setQuery}
/>
<StatusFilter />
</DataTable.Controls>
<DataTable rows={rows} columns={columns} />
<DataTable.Footer>
<Pagination currentPage={page} totalPages={pages} baseUrl="?page=" />
</DataTable.Footer>
</DataTable.Panel>DataTable.Header accepts primitive title and subtitle props. Its children
are actions. They sit beside the title while they fit and wrap onto further
rows below it on narrow panels, such as on a phone, so no action is cut off.
Controls and Footer accept ordinary child composition, so
search, filters, settings, bulk actions, and pagination remain replaceable.
The header automatically labels the nested table region. For a standalone
table, use ariaLabel; use ariaLabelledBy when an existing visible heading
already owns the label.
DataTableRenderCell receives row, col, the resolved value, and a
render callback for the default presentation. DataTableRenderHeader
receives the column and its default render callback. DataTableFooter
provides per-column values and an optional footer-cell renderer.
Sorting and filtering
Mark a column sortable: true, or provide the server's sort key as a string. Pass the current sort and build a URL in sortHref.
Sorting is link-based so it works on a cold server render and survives reload, sharing, and browser navigation. The same rule applies to filters and pagination: put user intent in the URL, query the server, then pass the returned rows to the table.
For a complete local dataset, pass onSortChange instead of sortHref: sortable
headers become buttons. The caller sorts the rows and passes the updated sort.
If both are supplied, sortHref takes precedence. A sort changes only the
header's aria-sort and arrow, never the button itself, so keyboard focus stays
on it and pressing it again reverses the order.
Do not filter or sort a paginated result in the browser. The client does not own the complete dataset.
Presentation
Rows follow the minimal Grids reference: no row lines, grid, or zebra striping;
44px rows (40px compact); a hover fill; a small muted header at weight 500 with
no line underneath; numbers aligned right with tabular figures. Tables with
more than 8 columns automatically get a hairline between body rows, the
approved exception for wide editing tables. There is no prop to change this.
data-table-minimal.browser.test.ts pins this threshold and presentation.
Pass loading for the initial read. With no rows, loadingRows renders
row-height placeholders (default 5, clamped to an integer from 1 to 50).
A refresh keeps existing rows until their replacement arrives. The region is
busy while loading and announces “Loading rows” in the inherited locale.
Without rows the table cannot infer that a column is numeric, so set
align: "right" on numeric columns of a loading table; otherwise their
headings move to the right when the first rows arrive.
Use density="compact" for dense operational tables. Headers are sticky unless stickyHeader={false}. footer accepts values and an optional cell renderer for server-computed totals.
DataTable owns its scroll viewport and keeps sticky headers aligned. Avoid
wrapping it in a second scrolling container.
Use surface="paper" for a standalone bordered table and surface="plain"
when a surrounding section owns the border. Inside AppWorkspace.Main the
table sits on Main's surface with surface="plain"; see
Paper. Set it explicitly whenever the
table also has a custom class. The class sizes the outer table shell; the
component keeps scrolling on its inner viewport. Bound a scrolling table with
height, max-height, or a correctly sized min-h-0 flex-1 region instead of
adding another scroll container.
hasMore, loadingMore, and onLoadMore add an infinite-load sentinel. The owning island still fetches the next server page and appends its rows. The table requests that page when its end comes near the visible area, whether the table scrolls its own viewport or grows with its rows while the page scrolls. While loadingMore is true, the table requests nothing more. Otherwise it requests again after rows or loading state advance, or after the end has left the visible area and returned, so a request the owner ignored is not lost. An owner that omits loadingMore must ignore a repeated call while its page is still loading.
API reference
type DataTableColumn<T> = {
id: string;
header: JSX.Element | ((ctx: { col: DataTableColumn<T> }) => JSX.Element);
subtitle?: JSX.Element | ((ctx: { col: DataTableColumn<T> }) => JSX.Element);
value?: keyof T | ((row: T) => unknown); class?: string; headerClass?: string; cellClass?: string;
align?: "left" | "center" | "right"; nowrap?: boolean; sortable?: boolean | string;
};
type DataTableSort = { key: string; direction: "asc" | "desc" };
type DataTableRenderCell<T> = (ctx: {
row: T; col: DataTableColumn<T>; value: unknown; render: (value: unknown) => JSX.Element;
}) => JSX.Element;
type DataTableRenderHeader<T> = (ctx: { col: DataTableColumn<T>; render: () => JSX.Element }) => JSX.Element;
type DataTableFooter<T> = {
values?: Record<string, unknown>;
renderCell?: (ctx: { col: DataTableColumn<T>; value: unknown; render: (value: unknown) => JSX.Element }) => JSX.Element;
};
type DataTableProps<T> = {
rows: readonly T[]; columns: readonly DataTableColumn<T>[]; ariaLabel?: string; ariaLabelledBy?: string;
getRowId?: (row: T) => string; sort?: DataTableSort | null; sortHref?: (next: DataTableSort) => string;
onSortChange?: (next: DataTableSort) => void;
selectedRowId?: string | null; rowClass?: string | ((row: T) => string | undefined); hoverRows?: boolean;
onRowClick?: (row: T) => void; onRowDoubleClick?: (row: T) => void; renderCell?: DataTableRenderCell<T>;
renderHeader?: DataTableRenderHeader<T>; footer?: DataTableFooter<T>; hasMore?: boolean;
loading?: boolean; loadingRows?: number;
loadingMore?: boolean; onLoadMore?: () => void; empty?: JSX.Element; density?: "compact" | "normal";
stickyHeader?: boolean; highlightColumns?: boolean; verticalAlign?: "top" | "middle" | "bottom";
cellContentClass?: string; fillHeight?: boolean; surface?: "paper" | "plain"; class?: string;
tableClass?: string; scrollClass?: string; scrollPreserveKey?: string | false;
};
type DataTablePanelProps = {
children: JSX.Element; class?: string;
};
type DataTableHeaderProps = {
title: string; subtitle?: string; children?: JSX.Element; as?: "h1" | "h2" | "h3"; size?: "sm" | "md";
class?: string;
};
type DataTableControlsProps = {
children: JSX.Element; class?: string;
};
type DataTablePanelFooterProps = {
children: JSX.Element; class?: string;
};The four panel types correspond to DataTable.Panel, .Header, .Controls, and .Footer. All row callbacks receive the original row. Footer values are keyed by column id. getRowId returns a stable string. onLoadMore returns void; expose request state through loadingMore and append rows after success.
Defaults: density="normal", stickyHeader=true, verticalAlign="middle". Use onRowClick/onRowDoubleClick only for real row actions; selection remains controlled through selectedRowId. scrollPreserveKey={false} disables restoration for the table. fillHeight makes the table fill a bounded parent's height. hoverRows defaults to whether a row callback exists; highlightColumns defaults on when row hover is enabled. Omitted surface is paper without a custom class and plain with one, so set it explicitly when styling. rowClass styles rows, tableClass the table, scrollClass the scroll container, and class its outer shell. Pass scrollClass="k2b-detail-trail" to keep column widths beside a workspace detail. The scroller narrows and scrolls the table at its width without the panel; closing returns the full width without extra space. Under an open waiting drawer the scroller gets the drawer's width as space at its end instead.
Accessibility
Sortable headers expose aria-sort. Interactive rows receive keyboard focus and activate with Enter or Space.
Do not make an entire row interactive when it contains unrelated controls. Give action columns an accessible heading, including a visually hidden one when the design does not show text.
Runtime
Rows, headers, sort links, selection, empty state, and footer render on the server. Row callbacks, column hover, and infinite loading require browser code.
Prefer normal links for navigation. Use callbacks only when the interaction cannot be represented by a URL.
Pagination
Pagination is the matching URL-owned page control. PaginationProps
contains currentPage, totalPages, baseUrl, and optional onNavigate.
The component appends each page number to baseUrl and renders native previous,
next, and bounded page links. Invalid page values are clamped to the available
range, and the control reacts when the total changes after mount.
<Pagination
currentPage={4}
totalPages={12}
baseUrl="?page="
/>Example
type RouteRow = {
id: string;
path: string;
requests: number;
};
const columns: DataTableColumn<RouteRow>[] = [
{ id: "path", header: "Route", value: "path", sortable: true },
{
id: "requests",
header: "Requests",
value: "requests",
sortable: "requestCount",
},
];
<DataTable
rows={rows}
columns={columns}
getRowId={(row) => row.id}
sort={{ key: "requestCount", direction: "desc" }}
sortHref={(next) =>
`/admin/routes?sort=${next.key}&direction=${next.direction}`
}
/>For multi-selection, supply selection={createCollectionSelection({ ids })}
and getRowId. The controller may also be shared with FileGrid to retain the
same selection between presentations. Rows expose aria-selected and roving
keyboard focus. Shift selects a range, Ctrl/Cmd toggles individual rows, Space
toggles the focused row, and Ctrl/Cmd-A selects the supplied IDs. Enter invokes
onRowDoubleClick in this mode. Existing single-row callbacks remain unchanged
when selection is omitted. onRowContextMenu lets the host align selection with
its context menu. See File browser for the shared model.