Stingray.Components.DataTable 1.8.1

The owner has unlisted this package. This could mean that the package is deprecated, has security vulnerabilities or shouldn't be used anymore.
dotnet add package Stingray.Components.DataTable --version 1.8.1
                    
NuGet\Install-Package Stingray.Components.DataTable -Version 1.8.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Stingray.Components.DataTable" Version="1.8.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Stingray.Components.DataTable" Version="1.8.1" />
                    
Directory.Packages.props
<PackageReference Include="Stingray.Components.DataTable" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Stingray.Components.DataTable --version 1.8.1
                    
#r "nuget: Stingray.Components.DataTable, 1.8.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Stingray.Components.DataTable@1.8.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Stingray.Components.DataTable&version=1.8.1
                    
Install as a Cake Addin
#tool nuget:?package=Stingray.Components.DataTable&version=1.8.1
                    
Install as a Cake Tool

Stingray.Components.DataTable

A paginated Blazor table with search, sort and pagination, in two flavours:

Component Data comes from Use it when
StingrayDataTableOffline<TItem> a list you already have (Datasource) up to roughly a thousand rows — every interaction is instant and fires no network traffic
StingrayDataTable<TItem> one page at a time (DataConsumer) past that, where shipping every row costs more than a round trip per interaction

Both take the same columns, the same templates and the same chrome, so moving a page from one to the other is a small edit rather than a rewrite.

Setup

Add the stylesheet to your host page (App.razor, or index.html for standalone WebAssembly):

<link rel="stylesheet" href="_content/Stingray.Components.DataTable/stingray-datatable.css" />

That is the only wiring. There is nothing to register in Program.cs. The package ships one script, StingrayDataTableDrag.js, which the component imports itself for drag-to-reorder — no <script> tag, and if it fails to load you lose dragging and nothing else.

Place the link before your own stylesheets if you intend to override anything: it is ordinary global CSS, so at equal specificity whichever loads last wins.

If the table renders as an unstyled HTML table, this link is missing or the path is wrong.

_content/ paths are unhashed, and a host serving them with UseStaticFiles sends no Cache-Control at all — only an ETag. That leaves a browser free to reuse the copy it already has without revalidating, so after an upgrade you can be running the new assembly against the old stylesheet. It fails as a feature that draws but does not respond: the new version renders markup the old CSS has no rules for. Column resize is the usual casualty — its grab handle is a bare <span> that the stylesheet gives its size and cursor.

The script handles this itself — it stamps its own import URL with the package version, which is why you will see ?v= on it in devtools — but the stylesheet is your markup, so give it a version too. On .NET 9+:

<link rel="stylesheet" href="@Assets["_content/Stingray.Components.DataTable/stingray-datatable.css"]" />

@Assets[…] requires app.MapStaticAssets() in place of app.UseStaticFiles(). It fingerprints the URL, and — the part that matters here — serves unfingerprinted _content/ paths as no-cache, so they revalidate on every request.

On .NET 8, or on any host still using UseStaticFiles, stamp the version yourself — but take it from the package rather than typing it, or it is one more number to remember at upgrade time:

@using Stingray.Components.DataTable

<link rel="stylesheet"
      href="@($"_content/Stingray.Components.DataTable/stingray-datatable.css?v={DataTableVersion.Current}")" />

DataTableVersion.Current is the package's own informational version, prerelease label included, and it is the identical value the script stamps on its import URL. Hard-coding ?v=1.4.0 works until the upgrade nobody remembers to edit it for, which is the upgrade that needs it.

Quick start

@using Stingray.Components.DataTable

<StingrayDataTableOffline TItem="UserDto" Datasource="users" DefaultSortKey="Name">
    <Columns>
        <StingrayDataColumn Title="Name"    Value="x => x.Name" />
        <StingrayDataColumn Title="Email"   Value="x => x.Email" />
        <StingrayDataColumn Title="Created" Value="x => x.CreatedAt" Format="d" />
    </Columns>
</StingrayDataTableOffline>

Server-side is the same markup with a delegate in place of the list, and a SortKey per sortable column:

<StingrayDataTable TItem="UserDto" DataConsumer="LoadUsers" DefaultSortKey="name">
    <Columns>
        <StingrayDataColumn Title="Name" Value="x => x.Name" SortKey="name" />
    </Columns>
</StingrayDataTable>

@code {
    private async Task<DataTableResultDto<UserDto>> LoadUsers(DataTableRequestDto request)
    {
        var response = await Api.GetUsersAsync(request);
        return new DataTableResultDto<UserDto> { Items = response.Items, TotalCount = response.Total };
    }
}

For AI coding agents

This package ships an AGENTS.md covering the rules that are easy to get wrong — the mandatory ItemKey, the DataConsumer contract, the theming mechanism, and the behaviours that look like bugs but aren't. Nothing reads it automatically: CLAUDE.md and AGENTS.md are discovered from your working directory, and no agent tool scans the NuGet cache. Point yours at it by pasting this into your own CLAUDE.md / AGENTS.md:

## Stingray.Components.DataTable

Before writing code against `StingrayDataTable` / `StingrayDataTableOffline`, read the usage rules:
- `%USERPROFILE%\.nuget\packages\stingray.components.datatable\<version>\AGENTS.md` (Windows)
- `~/.nuget/packages/stingray.components.datatable/<version>/AGENTS.md` (macOS / Linux)

Full API reference: `README.md` in that same folder.
Per-parameter reasoning: the XML docs in `lib/net8.0/`, which your IDE also surfaces in IntelliSense.

The XML documentation file is the richest machine-readable surface and needs no wiring — it sits beside the DLL and carries the why behind each parameter, not just its type.

Writing a DataConsumer — two rules

Both of these produce bugs that only show up once someone pages, which is why they are worth stating plainly rather than leaving to taste.

1. Match SortKey against an explicit allow-list, and fall back rather than throw.

query = (request.SortKey?.ToLowerInvariant(), request.SortDescending) switch
{
    ("name", false)  => query.OrderBy(x => x.Name).ThenBy(x => x.Id),
    ("name", true)   => query.OrderByDescending(x => x.Name).ThenBy(x => x.Id),
    ("email", false) => query.OrderBy(x => x.Email).ThenBy(x => x.Id),
    ("email", true)  => query.OrderByDescending(x => x.Email).ThenBy(x => x.Id),
    _                => query.OrderByDescending(x => x.CreatedAt).ThenBy(x => x.Id),
};

Never EF.Property<> or dynamic LINQ. An unknown key must land on the default — a stale bookmarked sort should not be able to 500 the page.

2. End every sort with a unique tiebreaker — the .ThenBy(x => x.Id) above. Paging over a non-unique column (a timestamp, a display name) lets the database order ties differently per page, so rows visibly duplicate and vanish as the user pages. This is the single most likely bug in a server-side table.

One more, less subtle: count the filtered query before Skip/Take, or the pager lies whenever a search is active.

var total = await query.CountAsync(request.CancellationToken);
var items = await query.Skip(request.StartIndex).Take(request.PageSize).ToListAsync(request.CancellationToken);

Columns

TItem below is the row type of the table the column sits in — see TItem on columns.

Parameter Type Purpose
Name string? stable id for column order/visibility; falls back to SortKey, then Title
Title string header text
Value Func<TItem, object?>? value accessor — default cell text, plus offline sorting and searching
SortKey string? the string sent to the server; required for server-side sorting
Sortable bool opt the column out of sorting (default true)
Searchable bool opt the column out of searching (default true)
Freeze ColumnFreezeEnumDto pin the column to an edge while the table scrolls sideways
Width string? fixed width as a CSS length, applied as width and min-width
Format string? format string applied when the value is IFormattable
Template RenderFragment<TItem>? custom cell markup; receives the row
HeaderTemplate RenderFragment? custom header markup; receives nothing — a header has no row
HeaderCssClass string? class on this column's <th>
CellCssClass string? class on this column's <td>

Value takes a lambda, never a method group. Value="Format" fails with CS0411: the type arguments … cannot be inferred from the usageTItem arrives by cascade, and it cannot flow back through a method group to fix the delegate's type. Write Value="x => Format(x)". The same applies to every other Func<…> parameter on this page.

A column that supplies both Value and SortKey works identically in either table.

Sortable alone is not enough: a column is only sortable if it also supplies what its table needs — SortKey server-side, Value offline.

TItem on columns

A column takes TItem from the table it sits in, so it is not written on the column tag. Nesting resolves to the nearest table, so a second table over a different row type inside a RowDetailTemplate needs no help.

The one case that still needs an explicit TItem is a column that is not lexically inside the table element — moved into its own component, or assigned to a RenderFragment field. The cascade is resolved at compile time and does not reach there:

@code {
    // Explicit TItem required: the cascade does not reach a fragment declared outside the table.
    // Without it: error CS0411, the type arguments ... cannot be inferred from the usage.
    private RenderFragment cols = @<StingrayDataColumn TItem="UserDto" Title="Name" Value="x => x.Name" />;
}

