Skip to content
IzzyBlazor 0.1.0-alpha.18

Enter all results ↑ ↓ move Esc close

Table

Sort, filter, page and select, with state in the URL.

A static page with IzzyBlazor's script. No circuit.

Usage

razor
<Table Items="orders" PageSize="10" Caption="Orders">
    <PropertyColumn Property="o => o.Customer" Sortable Filterable />
    <PropertyColumn Property="o => o.Total" Format="C" Sortable />
    <TemplateColumn Title="Status">
        <Badge>@context.Status</Badge>
    </TemplateColumn>
</Table>

Sorting and paging

Use Sortable on a column and PageSize on the table. Headers and page numbers are links that set ?sort= and ?page=, so the table works with no script, survives a reload and can be shared.

Preview

Orders
OrderCustomer Date Status Total
1007Ada LovelaceOct 20Pending$316.75
1014Ada LovelaceOct 19Refunded$149.00
1021Ada LovelaceOct 18Failed$441.25
1006Alan TuringOct 13Refunded$275.00
1013Alan TuringOct 12Failed$107.25

Code

SortPage.razor
@* Sorting and paging through the URL: headers and page numbers are links, so the table works with
   JavaScript off, survives a reload and can be shared. *@
<Table Items="Orders.All" PageSize="5" PageSizeOptions="[5, 10]" InitialSort="-date" Caption="Orders" Grow>
    <PropertyColumn Property="o => o.Number" Title="Order" />
    <PropertyColumn Property="o => o.Customer" Sortable />
    <PropertyColumn Property="o => o.Date" Format="MMM d" Sortable />
    <TemplateColumn Title="Status" SortBy="o => o.Status" SortKey="status" Sortable>
        <Badge Status="Orders.StatusOf(context.Status)" Dot>@context.Status</Badge>
    </TemplateColumn>
    <PropertyColumn Property="o => o.Total" Format="C" Sortable />
</Table>

Static: The same links; Blazor's enhanced navigation swaps the page in without a full reload.

InitialSort sets the order before anyone picks one ("-date" for newest first), and PageSizeOptions adds a "Rows per page" menu.

Column filters

Use Filterable on a column for a filter in its header. Text matches anywhere, numbers exactly, a date its day, and enums and booleans get a menu.

Preview

Status: PendingClear
Filtered orders
Order
Total
1023Katherine JohnsonPending$64.75

Code

Filters.razor
@* Column filters: a funnel in each Filterable header opens the filter; active filters show as tags
   above the table. Text matches anywhere, enums get a menu, numbers match exactly. *@
<Table Items="Orders.All" PageSize="5" InitialSort="-date" Caption="Filtered orders" QueryPrefix="orders-" Grow>
    <PropertyColumn Property="o => o.Number" Title="Order" />
    <PropertyColumn Property="o => o.Customer" Sortable Filterable />
    <PropertyColumn Property="o => o.Status" Sortable Filterable />
    <PropertyColumn Property="o => o.Total" Format="C" Sortable Filterable />
</Table>

Static: Filters apply as you type.

Filters go in the URL as ?f-customer=ada (here ?orders-f-customer=ada: this table has a QueryPrefix, so it sorts and pages apart from the one above, as under Several tables on a page). FilterMultiple lets an enum column match several values, and FilterPlacement.Row puts the filters in a row under the headers. For filters of your own, use a FilterForm.

Selection

Use Selectable with an ItemKey for a checkbox per row. In a form, the checked rows post their keys, so this works with JavaScript off.

Preview

Recent orders
Select OrderCustomerTotal
1001Margaret Hamilton$66.25
1002Katherine Johnson$108.00
1003Grace Hopper$149.75
1004Barbara Liskov$191.50
1005Linus Torvalds$233.25
Total$748.75

Code

Selection.razor
@* Selection in a form: each checked row posts its ItemKey, so Archive works with JavaScript off. A
   footer row shows the total. *@
