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

Forms — nested models & control groups

Nested/complex models, radio & checkbox groups, and building your own form controls.

‹ Back to Forms & validation

Nested / complex models

Bind and validation extend transparently through sub-objects and collections: a nested field takes its .Validate(…) rule exactly as a top-level one does. FieldIdentifier is reference-based (keyed off the owner sub-instance, not a dotted path from the root), so removing or replacing a row drops its error state with it.

public sealed class CheckoutModel
{
    public string Name { get; set; } = "";
    public AddressModel Address { get; set; } = new();
    public List<LineItem> Items { get; set; } = new();
}
public sealed class AddressModel
{
    public string Street { get; set; } = "";
    public string Country { get; set; } = "";
}

Sub-object binding uses the same .Bind(() => …) shape:

Input.Bind(() => _model.Address.Street)
    .Validate(v => v.Length > 0 ? [] : ["Street is required."]),
Validation.Message.Template(errs => Div.Class("err")[errs[0]]).For(() => _model.Address.Street),

Attributes on a nested model are also supported. Where the models carry DataAnnotations — as the demos on this page do — the form's built-in pass covers the whole reachable graph, with no per-level opt-in and nothing declared.

Collection binding — foreach + per-item capture (the canonical pattern). Each iteration closes over a distinct item, so each row's lambda targets its own instance:

foreach (var item in _model.Items)
{
    rows.Add(Tr[
        Td[Input.Bind(() => item.Description)],
        Td[Input.Bind(() => item.Quantity)],
        Td[Button.Type(ButtonType.Button).OnClick(() => _model.Items.Remove(item))["×"]]
    ]);
}

Collection binding — indexer style when you need the row number, or for records that get replaced rather than mutated (() => model.Items[i].Name re-resolves the slot every render). Watch the classic for closure trap — copy the index into a per-iteration local:

for (var idx = 0; idx < _model.Items.Count; idx++)
{
    var i = idx;                                  // per-iteration capture, NOT idx
    rows.Add(Tr[
        Td[$"#{i + 1}"],
        Td[Input.Bind(() => _model.Items[i].Description)],
        Td[Input.Bind(() => _model.Items[i].Quantity)]
    ]);
}

foreach has no closure trap. Records with init-only properties can't be auto-bound through the setter — declare them { get; set; }, or use the indexer pattern with a manual handler that replaces the slot (_model.Items[i] = _model.Items[i] with { Field = newValue }).

FluentValidation nesting uses SetValidator(...) and RuleForEach(...).SetValidator(...); Rask routes the dotted error.PropertyName (Address.Street, Lines[0].Quantity) back to the runtime sub-instance so Validation.Message.Template(…).For(() => _model.Address.Street) reads the right slot.

Trimming. Validating a nested graph reflects over every reachable model type. Whatever preserves the root model's public properties ([DynamicallyAccessedMembers], a routed page, or a trimmer descriptor) must extend to every nested type.

The four patterns, live — sub-object binding, foreach and indexer collection binding, and FluentValidation nesting:

namespace Rask.Site.Features;

// Sub-object binding — sub-class instance owns its own validation state under a single
// the form's built-in validation, with nothing declared.
public sealed partial class NestedSubObjectDemo : Component
{
    private readonly CheckoutModel _model = new();
    private string? _submission;

    private static Component FieldError(IReadOnlyList<string> msgs) =>
        [.. msgs.Select((m, i) => Div.Key(i).Class("text-danger text-sm mt-1")[m])];

