Rask
Rask
v0.23.1-alpha.0.496
You're reading the Rask v0.23.1-alpha.0.496 guides. Updated .View source
All guides

Composition — lists, toasts, drag & error boundaries

Windowed and reorderable lists, transient toast messages, drag-and-drop, and error boundaries.

‹ Back to Composition

Virtualize — windowed lists

Virtualize.Items<T> is headless: you render the scroll container and rows from a VirtualizationState ctx, and it tells you which slice is visible. The first argument is the body builder; pass exactly one of Items (positional or named) or ItemsProvider, plus ItemSize (row height in px, required) and optional OverscanCount.

It is the one place on the surface that is a method call rather than a chain, and for a reason a chain cannot work around: a chain infers its type argument from the step that opens it, and T here comes from the render delegate, not from a leading step. Virtualize is a global alias for the class that holds it, so no using is needed.

Virtualize.Items<Row>(
    ctx => Div.Style("height:400px; overflow:auto;").OnScroll(ctx.OnScroll)[
        Div.Style($"height:{ctx.OffsetBefore}px"),          // top spacer
        Table[Tbody[
            ctx.VisibleItems.Select(item => Tr.Style($"height:{ctx.ItemSize}px;").Data(new() { ["rask-key"] = item.Index.ToString() })[  // key → reuse <tr> on scroll
                Td[item.IsPlaceholder ? "—" : item.Value!.Name])
        ]],
        Div.Style($"height:{ctx.OffsetAfter}px")             // bottom spacer
    ],
    _rows,                 // Items (in memory)
    ItemSize: 32,
    OverscanCount: 4)

For lazy / server-paged data pass ItemsProvider: instead of the items list. The provider must propagate the CancellationToken it receives or it will leak in-flight requests:

ItemsProvider: async req =>
{
    var page = await _api.GetRows(req.StartIndex, req.Count, req.CancellationToken);
    return new ItemsProviderResult<Row>(page.Items, page.TotalCount);
}

Provider mode caches by index, marks rows IsPlaceholder while a page is in flight, and cancels + disposes superseded requests (and on unmount).

Pass InitialTotalCount: as well when the first paint matters. Until the provider has answered there is no total, so there is no window and your body renders no rows at all — an empty box that pops into a full list when the fetch resolves. That is a layout shift on every load, and on a prerendered page it makes the markup's shape depend on when it was sampled. With an estimate the window is drawn at full size immediately, every row flagged IsPlaceholder, so a body that already renders a pending row needs no new branch:

ItemsProvider: FetchRowsAsync,
ItemSize: 32,
InitialClientHeight: 360,
InitialTotalCount: 500        // a guess; the provider's real total replaces it on the next paint

An estimate is all it needs — the real count wins as soon as the provider reports one, and only the spacer heights change. It is the provider-mode sibling of InitialClientHeight, and for the same reason: the first render happens before the thing that knows the answer has answered. Ignored in items mode, where the count is never in doubt.

Items mode — a fixed in-memory list, windowed:

VirtualizeItemsDemo.cs
using System.Globalization;
using Rask.Core.Virtualization;

namespace Rask.Site.Features;

// In-memory virtualization: 10,000 rows in VirtualizeData.Rows, but only the visible window
// (plus a small overscan) ever reaches the DOM. The off-screen rows above and below are reserved
// by two spacer *rows* inside the tbody, so the single table is the scroller's only child.
public sealed partial class VirtualizeItemsDemo : Component
{
    // The header stays put while scrolling because (a) the table is the scroller's only child and
    // its total height is constant — the spacer rows just redistribute height inside tbody as the
    // window moves — so the sticky header's containing block never resizes, and (b) sticky lives on
    // the <th> cells (not <thead>), each painting an opaque background with an inset box-shadow
    // divider. Earlier the spacers were divs *outside* the table: the table's own box was relaid out
    // every scroll frame and the header unstuck (vanished mid-scroll, snapped back on stop).
    private const string StickyHead =
        "position:sticky; top:0; z-index:1; background:#f8f9fa; box-shadow:inset 0 -1px 0 #dee2e6; ";

    protected override Component? Render() =>
        Virtualize.Items<VirtualizeRow>(
            ctx => Div
                .Class("border rounded bg-white")
                .Style("height:360px; overflow:auto;")
                .Data("testid", "virtualize-scroller")
                .OnScroll(ctx.OnScroll)[
                Table
                    .Class("w-full text-left text-sm [&_td]:px-3 [&_td]:py-2 [&_th]:px-3 [&_th]:py-2")
                    .Style("table-layout:fixed; width:100%; border-collapse:separate; border-spacing:0;")[
                    Thead[
                        Tr[
                            Th.Style(StickyHead + "width:64px;")["#"],
                            Th.Style(StickyHead)["Name"],
                            Th.Style(StickyHead + "width:120px;")["City"],
                            Th.Style(StickyHead + "width:110px; text-align:right;")["Balance"]
                        ]
                    ],
                    Tbody[BodyRows(ctx)]
                ]
            ],
            VirtualizeData.Rows,
            ItemSize: 32,
            OverscanCount: 4,
            InitialClientHeight: 360);

    // tbody = top spacer + the visible window + bottom spacer. Every child carries a stable
    // data-rask-key so the whole tbody stays on the keyed reconciliation path (it's all-or-nothing):
    // the spacers keep their identity and only their height attribute is patched as you scroll, while
    // the real rows move by index — all trusted diff ops, so the header is never re-rendered.
    private static IEnumerable<Component> BodyRows(VirtualizationContext<VirtualizeRow> ctx)
    {
        yield return Spacer(ctx.OffsetBefore, "spacer-before");

        foreach (var item in ctx.VisibleItems)
        {
            yield return Tr
                .Style($"height:{ctx.ItemSize}px;")
                .Data(
                    ("row-index", item.Index.ToString(CultureInfo.InvariantCulture)),
                    ("rask-key", item.Index.ToString(CultureInfo.InvariantCulture)))[
                Td[item.Value?.Index.ToString(CultureInfo.InvariantCulture) ?? ""],
                Td[item.Value?.Name ?? ""],
                Td[item.Value?.City ?? ""],
                Td.Style("text-align:right;")[item.Value?.Balance.ToString("0.00", CultureInfo.InvariantCulture) ?? ""]
            ];
        }

        yield return Spacer(ctx.OffsetAfter, "spacer-after");
    }

    private static Component Spacer(int height, string key) =>
        Tr
            .Style($"height:{height}px;")
            .Data("rask-key", key)[
            Td.ColSpan(4)
        ];
}
Live result
#NameCityBalance
1Ada Lovelace #00001Amsterdam140.90
2Grace Lovelace #00002New York522.76
3Linus Lovelace #00003New York262.59
4Margaret Lovelace #00004Oxford512.92
5Donald Lovelace #00005New York761.25
6Barbara Lovelace #00006Helsinki257.32
7Edsger Lovelace #00007Cambridge320.23
8Tony Lovelace #00008Boston260.24
9Alan Lovelace #00009Cambridge35.31
10John Lovelace #00010Manchester577.19
11Ada Hopper #00011Boston152.19
12Grace Hopper #00012London705.15
13Linus Hopper #00013Manchester539.97
14Margaret Hopper #00014London711.15
15Donald Hopper #00015New York906.21
16Barbara Hopper #00016Amsterdam516.41

Provider mode — rows fetched on demand as they scroll into view:

VirtualizeProviderDemo.cs
using System.Globalization;
using Rask.Core.Virtualization;

namespace Rask.Site.Features;

// The same component, now backed by an ItemsProvider that simulates a 350 ms API call per
// window. Visible rows show a "—" placeholder until the fetch resolves, then morph in.
// Navigating away mid-fetch cancels the in-flight call: VirtualizeModel cancels its
// CancellationTokenSource in Unmount (and supersedes it whenever a new viewport arrives),
// so honour req.CancellationToken in your own providers to let the cancellation propagate.
public sealed partial class VirtualizeProviderDemo : Component
{
    // Sticky header on the <th> cells, kept rock-steady by the constant-height single table —
    // see VirtualizeItemsDemo for the full why (spacer rows inside tbody, not divs outside it).
    private const string StickyHead =
        "position:sticky; top:0; z-index:1; background:#f8f9fa; box-shadow:inset 0 -1px 0 #dee2e6; ";

    protected override Component? Render() =>
        Virtualize.Items(
            ctx => Div
                .Class("border rounded bg-white")
                .Style("height:360px; overflow:auto;")
                .Data("testid", "virtualize-async-scroller")
                .OnScroll(ctx.OnScroll)[
                Table
                    .Class("w-full text-left text-sm [&_td]:px-3 [&_td]:py-2 [&_th]:px-3 [&_th]:py-2")
                    .Style("table-layout:fixed; width:100%; border-collapse:separate; border-spacing:0;")[
                    Thead[
                        Tr[
                            Th.Style(StickyHead + "width:64px;")["#"],
                            Th.Style(StickyHead)["Name"],
                            Th.Style(StickyHead + "width:120px;")["City"],
                            Th.Style(StickyHead + "width:110px; text-align:right;")["Balance"]
                        ]
                    ],
                    Tbody[BodyRows(ctx)]
                ]
            ],
            ItemsProvider: FetchRowsAsync,
            ItemSize: 32,
            OverscanCount: 4,
            InitialClientHeight: 360,
            // Without this the provider has reported no total on the first render, so there is no
            // window and the tbody holds nothing but its two spacer rows -- an empty box that pops
            // into a full table 350ms later, when the fetch resolves. Every <tr> and <td> in the
            // window would be a tag that ARRIVES ON A TIMER, which is what makes a markup snapshot of
            // this demo a race against the wall clock.
            //
            // With it the window renders at full size immediately, every row a placeholder showing
            // "—", and the fetch fills in the TEXT. An estimate is all this needs; the real count
            // replaces it as soon as the provider answers, and only the spacer heights change.
            InitialTotalCount: VirtualizeData.Rows.Count);

    // tbody = top spacer + visible window + bottom spacer, every child keyed so the whole tbody
    // stays on the trusted keyed-diff path (spacers patch only their height; rows move by index).
    private static IEnumerable<Component> BodyRows(VirtualizationContext<VirtualizeRow> ctx)
    {
        yield return Spacer(ctx.OffsetBefore, "spacer-before");

        foreach (var item in ctx.VisibleItems)
        {
            yield return Tr
                .Style($"height:{ctx.ItemSize}px;")
                .Data(
                    ("row-index", item.Index.ToString(CultureInfo.InvariantCulture)),
                    ("rask-key", item.Index.ToString(CultureInfo.InvariantCulture)),
                    ("placeholder", item.IsPlaceholder ? "true" : null))[
                Td[item.IsPlaceholder ? "—" : item.Value!.Index.ToString(CultureInfo.InvariantCulture)],
                Td[item.IsPlaceholder ? "—" : item.Value!.Name],
                Td[item.IsPlaceholder ? "—" : item.Value!.City],
                Td.Style("text-align:right;")[item.IsPlaceholder ? "—" : item.Value!.Balance.ToString("0.00", CultureInfo.InvariantCulture)]
            ];
        }

        yield return Spacer(ctx.OffsetAfter, "spacer-after");
    }

    private static Component Spacer(int height, string key) =>
        Tr
            .Style($"height:{height}px;")
            .Data("rask-key", key)[
            Td.ColSpan(4)
        ];

    private static async ValueTask<ItemsProviderResult<VirtualizeRow>> FetchRowsAsync(ItemsProviderRequest req)
    {
        // The token is honoured so navigating away from /virtualize while a fetch is in flight
        // unwinds the Task.Delay promptly. Without the token check the delay would complete and
        // the continuation would try to update the cache after the component was disposed.
        await Task.Delay(350, req.CancellationToken).ConfigureAwait(false);
        var rows = VirtualizeData.Rows;
        var count = Math.Min(req.Count, rows.Count - req.StartIndex);
        var slice = new VirtualizeRow[Math.Max(count, 0)];
        for (var i = 0; i < slice.Length; i++)
        {
            slice[i] = rows[req.StartIndex + i];
        }

        return new ItemsProviderResult<VirtualizeRow>(slice, rows.Count);
    }
}
Live result
#NameCityBalance
————
————
————
————
————
————
————
————
————
————
————
————
————
————
————
————

Keyed lists

A .Key(…) on a list item gives it a stable identity across renders, so a reorder moves the live DOM node (with its focus, caret, and uncommitted input) instead of detaching and re-creating it. This is the same reconciliation identity the diff uses everywhere — not a reactive prop.

KeyedListsReorderDemo.cs
using System.Globalization;

namespace Rask.Site.Features;

// Keyed reconciliation in miniature. A stable Key: per row makes a reorder ship trusted Move ops, so
// each row's DOM node — and any uncommitted input value living only in the DOM — follows its logical row
// instead of being rewritten by position. Toggle Keys OFF to see positional reconciliation instead: the
// labels reorder but the inputs stay put, so typed text ends up next to the wrong fruit.
public sealed partial class KeyedListsReorderDemo : Component
{
    private readonly List<Fruit> _items =
    [
        new(1, "Apple"),
        new(2, "Banana"),
        new(3, "Cherry"),
        new(4, "Date"),
        new(5, "Elderberry")
    ];

    private int _nextId = 6;

    private bool _useKeys = true;

    protected override Component? Render() =>
        Div[
            Div.Class("flex gap-2 items-center flex-wrap mb-3")[
                Ui.Button.Icon(Ui.IconName.Key)
                    .Variant(_useKeys ? Ui.ButtonVariant.Primary : Ui.ButtonVariant.Outline)
                    .Color(_useKeys ? Ui.Color.Green : null)
                    .Id("kl-toggle-keys")
                    .OnClick(() => _useKeys = !_useKeys)[_useKeys ? "Keys: ON" : "Keys: OFF"],
                Span.Class("vr mx-1"),
                Ui.Button.Icon(Ui.IconName.ArrowsUpDown).Id("kl-rotate").OnClick(Rotate)["Rotate"],
                Ui.Button.Icon(Ui.IconName.ArrowPath).Id("kl-reverse").OnClick(Reverse)["Reverse"],
                Ui.Button.Icon(Ui.IconName.Plus).Id("kl-add").OnClick(AddTop)["Add to top"],
                Ui.Button.Red.Icon(Ui.IconName.Minus)
                    .Id("kl-remove")
                    .Disabled(_items.Count == 0)
                    .OnClick(RemoveTop)["Remove top"]
            ],
            Ui.Table.Id("kl-list")[
                Ui.TableColumns[Ui.TableColumn["#"], Ui.TableColumn["Fruit"], Ui.TableColumn["Note"]],
                Ui.TableRows[BuildRows()]
            ]
        ];

    private List<Component> BuildRows()
    {
        var rows = new List<Component>(_items.Count);
        for (var i = 0; i < _items.Count; i++)
        {
            var f = _items[i];
            // The keyless branch is deliberately unkeyed to demonstrate positional
            // reconciliation; RASK022 would otherwise flag it.
#pragma warning disable RASK022
            rows.Add(_useKeys
                ? Ui.TableRow.Key(f.Id)[Row(f, i)]
                : Ui.TableRow[Row(f, i)]);
#pragma warning restore RASK022
        }

        return rows;
    }

    private static List<Component> Row(Fruit f, int index) =>
    [
        Ui.TableCell[Ui.Badge[(index + 1).ToString(CultureInfo.InvariantCulture)]],
        Ui.TableCell.Variant(Ui.TableCellVariant.Strong).Class("kl-name")[f.Name],
        // The column says "Note" to the eye; the label around the field says it to a screen reader.
        Ui.TableCell[
            Label[
                Span.Class("sr-only")[$"Note for {f.Name}"],
                Ui.Input.Of<string>()
                    .Type(InputType.Text)
                    .Placeholder("type here, then reorder…").Class("kl-note")
            ]
        ]
    ];

    private void Rotate()
    {
        if (_items.Count < 2)
        {
            return;
        }

        var first = _items[0];
        _items.RemoveAt(0);
        _items.Add(first);
    }

    private void Reverse() => _items.Reverse();

    private void AddTop()
    {
        _items.Insert(0, new Fruit(_nextId, $"Fruit {_nextId}"));
        _nextId++;
    }

    private void RemoveTop()
    {
        if (_items.Count > 0)
        {
            _items.RemoveAt(0);
        }
    }

    private sealed record Fruit(int Id, string Name);
}
Live result
#
Fruit
Note
1
Apple
2
Banana
3
Cherry
4
Date
5
Elderberry

A master-detail grid is the same identity trick at work: each order row carries a Key, and expanding one inserts a keyed detail <tr> right after it. The diff reconciles that as an in-place keyed insert (collapse → remove), so the other open rows keep their own independently-sorted inner grid across the change — no wholesale re-render of the table:

MasterDetailDemo.cs
using System.Globalization;

namespace Rask.Site.Features;

// Master-detail datagrid, embedded in docs/composition.md's "Keyed lists" section (its standalone
// /master-detail page folded in). Expand/collapse and both grids' sort live in plain component fields.
//
// Expanding a row inserts a second, keyed <tr> ("detail-{id}") right after the keyed main row ("{id}").
// Because every row carries a stable Key, the live diff treats expand as an in-place keyed Insert and
// collapse as a keyed Remove — sibling expanded rows keep their own inner sort across the reconcile. Each
// detail panel hosts its own plain <table> of line items with an independent sort, so the demo owns three
// pieces of state: the expanded set, the outer sort, and a per-order inner sort.
public sealed partial class MasterDetailDemo : Component
{
    private static readonly Order[] _orders = BuildOrders();

    // (id, header, sortable, width) — the expander column has no label and no sort. Every column but one has
    // a width, so a sort moves the rows and never the columns; the one without takes the rest.
    private static readonly (string Id, string Header, bool Sortable, string? Width)[] _orderColumns =
    [
        ("expander", "", false, "w-14"),
        ("customer", "Customer", true, null),
        ("placed", "Placed", true, "w-32"),
        ("status", "Status", true, "w-36"),
        ("items", "Items", true, "w-24"),
        ("total", "Total", true, "w-32")
    ];

    private static readonly (string Id, string Header, string? Width)[] _itemColumns =
    [
        ("sku", "SKU", "w-32"),
        ("product", "Product", null),
        ("qty", "Qty", "w-20"),
        ("unit", "Unit price", "w-32"),
        ("line", "Line total", "w-32")
    ];

    // Expanded order ids, the outer sort, and the inner sort per order — all local UI state.
    // A sort is a (column id, ascending) pair; an empty column id means "unsorted".
    private readonly HashSet<int> _expanded = new();
    private readonly Dictionary<int, (string Col, bool Asc)> _itemSort = new();
    private (string Col, bool Asc) _orderSort = ("", true);

    protected override Component? Render()
    {
        var orders = SortOrders(_orders, _orderSort);

        return Ui.Card[Ui.CardBleed[
            Ui.Table.Id("md-orders").Class("w-full min-w-190 [&_tbody_tr:hover]:bg-zinc-50 dark:bg-zinc-900")[
                Ui.TableColumns.Class("bg-zinc-50 dark:bg-zinc-900")[
                    _orderColumns.Select(c =>
                        c.Sortable
                            ? SortHeader(c.Id, c.Header, c.Width, _orderSort, ToggleOrderSort)
                            : Ui.TableColumn.Key(c.Id).Class(c.Width))
                ],
                Ui.TableRows[BuildOrderRows(orders)]
            ]
        ]];
    }

    private List<Component> BuildOrderRows(IReadOnlyList<Order> orders)
    {
        var rows = new List<Component>(orders.Count * 2);
        foreach (var order in orders)
        {
            var open = _expanded.Contains(order.Id);

            rows.Add(Ui.TableRow.Key(order.Id).Class("md-row")[
                Ui.TableCell[
                    Ui.Button.Subtle.Icon(open ? Ui.IconName.ChevronDown : Ui.IconName.ChevronRight)
                        .AriaLabel(open ? $"Collapse order {order.Id}" : $"Expand order {order.Id}")
                        .Class("p-0 no-underline")
                        .Data("testid", $"expander-{order.Id}")
                        .OnClick(() => Toggle(order.Id))
                ],
                Ui.TableCell.Class("truncate font-semibold").Title(order.Customer)[order.Customer],
                Ui.TableCell.Class("text-zinc-500 dark:text-white/70 text-sm")[order.Placed.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)],
                Ui.TableCell[Ui.Badge.Color(StatusColor(order.Status))[order.Status]],
                Ui.TableCell.Class("text-zinc-500 dark:text-white/70")[order.Items.Count],
                Ui.TableCell.Style("text-align:right; font-variant-numeric:tabular-nums;")[
                    "$" + order.Total.ToString("N2", CultureInfo.InvariantCulture)
                ]
            ]);

            if (open)
            {
                rows.Add(Ui.TableRow.Key($"detail-{order.Id}").Class("md-detail")[
                    Ui.TableCell.Attributes(("colspan", _orderColumns.Length.ToString(CultureInfo.InvariantCulture))).Class("p-0 bg-zinc-50 dark:bg-zinc-900")[
                        Div
                            .Class("p-3")
                            .Data("testid", $"inner-{order.Id}")[
                            InnerGrid(order)
                        ]
                    ]
                ]);
            }
        }

        return rows;
    }

    private Component InnerGrid(Order order)
    {
        var sort = _itemSort.GetValueOrDefault(order.Id, ("", true));
        var items = SortItems(order.Items, sort);

        return Ui.Table.Class("w-full min-w-168 [&_tbody_tr:nth-child(odd)]:bg-zinc-50 dark:bg-zinc-900 bg-white dark:bg-zinc-800")[
            Ui.TableColumns[
                _itemColumns.Select(c => SortHeader(c.Id, c.Header, c.Width, sort, col => ToggleItemSort(order.Id, col)))
            ],
            Ui.TableRows[
                items.Select(it =>
                    Ui.TableRow.Key(it.Id)[
                        Ui.TableCell[Code[it.Sku]],
                        Ui.TableCell.Class("truncate").Title(it.Product)[it.Product],
                        Ui.TableCell.Class("text-zinc-500 dark:text-white/70")[it.Qty],
                        Ui.TableCell.Style("text-align:right; font-variant-numeric:tabular-nums;")[
                            "$" + it.UnitPrice.ToString("N2", CultureInfo.InvariantCulture)
                        ],
                        Ui.TableCell.Style("text-align:right; font-variant-numeric:tabular-nums;")[
                            "$" + it.LineTotal.ToString("N2", CultureInfo.InvariantCulture)
                        ]
                    ])
            ]
        ];
    }

    private void Toggle(int id)
    {
        if (!_expanded.Add(id))
        {
            _expanded.Remove(id);
        }

        StateHasChanged();
    }

    private void ToggleOrderSort(string col)
    {
        _orderSort = NextSort(_orderSort, col);
        StateHasChanged();
    }

    private void ToggleItemSort(int orderId, string col)
    {
        _itemSort[orderId] = NextSort(_itemSort.GetValueOrDefault(orderId, ("", true)), col);
        StateHasChanged();
    }

    // Cycle a column's sort: unsorted → asc → desc → unsorted.
    private static (string Col, bool Asc) NextSort((string Col, bool Asc) current, string col) =>
        current switch
        {
            _ when !string.Equals(current.Col, col, StringComparison.Ordinal) => (col, true),
            { Asc: true } => (col, false),
            _ => ("", true),
        };

    private static IReadOnlyList<Order> SortOrders(IReadOnlyList<Order> source, (string Col, bool Asc) sort)
    {
        if (sort.Col.Length == 0)
        {
            return source;
        }

        var asc = sort.Asc;
        IEnumerable<Order> view = source;
        view = sort.Col switch
        {
            "customer" => asc ? view.OrderBy(o => o.Customer, StringComparer.Ordinal) : view.OrderByDescending(o => o.Customer, StringComparer.Ordinal),
            "placed" => asc ? view.OrderBy(o => o.Placed) : view.OrderByDescending(o => o.Placed),
            "status" => asc ? view.OrderBy(o => o.Status, StringComparer.Ordinal) : view.OrderByDescending(o => o.Status, StringComparer.Ordinal),
            "items" => asc ? view.OrderBy(o => o.Items.Count) : view.OrderByDescending(o => o.Items.Count),
            "total" => asc ? view.OrderBy(o => o.Total) : view.OrderByDescending(o => o.Total),
            _ => view
        };
        return view.ToArray();
    }

    private static IReadOnlyList<LineItem> SortItems(IReadOnlyList<LineItem> source, (string Col, bool Asc) sort)
    {
        if (sort.Col.Length == 0)
        {
            return source;
        }

        var asc = sort.Asc;
        IEnumerable<LineItem> view = source;
        view = sort.Col switch
        {
            "sku" => asc ? view.OrderBy(i => i.Sku, StringComparer.Ordinal) : view.OrderByDescending(i => i.Sku, StringComparer.Ordinal),
            "product" => asc ? view.OrderBy(i => i.Product, StringComparer.Ordinal) : view.OrderByDescending(i => i.Product, StringComparer.Ordinal),
            "qty" => asc ? view.OrderBy(i => i.Qty) : view.OrderByDescending(i => i.Qty),
            "unit" => asc ? view.OrderBy(i => i.UnitPrice) : view.OrderByDescending(i => i.UnitPrice),
            "line" => asc ? view.OrderBy(i => i.LineTotal) : view.OrderByDescending(i => i.LineTotal),
            _ => view
        };
        return view.ToArray();
    }

    // Shared sort-aware header: a link button that toggles the column's sort, with a chevron reflecting
    // its current direction. Used by both the outer and the inner grid.
    // The table draws the sort button and its chevron; the page keeps which column is sorted and which way.
    private static Component SortHeader(string columnId, string header, string? width, (string Col, bool Asc) sort,
        Action<string> toggle) =>
        Ui.TableColumn
            .Key(columnId)
            .Class(width)
            .Sortable()
            .Sorted(string.Equals(sort.Col, columnId, StringComparison.Ordinal))
            .Direction(sort.Asc ? Ui.TableColumnDirection.Asc : Ui.TableColumnDirection.Desc)
            .OnSort(() => toggle(columnId))[header];

    // A COLOUR, not a class: the call site names the hue and Ui.Badge decides the tint, the text and dark mode.
    private static Ui.Color? StatusColor(string status) => status switch
    {
        "Shipped" => Ui.Color.Green,
        "Processing" => Ui.Color.Blue,
        "Pending" => Ui.Color.Yellow,
        "Cancelled" => Ui.Color.Red,
        _ => null
    };

    private static Order[] BuildOrders()
    {
        var customers = new[]
        {
            "Ada Lovelace", "Grace Hopper", "Linus Torvalds", "Margaret Hamilton", "Donald Knuth",
            "Barbara Liskov", "Edsger Dijkstra", "Tony Hoare", "Alan Turing", "John Backus",
            "Niklaus Wirth", "Bjarne Stroustrup", "Anders Hejlsberg", "James Gosling"
        };
        var statuses = new[] { "Shipped", "Processing", "Pending", "Cancelled" };
        var products = new[]
        {
            ("KBD-01", "Mechanical keyboard", 89m), ("MSE-02", "Wireless mouse", 39m),
            ("MON-27", "27\" monitor", 329m), ("USB-C", "USB-C hub", 59m),
            ("CBL-HD", "HDMI cable", 12m), ("STD-01", "Laptop stand", 45m),
            ("WBC-4K", "4K webcam", 129m), ("HPN-01", "Noise-cancelling headphones", 199m),
            ("DSK-MAT", "Desk mat", 24m), ("CHR-ERG", "Ergonomic chair", 449m)
        };

#pragma warning disable S2245 // seeded on purpose: the same demo data on every visit, nothing secret
        var rng = new Random(42);
#pragma warning restore S2245
        var orders = new Order[customers.Length];
        var nextItemId = 1;
        for (var i = 0; i < customers.Length; i++)
        {
            var itemCount = 2 + rng.Next(0, 5);
            var items = new LineItem[itemCount];
            for (var j = 0; j < itemCount; j++)
            {
                var (sku, name, price) = products[rng.Next(products.Length)];
                items[j] = new LineItem(nextItemId++, i + 1, sku, name, 1 + rng.Next(0, 5), price);
            }

            var placed = new DateOnly(2025, 1 + rng.Next(0, 12), 1 + rng.Next(0, 28));
            orders[i] = new Order(i + 1, customers[i], placed, statuses[rng.Next(statuses.Length)], items);
        }

        return orders;
    }

    private sealed record LineItem(int Id, int OrderId, string Sku, string Product, int Qty, decimal UnitPrice)
    {
        public decimal LineTotal => Qty * UnitPrice;
    }

    private sealed record Order(
        int Id,
        string Customer,
        DateOnly Placed,
        string Status,
        IReadOnlyList<LineItem> Items)
    {
        public decimal Total => Items.Sum(i => i.LineTotal);
    }
}
Live result
Ada Lovelace2025-04-15
Processing
5$1,843.00
Grace Hopper2025-02-03
Pending
3$1,522.00
Linus Torvalds2025-01-16
Cancelled
6$2,488.00
Margaret Hamilton2025-08-05
Shipped
5$1,420.00
Donald Knuth2025-09-02
Processing
5$858.00
Barbara Liskov2025-01-24
Shipped
2$732.00
Edsger Dijkstra2025-12-13
Processing
2$801.00
Tony Hoare2025-09-01
Cancelled
5$2,263.00
Alan Turing2025-12-22
Pending
6$711.00
John Backus2025-01-07
Pending
5$4,068.00
Niklaus Wirth2025-01-02
Cancelled
4$1,597.00
Bjarne Stroustrup2025-12-28
Cancelled
6$2,509.00
Anders Hejlsberg2025-07-20
Shipped
2$1,011.00
James Gosling2025-10-04
Shipped
4$1,891.00

