Rask
Rask
v0.23.1-alpha.0.459

Components

Filters

A list's filters, each kept in the page's address. Ui.Filters draws a Filters button that opens them in a flyout, and Badges says which are in force. Not one of Flux UI's components: the kit's own, built from its select, date picker, modal and badge.

Filters

Laid out as Flux's full-bleed table: the Filters button where its header puts an action, the badges under the header, the table bleeding through the card. Each control is bound to a [QueryParam] of the page; a pick moves the address, and the table's rows turn into skeleton rows until the filtered ones arrive. The table's sort is no filter: Clear all leaves it.

UiFiltersPage.Filters.cs
using System.Globalization;
using Rask.Core.Routing;
using Rask.Querying;

namespace Rask.Site.Features.UiDocs;

public sealed partial class UiFiltersPage
{
    private static readonly DateOnly Today = DateOnly.FromDateTime(TimeProvider.System.GetLocalNow().DateTime);

    private static readonly Order[] Orders =
    [
        new("#3102", "Ada Lovelace", Today, "Open"),
        new("#3101", "Grace Hopper", Today.AddDays(-1), "Shipped"),
        new("#3099", "Ada Lovelace", Today.AddDays(-3), "Shipped"),
        new("#3094", "Alan Turing", Today.AddDays(-8), "Open"),
        new("#3090", "Grace Hopper", Today.AddDays(-20), "Returned"),
    ];

    [QueryParam("driver")] public string? Driver { get; set; }

    [QueryParam("period")] public UiDateRange? Period { get; set; }

    [QueryParam("status")] public string? Status { get; set; }

    // The list's own order, kept in the address beside the filters: no filter, so Clear all leaves it.
    [QueryParam("sort")] public string? Sort { get; set; }

    private bool ByDay => string.Equals(Sort, "day", StringComparison.Ordinal);

    // The rows, read again whenever the address changes what they are filtered or sorted by.
    private Query<Order[]> Filtered => field ??= QueryClient.Query(
        QueryKey.Of("ui-filters"), () => (Driver, Period, Status, ByDay), (asked, ct) => Read(asked, ct));

    private Component FiltersExample()
    {
        var filters = Ui.Filters[
            Ui.Select.Bind(() => Driver).Label("Driver").Listbox.Searchable().Clearable().Placeholder("Anyone")[
                Ui.SelectOption.Value("ada")["Ada Lovelace"],
                Ui.SelectOption.Value("alan")["Alan Turing"],
                Ui.SelectOption.Value("grace")["Grace Hopper"]
            ],
            Ui.DatePicker.Range.Bind(() => Period).Label("Period").WithPresets().Clearable(),
            Ui.Select.Bind(() => Status).Label("Status").Listbox.Clearable().Placeholder("Any")[
                Ui.SelectOption.Value("Open")["Open"],
                Ui.SelectOption.Value("Shipped")["Shipped"],
                Ui.SelectOption.Value("Returned")["Returned"]
            ]
        ];

        return Ui.Card[
            Div.Class("flex items-center justify-between gap-4")[
                Div[
                    Ui.Heading["Recent orders"],
                    Ui.Text.Class("mt-1")["What each driver delivered."]
                ],
                filters
            ],
            Div.Class("mt-4")[filters.Badges],
            Ui.Table.Bleed().ContainerClass("mt-6").Of(Filtered)[
                Ui.TableColumns[
                    Ui.TableColumn["Order"],
                    Ui.TableColumn["Driver"],
                    Ui.TableColumn.Sortable().Sorted(ByDay).OnSort(() => Go.With("sort", ByDay ? null : "day"))["Day"],
                    Ui.TableColumn["Status"]
                ],
                Ui.TableRows[
                    (Filtered.Data ?? []).Select(order => Ui.TableRow.Key(order.Number)[
                        Ui.TableCell.Variant(Ui.TableCellVariant.Strong)[order.Number],
                        Ui.TableCell[order.Driver],
                        Ui.TableCell[order.Day.ToString("MMM d", CultureInfo.InvariantCulture)],
                        Ui.TableCell[order.Status]
                    ])
                ]
            ]
        ];
    }

    // A slow read, so the skeleton has time to be seen.
    private static async Task<Order[]> Read((string? Driver, UiDateRange? Period, string? Status, bool ByDay) asked, CancellationToken ct)
    {
        await Task.Delay(TimeSpan.FromMilliseconds(600), ct);
        var rows = Orders.Where(order => (asked.Driver is null || order.Driver.StartsWith(asked.Driver, StringComparison.OrdinalIgnoreCase))
                                         && (asked.Period is not { } period || period.Contains(order.Day))
                                         && (asked.Status is null || string.Equals(order.Status, asked.Status, StringComparison.Ordinal)));
        return [.. asked.ByDay ? rows.OrderBy(order => order.Day) : rows];
    }