    protected override Component? Render() =>
    [
        Form.Model(_model).OnSubmit(m => _submission =
                $"Checked out as {m.Name} to {m.Address.Street}, {m.Address.City} ({m.Address.Country}).").Class("flex flex-col gap-3")[
            Div[
                Ui.Input.Bind(() => _model.Name).Label("Name").Id("nf-name").ShowValidation(false),
                Validation.Message.Template(FieldError).For(() => _model.Name)
            ],
            Div[
                Ui.Input.Bind(() => _model.Email).Label("Email")
                    .Id("nf-email")
                    .Type(InputType.Email).ShowValidation(false),
                Validation.Message.Template(FieldError).For(() => _model.Email)
            ],
            Fieldset.Class("border rounded p-3 mt-2")[
                Legend.Class("text-base font-semibold")["Shipping address"],
                Div.Class("flex flex-col gap-3")[
                    Div[
                        Ui.Input.Bind(() => _model.Address.Street).Label("Street")
                            .Id("nf-street").ShowValidation(false),
                        Validation.Message.Template(FieldError).For(() => _model.Address.Street)
                    ],
                    Div[
                        Ui.Input.Bind(() => _model.Address.City).Label("City")
                            .Id("nf-city").ShowValidation(false),
                        Validation.Message.Template(FieldError).For(() => _model.Address.City)
                    ],
                    Div[
                        Ui.Input.Bind(() => _model.Address.Country).Label("Country (ISO)")
                            .Id("nf-country")
                            .MaxLength(2).ShowValidation(false),
                        Validation.Message.Template(FieldError).For(() => _model.Address.Country)
                    ]
                ]
            ],
            Div[
                Ui.Button.Primary.Icon(Ui.IconName.CheckCircle).Submit.Id("nf-submit")["Place order"]
            ]
        ],
        _submission is null
            ? null
            : Ui.Callout.Success.Icon(Ui.IconName.CheckCircle).Class("mt-3").Id("nf-result").Role("status").Text(_submission)
    ];
}
Live result
Shipping address
namespace Rask.Site.Features;

// Collection binding via foreach-capture — the canonical pattern.
public sealed partial class NestedListForeachDemo : Component
{
    private readonly CartModel _model = new();
    private int _seq = 2;
    private string? _submission;

    public NestedListForeachDemo() =>
        _model.Items.Add(new LineItem { Description = "Coffee beans (250g)", Quantity = 2 });

    private static Component FieldError(IReadOnlyList<string> msgs) =>
        [.. msgs.Select((m, i) => Div.Key(i).Class("text-danger text-sm mt-1")[m])];

    protected override Component? Render()
    {
        var rows = new List<Component>();
        foreach (var item in _model.Items)
        {
            var captured = item; // foreach already captures per-iteration but make it loud.
            rows.Add(Ui.TableRow.Key(captured.Id)[
                Ui.TableCell[
                    Ui.Input.Bind(() => captured.Description).Label("Description").ShowValidation(false),
                    Validation.Message.Template(FieldError).For(() => captured.Description)
                ],
                Ui.TableCell.Style("width: 6rem;")[
                    Ui.Input.Bind(() => captured.Quantity).Label("Quantity").ShowValidation(false),
                    Validation.Message.Template(FieldError).For(() => captured.Quantity)
                ],
                Ui.TableCell.Style("width: 3rem;")[
                    Ui.Button.Red.Icon(Ui.IconName.XMark)
                        .AriaLabel("Remove item")
                        .OnClick(() => _model.Items.Remove(captured))
                ]
            ]);
        }

        return
        [
            Form.Model(_model).OnSubmit(m => _submission = $"Submitted {m.Items.Count} line item(s).").Class("flex flex-col gap-3")[
                Ui.Table[
                    Ui.TableColumns[Ui.TableColumn["Description"], Ui.TableColumn["Quantity"], Ui.TableColumn],
                    Ui.TableRows[rows]
                ],
                Div.Class("flex gap-2 flex-wrap items-center")[
                    Ui.Button.Icon(Ui.IconName.Plus)
                        .Id("nf-list-add")
                        .OnClick(() =>
                            _model.Items.Add(new LineItem { Description = $"New item #{_seq++}", Quantity = 1 }))["Add row"],
                    Ui.Button.Primary.Icon(Ui.IconName.CheckCircle).Submit.Id("nf-list-submit")["Submit"]
                ]
            ],
            _submission is null
                ? null
                : Ui.Callout.Success.Class("mt-3").Id("nf-list-result").Role("status").Text(_submission)
        ];
    }
}
Live result
Description
Quantity
namespace Rask.Site.Features;

// Collection binding via indexer — the for-loop variant. Useful when the row index matters
// (row numbers, reorder controls) or when items are records that get replaced rather than
// mutated. The `var i = idx;` per-iteration capture dodges the classic C# closure trap.
public sealed partial class NestedListIndexerDemo : Component
{
    private readonly InvoiceModel _model = new();
    private int _seq = 2;
    private string? _submission;

    public NestedListIndexerDemo() => _model.Skus.Add(new SkuRow { Code = "WIDGET-1", Price = 9.99m });

    private static Component FieldError(IReadOnlyList<string> msgs) =>
        [.. msgs.Select((m, i) => Div.Key(i).Class("text-danger text-sm mt-1")[m])];

