The Editor control turns a plain C# record into a fully interactive form — no markup, no field wiring. Annotate your properties with standard .NET attributes and the editor selects the right input control, applies validation, and streams live updates back to any reactive view you attach. How the Editor works: record properties and attributes feed reflection, which maps each property to a typed field control; form changes stream to any attached reactive view.
Basic Usage
Simple form
Declare a record and call host.Edit(...). The editor reflects over every public property and renders an appropriate input:
public record Person
{
public string Name { get; init; } // → TextFieldControl
public int Age { get; init; } // → NumberFieldControl
public bool IsActive { get; init; } // → CheckBoxControl
}
host.Edit(new Person { Name = "Alice", Age = 30, IsActive = true })
The three properties above produce a text field, a number field, and a checkbox — in that order.
Form with reactive output
Pass a second argument to attach a live view that re-renders whenever the form data changes:
public record Calculator
{
public double X { get; init; } // first operand
public double Y { get; init; } // second operand
}
host.Edit(
new Calculator { X = 10, Y = 5 },
calc => Controls.Label($"Sum: {calc.X + calc.Y}") // updates as you type
)
The label below the two number fields recalculates on every keystroke. The reactive variant wraps the EditorControl in a StackControl and subscribes to form changes via GetDataStream<T>().
Form with validation attributes
Standard .NET data-annotation attributes are picked up automatically:
public record UserProfile
{
[Required]
[Description("Your full name as it appears on documents")]
public string FullName { get; init; }
[DisplayName("Email Address")]
public string Email { get; init; }
[Range(18, 120)]
public int Age { get; init; }
[Browsable(false)] // hidden — not rendered at all
public string InternalId { get; init; }
}
host.Edit(new UserProfile { Age = 25 })
FullName shows a required indicator and a help-text line. Email carries the custom label. Age enforces the 18–120 range. InternalId is invisible to the user.
Property Type Mapping
The editor chooses a control for each property based on its declared type:
| Property type | Rendered control | Typical example |
|---|---|---|
string |
Text field | public string Name { get; init; } |
int, double, decimal |
Number field | public decimal Price { get; init; } |
bool |
Checkbox | public bool Enabled { get; init; } |
DateTime |
Date/time picker | public DateTime BirthDate { get; init; } |
These defaults apply only when no override attribute is present — [UiControl<T>], [Dimension<T>], [MeshNode], [MeshNodeCollection], [Markdown], and [ContentItem] each substitute their own control. See Property Attributes for the full catalogue.
Supported Attributes
Apply any of these attributes directly to a property to alter how the field renders or validates:
| Attribute | Effect |
|---|---|
[Required] |
Field cannot be empty; shows a validation error when blank |
[Description("...")] |
Adds help text below the field |
[DisplayName("...")] |
Replaces the auto-generated label with a custom one |
[Browsable(false)] |
Hides the property entirely — it is never rendered |
[Range(min, max)] |
Restricts numeric input to the given inclusive range |
[Editable(false)] |
Renders the value as read-only; the user cannot change it |
[MeshNode("query")] |
Searchable mesh-node picker; stores the selected node's path |
[MeshNodeCollection("query")] |
Full-width inline chip collection of node references |
[Markdown] |
Markdown display + editor with preview / track-changes options |
[ContentItem("collection")] |
Text field + Browse button over a content collection |
Control Override
When the default control is not the right fit, use [UiControl<T>] to substitute any compatible control:
public record Settings
{
[UiControl<TextAreaControl>]
public string Description { get; init; } // multi-line instead of single-line
[UiControl<RadioGroupControl>(Options = new[] { "Light", "Dark", "System" })]
public string Theme { get; init; } // radio buttons instead of a text field
}
host.Edit(new Settings { Theme = "System" })
Description expands to a full multi-line text area. Theme becomes a three-option radio group with "System" pre-selected.
Dimension Dropdowns
[Dimension<T>] populates a field with records from a data source, turning it into a searchable dropdown. The referenced type must declare a [Key] property:
public record Country
{
[Key]
public string Code { get; init; }
public string Name { get; init; }
}
public record Address
{
public string Street { get; init; }
public string City { get; init; }
[Dimension<Country>]
public string CountryCode { get; init; } // dropdown populated from all Country records
}
At render time, CountryCode fetches every Country from the data context and presents them in a dropdown keyed by Code.
Live Example
The form below is generated live from the record definition — each property becomes the field
its type and attributes dictate (string → text field, int → number field, bool → checkbox,
DateTime → date/time picker):
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
record Person
{
[Required]
[DisplayName("Full name")]
public string Name { get; init; } = "Alice Example";
[Range(18, 120)]
public int Age { get; init; } = 34;
[DisplayName("Active member")]
public bool IsActive { get; init; } = true;
[DisplayName("Birth date")]
public DateTime BirthDate { get; init; } = new(1992, 3, 14);
}
Mesh.Edit(new Person(), "personDemo")
How It Works
Edit<T>() reflects over all public properties at startup:
typeof(T).GetProperties() // enumerate every public property
.Aggregate(new EditorControl(), // start with an empty editor container
serviceProvider.MapToControl) // add a named field for each property
Each property becomes a named area inside the EditorControl. The reactive overload wraps that control in a StackControl and appends a view that subscribes to form changes via GetDataStream<T>(), so the downstream view always sees the latest values.
Accessible names
A generated field paints its caption outside the input, and both generated forms do it differently:
| Form | Where the caption is painted | What names the input |
|---|---|---|
Edit<T>() / EditorControl |
the PropertySkin's <dt> |
a <label for> the renderer targets at the input's id, plus aria-label |
The node editor's click-to-edit form (MapToToggleableControl) |
a sibling LabelControl above the field |
aria-label only — a FluentLabel carries no for, and there is no id to point it at |
In both the control itself is created with no Label, so the caption is not drawn twice. That is
deliberate, and it costs the input its accessible name unless something puts one back.
So the generators set AriaLabel on every generated input — every IFormControl: text,
multi-line text, number, date, checkbox, switch, select, combobox, listbox, radio group, mesh-node
picker.
Each form reads it from the same helper its own caption comes from, so within a form the accessible name and the visible term can never disagree — which is what WCAG's label in name asks for. The two helpers do not resolve the caption the same way, and that predates this:
| Form | caption helper | resolution order |
|---|---|---|
Edit<T>() |
GetEditorLabel |
[Display(Name = …)] → [DisplayName(…)] → the property name word-split |
| click-to-edit | GetToggleableDisplayName |
[Display(Name = …)] → the viewer-localized [Description] / [Translation] → the property name word-split |
🚨 So [DisplayName("…")] names an Edit<T>() field and does not name a click-to-edit one, which
falls through to the wordified property name. That divergence is stated here rather than smoothed
over: it is a question about the visible CAPTION, not about the accessible name, and changing it
would re-word every click-to-edit caption in the portal.
public record Assessment
{
[DisplayName("1. What topics do you want to cover?")]
[UiControl<TextAreaControl>]
public string Topics { get; init; } = null!;
}
// Edit<T>() renders: <dt><label for="property-…">1. What topics …</label></dt>
// <dd><fluent-text-area id="property-…" aria-label="1. What topics …"> …
🚨 aria-label is not decoration on top of <label for>. A Fluent input is a web component that
keeps its real <input> inside a shadow root, and the generated form used to emit
<label for="topics"> pointing at an element that could not exist: the caption was visible, the
textbox was unnamed, and getByRole('textbox', { name: question }) matched nothing
(MeshWeaver#3863). aria-label sits on the host element, needs no id plumbing, and is the only one
of the two the click-to-edit form can use at all.
Setting Label yourself still works and still renders a second, visible caption — use it for a
control you compose by hand, not for a field the editor generates. A control that carries its own
Label is left alone: the Fluent host paints its own associated label, and an aria-label would
only shadow it.
One generated surface is NOT covered, and it is named here rather than left to be rediscovered
from a symptom: a [Markdown] / [UiControl<MarkdownEditorControl>] property. MarkdownEditorControl
is not an IFormControl — it is a composite editor with its own toolbar and its own view, so its
accessible name is an aria-labelledby question about that composite, not an aria-label on one
input.
See Also
- Property Attributes — every supported attribute in detail
- Data Binding — how form data flows through the reactive pipeline
- Stack Control — the container the reactive overload builds on
- DataGrid Control — tabular data display for read-only collections