Skip to contentAsheeUIAsheeUI

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-selected is 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 point aria-labelledby at 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.