<form method="post" @onsubmit="Archive" @formname="archive" style="width: 100%">
    <AntiforgeryToken />
    <Stack Gap="3">
        <Table Items="Recent" Caption="Recent orders" Selectable ItemKey="o => o.Number.ToString()"
               RowLabel='o => $"order {o.Number}"' @bind-SelectedKeys="_selected">
            <PropertyColumn Property="o => o.Number" Title="Order" FooterText="Total" />
            <PropertyColumn Property="o => o.Customer" />
            <PropertyColumn Property="o => o.Total" Format="C" FooterText="@Recent.Sum(o => o.Total).ToString("C")" />
        </Table>
        <Row Gap="3" Align="Align.Center" Wrap>
            <Button Submit Variant="ButtonVariant.Outline" Accent="Accent.Neutral" StartIcon="Lucide.Archive">Archive selected</Button>
            @if (_archived is not null)
            {
                <TextBlock Size="TextSize.Sm" Color="Color.Muted">
                    Archived: @(_archived.Count == 0 ? "nothing selected" : string.Join(", ", _archived.Select(k => "#" + k)))
                </TextBlock>
            }
        </Row>
    </Stack>
</form>

@code {
    private static readonly IReadOnlyList<Order> Recent = Orders.All.Take(5).ToList();

    // Static pages: the checked rows post as "selected".
    [SupplyParameterFromForm(FormName = "archive", Name = "selected")]
    private string[]? Posted { get; set; }

    // Interactive pages: bound to the checkboxes.
    private IReadOnlyCollection<string>? _selected;
    private IReadOnlyCollection<string>? _archived;

    protected override void OnInitialized() => _selected = Posted;

    private void Archive() => _archived = (IReadOnlyCollection<string>?)Posted ?? _selected ?? [];
}

Static: The header checkbox selects every row on the page.

Read the keys with [SupplyParameterFromForm(Name = "selected")] on a static page, or @bind-SelectedKeys on an interactive one. Give columns FooterText for a totals row.

State in the component

Use @bind-Sort, @bind-Page and @bind-Filters on an interactive page to keep them in C# instead of the URL, like a client-side grid.

Always Live

Preview

Order history
OrderCustomer Total
1021Ada Lovelace$441.25
1022Margaret Hamilton$483.00
1023Katherine Johnson$64.75

C# has sort -date, page 1, filters none

Code

Bound.razor
@* State in the component, on an interactive page: headers and page numbers become buttons, filters
   apply after a pause in typing, and the URL doesn't change. *@
<Stack Gap="3" Grow>
    <Table Items="Orders.All" PageSize="5" InitialSort="-date" Caption="Order history" QueryPrefix="bound-" FilterPlacement="FilterPlacement.Row"
           @bind-Sort="_sort" @bind-Page="_page" @bind-Filters="_filters">
        <PropertyColumn Property="o => o.Number" Title="Order" />
        <PropertyColumn Property="o => o.Customer" Sortable Filterable />
        <PropertyColumn Property="o => o.Total" Format="C" Sortable />
    </Table>
    <TextBlock Size="TextSize.Sm" Color="Color.Muted">
        C# has sort <Code>@(_sort ?? "-date")</Code>, page <Code>@_page</Code>, filters
        <Code>@(_filters is { Count: > 0 } f ? string.Join(", ", f.Select(kv => $"{kv.Key}={kv.Value}")) : "none")</Code>
    </TextBlock>
</Stack>

@code {
    private string? _sort;
    private int _page = 1;
    private IReadOnlyDictionary<string, string>? _filters;
}

The table starts from the URL if it has values, and a new sort or filter goes back to page 1. Set the page back to 1 yourself when your own filters change.

Data from a database

Pass an EF Core IQueryable as Items, and the database sorts, filters and pages. For more control, use ItemsProvider to load each page yourself:

razor
<Table TItem="Order" ItemsProvider="LoadOrders" PageSize="10">...</Table>

@code {
    async ValueTask<TableResult<Order>> LoadOrders(TableRequest<Order> request)
    {
        var query = db.Orders.Where(o => o.Active);
        var page = await request.Apply(query).ToListAsync();   // filters, sorts, pages
        return new(page, await request.ApplyFiltering(query).CountAsync());
    }
}

Use RowHref to make each row a link, and RowLabel to name it. Links, buttons and checkboxes inside the row still work on their own.

razor
<Table Items="orders" RowHref='o => $"orders/{o.Id}"' RowLabel='o => $"order {o.Number}"'>...</Table>

Several tables on a page

Use QueryPrefix to give each table its own keys in the URL, so they sort and page on their own.

