Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Dashboard list tables

Most list screens (Virtual Keys, Users, Model Providers, Provider Groups, the Model Catalog, Alert History, MCP Logs, OAuth Grants and Auth Sessions) draw their rows with the list-table primitives in ui/src/components/screen.tsx. They are divs laid out as a CSS grid, one template shared by the header and every row, so a column cannot drift between the two.

The grid only draws the columns. Until #2000 nothing in the markup said “table”, and a screen reader read every header and cell as a generic node: no table navigation, no column header announced with a cell, and a sort direction shown only by an unlabelled arrow. The roles below are what make the grid a table to assistive technology (WCAG 1.3.1).

PrimitiveRoleWhat it is
ListTabletablethe bordered, sideways-scrolling frame; label names it
ListHeaderrowgroup holding one rowthe header band; its className lands on the band
(inside ListTable)rowgroupthe body: every child of ListTable that is not the ListHeader
ListRowrowone data row, on the same grid template as the header
ListHeaderCellcolumnheadera column’s visible heading
SortLabelcolumnheader holding a buttona sortable heading, carrying aria-sort
ListActionsHeadercolumnheaderthe heading over a row’s buttons: no visible text, a name for screen readers
ListCellcellone column of one row
ListStateRowrow holding one cellwhat the body shows instead of rows: the loading skeleton, the empty state
ListLoadingRowa ListStateRow, or nothingthe skeleton row, shown while the screen’s read is awaiting an answer
ListEmptyRowa ListStateRow, or nothingthe empty row, shown only once the read succeeded with no rows

ListTable puts the body rowgroup in itself, the way a browser puts a <tbody> round rows written straight into a <table>, so a screen only writes the header and the rows. It tells the header apart by type, which means the ListHeader has to be a direct child.

Building a table

  • Name it. label is required and becomes the table’s aria-label. Pass the screen’s own title, t("screens.<key>.title"). The table is also the focusable scroll frame (#1181), so the name is what focus announces.
  • Every child of a row is a cell. A ListHeader holds ListHeaderCell, SortLabel or ListActionsHeader; a ListRow holds ListCell. A bare span in a row is read with no column header.
  • Replace an element, wrap a component. Where a row used to hold a span or div, change the tag to ListCell and keep its classes: the cell becomes the grid item and nothing moves. Where it held a component (a Pill, a Badge, a Combobox, a Switch, a RowIconButton), wrap it in <ListCell className="grid">. A plain block cell would shrink a pill to its text and a button to its icon; grid lays the component out exactly as it was when it sat in the row’s grid itself, stretched across its column. Add min-w-0 when the component’s own root carried it, or long content widens the column.
  • Name the button column. A column of row buttons takes a ListActionsHeader, never an empty <span />: a column header with no name is announced as an empty column and fails axe empty-table-header. It reads common.rowActions by default; a table whose buttons take more than one column passes label so each has its own name (Auth Sessions has colRenew and colRevoke).
  • A row that opens something holds a control for it. A ListRow is a row, which takes no focus, so an onClick on it is a mouse shortcut and nothing more: without a button inside it, Tab walks past every row (WCAG 2.1.1). MCP Logs is the model (#2022). Its last column is a ListActionsHeader labelled analytics.details, and each row’s chevron button is named after the event (tool, server and time, since a tool repeats down the page). The button stops propagation so the row’s own click does not fire a second time, and its story opens the drawer from the keyboard and asserts, in waitFor, that focus moves into the drawer and back to the button on close.
  • Loading and empty go in a ListStateRow. A skeleton’s role="status" or an empty state’s heading and button placed straight in the body is content no row owns, which a screen reader reads outside the table and axe fails as aria-required-children. The header stays on screen above it, which is the point: see loading and empty states.
  • Hand the state rows the query, not a condition. Write the skeleton as <ListLoadingRow read={query}> and the empty state as <ListEmptyRow read={query} rows={rows.length}>, where query is the screen’s useQuery result and rows is what survived its filters. The rows alone cannot tell a list that is still coming, or one whose read failed, from one that answered with nothing, and a hand-written !query.isLoading && rows.length === 0 put “No providers yet” and its create button under the list’s own LoadError (#2211).

Sorting

SortLabel is the header cell and the button in one. The sort state is aria-sort on the header, where a screen reader announces it with the column: ascending, descending, or none for a sortable column that is not sorted. A column that cannot be sorted carries no aria-sort at all.

The button’s name is the column’s label, which the caller has already translated. It deliberately has no aria-label such as “Sort by name”: a header’s name is computed from its content, so that label would become the column’s name as well, and a screen reader reads the column name before every cell in it. The arrow draws the same state for the eye and is aria-hidden.

Testing

Every story is an axe test, but axe walks straight through a generic span inside a row. A column that lost its ListCell and holds only text passes the axe gate while a screen reader reads it with no header. So ui/src/pages/story-harness.tsx exports expectListTable(canvasElement, name), which checks the table by name: two rowgroups, a header row of column headers, and body rows whose every child is a cell, one per column (or one across the table for a ListStateRow). Each list screen’s loaded story calls it.

The primitive’s own stories, under Display/ScreenPrimitives, assert the roles (TableSemantics), the aria-sort cycle and the hidden arrow (SortIsAnnouncedOnTheHeader), the loading and empty rows (LoadingRowKeepsTheTableWhole, NoRows), and the read they wait on (ParkedReadKeepsTheSkeleton, NoEmptyRowUntilTheReadSucceeds).

The Logs screen is not built from these primitives: it renders a native <table> and gets its semantics from the elements. So does the Roles & Permissions matrix (#2081), which needs what the list primitives do not have: a row header per resource (th scope="row") and a tbody per scope under its own full-width header. Each of its chips pairs an aria-hidden mark with the same statement in sr-only text, so a cell is read as “Read: allowed Create: not allowed …” with its role and resource announced; the Loaded story asserts that name and the mark’s glyph.