Toast messages

Toasts are built in. Say something to the person using the app from anywhere — an event handler, a save, a render — with nothing injected and nothing mounted:

private async Task Save()
{
    await Product.Create(_model);
    Toast.Success("Saved");
    Routes.ProductsPage().Go();   // the toast shows on the page they land on
}

Toast.Info, Toast.Warning and Toast.Error say the rest. A toast can carry a heading, a button or a link, and its own time on screen:

Toast.Success("Your order was placed").Heading("Order 42");
Toast.Info("Changes saved.").Action("Undo", UndoChanges);          // runs it, then dismisses the toast
Toast.Success("Invoice created.").Link("View invoice", Routes.InvoicePage(invoice.Id));
Toast.Error("Payment failed").For(30.Seconds);
Toast.Error("Couldn't reach the server").UntilDismissed();
BuiltInToastDemo.cs
namespace Rask.Site.Features;

// Toasts are built in: nothing injected, nothing mounted — the host draws them in the kit's look.
public sealed partial class BuiltInToastDemo : Component
{
    protected override Component? Render() =>
        Div.Class("flex flex-wrap gap-2")[
            Ui.Button.Primary.Id("toast-save").OnClick(() => Toast.Success("Saved"))["Save"],
            Ui.Button.Id("toast-order").OnClick(() => Toast.Info("Your order was placed").Heading("Order 42"))["Place order"],
            Ui.Button.Red.Id("toast-fail")
                .OnClick(() => Toast.Error("Couldn't reach the server").UntilDismissed())["Fail"]
        ];
}
Live result