razor
<Table Items="orders" QueryPrefix="o-" PageSize="10">...</Table>     @* ?o-sort=, ?o-page= *@
<Table Items="refunds" QueryPrefix="r-" PageSize="10">...</Table>

Lines, size and scrolling

Use Size, Striped, ColumnLines, RowLines and Bordered for the look; turn Bordered off for a table in a card. MaxHeight scrolls the rows under a sticky header, and EmptyText shows when there are none.

razor
<Table Items="orders" Size="ControlSize.Sm" Striped ColumnLines>...</Table>
<Table Items="orders" MaxHeight="Length.Rem(20)" EmptyText="No orders yet">...</Table>

Mode support

What works in each mode, Table
FeatureOffStaticLive
Sort, filter and page through the URLYesYesYes
Selected rows post with a formYesYesYes
Filters apply as you typeNoYesYes
Select-all checkboxNoYesYes
Sort, page, filters and selection bound to C#NoNoYes

Off: JavaScript disabled. Static: a static SSR page with IzzyBlazor's script. Live: an interactive page (Server, WebAssembly or Auto).

Accessibility

  • Caption names the table for screen readers; it isn't shown. It also names the scrolling area, which can be scrolled from the keyboard.
  • Sortable headers are links, and the sorted column has aria-sort.
  • Each filter button is named for its column ("Filter Customer"), and the active filters are a group named "Filters".
  • Row checkboxes are named "Select" and the row's RowLabel ("Select order 1001"); without one, the ItemKey.
  • The range under the table ("1–5 of 23") is read when it changes.

Parameters

Table parameters
PropTypeDefault
ItemsThe items.IEnumerable<TItem>?
ItemsProviderLoads one page of items yourself, in place of Items.Func<TableRequest<TItem>, ValueTask<TableResult<TItem>>>?
ChildContentThe columns: PropertyColumn<T1, T2> and TemplateColumn<T>.RenderFragment?
@bind-PageSizeItems per page.int?
PageSizeOptionsSizes to choose from in a "Rows per page" menu in the footer, such as new[] { 10, 25, 50 }.IReadOnlyList<int>?
InitialSortThe sort when the URL has none, such as "name" or "-date" (descending).string?
@bind-SortThe sort, such as "name" or "-date" (descending); null for InitialSort.string?
@bind-PageThe page, from 1.int?
FilterPlacementWhere the column filters go: a filter button in each header that opens the filter (FilterPlacement.Header, the default, with the active filters as removable tags above the table), or a row of fields under the headers (FilterPlacement.Row).FilterPlacementFilterPlacement.Header
@bind-FiltersThe filter values of Filterable columns, by column key ("customer" → "ada").IReadOnlyDictionary<string, string>?
QueryPrefixPrefix for this table's query-string keys, so several tables can share a page: QueryPrefix="orders-" uses ?orders-sort= and ?orders-page=.string""
CaptionThe table's name for screen readers (a visually hidden caption).string?
EmptyContentShown when there are no items.RenderFragment?computed
EmptyTextThe text when there are no items.string?"No items"
RowClassA class for each row, such as one that highlights overdue orders.Func<TItem, string?>?
StripedAlternating row backgrounds.boolfalse
RowLinesA line between rows.booltrue
ColumnLinesA line between columns.boolfalse
BorderedA rounded border around the table.booltrue
SizeRow height and text size.ControlSizeControlSize.Md
MaxHeightA height limit: the rows scroll and the header stays in view.Length?
ItemKeyA unique, stable key for each item, such as its id.Func<TItem, string>?computed
SelectableAdds a checkbox to each row.boolfalse
SelectionNameThe form field name the checked rows post under.string?"{QueryPrefix}selected"
@bind-SelectedKeysThe keys of the selected rows (on any page).IReadOnlyCollection<string>?
RowHrefMakes each row a link, such as to the item's details page.Func<TItem, string?>?
RowLabelNames a row for screen readers, in its link and its checkbox.Func<TItem, string>?computed
LoadingShows the table as loading: rows dim, or placeholder rows show while there are none.boolfalse
SelectAllLabelThe header checkbox's name for screen readers.string?"Select all rows"
SelectRowLabelThe start of each row checkbox's name for screen readers, before RowLabel.string?"Select"

See also