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.
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);
}What each driver delivered.
Order | Driver | Status | |
|---|---|---|---|
| #3102 | Ada Lovelace | Oct 21 | Open |
| #3101 | Grace Hopper | Oct 20 | Shipped |
| #3099 | Ada Lovelace | Oct 18 | Shipped |
| #3094 | Alan Turing | Oct 13 | Open |
| #3090 | Grace Hopper | Oct 1 | Returned |
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.
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)}."]
];
}
}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.RadioGroup | The words of the option or radio it holds. |
Ui.DatePicker | What its button says: the day, the two days, or the preset's name. |
Ui.Input | What was typed, trimmed. Blank is no filter. |
Ui.Checkbox, Ui.Switch | Its label alone, while it is on. |
Ui.Filter | Its 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
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 argument | Runs when the reader opens the panel: where a page loads what its fields offer. |
Ui.Filter
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(…) | string | What it filters by: the badge's first word. Required, and taken first. |
.Shown(…) | string | What it holds, as the badge says it. Unset, it is not in force and has no badge. |
.OnRemove(…) | a handler, with no argument | Runs when its badge is taken off, or every filter is cleared. |