An explicit TItem always overrides the cascade, so markup that writes it out explicitly compiles unchanged — it is redundant rather than wrong.

The toolbar is yours

ToolbarTemplate is the whole strip above the table, and its context carries every built-in control — put each one wherever it belongs among your own content rather than accepting a fixed corner:

<StingrayDataTable TItem="UserDto" DataConsumer="LoadUsers" ShowViewToggle="true">
    <ToolbarTemplate Context="toolbar">
        <h5>Delegation</h5>
        @toolbar.ViewToggle
        @toolbar.CardTools
        @toolbar.Search
        <button class="btn-primary">+</button>
        <button class="ms-auto">Export</button>
    </ToolbarTemplate>
    <Columns> … </Columns>
</StingrayDataTable>
On the context What it is Renders nothing when
@toolbar.Search the search box ShowSearch="false"
@toolbar.ViewToggle the table/cards segmented control ShowViewToggle="false"
@toolbar.CardTools the card-mode select-all box, sort dropdown and direction button in table mode, or nothing is selectable or sortable

Every fragment collapses to nothing when its feature is off — not to an empty box. So a template can write all three unconditionally and stay correct under any combination of settings, and you never have to mirror the component's own visibility rules in your markup.

Writing a fragment is what places it: leave one out and that control simply does not appear, which is how you drop a control you do not want. The strip is a wrapping flexbox, so ordering and spacing are yours through ordinary CSS (ms-auto, order, a spacer div). Leave ToolbarTemplate unset and you get the default strip: the controls at the leading end, the search box at the trailing one.

If you would rather build a control from scratch than place the built-in one, ViewMode is two-way bindable and SortByAsync(column, descending) is public.

Expandable rows

Two independent mechanisms, usable together. Either one adds the chevron column.

A detail panel — arbitrary content in a full-width row underneath:

<RowDetailTemplate>
    <div class="p-3">@context.Notes</div>
</RowDetailTemplate>

Nesting comes free: put another StingrayDataTable in there, to any depth.

When only some rows have anything to show, say so. ChildrenSelector needs no help here — the component asks it for a row's children and withholds the chevron when there are none. A RowDetailTemplate is opaque by comparison: it is a fragment, and whether one row's panel would hold anything is a question only you can answer. So without a predicate every row gets a chevron and the empty ones open an empty drawer:

<StingrayDataTableOffline TItem="TaskDto" Datasource="tasks"
                          IsRowExpandable="x => x.Subtasks.Count > 0">

The chevron column still appears whenever either mechanism is configured — the predicate withholds the toggle row by row, exactly as IsRowSelectable withholds a checkbox without hiding the selection column. Deciding the column itself would mean running your predicate across the whole page on every render to settle whether to draw one <th>.

Child rows — same-typed children, indented in the same table, recursively:

<StingrayDataTableOffline TItem="TaskDto" Datasource="tasks" ChildrenSelector="x => x.Subtasks">

Children fetched on expand — for a tree whose levels come from separate requests:

<StingrayDataTable TItem="TaskDto" DataConsumer="LoadTasks"
                   IsRowExpandable="x => x.HasChildren"
                   ChildrenLoader="(row, token) => Api.GetSubtasksAsync(row.Id, token)">

Available on both tables, not just the server one: "offline" here means the page is in memory, which says nothing about where one row's children come from.

IsRowExpandable is close to mandatory with it, for a sharper version of the reason above — the component cannot know whether a row has children until it has fetched them, and fetching the whole page's children to decide which chevrons to draw would defeat loading them on demand.

The result is cached per row until the next reload. Paging, sorting, searching and RefreshAsync() all collapse every row, so the cache has exactly the lifetime expansion already has; a row reopened after a reload fetches again, and one reopened before it does not. The token is cancelled when the table supersedes the request. A failure shows the localized Could not load data beneath the row with a Try again button beside it, and hands the exception to OnLoadError — never to the screen. Retrying refetches that one row without collapsing it; re-expanding still works too, because a transient failure must not become permanent.

Set both ChildrenSelector and ChildrenLoader and the selector answers first: returning an empty sequence means "no children" and is taken at its word, returning null means "not known here" and reaches the loader. That is what a tree whose first level ships embedded and whose deeper levels are lazy needs.

Paging, sorting and searching run over top-level rows only. Children are presentation attached to their parent: they are not counted in the total, not sorted, and not matched by the search box, and they render in the order ChildrenSelector returns them. Server mode is the reason — children arrive embedded in the parent's DTO, so the component cannot order or filter them without another round trip, and a rule that only held offline would be worse than no rule at all.

Expansion resets whenever the data reloads — paging, sorting, searching and RefreshAsync() all collapse everything, so a row can never stay open over data it no longer belongs to. Nothing is required of you for that to work: state only has to survive a single render pass, so unlike selection there is no mandatory ItemKey (though one is used if you have supplied it).

Indentation is --stingray-dt-indent per level, applied as a logical property so it indents from the right in RTL. Restyle the panel with --stingray-dt-detail-bg, the chevron with --stingray-dt-expand-icon-color, and child rows with --stingray-dt-child-row-bg.

As with selection, RowTemplate suppresses the expand column — a row you render yourself cannot have a cell injected into it.

With both columns on, the checkbox comes first and the chevron second, in the table and in the cards alike. The checkbox is the same control on every row, so leading with it gives a column that lines up; the chevron is indented per level and stair-steps beside it. (Before 1.7.0 the table drew them the other way round, which put the stair-step on the checkboxes and disagreed with card view.)

Row selection

<StingrayDataTableOffline TItem="UserDto" Datasource="users"
                          ShowSelectionColumn="true"
                          ItemKey="x => x.Id"
                          @bind-SelectedItems="selected">

ItemKey is required and the component throws without it. Selection is tracked by key rather than by object identity because a server-mode row comes back from the API as a new instance every time it is fetched — reference or Equals tracking would drop the selection the moment the user pages away and back. Keying makes that impossible, and it is why SelectedItems can hold rows that are not on the current page.

  • Selection is the page's top-level rows. CurrentPageItems is exactly that set. Rows revealed by expanding a parent carry no checkbox — the cell stays so the columns keep lining up, and only the control is withheld. (1.7.0 put a box on every rendered row; that is withdrawn while the requirements for selecting inside a tree are settled. See Upgrading to 1.8.0.)
  • The header checkbox covers the current page, showing a dash when it is partly selected. It is deliberately not "all N matching rows": in server mode the component does not hold the other pages, so anything wider would be a guess. Rows selected on other pages are left alone.
  • Only the checkbox selects. Clicking anywhere else in the row still fires OnRowClick, so selection and drill-down navigation stay independent.
  • IsRowSelectable disables the checkbox on rows it rejects, and select-all skips them.
  • Selected rows get sdt-row-selected; restyle with --stingray-dt-row-selected-bg.

ShowSelectionColumn draws the built-in column and gates nothing else. With it off, ItemKey set and SelectedItems bound, the component still tracks the selection — so you can render your own checkboxes and still drive it:

<button @onclick="() => table.SetAllSelectedAsync(table.SelectAllState != SelectAllStateEnumDto.All)">
    Select all
</button>

SelectAllState returns None / Some / All over the same rows the built-in header reads, so a control of your own cannot disagree with it. SetAllSelectedAsync(bool) is an explicit setter rather than a toggle, which is what lets a plain button be plain; it throws if ItemKey is missing, because there is nothing to record a selection in and refusing quietly would look like a component that does not work.

Selection and RowTemplate do not combine. A row you render yourself cannot have a cell injected into it, and drawing only the header cell would knock every row out of alignment — so with RowTemplate set the selection column is not rendered at all.

Column configuration — order and visibility

Both are bindable, so you persist the arrangement wherever you already keep user preferences:

<StingrayDataTableOffline TItem="UserDto" Datasource="users" ShowColumnChooser="true"
                          @bind-ColumnOrder="order" @bind-HiddenColumns="hidden">
    <Columns>
        <StingrayDataColumn Name="name" Title="Name" Value="x => x.Name" />
    </Columns>
</StingrayDataTableOffline>

ShowColumnChooser is the whole wiring — no @ref, no click handler. To choose where the button sits, place it from a ToolbarTemplate instead; the flag still gates it:

<ToolbarTemplate Context="toolbar">
    @toolbar.Search
    @toolbar.ColumnChooser
</ToolbarTemplate>

Give every column a Name when using this. A column's key is Name, else SortKey, else Title — and SortKey is null on non-sortable columns while Title is localized, so neither is a safe identifier.

If two columns do end up sharing a key, the component refuses every reorder and every visibility change rather than guessing which one you meant, and the chooser reports the offending key in place of its list. Reset still works. Nothing throws, deliberately: the Title fallback is localized, so a collision can arrive from a translation alone — and a component that threw would take the page down in Arabic and not in English, off a build that was clean and a test suite that was green.

