DataGridControl renders any collection as a tabular layout with sortable, resizable columns. It supports pagination, virtual scrolling for large datasets, custom cell templates, and column-level formatting — all wired up with a fluent builder API.
DataGridControl anatomy: column types and rendering mode options.
Basic Usage
The minimum setup is a DataGridControl(data) call plus one or more column definitions.
record Product(string Name, decimal Price, int Stock);
var products = new[]
{
new Product("Widget", 9.99m, 100),
new Product("Gadget", 24.99m, 50),
new Product("Gizmo", 14.99m, 75)
};
new DataGridControl(products)
.WithColumn(new PropertyColumnControl<string> { Property = "name" }.WithTitle("Product Name"))
.WithColumn(new PropertyColumnControl<decimal> { Property = "price" }.WithTitle("Price"))
.WithColumn(new PropertyColumnControl<int> { Property = "stock" }.WithTitle("In Stock"))
Property names are camelCase. The
Propertyvalue onPropertyColumnControlmust match the camelCase form of the record/class property name (e.g."name"forName,"unitPrice"forUnitPrice).
Column Types
PropertyColumnControl
Renders the value of a typed property from each row. The generic type parameter controls sorting and formatting behaviour.
new PropertyColumnControl<string> { Property = "email" }
.WithTitle("Email Address")
.WithWidth("200px")
.WithSortable(true) // default: true
.WithResizable(true) // default: true
.WithAlign("end") // start | center | end
.WithDefaultSort() // make this the initial sort column
.WithFormat("C2") // standard .NET format string (numbers, dates)
TemplateColumnControl
Places an arbitrary UiControl in every cell of the column. Use this for action buttons, badges, or any custom rendering.
new TemplateColumnControl(Controls.Button("View"))
.WithTitle("Actions")
.WithSortable(false) // action columns are rarely sortable
.WithWidth("100px")
Configuration Reference
DataGrid options
| Method | Purpose | Default |
|---|---|---|
WithVirtualize(bool) |
Enable virtual (windowed) scrolling | false |
WithItemSize(int) |
Row height in pixels (used by virtualizer) | 50 |
Resizable(bool) |
Allow column resizing globally | true |
WithPagination(bool) |
Enable built-in pagination | false |
WithItemsPerPage(int) |
Rows per page | — |
WithPageSizeOptions(int[]) |
Available page size choices | [5,10,25,50,100] |
WithShowHover(bool) |
Highlight row under the pointer | true |
WithSelectionMode(string) |
Row selection mode | — |
WithRowSelection(dataId, keyProperty, disabledReasonProperty?) |
Data-bound multi-row selection column — see "Row selection" below | — |
WithEmptyContent(control) |
Content shown when the dataset is empty | — |
WithLoading(bool) |
Show loading skeleton | false |
WithGenerateHeader(string) |
Header generation strategy ("Sticky", etc.) |
"Sticky" |
WithAutoFit(bool) |
Measure the cells once in the browser and pin their pixel widths (legacy) | false |
Default layout — readable without tuning
A grid that declares no widths is laid out by its CONTENT. Nothing per page is needed:
- The grid fills its container's width. Columns never stop short of the page.
- Every column is as wide as its content. A text column is an
autotrack: as wide as its widest cell (or header) when the room exists, and the room left over is shared among the text columns. A number, date or duration column (PropertyColumnControl<int>,<decimal>,<DateTime>,<TimeSpan>, …) is exactly its widest cell (max-content) and right-aligned; a template column (row actions, badges) ismax-contenttoo. A grid with no text column gives every columnauto, so it still fills its container. - A text cell that has to ellipsize shows its full value as a tooltip.
- On a narrow screen the grid scrolls horizontally inside its own box; the page never overflows.
- A grid ends with spacing below it, so the next heading never sits on its last row.
Override per column with WithWidth("…") / WithAlign("…"), or for the whole grid with WithGridTemplateColumns("…") (which then replaces every per-column default). Do not combine it with a column's WithWidth: Fluent's grid throws "You can use either the 'GridTemplateColumns' parameter on the grid or the 'Width' property at the column level, not both.", so the renderer adds no default width to a grid that declares a template..
🚨
WithAutoFit()used to be the renderer's DEFAULT. It measured the cells once in JavaScript after a render and pinned the measured pixel widths; measured before the rows had arrived (they bind a turn after the first render), it pinned header-sized columns, and every later measurement read back the pinned tracks — the fleet monitor showedHosting/…and🟢 Drai…in a grid that ended short of the page. It is now off unless a grid asks for it.
Column options (all column types)
| Method | Purpose | Default |
|---|---|---|
WithTitle(string) |
Column header text | — |
WithWidth(string) |
CSS width or grid track ("96px", "2fr", "auto") — overrides the content-sized default |
content-sized |
WithMinWidth(string) |
Minimum CSS width | — |
WithMaxWidth(string) |
Maximum CSS width | — |
WithAlign(string) |
Cell alignment (start, center, end) |
end for numbers, dates and durations; else start |
WithSortable(bool) |
Enable column sorting | true |
WithResizable(bool) |
Enable column resizing | true |
WithVisible(bool) |
Show or hide the column | true |
WithFrozen(bool) |
Freeze column (pin to left edge) | false |
WithFilterable(bool) |
Enable column filtering | — |
PropertyColumnControl extras
| Method | Purpose |
|---|---|
WithFormat(string) |
.NET format string ("C2", "d", etc.) |
WithDefaultSort() |
Make this the initial sort column |
WithInitialSortDirection(dir) |
"Ascending" or "Descending" |
WithEditable() |
Allow inline editing |
WithPlaceholderText(string) |
Placeholder shown for null/empty cells |
WithNullDisplayText(string) |
Text rendered when value is null |
Common Patterns
Pagination for longer lists
record Item(int Id, string Name, string Category);
var items = Enumerable.Range(1, 25)
.Select(i => new Item(i, $"Item {i}", i % 2 == 0 ? "A" : "B"))
.ToArray();
new DataGridControl(items)
.WithPagination(true)
.WithItemsPerPage(5)
.WithColumn(new PropertyColumnControl<int> { Property = "id" }.WithTitle("ID"))
.WithColumn(new PropertyColumnControl<string> { Property = "name" }.WithTitle("Name"))
.WithColumn(new PropertyColumnControl<string> { Property = "category" }.WithTitle("Category"))
Action buttons
Use TemplateColumnControl for per-row commands. Disable sorting on it so users aren't confused by clicking the header.
record User(string Name, string Email);
var users = new[]
{
new User("Alice", "alice@example.com"),
new User("Bob", "bob@example.com"),
new User("Carol", "carol@example.com")
};
var actionButtons = Controls.Stack
.WithOrientation(Orientation.Horizontal)
.WithHorizontalGap("4px")
.WithView(Controls.Button("Edit"))
.WithView(Controls.Button("Delete"));
new DataGridControl(users)
.WithColumn(new PropertyColumnControl<string> { Property = "name" }.WithTitle("Name"))
.WithColumn(new PropertyColumnControl<string> { Property = "email" }.WithTitle("Email"))
.WithColumn(new TemplateColumnControl(actionButtons)
.WithTitle("Actions").WithSortable(false))
Virtual scrolling for large datasets
Enable WithVirtualize(true) together with a fixed WithItemSize so the renderer can calculate offsets without measuring every row.
record DataRow(int Id, string Value);
var largeDataset = Enumerable.Range(1, 100)
.Select(i => new DataRow(i, $"Row {i}"))
.ToArray();
new DataGridControl(largeDataset)
.WithVirtualize(true)
.WithItemSize(40)
.WithColumn(new PropertyColumnControl<int> { Property = "id" }.WithTitle("ID"))
.WithColumn(new PropertyColumnControl<string> { Property = "value" }.WithTitle("Value"))
Read-only report grid
For display-only tables, disable resizing and right-align numeric columns.
record Report(string Category, decimal Amount);
var reportData = new[]
{
new Report("Sales", 15000.00m),
new Report("Marketing", 8500.50m),
new Report("Operations", 12300.75m)
};
new DataGridControl(reportData)
.Resizable(false)
.WithColumn(new PropertyColumnControl<string> { Property = "category" }
.WithTitle("Category").WithResizable(false))
.WithColumn(new PropertyColumnControl<decimal> { Property = "amount" }
.WithTitle("Amount").WithAlign("end").WithFormat("C2"))
Table with action column
A complete employee directory showing name, email, department, and a "View Details" action — a common production pattern.
record Employee(string Name, string Email, string Dept);
var employees = new[]
{
new Employee("Alice", "alice@co.com", "Engineering"),
new Employee("Bob", "bob@co.com", "Marketing"),
new Employee("Carol", "carol@co.com", "Sales")
};
new DataGridControl(employees)
.WithColumn(new PropertyColumnControl<string> { Property = "name" }.WithTitle("Name"))
.WithColumn(new PropertyColumnControl<string> { Property = "email" }.WithTitle("Email"))
.WithColumn(new PropertyColumnControl<string> { Property = "dept" }.WithTitle("Department"))
.WithColumn(new TemplateColumnControl(Controls.Button("View Details"))
.WithTitle("").WithWidth("120px").WithSortable(false))
.Resizable(true)
.WithShowHover(true)
Row selection
A grid that feeds a bulk action ("Approve selected") declares its selection — it never builds one
from checkbox buttons and a hand-rolled state machine. WithRowSelection gives the grid a
selection column: a checkbox per row and a header checkbox, both bound to the layout area's
data section.
const string selectionId = "inboxSelection"; // one per grid; no '/' in the id
host.SeedRowSelection(selectionId); // where the grid is RENDERED — seeds once per session, a re-render keeps the ticks
Controls.Stack
.WithView(new DataGridControl(rows)
.WithColumn(new PropertyColumnControl<string> { Property = "title" }.WithTitle(host.Localize("…")))
.WithRowSelection(selectionId, keyProperty: "path", disabledReasonProperty: "blockedReason"))
.WithView(Controls.Button(host.Localize("…approveSelected"))
.WithReactiveClickAction(ctx => ctx.SelectedRowKeys(selectionId)
.Select(keys => DataGridRowSelection.Prune(rows, keys, r => r.Path, r => r.BlockedReason))
.SelectMany(toApprove => ApproveAll(ctx, toApprove)) // reports progress, see below
.Select(_ => Unit.Default)));
| Piece | What it does |
|---|---|
| row checkbox | toggles that row's key in the selection; a row whose disabledReasonProperty is non-empty renders disabled, with the reason as its tooltip, and can never be ticked |
| header checkbox | unchecked → selects every selectable row; indeterminate or checked → clears the selection. "All" means all selectable rows, so a grid with blocked rows still reaches the checked state |
RowKey |
set by WithRowSelection (or alone with WithRowKey(property)): the client carries each row's key value as RowContext.Key on a row-scoped click, so every row's busy state and Cancel are its own |
DataGridSelectionState |
what the client writes under the data id: { keys: [...] }, in selection order |
ctx.SelectedRowKeys(id) |
the selection as it stands at the click — a one-off read to return from the click handler |
host.RowSelection(id) |
the live selection — for a "3 selected" label or a bulk button's Disabled binding |
DataGridRowSelection.Prune |
the owner's re-check before acting: drops keys whose row is gone or no longer selectable. A selection is the viewer's claim about the rows they saw, never a grant |
The rules (select-all, header state, toggle) live in ONE place, DataGridRowSelection, which the
client views call and the owner re-applies, so what the header selects and what the action may act
on cannot drift apart. The bulk button is an ordinary button: it gets the framework's busy state,
progress line, summary and Cancel (Buttons: Pending State) — call
ctx.ReportProgress("Approving 3 of 7", 3 / 7.0) as it goes and ctx.ReportSummary(…) at the end.
🚨 Keep
/out of the selection data id: a client write into a data id containing/does not reach the owner today.
See Also
- Editor Control — Form generation
- Stack Control — Layout container