A toast belongs to the session, not the page, so one raised just before navigating survives the navigation and shows once on the destination. The host draws them — as the UI kit's Ui.Toast, or in a small look of Rask's own when the kit is off — in the bottom end corner, one at a time, each gone after five seconds unless it says otherwise. Where they appear and how long they stay is the app's to set, in Program.cs or appsettings.json:

RaskApp.Create(args)
    .Configure(c => c.Toasts.At(Ui.ToastPosition.TopEnd).For(8.Seconds))
    .Run<App>();
{ "Rask": { "Toasts": { "Position": "TopEnd", "Duration": "00:00:08" } } }

For anything else — a stack, the inverted look — place the kit's toast in your layout and the built-in one steps aside:

Ui.ToastGroup[Ui.Toast]          // toasts stack, and the deck opens under the pointer
Ui.Toast.TopEnd.Invert()         // one at a time, dark on a light page

Your own look

Mount a ToastOutlet anywhere and the built-in one steps aside — your outlet gets every toast, and draws them through Template, which receives the messages showing now and a dismiss(id) callback:

protected override Component? Render() =>
[
    Router,
    ToastOutlet.Template((messages, dismiss) =>
        Div.Class("notices")[messages.Select(m => Div.Key(m.Id).Class("notice")[
            m.Message,
            Button.OnClick(() => dismiss(m.Id))["×"]])]),
];