ColumnOrder is deliberately forgiving: columns it names render in that order, columns it doesn't render after them in declaration order, and keys matching no column are ignored. An arrangement persisted months ago therefore keeps working when you add a column to the markup.

Reordering is confined to a freeze group. A pinned column can only move among other columns with the same Freeze, so it can never end up stuck to an edge from a position in the middle.

A column with nowhere legal to go says so. The chooser disables the arrow that has no neighbour to swap with — the ends of a group, and both arrows for a column pinned on its own — and a column that cannot move in either direction is not draggable at all: the gesture is refused at the press, rather than raising a ghost that follows the pointer to a drop the component then declines. In the table the same question is asked of the visible columns, since a header drag can only land on a column that is rendered; a column whose only group peer is hidden is therefore fixed in the table and still movable in the panel.

Columns are drag-reorderable by default — drag a header, or a row in the chooser. Set AllowColumnReorder="false" to fix the order: headers stop dragging, chooser rows stop dragging and its arrows go disabled. Resize is unaffected either way; the two gestures share one script but not one hit test, and a resize is claimed at the handle before a drag is considered.

Note that dragging and the chooser's arrows are deliberately different operations: a drag lands the column where you drop it and shifts the rest along, while an arrow swaps it with its nearest neighbour in the group. Both publish through ColumnOrderChanged.

A header both sorts and drags: under 5px of travel it is a click and sorts exactly as before, past that it is a drag and the resulting click is swallowed. On touch, reordering is a long press rather than a drag — a drag threshold could not be told apart from a scroll, and in the chooser the two share an axis.

Drag is pointer-only, and the chooser's arrows are the only keyboard path to a reorder — they are also the fallback if the script never loads. ShowColumnChooser defaults to false, so a table that leaves it off and leaves reorder on offers a mouse-only operation. Show the chooser, or set AllowColumnReorder="false"; the combination to avoid is reorder on with no chooser.

The last visible column cannot be hidden. Hiding the sorted column leaves the sort active — the rows stay in that order and nothing refetches. Hidden columns are excluded from offline search.

The chooser needs a popup host

The component builds the panel; your layout provides the surface, so outside-click, Esc and focus are handled by your own dialog rather than by this package. Cascade two delegates:

@using Stingray.Components.DataTable

<CascadingValue Value="ToggleDialog" Name="@DataTableCascades.ToggleDialog">
    <CascadingValue Value="SetRenderFragment" Name="@DataTableCascades.SetRenderFragment">
        @Body
    </CascadingValue>
</CascadingValue>

<YourPopup HeaderText="@HeaderName" @bind-Visible="IsShowDialog">@Content</YourPopup>

@code {
    bool IsShowDialog;
    RenderFragment? Content;
    string HeaderName = "";

    void SetRenderFragment(RenderFragment content, string headerName)
    {
        Content = content; HeaderName = headerName; StateHasChanged();
    }

    void ToggleDialog(bool isShow) { IsShowDialog = isShow; StateHasChanged(); }
}

Name them. Action<bool> is a type an app plausibly cascades for its own reasons, and one of those anywhere between your layout and the table captures ToggleDialog instead — the chooser then opens nothing, or toggles something else, with no exception to trace. The names in DataTableCascades make the match unambiguous.

Both are still matched unnamed, by type, as a fallback, so a layout already wired that way for other components keeps working; add the names to it when convenient. A named cascade wins over an unnamed one when both are in scope.

Without a host, ShowColumnChooser draws no button — the panel would have nowhere to render, so the control that summons it is withheld rather than offered and then failing on click. A table whose toolbar had nothing else in it draws no toolbar either. If the button is missing where you expected one, the cascade is what to check.

The host is required for OpenColumnChooser() too, and there the failure is loud: the method throws an InvalidOperationException naming both cascades. The asymmetry is deliberate — a button can be withheld, a method call cannot, and a quiet no-op is the exact fault the named cascades exist to prevent. ColumnChooserAvailable answers the same question the button asks, so a control you enable by it can never call an opener that then refuses.

The flag is not a precondition for the method. ShowColumnChooser renders the built-in button and gates nothing else, so a button of your own does not need it switched on and then hidden:

<StingrayDataTableOffline @ref="table" ShowColumnChooser="false" … />

@if (table?.ColumnChooserAvailable == true)
{
    <button @onclick="() => table.OpenColumnChooser()">Columns</button>
}

This is also the keyboard path to a column reorder: dragging a header is pointer-only, and the panel's Move up / Move down arrows are the alternative. With AllowColumnReorder left on and no way to open the panel, reordering is a mouse-only operation — a 2.1.1 failure — so a table that hides the built-in button owes the user one of its own.

The panel is themed from the same --stingray-dt-* tokens as the table. It resolves them itself rather than inheriting, because it renders inside your popup rather than inside the table — so set them on :root or body (as you normally would) and the panel picks them up too.

Each row is two boxes: a card carrying the column's title and a toggle switch, and a separate box beside it holding Move up / Move down. The switch is the same <input type="checkbox"> the table's selection column uses, repainted — so it toggles, tabs and reads to a screen reader exactly as a checkbox does. Restyle the row with the chooser-card-* tokens and the toggle with the switch-* ones; both groups are in the table below.

Prefer your own chooser UI? Two different asks, two different answers. If you want this panel somewhere else, OpenColumnChooser() opens it wherever your button is. If you want a genuinely different panel, the API behind this one is public — OrderedColumns(), IsColumnVisible(), SetColumnVisibleAsync(), MoveColumnAsync(), MoveColumnToAsync(), CanMoveColumn(), ResetColumnsAsync() and VisibleColumnCount — so build whatever you like against the same state. (StingrayColumnChooser<TItem> is public as well, but rendering it yourself means knowing that it takes Table and three already-localized labels, and a fourth label later would be a breaking change for you. Prefer either route above.) MoveColumnAsync(column, ±1) swaps with the neighbour; MoveColumnToAsync(column, target) lands the column at the target's position. Both refuse a move that would leave the column's freeze group, and CanMoveColumn(column, ±1) is how you ask first — it answers from the same helper the mover uses, so a control you enable by it can never trigger a move that is then refused.

Frozen columns

A column can stay pinned to an edge while the rest of the table scrolls sideways — most often an actions column that must stay reachable, or an identifying column that keeps a scrolled row recognisable:

<StingrayDataColumn Title="Name" Value="x => x.Name" Freeze="ColumnFreezeEnumDto.Start" />
<StingrayDataColumn Title="Actions" Sortable="false" Freeze="ColumnFreezeEnumDto.End">
    <Template>
        <button @onclick="() => Edit(context)">Edit</button>
    </Template>
</StingrayDataColumn>

Start and End follow the writing direction rather than left and right, so End is the right edge in English and the left edge in Arabic. That is what "the actions column stays visible" means in both — no second set of markup for RTL.

Pinning one column per edge needs nothing else. To pin several to the same edge, give each of them a Width:

<StingrayDataColumn Title="" Freeze="ColumnFreezeEnumDto.Start" Width="3rem" />
<StingrayDataColumn Title="Name" Freeze="ColumnFreezeEnumDto.Start" Width="14rem" />

The inner column's offset is the sum of the widths between it and the edge, so a missing Width leaves pinned columns stacked on top of each other. Note also that content wider than Width still expands the cell — max-width is not honoured on cells in an auto-layout table — and an expanded cell throws the offsets of the columns beside it out by the difference. AllowColumnResize changes that: it switches the table to table-layout: fixed, where Width is authoritative and over-long content is clipped instead. See Resizable columns.

Two more things worth knowing:

  • A pinned cell paints an opaque background so scrolled content cannot show through it. Restyle it with --stingray-dt-frozen-bg, --stingray-dt-frozen-hover-bg and --stingray-dt-frozen-header-bg; the separator line uses --stingray-dt-frozen-edge-color and --stingray-dt-frozen-edge-width.
  • Freeze applies to the cells the component renders. If you supply a RowTemplate you are emitting the <td>s yourself, so add class="sdt-frozen sdt-frozen-end" and style="inset-inline-end: 0" to the ones you want pinned.

Resizable columns

AllowColumnResize puts a grab handle on every header's trailing edge. Widths land in a bindable ColumnWidths, keyed by column Key:

<StingrayDataTableOffline TItem="UserDto" Datasource="users"
                          AllowColumnResize="true"
                          @bind-ColumnWidths="widths">
    <Columns>
        <StingrayDataColumn Name="name" Title="Name" Value="x => x.Name" Width="14rem" />
    </Columns>
</StingrayDataTableOffline>

