Data grid
Columns arrive through a factory — [c => [ c.Field(p => p.Name) ]] — and the lambda's parameter is the grid itself. That is not a style choice: C# infers a method's type arguments from its own arguments and never from the target type of the indexer it sits in, so a column written as a flat child has nothing to tell it what p is. It is also why the grid's chain is GridBuild<T, TKey> rather than the usual Build<T> — an indexer cannot be constrained, so offering a column-factory indexer on a grid and nowhere else takes a chain type of its own.
Every state axis is controlled or uncontrolled independently. Say nothing and the grid holds its own sort, page, selection, grouping and column layout; name the state and its callback and that one axis moves to the page. Below sm the table restyles into stacked labelled lines — the same markup under different utilities, so the phone layout costs nothing but the classes.
The selection's keys come back as an IReadOnlyList<int> because RowKey pinned the chain's key type; the last grid's sort and page are plain fields on the demo. Every interaction here is a C# handler, so this component — unlike most of the kit — needs the runtime.
namespace Rask.Site.Features.UiKit;
/// <summary>
/// The data grid, in the four shapes worth seeing side by side.
/// </summary>
/// <remarks>
/// Every one of these is the same component: what differs is which state the page took ownership of.
/// The first grid owns nothing and holds its own sort, page and selection; the last hands its sort
/// and page to fields on this class, which is the shape a server-paged screen has.
/// </remarks>
public sealed partial class UiKitDataGridDemo : Component
{
private sealed record Release(int Id, string Name, string Channel, int Downloads, DateOnly Shipped);
private static readonly Release[] Catalog =
[
new(1, "Rask.Core", "stable", 18420, new DateOnly(2026, 2, 11)),
new(2, "Rask.Ui", "stable", 9310, new DateOnly(2026, 3, 4)),
new(3, "Rask.Cli", "stable", 7745, new DateOnly(2026, 1, 27)),
new(4, "Rask.Data", "preview", 2180, new DateOnly(2026, 5, 19)),
new(5, "Rask.Jobs", "preview", 1640, new DateOnly(2026, 6, 2)),
new(6, "Rask.WebPush", "preview", 880, new DateOnly(2026, 6, 30)),
new(7, "Rask.Signaling", "alpha", 210, new DateOnly(2026, 8, 8)),
];
private IReadOnlyList<int> _selected = [];
private string? _sort = "downloads";
private bool _descending = true;
private int _page;
/// <inheritdoc />
protected override Component? Render() =>
[
Section(
"Columns are a factory, not a list",
"The indexer takes a lambda whose parameter is the grid, and that is what fixes the row "
+ "type. Written as a flat child, a column would have nothing to infer its own lambda from.",
Div.Data(Testid("ui-grid-basic"))[
UiDataGrid.Data(Catalog).Zebra(true).Label("Packages")[c => [
c.Field(r => r.Name).Title("Package").Sortable(true),
c.Field(r => r.Channel).Title("Channel")
.Cell(r => UiBadge.Label(r.Channel).Tone(r.Channel == "stable" ? "success" : "info")),
c.Field(r => r.Downloads).Title("Downloads").Sortable(true).Class("text-right")
.Footer(rows => rows.Sum(x => x.Downloads)),
]]
]),
Section(
"Selection is typed, and staged behind the row key",
"RowKey pins the chain's key type, so the selection steps are offered only once a row can "
+ "be named — and the keys arriving back are an IReadOnlyList<int>, not boxed objects.",
Div.Data(Testid("ui-grid-selection"))[
UiDataGrid.Data(Catalog)
.Label("Packages to publish")
.RowKey(r => r.Id)
.Selected(_selected)
.OnSelectionChange(keys => _selected = keys)[c => [
c.Field(r => r.Name).Title("Package"),
c.Field(r => r.Shipped).Title("Shipped").Class("text-right"),
]],
P.Class("mt-2 text-sm text-ui-muted").Data(Testid("ui-grid-selection-state"))[
_selected.Count == 0
? "Nothing selected."
: $"Selected ids: {string.Join(", ", _selected.Order())}."
]
]),
Section(
"Grouping, subtotals and the column chooser",
"Group from a column's header button, then reorder or remove the grouping in the panel. "
+ "Both the panel and the chooser move things with buttons — drag is added on top of them, "
+ "because HTML5 drag fires on neither touch nor a keyboard.",
Div.Data(Testid("ui-grid-grouped"))[
UiDataGrid.Data(Catalog)
.Label("Packages by channel")
.GroupPanel(true)
.ColumnChooser(true)
.GroupSubtotals(true)
.Detail(r => P.Class("text-sm text-ui-muted")[
$"{r.Name} shipped on {r.Shipped:d MMMM yyyy}."
])[c => [
c.Field(r => r.Channel).Title("Channel").Groupable(true),
c.Field(r => r.Name).Title("Package").Sortable(true),
c.Field(r => r.Downloads).Title("Downloads").Class("text-right")
.Footer(rows => rows.Sum(x => x.Downloads)),
]]
]),
Section(
"Handing the state over",
"Name a state and its callback and that axis belongs to the page. Here the sort and the "
+ "page do; the expander and the column layout carry on holding their own.",
Div.Data(Testid("ui-grid-controlled"))[
UiDataGrid.Data(Catalog)
.Label("Packages, paged by the page")
.PageSize(3)
.Page(_page)
.OnPageChange(page => _page = page)
.Sort(_sort)
.SortDescending(_descending)
.OnSortChange(sort => { _sort = sort.Field; _descending = sort.Descending; })[c => [
c.Field(r => r.Name).Title("Package").Sortable(true),
c.Field(r => r.Downloads).Title("Downloads").Sortable(true).Class("text-right"),
]],
P.Class("mt-2 text-sm text-ui-muted").Data(Testid("ui-grid-controlled-state"))[
$"Page {_page + 1}, sorted by {_sort ?? "nothing"} "
+ (_descending ? "descending." : "ascending.")
]
])
];
private static Dictionary<string, string?> Testid(string value) => new() { ["testid"] = value };
private static Component Section(string heading, string blurb, Component body) =>
Div.Key(heading).Class("mb-8")[
H2.Class("text-lg font-semibold tracking-tight")[heading],
P.Class("mt-1 mb-3 text-sm text-ui-muted")[blurb],
body
];
}
Columns are a factory, not a list
The indexer takes a lambda whose parameter is the grid, and that is what fixes the row type. Written as a flat child, a column would have nothing to infer its own lambda from.
Channel | ||
|---|---|---|
| Rask.Core | stable | 18420 |
| Rask.Ui | stable | 9310 |
| Rask.Cli | stable | 7745 |
| Rask.Data | preview | 2180 |
| Rask.Jobs | preview | 1640 |
| Rask.WebPush | preview | 880 |
| Rask.Signaling | alpha | 210 |
| 40385 |
Selection is typed, and staged behind the row key
RowKey pins the chain's key type, so the selection steps are offered only once a row can be named — and the keys arriving back are an IReadOnlyList<int>, not boxed objects.
Package | Shipped | |
|---|---|---|
| Rask.Core | 02/11/2026 | |
| Rask.Ui | 03/04/2026 | |
| Rask.Cli | 01/27/2026 | |
| Rask.Data | 05/19/2026 | |
| Rask.Jobs | 06/02/2026 | |
| Rask.WebPush | 06/30/2026 | |
| Rask.Signaling | 08/08/2026 |
Nothing selected.
Grouping, subtotals and the column chooser
Group from a column's header button, then reorder or remove the grouping in the panel. Both the panel and the chooser move things with buttons — drag is added on top of them, because HTML5 drag fires on neither touch nor a keyboard.
Channel | Downloads | ||
|---|---|---|---|
| stable | Rask.Core | 18420 | |
| stable | Rask.Ui | 9310 | |
| stable | Rask.Cli | 7745 | |
| preview | Rask.Data | 2180 | |
| preview | Rask.Jobs | 1640 | |
| preview | Rask.WebPush | 880 | |
| alpha | Rask.Signaling | 210 | |
| 40385 |
Handing the state over
Name a state and its callback and that axis belongs to the page. Here the sort and the page do; the expander and the column layout carry on holding their own.
| Rask.Core | 18420 |
| Rask.Ui | 9310 |
| Rask.Cli | 7745 |
Page 1, sorted by downloads descending.