# PkDataTable: a searchable, sortable, server-paged table

> Plainkit 0.28.0. Generated by scripts/build-skills.mjs from Components/PkDataTable.razor, PkListTypes.cs and PkTableTypes.cs. Do not edit: change the source and run the generator.

A hand-written component, a thin typed wrapper over the `pk-data-table` element (`plainkit-sdk` skill). The element owns the query (search text, sort key and direction, page, page size), the loading, empty and error states, the pager and the selection; the component turns your typed `Columns` and options into its plain props, hands it `Load` as its `load(query)` callback, renders each loaded row's `Cell` templates into the element's `cell-<id>-<key>` slots and raises your callbacks from its events. You own the data: `Load` returns one page. Use it for any list you load from a database a page at a time. For a table you fill yourself, use `PkTable`.

Request: `record PkListRequest(string? Search, string? SortKey, bool Descending, int Page, int PageSize)`, with `public CancellationToken CancellationToken` (Cancelled when a newer request replaces this one (the user typed, sorted or paged again) or the list goes away. Pass it to the database call; the list ignores the result of a superseded request in any case.) and `public IReadOnlyDictionary<string, string>? Filters` (The values of the filter fields a PkListPage{TItem} shows, keyed by filter key (text, as typed or chosen); null when none is set. Always null for a PkDataTable{TItem}.) and `public IReadOnlyDictionary<string, IReadOnlyList<string>>? MultiFilters` (The values of the `multiselect` filters (several chosen options), keyed by filter key; null when none is set. A filter holds its value in Filters (one text) or here (a list), never both.) and `public int Skip` (The number of items to skip: `(Page - 1) * PageSize`, for `Skip` in a query.). Result: `record PkListResult<T>(IReadOnlyList<T> Items, int Total, IReadOnlyDictionary<string, IReadOnlyList<PkFilterOption>>? FilterOptions = null)`.

