Skip to main content

table — rows and columns of data

UiTable renders a list of records as a grid: column definitions describe the header and the data lookup, row nodes carry the values. On top of that it adds the things a data grid always ends up needing — header actions, per-row actions, row selection and pagination.

Reach for a table when the records share a shape and the user compares them across fields. When each item is a heading plus a bit of prose, use list; for a single record's fields, use detail.

Live — tick the checkboxes (selectMode: MULTI, "Gadget" pre-checked and highlighted), click a row's pencil or bin to see the {id} placeholder already resolved to that row's id, and use Next to see {page} substituted in the pagination trigger.

Fields

FieldTypeMeaning
idStringNode id — also the DOM id, the patch target, and the prefix of the selection form-name.
titleStringOptional heading in the header bar.
iconStringLeading icon token in the header, before the title. See icons.
headerExtraUiNodeNode rendered in the header bar between title and actions — e.g. a compact search form. The bar renders when there is a title, a headerExtra or an action.
columnsList<UiColumn>Column definitions, left to right. See column.
rowsList<UiRow>The data rows. Empty renders a single "No rows." cell. See row.
actionsList<UiAction>Table-level buttons in the header bar (New, Export, …). The bar renders when there is a title or at least one action.
rowActionsList<UiAction>One shared action template rendered into a trailing cell of every row. {id} in the trigger url is replaced with the row's id.
selectModeNONE · SINGLE · MULTIRow-selection controls. Defaults to NONE.
selectedRowIdsList<String>Row ids whose radio/checkbox renders pre-checked.
selectedRowIdStringRow id rendered as highlighted (sui-row--selected). Independent of the checkboxes.
paginationUiTable.PaginationPage footer. Absent = no footer.
stackOnMobilebooleanDefaults to false. true collapses the table to stacked Label: value cards on narrow screens.
sortTriggerUiTriggerTrigger template for sortable headers. {column} and {direction} in its url are substituted per header. Null = sort client-side.
sortColumnStringThe dataKey currently sorted by — drives the header indicator and aria-sort.
sortDirectionASC · DESCDirection of sortColumn. Defaults to ASC.
maxHeightStringCSS length ("420px", "60vh"). Caps the row area: rows scroll, the header row stays pinned.
cssClassStringExtra CSS class on the wrapping <div>.

UiTable.Pagination

FieldTypeMeaning
pageintCurrent page, 1-based.
sizeintRows per page — used to compute the page count from total.
totallongTotal number of rows across all pages.
pageTriggerUiTriggerTrigger template for Prev/Next. The literal {page} in its url is replaced with the target page number. Null renders the footer read-only — the buttons are there but disabled.

UiTable.SelectMode

ValueRenders
NONENo selection column. The table is read-only.
SINGLEA leading radio column.
MULTIA leading checkbox column.

For SINGLE and MULTI every input shares the form-name "<table.id>__selection" and carries the row id as its value, so a surrounding form submits the chosen ids under that one key.

Building one

UiTable.of("demo-table", "Products")
.column(UiColumn.of("name", "Name"))
.column(UiColumn.of("price", "Price"))
.column(UiColumn.of("stock", "Stock"))
.row(Map.of("id", "p1", "name", "Widget", "price", "€ 19.00", "stock", "128"))
.row(Map.of("id", "p2", "name", "Gadget", "price", "€ 49.00", "stock", "12"))
.row(Map.of("id", "p3", "name", "Gizmo", "price", "€ 99.00", "stock", "0"))
.action(UiAction.primary("t-add", "New").icon("add")
.onClick(UiTrigger.go("/products/new")))
.rowAction(UiAction.secondary("row-edit", "Edit")
.onClick(UiTrigger.api("GET", "/products/{id}/edit")))
.selectMode(UiTable.SelectMode.MULTI)
.selectedRowIds(List.of("p2"))
.selectedRow("p2")
.stackOnMobile(true)
.paginate(1, 3, 57, UiTrigger.api("GET", "/products?page={page}"));

Sorting and a pinned header

Mark columns sortable and the header becomes a click target. maxHeight caps the row area so long tables scroll inside themselves with the header row pinned:

Live — click SKU, Name, Price or Note to sort; click again to reverse. This table has no sortTrigger, so the browser is doing it. Scroll the rows and the header stays put.

UiTable.of("products", "Products")
.column(UiColumn.of("name", "Name").asSortable())
.column(UiColumn.of("price", "Price").asSortable())
.maxHeight("320px");

With a sortTrigger the server sorts instead — the placeholders are filled in per header, exactly like {page} in pageTrigger:

.sortTrigger(UiTrigger.go("/products?sort={column}&dir={direction}"))
.sortedBy("price", UiTable.SortDirection.DESC)

Paging

The footer is display state plus a trigger template. {page} in the trigger's url is replaced with the target page before dispatch — the same substitution {column} gets for sorting.

Live — 7 rows, 3 per page. Prev and Next work with no server: the page number rides along in the trigger and a handler swaps the table.

@GetMapping("/products")
public UiPage products(@RequestParam(defaultValue = "1") int page) {
var slice = repo.findPage(page, 20); // your query, your paging
var table = UiTable.of("products", "Products")
.column(UiColumn.of("name", "Name"))
.column(UiColumn.of("price", "Price"));
slice.forEach(p -> table.row(Map.of("id", p.getId(), "name", p.getName(),
"price", money(p.getPriceCents()))));
// {page} is substituted per button — Prev gets page-1, Next page+1.
table.paginate(page, 20, repo.count(), UiTrigger.go("/products?page={page}"));
return UiPage.of("/products", table);
}

The browser sends a normal request, the controller returns the next page, the bus swaps the tree. Nothing client-side to write.

Patch the table, not the page

REPLACE on the table's own id swaps just that subtree. The rest of the screen — filters, sidebar, scroll position — stays exactly where it was.

Notes

Client-side sorting sees only the rows on screen. Without a sortTrigger the browser reorders the rows it has: numeric when both values parse as numbers, otherwise a locale-aware string compare, blanks last in both directions. That is right for a fully loaded table and misleading for a paginated one — pair pagination with a sortTrigger.

maxHeight takes any CSS length. "420px" for a fixed area, "60vh" to scale with the viewport, "100%" inside a sized parent. Without it the table grows with its content and the page scrolls, which is usually what you want for short tables.

Row actions are one template, not one per row. You declare a single UiAction; the renderer clones it per row, substitutes {id} in the trigger url, and suffixes the button's DOM id with __<row.id> so ids stay unique. That means the action's label, confirm and style are the same for every row — anything row-dependent belongs in a column cellTemplate instead.

selectedRowId and selectedRowIds are different things. The singular field only adds the sui-row--selected highlight class; it does not tick anything. The plural field pre-checks the radio/checkbox inputs. Set both when you want a pre-selected row that also looks selected.

The table never slices. It renders exactly the rows you hand it. page/size/total are display state for the footer, and Prev/Next fire your pageTrigger with the target page substituted for {page}. Without a pageTrigger the buttons render disabled — the footer is then informational only. See Paging below.

Header actions no longer need a title. The header bar renders when there is a title or at least one action; put the buttons in the surrounding stack instead.

Wide tables and small screens. stackOnMobile is the built-in answer — each row becomes a card of Column: value lines, using each column's label as the data-label. See responsive layout.

See also

  • column — headers, data keys and custom cell rendering.
  • row — row identity and the cell data map.
  • list — when the records are not a grid.
  • action — what goes into actions and rowActions.
  • Triggers & actions — the {id} / {page} substitution contract.
  • Responsive layoutstackOnMobile in context.