Turning it on changes how the table lays out, and that is the point. The table is auto-layout otherwise, where a Width is only a hint — content pushes a column wider regardless — so dragging one narrower than its content would snap straight back and the gesture would read as broken. AllowColumnResize switches to table-layout: fixed, which makes widths authoritative. What that costs:

  • Columns with no Width share the remaining space equally rather than sizing to their content. Set a Width on the ones that need a particular size.
  • Content that no longer fits is clipped with an ellipsis instead of widening its column.
  • The selection and expand columns are exempt from both: the component states their widths, because a column that cannot size to its content would otherwise leave a checkbox or a chevron in a cell narrower than itself. The expand column's width tracks how deep the tree is currently opened — the per-level indent is padding inside that column, so the room a chevron needs grows with its row's depth — and it is the width auto layout would have computed for the same tree. Like any other declared width it still takes a share of whatever space the table has spare — a column cannot opt out of that, so give at least one column no Width and it will absorb the slack instead, leaving the gutters snug. Nothing is required of you, and there is nothing to override.

That is why it is off by default: upgrading must never silently re-lay-out a table.

The dictionary holds only the columns the user actually changed, so it stays small and is cheap to persist — it is bindable, and, like ColumnOrder, not persisted for you. Keys matching no column are ignored, so an arrangement saved months ago survives a column being renamed or removed. ResetColumnsAsync() clears it along with the order and the hidden set, and double-clicking a handle restores that one column's declared width.

The handle is focusable, so a width is reachable without a pointer: <kbd>←</kbd> and <kbd>→</kbd> step it by 16px (mirrored in RTL, so the arrow that widens is always the one pointing at the trailing edge), <kbd>Shift</kbd> with either steps by 64px, and <kbd>Home</kbd> does what the double-click does. Each press commits on its own — there is no drag to preview and no click to disambiguate from a sort.

Any column can be resized, including one alone in its freeze group — unlike reordering, a resize has no notion of a group to stay inside. A resized frozen column moves the ones it holds out from the edge, live, because the offsets are summed from the same widths.

Widths commit in px, because that is what the gesture measured; converting to the column's declared unit would need the font size and the container width and would land somewhere other than where the user let go.

Card view

ViewMode draws the same table as a responsive grid of cards. Nothing else changes: the page, the sort, the search term, the selection and the expansion are one piece of state that both layouts read, so switching mid-session keeps everything the user had set.

<StingrayDataTableOffline TItem="UserDto" Datasource="users"
                          ShowViewToggle="true"
                          @bind-ViewMode="viewMode">
    <Columns>
        <StingrayDataColumn Title="Name" Value="x => x.Name" />
        <StingrayDataColumn Title="Email" Value="x => x.Email" />
        <StingrayDataColumn Title="Salary" Value="x => x.Salary" Format="C0" />
    </Columns>
</StingrayDataTableOffline>

@code {
    private ViewModeEnumDto viewMode = ViewModeEnumDto.Cards;
}

Each card lists the visible columns as label/value pairs, honouring Template, Format, ColumnOrder and HiddenColumns exactly as the cells do. ShowViewToggle adds the segmented table/cards control; leave it off and drive ViewMode yourself.

The grid is auto-fill, so the number of cards per row falls out of the width available and there is no breakpoint to configure. Height scrolls a card grid exactly as it scrolls rows.

MaxCardCountPerRow caps the other end. CardMinWidth stays the floor, so the cap can only ever remove columns the container would otherwise have fitted — it can never force ones that do not fit. Narrow the container and the count still steps down on its own; a hard column count would instead put four unreadable slivers in a sidebar.

It follows that the cap does nothing until the container is wide enough to have shown more than that many. At the default 17rem minimum, MaxCardCountPerRow="3" first takes effect around 1124px, and every width below that renders exactly as it would uncapped — phones and tablets are unaffected by setting it. Where the cap does bind, the surviving tracks still stretch to fill the row rather than sitting at their minimum with dead space beside them.

Values below 1 are treated as 1. Leave it unset for no limit.

Sorting moves to the toolbar. There is no header to click, so card mode shows a dropdown of the sortable columns and a direction button. It appears only in card mode, and only when something is actually sortable — offline that means a Value, server-side a SortKey.

Freeze and Width do nothing in card mode. A card has no columns to pin or size. Both are simply not read, so a table that uses them can switch to cards and back without changes.

Hierarchy nests inside the card

ChildrenSelector children render inside their parent's card, to any depth. The grid itself holds nothing but top-level records, however many levels are open.

This is not a stylistic choice. A grid's cells are peers, so a flattened child would simply be another card — one that can wrap onto a different row from its parent entirely, with an indent that only narrows it inside its own cell. A grid can express hierarchy through containment or not at all. The table keeps flattening, because there a child genuinely does sit directly beneath its parent.

Nested cards are demoted rather than repeated — tinted, no shadow, lighter border — and each level adds a rail that reads as a tree spine. Indentation comes from the nesting itself, so it accumulates without any depth arithmetic, and it mirrors in RTL for free.

Two consequences worth knowing:

  • A click on a nested card does not reach its ancestors. Nested cards stop propagation, so OnRowClick fires for the card you clicked and nothing above it.
  • An expanded card grows taller than its neighbours. Grid rows size to their tallest item, so expect whitespace beneath shorter cards in the same row. CardMinWidth trades columns for card width if deep trees get cramped.

RowDetailTemplate and ChildrenSelector can both be set: the detail panel renders first, then the children — the same order the table puts them in.

CardTemplate

To own the card's body, supply CardTemplate. The component still draws the card surface around it:

<CardTemplate Context="user">
    <div class="user-card">
        <img src="@user.AvatarUrl" alt="" />
        <div>
            <strong>@user.Name</strong>
            <span>@user.Email</span>
        </div>
    </div>
</CardTemplate>

Unlike RowTemplate, this does not suppress the selection checkbox or the expand toggle. Those are drawn as chrome in the card's corner rather than as injected cells, so there is no column alignment for them to knock out. Columns is still required — they own the sort keys, the offline search accessors and the fields the column chooser lists.

RowTemplate is ignored in card mode; it emits a <tr>, which has nowhere to go in a card grid.

Where the controls go

The card-mode controls are one ordinary member of the toolbar context, so a ToolbarTemplate places them like anything else — see The toolbar is yours:

<ToolbarTemplate Context="toolbar">
    @toolbar.Search
    <button class="ms-auto">Export</button>
    @toolbar.CardTools
    @toolbar.ViewToggle
</ToolbarTemplate>

@toolbar.CardTools renders nothing in table mode, so it appears and disappears as the user switches without the template having to test for it. It exists because cards have no header row: in the table the header sorts and selects the page, and in cards nothing would. The select-all box and the sort picker come as one fragment for the same reason — they answer the same absence, and a template placing them should not have to keep two halves side by side itself.

RowTemplate — the escape hatch

For rows whose markup does not decompose into independent cells, supply RowTemplate instead. It emits the whole <tr>:

<StingrayDataTableOffline TItem="UserDto" Datasource="users">
    <Columns>
        <StingrayDataColumn Title="Name" Value="x => x.Name" />
        <StingrayDataColumn Title="Email" Value="x => x.Email" />
    </Columns>
    <RowTemplate Context="user">
        <tr @key="user.Id">
            <td>@user.Name</td>
            <td><a href="mailto:@user.Email">@user.Email</a></td>
        </tr>
    </RowTemplate>
</StingrayDataTableOffline>

Columns is still required — the columns own the headers, the sort keys and the offline search accessors. Keep the template's cell count equal to the column count, or the header and body will not line up. OnRowClick and RowClass do not apply to a RowTemplate row; put the handler on your own <tr>.

Table parameters

TItem is the table's row type. A bindable parameter has a matching …Changed callback, listed on its own row: Razor matches a handler by exact delegate signature, so a handler written against a near-miss type fails with CS1503: cannot convert from 'method group' to 'Microsoft.AspNetCore.Components.EventCallback' — a diagnostic that points nowhere near the type.