    protected override Component? Render()
    {
        var rows = new List<Component>();
        for (var idx = 0; idx < _model.Skus.Count; idx++)
        {
            var i = idx; // Per-iteration capture — without this every lambda closes over Skus.Count.
            rows.Add(Ui.TableRow.Key(_model.Skus[i].Id)[
                Ui.TableCell.Class("text-zinc-500 dark:text-white/70 text-sm")[$"#{i + 1}"],
                Ui.TableCell[
                    Ui.Input.Bind(() => _model.Skus[i].Code).Label("SKU").ShowValidation(false),
                    Validation.Message.Template(FieldError).For(() => _model.Skus[i].Code)
                ],
                Ui.TableCell.Style("width: 7rem;")[
                    Ui.Input.Bind(() => _model.Skus[i].Price).Label("Price").ShowValidation(false),
                    Validation.Message.Template(FieldError).For(() => _model.Skus[i].Price)
                ],
                Ui.TableCell.Style("width: 5rem;")[
                    Ui.Button.Icon(Ui.IconName.ArrowUp)
                        .AriaLabel("Move up")
                        .Class("me-1")
                        .Disabled(i == 0)
                        .OnClick(() => (_model.Skus[i - 1], _model.Skus[i]) = (_model.Skus[i], _model.Skus[i - 1])),
                    Ui.Button.Red.Icon(Ui.IconName.XMark)
                        .AriaLabel("Remove SKU")
                        .OnClick(() => _model.Skus.RemoveAt(i))
                ]
            ]);
        }

        return
        [
            Form.Model(_model).OnSubmit(m => _submission =
                    $"Invoice with {m.Skus.Count} sku line(s) at total {m.Skus.Sum(s => s.Price):F2}").Class("flex flex-col gap-3")[
                Ui.Table[
                    Ui.TableColumns[Ui.TableColumn.Style("width: 3rem;")["#"], Ui.TableColumn["SKU"], Ui.TableColumn["Price"], Ui.TableColumn],
                    Ui.TableRows[rows]
                ],
                Div.Class("flex gap-2 flex-wrap items-center")[
                    Ui.Button.Icon(Ui.IconName.Plus)
                        .Id("nf-idx-add")
                        .OnClick(() => _model.Skus.Add(new SkuRow { Code = $"WIDGET-{_seq++}", Price = 1.00m }))["Add row"],
                    Ui.Button.Primary.Icon(Ui.IconName.CheckCircle).Submit.Id("nf-idx-submit")["Submit"]
                ]
            ],
            _submission is null
                ? null
                : Ui.Callout.Success.Class("mt-3").Id("nf-idx-result").Role("status").Text(_submission)
        ];
    }
}
Live result
#
SKU
Price
#1
using FluentValidation;

namespace Rask.Site.Features;

// FluentValidation with SetValidator + RuleForEach — one root validator covers the whole
// graph; Rask routes dotted property paths back to the runtime sub-instance.
public sealed partial class NestedFluentValidationDemo : Component
{
    private readonly NestedOrderModel _model = new();
    private int _seq = 2;
    private string? _submission;

    public NestedFluentValidationDemo() => _model.Lines.Add(new NestedOrderLine { Sku = "BOX-1", Quantity = 3 });

    private static Component FieldError(IReadOnlyList<string> msgs) =>
        [.. msgs.Select((m, i) => Div.Key(i).Class("text-danger text-sm mt-1")[m])];

