Table
Rows of records in a real table, from the column shape a richer table builds on.
A table puts a set of records into columns so they can be compared down a line rather than
read one record at a time. Table draws the element itself, a <table> with header
cells, a body, rows that can be chosen and rows that can be pressed, and takes its columns
as data: a header and a cell renderer per column.
What separates it from DataTable is the scope of the job. There is no toolbar, no search,
no paging and no sort state: Table renders the records it is given, in the order it is
given them, which is what makes it the primitive DataTable composes, the column shape is
the same ColumnDef, and every visual decision resolves through the same cascade.
Usage
import { Table, type ColumnDef } from "@asheeui/web";
type Invoice = { id: string; client: string; amount: string };
const columns: ColumnDef<Invoice>[] = [
{ id: "number", header: "Invoice", cell: (row) => row.id },
{ id: "client", header: "Client", cell: (row) => row.client },
{ id: "amount", header: "Amount", cell: (row) => row.amount },
];
<Table data={invoices} columns={columns} rowKeyAccessor={(row) => row.id} />
A column is { id?, header, cell }. header is a node rendered in the <th>, so it can
be text or a control; cell receives the row and returns the cell's content, which is how
one column renders a badge and the next a link. id is the column's key, without one the
key is the column's position, never its header, so a table whose columns can be reordered
should state id on every column it cares about.
rowKeyAccessor is the row's identity, and it is optional: without it a row is keyed by its
index, which is right for a list that never reorders and wrong for one that does. The same
key is what selectedRowKey is compared against and what handleClick receives as its
second argument.
Selection and interactive rows
Two things decide whether a row can be pressed. handleClick and handleDoubleClick make
rows interactive on their own, and isClickable states it explicitly: isClickable={false}
with a handler attached leaves the rows inert, and the handler is then never called.
selectedRowKey is the selection, one key, compared with the row's key, and a matching
row is filled with the accent that color names. It is a single row rather than a set, and
the page owns it, because Table reports presses and changes nothing itself.
Naming and order
A table is a reading structure, so it can name itself. caption is rendered as the table's own
<caption>, placed below the rows; a table already named by the heading above it can point
aria-labelledby at that heading instead, which names the same table without repeating the words.
There is no sort state, here or on DataTable, because an order is a decision about the data and
the page already has one. What a column can do is state the order its rows are already in:
sortDirection puts aria-sort on that column's header cell, so a reader is told which column
ordered the rows and which way. A column that is not sorted states nothing at all, rather than
claiming to be unsorted.
Variants and the empty state
Four variants decide the surface. grid rules the columns apart; striped bands alternate
rows; bordered keeps the outer border and drops the rules between columns; ghost draws
no surface at all, no border, no radius, no shadow, and no rule under the last row. The
header row is sticky in every variant, so the column names stay in place while the page
scrolls.
size sets the cell padding and the type scale, in the same three steps the fields use.
emptyMessage is what a table with no rows shows: one cell spanning every column, centred
in a fixed-height block, so an empty result is a sentence rather than a bare border. It
takes a node, which is how an empty state component stands in it.
Props
size (md) and variant (grid) are registered, so components.table states the
density and the treatment once for the application.
color is not registered, so it resolves from the application's defaultColor and then
primary; radius is not registered either, so it resolves from defaultRadius and then
md. An unconfigured application therefore draws a grid table at md, accented
primary and rounded xs, the framework's own corner. full is read as xl here: a
table cannot be a circle.
The four class-name overrides exist at two tiers, components.table for the application and
the prop for one table, and the instance's is applied last, so it wins, gridClasses included,
and it lands on the <table> itself, so a consumer's own width beats the default w-full.
caption names the table and is rendered as the <caption> the layout has always reserved.
data, columns, rowKeyAccessor, handleClick, handleDoubleClick, selectedRowKey,
emptyMessage and isClickable are instance-only: the records, their columns and the chosen row
are statements about one screen. A column's sortDirection states itself to assistive technology
as aria-sort on its header cell; ordering the rows is still the consumer's job.
Accessibility
- The element is a real
<table>:<th scope="col">in a<thead>, one<td>per column in a<tbody>. A reader hears a cell with its column's name, which is the whole reason the element is not a grid of divs. - The header row is sticky, so the column names stay the same nodes while the page scrolls rather than being duplicated above a scrolled body.
- A chosen row carries
aria-current="true"and the accent fill, so the choice is announced rather than only coloured.aria-selectedis deliberately not used: on a plain<table>it is not part of a row's exposed state, because it belongs to a grid. - An interactive row is focusable and takes Enter and Space, but it is
announced as a row rather than as a control, and there is no keyboard path to
handleDoubleClick. Whatever the double press does needs another route. - The table can name itself: pass
caption, or pointaria-labelledbyat a heading above it. Those attributes land on the<table>rather than on the scrolling wrapper, a name on the wrapper names nothing a reader navigates to. An empty table still announces the empty message, because it is rendered as a cell in the body.