Parameter Type Default Notes
Columns RenderFragment? required; holds the <StingrayDataColumn> definitions
DataConsumer Func<DataTableRequestDto, Task<DataTableResultDto<TItem>>> StingrayDataTable only, required
Datasource IEnumerable<TItem> [] StingrayDataTableOffline only, required
RowTemplate RenderFragment<TItem>? null see above
ViewMode ViewModeEnumDto Table Table or Cards; supports @bind-ViewMode
ViewModeChanged EventCallback<ViewModeEnumDto>
ShowViewToggle bool false draws the built-in table/cards toggle in the toolbar
CardTemplate RenderFragment<TItem>? null replaces a card's body; card mode only
CardMinWidth string? 17rem token narrowest a card may be before the grid drops a column
MaxCardCountPerRow int? null most cards per row; a cap, never a mandate
EmptyTemplate RenderFragment? localized "No available records" shown when the table itself has no rows
NoResultsTemplate RenderFragment? localized "No matching records" shown when a search matched nothing; falls back to EmptyTemplate
ErrorTemplate RenderFragment? localized "Could not load data" plus a Try again button replaces the whole failure state, retry included; set it empty to show nothing
OnLoadError EventCallback<Exception> the exception behind a failed load — the only place it is offered
AllowColumnReorder bool true drag-to-reorder and the chooser's arrows; off fixes the column order
AllowColumnResize bool false drag-to-resize; switches the table to table-layout: fixed
ColumnWidths IReadOnlyDictionary<string, string>? null bindable width per column key; supports @bind-ColumnWidths
ColumnWidthsChanged EventCallback<IReadOnlyDictionary<string, string>>
ShowColumnChooser bool false draws the built-in chooser button, and gates nothing else; needs a popup host cascaded. OpenColumnChooser() opens the panel either way
ToolbarTemplate RenderFragment<DataTableToolbarContext>? null the whole toolbar strip; its context carries Search, ViewToggle, CardTools, ColumnChooser and Reset
ColumnOrder IReadOnlyList<string>? null bindable column arrangement; see Column configuration
ColumnOrderChanged EventCallback<IReadOnlyList<string>>
HiddenColumns IReadOnlyList<string>? null bindable column visibility; see Column configuration
HiddenColumnsChanged EventCallback<IReadOnlyList<string>> same type as ColumnOrderChanged; changed in 1.5.0, see below
RowDetailTemplate RenderFragment<TItem>? null either this or ChildrenSelector adds the expand column
ChildrenSelector Func<TItem, IEnumerable<TItem>?>? null children already embedded in the parent row
ChildrenLoader Func<TItem, CancellationToken, Task<IEnumerable<TItem>>>? null children fetched when the row is expanded, cached until the next reload
IsRowExpandable Func<TItem, bool>? null withholds the expand toggle per row; governs RowDetailTemplate and ChildrenLoader
ShowSelectionColumn bool false draws the built-in checkbox column, on top-level rows only, and gates nothing else; ItemKey is required with it
ItemKey Func<TItem, object>?
SelectedItems IReadOnlyCollection<TItem> [] supports @bind-SelectedItems
SelectedItemsChanged EventCallback<IReadOnlyCollection<TItem>>
IsRowSelectable Func<TItem, bool>? null
PageSize int? 10 initial rows per page
PageSizeOptions int[]? [10, 25, 50] empty array hides the selector
DefaultSortKey string? null matched against SortKey, then Title
DefaultSortDescending bool false
ShowSearch bool true draws the built-in search box. The one control with no public way to drive it — see Driving the built-in controls yourself
SearchPlaceHolder string? localized "Search…"
SearchDebounceMs int? 500
MinSearchCharacterCount int? 0
ShowPaginationInformation bool? true the "1 - 10 of 137" range line
MaxPageButtons int? 5 numbered buttons between the first and last page
OnRowClick EventCallback<TItem>
RowClass Func<TItem, string?>? null
CssClass string "" extra classes on the wrapper
TableCssClass string "" extra classes on <table>
Height string? null CSS length fixing the whole component's height, whatever the row count
MaxHeight string? null CSS length capping it instead, leaving it its natural size below that
IsDisabled bool false

Public members

Everything public, on DataTableBase<TItem> and so on both tables. Reach it with an @ref.

Data

Member Signature
RefreshAsync Task RefreshAsync(bool resetPage = false)
CurrentPageItems IReadOnlyList<TItem> (get) — the page's top-level rows; expanded children are not included
TotalCount int (get)

Sorting

Member Signature
SortByAsync Task SortByAsync(StingrayDataColumn<TItem> column, bool descending)
SortColumn StingrayDataColumn<TItem>? (get)
SortDescending bool (get)

Paging

Member Signature
GoToPageAsync Task GoToPageAsync(int page) — clamped to the valid range
SetPageSizeAsync Task SetPageSizeAsync(int pageSize) — resets to page 1; throws below 1
CurrentPage int (get)
CurrentPageSize int (get)
TotalPages int (get) — at least 1

Selection

Member Signature
SelectAllState SelectAllStateEnumDto (get) — None / Some / All over the current page
SetAllSelectedAsync Task SetAllSelectedAsync(bool selected) — throws without ItemKey

Expansion

Member Signature
SetExpandedAsync Task SetExpandedAsync(TItem item, bool expanded)
IsExpanded bool IsExpanded(TItem item)

Columns

Member Signature
OpenColumnChooser void OpenColumnChooser() — throws without a cascaded popup host
ColumnChooserAvailable bool (get) — whether a popup host is cascaded
RefreshColumnChooser void RefreshColumnChooser() — redraws an open panel after a change
OrderedColumns List<StingrayDataColumn<TItem>> OrderedColumns()
IsColumnVisible bool IsColumnVisible(StingrayDataColumn<TItem> column)
SetColumnVisibleAsync Task SetColumnVisibleAsync(StingrayDataColumn<TItem> column, bool visible)
MoveColumnAsync Task MoveColumnAsync(StingrayDataColumn<TItem> column, int direction) — swaps with the neighbour
MoveColumnToAsync Task MoveColumnToAsync(StingrayDataColumn<TItem> column, StingrayDataColumn<TItem> target) — lands it at the target
MoveColumnToKeyAsync Task MoveColumnToKeyAsync(string columnKey, string targetKey)
CanMoveColumn bool CanMoveColumn(StingrayDataColumn<TItem> column, int direction)
SetColumnWidthAsync Task SetColumnWidthAsync(StingrayDataColumn<TItem> column, string? width)
ResetColumnsAsync Task ResetColumnsAsync() — clears order, hidden and widths
ColumnsRearranged bool (get) — whether any of those three differ from the declaration
VisibleColumnCount int (get)

Call RefreshAsync() after a create, update or delete — it keeps the current page, and clamps if that page no longer exists. Pass RefreshAsync(resetPage: true) instead when something outside the table changed what the data means — an external filter, a view switch, a search term you own — and page 1 is the honest place to land. The sort, the page size and the column arrangement survive both.

Driving the built-in controls yourself

A Show* flag says whether the package draws its own control. It never decides whether the capability exists. Every built-in control has a public method or a bindable parameter behind it, so you can hide the one that ships and put your own anywhere on the page — in a page header, a section header, a settings bar — without losing anything.

Built-in control Flag Drive it yourself with
View toggle ShowViewToggle ViewMode, two-way bindable
Sort — headers, and the card-view picker SortByAsync(column, descending), SortColumn, SortDescending
Column chooser button ShowColumnChooser OpenColumnChooser(), guarded by ColumnChooserAvailable
Reset button — (appears when ColumnsRearranged) ResetColumnsAsync(), ColumnsRearranged
Select-all checkbox ShowSelectionColumn SelectAllState, SetAllSelectedAsync(bool)
Expand chevron SetExpandedAsync(item, expanded), IsExpanded(item)
Pager and page-size select GoToPageAsync(int), SetPageSizeAsync(int), TotalPages
Search box ShowSearch nothing yet — see below

AllowColumnReorder and AllowColumnResize are deliberately different. They are Allow*, not Show*: they gate the capability itself, so MoveColumnAsync and the drag both refuse under AllowColumnReorder="false". That is the flag working, not a gap in this rule.

The one exception is ShowSearch. There is no public way to set the search term, so hiding the box does remove the only way to search from inside the component. The workaround is to filter in your own DataConsumer (or over the collection you hand Datasource) and call RefreshAsync(resetPage: true) — which is a route the chooser never had. A public setter is the intended fix; it is not in 1.8.0.

Two things behave differently when you take a control over:

  • A method has no affordance to withhold, so where a button is simply not drawn, the method throws: OpenColumnChooser() without a popup host, SetAllSelectedAsync without ItemKey. A silent no-op would be indistinguishable from a component that does not work. ColumnChooserAvailable is how to ask first.
  • These methods call StateHasChanged on the table for you. Your button lives in another component, and that does not mark this one dirty.

Page reset behaviour

Changing the search term, the sort, or the page size returns to page 1. RefreshAsync() does not, and that is its purpose — an edit should not throw the user back to the top. When an external change makes the current page meaningless, ask for the reset explicitly:

<button @onclick="() => table.RefreshAsync(resetPage: true)">Apply filter</button>

Do not remount the component to get back to page 1. A @key that changes with your filter does reset the page — and also discards the sort and the page size, and re-reads any column layout you had persisted. Three things the user chose, thrown away to move one of them.

The three row-area states

With no rows to draw, the table renders one of three states in place of them — each with its own mark, drawn in the component's own stroke language rather than borrowed from an icon font:

State When Shows
Empty no rows at all a table outline, and "No available records"
No results a search matched nothing a magnifier, and "No matching records"
Error the load threw an alert mark, the localized message, and a Try again button

The error state can recover itself. Try again calls the same reload the component uses elsewhere, keeping the current page — the load failed, the page the user was on did not become wrong. Before this the message named what had happened and then left the user with nothing to press. The exception still goes only to OnLoadError, never to the screen.

ErrorTemplate replaces that whole state, retry included, so supply your own control if you take it over. EmptyTemplate and NoResultsTemplate replace theirs, marks included — which is the right place to put "Add your first user", because the component cannot know what creating a row means.

The marks are aria-hidden: the message carries the meaning, and a screen reader hears it once.