    protected override Component? Render()
    {
        var rows = new List<Component>();
        foreach (var line in _model.Lines)
        {
            var captured = line;
            rows.Add(Ui.TableRow.Key(captured.Id)[
                Ui.TableCell[
                    Ui.Input.Bind(() => captured.Sku).Label("SKU").ShowValidation(false),
                    Validation.Message.Template(FieldError).For(() => captured.Sku)
                ],
                Ui.TableCell.Style("width: 6rem;")[
                    Ui.Input.Bind(() => captured.Quantity).Label("Quantity").ShowValidation(false),
                    Validation.Message.Template(FieldError).For(() => captured.Quantity)
                ],
                Ui.TableCell.Style("width: 3rem;")[
                    Ui.Button.Red.Icon(Ui.IconName.XMark)
                        .AriaLabel("Remove line")
                        .OnClick(() => _model.Lines.Remove(captured))
                ]
            ]);
        }

        return
        [
            Form.Model(_model).OnSubmit(m => _submission = $"Order routed: {m.CustomerName} → {m.Address.Street}, {m.Lines.Count} line(s)").Class("flex flex-col gap-3")[
                Div[
                    Ui.Input.Bind(() => _model.CustomerName).Label("Customer").Id("nf-fv-name").ShowValidation(false),
                    Validation.Message.Template(FieldError).For(() => _model.CustomerName)
                ],
                Fieldset.Class("border rounded p-3")[
                    Legend.Class("text-base font-semibold")["Address"],
                    Div.Class("flex flex-col gap-2")[
                        Div[
                            Ui.Input.Bind(() => _model.Address.Street).Label("Street").ShowValidation(false),
                            Validation.Message.Template(FieldError).For(() => _model.Address.Street)
                        ],
                        Div[
                            Ui.Input.Bind(() => _model.Address.City).Label("City").ShowValidation(false),
                            Validation.Message.Template(FieldError).For(() => _model.Address.City)
                        ]
                    ]
                ],
                Ui.Table.ContainerClass("mt-2")[
                    Ui.TableColumns[Ui.TableColumn["SKU"], Ui.TableColumn["Qty"], Ui.TableColumn],
                    Ui.TableRows[rows]
                ],
                Div.Class("flex gap-2 flex-wrap items-center")[
                    Ui.Button.Icon(Ui.IconName.Plus)
                        .Id("nf-fv-add")
                        .OnClick(() => _model.Lines.Add(new NestedOrderLine { Sku = $"BOX-{_seq++}", Quantity = 1 }))["Add line"],
                    Ui.Button.Primary.Icon(Ui.IconName.CheckCircle).Submit.Id("nf-fv-submit")["Place"]
                ]
            ],
            _submission is null
                ? null
                : Ui.Callout.Success.Class("mt-3").Id("nf-fv-result").Role("status").Text(_submission)
        ];
    }
}
Live result
Address
SKU
Qty

A nested graph with async validators and live totals rolling up from the rows:

using System.Globalization;
using System.Text.RegularExpressions;

namespace Rask.Site.Features;

public sealed partial class NestedAsyncWithLiveTotalsDemo : Component
{
    // Layers two things on top of the basic nested-binding showcase:
    //   * Async inline Validate: on a nested field (Address.PostalCode). The field is Live, so the
    //     lookup runs as the code is typed and its kit field shows "Checking…" meanwhile — proves the
    //     latest-wins cancellation + pending-indicator path works for sub-objects, not just root fields.
    //   * Live derived UI: the order totals are computed inside Render() from the current model
    //     state. The promo code, the quantities and the prices are Live, so each is written as it is
    //     typed and the figures follow. The names wait for Pay, as a bound field does unless it says
    //     otherwise. No StateHasChanged calls needed — the dispatcher handles it.
    private static readonly HashSet<string> UndeliverableZips =
        new(StringComparer.Ordinal) { "00000", "99999" };

    private static readonly Dictionary<string, decimal> PromoCodes =
        new(StringComparer.OrdinalIgnoreCase) { ["SAVE10"] = 0.10m, ["SAVE25"] = 0.25m };

    private readonly StorefrontModel _model = new()
    {
        CustomerName = "",
        Address = new StorefrontAddress { PostalCode = "" },
        Items =
        {
            new StorefrontLineItem { Name = "Widget", Quantity = 1, UnitPrice = 9.99m },
            new StorefrontLineItem { Name = "Gadget", Quantity = 2, UnitPrice = 14.99m }
        },
        DiscountCode = ""
    };

    private string? _submission;

    private static Component FieldError(IReadOnlyList<string> msgs) =>
        [.. msgs.Select((m, i) => Div.Key(i).Class("text-danger text-sm mt-1")[m])];

    private static async ValueTask<IEnumerable<string>> ValidatePostalAsync(string code)
    {
        if (string.IsNullOrWhiteSpace(code))
        {
            return new[] { "Postal code is required." };
        }

        if (!FiveDigits().IsMatch(code))
        {
            return new[] { "Postal code must be 5 digits." };
        }

        // Fake reverse-geocode lookup — the 300ms delay is what drives latest-wins cancellation
        // when the user keeps typing past a partial match. ConfigureAwait(false) is required:
        // the inline async-validator path runs inside HandlerSyncContext, and a captured
        // continuation here would race the outer InvokeWithRenderingAsync mid-await render
        // (concurrent WebSocket.SendAsync calls deadlock on the same socket).
        await Task.Delay(300, Current.Cancellation).ConfigureAwait(false);
        return UndeliverableZips.Contains(code)
            ? new[] { "We don't ship to this area." }
            : Array.Empty<string>();
    }