Each message is delivered to exactly one outlet and never reappears on a later render. AutoDismissAfter sets how long a toast stays in your outlet when it did not say (.For, .UntilDismissed()).

In a test

A toast is on screen, so a test that visits the page sees it: page.Shows("Saved"). A test that runs no page records them instead:

using var toasts = Toast.Fake();

await Orders.Place(cart);

toasts.Shown("Order placed").Once();
toasts.Shown("Order placed").As(ToastLevel.Success);
toasts.Shown("Payment failed").Never();

Drag and drop

A headless drag-and-drop primitive lives in Rask.Core/DragAndDrop. It tracks the dragged item and the drop target and raises a callback when an item is dropped; you own the visuals — a sortable list and a kanban board built on the same primitive:

DragDropSortableDemo.cs
using Rask.Core.DragAndDrop;

namespace Rask.Site.Features;

// Single-list reorder. One drop zone ("list"); drop a fruit onto another to reorder.
public sealed partial class DragDropSortableDemo : Component
{
    private readonly List<string> _fruits =
    [
        "Apple", "Banana", "Cherry", "Date", "Elderberry"
    ];

    protected override Component? Render() => DragDrop.Body(SortableBody).OnDrop(ReorderFruit);

    private Component SortableBody(DragDropContext ctx) =>
        Ui.Table.Class("dd-list").Id("dd-fruit-list")[Ui.TableRows[_fruits.Select((fruit, index) => Ui.TableRow
            .Key(fruit)
            .Class(
                "dd-item",
                ctx.IsSource("list", index) ? "dd-dragging" : null,
                ctx.IsDropTarget("list", index) ? "dd-drop-target" : null)
            .Draggable()
            .OnDragStart(ctx.DragStart("list", index))
            .OnDragOver(ctx.DragOver("list", index))
            .OnDrop(ctx.Drop("list", index))
            .OnDragEnd(ctx.DragEnd)
            .Data("testid", $"fruit-{index}")[
                Ui.TableCell.Class("w-8")[Ui.Icon.Name(Ui.IconName.EllipsisVertical).Variant(Ui.IconVariant.Mini)],
                Ui.TableCell.Variant(Ui.TableCellVariant.Strong)[fruit]
            ])]];