    private sealed record Order(string Number, string Driver, DateOnly Day, string Status);
}
Live result
Recent orders

What each driver delivered.

Order
Driver
Status
#3102Ada LovelaceOct 21Open
#3101Grace HopperOct 20Shipped
#3099Ada LovelaceOct 18Shipped
#3094Alan TuringOct 13Open
#3090Grace HopperOct 1Returned

A filter of your own

Ui.Filter carries a filter the set cannot read from a control, such as a select of several answers: the page says what it is called, what it holds and how it is taken off.

UiFiltersPage.Filter.cs
using Rask.Core.Routing;

namespace Rask.Site.Features.UiDocs;

public sealed partial class UiFiltersPage
{
    private static readonly string[] Labels = ["Fragile", "Express", "Gift"];

    [QueryParam("labels")] public string? Tagged { get; set; }

    private Component FilterExample()
    {
        List<string> tagged = [.. (Tagged ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries)];

        var filters = Ui.Filters[
            Ui.Filter.Label("Labels").Shown(tagged.Count == 0 ? null : string.Join(", ", tagged)).OnRemove(() => Go.With("labels", null))[
                Ui.Select.Values((ICollection<string>)tagged).Label("Labels").Listbox.Multiple().Placeholder("Any label")
                    .OnChange((ICollection<string> picked) => Go.With("labels", picked.Count == 0 ? null : string.Join(',', picked)))[
                    Labels.Select(label => Ui.SelectOption.Key(label).Value(label)[label])
                ]
            ]
        ];

        return Div.Class("space-y-4")[
            filters,
            filters.Badges,
            Ui.Text[filters.Active == 0 ? "Every order." : $"Orders labelled {string.Join(" or ", tagged)}."]
        ];
    }
}
Live result

Every order.

Behaviour

Kept in the address

Each control is bound to a page member with [QueryParam], as it would be in a form, and the set binds it to the address instead: a pick moves the query, the router writes the member, and the page hears OnUpdated as for any move of its query. A filtered list can be linked to and reloaded.

The key is the attribute's name, or the member's own. A member without one fails as the page renders, naming the declaration to write. A range is one key, period=2026-10-01..2026-10-09, because UiDateRange reads and writes itself as text: [QueryParam] public UiDateRange? Period works on any page.

A move replaces the history entry, so Back leaves the list rather than undoing each pick.

Applied as chosen

There is no Apply button. A pick in a select, a date picker or a box applies at once; a text field once typing pauses for 150 ms. A Debounce or Blur the call site writes on a field is kept.

The table it drives

The set drives the first Ui.Table.Of(query) built after it. Its fields, each badge's cross, Clear all and Clear filters name that table in aria-controls — its own id, or one the set gives it — so a filter press turns the rows on screen into skeleton rows at once, as the table's own pager and sortable headings do, and the filtered rows take their place when the query answers.

Place the button where an action goes and the badges between the header and the table: the example above is Flux's full-bleed table, with the Filters button where Flux puts "Add customer".

Badges, and what counts

filters.Badges is a badge for each filter in force, in the words its control shows — "Driver: Ada Lovelace", a range as the picker writes it or the name of the preset that made it — each with a cross that takes that one key out. From two on, Clear all takes every filter's key out in one move and leaves the rest of the address alone. filters.Active counts them, and the Filters button shows the count.

Control
What happens
Ui.Select, Ui.RadioGroupThe words of the option or radio it holds.
Ui.DatePickerWhat its button says: the day, the two days, or the preset's name.
Ui.InputWhat was typed, trimmed. Blank is no filter.
Ui.Checkbox, Ui.SwitchIts label alone, while it is on.
Ui.FilterIts Label and its Shown, while Shown is set.

A lazy panel

The fields are in a flyout that is a lazy Ui.Modal: closed, the page sends the dialog alone, and opening it is answered with one frame that carries the fields, so a reader who never filters is sent none of them. OnOpen runs as it opens, where a page loads the options a field offers.

Ui.Modal.Lazy() is a step of its own, for any modal that holds what most readers never open; Flux's modal has no such switch. It is off by default: a modal that must open with no server at all, such as Ui.ConfirmLeave or a ⌘K palette, has to hold its content before anyone asks.

Reference

Ui.Filters

not in Flux UI

A list's filters kept in the address: a button that opens them in a lazy flyout, and badges for those in force.

Step
Takes
What it does
.OnOpen(…)a handler, with no argumentRuns when the reader opens the panel: where a page loads what its fields offer.

Ui.Filter

not in Flux UI

A filter the set cannot read from a control: the page says what it is called, what it holds and how it is taken off.

Step
Takes
What it does
.Label(…)stringWhat it filters by: the badge's first word. Required, and taken first.
.Shown(…)stringWhat it holds, as the badge says it. Unset, it is not in force and has no badge.
.OnRemove(…)a handler, with no argumentRuns when its badge is taken off, or every filter is cleared.
Confirm