    protected override Component? Render()
    {
        // Live derived state — recomputed on every render. The dispatcher re-renders this
        // component after each event handler completes, so the figures stay in sync with the
        // model without any explicit subscription.
        var subtotal = _model.Items.Sum(i => i.Quantity * i.UnitPrice);
        var discountPct = PromoCodes.TryGetValue(_model.DiscountCode ?? "", out var p) ? p : 0m;
        var discount = Math.Round(subtotal * discountPct, 2);
        var afterDiscount = subtotal - discount;
        var tax = Math.Round(afterDiscount * 0.08m, 2);
        var total = afterDiscount + tax;

        return
        [
            Form.Model(_model)
                .OnSubmit(m =>
                    _submission = $"Charged ${total.ToString("F2", CultureInfo.InvariantCulture)} to {m.CustomerName}")
                .Class("flex flex-col gap-3")[
                Div[
                    Ui.Input.Bind(() => _model.CustomerName).Label("Customer name")
                        .Id("v-nlive-name")
                        .Validate(v =>
                            string.IsNullOrWhiteSpace(v)
                                ? ["Name is required."]
                                : []).ShowValidation(false),
                    Validation.Message.Template(FieldError).For(() => _model.CustomerName)
                ],
                Ui.Input.Bind(() => _model.Address.PostalCode)
                    .Live()
                    .Label("Postal code")
                    .Description("Try 12345, 99999, or any 5-digit code.")
                    .Id("v-nlive-postal")
                    .Validate(ValidatePostalAsync),
                ItemsPanel(),
                Div[
                    Ui.Input.Bind(() => _model.DiscountCode)
                        .Live()
                        .Label("Promo code")
                        .Description("Try SAVE10 or SAVE25.")
                        .Id("v-nlive-promo")
                ],
                Totals(subtotal, discountPct, discount, tax, total),
                Div[
                    Ui.Button.Primary.Icon(Ui.IconName.CreditCard).Submit["Pay"]
                ]
            ],
            _submission is null
                ? null
                : Ui.Callout.Success.Icon(Ui.IconName.CheckCircle).Class("mt-3").Id("v-nlive-submission").Role("status").Text(_submission)
        ];
    }

    private Component ItemsPanel() =>
        Div.Class("border rounded p-3")[
            Div.Class("font-semibold text-sm mb-2")["Items"],
            Div.Class("grid grid-cols-12 gap-4 mb-2 items-center")[
                Div.Class("col-span-6")[
                    Ui.Input.Bind(() => _model.Items[0].Name)
                        .Label("Item 1 name")
                        .Id("v-nlive-item0-name")
                ],
                Div.Class("col-span-3")[
                    Ui.Input.Bind(() => _model.Items[0].Quantity)
                        .Live()
                        .Label("Item 1 quantity")
                        .Id("v-nlive-item0-qty")
                        .Min("0")
                ],
                Div.Class("col-span-3")[
                    Ui.Input.Bind(() => _model.Items[0].UnitPrice)
                        .Live()
                        .Label("Item 1 unit price")
                        .Id("v-nlive-item0-price")
                        .Step("0.01")
                ]
            ],
            Div.Class("grid grid-cols-12 gap-4 items-center")[
                Div.Class("col-span-6")[
                    Ui.Input.Bind(() => _model.Items[1].Name)
                        .Label("Item 2 name")
                        .Id("v-nlive-item1-name")
                ],
                Div.Class("col-span-3")[
                    Ui.Input.Bind(() => _model.Items[1].Quantity)
                        .Live()
                        .Label("Item 2 quantity")
                        .Id("v-nlive-item1-qty")
                        .Min("0")
                ],
                Div.Class("col-span-3")[
                    Ui.Input.Bind(() => _model.Items[1].UnitPrice)
                        .Live()
                        .Label("Item 2 unit price")
                        .Id("v-nlive-item1-price")
                        .Step("0.01")
                ]
            ]
        ];

    private static Component Totals(decimal subtotal, decimal discountPct, decimal discount, decimal tax, decimal total) =>
        Div.Id("v-nlive-totals").Class("bg-zinc-50 dark:bg-zinc-900 rounded p-3 text-sm")[
            Div.Class("flex justify-between flex-wrap items-center")[
                Span["Subtotal"],
                Span.Id("v-nlive-subtotal")[$"${subtotal.ToString("F2", CultureInfo.InvariantCulture)}"]
            ],
            Div.Class("flex justify-between flex-wrap items-center")[
                Span[discountPct > 0m
                    ? $"Discount ({(int)(discountPct * 100)}%)"
                    : "Discount"],
                Span.Id("v-nlive-discount")[$"-${discount.ToString("F2", CultureInfo.InvariantCulture)}"]
            ],
            Div.Class("flex justify-between flex-wrap items-center")[
                Span["Tax (8%)"],
                Span.Id("v-nlive-tax")[$"${tax.ToString("F2", CultureInfo.InvariantCulture)}"]
            ],
            Hr.Class("my-2"),
            Div.Class("flex justify-between flex-wrap items-center font-bold")[
                Span["Total"],
                Span.Id("v-nlive-total")[$"${total.ToString("F2", CultureInfo.InvariantCulture)}"]
            ]
        ];

    [GeneratedRegex(@"^\d{5}$", RegexOptions.None, matchTimeoutMilliseconds: 1000)]
    private static partial Regex FiveDigits();
}
Live result
Try 12345, 99999, or any 5-digit code.
Items
Try SAVE10 or SAVE25.
Subtotal$39.97
Discount-$0.00
Tax (8%)$3.20

Total$43.17

Radio & checkbox groups

Ui.RadioGroup binds one value; Ui.CheckboxGroup binds an ICollection<T>. Both are Flux UI's, and both are ordinary IFormControl<T> controls — the binding API of §9 — so they work bound or controlled, exactly like Input, and you can build a group of your own the same way (building-form-controls.md). The choices are the group's children: a Ui.Radio or a Ui.Checkbox whose Value is what the group binds.

// Bound — two-way binds the model, with an optional per-field Validate rule.
Form.Model(_prefs)[
    Ui.RadioGroup.Bind(() => _prefs.Plan).Label("Plan")[                     // single value
        Ui.Radio.Value(Plan.Free).Label("Free"),
        Ui.Radio.Value(Plan.Pro).Label("Pro"),
        Ui.Radio.Value(Plan.Team).Label("Team")
    ],

    Ui.CheckboxGroup.Bind(() => _prefs.Interests).Label("Interests")         // a collection
        .Validate(tags => tags.Count >= 1 ? [] : ["Pick at least one."])[
        Ui.Checkbox.Value("web").Label("Web"),
        Ui.Checkbox.Value("mobile").Label("Mobile"),
        Ui.Checkbox.Value("ai").Label("AI")
    ]
]

// Controlled — the parent owns the value; OnChange (auto-wrapped) re-renders it.
Ui.RadioGroup.Value(_plan).Label("Plan").OnChange(v => _plan = v)[ … ]
Ui.CheckboxGroup.Values(_interests).Label("Interests").OnChange(next => _interests = next)[ … ]
  • Bound mode opens with Bind; Validate takes a synchronous or an asynchronous rule, like Input (§9). Ui.RadioGroup renders the radio whose Value equals the current value checked and sets the bound property on select; Ui.CheckboxGroup writes the bound collection, in the order the checkboxes are written (membership by EqualityComparer<T>.Default). Each change calls NotifyFieldChanged + NotifyFieldTouched + ValidateField, so DataAnnotations / FluentValidation rules apply.
  • A choice takes Label, Description, Disabled and (in a card, a segment or a button) Icon. The group's variant picks the look — .Cards, .Pills, .Buttons, and .Segmented on a radio group — while keeping a real <input type="radio">/<input type="checkbox"> inside each label, so the arrow keys, the space bar and the form post are the browser's. Ui.CheckboxAll among the checkboxes ticks or clears them all, and shows a dash while only some are ticked.
  • Give the group a Label: it names the group's container for a screen reader (aria-labelledby). Without a Name, the radios share the group's own id as their name, so two groups on one page are never merged into a single browser radio group.
  • They are Components (their own re-render boundary), so a toggle re-renders the control itself; for host-side derived UI (a live summary) use controlled mode — the auto-wrapped OnChange re-renders the host. (In bound mode, feedback lives inside the control via the embedded Validation.Message.)
  • Reading validation state in a custom control just works. If you bake feedback straight into your own Render() — reading EditContext.GetValidationMessages(field) / GetValidationEntries() / ShouldShowValidatingIndicator(field) — the framework detects the read and opts that control out of its render cache automatically, so a message produced later in the submit pipeline always repaints. No StateHasChanged(), no BypassRenderCache override (the same auto-opt-out Context.Get consumers get).

Ui.RadioGroup (single value) and Ui.CheckboxGroup (a collection), live:

A drawn single-select. UiSelect<T> binds one T, takes its options as children (Ui.SelectOption) and renders the platform's <select> by default; .Listbox draws the list itself instead — a native popover role="listbox" under a role="combobox" button, with the arrow keys, Enter, type-ahead and an aria-activedescendant cursor that skips unavailable options — and .Combobox makes the box a text input that filters it. .Listbox is what a form normally writes — .Searchable() for a long list — and an option of it can carry more than words (an icon, a description, an avatar). The drawn list needs the runtime; the native one does not.

A plain <select multiple> bound to a collection. Select.Bind(() => …).Multiple() binds the whole selection when T is a string collection — string[], List<string>, HashSet<string>, or the IReadOnlyList<string> / IList<string> / ICollection<string> / IEnumerable<string> interfaces:

Select.Bind(() => model.Tags).Multiple()[
    Option.Value("news")["News"], Option.Value("sport")["Sport"], Option.Value("weather")["Weather"]
]

Every picked option is marked on render, and each change replaces the collection rather than editing its membership — the browser reports the absolute selection every time, so a replace re-syncs the model even if an intermediate render was coalesced.

Two limits worth knowing:

  • The element type is string. The reflective version that would accept any parsable element needs MakeGenericType and Array.CreateInstance, both of which are AOT-hostile — and src/Rask.Site has to publish with zero trim warnings. Bind string[] and convert.
  • .Multiple() over a scalar property keeps the single-value binding. That is a model which can only hold one answer; widening it silently would be the more surprising behaviour.

Taking the picked values yourself. OnSelect (a synchronous or an asynchronous handler) hands over the raw option values the user picked, as IReadOnlyList<string> — the whole selection every time, never a delta:

Select.Of<string>().Multiple().OnSelect(picked => _chosen = Map(picked))[
    Option.Value("news")["News"], Option.Value("sport")["Sport"], Option.Value("weather")["Weather"]
]

It is the way past the string-element limit above: a control that rendered its own options already knows how to turn those values back into its own type, so it needs none of the binding machinery that limit belongs to. The kit's collection-bound select (UiSelectMultiple<T>) is generic over any T — an int, an enum, a Guid — for the same reason: it draws its own options and maps a pick back itself, while this control cannot. Controlled mode only, and it takes precedence over OnChange: both write the one data-rask-on-change attribute, so a control cannot have two.

The kit has a control for this. Everything above is the raw <select>. For a field a person fills in, Ui.Select opened on a collection with .Listbox.Multiple() is the one to reach for — a search field (Searchable()), a keyboard, a button that names or counts what is picked, and a drawn list that stays open while you pick.

Surviving a redeploy

If the server is replaced while someone is filling a form in, the page may have to reload — and the fields they had edited are put back. It is a three-way merge, so a field is only re-applied when the replacement server rendered the same value the old one had: if its state changed in the meantime, the server wins and the stale edit is dropped. Whatever is restored is pushed back over the socket, so the model matches what the page shows. Shutdown and redeploy has the full rules.

Two things to know when building a form:

  • A field needs an id or a name. A bound Input gets a name from the bound property for free, so this is usually nothing to think about — but a key that matches more than one control on the page is skipped rather than guessed at, and a control with neither is never restored.
  • data-rask-no-restore opts out a field, or every field under it:
Div.Data("rask-no-restore")[
    Input.Bind(() => _model.CouponCode).Class("w-full rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-900 placeholder:text-slate-400 focus:border-violet-500 focus:outline-none dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100")   // never carried across a reload
]

Passwords, file, hidden and one-time-code inputs, and anything with a cc-* / current-password / new-password autocomplete, are excluded unconditionally — they never reach sessionStorage at all. <select> isn't restored yet.

Building your own form controls

The binding system is public: a custom control implementing IFormControl<T> gets generator-synthesized bound + controlled chains, per-field validation, and the same ergonomics as the built-ins — see the dedicated guide building-form-controls.md (with a complete worked example and the IFormControl<T> helper reference). Ui.RadioGroup/Ui.CheckboxGroup (§8) and Ui.Select are built entirely on it.

Confirm