    // Direction-aware: dragging down lands after the target, dragging up lands before it.
    private void ReorderFruit(DragDropMove move) => move.ApplyTo(_fruits);
}
Live result
Apple
Banana
Cherry
Date
Elderberry
DragDropKanbanDemo.cs
using Rask.Core.DragAndDrop;

namespace Rask.Site.Features;

// Multi-column board. One drop zone per column; drag cards across columns or reorder within one.
public sealed partial class DragDropKanbanDemo : Component
{
    private static readonly string[] _columns = ["todo", "doing", "done"];

    private static readonly Dictionary<string, string> _columnLabels = new(StringComparer.Ordinal)
    {
        ["todo"] = "To do",
        ["doing"] = "In progress",
        ["done"] = "Done"
    };

    private readonly Dictionary<string, List<Card>> _board = new(StringComparer.Ordinal)
    {
        ["todo"] =
            [new Card(1, "Sketch the API"), new Card(2, "Write the primitive"), new Card(3, "Add the events")],
        ["doing"] = [new Card(4, "Wire the client JS")],
        ["done"] = [new Card(5, "Read the codebase")]
    };

    protected override Component? Render() => DragDrop.Body(KanbanBody).OnDrop(MoveCard);

    private Component KanbanBody(DragDropContext ctx) =>
        Div.Class("grid grid-cols-12 gap-4 dd-board")[_columns.Select(zone => KanbanColumn(ctx, zone))];

