Skip to contentAsheeUIAsheeUI

DataTable

A table with the furniture a list page needs, a heading, a search control, a count and paging.

A data table is the table a list page is built from: the rows and columns, and the four things a consumer would otherwise write around them, a heading, a search control, a count of what is showing, and a way to the next page. It is a composition rather than a second table: Table draws the rows, SearchInput names and reports the query, Pagination moves between pages, and EmptyState and LoadingState say why the table has no rows, or none yet.

What it does not do is decide anything about the data. data is shown in the order it is given and columns come in the order they are listed, so sorting, filtering on the server and every other decision stay with the consumer. It does not select rows either, but it does mark one: selectedRowKey passes straight through to Table, so a chosen row is announced and filled whenever the page can name it.

Usage

import { DataTable, type ColumnDef } from "@asheeui/web";

const columns: ColumnDef<Invoice>[] = [
  { id: "number", header: "Invoice", cell: (invoice) => invoice.number },
  { id: "customer", header: "Customer", cell: (invoice) => invoice.customer },
  { id: "total", header: "Total", cell: (invoice) => formatMoney(invoice.total) },
];

<DataTable
  title="Invoices"
  description="Everything issued this quarter."
  data={invoices}
  columns={columns}
  rowKeyAccessor={(invoice) => invoice.id}
  searchAccessor={(invoice) => `${invoice.number} ${invoice.customer}`}
  pageSize={20}
/>

A column is data rather than markup: a header, which is a node, and a cell renderer that receives the row and returns what that column shows. id is the column's key and falls back to the column's position, so a table whose columns can be reordered should state it. rowKeyAccessor gives every row a stable identity, so a row keeps its element, and its focus, as the data around it changes; without it a row is keyed by its position. onRowClick is what makes the rows interactive: with it, a row answers to a press and to Enter, and reports the row it was given.

title and description are drawn above the toolbar as a heading and a sentence under it. toolbar is the slot beside the search control, for the filters a page needs and the table cannot guess, and as changes the element the wrapper renders as.

variant, size, color and radius are the table's own treatment and are handed straight to Table, so they resolve through the table's cascade rather than a second one, the Props section below says which tier supplies each.

Searching

The search control is drawn only when there is something to search: searchAccessor, which reads the text a row is matched by, or onSearchChange, which reports the query and leaves the filtering to the consumer. A field that could not search would be a control that does nothing, so searchable is not enough on its own, the registered true is permission, and an accessor is the reason.

With searchAccessor the table filters the rows it was given, comparing the accessor's text case-insensitively. With onSearchChange the table reports each query and draws the rows it is handed next, which is what a consumer that searches on the server wants; give it both and the consumer's rows are filtered again on the way in, so a server search states onSearchChange alone.

A new query is a new result set, so the table goes back to the first page: an uncontrolled table resets its own page, and a controlled one reports onPageChange(1) alongside the query. searchLabel, registered as Search, names the field and is its placeholder, which is why a product that searches something other than everything states it once in configuration.

Nothing matching is a state rather than a failure: the table draws EmptyState with emptyTitle, registered as No results, and emptyDescription, registered as Try a different search, or clear it to see everything.

Paging and what the table says about itself

paginated is registered true and pageSize 10, so a table of forty rows is four pages without a word of configuration. page makes the page the consumer's; defaultPage, whose own default is 1, is where an uncontrolled table starts, and onPageChange reports where a reader asked to go. The paging controls are drawn only when there is more than one page to draw.

Under the table the count states what is showing, Showing 1 to 10 of 40. It is a sentence a reader is told rather than a control they operate, so it is a template: rowCountLabel under components.datatable, or on the instance, with {first}, {last} and {total} filled in. The default wording is the sentence above, so nothing changes unless a product wants its own. showRowCount (registered true) hides it, and a table that shows neither the count nor the paging has no footer at all.

isLoading is the state a table is in while its rows are on their way: the rows are replaced by a loading region rather than shown as an empty table, because an empty table says the wrong thing about a request that has not finished. That region names what is loading, loadingLabel, registered as Loading results, so a waiting table announces more than the framework's generic Loading. The footer goes with the rows, so a count of zero is never announced while the count is still unknown.

The chosen row

selectedRowKey passes straight through to Table, so a data table marks the row the page has chosen, filled for the eye, aria-current for a reader, without managing the selection itself. The page decides what a press means; the table only says which row is the answer.

Props

The registered values are the behaviour: searchable true, searchLabel Search, searchDebounce 0, paginated true, pageSize 10, showRowCount true, rowCountLabel the sentence above, loadingLabel Loading results, and the two empty-state strings. All ten live under components.datatable, so a product that pages by fifty, searches invoices instead of rows or calls its wait something else states it once.

variant, size, color and radius are not registered by this component. They are passed to Table, which resolves them through its own cascade: size and variant from components.table (registered as md and grid), color from the application's defaultColor, radius from its defaultRadius, and the table's own fallbacks of primary and md behind those. That is why restyling components.table restyles every data table, and why a data table and a bare table on the same page are dressed alike.

data, columns, title, description, caption, selectedRowKey, searchAccessor, onSearchChange, rowKeyAccessor, onRowClick, page, defaultPage, onPageChange, rowCount, isLoading, toolbar and as are instance-only. rowCount is what a remote list states its total with, so the pager and the count sentence know how many rows exist while the table holds one page. The rows, the columns, the size of the result set, the chosen row and what a press on one means are statements about one screen; none of them is a statement about the design.

Accessibility

  • The rows are a real <table> with <th scope="col"> header cells, so a reader can move between cells and hear which column a value belongs to. A row that can be activated is in the tab order and answers to Enter and Space; a row that cannot is not.
  • The count is a role="status" region, so a query or a page change is announced as Showing 11 to 20 of 40 without moving focus. An empty result is announced twice on purpose: the count becomes Showing 0 to 0 of 0, and the empty presentation is also a status while it answers a query, so the sentence that explains it is heard too. A table that is empty on first paint stays quiet, because nothing changed.
  • The table names itself: the heading is wired to it with aria-labelledby, or caption names it when a page would rather state the name inside the table.
  • The search field is named by searchLabel, the same string it shows as a placeholder, so it is announced as a labelled field rather than as a placeholder alone; the control that empties it returns focus to the field.
  • The paging controls are named, Previous page, Next page, Page 3, and the current one carries aria-current="page". While isLoading, a role="status" region stands in for the rows, labelled with loadingLabel, Loading results unless the product states otherwise, so a waiting table is announced as waiting and says what it is waiting for.

Native

@asheeui/native renders this contract as RowList, because a phone cannot lay out columns side by side and read them as a row: each column becomes a labelled line inside a Card, onRowPress replaces onRowClick, and selectedRowKey marks the row a reader has chosen, on both platforms. components.datatable configures the two alike, so the search wording, the page size, the count sentence and the empty state are stated once. See React Native.