Rules the element follows: a new search, sort or page size goes back to page 1; the search box debounces itself (`SearchDebounceMs`, no .NET timer); `Search` presets the box and the first request, for example from a query string (a later change replaces the term); a request that a newer one replaces has its `CancellationToken` cancelled (when the newer request or the component's disposal arrives) and its result ignored, so no stale rows appear; while a request is in flight the table shows a loading state; a thrown `Load` shows an error with a Retry button (`OnLoadError` reports the exception). `ReloadAsync()` loads the current page again (after the host saved something). Rows are keyboard stops when `OnRowClick` is set (Enter or Space activates); put a link in a `Cell` if the first column should look like one. A column `Key` is used exactly as you write it: in the column definition, the row, the cell slot and the keys reported back, so `PkListRequest.SortKey` is the `Key` of the sorted column verbatim (PascalCase in, PascalCase out; nothing is camelCased). Map it to your repository column yourself, and match it case-insensitively if you like. A column without `Text` or `Cell` finds the item property in any casing.

The table marks the open record with `CurrentRow` (the row id: tinted, an accent bar and `aria-current`); `PkDataTable` passes it through. `Clickable` rows are keyboard stops (Enter or Space activates). A third activation of a sortable header clears the sort (`OnSort` reports a null key and direction). A `HidePhone` column is hidden in the `cards` layout too. `Editable` edits cells in place (columns with an `Editor`); it has no column resize yet; editors are text, number, select and date (an ISO date string). At 500 rows or more it windows like any table (the active and the edited row stay drawn, arrows, PageUp/PageDown and Ctrl+Home/End move by row, paste and undo reach rows that are not drawn), unless the table is expandable.

## Selecting rows and "Select all N rows"

Set `Selectable`. `@bind-Selected` (the ids, `IdOf`) and `OnSelect` follow every change: a box, select-all for the page, "Select all N rows", clear. The selection is kept by id across paging, sort and search, so `Selected` can hold ids of rows that are not loaded. "Select all N rows" is offered when the whole page is selected and the searched list has more rows; it is the **query**, not a list of ids: `OnSelect` then has `Scope == "all"`, `Selected` holds only the loaded page, and `args.ToRequest()` (`PkSelectQuery`) returns the search and sort the selection means (ignore its `Page` and `PageSize`). Run the bulk action on the server against that request (`WHERE name LIKE ...`), so a selection of thousands of rows never travels through the browser. A new search narrows scope `all` back to `page` (the ids stay selected). `SelectScope` follows the element (`page` or `all`). There is no `LoadAllIds` any more. Set `SelectPageOnly` when the bulk action works only on explicit ids: the offer is never shown, the header box still selects the page and `SelectScope` stays `page`.

```razor
<PkDataTable TItem="Order" Load="LoadAsync" Columns="_columns" IdOf="o => o.Id.ToString()" Label="Orders" Selectable @bind-Selected="_ids" OnSelect="Picked">
    <BulkContent><PkButton OnClick="Archive">Archive</PkButton></BulkContent>
</PkDataTable>

@code {
    private IReadOnlyList<string>? _ids;
    private PkSelectEventArgs? _pick;

    private void Picked(PkSelectEventArgs e) => _pick = e;
    // Archive: when _pick.Scope == "all" run the update against _pick.ToRequest() (Search, SortKey) on the server; otherwise against _pick.Selected.
}
```

A selection of 64 loaded rows or more crosses the wire as runs of row indexes and the component expands it to ids (`Selected` and `OnSelect` still get the ids, in row order), so it stays under SignalR's 32 KB message limit (about 5,000 numeric ids or 800 GUIDs). Keep the pages at 10 to 100 rows.

## Editing cells in place

Cell editing is a `PkTable` feature, not a `PkDataTable` one. Give `PkTable` `Editable` and set `Editor` (a `PkTableEditor`: `Text`, `Number`, `Select` with `Options`, `Date` (a native date input; the item value is an ISO date string such as `2026-01-07`, and `Min`/`Max` do not bound it yet); `Required`, `Min`, `Max`, `MaxLength` are checked first) on each `PkTableColumn<TItem>` that may change (a column with a `Cell` template stays read-only, and is where a switch goes: a `PkSwitch` with `tabindex="-1"` and its own name (its text, in a `u-sr-only` span) in the `Cell` template, updating your item from its `CheckedChanged`). `OnCellEdit` gets `Id`, `Item`, `Key`, `Value` and `Previous` for each accepted value: update your item there, then `Refresh()`. The event cannot be cancelled from the server, so to refuse a value put a message in `CellErrors["<id>:<key>"]` and put the old value back. `OnCellsPaste` (`PkCellsPasteEventArgs`: `Cells`, `Rejected`) summarises a paste from a spreadsheet; each accepted cell has already raised `OnCellEdit`. The asynchronous commit state of the element (`event.detail.wait(promise)`) needs a JavaScript handler, because the browser does not wait for a server callback: in Blazor, refuse through `CellErrors` as above. Column layout: `Resizable` gives each header a resize handle (drag, or Arrow keys on the focused handle; Shift for bigger steps) and `Reorderable` lets a header be dragged or moved with Alt+Arrow. Give a column a starting `Width` (px) on its `PkTableColumn`. `OnColumnResize` (`PkColumnResizeEventArgs`: `Key`, `Width` in px) and `OnColumnReorder` (`PkColumnReorderEventArgs`: `Key`, `From`, `To`, `Order`, the new key order) tell the app what the user did; the table keeps the layout itself while it is on screen, so persist the widths and the order yourself (per user, in your own store) and feed them back as `Width` and the order of `Columns` on the next visit. A reorder cannot be cancelled from Blazor.

## A list that opens a record page

`OnRowClick` calls `Nav.NavigateTo($"/locations/{item.Id}")`, `OnAdd` navigates to `/locations/new`, and the record page is `PkRecordPage<TItem>` on its own route: typed `Fields` (`PkFieldSpec<TItem>`), `Load` returning the item, `Save` receiving it, `OnDelete`, `Busy` and `Error` bound to a `PageBase` page; the route is the only state (the guide "A routed list and record page in Blazor"). For a form that needs tabs, a sidebar or sections of your own markup, `PkRecordForm` (`references/record-form.md`) is the composable alternative, with the load, validate and save state in `PkRecordEditor` (`references/record-editor.md`).

## `PkDataTable`

| Parameter | Type | Description |
|---|---|---|
| `Load` | `Func<PkListRequest, Task<PkListResult<TItem>>>` | Loads one page. It gets the search, the sort, the 1-based page and the page size (PkListRequest) and returns the items of that page and the total of the whole searched list (PkListResult{T}). Called for the first page, and again for every search, sort, page or page-size change and for ReloadAsync; a request that a newer one replaces has its CancellationToken cancelled and its result ignored. |
| `Columns` | `IReadOnlyList<PkTableColumn<TItem>>` | The columns. A column Cell template fills the element's cell-<rowId>-<key> slot of each loaded row. |
| `IdOf` | `Func<TItem, string>` | The id of an item: names the row for click, current-row, selection and the cell slots. Ids must not contain a double quote. |
| `PageSize` | `int` | The page size to start with (initial only; the user changes it in the pager). Default 25. Two-way: @bind-PageSize follows the reader's page size (pk-query-change). |
| `PageSizeChanged` | `EventCallback<int>` | Raised when PageSize changes (two-way binding: `@bind-PageSize`). |
| `PageSizeOptions` | `int[]?` | The page sizes the pager offers. Default 10, 25, 50, 100; an empty array hides the choice. |
| `SortKey` | `string?` | The key of the column to sort by first (initial only). Null loads the default order. Two-way: @bind-SortKey follows the reader's sort (pk-query-change). |
| `SortKeyChanged` | `EventCallback<string?>` | Raised when SortKey changes (two-way binding: `@bind-SortKey`). |
| `Descending` | `bool` | Sort SortKey descending first (initial only). Two-way: @bind-Descending. |
| `DescendingChanged` | `EventCallback<bool>` | Raised when Descending changes (two-way binding: `@bind-Descending`). |
| `Search` | `string?` | The search term to start with, for example from a query string: it fills the search box and the first Load request, and typing replaces it. Changing it later replaces the term and loads page 1. Two-way: @bind-Search. |
| `SearchChanged` | `EventCallback<string?>` | Raised when Search changes (two-way binding: `@bind-Search`). |
| `Searchable` | `bool` | Show the search box. Default true. |
| `SearchPlaceholder` | `string?` | The text shown in the empty search box. It is also the accessible name unless SearchLabel is set. |
| `SearchLabel` | `string?` | The accessible name of the search box; defaults to SearchPlaceholder, then "Search". |
| `SearchDebounceMs` | `int` | Milliseconds to wait after the last keystroke before searching (the element's own timer). Default 300. |
| `EmptyText` | `string` | The text shown when the list is empty and there is no search. |
| `NoResultsText` | `string` | The text shown when a search matched nothing. |
| `EmptyContent` | `RenderFragment?` | Your own empty state (for example a PkEmptyState), shown instead of the text when the list is empty and there is no search. |
| `LoadErrorText` | `string` | The heading of the failed-load message. The element shows it with a Retry button when Load throws. |
| `OnLoadError` | `EventCallback<Exception>` | Raised when Load throws (not for a cancelled request), so you can log it. |
| `AddLabel` | `string?` | The label of the primary action button after the search box (for example "+ Add customer"). Empty shows no button. |
| `OnAdd` | `EventCallback` | The add button (addLabel) was pressed. |
| `OnRowClick` | `EventCallback<TItem>` | A row was activated (a click, or Enter or Space on the focused row): the item of that row. Setting it makes the rows clickable. |
| `CurrentRow` | `string?` | The id (IdOf) of the row whose record is open in a detail pane: it is tinted and marked aria-current. Set it from the route; the list never changes it. |
| `Expandable` | `bool` | Rows get a toggle that shows their DetailTemplate under them (the detail is rendered for the loaded page's rows only). Expanding is not a row click. |
| `Expander` | `string?` | Set to none to leave out the toggle column: the host opens rows by setting expanded, as pk-table's expander. "none" leaves out the built-in toggle column; the host then opens rows through Expanded. |
| `Expanded` | `IReadOnlyList<string>?` | The ids (IdOf) of the expanded rows (two-way: @bind-Expanded; needs Expandable and a DetailTemplate). |
| `ExpandedChanged` | `EventCallback<IReadOnlyList<string>?>` | Raised with the new value of Expanded (two-way: Expanded follows it). |
| `DetailTemplate` | `RenderFragment<TItem>?` | The detail of one loaded row, shown under it when the user expands it (the element's detail-<rowId> slot). Needs Expandable and IdOf. The slot name is dynamic: the component renders one slot="detail-<rowId>" per loaded row when Expandable is set, and only those rows get a toggle. |
| `DetailPanels` | `IReadOnlyDictionary<string, RenderFragment<TItem>>?` | Further named detail panels of a loaded row, by panel name (the element's detail-<rowId>.<name> slots), next to or instead of DetailTemplate. Needs Expandable. A named panel has no toggle: the host opens it by putting "<rowId>.<name>" in Expanded. The slot name is dynamic: one slot="detail-<rowId>.<name>" per panel and loaded row when Expandable is set. |
| `OnRowExpand` | `EventCallback<PkTableRowExpandArgs<TItem>>` | The user expanded or collapsed a row: its id, item and the new state. Expanded already follows it. Not a row click. |
| `Selectable` | `bool` | A checkbox column with select-all for the page, and "Select all N rows" when the searched list has more. The selection is kept by id across pages and searches (see Selected). |
| `SelectPageOnly` | `bool` | Never offer "Select all N rows": the header checkbox selects the loaded page and SelectScope stays page. For a list whose bulk action works only on explicit ids. Default false. |
| `Selected` | `IReadOnlyList<string>?` | The ids (IdOf) of the selected rows, across every page and search the user visited (two-way: @bind-Selected). The order carries no meaning. After "Select all N rows" (SelectScope all) it holds only the loaded page: the rest is the query. |
| `SelectedChanged` | `EventCallback<IReadOnlyList<string>?>` | Raised with the new value of Selected (two-way). |
| `SelectScope` | `string?` | page or all: all after the user chose "Select all N rows", when every row of the query counts as selected. It follows the element and goes back to page when the selection changes. |
| `OnSelect` | `EventCallback<PkSelectEventArgs>` | The selection changed: Selected holds the ids (every selected id across pages, or the loaded page for scope all), Scope is page or all and Query the search and sort it refers to (read it with ToRequest). For scope all run the bulk action against that query on the server: the ids of the other pages are never sent. |
| `BulkContent` | `RenderFragment?` | Actions shown while rows are selected (the element's bulk slot). |
| `ToolbarContent` | `RenderFragment?` | Extra toolbar content, after the search box and before the add button (the element's actions slot). |
| `Cards` | `bool` | Each row becomes a card below 640px, columns marked HidePhone dropped. Default true. |
| `Striped` | `bool` | Alternate row tint. |
| `Density` | `PkTableDensity?` | The cell padding step; null leaves the element's default. |
| `MaxHeight` | `string?` | Bounds the table's height (a CSS length); with StickyHeader the header stays while the rows scroll. |
| `StickyHeader` | `bool` | Pin the header row (needs MaxHeight). |
| `Label` | `string?` | The accessible name of the table. Default "List"; give each list its own. |
| `PagerLabel` | `string?` | The accessible name of the pager. Default "List pages". |
| `Caption` | `string?` | The table caption, as text. |
| `RowHrefTemplate` | `string?` | Row links: an address with {id} (and any {field}, URL-encoded), like /orders/{id}. The first cell of each row becomes a real link; a plain click goes through the router. |
| `Filters` | `IReadOnlyList<PkFilterField>?` | The filter fields beside the search box; each value reaches Load in Filters (a multiselect in MultiFilters). |
| `Navigate` | `string?` | Who follows a RowHrefTemplate link on a plain click: "router" (default, or leave it unset) lets the app router handle the cancelable pk-navigate; "host" raises OnNavigate as a notification the router ignores and nothing navigates, so your handler navigates (NavigationManager); "none" raises nothing and the browser follows the address. |
| `OnNavigate` | `EventCallback<PkNavigateEventArgs>` | A click on a row link (pk-table's event, passed through). The event can be cancelled in the browser (preventDefault); a callback cannot cancel it. |

## `PkTableColumn<TItem>` (the columns of `PkDataTable` and `PkTable`)

| Member | Description |
|---|---|
| `public Func<TItem, string?>? Text` | The cell as text, computed from the item (a formatted date, an amount). Sent in the row under Key. |
| `public RenderFragment<TItem>? Cell` | The cell as markup (a chip, a link, an icon). Rendered as the child of the slot of that cell; the element shows it in place of the text. |
| `public RenderFragment? Header` | Markup beside the title of the column header (a help tooltip, an icon button, a menu). Rendered as the child of the element's `header-<key>` slot; the text Label and the sort button stay, and the markup sits outside the sort button, so using it never sorts. |