    private Component KanbanColumn(DragDropContext ctx, string zone)
    {
        var cards = _board[zone];
        var cardChildren = new List<Component>(cards.Count);
        for (var i = 0; i < cards.Count; i++)
        {
            cardChildren.Add(KanbanCard(ctx, zone, i, cards[i]));
        }

        // The whole column body is the drop-at-end zone, so a card can land in empty space, at
        // the tail of a column, or into an empty column. Cards inside carry their own per-index
        // drop handlers; the client's e.target.closest(...) resolves the innermost match, so
        // hovering a card targets that card and hovering empty space targets the column end.
        var dropAtEnd = cards.Count;
        var bodyCls = "dd-column-body";
        if (ctx.IsDropTarget(zone, dropAtEnd))
        {
            bodyCls += " dd-drop-target";
        }

        return Div.Key(zone).Class("col-span-12")[
            Div.Class("dd-column h-full")[
                Div.Class("dd-column-header flex justify-between items-center")[
                    Span.Class("font-semibold")[_columnLabels[zone]],
                    Ui.Badge[cards.Count]
                ],
                Div
                    .Class(bodyCls)
                    .OnDragOver(ctx.DragOver(zone, dropAtEnd))
                    .OnDrop(ctx.Drop(zone, dropAtEnd))
                    .Data("testid", $"col-{zone}")[cardChildren]
            ]
        ];
    }