Upgrading to 1.8.0

No compile-time breaks. One behaviour change, and it is a step back from 1.7.0.

  1. Selection is the page's top-level rows again. Expanded child rows no longer draw a checkbox, and select-all no longer reaches them. The requirements for selecting inside a tree — what a parent's box should mean for its children, what a partly selected subtree publishes — are not settled, so the behaviour is withdrawn rather than guessed at. SelectedItems can no longer come back holding a child row, which reverses the note under 1.7.0 below; if you added handling for that, it is now unreachable rather than wrong. The visible consequences: a child row has an empty selection cell (the cell stays, so the columns still line up), a nested card has no checkbox, and select-all followed by expanding a row leaves the header ticked instead of dropping it to a dash.

  2. Every built-in control is now drivable from your own UI, which is additive. See Driving the built-in controls yourself for the whole table; the new members are OpenColumnChooser(), ColumnChooserAvailable, SelectAllState, SetAllSelectedAsync(bool), SetExpandedAsync(item, expanded), IsExpanded(item), GoToPageAsync(int), SetPageSizeAsync(int), TotalPages, SortColumn and SortDescending. ShowColumnChooser and ShowSelectionColumn now mean "draw the built-in control" and nothing more.

One fix worth calling out on its own: ShowSelectionColumn="false" used to discard an incoming SelectedItems. If you were hiding the built-in column and binding the selection anyway, the binding was accepted, ignored on the way in, and then overwritten by whatever the component published next. It is honoured now. Nothing changes for anyone who had the column on.

For anyone subclassing DataTableBase<TItem> directly: SelectAllStateFor takes IReadOnlyList<TItem> rather than IReadOnlyList<RowRenderInfo>, and ToggleExpandedAsync is a thin wrapper over the new SetExpandedAsync. Ordinary consumer markup touches neither.

Upgrading to 1.7.0

