Skip to content

Groups, custom layout & resilience

Three things a form needs once it stops being a demo. Fields that cluster into named <fieldset>s a screen reader announces; a layout the page owns, built from <BormField>; and a dropdown whose service is allowed to fall over without taking the page with it. Every field also has its own undo, because Reset() is far too blunt when the user changed their mind about one answer.

Grouped, rendered whole

[Display(GroupName = "…")] — or f.Group("…") — clusters a run of fields into one labelled fieldset.

Raise a ticket

Who you are
Where it belongs

Loaded once a team is chosen.

The same schema, the page's layout

Placed one field at a time, interleaved with the page's own markup. Labels, validation, conditions and the aria-* wiring all still come from the schema.

Routing

Loaded once a team is chosen.


No unsaved changes
View source
// 1. Named groups. A run of consecutive fields sharing a Group name becomes one <fieldset> with a
//    <legend>, so a screen reader announces "Who you are" as focus enters the first control in it.
//    [Display(GroupName = "Who you are")] on the model does the same thing.
var schema = BormBuilder.For<SupportTicket>()
    .Field(x => x.ReporterName,  f => f.Required().Group("Who you are"))
    .Field(x => x.ReporterEmail, f => f.AsEmail().Required().Group("Who you are"))
    .Field(x => x.Team,      f => f.Group("Where it belongs").Options(Teams))
    .Field(x => x.Component, f => f.Group("Where it belongs")
        // 2. A lookup that is allowed to fail. An options provider that throws used to take the
        //    rendering component down with it; now it is recorded, announced, and shown in place of
        //    the choices — and the next Invalidate retries.
        .OptionsFrom(async ctx => await components.ForTeamAsync(ctx.Value("Team") as string, ctx.CancellationToken),
                     value: c => c, label: c => c,
                     dependsOn: nameof(SupportTicket.Team)))
    .Field(x => x.RelatedTicket, f => f.AsSearch().Suggest("SUP-1201", "SUP-1233"))
    // A counter drops the maxlength attribute, so a pasted subject keeps every character and the
    // count turns red instead of the answer being silently truncated.
    .Field(x => x.Subject, f => f.Required().MaxLength(80).CharacterCount().UpdateOnInput())
    .Field(x => x.Details, f => f.AsTextArea(4))
    .Build();

var state = new BormState(schema, new BormModelDataAccessor(model));
state.OptionsLoadFailed += (path, ex) => logger.LogWarning(ex, "Options for {Path} failed", path);

// 3. The page owns the layout. BormField places one field wherever you want it while the
//    schema keeps the label, the rules, the conditions and the accessibility wiring. The theme's
//    custom properties live on .bit-borm-form, so the page's own wrapper carries the class.
<div class="bit-borm-form">
    <div style="display:grid;grid-template-columns:1fr 1fr;">
        <BormField State="state" Name="ReporterName" />
        <BormField State="state" Name="ReporterEmail" />
    </div>
    <BormField State="state" Name="Subject" />
</div>

// 4. Per-field undo, and dirtiness that is a comparison rather than a flag: typing a character and
//    deleting it again leaves the field clean, so this button switches itself off.
<button disabled="@(!state.IsDirty("Subject"))" @onclick='() => state.ResetField("Subject")'>Undo subject</button>
An unhandled error has occurred. Reload 🗙