    private static Component KanbanCard(DragDropContext ctx, string zone, int index, Card card)
    {
        var cls = "card dd-card";
        if (ctx.IsSource(zone, index))
        {
            cls += " dd-dragging";
        }

        if (ctx.IsDropTarget(zone, index))
        {
            cls += " dd-drop-target";
        }

        return Div
            .Key(card.Id)
            .Class(cls)
            .Draggable()
            .OnDragStart(ctx.DragStart(zone, index))
            .OnDragOver(ctx.DragOver(zone, index))
            .OnDrop(ctx.Drop(zone, index))
            .OnDragEnd(ctx.DragEnd)
            .Data("testid", $"card-{card.Id}")[
            Div.Class("p-2 flex items-center gap-2")[
                Ui.Icon.Name(Ui.IconName.EllipsisVertical).Class("size-5 text-zinc-500 dark:text-white/70"),
                Span[card.Title]
            ]
        ];
    }

    private void MoveCard(DragDropMove move)
    {
        if (_board.TryGetValue(move.FromZone, out var from) && _board.TryGetValue(move.ToZone, out var to))
        {
            move.ApplyTo(from, to);
        }
    }

    private sealed record Card(int Id, string Title);
}
Live result
To do
3
Sketch the API
Write the primitive
Add the events
In progress
1
Wire the client JS
Done
1
Read the codebase

Error boundaries

An error boundary catches an exception thrown by a descendant — during an event handler or during render — and shows a fallback instead of tearing down the whole app. The nearest boundary you wrote handles it; everything outside it (the navbar, the rest of the page) keeps running, and Recover restores the healthy subtree. Boundaries nest, so a local failure stays local. With no boundary of yours around the part, a handler that throws leaves the page as it is and an error toast says so; only a render that throws reaches the root error page (lifecycle).

The Fallback is handed the exception so it can choose its words, not so it can print them: an exception's message or type name on a page can carry a path, a query or a user's data, and the boundary has already logged it (what a visitor sees). Each fallback below says one fixed sentence and offers the way back.

BoomHandlerDemo.cs
namespace Rask.Site.Features;

// The nearest ErrorBoundary the app wrote catches an exception thrown by a
// descendant's event handler and renders the Fallback in place of the subtree
// (with none around the part, the page stays and an error toast says so). The fallback receives a
// recover() callback that clears the boundary's error and re-renders the
// healthy subtree. The fallback is also handed the exception and prints none of
// it: a message can carry a path, a query or a user's data, and it is already in
// the log. It says what happened in the visitor's words and what they can do.
public sealed partial class BoomHandlerDemo : Component
{
    protected override Component? Render() =>
        ErrorBoundary
            .Fallback((_, recover) => BoundaryFallback(recover))[
            Div.Class("p-3 border rounded bg-white").Id("boom-handler-host")[
                P.Class("text-zinc-500 dark:text-white/70 text-sm mb-2")["Healthy subtree — click to throw."],
                Ui.Button.Danger.Icon(Ui.IconName.ExclamationTriangle).Id("boom-throw").OnClick(ThrowFromHandler)["Throw a handler exception"]
            ]
        ];

    private static Component BoundaryFallback(Action recover) =>
        Ui.Callout.Danger.Icon(Ui.IconName.ExclamationTriangle).Id("boom-fallback").Role("alert")
            .Actions(Ui.Button.Icon(Ui.IconName.ArrowUturnLeft).Id("boom-recover").OnClick(recover)["Recover"])[
            Ui.CalloutHeading["This part stopped working."],
            Ui.CalloutText["The rest of the page is still running. Recover brings this part back."]
        ];

    private static void ThrowFromHandler() =>
        throw new InvalidOperationException("kaboom — handler boundary demo");
}
Live result

Healthy subtree — click to throw.

A render-time throw is rewound cleanly (the serializer discards the partial output) and caught exactly once:

BoomRenderDemo.cs
namespace Rask.Site.Features;

// The same ErrorBoundary catches synchronous exceptions thrown inside a
// descendant's Render(). Clicking the button flips a flag; the next render of
// the child throws and the fallback replaces it.
public sealed partial class BoomRenderDemo : Component
{
    private bool _throwOnRender;

    protected override Component? Render() =>
        ErrorBoundary
            .Fallback(// Recover for the render-throw demo must ALSO reset _throwOnRender —
                      // otherwise the boundary clears its error, re-walks its cached Children
                      // (still containing the RenderThrower built last frame), and trips
                      // again on the same exception. Two cooperating dirty-marks are needed:
                      //   - recover()         → clears boundary._error, marks boundary dirty
                      //   - StateHasChanged() → marks THIS demo dirty so its Render re-
                      //                         executes with _throwOnRender=false and the
                      //                         boundary receives fresh Children without the
                      //                         RenderThrower.
                      // The handler-throw demo doesn't need this because the underlying
                      // state isn't stale across the trip.
            (_, recover) => BoundaryFallback(() =>
            {
                // Order matters: dirty-mark this demo FIRST so its re-render calls
                // the ErrorBoundary factory → boundary.SetProps with fresh Children
                // that no longer include the RenderThrower. THEN clear the boundary's
                // error. Calling recover() before that would synchronously re-render
                // the boundary against the stale cached Children (still containing
                // RenderThrower) — the boundary would trip again on the same
                // exception and the recovery would appear to do nothing.
                _throwOnRender = false;
                StateHasChanged();
                recover();
            }))[
            Div.Class("p-3 border rounded bg-white").Id("boom-render-host")[
                P.Class("text-zinc-500 dark:text-white/70 text-sm mb-2")["Healthy. Click below to make my next render throw."],
                Ui.Button.Filled.Amber.Icon(Ui.IconName.BugAnt).Id("boom-render-trigger").OnClick(() => _throwOnRender = true)["Throw on next render"],
#pragma warning disable RASK014
                // Intentionally bypass the factory: RenderThrower is [SkipFactory] and
                // exists only to demonstrate that a descendant whose Render() throws is
                // caught by the enclosing ErrorBoundary.
                _throwOnRender ? new RenderThrower() : Text.Value(string.Empty)
#pragma warning restore RASK014
            ]
        ];

    // Nothing of the exception is printed: the fallback says what the visitor can do.
    private static Component BoundaryFallback(Action recover) =>
        Ui.Callout.Danger.Icon(Ui.IconName.ExclamationTriangle).Id("boom-fallback").Role("alert")
            .Actions(Ui.Button.Icon(Ui.IconName.ArrowUturnLeft).Id("boom-recover").OnClick(recover)["Recover"])[
            Ui.CalloutHeading["This part could not be shown."],
            Ui.CalloutText["The rest of the page is still running. Recover draws it again."]
        ];

    // Trivial component whose Render always throws — used to demonstrate render-time
    // boundary capture. SkipFactory tells the source generator not to emit a public
    // factory; we instantiate it directly from inside the boundary's Children.
    [SkipFactory]
    private sealed class RenderThrower : Component
    {
        protected override Component? Render() =>
            throw new InvalidOperationException("kaboom — render-time boundary demo");
    }
}
Live result

Healthy. Click below to make my next render throw.

Nested boundaries — the innermost one catches, leaving its siblings untouched:

BoomNestedDemo.cs
namespace Rask.Site.Features;

// Boundaries nest: the inner boundary catches first, so the outer healthy
// region (and its sibling paragraph) stays mounted. If the inner fallback
// itself throws, the outer boundary catches the escalation. Neither fallback
// prints the exception it is handed.
public sealed partial class BoomNestedDemo : Component
{
    protected override Component? Render() =>
        ErrorBoundary.Fallback((_, _) => OuterFallback())[
            Div.Class("p-3 border rounded bg-white").Id("boom-nested-host")[
                P
                    .Class("mb-2 text-sm text-zinc-500 dark:text-white/70")
                    .Id("boom-nested-outer-healthy")[
                    "Outer healthy region — stays mounted while the inner boundary trips."],
                ErrorBoundary.Fallback((_, recover) => InnerFallback(recover))[
                    Div.Class("p-3 border rounded bg-zinc-50 dark:bg-zinc-900")[
                        P.Class("text-sm text-zinc-500 dark:text-white/70 mb-2")["Inner boundary subtree."],
                        Ui.Button.Danger.Icon(Ui.IconName.ExclamationTriangle)
                            .Id("boom-nested-throw")
                            .OnClick(ThrowFromInnerHandler)["Throw inside inner boundary"]
                    ]
                ]
            ]
        ];

    private static Component InnerFallback(Action recover) =>
        Ui.Callout.Warning.Icon(Ui.IconName.ShieldExclamation).Id("boom-nested-inner-fallback").Role("alert")
            .Actions(Ui.Button.Icon(Ui.IconName.ArrowUturnLeft)
                .Id("boom-nested-inner-recover")
                .OnClick(recover)["Recover inner"])[
            Ui.CalloutHeading["The inner part stopped."],
            Ui.CalloutText["Everything around it is still running. Recover inner brings it back."]
        ];

    private static UiCallout OuterFallback() =>
        Ui.Callout.Danger.Id("boom-nested-outer-fallback").Role("alert")
            .Heading("This example stopped.").Text("Reload the page to bring it back.");

    private static void ThrowFromInnerHandler() =>
        throw new InvalidOperationException("kaboom — inner boundary demo");
}
Live result

Outer healthy region — stays mounted while the inner boundary trips.

Inner boundary subtree.

A handler that refuses

Not every exception is a fault. When the app's own code says no in a sentence written for the reader — an exception that carries field failures (IFieldFailures, which RaskValidationException is) or a System.ComponentModel.DataAnnotations.ValidationException — a handler that lets it through is answered with that sentence as an error toast. No boundary is tripped, nothing is logged as an error, the page keeps whatever the handler changed before it threw, and the next click is answered:

// The confirm button of a list's bin. The store refuses: "The trailer cannot be deleted: a waybill refers to it."
async Task Delete(Guid id)
{
    await Trailer.Delete(id);   // throws RaskValidationException — nothing to catch
    _asking = false;            // not reached, so the dialog stays open, as the handler left it
}

The sentence is the refusal's own, the same in Development and in Production. Several failures in one exception are one toast, each different sentence once, in order. A failure that names fields is said in the toast as well: outside a form there is no field to put it under (in a form it goes there). The exception is recognised inside an AggregateException or a TargetInvocationException too; a class of your own derived from ValidationException is not a refusal. Every other exception trips the boundary you wrote around it, or is a handler that fails. A refusal in a hand-wired host whose layout draws no toasts goes to the boundary too — there would be nowhere to read it.

A handler that fails

A handler that throws anything else — the database is down, a null where a row should be — with no ErrorBoundary of yours around it does not replace the page. The page stays as the handler left it, the reader gets one error toast, and the next click is answered:

That did not work. Please try again. The problem has been recorded. If it keeps happening, quote this reference: K7WQ2MZP
// No try/catch, no boundary: the list, its filter text and the open dialog are all still there.
async Task Delete(Guid id)
{
    _attempts++;                 // rendered: it happened before the throw
    await Trailer.Delete(id);    // throws SqlException
    _asking = false;             // not reached
}

Nothing of the exception is in the toast or in the frame that carries it outside Development; it is logged once at Error on Rask.Errors with the reference the toast shows (what a visitor sees). In Development the toast adds the exception's type and message, and the overlay shows the rest. The sentence is RaskString.HandlerFailed (localization).

Which of the two a fault gets is decided by where the handler was written:

The handler What the reader gets
is written in a component with no boundary of yours around it the toast; the page stays
is written in a component inside an ErrorBoundary you wrote that boundary's fallback, as above — a boundary is a request to take that part over
is the page's, handed to a child or a kit control (Ui.Button.OnClick(Delete), Ui.Pagination.OnPage, a select's OnChange) decided by the page that wrote it, not by where the control sits
throws after an await the same as before one
is a form's submit the form's own sentence, The save failed. Please try again.
throws OperationCanceledException nothing: the work was called off
runs where no toast can be drawn (a hand-wired host with no outlet) the boundary, as before — a fault is never swallowed

A Render or a lifecycle hook that throws is never a toast: there is no page left to keep, so the nearest boundary — yours, or the app's own error page — takes it. To say something of your own for one failure, catch it in the handler and raise the toast you want.

Confirm