No compile-time breaks. Two behaviour changes are visible on screen, and one of them changes what SelectedItems holds after a select-all.

  1. The selection column now comes before the expand column. Card view already drew the checkbox first; the table drew the reverse, and the two disagreed. The checkbox is identical on every row so it reads as a column, while the chevron carries the per-level indent and stair-steps — lead with the chevron and it is the checkboxes that stair-step instead. Nothing to change unless you select cells positionally in your own CSS or tests.
  2. Select-all now covers the rows on screen, expanded children included. (Superseded — 1.8.0 takes selection back to the page's top-level rows. Kept because it describes what 1.7.0 did.) It read the page's top-level rows before, while every rendered row — children among them — draws a checkbox of its own. So the header could report "all selected" with a visibly unticked child beneath it, and a child ticked on its own left it reporting nothing selected at all; aria-checked carried both claims to a screen reader. If you act on SelectedItems after a select-all, it can now contain child rows, which is the fix rather than a side effect. Collapsed children are still out of scope — behind a ChildrenLoader they are not fetched, so they cannot be selected — which means select-all followed by expanding a row moves the header to a dash. See Row selection.

Also worth knowing:

Changed What it answers
.sdt-select-cell no longer sets text-align: center a checkbox floating into the middle of the gutter under AllowColumnResize, where a fixed-layout column takes a share of the table's spare width. Centred and start are the same thing in a column shrink-wrapped to one checkbox, so this only ever did anything in the case where it was wrong
--stingray-dt-expand-toggle-size the expand column's width arithmetic and the toggle's own box drifting apart, now that the column has to state a width

The fix this release exists for: with AllowColumnResize="true", the expand and selection columns were sized width: 1px — a shrink-to-fit instruction that table-layout: fixed makes authoritative — so both collapsed to their own padding and clipped the control inside. The toggle stayed focusable and in the tab order while painted at almost zero width, with its focus ring clipped away too. Both columns now declare a real width, and the expand column's tracks how deep the tree is currently opened. Nothing to do on your side.

One note for anyone subclassing DataTableBase<TItem> directly rather than using the two shipped tables: the protected SelectAllState property is now SelectAllStateFor(IReadOnlyList<RowRenderInfo>), and SelectablePageItems is gone — both had to take the rows on screen rather than the page's top-level items. Ordinary consumer markup touches neither. (In 1.8.0 the parameter is IReadOnlyList<TItem> again, and there is a public SelectAllState beside it.)

Upgrading to 1.5.0

One breaking change: HiddenColumns and HiddenColumnsChanged are now IReadOnlyList<string> rather than IReadOnlyCollection<string>. ColumnOrder was already a list, and nothing justified the two differing — it only produced a compile error that names neither the parameter nor the type it wanted:

CS1503: Argument 2: cannot convert from 'method group'
        to 'Microsoft.AspNetCore.Components.EventCallback'

Two things break. Both are compile errors, so neither can pass unnoticed:

  1. A handler written against the old type — change IReadOnlyCollection<string> to IReadOnlyList<string> in its signature. One line.
  2. A HashSet<string> passed to HiddenColumns. A set is the natural type to reach for given the parameter's name, and it implements IReadOnlyCollection<T> but not IReadOnlyList<T>. Pass a list, or spread it: HiddenColumns="[.. hiddenKeys]".

DataTableToolbarContext also gained a Reset member. That is additive for anyone reading the context, which is every normal use; it would only break code that constructs or deconstructs the record positionally, which nothing outside the component has cause to do.

Everything else in 1.5.0 is additive:

Added What it answers
RefreshAsync(resetPage: true) reloading from page 1 when an external filter changed what the rows mean, without remounting the component and losing the sort, the page size and the persisted column layout
IsRowExpandable a RowDetailTemplate drawing a chevron on every row, including the ones whose panel would be empty
AllowColumnReorder no way to fix a table's column order; also the mouse-only reorder a table gets with ShowColumnChooser="false"
ChildrenLoader a lazy tree, where a row's children come from their own request rather than embedded in the parent DTO. Previously only reachable by nesting a whole table inside a RowDetailTemplate, which renders as a sub-table with its own header and pager rather than as indented rows
Reset on the toolbar context ResetColumnsAsync() having no UI outside the chooser panel
A Try again button on the error state a failed load naming what happened and then offering no way forward
DataTableVersion.Current hard-coding a version into the stylesheet link

One internal note, for anyone subclassing DataTableBase<TItem> directly rather than using the two shipped tables: the protected ToggleExpanded(TItem) is now ToggleExpandedAsync(TItem) returning Task, because expanding a row can now start a fetch. Nothing in ordinary consumer markup touches it.

Defaults

Any parameter left unset falls back to a fixed default:

Parameter Default
PageSize 10
PageSizeOptions 10, 25, 50
SearchDebounceMs 500
MinSearchCharacterCount 0
ShowPaginationInformation true
MaxPageButtons 5

There is no configuration file. The package does not read IConfiguration and has no appsettings keys — a component library should not need a configuration system to construct, and a default that lives in appsettings is a default nobody finds. Every one of the above is a parameter; set it in markup, or wrap the component in your own if you want different house defaults:

@* YourTable.razor — your defaults, once, in one place *@
<StingrayDataTableOffline TItem="TItem" PageSize="25" MaxPageButtons="7" @attributes="Extra"> … </StingrayDataTableOffline>

Theming

The table styles itself and does not depend on Bootstrap, so it looks the same in every app that consumes it. Every visual decision is a CSS custom property you can override — there are no baked-in values to fork around.

Set the tokens on any ancestor. :root in your app.css is the usual place:

:root {
    --stingray-dt-accent: #7c3aed;
    --stingray-dt-radius: 1rem;
    --stingray-dt-row-hover-bg: #faf5ff;
    --stingray-dt-header-color: #6b7280;
}

Or scope it to one table by wrapping it, or via CssClass:

<StingrayDataTableOffline TItem="UserDto" Datasource="users" CssClass="compact-grid"> … </StingrayDataTableOffline>
.compact-grid { --stingray-dt-row-padding-y: .375rem; --stingray-dt-font-size: .8125rem; }

Why your override works: the component never declares a --stingray-dt-* property, it only reads one. Defaults resolve into a private --_sdt-* tier, leaving the public names free for you to set anywhere up the tree — and because the public and private names are different properties, your declaration resolves correctly regardless of stylesheet load order.

The Reorder group is the one exception to per-table theming: the drag ghost and the drop indicator are appended to <body> so they escape the table's overflow clip and z-index stack, which puts them outside any .stingray-data-table wrapper. Set those five on :root.

That private tier is resolved against three selectors — :root, .stingray-data-table and .sdt-chooser. :root is what lets content rendered outside a table resolve its tokens, which the column chooser needs since it renders inside your popup rather than inside the table. .stingray-data-table re-resolves per instance, so wrapping one table in an element that sets a token themes only that table.

Beyond the tokens, the stylesheet is ordinary global CSS, so you can override any rule with normal specificity — no hidden [b-…] attribute selector working against you. Two practical notes: match or exceed the component's specificity (many rules are written as .sdt-table > tbody > tr > td, which is 0,1,3), and load your overrides after the component's stylesheet.

Tokens

Group Token (prefix --stingray-dt-) Default
Surface bg #ffffff
border-color #eceef4
radius .75rem
padding .25rem .5rem
shadow 0 1px 2px rgba(16,24,40,.04)
Size height auto — the default for every table; the Height parameter beats it
max-height 100% — likewise for MaxHeight; the default is what makes a definite-height container constrain the table
Table table-min-width auto
sticky-scroll-padding (header padding * 2 + 1.25rem) — keeps a tabbed-to control clear of the sticky header
Expansion indent 1.5rem
expand-toggle-size 1.5rem — the chevron button's box. Under AllowColumnResize it also sizes the expand column, together with indent and row-padding-x
detail-bg #fbfcfe
expand-icon-color (muted-color)
expand-hover-bg #e9edf7
child-row-bg #f7f9fd
Sort sort-indicator-color (icon-muted-color)
sort-indicator-active-color (accent)
sort-indicator-width / -height .625rem / .875rem
Selection check-size / check-radius 1rem / .25rem
check-border-color / check-bg (icon-muted-color) / #ffffff
check-checked-bg / check-checked-color (accent) / #ffffff
row-selected-bg #d7e1fa
row-selected-hover-bg #c9d6f7
row-selected-accent-width 3px — the leading bar; 0 turns it off
Card view card-min-width 17rem
card-max-per-row (uncapped)
card-gap .75rem
card-padding-y / card-padding-x .875rem / 1rem
card-radius .625rem
card-bg / card-border-color (bg) / (border-color)
card-shadow (shadow)
card-hover-bg / card-selected-bg (row-hover-bg) / (row-selected-bg)
card-nest-indent .625rem
card-nested-bg #f7f9fd
card-nested-border-color #e6eaf4
card-rail-color #dde3f0
card-field-gap .5rem
card-label-width 40%
card-label-color / -font-size / -font-weight (the header's)
card-divider-color (row-border-color)
Toolbar controls control-height 2.5rem
control-bg / control-border-color (select-bg) / (select-border-color)
control-radius (select-radius)
control-color / control-hover-bg (page-color) / (page-hover-bg)
control-active-bg / control-active-color (accent-soft) / (accent)
Resize resize-handle-width .5rem — the grab target, wider than the line it draws
resize-line-width / resize-line-color 2px / (accent)
resize-line-inset .35rem
Cell popup popup-bg / popup-border-color (bg) / (border-color)
popup-radius / popup-shadow (page-radius) / 0 6px 16px rgba(16,24,40,.18)
popup-min-width 10rem
popup-max-height min(20rem, 60vh) — past it the panel scrolls inside itself
Frozen frozen-bg (bg)
frozen-hover-bg (row-hover-bg)
frozen-header-bg (bg)
frozen-edge-color #e3e7f0
frozen-edge-width 1px
frozen-selected-bg / frozen-selected-hover-bg (row-selected-bg) / (row-selected-hover-bg)
Type font-family inherit
font-size .875rem
text-color #1f2a4e
muted-color #6b7391 — secondary text, so it clears 4.5:1
icon-muted-color #8a93a8 — the same grey for glyphs, where the bar is 3:1
Accent accent #1b2f7e
accent-soft #e8edfb
Header header-bg (bg) — must stay opaque, the header is sticky
header-color #6b7391
header-font-size .8125rem
header-font-weight 500
header-padding-y / -x .875rem / .75rem
header-border-color #eceef4
header-hover-bg #f5f7fc
Rows row-padding-y / -x .8125rem / .75rem
row-border-color #f2f4f9
row-hover-bg #f7f8fd
row-accent-color (accent)
row-accent-width 3px
Search search-bg #ffffff
search-border-color #e3e7f0
search-radius 999px
search-height 2.5rem
search-color (text-color)
search-placeholder-color (muted-color)
search-icon-color (muted-color)
search-min-width 15rem
Pager page-size 2rem
page-radius .5rem
page-gap .25rem
page-color #5a6486
page-hover-bg #f2f4fb
page-active-bg (accent-soft)
page-active-color (accent)
page-disabled-color #c3c9d9
Select select-bg #ffffff
select-border-color #e3e7f0
select-radius .5rem
select-color (text-color)
select-caret-color (muted-color)
Column chooser chooser-row-gap / chooser-gap .5rem / .5rem
chooser-card-bg / chooser-card-border-color (bg) / #e3e7f0
chooser-card-radius .5rem
chooser-card-padding-y / -x .75rem / .875rem
chooser-card-hover-bg (page-hover-bg)
chooser-actions-width 2.25rem
chooser-move-color / chooser-move-size (text-color) / .875rem
chooser-move-disabled-color (page-disabled-color)
Chooser switch switch-width / switch-height 2.5rem / 1.375rem
switch-inset .1875rem — the thumb is the height less twice this
switch-off-bg / switch-on-bg (icon-muted-color) / (accent)
switch-thumb-color #ffffff
switch-thumb-shadow 0 1px 2px rgba(16,24,40,.2)
Reorder drop-indicator-color / drop-indicator-width (accent) / 2px
drag-ghost-bg / drag-ghost-color (header-bg) / (text-color)
drag-ghost-border-color (border-color)
drag-ghost-shadow 0 6px 16px rgba(16,24,40,.18)
drag-ghost-opacity .9
State overlay-bg rgba(255,255,255,.6)
busy-opacity .55
spinner-color / spinner-size (accent) / 1rem
focus-ring-color (accent)
focus-ring 0 0 0 2px (bg), 0 0 0 4px (focus-ring-color)
focus-ring-inset inset 0 0 0 2px (focus-ring-color) — used where an outward ring would be clipped
transition .15s ease-in-out
danger-color #b42318

Tokens marked (…) inherit from another token by default, so setting --stingray-dt-accent alone recolours the sort indicator, the active page, the row accent bar and the spinner together.

Touch targets

The pager buttons are --stingray-dt-page-size (default 2rem) and the toolbar's icon buttons are --stingray-dt-control-height (default 2.5rem). Both clear WCAG 2.5.8 (AA), which asks for 24×24, and neither reaches 2.5.5 (AAA), which asks for 44×44. If you are held to AAA, or your users are primarily on touch, raise them:

.stingray-data-table {
    --stingray-dt-page-size: 2.75rem;
    --stingray-dt-control-height: 2.75rem;
}

Only the card paints a surface

The toolbar and the footer sit outside .sdt-card and show whatever the host has behind them, which is what lets a table drop into any page without painting a box around itself. One consequence is worth knowing: theme a single table by wrapping it in .sdt-theme-dark inside an otherwise light page and the footer text goes light against your light background. Set --stingray-dt-root-bg on the same wrapper and the whole component sits on a matching surface.

Dark mode

Ships as a complete palette, and it is opt-in — put one of two classes on any ancestor:

<body class="sdt-theme-auto">   
<body class="sdt-theme-dark">   

Following prefers-color-scheme on its own was considered and rejected: a package that did that would turn the table dark inside a light host application the moment the user's OS was set that way, which is one dark rectangle on a white page rather than a theme. The host decides.

Put the class high enough to cover your popup host as well — the column chooser renders inside it, outside the table, and a class on the table alone leaves the panel light.

Every --stingray-dt-* you set still wins over the dark palette, so this is a starting point rather than a lock-in. Setting any token also keeps working unchanged if you never opt in.

Accessibility

What the component handles, so you know what is left to you:

  • Contrast. Every default clears WCAG 1.4.3 for text (4.5:1) and 1.4.11 for meaningful non-text (3:1), in both the light and dark palettes. Overriding a colour token is where that becomes yours again — muted-color and icon-muted-color are split precisely so the two bars stay separable.
  • Focus. One focus-ring token behind every control, at 3:1 or better against the surface and the control. Keep both layers if you replace it; a single low-alpha ring is what it replaced.
  • Keyboard. Sorting is a real <button>. Resizing is on the handle: <kbd>←</kbd>/<kbd>→</kbd> step it, <kbd>Shift</kbd> steps four times as far, <kbd>Home</kbd> restores the declared width. Reordering is on the chooser's arrows, and the drag is an alternative to them — provided the chooser is shown. With ShowColumnChooser="false" and reorder left on, dragging is the only way to reorder and there is no keyboard path at all; AllowColumnReorder="false" is the other fix.
  • Announcements. One aria-live region reports loading, load failures, and the result range — which is what tells a screen-reader user that a search, a sort or a page change did anything.
  • Forced colours. Windows High Contrast is handled: system colours replace the painted checkbox, the switch and the focus ring, all of which that mode would otherwise erase.

Two things it cannot do for you: <html lang> and dir belong to your document, and a CellTemplate renders whatever you put in it.

Overflow and layout

A table wider than its container scrolls horizontally inside its own card; the header scrolls with it and the loading spinner stays pinned to the card. Set --stingray-dt-table-min-width to stop columns squashing before the scroll kicks in:

:root { --stingray-dt-table-min-width: 48rem; }

The component root is a single-column grid using minmax(0, 1fr). That is not decoration — it stops the table's intrinsic width propagating up the ancestor chain. Without it, any host layout built on flex or grid is forced wider than the viewport and the whole page scrolls sideways instead of the table, because a flex or grid item's min-width defaults to auto and therefore refuses to shrink below its content.

Nothing the component draws around the table holds the width open either: the toolbar and the pager both wrap, so on a phone the pager takes two rows rather than pushing the page sideways.

If you still see the page scrolling sideways, an ancestor between your layout root and the table is holding the width open. The fix is on that element, not on the table:

main { min-width: 0; }        /* flex item */
.some-grid-cell { min-width: 0; }

Vertical scrolling

Rows scroll vertically with the header stuck in place, and the toolbar and pager stay outside the scroll. Two ways to trigger it:


<StingrayDataTable TItem="UserDto" DataConsumer="LoadUsers" Height="24rem"> … </StingrayDataTable>

<div style="height: 30rem">
    <StingrayDataTable TItem="UserDto" DataConsumer="LoadUsers"> … </StingrayDataTable>
</div>

Both work the same way: the component lays itself out as three rows — toolbar, rows, pager — with the middle one taking the remaining space and allowed to shrink below its content. Without that, a fixed-height container would scroll everything, carrying the toolbar and pager out of view.

A container with a max-height and no height does not work — a percentage max-height resolves to none against an auto-height parent, whatever its display type, so the table grows past the container instead of scrolling inside it. Use MaxHeight rather than the container there.

Height is a height, not a maximum, and that is the point. The component occupies exactly what you asked for whether it drew two rows or two hundred, so a page laid out around it does not reflow as the data changes.

MaxHeight is the other trade, for when the empty space would show. The table stays its natural size until the content would exceed the cap, then scrolls — so a short result set draws a short table instead of one padded out with blank space. What you give up is the stable footprint: the rendered height moves with the row count.

<StingrayDataTable TItem="UserDto" DataConsumer="LoadUsers" MaxHeight="70vh"> … </StingrayDataTable>

They are independent and compose. Set both and CSS clamps the height by the maximum, so Height="40rem" MaxHeight="70vh" is a fixed 40rem on a tall viewport and a cap on a short one.

One height for every table

Both parameters read a public token, so the default for a whole app, a section, or a class of tables is CSS — and unlike a C# default it can respond to a media query:

:root                       { --stingray-dt-height: 24rem; }   /* every table            */
.dashboard                  { --stingray-dt-height: 20rem; }   /* every table in here    */
@media (max-width: 40rem)   { :root { --stingray-dt-height: 60vh; } }

A table's own Height or MaxHeight beats all of them, and a table opts back out of a global default with the CSS keyword: Height="auto", or MaxHeight="none".

The row area fills that height rather than stopping at the last row, so the frozen columns' dividers run the full depth of the table. That is what the empty <tr class="sdt-filler"> at the end of every <tbody> is for: it takes the leftover height so the real rows keep theirs, and collapses to zero when there is none. It is aria-hidden and carries no data — select rows as tbody tr:not(.sdt-filler) if you are writing your own CSS or tests against the markup.

The row area is overflow: auto — that is what makes the header stick and the table scroll sideways — so it clips an ordinary dropdown rendered in a cell. A menu on the last visible row is cut off.

StingrayCellPopup solves it. Its panel is position: fixed, which takes the viewport as its containing block and is therefore not clipped by any ancestor's overflow:

<StingrayDataColumn Name="actions" Freeze="ColumnFreezeEnumDto.End">
    <Template>
        <StingrayCellPopup>
            <Trigger><button class="btn">⋯</button></Trigger>
            <Content>
                <ul>
                    <li><button @onclick="() => Edit(context)">Edit</button></li>
                    <li><button @onclick="() => Delete(context)">Delete</button></li>
                </ul>
            </Content>
        </StingrayCellPopup>
    </Template>
</StingrayDataColumn>

position: fixed escapes an ancestor's clipping, and not its stacking — a frozen cell is position: sticky with a z-index, which makes it a stacking context, so the panel's own z-index would otherwise resolve inside that cell and a later row's pinned cell would paint straight over the menu. The stylesheet handles it by raising the cell that holds an open panel above its neighbours and the sticky header. If you set your own z-index on cells, leave room above the header's.

It knows nothing about any menu library — Content is whatever markup you like, Bootstrap's included. It hangs from the trigger's trailing edge, flips above when there is no room below, and closes on an outside click, Escape, or a scroll underneath it — a fixed panel does not travel with the cell it hangs from, so a scroll below would strand it over unrelated content. A click inside it closes it too, after your handler has run; set CloseOnContentClick="false" for a panel holding a form or a filter.

Two things it deliberately does not close on. A scroll of its own contents: the panel is capped at --stingray-dt-popup-max-height and scrolls inside itself, so a long menu can be read without dismissing itself. And a resize, which repositions it instead — the anchor has not moved, only the coordinates measured from it, and closing there dismissed the panel every time a mobile keyboard opened.

The panel is styled from --stingray-dt-popup-* and supplies its own surface, so nesting a .dropdown-menu inside gives you two bordered boxes — use a bare list.

One consequence worth knowing: --stingray-dt-header-bg defaults to the card background rather than transparent. A sticky header has to paint over the rows passing beneath it, so setting this to a transparent colour will let them show through.

Cell content is yours

The package styles table chrome only. The pills, avatar stacks, progress bars and action menus you see in dashboard designs are cell markup you supply through Template, and the package ships no classes for them — so it never fights your design system. The demo page in this repo shows one way to build them.

Localization

Cascade an IStringLocalizer and the chrome is translated; without one, the English key text is used, as is any key your resource file is missing.

<CascadingValue Value="Loc">
    <StingrayDataTableOffline TItem="UserDto" Datasource="users"> ... </StingrayDataTableOffline>
</CascadingValue>

Keys: Search…, Loading, No available records, No matching records, Could not load data, Try again, of, Page size, Pagination, Page, First, Previous, Next, Last, plus Select All and Select row with selection on, and Expand row with expansion on. Card view adds View mode, Table view and Card view for the toggle, and Sort by, Sort ascending and Sort descending for its sort control.

The info line reads 1 - 10 of 137, composing of around the numbers. If a language needs a different word order there, that is the point to switch to a single format-string key.

First / Previous / Next / Last label icon-only buttons, so they are the pager's accessible names rather than visible text.

RTL works through html[dir=rtl] and logical properties. Sort indicators are vertical carets rather than arrows, so they carry no reading-order meaning.

Things worth knowing

  • Offline search on a date column matches DateTime.ToString(), not the text you rendered. Either set Searchable="false" on it or give it a Value accessor that returns the formatted string.
  • Offline sorting is culture-aware (CurrentCultureIgnoreCase), so Arabic and accented text sort correctly. Search is ordinal, matching the Stingray dropdowns.
  • Server mode has no optimistic UI. After a create, RefreshAsync() refetches; a new row that sorts onto page 4 will not appear. Consider resetting to page 1 and the default sort after a create.
  • LIKE wildcards (%, _, [) in a search term are the consumer's business. EF parameterizes, so it is not an injection risk, just surprising.
  • Stale responses are handled. Requests carry a CancellationToken, and results arriving out of order are discarded — a slow first request cannot overwrite a faster later one. You do not need to guard for this in your DataConsumer.
  • A failed load never shows the exception. The table renders a localized "Could not load data"; the exception goes to OnLoadError and nowhere else. An e.Message out of EF or an HTTP wrapper routinely names tables, columns, connection details and internal hosts, and this is rendered straight into the page. Handle OnLoadError to log it, and use ErrorTemplate to say something better — or to say nothing, if you would rather route failures to your own toast.
  • Give every column a Name. With ColumnOrder or HiddenColumns in play, two columns sharing a key make the arrangement ambiguous, and the component then refuses every reorder and every visibility change rather than guessing which column was meant; the chooser says which key collided. Nothing throws — a key falls back to the localized Title, so a collision can arrive from a translation alone, and a component that threw would take the page down in one language and not another. Reset still works, and is the way out of an arrangement saved before the keys collided.
  • "No results" is a different state from "empty". A search that matched nothing draws NoResultsTemplate, not EmptyTemplate — because an empty state is usually a call to action, and offering "add your first user" to someone whose search just missed is worse than saying nothing. Which one applies is decided by the term the table is actually searching on, so anything below MinSearchCharacterCount counts as no search, and a consumer filtering their own data outside the component correctly keeps EmptyTemplate.
  • ChildrenSelector is followed to a floor. A row already on the path to itself is not descended into, and nothing goes deeper than 64 levels — so a cycle in your data truncates instead of overflowing the stack. The expand toggle is withheld from a row with nothing left to reveal, so the limit never shows as a control that does nothing.
Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated