A schema-driven form engine for Blazor. Describe a form once - from a C# type, with a fluent builder or as a JSON Schema document - and Borm renders it, validates it and keeps its state: conditional visibility, requiredness and read-only fields, computed values, cascading options, multi-step wizards and array/repeater fields included. Every decision is made in C#; the renderers are plain, accessible HTML with a pluggable renderer registry, and there is no JavaScript at all.
<BormView Definition="_definition" Model="_model" OnValidSubmit="Save" />
@code {
private readonly Contact _model = new();
private readonly BormDefinition _definition = BormBuilder.For<Contact>()
.Title("Contact us")
.Field(x => x.Name, f => f.Required().Placeholder("Your name"))
.Field(x => x.Email, f => f.AsEmail().Required().Trim())
.Field(x => x.Company, f => f.VisibleWhen(nameof(Contact.IsBusiness), BormConditionOperator.IsTrue))
.Build();
private Task Save(BormState state) => Task.CompletedTask; // _model is populated and valid here
}Sign-up, checkout and settings pages, admin and back-office CRUD screens, surveys and questionnaires, onboarding wizards, configuration editors driven by a JSON Schema the server owns, and any other screen where the shape of a form is better described as data than written out by hand field by field.
The schema is plain C#: a BormDefinition holding BormFieldDefinitions, which can be generated, built, imported,
checked, cloned, tailored and exported with no UI at all. The runtime state, BormState, is just as independent of
the page: it reads and writes your model (or a dictionary), runs the rules, evaluates the conditions and walks the
wizard, which is what makes a form testable without a browser.
- Three ways in: generate a schema from a model's properties and DataAnnotations, build one with a strongly
typed fluent API (
x => x.Address.City, refactor-safe), or import a JSON Schema document (draft-07 to 2020-12, with$ref,allOf,if/then/else,dependentRequired,dependentSchemasand discriminatedoneOf/anyOfsubforms) - and export any form back out again. - Rich field types: text, search, password, email, URL, number, select, combobox, multi-select, radio,
checkbox and switch, date, time, date-time, month and week, range, colour, file upload, tags, formatted text
(Markdown with a safe preview), a signature pad with a typed alternative, static headings, nested objects and
repeating arrays - and input masks (
(000) 000-0000) on any text field. - Uploads that start when a file is chosen: progress, cancel and retry beside each file, the files judged before they are sent, what the server returned stored in their place, and a submit made meanwhile that waits for them.
- Validation: required, length, range (numbers, dates and times), pattern, email, URL, UUID, compare,
multiple-of, unique items, collection size, file size and type, custom sync and async rules, form-level rules,
conditional rules, DataAnnotations'
IValidatableObject, and FluentValidation throughBit.Borm.FluentValidation. - When it runs: on submit, on blur or on change, separately before and after the first submit; dependent fields revalidate when the field they read changes; async rules are debounced, superseded and cancelled.
- Conditional behaviour: show, hide, disable, lock (read-only) or require fields and wizard steps based on other values - per row inside a repeater - and clear the values of branches the user abandoned.
- Data that follows the form: computed values, async and cascading options, lookups that search a service as
the user types, change handlers, value normalisation (
Trim(),Normalize(...)), dirty and touched tracking, snapshots and resets. - Expressions: totals, conditions and rules written as text (
{Quantity} * {UnitPrice},{Total} >= 10000) - exported to JSON Schema and back, so a form a server sends can compute and judge with no code behind it, and evaluated by a bounded interpreter that can read the answers and nothing else. - Steps: a wizard with per-step validation, conditional steps and a clickable stepper - or the same steps as tabs, an accordion or titled sections - and a review that reads every answer back in words.
- Long forms: undo and redo of every change, typing coalesced into one step; a repeater of hundreds of rows shown a page at a time, with rows needing attention on other pages pointed out and focus taken to them.
- No interactivity needed: a named form posts like any HTML form from a page rendered on the server alone, judged there exactly as an interactive one would be.
- A designer:
BormDesignerbuilds a form by clicking - its fields, their rules and conditions, a live preview and the JSON Schema it comes to - out of the same builder an application uses. - Unfinished work kept: versioned drafts without the sensitive answers, autosave, and a guard that asks before
the user leaves unsaved changes; a submit that says how it ended (invalid, rejected, failed, cancelled or
succeeded) and can be cancelled, with a server's
ProblemDetailserrors placed on their fields. - Accessible by default: real labels,
aria-required/aria-invalid/aria-describedby, grouped radio and multi-select controls, focus management after a failed submit, live announcements and an error summary that links to each field. - Yours to restyle and extend: every colour is a
--bit-borm-*custom property with light and dark themes, every built-in string goes throughIBormMessageProvider- and comes in twelve languages besides English withAddBormTranslations()- the schema's own text throughIBormSchemaLocalizeror the translations it carries, any control can be replaced through the renderer registry, and Blazor's ownInputBasecontrols andValidationSummarywork inside a form through theEditContextit can provide.
dotnet add package Bit.Borm@* _Imports.razor *@
@using Bit.Borm// Program.cs
builder.Services.AddBorm();<!-- App.razor or index.html -->
<link rel="stylesheet" href="_content/Bit.Borm/bit-borm.css" />Then render a BormView with a Definition and a Model, as above. The full walkthrough is in
getting started.
- .NET 10 or .NET 11
- Blazor WebAssembly, Blazor Server, Blazor Web App or Blazor Hybrid
FluentValidation12 only when the optionalBit.Borm.FluentValidationpackage is used
The demo site, borm.bitplatform.dev, opens on an overview with a live form, and its
documentation - starting at /getting-started - is a set of working forms, one per topic of this guide:
/quickstart (a schema from a C# type), /builder (the fluent builder), /conditions (every kind of condition),
/json-schema (a live JSON Schema editor with round-trip export and the schema's checks), /designer (the visual
designer), /field-types (every built-in control), /layout, /field-polish, /combobox-tags, /search-lookup,
/files, /rich-fields (masks, Markdown, signatures and uploads), /composition, /custom-renderer,
/blazor-inputs (Blazor's own inputs through the provided EditContext), /theming (the tokens, colour modes and
accessibility), /localization, /static-form (a form with no interactivity), /array (repeaters and computed
totals), /per-row-conditions, /wizard, /tabs-review (tabs, accordion, sections and a review step),
/undo-long-lists, /validation, /dependent-fields, /settled-answers, /data-annotations, /fluent-validation,
/cascading, /state-api, /saving (how a submit ends, and a server's errors), /expressions (with a console that
evaluates any expression), /drafts (autosave and the unsaved-changes guard), /api-reference (BormView and
BormState at a glance) and /mcp-server. Every page can show its own source, and Ctrl+K (or /) searches them
all. The site re-tints Borm's --bit-borm-* tokens to its own light and dark theme, which is what
Theming describes.
To run it locally:
dotnet run --project src/Utilities/Borm/Demo/ServerSamples is the hosting harness rather than the docs: one shared Core project of pages
(a form generated from a C# type, the fluent builder with conditions, an async rule and a custom renderer, a wizard
with a conditional step and a review, a JSON Schema form with a repeater and computed totals, and a form that posts
with no interactivity), hosted unchanged under Server, WASM and
Auto so the same forms run, prerendered first, in each render mode - and under MAUI
(Android, iOS, Mac Catalyst and Windows), where a BlazorWebView runs them as a Blazor Hybrid app - all but the form
that posts, which has no server to post to there. Each host is also a
minimal example of the wiring its platform needs: AddBorm() in every container that renders the pages - called
through AddCoreServices() in each host's Program.cs (MauiProgram.cs in the MAUI app) - and the
_content/Bit.Borm/bit-borm.css link in its App.razor (wwwroot/index.html in the MAUI app).
BormSchemaGenerator a C# type and its DataAnnotations \
BormBuilder<T> a fluent, expression-based builder >-- BormDefinition the schema: fields, rules, steps
BormJsonSchemaImporter a JSON Schema document / |
|
BormState values (through IBormDataAccessor), validation, conditions, computed values,
options, dirty/touched tracking, the wizard's position
|
BormView / BormField render it through IBormFieldRendererRegistry: BormTextInput, BormSelectInput, ...
or your own components
Two packages ship it:
| Package | Contents |
|---|---|
Bit.Borm |
Everything: the schema model, the builders, the generator, JSON Schema import and export, the state, the rules, the components and the stylesheet. No dependencies beyond Blazor itself. |
Bit.Borm.FluentValidation |
UseFluentValidation(), which runs a model's FluentValidation validator alongside the schema's own rules. |
All public types live in the single Bit.Borm namespace and are prefixed with Borm, so one @using Bit.Borm
brings everything into scope.
The demo hosts a Model Context Protocol server at
https://borm.bitplatform.dev/mcp, so an AI agent writing form code reads the library itself - its API, this guide
and the demo's working forms - instead of guessing at an API it half remembers.
{
"mcpServers": {
"borm": { "type": "http", "url": "https://borm.bitplatform.dev/mcp" }
}
}Start with SearchBorm, which searches the guide, every public type and member, the setup guides, the stylesheet's
theme properties and classes, and the demo's pages at once, and points each hit at the exact follow-up call.
GetBormSetupGuide gives the wiring for one hosting model, GetBormGuideSection one section of this guide,
GetBormApi one public type with its members, real defaults and documentation, GetBormTheme the --bit-borm-*
custom properties with their light and dark values, and GetBormSourceFile one of the demo's working pages; each
returns its index when called without a key. InspectBormSchema runs a JSON Schema document through the library's
own importer and schema checks and reports the form it becomes - every field with its type, rules and conditions,
the diagnostics, and the document Borm exports back - or, called with no schema, returns a sample one. Every tool is
read-only.
- Getting started
- Generating a schema from a C# type
- The fluent builder
- JSON Schema
- Validation
- Conditional fields and wizards
- Async and cascading options
- Computed values
- Expressions
- Combobox and tags
- Change handlers
- Drafts and autosave
- Layout
- Field presentation
- Checking a schema
- Custom renderers
- The BormView component
- BormState at a glance
- Theming
- Accessibility
- Localization
- Hosting models
- Key types
dotnet add package Bit.BormAdd the namespace to _Imports.razor:
@using Bit.BormRegister the rendering services - the field renderer registry - in every container that renders a form. In a Blazor Web App that is both the server and the WebAssembly client, since every page is prerendered on the server first:
builder.Services.AddBorm();Link the stylesheet from your host page (App.razor or index.html); it is not injected for you:
<link rel="stylesheet" href="_content/Bit.Borm/bit-borm.css" />Without it the form still works, as plain HTML: the stylesheet only adds the theme, the grid and the error colours.
Annotate a plain model and let the schema generator do the work:
using System.ComponentModel.DataAnnotations;
public class Contact
{
[Required, MaxLength(80)]
public string Name { get; set; } = "";
[Required, EmailAddress]
public string Email { get; set; } = "";
[Range(18, 120)]
public int Age { get; set; }
}<BormView Definition="_definition"
Model="_model"
OnValidSubmit="HandleValidSubmit" />
@code {
private readonly BormDefinition _definition = BormSchemaGenerator.Generate<Contact>();
private readonly Contact _model = new();
private Task HandleValidSubmit(BormState state)
{
// _model is populated and valid here
return Task.CompletedTask;
}
}BormView builds a BormState for the definition and the model, renders every field, and calls
OnValidSubmit once the submit passes validation. Supply a State instead when the page wants to hold the state
itself - to wire up a validator, read its dirty fields or drive it from code.
Every DOM id the form renders is derived from a field's path and starts with borm_. Two forms on one page that
share field names would share ids - and a label or an error-summary link in one would point into the other - so
give each its own prefix with IdPrefix on BormView (or state.IdPrefix).
BormSchemaGenerator.Generate<T>() reads a model's public properties and their DataAnnotations. Attributes keep the
meaning DataAnnotations gives them, so the form never accepts what the model's own validation would then reject:
[RegularExpression]must match the whole value (the pattern is anchored as^(?:…)$).[Range]honoursMinimumIsExclusive/MaximumIsExclusive, and text bounds are read in the current culture unlessParseLimitsInInvariantCultureis set.[MinLength],[MaxLength]and[Length]count items on any collection - a repeater, a multi-select overList<TEnum>, a list of files - and characters on text.[Url]acceptshttp,httpsandftp, asUrlAttributedoes.[FileExtensions]on astringchecks the file name it holds; on anIBrowserFileit becomes the picker'sacceptlist.[Display(GroupName = …)]puts consecutive fields in one<fieldset>;[Display(Name, Description, Prompt, Order)]become the label, the help text, the placeholder and the order.
Only what a form can actually edit is generated. A value that is replaced when it is edited - a scalar, a struct, an
array - needs a public setter (a private/internal one is rendered read-only, and the accessor refuses writes
through it). A class-typed group or list is edited in place, so Shipping { get; private set; } = new() and
Lines { get; private set; } = new() stay fully editable. byte[] / Stream properties are left out - a file
input produces an IBrowserFile, so declare one of those and read it in your submit handler - and the typed builder
refuses one unless you give it a CustomRenderer. Nested structs are editable like classes.
var definition = BormBuilder.For<Contact>()
.Title("Contact us")
.Field(x => x.Name, f => f.Required().Placeholder("Your name"))
.Field(x => x.Email, f => f.AsEmail().Required())
.Field(x => x.Age, f => f.Range(18, 120))
.Build();Constraints replace, rather than stack on top of, the rules generated from DataAnnotations, so restating
[Required] in the builder to give it a better message produces one error, not two.
Nested members, object groups and arrays are all expression-based:
BormBuilder.For<Order>()
.Field(x => x.Customer.Email, f => f.Required()) // binds to "Customer.Email"
.Object(x => x.ShippingAddress, a => a
.Field(p => p.Street)
.Field(p => p.City))
.Array(x => x.Lines, i => i
.Field(l => l.Product, f => f.Required())
.Field(l => l.Quantity, f => f.Range(1, 999)))
.Build();The untyped builder is handy when you have no compiled model; the form then binds to a dictionary:
var definition = BormBuilder.Create()
.Title("Feedback")
.Text("subject", f => f.Required())
.Field("body", BormFieldType.TextArea, f => f.AsTextArea(rows: 6))
.Select("topic", f => f.Options(("bug", "Bug"), ("idea", "Idea")))
.ArrayOf("tags", BormFieldType.Text, f => f.Items(max: 5).UniqueItems())
.Build();<BormView Definition="definition" Data="_data" OnValidSubmit="Send" />
@code {
private readonly Dictionary<string, object?> _data = new();
}var definition = BormJsonSchemaImporter.Import(jsonSchemaString);The importer covers draft-07 through 2020-12: type (including nullable unions such as ["string","null"]),
boolean subschemas (true accepts anything; a false property is left out), properties, required, enum,
const, format, default, examples, readOnly, minimum/maximum and their exclusive forms (draft-04's
boolean exclusiveMinimum too, as OpenAPI 3.0 writes it), multipleOf, minLength/maxLength, pattern,
items, minItems/maxItems, uniqueItems, $ref into $defs/definitions, allOf (merged),
if/then/else and dependentRequired/dependencies - the last two mapped onto conditional requiredness.
A few of those deserve a sentence each:
$refkeeps the keywords written beside it, and they win over the definition's own - that is how{"$ref":"#/$defs/Address","title":"Billing address"}labels a shared shape. A recursive reference (a category with sub-categories) describes an unbounded tree, which no form can render, so it is followed one level - a node and its children - and the property that would repeat it again is left out; a repeater whose rows refer back that far gets a text box per row instead. Recursion spelled throughallOf(pydantic v1 and Swashbuckle wrap described references that way) is recognised the same way, and a definition that both extends and contains another is not mistaken for recursion.allOfmeans every branch applies. Annotations (title,description,x-hints) cannot all apply, so the later one wins and the outer schema has the last word; constraints combine -requiredanddependentRequiredare unioned, a property (or an array'sitems) described by two branches keeps both descriptions, the stricter of two bounds wins, twopatterns are both enforced, twoenums keep only the values both allow, and each branch'sif/then/elseis applied on its own.type,format,constandmultipleOftake the later branch's word.if/then/elsebecomes a condition only when theifpins properties to aconstor anenum(optionally with arequiredlist, read as "once this is filled in"). Anifwith anything else in it is ignored as a whole rather than half-applied.- Exclusive bounds stay exclusive:
"exclusiveMinimum": 0is "greater than 0", not "at least 0.000000001", and it exports asexclusiveMinimumagain. - Typed choices keep their type: an
enumof integers, numbers or booleans gives the field aValueType, so choosing "2" stores the number 2 and the export says"type":"integer". formatadds the matching rule as well as the input type -email,urianduuid(which is enforced alongside anypatternthe document also gives).
An array with no single item schema - no items at all, or the tuple form - still renders: each row is a text box.
An array of a closed set of choices that may not repeat ({"type":"array","uniqueItems":true,"items":{"enum":[…]}})
imports as a multi-select rather than a repeater of dropdowns - the same rule react-jsonschema-form uses. Without
uniqueItems the document is explicitly allowing the same value twice, which only a repeater can express, so it
stays one. A multi-select exports in that same standard shape - its choices on items, where every JSON Schema
validator looks for them - and the older top-level enum spelling still imports.
anyOf/oneOf is handled for the two shapes that describe a single control: a null union
({"anyOf":[{"type":"string"},{"type":"null"}]}) collapses to the branch it wraps, and a list of const branches
with titles (with or without a {"type":"null"} branch, and with or without $refs) becomes a labelled select.
A union of object shapes describes a choice of subforms, and becomes one when the data says which branch an
answer is - when a property tells them apart. That property is OpenAPI's discriminator.propertyName when the
schema names one (its values from each branch's const, the mapping, or the definition's name, which is what
Swashbuckle and NSwag write for an allOf hierarchy), and otherwise the first property every branch pins to a
const of its own. It becomes a required choice labelled by the branches' titles; every other property a branch
adds shows only while that branch is chosen, is cleared when it stops being, and is required while its branch
requires it; properties every branch shares always show. A union with nothing in the data to tell its branches
apart is still left alone rather than bound to a branch the document never committed to.
dependentSchemas (and draft-07's schema-valued dependencies) adds fields that show once the property they hang
off is answered, and makes properties already there required then. The react-jsonschema-form idiom - a dependency
whose oneOf branches each pin the answer to one of its values - asks a different set of questions per answer.
The export writes the form these become (a choice and conditional fields), not the original union.
Use TryImport when the schema comes from a user. Besides malformed JSON, it reports a document that is not a
schema, and one that goes past a budget no real form comes near: 10,000 fields, objects and arrays nested 32 levels
deep, allOf nested 64 levels deep, 80,000 allOf/$ref merges, 250,000 choices and suggestions (10,000,000
characters of them) read across its fields, fields built from more than 10,000,000 bytes of schema, or more than
20,000,000 bytes of it copied by those merges — a definition every field refers to counts once for each of them. An
anyOf/oneOf list is worked out once, however many fields refer to it. A property name with a ., [ or ] in
it is refused too, since a field name is one step of a path. A few hundred bytes of $refs can describe millions of
fields, and those documents are refused rather than built:
if (BormJsonSchemaImporter.TryImport(json, out var definition, out var error))
Render(definition);
else
ShowError(error);UI intent that JSON Schema has no vocabulary for travels in x- extensions, and all of it round-trips through
BormJsonSchemaExporter.Export(definition):
| Extension | Meaning |
|---|---|
x-widget |
Force a control: select, textarea, radio, combobox, multiselect, tags, range, switch, file, static, hidden, array, month, week, markdown, signature, … A document that names its control is taken at its word; one that does not gets the friendliest default for the shape it describes. JSON Schema has no format for a month or a week, so their values travel as the pickers' text (2024-05, 2024-W19). |
x-allowCustom, x-enum |
A combobox that also accepts answers on no list. Its choices travel as x-enum, because enum means "one of these and nothing else" to every validator; a combobox with a plain enum is a closed choice and rejects anything else. |
x-renderer |
A custom renderer key registered with RegisterCustom. |
x-order, x-group, x-placeholder, x-autocomplete, x-inputMode, x-autofocus |
Field metadata. |
x-columns, x-colSpan |
Grid layout. |
x-prefix, x-suffix, x-showLabel, x-characterCount |
Presentation. |
x-updateOn, x-debounce |
When the value is written back. |
x-step |
The control's granularity, as distinct from a multipleOf constraint. |
x-accept, x-multiple, x-maxFileSize |
File upload constraints. |
x-visibleWhen, x-disabledWhen, x-readOnlyWhen, x-requiredWhen |
Conditions, as an expression string ("{total} > 100 and {country} = 'NL'"), as {"field":…,"op":…,"value":…}, or as {"all":[…]} / {"any":[…]} of either. op is an operator name (any case) and defaults to Equals when absent; a condition whose op names no operator, or a group with a member that cannot be read, is dropped as a whole rather than guessed at. An expression that does not parse fails the import instead, naming the field and the keyword. |
x-computed |
An expression the field's value is derived from - "{quantity} * {unitPrice}". Its dependencies are read from it, and the field is read-only unless the document says "readOnly": false. |
x-rules |
Expressions the value must satisfy: [{"expression": "len({code}) = 6", "message": "…", "severity": "warning"}], or a bare string. On the root, a rule may name the field its message belongs on. A rule with no message uses the provider's BormMessageKeys.Rule text. |
x-revalidateOn |
Paths whose change revalidates this field - the other half of a cross-field rule. |
x-clearOnHide |
Clear the value when the field is hidden. |
x-sensitive |
The value must not outlive the page: left out of drafts. Passwords are sensitive without it. |
x-pageSize |
How many rows of a long list are shown at once. |
x-mask / x-maskStoresRaw |
The shape the value is written in, (000) 000-0000, and whether the model holds only what filled its slots. |
x-version |
On the root: which version of the form this is, recorded in every draft so one saved against an older version can be recognised and migrated. |
x-i18n |
The key a field's texts are translated under (see the schema's own text). |
x-translations |
On the root: the form's texts per culture, {"de": {"form.title": "…", "name.label": "…"}}. |
x-attributes, x-inputAttributes |
Renderer hints and extra HTML attributes. |
x-steps |
Wizard steps, each with id, title, fields and an optional visibleWhen. |
x-stepLayout |
How the steps are shown: wizard (the default), tabs, accordion or sections. |
enumNames, x-enumGroups, x-enumDisabled |
Labels matching the enum values, the <optgroup> each belongs to, and the values that may not be chosen. |
examples |
<datalist> suggestions. A tag list's go on items with its per-tag minLength, maxLength and pattern. |
Conditions, computed values and rules written as expressions export as the strings they were
written as. Those backed by code (VisibleWhen(predicate), Must(predicate), MustAsync, Computed(delegate),
OnChange, Normalize, a delegate stashed in Attributes) have no JSON form and are omitted from the export
rather than approximated.
BormDesigner builds a form without code - for the people who own a form's questions but not the application:
<BormDesigner @bind-Value="_definition" />
@code {
private BormDefinition? _definition; // an existing schema to edit, or null to start empty
}A palette adds any kind of field; the list of fields chooses one, and moves, copies or deletes it; its properties -
kind, name, label, help, placeholder, required and read-only, lengths and limits, a pattern or a mask, options one per
line, a group and a column span, and expressions for when it shows, when it is required and what it
is computed as - are a Borm form of their own, which says when an expression or a mask does not read or a name is
taken. The form's title, description and columns are settings of their own. A live preview renders the form as it
stands, and keeps its answers as fields change; the JSON Schema beneath it can be copied out, or replaced and loaded
back in. Every change is built through the same builder an application uses, and handed out through ValueChanged.
Read in, an existing schema keeps what the designer has no place for: an options provider, a condition or a rule written in code, renderer hints, steps (a new field joins the last one), translations and form-level rules - and a field holding such code says so. Objects, repeaters, hidden values and fields drawn by a renderer of their own are kept as they are, kind and all - their label, help, conditions and the like can still be changed - and edited in JSON.
The list is a list of buttons first: each field is chosen, moved, copied and deleted with buttons named for it ("Move
Email up"), focus stays where the user is, and each change is announced ("Email is now number 2 of 3"). Dragging a
field onto another moves it too, for a mouse. Every word it shows goes through IBormMessageProvider.
Built-in rules are added through the field builder:
.Field(x => x.Username, f => f
.Required("Username is required")
.MinLength(3)
.MaxLength(20)
.Pattern("^[a-z0-9_]+$", "Lowercase letters, numbers and underscores only"))Custom, async and cross-field rules:
.Field(x => x.Password, f => f
.AsPassword()
.Must(value => value is string s && s.Length >= 8, "At least 8 characters"))
.Field(x => x.ConfirmPassword, f => f
.AsPassword()
.MatchesField(nameof(Model.Password), "Password"))
.Field(x => x.Username, f => f
.MustAsync(async ctx => await users.IsAvailableAsync((string?)ctx.Value ?? ""), "That username is taken."))Async rules are skipped while the user types and run on blur and on submit - and on the pause of a debounced field, which is what debouncing is for:
.Field(x => x.Username, f => f
.UpdateOnInput(debounceMilliseconds: 400) // one write per pause, not one per character
.MustAsync(async ctx => await users.IsAvailableAsync((string?)ctx.Value ?? "", ctx.CancellationToken),
"That username is taken."))state.IsValidatingField(path) reports the lookup while it is in flight, so a spinner goes beside that box rather
than the whole form declaring itself busy. A run that is superseded - the user typed again, pressed Next twice,
reset the form - has its answer discarded, and ctx.CancellationToken is signalled so the lookup itself can stop.
A field's synchronous rules run first, and its asynchronous ones only when those found no error: a username that
does not match its pattern is not sent to the server to ask whether it is taken, which would cost a request and
answer with a second complaint about a value the user has to change anyway. Set RunAsyncRulesOnInvalidFields to
run them regardless. GetFieldState(path).Status says where a field stands - Unvalidated, Pending (a lookup in
flight), Invalid or Valid - so a "✓ available" can appear once the lookup has answered and not before; a value
that changes is Unvalidated again until it is judged.
A cross-field rule lives on one of the two fields it compares, so only one of the two changes ever runs it. Get the
confirmation wrong, then fix the password to agree with what you typed, and the complaint under the confirmation
box is now false - and stays there until you go back and touch a field that was already right. RevalidateOn
closes that:
.Field(x => x.End, f => f
.RevalidateOn(nameof(Booking.Start))
.Must(ctx => …, "End must be on or after start."))MatchesField(...) and [Compare] register their own dependency, so a confirm-password field needs nothing extra.
Paths resolve against the object that owns the field before falling back to the root, exactly as conditions and
computed dependencies do, so a rule on a repeater's item template means that row; naming a container covers
everything inside it.
A dependent that has nothing to say is left alone - a field the user has never visited on a form nobody has
submitted does not start showing errors because a different field changed. The point is to correct a verdict already
on screen, never to bring one forward. React Hook Form spells this deps; TanStack Form spells it
onChangeListenTo. Definition.Validate() reports a comparison rule that never revalidates.
Rules can also be scoped to a condition, the way FluentValidation's .When(…) works:
.Field(x => x.CompanyName, f => f
.When(nameof(Model.IsBusiness), BormConditionOperator.IsTrue, null, w => w
.Required("Business accounts need a company name.")
.MinLength(3)))Rules that need the whole model live on the form:
BormBuilder.For<Booking>()
.Field(x => x.Start)
.Field(x => x.End)
.MustAll(m => m.End >= m.Start, "End must be on or after start.", nameof(Booking.End))
.Build();A rule that rejects " a@b.com " for its surrounding spaces is technically correct and of no use to anyone: the
user cannot see the difference, and fixing the message inside the rule leaves the untidy value in the model anyway.
Normalize fixes the value:
.Field(x => x.Email, f => f.AsEmail().Required().Trim())
.Field(x => x.Reference, f => f
.Normalize(v => v is string s ? s.Trim().ToUpperInvariant() : v))It runs when the user leaves the field, and once more over every field on submit - so a field nobody visited is
tidied too. Never per keystroke, which would eat the space between two words the moment it was typed. Trim() turns
a value that is nothing but whitespace into null, so a required field is not satisfied by " ". React Hook Form
spells this setValueAs.
Mask writes a value in a fixed shape - a phone number, an IBAN, a postcode:
.Field(x => x.Phone, f => f.Mask("(000) 000-0000"))
.Field(x => x.Iban, f => f.Mask("AA00 0000 0000 0000 0000 00", storeRaw: true))0 is a digit, A a letter (written in capitals), a a letter as typed and * either; a backslash makes the next
character literal, and anything else is literal already. When the user leaves the box what they typed is put into
the shape: the literals they type stand for themselves and any other separator is passed over, so 5551234567,
555-123-4567 and (555) 123-4567 are the same answer. A value that does not fit is left as typed and reported
with the shape to write it in (Enter it in the form (###) ###-####., BormMessageKeys.Mask). storeRaw: true
stores only what filled the slots - 5551234567 - while the box and a review still show it formatted. A mask of
digits alone asks a phone for its number pad. x-mask and x-maskStoresRaw carry it through JSON Schema.
The box is not rewritten under the caret as the user types. That needs script to put the caret back, and a box that changes what is being typed into it is one assistive technology describes badly - which is why GOV.UK's advice is not to use input masks at all. Formatting the answer once it is given keeps the benefit and drops the cost.
Range takes dates and times as well as numbers. The bound is rendered as the control's own min / max - so the
picker will not offer a date the form would then refuse - and enforced by the rule, so a value typed past the
picker is reported rather than quietly accepted:
.Field(x => x.Arrival, f => f.Range(DateTime.Today, DateTime.Today.AddMonths(6)))
.Field(x => x.CheckIn, f => f.Range(new TimeOnly(14, 0), new TimeOnly(22, 0)))[Range(typeof(DateTime), "2024-01-01", "2024-12-31")] on a model means the same thing.
A DateTimeOffset is shown on its own clock, and what the user types is given back the offset the value already
had, so editing one never shifts it to another instant or another zone.
AsMonth() and AsWeek() put the browser's month and week pickers on a field - a card's expiry, a delivery
slot. A DateOnly, DateTime or DateTimeOffset holds the period's first day (the 1st of the month, the ISO
week's Monday); with no CLR type, or a string property, the value is the picker's own text, 2024-05 or
2024-W19. Firefox and Safari on the desktop have no such pickers and show a text box, so anything else typed into
one is reported as an entry the field cannot accept rather than stored. Range bounds work as they do for dates.
Required()on a checkbox means "must be ticked" - the same thing HTML's ownrequiredattribute means, and what "I accept the terms" always needs. On any other field type,falseis a perfectly good value.- A value the model cannot accept is reported, not swallowed. Typing
abcinto a field bound to anintleaves the model on its last valid value and raises a validation message naming the entry, rather than silently discarding it on the next render. A fraction offered to a whole-number field is refused rather than rounded. UniqueItems()compares content. Two rows are duplicates when they hold the same values - a dictionary row by its keys and everything beneath them, a model row by its public scalar properties (its navigation-style members are never read) - and numbers compare by value, so1and1.0mare the same item while"1"and1are not.
var state = new BormState(definition, accessor)
{
ValidationTrigger = BormValidationTrigger.OnBlur, // before the first submit
RevalidationTrigger = BormValidationTrigger.OnChange // after it
};Or let BormView build the state and configure it in place, which keeps the Definition + Model shorthand:
<BormView Definition="_definition" Model="_model"
ConfigureState="s => s.ValidationTrigger = BormValidationTrigger.OnBlur" />A field that already shows an error always revalidates eagerly, so the error clears as soon as it is fixed.
By default a field shows every rule it currently breaks. Set SingleErrorPerField to show only the first - React
Hook Form's default, and the right call for a password with four constraints on it, where four complaints under one
box read as four problems rather than one field to go and fix:
state.SingleErrorPerField = true;Every rule still runs, so the submit decision is unchanged; this is about what the user reads. Warnings sit alongside the error rather than competing with it.
Reset() restores the values the form was constructed with, which after a save round-trip are the wrong ones.
Reset(values) writes new ones and makes them the baseline, so IsFormDirty, an undo button and an unsaved-changes
prompt all mean what they say again:
state.Reset(new Dictionary<string, object?>
{
["Customer"] = stored.Customer,
["Lines[0].Description"] = stored.Lines[0].Description,
});Snapshot() is the other half: it maps every path the schema binds to onto its value - one entry per repeater row
included - which is exactly what Reset(values) takes back, so the two together are a draft save and restore:
var draft = state.Snapshot(); // keep it in memory
// … the user comes back …
state.Reset(draft);A snapshot holds live objects; for a draft that has to outlive the page - written to storage as JSON, without
passwords, and restored into a newer version of the form - use BormDraft.
ValidateFieldAsync(path) checks one answer without judging the whole form, and GetFieldState(path) answers
everything about one field at once:
await state.ValidateFieldAsync("Customer");
var field = state.GetFieldState("Customer");
// field.IsTouched, field.IsDirty, field.IsInvalid, field.Error, field.MessagesErrors the server finds after a submit go back onto the fields they belong to with SetServerError(path, message),
or SetServerErrors(...), which replaces the form's messages with a whole batch from one failed request. An ASP.NET
Core ValidationProblemDetails goes in as it arrives - keys are matched to fields without regard to case, and the
$. System.Text.Json puts in front of its own is dropped - with an optional map for an API whose names are not the
form's:
private async Task Save(BormState state, CancellationToken cancellationToken)
{
var response = await http.PostAsJsonAsync("api/orders", _model, cancellationToken);
if (response.StatusCode == HttpStatusCode.BadRequest
&& await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(cancellationToken) is { } problem)
state.SetServerErrors(problem.Errors); // the submit now reports Rejected
else
response.EnsureSuccessStatusCode(); // throwing reports Failed
}
// await state.SubmitAsync(Save);SubmitOutcome says how the last submit ended, which is more than valid-or-not: Invalid (the form's own rules
refused it, and the handler never ran), Rejected (the handler recorded errors with SetServerError or
SetServerErrors - a server that said no), Failed (the handler threw; the exception is in SubmitError and is
rethrown), Cancelled, or Succeeded. SubmitAsync returns true only for the last of those, and
IsSubmitSuccessful is the same thing. An error the user makes while the handler runs - letters typed into a number
box during a slow save - is theirs to fix, and does not turn a save that went through into a rejected one.
The overload whose handlers take (state, cancellationToken) hands them a token that CancelSubmit() and disposing
the form signal; a handler wired through BormView.OnValidSubmit reads it from state.SubmitCancellationToken.
ReadOnlyWhileSubmitting holds every field still while the handler runs, so a slow save cannot leave the form
showing values other than the ones sent. Nothing is ever retried behind the application's back, and the values
are left exactly as they were, so "try again" is simply submitting again.
Every message also says where it came from - Source is Rule, External, Server or Conversion - and, for a
rule with a key, Code is that key (required, maxLength, …), stable across languages for a test or a custom
message component to key off. Neither takes part in equality: two messages saying the same thing about the same
field are one message.
A schema generated from a model already carries its [Required], [StringLength], [Range] and friends as field
rules. What no attribute can express is a rule that reads more than one property - and in .NET that is
IValidatableObject:
var state = new BormState(definition, new BormModelDataAccessor(model))
.UseDataAnnotations();Only the cross-property layer is added: running Validator over the properties again would put two
differently-worded copies of "required" under the same box. Pass UseDataAnnotations(includePropertyAttributes: true) when the schema came from somewhere that never saw those attributes - a JSON Schema document rendered over a
typed model.
A result naming one member lands on that field, one naming several puts the same complaint under each of them, and
one naming none becomes a form-level message. IValidatableObject.Validate is called directly, exactly once per
pass, rather than through Validator.TryValidateObject, which skips it entirely once any property attribute has
failed - the user would otherwise fix the last required field, submit again, and be told about something new.
FluentValidation support lives in its own package, so a form that does not use it does not carry it:
dotnet add package Bit.Borm.FluentValidationWire up a validator on the form state. Failure property paths (e.g. Address.City, Items[0].Product) map directly
onto field paths, and failures landing on a field the schema is currently hiding are dropped - a validator sees the
whole model and knows nothing about the form's conditions, so without that a rule on an abandoned branch would refuse
the submit and point at a control that is not on the page.
var state = new BormState(definition, new BormModelDataAccessor(model), serviceProvider)
.UseFluentValidation(new ContactValidator());<BormView State="state" OnValidSubmit="HandleValidSubmit" />If your validators are registered in DI, call state.UseFluentValidation() (no argument) and the matching
IValidator<TModel> is resolved from the service provider; pass throwIfMissing: true to catch a forgotten
registration during development.
UseFluentValidation() and UseDataAnnotations() compose, so a model that carries both a validator and its own
IValidatableObject can use both - a complaint they report identically on the same field is still read once.
BormExternalValidator.CombineWith(...) does the same for a hand-written external validator.
Show, disable, lock or conditionally require fields based on other values:
.Field(x => x.CompanyName, f => f
.VisibleWhen(nameof(Model.IsBusiness), BormConditionOperator.IsTrue)
.RequiredWhen(nameof(Model.IsBusiness), BormConditionOperator.IsTrue)
.ClearOnHide())ReadOnlyWhen is for an answer that is settled rather than one that does not apply - an invoice that has been
sent, a field the approver owns:
.Field(x => x.Reference, f => f
.ReadOnlyWhen(nameof(Model.Confirmed), BormConditionOperator.IsTrue))Reach for it rather than DisabledWhen whenever the value still matters to the person reading the form. A disabled
control leaves the tab order and is not announced at all, so "the invoice has been sent, here is its reference"
becomes a value a keyboard or screen-reader user cannot get to; read-only keeps it reachable, readable and copyable.
On an object or a repeater, ReadOnly / ReadOnlyWhen makes everything inside it read-only the same way; a
read-only repeater also locks its add, remove and reorder buttons.
ClearOnHide empties the value as soon as the field disappears, so a branch the user abandoned never contributes
data to the submitted model.
Inside a repeater, a condition means that row. Paths are resolved against the object that owns the field before falling back to the root, exactly as a computed value's dependencies are - so one row can ask for an email while the next asks for a phone number:
.Array(x => x.Contacts, row => row
.Field(c => c.Kind, f => f.AsRadio().Options(("email", "Email"), ("phone", "Phone")))
.Field(c => c.Email, f => f
.VisibleWhen("Kind", BormConditionOperator.Equals, "email") // this row's Kind
.RequiredWhen("Kind", BormConditionOperator.Equals, "email")
.ClearOnHide()))Split a form into steps:
BormBuilder.For<Order>()
.Step("details", s => s.Title("Details").Fields("Name", "Email"))
.Step("shipping", s => s.Title("Shipping").Fields("Address", "City", "Zip"))
.Build();BormView renders a stepper, Back/Next navigation, and validates each step before advancing. Hidden steps are
skipped and the visible ones stay contiguously numbered.
Pressing Enter in a text box on a step that is not the last one advances, exactly as the Next button does - the browser would otherwise submit the whole form and answer a user who is halfway through with a wall of errors about questions they have not been asked yet.
Every step the user has already walked past is clickable - forwards as well as back, since each was validated on the
way through - so coming back to fix one answer does not mean pressing Next through the rest of the form. A step never
reached stays inert, because jumping to it would skip the validation that gates it. AllowStepNavigation="false"
turns the links off entirely, and state.IsStepReachable(i) / state.FurthestStepIndex expose the same information
to a custom stepper.
A hidden step is hidden in every sense: its fields are not rendered, not validated on submit, and never the reason a form refuses to submit. If a condition hides the step the user is standing on, the wizard falls back to the nearest visible one rather than stranding them. Changing step moves focus to the new step's content, so the change is not invisible to a keyboard or screen-reader user.
A long record the user moves around in freely - a settings page, a customer's details - is not a process to walk through in order. The same steps can be shown as tabs, an accordion or titled sections:
BormBuilder.For<Customer>()
.StepLayout(BormStepLayout.Tabs) // or Accordion, Sections; Wizard is the default
.Step("contact", s => s.Title("Contact").Fields("Name", "Email", "Phone"))
.Step("billing", s => s.Title("Billing").Fields("Vat", "Address"))
.Step("tax", s => s.Title("Tax").Fields("TaxId").VisibleWhen("{Country} = 'NL'"))
.Build();- Tabs follow the WAI-ARIA tabs pattern: one tab stop for the list, the arrow keys, Home and End moving between tabs, and the panel labelled by its tab. Any tab can be opened without validating the one left.
- Accordion uses the browser's own
<details>disclosure, the first section open; a closed section keeps its fields in the page, so nothing it holds is lost. - Sections are titled regions one after another, all open.
In all three, Submit validates everything (a hidden tab or section never blocks it), and Enter in a text box
submits rather than moving on. A failed submit turns to the first tab with something to fix, or opens every section
that has something, so focus and the error summary's links can reach it; a tab or section holding errors the user
has been shown carries a count that is also read out (BormMessageKeys.StepErrors), never colour alone. Drafts
remember which tab was open. x-stepLayout carries the choice through JSON Schema, and BormStepsView renders the
layouts for a page that composes its own form.
Review(name, heading) adds a "check your answers" summary - put it on a wizard's last step:
BormBuilder.For<Application>()
.Step("you", s => s.Title("About you").Fields("Name", "Email"))
.Step("plan", s => s.Title("Your plan").Fields("Plan", "Seats", "Start"))
.Review("summary", "Check your answers")
.Step("review", s => s.Title("Review").Fields("summary"))
.Build();Every answer of every other visible step is read back in words - an option's label rather than its stored value,
Yes or No, a number with its prefix and suffix, a date as the reader's culture writes it, a file by its name, a
repeater row by row - and a question nobody answered says so. A sensitive answer (a password, a field marked
Sensitive()) is never shown. Each step has a "Change" button back to it, named for the step for assistive
technology, which moves focus with the step as the stepper does. <BormReview State="…" /> places the same
summary anywhere - a confirmation page, a printable record - and x-attributes: {"review": true} on a static field
carries it through JSON Schema.
Options can be loaded on demand and refreshed when another field changes:
.Field(x => x.City, f => f
.Required()
.OptionsFrom(
load: async ctx => await cities.ForCountryAsync(ctx.Value("Country") as string, ctx.CancellationToken),
value: c => c.Code,
label: c => c.Name,
dependsOn: nameof(Model.Country)))When Country changes the cached options are dropped and the now-invalid selection is cleared. Dependencies resolve
against the object that owns the field before falling back to the root - exactly as conditions and computed
dependencies do - so dependsOn: "Country" inside a repeater means that row's country, and naming a container
(dependsOn: "Address") covers everything inside it. The provider reads the same way: inside a repeater,
ctx.Value("Country") is that row's country.
A lookup that goes over the network fails for ordinary reasons, so a provider that throws is recorded rather than
thrown: the control renders "options could not be loaded" instead of an empty dropdown, the failure is available as
state.OptionsError(path) and announced through OptionsLoadFailed, and InvalidateOptions(path) retries.
state.OptionsLoadFailed += (path, ex) => logger.LogWarning(ex, "Options for {Path} failed", path);Static choices come from Options(...), and an enum's members from OptionsFromEnum<TEnum>() (a property typed as
an enum gets them without asking, labelled by [Display(Name)] where there is one).
A field can derive its value from the rest of the form instead of being typed in - a line total, a full name, a price after discount:
BormBuilder.For<Invoice>()
.Field(x => x.Customer, f => f.Required())
.Array(x => x.Lines, line => line
.Field(l => l.Quantity)
.Field(l => l.UnitPrice)
// Evaluated against the *line*, so it works on every row without knowing its index.
.Computed(l => l.LineTotal, l => l.Quantity * l.UnitPrice,
dependsOn: ["Quantity", "UnitPrice"]))
.Computed(x => x.Total, m => m.Lines.Sum(l => l.Quantity * l.UnitPrice),
dependsOn: [nameof(Invoice.Lines)])
.Build();Computed fields are read-only by default, seeded when the form is created, and refreshed whenever a declared dependency changes - including changes inside an array. One computed field may depend on another; a set of formulas that reference each other in a cycle settles rather than recursing.
Dependencies are named relative to the object that owns the field, so "Quantity" on a repeater row means that
row's quantity. The untyped builder takes a context instead: f.Computed(ctx => ctx.Sibling("width"), "width"),
or an expression: f.Computed("{width} * {height}").
A condition, a computed value or a rule can be written as text instead of C#. Nothing changes about how it behaves, but text travels: it is exported to JSON Schema and imported back, so a form a server sends as JSON can compute, decide and judge with no code behind it.
BormBuilder.For<Invoice>()
.Array(x => x.Lines, line => line
.Field(l => l.Quantity)
.Field(l => l.UnitPrice)
.Computed(l => l.LineTotal, "{Quantity} * {UnitPrice}")) // this row's quantity and price
.Field(x => x.Discount)
.Computed(x => x.Total, "sum({Lines[].LineTotal}) - coalesce({Discount}, 0)")
.Field(x => x.Approver, f => f.RequiredWhen("{Total} >= 10000"))
.Field(x => x.DueDate, f => f.Must("{DueDate} > {IssueDate}", "The due date comes after the issue date."))
.MustAll("count({Lines}) > 0", "Add at least one line.", nameof(Invoice.Lines))
.Build();"total": { "type": "number", "x-computed": "sum({lines[].lineTotal}) - coalesce({discount}, 0)" },
"approver": { "type": "string", "x-requiredWhen": "{total} >= 10000" },
"dueDate": { "type": "string", "format": "date",
"x-rules": [{ "expression": "{dueDate} > {issueDate}", "message": "The due date comes after the issue date." }] }VisibleWhen, DisabledWhen, ReadOnlyWhen, RequiredWhen, When and a step's VisibleWhen each take an
expression; Computed(expression) makes the field read-only; Must(expression, message, severity) registers
RevalidateOn for every other field it reads, so correcting either side of a comparison clears the message; and
MustAll(expression, message, field) is a form-level rule. The fields an expression reads are its dependencies,
so it is re-evaluated exactly when one of them changes - there is no dependsOn to keep in step.
- Fields are written in braces:
{Email},{Address.City},{Lines[0].Product}. A path resolves against the object that owns the field first, so on a repeater's row template{Quantity}is that row's.[]means every row:{Lines[].Total}is the list of every line's total, and{Orders[].Lines[].Qty}flattens a list of lists. - Values: numbers (
12,0.5,1e3), text in single or double quotes (a doubled quote or a backslash escapes one; any other backslash is kept, so a pattern reads as itself),true,false,null, and lists['a', 'b']. - Operators:
+ - * / %,=(or==),!=(or<>),< <= > >=,in,not in,and(&&),or(||),not(!). Keywords ignore case. Comparisons do not chain:1 < {x} < 5is refused; write1 < {x} and {x} < 5. - Numbers are decimals, so
0.1 + 0.2is0.3and money adds up. Dividing by zero has no answer (null). - Text joins with
+, and a missing part joins as nothing:{First} + ' ' + {Last}. - Dates compare as points in time whether they are
DateOnly,DateTime,DateTimeOffsetor ISO text; a date plus a number moves it by that many days, and one date minus another is the days between them. - Comparisons are the ones
BormConditionOperatormakes: numbers by value, text without regard to case.
| Kind | Functions |
|---|---|
| Logic | iif(condition, then, else) (evaluates only the branch it takes), coalesce(a, b, …), isEmpty(x), isNotEmpty(x) |
| Text | len(x) / length(x) (a list's item count too), lower, upper, trim, concat(…), substring(text, start, length?), contains, startsWith, endsWith, matches(text, 'pattern') |
| Numbers | abs, round(x, digits?) (halves away from zero), floor, ceil, number(x), text(x) |
| Aggregates | sum, avg, min, max, count - each takes values, lists or both, and skips what has not been answered |
| Dates | today(), now(), year, month, day, age(birthDate), dateDiff(from, to, unit?) ('days', 'weeks', 'months', 'years', 'hours', 'minutes'), addDays, addMonths, addYears |
A field nobody has answered reads as null, and in a yes/no position null is no: not {Subscribe} holds for a
box nobody ticked, exactly as BormConditionOperator.IsFalse does. A comparison that needs a missing answer is
unknown instead, and unknown follows SQL's three-valued logic: while the age is missing, {Age} >= 18 or {Override} is true once the override is ticked and unknown otherwise, and {Age} >= 18 and {Override} is false
while the override is unticked and unknown otherwise. A condition that is
unknown is false, so a field depending on a missing answer stays hidden. A rule that is unknown passes - a
rule comparing two dates has nothing to say until both are given, and saying a value is missing is Required()'s
job. = and != treat null as a value, so {Notes} = null asks whether the notes are empty.
An expression is not a program. It can read the form's values and call the functions listed above, and nothing
else: no member access, no reflection, no assignment, no loops, so evaluating one from an untrusted document always
finishes and can read nothing but the answers. Parsing is bounded - 4,096 characters, 48 levels of nesting, 1,024
parts - and matches only takes its pattern as a literal in the expression, never one read from a field, with the
same 250 ms time limit every pattern gets. Evaluating never throws for the values it meets: text multiplied by a
number, a date that does not parse or a division by zero is simply no answer. A document whose expression does not
parse fails TryImport, saying which field and keyword it was in, rather than having the condition quietly dropped
- which would show a field meant to be hidden, or make one meant to be required optional.
BormExpressionFunctions.Register("vatRate", args => args[0] is "DE" ? 0.19m : 0.20m, minArgs: 1, maxArgs: 1);
// "x-computed": "round({net} * (1 + vatRate({country})), 2)"Registered functions are the trust boundary: an expression from any document can call them, so they must be safe
with any arguments (they are given evaluated values, a missing answer as null) and should return null rather than
throw. Register them at startup; a built-in function cannot be replaced. today() and now() read
BormExpressionFunctions.TimeProvider, which a test can replace with a fixed clock; it is the local time of the
process evaluating the expression - on Blazor Server, the server's.
Expressions are synchronous and pure. A rule that calls a service, a lookup, or anything asynchronous stays a
delegate (MustAsync, OptionsFrom), and keeps working alongside them.
A <select> of two hundred countries is navigated by scrolling. A combobox is typed into:
.Field(x => x.Country, f => f
.Options(("fr", "France"), ("gb", "United Kingdom") /* … */)
.AsCombobox() // closed: an answer on no list is reported
.Clearable())
.Field(x => x.City, f => f
.AsCombobox(allowCustom: true) // the list proposes; anything is accepted
.OptionsFrom(load: …, dependsOn: nameof(Model.Country)))It is built on the browser's own <input list> + <datalist>, so filtering, keyboard navigation and screen-reader
support are the platform's. The library supplies the piece a bare datalist cannot: the label the user reads is
mapped onto the value the model stores, so an option can be ("fr", "France") rather than being forced to show its
own key. Static options, OptionsFromEnum and async/cascading OptionsFrom all work exactly as they do on a
<select>.
A combobox always commits when the choice is made, whatever UpdateOn says: the box holds a label and the model
holds the value that label stands for, and half a label stands for nothing.
AsCombobox() adds a rule reporting an answer that is on no list - it is reported, never silently discarded. The
rule can only check options the schema itself declares; choices that arrive from an OptionsFrom provider live in the
form's runtime state rather than the schema, so validate those on the server where the same lookup lives.
A customer out of a hundred thousand fits no list. SearchFrom makes the combobox ask as the user types:
.Field(x => x.CustomerId, f => f.SearchFrom(
search: ctx => customers.SearchAsync(ctx.SearchText, ctx.CurrentValue as string, ctx.CancellationToken),
value: c => c.Id,
label: c => c.Name,
minLength: 2, // nothing is asked before two characters
debounceMilliseconds: 250)) // and only once the user pauses- The search runs once
minLengthcharacters are typed and the user pauses; a search typed past is cancelled (ctx.CancellationToken) and its answer never lands over a newer one. AminLengthof 0 searches with empty text before anything is typed - "the top results". - A field that opens holding a value - an edit form - is asked once with no
SearchTextand the value inctx.CurrentValue, so it can return the one option the box should show instead of a bare id. The option the user picks is remembered, so its label stays in the box after a later search no longer returns it. - Text committed before its results arrived (typed quickly, tabbed away) is searched for exactly, and matched if it
is a result. Text that names no result is refused - left in the box with a message while the model keeps its
last value - because a search field stands for a record;
allowCustom: truestores it as typed instead. A submit or a wizard's Next pressed while that search is out waits for its answer rather than sending the value from before;state.TrackEntryAsync(task)does the same for a control of your own whose commit awaits something. - Until enough is typed the box tells assistive technology how much is needed; then how many results there are. Nothing is written to the model per keystroke: only a choice, or leaving the box, commits.
state.SearchOptionsAsync(field, path, text)runs the same search for a control of your own.
Search providers are code, so they are not exported to JSON Schema; validate the chosen id on the server, where the same lookup lives.
A list of short strings is a set of chips rather than a repeater:
.Field(x => x.Skills, f => f.AsTags(max: 8))Enter or a comma adds one (Attr("tagSeparators", ";") changes the set), backspace on an empty box takes the last
one back, and duplicates are refused case-insensitively. A list loaded from storage that already holds a duplicate
still renders, and × removes only the chip it is on. The entry box deliberately belongs to no form, so pressing Enter
adds a tag instead of posting the page.
Computed derives a value the field owns. OptionsFrom(dependsOn: …) reloads a list. ClearOnHide empties an
abandoned branch. What none of them expresses is "when A changes, write B" where B is still the user's to edit - so
that is what OnChange is for:
.Field(x => x.Plan, f => f
.Options(("solo", "Solo"), ("team", "Team"))
.OnChange(ctx => ctx.SetSibling(nameof(Model.Seats), ctx.Value is "team" ? 5 : 1)))
.Field(x => x.Country, f => f.OnChange(ctx => ctx.ClearSibling("City")))Paths resolve relative to the object that owns the field, exactly as conditions and computed dependencies do, so a handler on a repeater's item template means that row. What a handler writes does not mark the field touched - the user has not been there, and a field the form filled in on their behalf should not open covered in errors. Handlers do not run while the form is being constructed, and a pair that answer each other settles rather than recursing. A handler runs after the form has settled the change it reacts to - computed values are up to date and any select cascading off the field has already been emptied - so it reads current totals and may set the dependent field itself.
A long form owes its user "you were halfway through this - carry on?" when the tab closes or the session expires.
BormDraft writes the form's unfinished answers as a small JSON document and puts them back; where it is kept -
browser storage, the server, a database - is the application's choice:
<BormView State="_state" OnValidSubmit="Submit" />
<BormAutosave State="_state" OnSave="json => drafts.SaveAsync(DraftKey, json)" OnDiscard="() => drafts.DeleteAsync(DraftKey)" />
<BormUnsavedChangesGuard State="_state" />
@code {
protected override async Task OnInitializedAsync()
{
_state = new BormState(_definition, new BormModelDataAccessor(_model), Services);
if (await drafts.LoadAsync(DraftKey) is { } json)
BormDraft.Restore(_state, json); // back as unsaved changes, on the step it was saved on
}
private async Task Submit(BormState state)
{
await api.SaveAsync(_model);
await drafts.DeleteAsync(DraftKey); // a submitted form has no draft
state.AcceptChanges(); // and nothing left to warn about
}
}- What a draft holds: every value the schema binds to, one per path (
Lines[2].Quantity), with each repeater's row count and the wizard step. Types come back as they went - enums, dates, a timestamp's offset, a multi-select's list. Computed values are not saved; they are computed again. - What it never holds: files (a browser file cannot outlive the page that chose it) and sensitive values -
passwords, and fields marked
Sensitive()(x-sensitive) - unlessBormDraftOptions.IncludeSensitiveasks.ExcludeHiddenalso leaves out the answers of branches the user hid. - Restoring writes the answers as unsaved changes on top of what the form opened with, so
IsFormDirtyand an unsaved-changes prompt stay meaningful andReset()takes them away. Change handlers stay silent and a cascading select keeps the answer it is given. What the form has no field for, or can no longer hold, is listed in the result'sSkippedrather than left on a field as an error the user never made - and so are rows pastBormDraft.MaxRows(10,000, all the draft's repeaters together) and a row count for a row that is not there. - Versions: a draft records
BormDefinition.Version(.Version("3"),x-version). When it differs from the form's,BormDraftOptions.Migrategets the draft to bring up to date first:m => { if (m.FromVersion == "2") m.Rename("Phone", "Contact.Phone").Remove("Fax"); }.
BormAutosave writes one a moment after the user stops changing the form (DelayMilliseconds, default 1000) -
only while there are unsaved changes, and once more as the page goes away if a save was still waiting. A draft
put back by BormDraft.Restore is not saved again, and OnDiscard runs once the form is back where it started -
Reset(), or every change undone - so the stored draft can go with it. Clear the
stored draft once the form is submitted. Autosave and the guard below hear what a field has committed, and a text box commits what is typed
in it when it is left: give the fields whose half-written answers a draft should keep UpdateOnInput(300), and they
commit as the user types. BormUnsavedChangesGuard asks before the user leaves a form with unsaved
changes: Blazor's NavigationLock gives an external navigation the browser's own "leave site?" prompt, and an
in-app one asks OnConfirm (a dialog of yours) or the browser's confirm() with BormMessageKeys.UnsavedChanges.
state.Snapshot(new BormSnapshotOptions { ExcludeHidden = true, ExcludeSensitive = true }) is the payload-shaped
counterpart: a hidden answer is retained while the user can still toggle back to it, cleared when the field is
ClearOnHide, and excluded from what is sent when the snapshot says so. A snapshot that leaves something out gives a
repeater row by row (Lines[0].Product) rather than as its whole list, and everything inside a sensitive list or group
counts as sensitive.
A draft is plain JSON holding personal data. Keep it where the user's other data is kept, and not in storage another user of the same machine can read.
ShowUndo puts Undo and Redo buttons beside Submit; state.UndoAsync() and state.RedoAsync() do the same from
code, with CanUndo, CanRedo and NextUndoPath to drive buttons of your own.
<BormView Definition="_definition" Model="_model" ShowUndo="true" />- A change is everything one answer set off: the value itself, a field a condition then hid and cleared, a sibling a change handler filled in, a city an abandoned country took with it. They are taken back together, and the handler is not run again on the way - what it did is part of the change. Computed values are not recorded; they follow the answers back.
- Rows keep their identity. Adding, removing, moving, swapping, copying and clearing rows are changes like any other, and undoing a removal puts back the same row object - with whatever it held - rather than a copy.
- Two writes to one field within a second are one change, so a field that writes as the user types is not undone a
character at a time, and the tidying a field does as the user leaves it (
Trim(), a mask) belongs to that answer.ResetFieldandSetValuesare changes of their own. - A field put back is judged again as a change would be, so an error the user has been shown comes back with the value that caused it.
HistoryLimitis how many changes are kept - 0, the default, keeps none;ShowUndomakes it 100.Reset()and restoring a draft start the history again. Only changes made through the state are recorded: a model edited behind the form's back has nothing to undo.
The buttons keep the focus, so they can be pressed again; each is named for the field it acts on ("Undo the change to Full name"), and what it did is announced ("Undone: Full name"). Ctrl+Z inside a text box still undoes the typing in that box, as the browser always has - the form's Undo takes back answers already given.
Set a column count on the form and let individual fields span more than one. The grid collapses to a single column on narrow screens.
BormBuilder.For<Profile>()
.Columns(2)
.Field(x => x.FirstName)
.Field(x => x.LastName)
.Field(x => x.Bio, f => f.AsTextArea(4).ColumnSpan(2))
.Build();A list of hundreds or thousands of rows is shown a page at a time:
.Array(x => x.Lines, line => line.Field(l => l.Product), f => f.PageSize(25))The pager offers Previous and Next, a page picker and the rows a page holds ("26–50 of 1,000"), and says how many rows
on other pages need attention, with a button to the first of them. Every way of reaching a row still reaches it: the
error summary's links and focus-the-first-error turn to the row's page before they move the focus - a list inside a
row of another paged list included - and adding, copying or moving a row turns to the page it lands on. Row numbers
and the rows' accessible names stay those of the whole list. x-pageSize carries it through JSON Schema.
It is paged rather than virtualised on purpose. A virtualised list leaves the rows out of view out of the page, where neither the keyboard, nor the error summary, nor a screen reader can reach them; a page is always whole.
A run of consecutive fields sharing a group name renders as one <fieldset> with a <legend>, so a screen reader
announces "Who you are" as focus enters the first control in it. [Display(GroupName = …)] on the model does the
same thing.
BormBuilder.For<SupportTicket>()
.Field(x => x.Name, f => f.Group("Who you are"))
.Field(x => x.Email, f => f.Group("Who you are"))
.Field(x => x.Team, f => f.Group("Where it belongs"))
.Build();Grouping is by runs rather than by collecting every field with the same name, so the declared order is never rearranged behind your back.
BormView renders the whole schema. When the page wants the layout for itself, place fields one at a time instead -
they keep the schema's label, rules, conditions and ARIA wiring:
<div class="two-up">
<BormField State="_state" Name="ReporterName" />
<BormField State="_state" Name="ReporterEmail" />
</div>
<h3>Routing</h3>
<BormField State="_state" Name="Team" />
<BormField State="_state" Name="Lines[0].Product" />
<button @onclick="() => _state.SubmitAsync(Save)">Raise ticket</button>Each field follows the state by itself - its message, a condition, a list of options arriving - so the page only
needs to subscribe to StateChanged for markup of its own that reads the state (an "unsaved changes" count, say). An
unknown path renders nothing, which is what a schema chosen at run time needs; set ThrowIfMissing="true" to catch a
typo in a fixed layout. Rendering the same schema twice on one page? Give each state its own IdPrefix.
The details that separate a generated form from a designed one:
BormBuilder.For<Listing>()
// A section heading that belongs to the schema, not to the page around it.
.Static("basics", "The basics", "What someone sees first in the search results.")
.Field(x => x.Title, f => f
.MaxLength(60)
.CharacterCount() // a live "12 / 60"
.UpdateOnInput()) // write on every keystroke, not on blur
.Field(x => x.Summary, f => f
.AsTextArea(3)
.UpdateOnInput(debounceMilliseconds: 150)) // one write per pause, not per character
.Field(x => x.Price, f => f.Prefix("€").Suffix("/ month"))
.Field(x => x.City, f => f
.Suggest("Amsterdam", "Berlin", "Lisbon") // proposes; does not restrict
.Clearable())
.Field(x => x.Passcode, f => f.AsPassword().Revealable())
.Field(x => x.Published, f => f.AsSwitch())
.Field(x => x.Query, f => f
.HideLabel() // stays the accessible name
.InputAttr("data-testid", "search")) // any HTML attribute you like
.Build();UpdateOnInput matters more than it looks: the browser's change event fires on blur for a text box, so without
it the model does not see a keystroke until the user leaves the field. The input handler is only wired when a field
actually needs it, so nothing pays for a round-trip per keystroke it has no use for. A debounced value never waits for
its timer when it matters: leaving the box, pressing Enter (which submits, or moves a wizard on) and the control
going away all write it first.
Clearable() puts a × inside the box once it holds something, and puts the caret back in the box after clearing it.
A property typed IBrowserFile (or a list of them) renders a file picker. Accept(...), MaxFileSize(...) and
[FileExtensions] become both the picker's own accept list and rules that report a file the picker let through
anyway; a list of files counts against Items(min, max) like any other collection. Each takes a message of its own,
which is used only for its own failure, whichever order they are called in. Read the files in your submit
handler with OpenReadStream, exactly as you would with Blazor's InputFile.
Or send each file as soon as it is chosen, and keep what your storage returns for it instead:
.Field(x => x.ResumeId, f => f.Accept(".pdf").MaxFileSize(5_000_000).UploadWith(async upload =>
{
using var content = new StreamContent(upload.OpenReadStream()); // the bar follows these bytes
var response = await http.PostAsync("api/files", content, upload.CancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(upload.CancellationToken); // what the field stores
}))The field then holds a string - or a list of them for a field taking several files - and never the file itself, so it
is an answer like any other: it goes into a draft, survives a reload and reaches the server as the id it is. A field
taking several adds each new file to the ones it opened with, which are listed - each with its own Remove - beside
them; one taking a single file lets go of the one it had as soon as another is chosen, so a replacement that fails
leaves it empty rather than quietly still holding the file the user replaced. Each file
is judged by Accept and MaxFileSize before anything is sent, then listed with a progress bar and a Cancel button
while it goes, Remove once it is there, and Retry - with the reason - if the uploader throws. Completion and failure
are announced once each, not per percent. A submit pressed while a file is on its way waits for it, and Cancel,
clearing the field or leaving the form cancels upload.CancellationToken. state.UploadsFor(path) lists them for a
control of your own. Uploaders are code, so they are not exported to JSON Schema.
.Field(x => x.CoverLetter, f => f.AsMarkdown().MaxLength(4000))
.Field(x => x.Signature, f => f.AsSignature().Required())AsMarkdown() is a text area for formatted text written in Markdown - paragraphs and line breaks, headings, bulleted
and numbered lists, quotes, code, rules, **bold**, *italic*, ~~struck~~, `code` and [links](https://…) -
with a preview beneath it that follows the typing, a short guide to the syntax, and a review that shows it formatted.
The value is the Markdown text, so its length rules count what the user typed. BormMarkdown.ToHtml renders it
anywhere else, and ToPlainText takes the formatting out.
What it renders is safe to put on a page: everything the user wrote is HTML-encoded, only the elements listed above
are written, a link goes only to an http, https or mailto address - javascript: and every other scheme stay
text - and links carry rel="nofollow noopener noreferrer". Rendering is linear in the length of the text, however
it is crafted. It is not a WYSIWYG editor: that needs a contenteditable element and script to read it, and Borm
ships no script.
AsSignature() is a signature pad: an SVG drawn on with a mouse, a finger or a pen (the page does not scroll while it
is being signed), stroke by stroke, with Clear beside it - and Type it instead, for anyone who cannot draw, which
writes the typed name in a handwriting face. Either way the value is an SVG image as a data: URL that an <img>
shows as it is; BormSignature builds, checks and decodes them, and a review shows the signature as an image. Only a
value in that shape is ever shown, and SVG shown through an <img> cannot run script. A signature is personal data:
mark it Sensitive() if it should not be kept in a draft. x-widget: markdown and x-widget: signature carry both
through JSON Schema.
Validate() reports the mistakes that are easy to make and hard to see - duplicate field names, arrays with no item
template, steps naming fields that do not exist, conditions and expressions pointing nowhere (past a [] too), a
comparison rule that never revalidates, computed values that feed each other in a cycle, and settings a control can
never honour. It is a development aid, never run automatically - a schema being edited at runtime has every right
to be incomplete:
foreach (var problem in definition.Validate())
logger.LogWarning("{Problem}", problem); // [Error] Lines: An array field has no ItemTemplate…AddBorm takes a callback that configures the field renderer registry - override a built-in control, or register a
component under a key a field asks for:
builder.Services.AddBorm(registry =>
{
registry.RegisterCustom<StarRatingInput>("rating");
});.Field(x => x.Rating, f => f.CustomRenderer("rating").Attr("max", 5))Custom input components inherit from BormInputBase, which hands them the field (Field), its context (Context,
with Value, StringValue and SetValueAsync) and whether they are Disabled:
@inherits BormInputBase
<div role="radiogroup" aria-label="@Field.Label">
@for (var n = 1; n <= 5; n++)
{
var star = n;
<button type="button" disabled="@Disabled" @onclick="@(() => Context.SetValueAsync(star))">★</button>
}
</div>A field of type BormFieldType.Custom whose renderer key is not registered throws with a message naming the key,
rather than silently falling back to a text box. Calling AddBorm twice configures the registry that is already
there, so a module can add its renderers after the application has registered its own.
InputText, InputNumber<T>, ValidationMessage<T>, ValidationSummary and the inputs of most component libraries
need a Blazor EditContext to exist at all. Set ProvideEditContext on the view and it cascades one that tells the
same story as the form, so such a control can sit in a custom renderer and show the field's errors as its own:
<BormView Definition="_definition" Model="_model" ProvideEditContext="true">
<ValidationSummary />
</BormView>@inherits BormInputBase
<InputText id="@Context.ElementId"
Value="@Context.StringValue"
ValueChanged="@((string? v) => Context.SetFromStringAsync(v))"
ValueExpression="@(Context.ValueExpression<string?>())" />- The form stays the source of truth. Values are written through the field context, never through the
EditContext, so conditions, computed values and dirty tracking stay right.Context.ValueExpression<T>()names the property of the object that owns the field - the model, a nested object, a list's row - or the key of a dictionary-backed one, so the field identifier Blazor works out is the one the form's messages are filed under. Ask for the property's own type:ValueExpression<string>()over anintthrows rather than read it wrongly. - A message reaches the context when the form would show it - the field touched, or the form submitted - so a
Blazor input reads
invalidno sooner than Borm's own controls do. Only errors are copied: anEditContexthas no severity, so warnings and notes stay Borm's. EditContext.Validate()runs the form's validation, which is what a hand-written submit button over the context does; the synchronous rules have answered before it returns.Context.EditContextandContext.FieldIdentifierare there for a component that wants them directly, and are null while the view provides no context. For a state rendered without a view, construct aBormEditContextAdapterover it and cascade itsEditContext; give it your component'sInvokeAsync, and dispose it with the state.
Off by default: a form that does not use these controls has no cascade in its tree.
| Parameter | Description |
|---|---|
Definition |
The schema to render. Required unless State is supplied. |
Model |
Optional typed model to bind to. When omitted, a dictionary store is used. |
Data |
Optional IDictionary<string, object?> backing store (used when Model is null). A writable dictionary is edited in place at every level - so don't share one (a static "defaults" dictionary, say) between forms or circuits; a read-only one is copied. Keys match without regard to case, typed nested containers keep their types, and values from JsonSerializer.Deserialize<Dictionary<string, object?>> are converted to plain dictionaries, lists and primitives as they are reached. |
State |
Provide a pre-configured BormState (e.g. with FluentValidation wired up). |
ConfigureState |
Runs against the state the view builds from Definition, before it renders - set the validation triggers, wire up a validator, subscribe to an event. Saves constructing and disposing a state by hand just to change one setting. |
OnValidSubmit |
Raised with the state after a successful (valid) submit. |
OnInvalidSubmit |
Raised after a submit that failed validation. |
OnSubmitError |
Raised with the exception when OnValidSubmit throws. While it is set the failure is handled here, rather than escaping into the renderer - where, on Blazor Server, an unhandled exception ends the circuit. |
OnFieldChanged |
Raised with the path of a field whose value changed. |
ReadOnly |
Renders every field read-only (review mode) and hides the buttons. Pressing Enter in a read-only form does not submit it. |
Disabled |
Disables every field and button - a save in flight, a locked record. Unlike ReadOnly the fields cannot be tabbed through or read out, so reach for it only when the form is genuinely inoperable. |
ShowSubmitButton / SubmitText / SubmittingText |
The built-in submit button (default true / "Submit"). While the form is validating or saving the button says so, rather than only going grey. |
ShowResetButton / ResetText |
A button restoring the values the form started with. |
ShowUndo |
Undo and Redo buttons, and the history they need - see Undo and redo. |
ShowErrorSummary |
Lists every error above the form, each linking to its field - or to its step on a wizard. The links name the current page in full, so they work under any <base href>. |
FocusFirstError |
Moves focus to the first invalid field after a failed submit (default true; suppressed while ShowErrorSummary is on, since the summary takes focus itself). |
AllowStepNavigation |
Lets the user click back to a completed wizard step (default true). |
BackText / NextText |
Wizard navigation labels. |
Header / Footer / Actions |
Render fragments for extra content and a custom button row. |
Class |
Extra CSS classes for the <form> element. |
IdPrefix |
What every DOM id in the form starts with (default borm_). Give two forms on one page a prefix each. |
ProvideEditContext |
Cascades a Blazor EditContext mirroring the form's validation, for InputBase-derived controls, ValidationSummary and component-library inputs placed in the form (default false). See Blazor's own inputs inside a renderer. |
FormName / PostedModelName |
Makes the form one the browser can post with no interactivity, its controls named for the page's [SupplyParameterFromForm] model (default Model). See Forms that work with no interactivity. |
ChildContent |
Extra content rendered inside the <form>. |
Any other attribute you set is splatted onto the <form> element.
When you let the view build its own state from Definition, capture the component to reach it:
<BormView @ref="_form" Definition="_definition" Model="_model" />
<button @onclick="() => _form.Form.Reset()" disabled="@(!_form.Form.IsFormDirty)">Undo</button>
@code { private BormView _form = default!; }A state the page constructs itself is the page's to dispose: BormState is IDisposable, and disposing it cancels
an options load or an async rule still in flight instead of letting it finish against a form nobody is looking at.
| Member | Purpose |
|---|---|
GetValue / SetValue / SetValueQuietly |
Read and write by path; the quiet variant does not mark the field touched. |
For<TModel>() |
The same state addressed by expression - state.For<Order>().SetValue(x => x.Lines[i].Quantity, 3), GetFieldState(x => x.Email), FocusAsync, ResetField, SetServerError - so a rename renames every read and write, and a typo does not compile. |
SetValues / Batch |
Write many values as one change - one re-render, not one per field. |
IsFormDirty, DirtyFields, TouchedFields |
Change tracking. Dirtiness is a comparison against the values the form opened with, so a field typed into and put back is clean again. |
ResetField(path) |
Puts one field - and anything nested beneath it - back to the value it started with. |
IsValidating, IsSubmitting, IsSubmitted, SubmitCount, IsValid, HasValidated |
Submission state. IsValid reports what validation has found, so check HasValidated before treating it as a verdict - a form nobody has validated has no errors. |
IsSubmitSuccessful |
Whether the last submit passed validation and its handler returned without throwing, being cancelled or recording errors - what a "Saved." banner binds to. |
SubmitOutcome, SubmitError |
How the last submit ended - Invalid, Rejected, Failed, Cancelled, Succeeded - and the exception behind a Failed one. |
CancelSubmit(), SubmitCancellationToken, ReadOnlyWhileSubmitting |
Stop a save in flight, the token its handler passes on, and holding the fields still while it runs. |
NotifyDefinitionChanged() |
The schema was changed in place (a field added, removed or reconfigured): new fields get defaults and baselines, computed values are evaluated again, and what was recorded against removed paths is dropped. |
IsValidatingField(path), ValidatingFields |
Whether an async rule is in flight for one field, so a remote uniqueness check can put a spinner beside its own box rather than declaring the whole form busy. |
GetFieldState(path) |
Touched, dirty, invalid, validating, validated, the first error and every message for one field, in a single read - and Status (Unvalidated, Pending, Invalid, Valid) for a control that wants it in one word. |
RunAsyncRulesOnInvalidFields |
Run a field's async rules even when its sync rules already found an error (off by default). |
NormalizeField(field, path) / NormalizeAll() |
Run the schema's normalizers. The built-in controls call these for you on blur and on submit. |
SubmitAsync |
Normalizes, marks everything touched, validates, and dispatches - ignoring re-entrant calls. See How a submit ends. |
ValidateAsync / ValidateStepAsync / ValidateFieldAsync |
Validation at three scopes; newer runs supersede older ones, and a superseded run reports false rather than a verdict it never reached. ValidateFieldAsync(path) resolves the field from the schema for you. |
ValidateDependentsAsync(path) |
Revalidates every field that declared RevalidateOn(path). The built-in controls call it for you; this is the hook for a custom one. |
SingleErrorPerField |
Show a field's first error rather than every rule it breaks. Every rule still runs. |
MessagesFor, AllMessages, OrderedMessages, SetServerError(s) |
Validation messages. |
Reset() / Reset(values) / AcceptChanges |
Restore the starting values, rebase onto new ones, or make the current ones the new baseline. |
Snapshot() |
Every bound path mapped onto its value - the shape Reset(values) takes back, so the two are a draft save and restore. |
AddArrayItem, InsertArrayItem, DuplicateArrayItem, RemoveArrayItem, MoveArrayItem, SwapArrayItems, ClearArrayItems |
Repeater operations; messages, touched and dirty state, rejected entries and loaded choices follow their items. A T[] property works too - it is replaced with a resized copy. |
FocusAsync(path) |
Moves focus to the control rendering a path; false when nothing is rendering it. |
Text(key, args) |
Resolves the library's own UI text through the registered message provider. |
NextStepAsync, PreviousStep, GoToStep, VisibleSteps, CurrentStepNumber, FurthestStepIndex, IsStepReachable |
Wizard navigation. |
ReadOnly / Disabled |
Makes the whole form read-only (still focusable and readable) or disabled (inoperable). |
StateChanged |
Raised after anything the page might render changes. A page that renders its own markup from the state subscribes to it (and unsubscribes when it is disposed). |
Every colour - plus the corner radius, the field gap and the form's maximum width - is a CSS custom property declared
on .bit-borm-form, so a host app restyles every form by overriding them in a rule that reaches the form itself,
without touching the stylesheet. Because the form declares every token on its own element, a value set on an ancestor
(:root, body, a page wrapper) is shadowed and has no effect; write .bit-borm-form or .my-page .bit-borm-form
instead:
| Property | Light | Dark | Used for |
|---|---|---|---|
--bit-borm-accent |
#4f46e5 |
#4f46e5 |
The primary button, focus rings, a checked switch, the range slider. |
--bit-borm-accent-contrast |
#ffffff |
#ffffff |
Text and marks drawn on the accent colour. |
--bit-borm-surface |
#ffffff |
#111827 |
The background of controls, tags and buttons. |
--bit-borm-surface-muted |
#fafafa |
#1f2937 |
Read-only controls, affixes and hovered buttons. |
--bit-borm-surface-disabled |
#f3f4f6 |
#374151 |
Disabled controls. |
--bit-borm-border |
#d1d5db |
#4b5563 |
Control and card borders. |
--bit-borm-border-focus |
var(--bit-borm-accent) |
var(--bit-borm-accent) |
The border of a focused control. |
--bit-borm-error |
#dc2626 |
#f87171 |
Error text, invalid borders, the required marker. |
--bit-borm-error-contrast |
#ffffff |
#111827 |
Text drawn on the error colour: a step's error count. |
--bit-borm-error-surface |
#fef2f2 |
#2a1215 |
The error summary's background and a tag's remove button on hover. |
--bit-borm-warning |
#d97706 |
#fbbf24 |
Warning messages. |
--bit-borm-text |
#111827 |
#f9fafb |
Labels and values. |
--bit-borm-muted |
#6b7280 |
#9ca3af |
Help text, counters, placeholders. |
--bit-borm-radius |
8px |
8px |
Corner radius of controls and cards. |
--bit-borm-gap |
1rem |
1rem |
The space between fields. |
--bit-borm-max-width |
720px |
720px |
The form's maximum width; none fills the container. |
.bit-borm-form {
--bit-borm-accent: #0d9488;
--bit-borm-radius: 4px;
--bit-borm-max-width: none;
}That is enough for a token the two themes share - the accent, the radius, the gap, the width. A token that differs
between them (every surface, border and text colour) has to be overridden once per theme: a plain .bit-borm-form
rule loaded after the stylesheet would also replace the dark value when the OS is dark, and a data-theme rule outranks
it. The four rules below mirror the stylesheet's own:
.bit-borm-form { --bit-borm-surface: #fffdf7; }
[data-theme="light"] .bit-borm-form, .bit-borm-form[data-theme="light"] { --bit-borm-surface: #fffdf7; }
@media (prefers-color-scheme: dark) {
.bit-borm-form { --bit-borm-surface: #1c1917; }
}
[data-theme="dark"] .bit-borm-form, .bit-borm-form[data-theme="dark"] { --bit-borm-surface: #1c1917; }The default theme follows the OS light/dark preference. An app with its own theme toggle drives the same tokens by
putting data-theme="dark" or data-theme="light" on any ancestor (or on the form itself), which wins over the media
query - so "dark site, light OS" comes out dark. Each theme also sets color-scheme, so the parts of a control the
browser draws itself - date pickers, open dropdowns, the file button, scrollbars - follow it too.
The markup uses BEM-style classes under the bit-borm- prefix. The ones most worth targeting:
| Class | Element |
|---|---|
bit-borm-form |
The <form>; carries the custom properties. __title, __description, __body, __actions are its parts. |
bit-borm-field |
One field: its label, control, help and messages. --invalid, --checkbox and --static are its states. |
bit-borm-label, bit-borm-help, bit-borm-required |
A field's label, its help text and the required marker. |
bit-borm-input |
Every text-like control, select and textarea; --invalid when it holds an error. |
bit-borm-messages, bit-borm-message |
A field's message list; --error, --warning and --info per severity. |
bit-borm-group, bit-borm-fieldgroup |
A nested object, and a run of fields sharing a group name. |
bit-borm-array |
A repeater; __item, __item-body, __item-actions, __header, __empty are its parts. |
bit-borm-stepper |
A wizard's step list; __step, __link, __num, __label are its parts. |
bit-borm-tabs |
Steps shown as tabs; __list, __tab, __label, __badge (a count of errors) and __panel are its parts. |
bit-borm-section |
A step shown as a section, --collapsible in an accordion; __title and __body are its parts, has-errors its state. |
bit-borm-review |
A review of the answers; __section, __header, __heading, __list, __entry, __label, __value, __rows and __empty are its parts. |
bit-borm-summary |
The error summary. |
bit-borm-btn |
Every button; --primary, --ghost, --danger, --add, --icon and --sm are its variants. |
bit-borm-grid |
The form body when Columns(...) is set. |
bit-borm-sr-only |
Text that is read out but not shown. |
Accessibility is the default rather than an option:
- Every control has a real
<label>(a hidden one still names it whenHideLabel()is set), and radio groups, multi-selects, groups and repeaters are<fieldset>s with a<legend>. aria-required,aria-invalidandaria-describedbyare set on every control, the last pointing at the field's help text and messages, so a message is read out when focus reaches the field.- After a failed submit, focus moves to the first invalid field - or to the error summary, which links to each field (or its step on a wizard). When there is no field to take focus, the outcome is announced instead.
- Changing a wizard step moves focus to the new step's content; adding a repeater row puts the caret in it, and removing one moves it to the row that took its place, or to the add button when the last one goes.
- Repeater, tag and character-count changes are announced through live regions.
ReadOnlyWhenkeeps a settled value focusable and readable, where a disabled control would leave the tab order.Autofocus()focuses a field when the form first renders.- A message's severity is said in words as well as shown in colour: "Error:" is read before an error (which is also set in a heavier weight), and "Warning:" or "Note:" is shown and read before the others, so a warning is never told from an error by colour alone. The words come from the message provider and are no part of the message's own text.
- Tabs follow the WAI-ARIA tabs pattern (one tab stop, arrow keys, Home and End); an accordion is the browser's
own
<details>; a tab or section with errors carries a count that is read out too; a review's "Change" buttons are named for their step. - Every target the stylesheet draws is at least 24 × 24 CSS pixels, WCAG 2.2's minimum, by its own size rather than by its spacing.
- The layout uses logical CSS properties only, so a form mirrors correctly inside
dir="rtl".
What the library cannot vouch for: a custom renderer's own markup, the browser's built-in pickers (the date, month
and week controls, <datalist> and <select> popups) and the colours a host app re-tints the tokens to - check
contrast against WCAG 1.4.3 when you do.
Register an IBormMessageProvider to replace the built-in English text - typically one wrapping IStringLocalizer:
builder.Services.AddBormMessages<MyMessageProvider>();public sealed class MyMessageProvider(IStringLocalizer<MyMessageProvider> localizer) : IBormMessageProvider
{
public string Get(string key, params object?[] args)
{
var text = localizer[key, args];
return text.ResourceNotFound ? BormDefaultMessageProvider.Instance.Get(key, args) : text.Value;
}
}It covers everything the library renders, not only the validation messages: the submit and reset buttons, a wizard's
Back/Next, the select placeholder and its loading state, a repeater's add/remove labels and empty state, the error
summary heading, the character counter. The keys are listed on BormMessageKeys; anything a provider does not
recognise should fall through to the default provider (or to the key itself).
Messages passed explicitly to a rule - and labels passed explicitly to BormView - always win over the provider.
The library's own words come translated, too: German, Spanish, French, Italian, Dutch, Portuguese, Russian, Turkish, Persian, Arabic, Japanese and simplified Chinese.
builder.Services.AddBormTranslations();Every validation message, button, announcement and accessible name the library renders, and the designer, is then
said in the form's state.Language when it has one, else in CurrentUICulture, and in English for any other
language. Regional cultures read their language's table (de-CH, pt-BR); simplified Chinese answers to zh,
zh-Hans, zh-CN and zh-SG, and a traditional-Chinese culture is answered in English rather than in the wrong
script. Counts are phrased to read right for any number ("Anzahl: 3") instead of imposing English plural rules on
other languages, with a wording of its own for exactly one where a language has one. Changing state.Language
rewrites the form's words at once; a rule's message already on screen is rewritten when its field is next validated.
A provider of your own can answer in a form's language as well: implement IBormLocalizedMessageProvider, whose
Get(language, key, args) the form calls with its Language. BormValidator.Language does the same for a form
validated on its own, such as one posted to the server, and BormDesigner takes a Language parameter. The words a
schema supplies - labels, options, a repeater's item noun - are the schema's to translate, below.
The message provider translates the library's words; the schema's - labels, help, placeholders, the labels of its
own options, step titles, the form's title - go through an IBormSchemaLocalizer, typically one wrapping
IStringLocalizer:
builder.Services.AddBormSchemaLocalizer<FormText>();
public sealed class FormText(IStringLocalizer<FormText> localizer) : IBormSchemaLocalizer
{
public string? Localize(string key, string? text)
=> localizer[key] is { ResourceNotFound: false } found ? found.Value : null; // null keeps the text
}| Key | Text |
|---|---|
{field}.label, .help, .placeholder, .prefix, .suffix |
A field's texts. {field} is its TextKey (x-i18n) when set, else its path with the rows taken out - Lines.Product for every line's product. |
{field}.options.{value} |
The label of one of the field's own options (a provider's options are its own to translate). |
steps.{id}.title, steps.{id}.description |
A step's texts. |
form.title, form.description |
The form's. |
A form described in JSON can carry its translations itself, as x-translations on the root - culture to key to
text - consulted after any registered localizer, for the current UI culture and then its parent (de-CH, then
de):
"x-translations": {
"de": { "form.title": "Bestellung", "Lines.Product.label": "Artikel", "plan.options.team": "Team" },
"fr": { "form.title": "Commande" }
}The builders add the same tables in C#, and they are exported as x-translations with the rest of the schema:
BormBuilder.For<Order>()
.Title("Order")
.Translate("de", new Dictionary<string, string> { ["form.title"] = "Bestellung", ["Plan.options.team"] = "Team" })
.Translate("fr", new Dictionary<string, string> { ["form.title"] = "Commande" })
.Build();Both are asked each time the text renders, so a page that changes culture shows the new language on its next render.
state.Language gives one form a language of its own - a public form in the visitor's language, whatever the page's
culture. It needs no culture data, which matters on WebAssembly: under InvariantGlobalization the UI culture is
always the invariant one, and no translation would ever apply. The document's translations are looked up by the name
and then by its language (de-CH, then de), and BormView marks the form with it as its lang, so a screen
reader reads it in that language. Null, the default, follows CurrentUICulture.
A translated option label is what a combobox matches typed text against and what a review reads back. A form with
nothing to translate pays nothing for any of this. Dates and numbers a review reads back follow CurrentCulture;
the inputs themselves are the browser's, which format them for the user's own locale. The library sets no dir:
its layout uses logical CSS properties throughout, so a form inside dir="rtl" mirrors as the page does.
Borm has no JavaScript and no browser-only API, so it runs the same way under every Blazor hosting model.
- Blazor WebAssembly: register
AddBorm()inProgram.csand link the stylesheet fromindex.html. - Blazor Server: the same, in the server's
Program.csandApp.razor. Every field that updates on input sends its keystrokes over the circuit, so prefer the default update-on-change, or a debounce, on a busy form. - Blazor Web App: register
AddBorm()in both the server and the client project - the page is prerendered on the server and then runs on the client, and both containers must resolve the renderer registry. The form prerenders as plain HTML and becomes interactive once the page does. A page in static server rendering - no interactive render mode at all - can post a Borm form too; see below.
Give the view a FormName, and bind the page's model from the post:
@page "/apply"
@attribute [ExcludeFromInteractiveRouting] @* This page stays static in an interactive app *@
<BormView Definition="_definition" Model="Model" FormName="apply" OnValidSubmit="SaveAsync" />
@code {
[SupplyParameterFromForm(FormName = "apply")] public Application? Model { get; set; }
protected override void OnInitialized() => Model ??= new();
}The form then posts like any HTML form - in a page with no interactive render mode, or in a browser with no script -
with an antiforgery token, each control under the name model binding reads (Model.Address.City,
Model.Lines[0].Product, PostedModelName to change Model). On the server the view judges the posted answers
exactly as an interactive one would and calls OnValidSubmit, or renders the page again with every message in place;
text the binder could not read - letters in a number box - is shown back as the user typed it, with the reason. A
combobox posts the label the user saw, and it is turned back into its value; tags post one entry each. A read-only
select, radio group, checkbox, multi-select or slider - drawn disabled, which a browser never posts - sends its answer
in a hidden input instead, and so does a signature the field holds; a disabled field posts nothing. Interactive, the
same form is a Blazor form as before.
With nothing to click, the form shows what a click would have: a wizard's steps, and tabs, as sections one after
another (an accordion is the browser's own and still opens); a repeater's rows as they stand, every row of a paged one
included, without add, remove or move buttons. Conditions are judged when the page is rendered, so a field that
depends on another appears after the post that answered it. A box left unticked posts nothing and binds as false, so
a bool whose default is true cannot be turned off without script; a file, a signature and a search lookup need an
interactive page.
- Blazor Hybrid: register
AddBorm()inMauiProgram.csand link the stylesheet fromwwwroot/index.html.
A Blazor WebAssembly app is trimmed when it is published. Bit.Borm is not marked as trimmable, so the default
(TrimMode partial) keeps it whole, and keeps the app's own assembly, where its models live: the demo is published
this way, and its end-to-end suite passes against that build. A bound model is read, written and generated from by
reflection over its properties, all the way down through its objects and lists, which no annotation can describe;
marking the package trimmable would mean hiding that from the trimmer, or a warning on every BormView that binds a
model. With TrimMode full, keep Borm and the assemblies holding your models whole - the end-to-end suite passes
against the demo published this way too:
<ItemGroup>
<TrimmerRootAssembly Include="Bit.Borm" />
<TrimmerRootAssembly Include="$(AssemblyName)" /> <!-- and any other assembly holding models -->
</ItemGroup>A control registered with RegisterCustom<TComponent>() keeps its constructor and parameters in a trimmed app: the
registry's component types carry the annotation Blazor gives its own.
| Type | Purpose |
|---|---|
BormDefinition |
The schema: fields, wizard steps, form-level rules and metadata. Clone() for a shared schema you want to tailor, Validate() to check it. |
BormFieldDefinition / BormFieldType |
A single field and its logical type. |
BormBuilder / BormBuilder<TModel> |
Fluent schema builders. |
BormSchemaGenerator |
Reflection + DataAnnotations schema generation. |
BormExpression, BormExpressionCondition, BormExpressionRule, BormExpressionFunctions |
The expression language: parse and evaluate one, use it as a condition or a rule, register functions of your own. |
BormJsonSchemaImporter / BormJsonSchemaExporter |
JSON Schema import and export. |
BormState |
Runtime state: data, validation, dirty/touched tracking, wizard position. |
BormView / BormField |
Render a whole schema, or one field of it inside your own layout. |
BormErrorSummary, BormValidationMessages |
The error summary and a field's message list, for a layout of your own. |
BormModelDataAccessor / BormDictionaryDataAccessor |
Data backing stores. |
IBormFieldRendererRegistry, BormInputBase |
Maps field types to renderer components, and the base class for your own. |
IBormMessageProvider, BormMessageKeys |
Supplies the text of every built-in message and label. |
BormTranslatedMessageProvider, IBormLocalizedMessageProvider |
The library's words in twelve more languages (AddBormTranslations()), and a provider that answers in a form's own Language. |
BormMask, BormMarkdown, BormSignature |
An input mask; the safe Markdown renderer; a signature's SVG data URL, drawn or typed. |
BormFileUploader, BormUpload, BormUploadContext |
Uploading a file as it is chosen (UploadWith), and following it. |
BormDesigner |
The visual form designer: Value in, a new definition out through ValueChanged. |
BormConditionOperator, BormValidationTrigger, BormUpdateTrigger |
The vocabulary of conditions, validation timing and write-back timing. |